carlos / contra Public

Clone
git clone https://amadan.net/carlos/contra

Plain git — no account needed to clone.

Download

Download this file

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
6import { concat, enc, u32be, u64be } from "./bytes.js";
7import { LABEL_AAD } from "./kdf.js";
8
9export const OBJECT_WRAP_PW = "wrap.pw";
10export const OBJECT_WRAP_RC = "wrap.rc";
11export const OBJECT_WRAP_PRF = "wrap.prf";
12export const OBJECT_VAULT_KEYSET = "vault.keyset";
13export const OBJECT_ITEM = "item";
14export 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.
25export 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
37async 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.
43export 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.
53export 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.
66export 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.
71export 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
81export 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