Phase 2 green: implement crypto primitives

Each stub is replaced with a thin wrapper over libsodium-wrappers-sumo:

  * init() awaits sodium.ready
  * toBase64 / toBase64URL / fromBase64 use sodium's base64 variants;
    fromBase64 tries all four (standard, standard-no-pad, URL-safe,
    URL-safe-no-pad) so callers don't have to know which form Ente
    delivered
  * deriveKEK is sodium.crypto_pwhash with ALG_ARGON2ID13 and 32-byte
    output
  * deriveLoginSubkey is sodium.crypto_kdf_derive_from_key(32, 1,
    'loginctx', kek).slice(0, 16) per the upstream Ente clients
  * decryptBox is sodium.crypto_secretbox_open_easy
  * decryptSealed is sodium.crypto_box_seal_open
  * initStreamPull / pullStreamChunk wrap the secretstream pull API,
    throwing on authentication failure rather than returning false

All 32 tests pass; make check is green.
This commit is contained in:
2026-05-09 12:44:59 -07:00
parent 676d42c5eb
commit 8aecf977e9
5 changed files with 101 additions and 48 deletions

View File

@@ -1,17 +1,19 @@
// Stub: see the README "Development workflow" section for TDD policy.
import sodium from "libsodium-wrappers-sumo";
// XSalsa20-Poly1305 secretbox decryption. Throws on authentication failure
// (tampered ciphertext, wrong key, wrong nonce). Used pervasively by Ente
// for sealed key material and metadata.
export const decryptBox = (
_ciphertext: Uint8Array,
_nonce: Uint8Array,
_key: Uint8Array,
): Uint8Array => {
throw new Error("crypto.decryptBox not implemented");
};
ciphertext: Uint8Array,
nonce: Uint8Array,
key: Uint8Array,
): Uint8Array => sodium.crypto_secretbox_open_easy(ciphertext, nonce, key);
// Anonymous sealed-box (X25519 + XSalsa20-Poly1305) decryption. Used to
// recover the auth token returned by login. The recipient (us) needs both
// halves of the keypair; sealed-box is anonymous on the sender side.
export const decryptSealed = (
_ciphertext: Uint8Array,
_publicKey: Uint8Array,
_secretKey: Uint8Array,
): Uint8Array => {
throw new Error("crypto.decryptSealed not implemented");
};
ciphertext: Uint8Array,
publicKey: Uint8Array,
secretKey: Uint8Array,
): Uint8Array => sodium.crypto_box_seal_open(ciphertext, publicKey, secretKey);

View File

@@ -1,13 +1,31 @@
// Stub: see the README "Development workflow" section for TDD policy.
import sodium from "libsodium-wrappers-sumo";
export const fromBase64 = (_s: string): Uint8Array => {
throw new Error("crypto.fromBase64 not implemented");
};
export const toBase64 = (b: Uint8Array): string =>
sodium.to_base64(b, sodium.base64_variants.ORIGINAL);
export const toBase64 = (_b: Uint8Array): string => {
throw new Error("crypto.toBase64 not implemented");
};
export const toBase64URL = (b: Uint8Array): string =>
sodium.to_base64(b, sodium.base64_variants.URLSAFE_NO_PADDING);
export const toBase64URL = (_b: Uint8Array): string => {
throw new Error("crypto.toBase64URL not implemented");
// Ente uses standard base64 for most fields, URL-safe (with padding stripped)
// for the auth token. Rather than make callers specify, fromBase64 accepts
// any of the four variants libsodium understands and returns the bytes.
const VARIANTS = [
sodium.base64_variants.ORIGINAL,
sodium.base64_variants.ORIGINAL_NO_PADDING,
sodium.base64_variants.URLSAFE,
sodium.base64_variants.URLSAFE_NO_PADDING,
] as const;
export const fromBase64 = (s: string): Uint8Array => {
let lastError: unknown;
for (const variant of VARIANTS) {
try {
return sodium.from_base64(s, variant);
} catch (err) {
lastError = err;
}
}
throw lastError instanceof Error
? lastError
: new Error("invalid base64 input");
};

View File

@@ -1,14 +1,25 @@
// Stub: see the README "Development workflow" section for TDD policy.
import sodium from "libsodium-wrappers-sumo";
// Argon2id over (password, salt, opsLimit, memLimit) producing a 32-byte
// Key Encryption Key. Parameters come from the server; quack passes them
// straight through. memLimit is in bytes, opsLimit is the iteration count.
export const deriveKEK = async (
_password: string,
_salt: Uint8Array,
_opsLimit: number,
_memLimit: number,
): Promise<Uint8Array> => {
throw new Error("crypto.deriveKEK not implemented");
};
password: string,
salt: Uint8Array,
opsLimit: number,
memLimit: number,
): Promise<Uint8Array> =>
sodium.crypto_pwhash(
32,
password,
salt,
opsLimit,
memLimit,
sodium.crypto_pwhash_ALG_ARGON2ID13,
);
export const deriveLoginSubkey = (_kek: Uint8Array): Uint8Array => {
throw new Error("crypto.deriveLoginSubkey not implemented");
};
// BLAKE2b-based KDF from the KEK. Subkey id 1 and context "loginctx" are
// fixed by the upstream Ente clients; the first 16 bytes of the 32-byte
// output are used as the SRP password. Any deviation breaks login.
export const deriveLoginSubkey = (kek: Uint8Array): Uint8Array =>
sodium.crypto_kdf_derive_from_key(32, 1, "loginctx", kek).slice(0, 16);

View File

@@ -1,6 +1,12 @@
// Stub: see the README "Development workflow" section for TDD policy.
// This module's real implementation lands in a follow-up commit.
import sodium from "libsodium-wrappers-sumo";
// libsodium ships as WebAssembly and loads asynchronously. The `sodium.ready`
// promise resolves once the WASM module is instantiated and the bindings are
// safe to call. After it resolves, all subsequent calls are synchronous.
//
// `init` is the single front door for that. Code that needs crypto awaits
// init() once before doing anything. Repeat calls await the same promise,
// so they are effectively free after the first.
export const init = async (): Promise<void> => {
throw new Error("crypto.init not implemented");
await sodium.ready;
};

View File

@@ -1,22 +1,38 @@
// Stub: see the README "Development workflow" section for TDD policy.
import sodium from "libsodium-wrappers-sumo";
// Plaintext chunk size used by Ente for file content streams. Hard-coded by
// the server; clients must match.
export const STREAM_CHUNK_SIZE = 4 * 1024 * 1024;
// Per-chunk overhead added by libsodium's secretstream construction:
// 16 bytes of Poly1305 tag plus 1 byte of secretstream tag.
export const STREAM_CHUNK_OVERHEAD = 17;
export interface StreamPullState {
readonly _opaque: unique symbol;
}
// Opaque handle to libsodium's secretstream pull state. Threaded through
// successive pullStreamChunk calls.
export type StreamPullState = sodium.StateAddress;
// Initialise a pull stream from the per-file decryption header and the
// per-file key.
export const initStreamPull = (
_header: Uint8Array,
_key: Uint8Array,
): StreamPullState => {
throw new Error("crypto.initStreamPull not implemented");
};
header: Uint8Array,
key: Uint8Array,
): StreamPullState =>
sodium.crypto_secretstream_xchacha20poly1305_init_pull(header, key);
// Decrypt one ciphertext chunk. Returns the plaintext and the secretstream
// tag (0=MESSAGE, 1=PUSH, 2=REKEY, 3=FINAL). The caller should verify the
// stream ended on TAG_FINAL to detect truncation.
export const pullStreamChunk = (
_state: StreamPullState,
_ciphertext: Uint8Array,
state: StreamPullState,
ciphertext: Uint8Array,
): { plaintext: Uint8Array; tag: number } => {
throw new Error("crypto.pullStreamChunk not implemented");
const result = sodium.crypto_secretstream_xchacha20poly1305_pull(
state,
ciphertext,
);
if (result === false) {
throw new Error("secretstream chunk authentication failed");
}
return { plaintext: result.message, tag: result.tag };
};