/** * 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(); }); });