initial
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
// Platform-agnostic crypto backend contract. Each host (desktop / mobile)
|
||||
// registers its own implementation at boot via `setCryptoBackend`.
|
||||
//
|
||||
// Why an interface?
|
||||
// - Desktop: libsodium-wrappers (WASM, needs async ready-gate).
|
||||
// - Mobile: react-native-libsodium (native bindings, different API shape).
|
||||
// - Tests: deterministic backend with seeded RNG.
|
||||
//
|
||||
// Backends are expected to be *synchronous* after registration. Any async
|
||||
// initialisation (e.g. libsodium's WASM warm-up) happens before the backend
|
||||
// object is handed to `setCryptoBackend`.
|
||||
|
||||
export interface KeyPair {
|
||||
publicKey: Uint8Array;
|
||||
privateKey: Uint8Array;
|
||||
}
|
||||
|
||||
export interface CryptoBackend {
|
||||
readonly name: string;
|
||||
readonly nonceLength: number; // crypto_box_NONCEBYTES — 24
|
||||
readonly publicKeyLength: number; // 32
|
||||
readonly privateKeyLength: number; // 32
|
||||
readonly secretboxKeyLength: number; // crypto_secretbox_KEYBYTES — 32
|
||||
readonly secretboxNonceLength: number; // crypto_secretbox_NONCEBYTES — 24
|
||||
|
||||
randomBytes(n: number): Uint8Array;
|
||||
generateKeyPair(): KeyPair;
|
||||
|
||||
// Authenticated encryption (X25519 + XSalsa20-Poly1305, AKA crypto_box).
|
||||
box(
|
||||
plaintext: Uint8Array,
|
||||
nonce: Uint8Array,
|
||||
recipientPublicKey: Uint8Array,
|
||||
senderPrivateKey: Uint8Array,
|
||||
): Uint8Array;
|
||||
|
||||
// Must throw on authentication failure.
|
||||
boxOpen(
|
||||
ciphertext: Uint8Array,
|
||||
nonce: Uint8Array,
|
||||
senderPublicKey: Uint8Array,
|
||||
recipientPrivateKey: Uint8Array,
|
||||
): Uint8Array;
|
||||
|
||||
// Symmetric authenticated encryption (XSalsa20-Poly1305, crypto_secretbox).
|
||||
// Used for large blobs (attachments) — one key per blob, key itself is
|
||||
// distributed per recipient via crypto_box envelopes.
|
||||
secretbox(plaintext: Uint8Array, nonce: Uint8Array, key: Uint8Array): Uint8Array;
|
||||
secretboxOpen(ciphertext: Uint8Array, nonce: Uint8Array, key: Uint8Array): Uint8Array;
|
||||
}
|
||||
|
||||
let current: CryptoBackend | null = null;
|
||||
|
||||
export function setCryptoBackend(backend: CryptoBackend): void {
|
||||
current = backend;
|
||||
}
|
||||
|
||||
export function getCryptoBackend(): CryptoBackend {
|
||||
if (!current) {
|
||||
throw new Error(
|
||||
'Crypto backend not configured. Call setCryptoBackend() at app boot before any crypto operation.',
|
||||
);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
export function isCryptoBackendReady(): boolean {
|
||||
return current !== null;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { getCryptoBackend } from './backend.js';
|
||||
|
||||
// Authenticated encryption using XSalsa20-Poly1305 + X25519 (Curve25519).
|
||||
// Delegates to the active CryptoBackend (libsodium on desktop, libsodium-rn
|
||||
// on mobile, etc.).
|
||||
|
||||
export interface EncryptedEnvelope {
|
||||
ciphertext: Uint8Array;
|
||||
nonce: Uint8Array;
|
||||
}
|
||||
|
||||
export async function encryptFor(
|
||||
plaintext: Uint8Array,
|
||||
recipientPublicKey: Uint8Array,
|
||||
senderPrivateKey: Uint8Array,
|
||||
): Promise<EncryptedEnvelope> {
|
||||
const backend = getCryptoBackend();
|
||||
const nonce = backend.randomBytes(backend.nonceLength);
|
||||
const ciphertext = backend.box(plaintext, nonce, recipientPublicKey, senderPrivateKey);
|
||||
return { ciphertext, nonce };
|
||||
}
|
||||
|
||||
export async function decryptFrom(
|
||||
ciphertext: Uint8Array,
|
||||
nonce: Uint8Array,
|
||||
senderPublicKey: Uint8Array,
|
||||
recipientPrivateKey: Uint8Array,
|
||||
): Promise<Uint8Array> {
|
||||
const backend = getCryptoBackend();
|
||||
return backend.boxOpen(ciphertext, nonce, senderPublicKey, recipientPrivateKey);
|
||||
}
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
export function utf8ToBytes(text: string): Uint8Array {
|
||||
return textEncoder.encode(text);
|
||||
}
|
||||
|
||||
export function bytesToUtf8(bytes: Uint8Array): string {
|
||||
return textDecoder.decode(bytes);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './backend.js';
|
||||
export * from './box.js';
|
||||
export * from './keys.js';
|
||||
@@ -0,0 +1,39 @@
|
||||
import { getCryptoBackend } from './backend.js';
|
||||
|
||||
export interface X25519KeyPair {
|
||||
publicKey: Uint8Array; // 32 bytes
|
||||
privateKey: Uint8Array; // 32 bytes
|
||||
}
|
||||
|
||||
export async function generateX25519KeyPair(): Promise<X25519KeyPair> {
|
||||
const backend = getCryptoBackend();
|
||||
const kp = backend.generateKeyPair();
|
||||
return { publicKey: kp.publicKey, privateKey: kp.privateKey };
|
||||
}
|
||||
|
||||
// ---- base64 helpers (used to persist private keys client-side) ----------
|
||||
// Use browser-native btoa/atob. Both are available in Tauri webview and RN
|
||||
// (Expo polyfills atob/btoa). No external dep needed.
|
||||
|
||||
export async function toBase64(bytes: Uint8Array): Promise<string> {
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]!);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
export async function fromBase64(input: string): Promise<Uint8Array> {
|
||||
const binary = atob(input);
|
||||
const out = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
out[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Wipe a key best-effort. JS can't truly guarantee zeroization but
|
||||
// overwriting the buffer removes the value from live references.
|
||||
export function wipe(bytes: Uint8Array): void {
|
||||
for (let i = 0; i < bytes.length; i++) bytes[i] = 0;
|
||||
}
|
||||
Reference in New Issue
Block a user