This commit is contained in:
2026-04-18 23:11:35 +02:00
commit f7cfd2a86e
196 changed files with 35538 additions and 0 deletions
+69
View File
@@ -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;
}
+42
View File
@@ -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);
}
+3
View File
@@ -0,0 +1,3 @@
export * from './backend.js';
export * from './box.js';
export * from './keys.js';
+39
View File
@@ -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;
}