| 1 | // AEAD: the AAD binding, AES-256-GCM, and the size quantisation that keeps |
| 2 | // item lengths out of the bucket. |
| 3 | // |
| 4 | // Pinned by vectors/contra-v1.json. The Go side is internal/contra/crypto. |
| 5 | |
| 6 | import { concat, enc, u32be, u64be } from "./bytes.js"; |
| 7 | import { LABEL_AAD } from "./kdf.js"; |
| 8 | |
| 9 | export const OBJECT_WRAP_PW = "wrap.pw"; |
| 10 | export const OBJECT_WRAP_RC = "wrap.rc"; |
| 11 | export const OBJECT_WRAP_PRF = "wrap.prf"; |
| 12 | export const OBJECT_VAULT_KEYSET = "vault.keyset"; |
| 13 | export const OBJECT_ITEM = "item"; |
| 14 | export const OBJECT_HANDOFF = "handoff"; |
| 15 | |
| 16 | // aad binds (scope, object type, object id, version) into every AEAD, so a |
| 17 | // malicious server cannot transplant a ciphertext between slots or between |
| 18 | // users. |
| 19 | // |
| 20 | // The scope id is the account id for account-scoped objects and the vault id |
| 21 | // for vault-scoped ones. Length-prefixed because plain concatenation is not |
| 22 | // injective — without the lengths, different tuples produce identical AAD, |
| 23 | // which is the exact transplant the binding exists to prevent — and |
| 24 | // domain-tagged so AAD from this protocol cannot be replayed into another. |
| 25 | export function aad(scopeId, objectType, objectId, version) { |
| 26 | const type = enc.encode(objectType); |
| 27 | return concat( |
| 28 | enc.encode(LABEL_AAD), |
| 29 | new Uint8Array([0]), |
| 30 | u32be(scopeId.length), scopeId, |
| 31 | u32be(type.length), type, |
| 32 | u32be(objectId.length), objectId, |
| 33 | u64be(version), |
| 34 | ); |
| 35 | } |
| 36 | |
| 37 | async function gcmKey(raw, usage) { |
| 38 | return crypto.subtle.importKey("raw", raw, { name: "AES-GCM" }, false, [usage]); |
| 39 | } |
| 40 | |
| 41 | // seal returns ciphertext with the 128-bit tag appended — the layout the |
| 42 | // wrap rows and item rows both store. |
| 43 | export async function seal(key, nonce, additionalData, plaintext) { |
| 44 | const k = await gcmKey(key, "encrypt"); |
| 45 | const out = await crypto.subtle.encrypt( |
| 46 | { name: "AES-GCM", iv: nonce, additionalData, tagLength: 128 }, k, plaintext); |
| 47 | return new Uint8Array(out); |
| 48 | } |
| 49 | |
| 50 | // open reverses seal. A failure is either the wrong key or a tampered blob; |
| 51 | // the wrap's KCV is what tells those apart, and callers on an unlock path |
| 52 | // should check it first so they can say which. |
| 53 | export async function open(key, nonce, additionalData, sealed) { |
| 54 | const k = await gcmKey(key, "decrypt"); |
| 55 | const out = await crypto.subtle.decrypt( |
| 56 | { name: "AES-GCM", iv: nonce, additionalData, tagLength: 128 }, k, sealed); |
| 57 | return new Uint8Array(out); |
| 58 | } |
| 59 | |
| 60 | // PAD_BUCKET is the size item plaintexts are quantised into before sealing. |
| 61 | // |
| 62 | // A1 tolerates size residue; that is no reason to hand over item-length |
| 63 | // fingerprints for free. An unpadded vault publishes the length of every |
| 64 | // password and every note to anyone holding the bucket, and lengths are |
| 65 | // enough to pick the interesting rows out of a pile of ciphertext. |
| 66 | export const PAD_BUCKET = 256; |
| 67 | |
| 68 | // pad uses ISO/IEC 7816-4: one 0x80 byte, then zeros. It always adds at |
| 69 | // least one byte, so an input that is already bucket-aligned is not a |
| 70 | // special case somebody's second implementation gets wrong. |
| 71 | export function pad(b) { |
| 72 | let n = b.length + 1; |
| 73 | const r = n % PAD_BUCKET; |
| 74 | if (r !== 0) n += PAD_BUCKET - r; |
| 75 | const out = new Uint8Array(n); |
| 76 | out.set(b); |
| 77 | out[b.length] = 0x80; |
| 78 | return out; |
| 79 | } |
| 80 | |
| 81 | export function unpad(b) { |
| 82 | if (b.length === 0 || b.length % PAD_BUCKET !== 0) { |
| 83 | throw new Error("contra: padded length is not a whole number of buckets"); |
| 84 | } |
| 85 | for (let i = b.length - 1; i >= 0; i--) { |
| 86 | if (b[i] === 0x00) continue; |
| 87 | if (b[i] === 0x80) return b.slice(0, i); |
| 88 | break; |
| 89 | } |
| 90 | throw new Error("contra: malformed padding bytes"); |
| 91 | } |
| 92 | |