Files
quak/test/crypto/box.test.ts
sneak d8a4b0291e Rename quack to quak
German for 'quack', matching the Ente (German for 'duck') naming. All
references updated: package name, CLI binary, X-Client-Package header,
test descriptions, temp dir prefixes, README, Makefile docker tag.
2026-05-13 18:02:55 -07:00

161 lines
6.1 KiB
TypeScript

/**
* Tests for `crypto.decryptBox` and `crypto.decryptSealed`.
*
* These cover the two asymmetric-and-symmetric "box" primitives quak 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; quak 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();
});
});