Phase 2 red: crypto primitive tests and stub modules
Tests for the entire crypto/ public surface, written against the API
shape declared in the README. The accompanying src/crypto/ modules are
stubs that throw 'not implemented' so the test files compile and tests
fail with clear errors rather than module-not-found.
Tests cover:
* init() resolves and is idempotent
* fromBase64 / toBase64 / toBase64URL round-trips, including URL-safe
input with stripped padding (the form Ente uses for auth tokens)
* deriveKEK matches sodium.crypto_pwhash with Argon2id parameters
* deriveLoginSubkey matches sodium.crypto_kdf_derive_from_key with
subkey id 1 and ctx 'loginctx', truncated to 16 bytes
* decryptBox round-trips, rejects tampering, wrong key, wrong nonce
* decryptSealed round-trips, rejects wrong keypair and tampering
* Secretstream pull decrypts multi-chunk streams in order, exposes
per-chunk tags, rejects tampering, wrong key, and out-of-order chunks
* Constants STREAM_CHUNK_SIZE (4 MiB) and STREAM_CHUNK_OVERHEAD (17)
Tests are commented to serve as the canonical API documentation per the
README development workflow policy. Verified: 29 tests fail (red), 3
trivial constant tests pass; lint and fmt-check are green.
eslint.config.mjs is updated to honour the leading-underscore convention
for intentionally unused parameters (the stubs).
This commit is contained in:
160
test/crypto/box.test.ts
Normal file
160
test/crypto/box.test.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Tests for `crypto.decryptBox` and `crypto.decryptSealed`.
|
||||
*
|
||||
* These cover the two asymmetric-and-symmetric "box" primitives quack uses
|
||||
* to unwrap key material from Ente:
|
||||
*
|
||||
* - `decryptBox`: secretbox decryption. Used everywhere a small payload
|
||||
* is sealed under a single shared key. Specifically:
|
||||
*
|
||||
* master key = decryptBox(encryptedKey, keyDecryptionNonce, kek)
|
||||
* secret key = decryptBox(encryptedSecretKey, secretKeyDecryptionNonce, masterKey)
|
||||
* collection key = decryptBox(coll.encryptedKey, coll.keyDecryptionNonce, masterKey)
|
||||
* file key = decryptBox(file.encryptedKey, file.keyDecryptionNonce, collectionKey)
|
||||
* file metadata = decryptBox(metadata.encryptedData, metadata.decryptionHeader, fileKey)
|
||||
*
|
||||
* - `decryptSealed`: anonymous sealed-box decryption. Used exactly once,
|
||||
* to recover the auth token returned by login:
|
||||
*
|
||||
* authToken = decryptSealed(encryptedToken, publicKey, secretKey)
|
||||
*
|
||||
* Encryption is server-side; quack only ever decrypts.
|
||||
*/
|
||||
|
||||
import sodium from "libsodium-wrappers-sumo";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { decryptBox, decryptSealed, init } from "../../src/crypto/index.js";
|
||||
|
||||
describe("crypto.decryptBox (XSalsa20-Poly1305 secretbox)", () => {
|
||||
beforeAll(async () => {
|
||||
await init();
|
||||
await sodium.ready;
|
||||
});
|
||||
|
||||
it("decrypts ciphertext produced by sodium.crypto_secretbox_easy", () => {
|
||||
const key = sodium.crypto_secretbox_keygen();
|
||||
const nonce = sodium.randombytes_buf(
|
||||
sodium.crypto_secretbox_NONCEBYTES,
|
||||
);
|
||||
const plaintext = new TextEncoder().encode("hello, ente");
|
||||
const ciphertext = sodium.crypto_secretbox_easy(plaintext, nonce, key);
|
||||
|
||||
const got = decryptBox(ciphertext, nonce, key);
|
||||
expect(new TextDecoder().decode(got)).toBe("hello, ente");
|
||||
});
|
||||
|
||||
it("decrypts a zero-byte plaintext", () => {
|
||||
// Edge case: zero-length plaintext still produces a 16-byte
|
||||
// Poly1305 tag, so the ciphertext is 16 bytes and decryption must
|
||||
// succeed.
|
||||
const key = sodium.crypto_secretbox_keygen();
|
||||
const nonce = sodium.randombytes_buf(
|
||||
sodium.crypto_secretbox_NONCEBYTES,
|
||||
);
|
||||
const ciphertext = sodium.crypto_secretbox_easy(
|
||||
new Uint8Array(0),
|
||||
nonce,
|
||||
key,
|
||||
);
|
||||
const got = decryptBox(ciphertext, nonce, key);
|
||||
expect(got.length).toBe(0);
|
||||
});
|
||||
|
||||
it("throws when the ciphertext has been tampered with", () => {
|
||||
// Authentication is the whole point. A single-bit flip must
|
||||
// reject. If this test ever passes silently, the wrapper has lost
|
||||
// the Poly1305 check and we have a security regression.
|
||||
const key = sodium.crypto_secretbox_keygen();
|
||||
const nonce = sodium.randombytes_buf(
|
||||
sodium.crypto_secretbox_NONCEBYTES,
|
||||
);
|
||||
const ciphertext = sodium.crypto_secretbox_easy(
|
||||
new Uint8Array([1, 2, 3, 4, 5]),
|
||||
nonce,
|
||||
key,
|
||||
);
|
||||
ciphertext[0] = ciphertext[0]! ^ 0x01;
|
||||
expect(() => decryptBox(ciphertext, nonce, key)).toThrow();
|
||||
});
|
||||
|
||||
it("throws when the wrong key is supplied", () => {
|
||||
const key = sodium.crypto_secretbox_keygen();
|
||||
const wrongKey = sodium.crypto_secretbox_keygen();
|
||||
const nonce = sodium.randombytes_buf(
|
||||
sodium.crypto_secretbox_NONCEBYTES,
|
||||
);
|
||||
const ciphertext = sodium.crypto_secretbox_easy(
|
||||
new Uint8Array([9, 9, 9]),
|
||||
nonce,
|
||||
key,
|
||||
);
|
||||
expect(() => decryptBox(ciphertext, nonce, wrongKey)).toThrow();
|
||||
});
|
||||
|
||||
it("throws when the nonce is wrong", () => {
|
||||
const key = sodium.crypto_secretbox_keygen();
|
||||
const nonce = sodium.randombytes_buf(
|
||||
sodium.crypto_secretbox_NONCEBYTES,
|
||||
);
|
||||
const wrongNonce = sodium.randombytes_buf(
|
||||
sodium.crypto_secretbox_NONCEBYTES,
|
||||
);
|
||||
const ciphertext = sodium.crypto_secretbox_easy(
|
||||
new Uint8Array([1, 2, 3]),
|
||||
nonce,
|
||||
key,
|
||||
);
|
||||
expect(() => decryptBox(ciphertext, wrongNonce, key)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("crypto.decryptSealed (anonymous box)", () => {
|
||||
beforeAll(async () => {
|
||||
await init();
|
||||
await sodium.ready;
|
||||
});
|
||||
|
||||
/**
|
||||
* Sealed-box (`crypto_box_seal`) is anonymous public-key encryption: a
|
||||
* sender encrypts to a recipient public key without authenticating its
|
||||
* own identity. The recipient decrypts using both halves of its own
|
||||
* X25519 keypair.
|
||||
*
|
||||
* Ente's server uses this to deliver the auth token after login: the
|
||||
* server seals the token to the user's published public key. The user
|
||||
* recovers the secret key from a secretbox under the master key (see
|
||||
* decryptBox above), then opens the sealed token.
|
||||
*/
|
||||
it("decrypts a sealed box produced by sodium.crypto_box_seal", () => {
|
||||
const kp = sodium.crypto_box_keypair();
|
||||
const message = new TextEncoder().encode("auth-token-payload");
|
||||
const sealed = sodium.crypto_box_seal(message, kp.publicKey);
|
||||
|
||||
const got = decryptSealed(sealed, kp.publicKey, kp.privateKey);
|
||||
expect(new TextDecoder().decode(got)).toBe("auth-token-payload");
|
||||
});
|
||||
|
||||
it("throws when given the wrong keypair", () => {
|
||||
const kp = sodium.crypto_box_keypair();
|
||||
const otherKp = sodium.crypto_box_keypair();
|
||||
const sealed = sodium.crypto_box_seal(
|
||||
new Uint8Array([1, 2, 3]),
|
||||
kp.publicKey,
|
||||
);
|
||||
expect(() =>
|
||||
decryptSealed(sealed, otherKp.publicKey, otherKp.privateKey),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("throws when the ciphertext has been tampered with", () => {
|
||||
const kp = sodium.crypto_box_keypair();
|
||||
const sealed = sodium.crypto_box_seal(
|
||||
new Uint8Array([1, 2, 3]),
|
||||
kp.publicKey,
|
||||
);
|
||||
sealed[sealed.length - 1] = sealed[sealed.length - 1]! ^ 0x01;
|
||||
expect(() =>
|
||||
decryptSealed(sealed, kp.publicKey, kp.privateKey),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user