agmsg-cloud 0.0.1 → 0.1.0-rc.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +39 -2
  2. package/dist/src/api.js +517 -0
  3. package/dist/src/authenticated-digest.js +234 -0
  4. package/dist/src/browser.js +241 -0
  5. package/dist/src/ceremony.js +181 -0
  6. package/dist/src/commands/approve.js +392 -0
  7. package/dist/src/commands/connect.js +273 -0
  8. package/dist/src/commands/fetch.js +249 -0
  9. package/dist/src/commands/login.js +334 -0
  10. package/dist/src/commands/logout.js +74 -0
  11. package/dist/src/commands/pull.js +80 -0
  12. package/dist/src/commands/request.js +371 -0
  13. package/dist/src/commands/sync.js +138 -0
  14. package/dist/src/commands/vault.js +478 -0
  15. package/dist/src/commands/watch.js +47 -0
  16. package/dist/src/config.js +34 -0
  17. package/dist/src/credentials.js +374 -0
  18. package/dist/src/device-slot.js +148 -0
  19. package/dist/src/filelock.js +167 -0
  20. package/dist/src/index.js +242 -0
  21. package/dist/src/ledger.js +296 -0
  22. package/dist/src/machine-name.js +90 -0
  23. package/dist/src/oss-env.js +49 -0
  24. package/dist/src/oss.js +289 -0
  25. package/dist/src/paths.js +8 -0
  26. package/dist/src/pending.js +330 -0
  27. package/dist/src/pick-request.js +56 -0
  28. package/dist/src/preflight.js +257 -0
  29. package/dist/src/recovery-key.js +386 -0
  30. package/dist/src/sas.js +18 -0
  31. package/dist/src/secure-store.js +176 -0
  32. package/dist/src/shell-arg.js +18 -0
  33. package/dist/src/slot-advice.js +74 -0
  34. package/dist/src/vault-container.js +115 -0
  35. package/dist/src/vault-crypto.js +190 -0
  36. package/dist/src/vault-protocol.js +358 -0
  37. package/dist/src/version.js +57 -0
  38. package/node_modules/@agmsg-cloud/sas-core/dist/src/bech32.d.ts +17 -0
  39. package/node_modules/@agmsg-cloud/sas-core/dist/src/bech32.js +103 -0
  40. package/node_modules/@agmsg-cloud/sas-core/dist/src/index.d.ts +17 -0
  41. package/node_modules/@agmsg-cloud/sas-core/dist/src/index.js +147 -0
  42. package/node_modules/@agmsg-cloud/sas-core/package.json +30 -0
  43. package/package.json +50 -7
  44. package/bin/agmsg-cloud.js +0 -4
@@ -0,0 +1,115 @@
1
+ // The account vault's plaintext: every team's handoff bundle in one container.
2
+ //
3
+ // The vault holds one account's key material, and the OSS side exports one team
4
+ // at a time (`key.sh handoff` refuses more than one team), so something has to
5
+ // hold the set. That something is defined here, in the cloud CLI, because the
6
+ // vault is a cloud feature and no OSS counterpart is being created.
7
+ //
8
+ // The format is documented in docs/design/vault-container-v1.md. If you change
9
+ // anything in this file, change that too — a format whose only description is
10
+ // its parser is a format nobody can implement against.
11
+ /** Identifies the container itself, so a reader that cannot read it says so. */
12
+ export const CONTAINER_FORMAT = 'agmsg-cloud-vault-container';
13
+ /** Bumped when the shape changes in a way an older reader must refuse. */
14
+ export const CONTAINER_VERSION = 1;
15
+ export function emptyContainer() {
16
+ return { format: CONTAINER_FORMAT, version: CONTAINER_VERSION, teams: [] };
17
+ }
18
+ export class UnreadableContainerError extends Error {
19
+ constructor(message) {
20
+ super(message);
21
+ this.name = 'UnreadableContainerError';
22
+ }
23
+ }
24
+ // Refuse rather than guess. A container from a future version may put different
25
+ // meaning on the same field names, and a reader that takes the fields it
26
+ // recognises would restore a bundle that is not what the vault holds. The
27
+ // recovery key would be correct and the restore would be wrong, which is the
28
+ // worst pair of properties available.
29
+ export function parseContainer(plaintext) {
30
+ let value;
31
+ try {
32
+ value = JSON.parse(plaintext.toString('utf8'));
33
+ }
34
+ catch {
35
+ throw new UnreadableContainerError('vault content is not the expected container (not JSON)');
36
+ }
37
+ if (typeof value !== 'object' || value === null) {
38
+ throw new UnreadableContainerError('vault content is not the expected container');
39
+ }
40
+ const c = value;
41
+ if (c.format !== CONTAINER_FORMAT) {
42
+ throw new UnreadableContainerError(`vault content declares format '${String(c.format)}', which this version cannot read`);
43
+ }
44
+ if (c.version !== CONTAINER_VERSION) {
45
+ throw new UnreadableContainerError(`vault content is container version ${String(c.version)}; this version reads ` +
46
+ `${CONTAINER_VERSION}. Upgrade agmsg-cloud rather than restoring from it.`);
47
+ }
48
+ if (!Array.isArray(c.teams) || !c.teams.every(isTeamEntry)) {
49
+ throw new UnreadableContainerError('vault content has no readable team entries');
50
+ }
51
+ return { format: CONTAINER_FORMAT, version: CONTAINER_VERSION, teams: c.teams };
52
+ }
53
+ function isTeamEntry(value) {
54
+ if (typeof value !== 'object' || value === null)
55
+ return false;
56
+ const e = value;
57
+ return (typeof e.server_instance_id === 'string' &&
58
+ e.server_instance_id !== '' &&
59
+ typeof e.team_id === 'string' &&
60
+ e.team_id !== '' &&
61
+ Array.isArray(e.key_ids) &&
62
+ e.key_ids.every((k) => typeof k === 'string' && k !== '') &&
63
+ typeof e.bundle === 'string' &&
64
+ e.bundle !== '');
65
+ }
66
+ // The identity of an entry is the PAIR. A team id alone is not unique across
67
+ // data-plane instances — it is minted by the client, and two instances can
68
+ // legitimately carry the same one — so keying by it makes the second instance's
69
+ // backup overwrite the first's, silently, at the moment the person is trying to
70
+ // protect it.
71
+ function identityOf(entry) {
72
+ return `${entry.server_instance_id}\u0000${entry.team_id}`;
73
+ }
74
+ // Add or replace ONE team, leaving every other entry byte-for-byte as it was.
75
+ //
76
+ // Not a stylistic choice: a vault gets re-wrapped on every put, and if adding a
77
+ // team changed what the other entries bind, every backup would put the rest of
78
+ // the account's key material at risk of being written wrong. Each entry carries
79
+ // its own binding and nothing derived from its neighbours — no index, no count,
80
+ // no digest over the set — so touching one cannot invalidate another.
81
+ export function upsertTeam(container, entry) {
82
+ const key = identityOf(entry);
83
+ const others = container.teams.filter((t) => identityOf(t) !== key);
84
+ // Sorted by team id so the same set of teams always serialises identically,
85
+ // whatever order they were added in. Two machines backing up the same account
86
+ // then produce the same plaintext rather than a diff that is pure history.
87
+ const teams = [...others, entry].sort((a, b) => (identityOf(a) < identityOf(b) ? -1 : 1));
88
+ return { format: CONTAINER_FORMAT, version: CONTAINER_VERSION, teams };
89
+ }
90
+ export function serializeContainer(container) {
91
+ return Buffer.from(JSON.stringify(container), 'utf8');
92
+ }
93
+ // The entry for a team, checked against what the caller asked for. The AEAD
94
+ // already proved the container is the account's; this proves the entry inside
95
+ // it is the team's — a restore that quietly opened the wrong team's bundle
96
+ // would look like a success.
97
+ export function selectTeam(container, want) {
98
+ // Matched on the full pair, not found by team id and then checked. Finding by
99
+ // team id alone answers with whichever entry happens to come first, so a
100
+ // vault legitimately holding the same team id on two instances could never
101
+ // restore the second one — the check would reject an entry that was there.
102
+ const key = identityOf({ server_instance_id: want.serverInstanceId, team_id: want.teamId });
103
+ const entry = container.teams.find((t) => identityOf(t) === key);
104
+ if (entry)
105
+ return entry;
106
+ // Nothing for this pair. If the team id is present under a different
107
+ // instance, say so: "no backup for this team" would be misleading when the
108
+ // name matches something in the vault.
109
+ const elsewhere = container.teams.find((t) => t.team_id === want.teamId);
110
+ if (elsewhere) {
111
+ throw new UnreadableContainerError(`the vault's backup for this team belongs to a different server instance ` +
112
+ `(${elsewhere.server_instance_id}); this machine is bound to ${want.serverInstanceId}`);
113
+ }
114
+ throw new UnreadableContainerError('there is no backup for this team yet. Create one first with `agmsg-cloud recovery setup`.');
115
+ }
@@ -0,0 +1,190 @@
1
+ import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
2
+ import { argon2id } from 'hash-wasm';
3
+ // Argon2id at 64 MiB / 3 passes. The server's schema permits 8 MiB..1 GiB; this
4
+ // sits where a laptop pays well under a second and a cracker pays real memory.
5
+ export const DEFAULT_KDF = {
6
+ kdf: 'argon2id',
7
+ m: 65536,
8
+ t: 3,
9
+ p: 1,
10
+ };
11
+ // The same bounds the server enforces (routes.ts kdfMetaSchema), re-checked here
12
+ // because on restore the kdf_meta arrives *from* the server and we must derive
13
+ // with it before we can tell whether it is honest. Binding the params into the
14
+ // AAD makes a tampered set fail — but only after we have already spent the
15
+ // memory and time it asked for, so a hostile server could bill us 1 GiB per
16
+ // attempt. Bound it first, then derive.
17
+ export const KDF_LIMITS = {
18
+ m: { min: 8192, max: 1048576 }, // KiB
19
+ t: { min: 1, max: 16 },
20
+ p: { min: 1, max: 16 },
21
+ };
22
+ function assertUsableKdf(meta) {
23
+ if (meta.kdf !== 'argon2id')
24
+ throw new Error(`unsupported kdf: ${meta.kdf}`);
25
+ for (const field of ['m', 't', 'p']) {
26
+ const value = meta[field];
27
+ const { min, max } = KDF_LIMITS[field];
28
+ if (!Number.isInteger(value) || value < min || value > max) {
29
+ throw new Error(`kdf parameter ${field} is out of range: ${value} (want ${min}..${max})`);
30
+ }
31
+ }
32
+ const salt = Buffer.from(meta.salt, 'base64');
33
+ // Round-trip, so a string Buffer.from would silently partial-decode is refused
34
+ // rather than quietly deriving from fewer bytes than the sender encoded.
35
+ if (salt.toString('base64') !== meta.salt || salt.length < 8) {
36
+ throw new Error('kdf salt is not valid base64 of at least 8 bytes');
37
+ }
38
+ }
39
+ const SALT_BYTES = 16;
40
+ const KEY_BYTES = 32;
41
+ const IV_BYTES = 12;
42
+ const TAG_BYTES = 16;
43
+ export function newSalt() {
44
+ return randomBytes(SALT_BYTES).toString('base64');
45
+ }
46
+ // The VDK itself: random, not derived.
47
+ //
48
+ // It used to be argon2id(recovery key, salt), which made the recovery key and
49
+ // the VDK the same secret wearing two shapes. Re-issuing a key then meant
50
+ // re-encrypting everything under a new derivation, and there was nowhere to
51
+ // put a second way in — a device slot has no recovery key to derive from.
52
+ //
53
+ // Random, wrapped once per way of opening it (recovery-vault-v1.md:506-513):
54
+ // the key wraps it, and later a device KEK wraps the same VDK, and neither
55
+ // knows about the other. Re-issuance still means a FRESH VDK and a full
56
+ // re-encrypt — never rewrapping the old one, because a leaked key that opens
57
+ // the old version yields the same VDK and opens the new one too.
58
+ export function newVdk() {
59
+ return randomBytes(KEY_BYTES);
60
+ }
61
+ function slotAad(context) {
62
+ const fields = [
63
+ 'agmsg-vault-slot-v1',
64
+ context.vaultId,
65
+ String(context.recoveryGeneration),
66
+ context.slotId,
67
+ context.slotType,
68
+ context.wrapProfile,
69
+ // The kind-specific tail. The union makes both branches exhaustive, so a
70
+ // new slot kind cannot be added without deciding what it binds.
71
+ ...(context.slotType === 'recovery-key'
72
+ ? [
73
+ context.kdfProfile,
74
+ String(context.kdfParams.m),
75
+ String(context.kdfParams.t),
76
+ String(context.kdfParams.p),
77
+ context.salt,
78
+ ]
79
+ : [context.vaultServiceId, context.accountId]),
80
+ ];
81
+ return Buffer.from(fields.map((f) => `${Buffer.byteLength(f, 'utf8')}:${f}`).join(''), 'utf8');
82
+ }
83
+ export function wrapVdk(kek, vdk, context) {
84
+ // Checked at the boundary rather than trusted. Without it this API accepts a
85
+ // short "VDK" and returns something that unwraps to it perfectly — a
86
+ // 16-byte key would round-trip and look correct everywhere except in how
87
+ // much work it costs to guess.
88
+ if (vdk.length !== KEY_BYTES) {
89
+ throw new Error(`a VDK is ${KEY_BYTES} bytes; got ${vdk.length}`);
90
+ }
91
+ if (kek.length !== KEY_BYTES) {
92
+ throw new Error(`a KEK is ${KEY_BYTES} bytes; got ${kek.length}`);
93
+ }
94
+ const iv = randomBytes(IV_BYTES);
95
+ const cipher = createCipheriv('aes-256-gcm', kek, iv);
96
+ cipher.setAAD(slotAad(context));
97
+ const sealed = Buffer.concat([cipher.update(vdk), cipher.final()]);
98
+ return Buffer.concat([iv, cipher.getAuthTag(), sealed]);
99
+ }
100
+ const WRAP_BYTES = IV_BYTES + TAG_BYTES + KEY_BYTES;
101
+ export function unwrapVdk(kek, wrapped, context) {
102
+ // A wrap is exactly iv + tag + a 32-byte key. Refusing anything else here
103
+ // means a truncated or padded blob fails as a malformed wrap rather than as
104
+ // an authentication error, which are different problems with different
105
+ // causes.
106
+ if (wrapped.length !== WRAP_BYTES) {
107
+ throw new Error(`a wrapped VDK is ${WRAP_BYTES} bytes; got ${wrapped.length}`);
108
+ }
109
+ const iv = wrapped.subarray(0, IV_BYTES);
110
+ const tag = wrapped.subarray(IV_BYTES, IV_BYTES + TAG_BYTES);
111
+ const body = wrapped.subarray(IV_BYTES + TAG_BYTES);
112
+ const decipher = createDecipheriv('aes-256-gcm', kek, iv);
113
+ decipher.setAAD(slotAad(context));
114
+ decipher.setAuthTag(tag);
115
+ return Buffer.concat([decipher.update(body), decipher.final()]);
116
+ }
117
+ // Argon2id over the recovery key, producing the KEK that unwraps a stored VDK
118
+ // from a recovery-key slot.
119
+ //
120
+ // The name says that now because it is now true. It was deriveKeyFromRecoveryKey
121
+ // while callers still sealed the bundle directly under this value — naming it
122
+ // for the KEK then would have described a format that had not arrived. This is
123
+ // the change that moved the callers, so the name moves with them.
124
+ //
125
+ // It is NOT the vault's data key. The data key is random (newVdk) and this
126
+ // value only opens one wrap of it, which is what makes a second way in — and
127
+ // re-issuance — expressible at all.
128
+ export async function deriveRecoveryKek(recoveryKey, meta) {
129
+ assertUsableKdf(meta);
130
+ const salt = Buffer.from(meta.salt, 'base64');
131
+ const hex = await argon2id({
132
+ password: recoveryKey,
133
+ salt,
134
+ memorySize: meta.m,
135
+ iterations: meta.t,
136
+ parallelism: meta.p,
137
+ hashLength: KEY_BYTES,
138
+ outputType: 'hex',
139
+ });
140
+ return Buffer.from(hex, 'hex');
141
+ }
142
+ function additionalData(context, meta) {
143
+ // Length-prefixed rather than delimiter-joined. wrap_profile is a free-form
144
+ // string, so with a separator its contents could impersonate a field boundary
145
+ // and make two different contexts produce the same AAD. Today's schema happens
146
+ // to make that unreachable; the framing should not depend on that staying true
147
+ //. Order is fixed because the other side recomputes this — a
148
+ // JSON.stringify over an object would make key order part of the format by
149
+ // accident.
150
+ const fields = [
151
+ // The format identifier the design's AAD tuple calls for, and the reason
152
+ // this is v2: the vault stopped being per-team, so a v1 ciphertext (which
153
+ // bound a team id) must fail to open rather than appear compatible. A
154
+ // change that breaks stored data should break it detectably.
155
+ 'agmsg-vault-v2',
156
+ context.accountId,
157
+ context.vaultId,
158
+ context.vaultServiceId,
159
+ context.wrapProfile,
160
+ String(context.revision),
161
+ meta.kdf,
162
+ meta.salt,
163
+ String(meta.m),
164
+ String(meta.t),
165
+ String(meta.p),
166
+ ];
167
+ return Buffer.from(fields.map((f) => `${Buffer.byteLength(f, 'utf8')}:${f}`).join(''), 'utf8');
168
+ }
169
+ // iv || tag || ciphertext. Length-prefix-free because both leading parts are
170
+ // fixed width for AES-256-GCM.
171
+ export function wrapWithVdk(vdk, plaintext, context, meta) {
172
+ const iv = randomBytes(IV_BYTES);
173
+ const cipher = createCipheriv('aes-256-gcm', vdk, iv);
174
+ cipher.setAAD(additionalData(context, meta));
175
+ const body = Buffer.concat([cipher.update(plaintext), cipher.final()]);
176
+ return Buffer.concat([iv, cipher.getAuthTag(), body]);
177
+ }
178
+ export function unwrapWithVdk(vdk, wrapped, context, meta) {
179
+ if (wrapped.length < IV_BYTES + 16 + 1)
180
+ throw new Error('vault ciphertext is truncated');
181
+ const iv = wrapped.subarray(0, IV_BYTES);
182
+ const tag = wrapped.subarray(IV_BYTES, IV_BYTES + 16);
183
+ const body = wrapped.subarray(IV_BYTES + 16);
184
+ const decipher = createDecipheriv('aes-256-gcm', vdk, iv);
185
+ decipher.setAAD(additionalData(context, meta));
186
+ decipher.setAuthTag(tag);
187
+ // `final()` is what raises on a bad tag; without it a caller reading only
188
+ // `update()` would accept forged bytes.
189
+ return Buffer.concat([decipher.update(body), decipher.final()]);
190
+ }
@@ -0,0 +1,358 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { DEFAULT_KDF, deriveRecoveryKek, newSalt, newVdk, unwrapVdk, unwrapWithVdk, wrapVdk, wrapWithVdk, } from './vault-crypto.js';
3
+ // The vault exchange with the server: read, create, append, open. Split from the
4
+ // command layer so that what talks to the server is separable from what shells
5
+ // out to the OSS tooling and prompts the terminal — the integration test drives
6
+ // this half against a real server, and the half it cannot reach is then exactly
7
+ // two named shell-outs rather than a vague remainder.
8
+ //
9
+ // The shape of a vault, since the wrap list:
10
+ //
11
+ // the bundle sealed under a RANDOM data key (the VDK), not under anything
12
+ // derived from the recovery key
13
+ // the slots one row per way of getting that VDK. The recovery-key slot
14
+ // lives on the server; the device slot lives in this machine's
15
+ // secure store and is never uploaded (recovery-vault-v1.md:528)
16
+ //
17
+ // Which is why the functions below come in pairs. The `WithVdk` half is the
18
+ // real one: given the data key, open or append. The other half is a shell that
19
+ // obtains the data key from the recovery key first. A caller holding a device
20
+ // slot skips the shell — which is the whole point of the device slot, and the
21
+ // owner's requirement that the master key not come out unless a machine is
22
+ // lost.
23
+ /** How the bundle is sealed under the VDK. Unchanged by the wrap list. */
24
+ export const WRAP_PROFILE = 'argon2id-aes256gcm-v1';
25
+ /**
26
+ * How a recovery-key slot wraps the VDK. Distinct from WRAP_PROFILE because
27
+ * they describe different things: one is how the bundle is sealed, the other is
28
+ * how the key to it is wrapped, and a change to either must be namable without
29
+ * implying the other changed.
30
+ */
31
+ export const RECOVERY_SLOT_WRAP_PROFILE = 'aes256gcm-v1';
32
+ /**
33
+ * The generation a vault is created at.
34
+ *
35
+ * It is NOT sent — the server refuses a client-supplied generation and takes
36
+ * this from its column default. The two must agree: the generation is bound
37
+ * into the slot's AEAD, so a vault stored at a generation the slot was not
38
+ * wrapped for cannot be opened by the recovery key that created it. The create
39
+ * checks what the server actually recorded rather than assuming, for the same
40
+ * reason `assertLanded` checks the revision.
41
+ */
42
+ const INITIAL_RECOVERY_GENERATION = 1;
43
+ export class WrongRecoveryKeyError extends Error {
44
+ constructor(message) {
45
+ super(message);
46
+ this.name = 'WrongRecoveryKeyError';
47
+ }
48
+ }
49
+ /** A vault this version cannot open, for a reason that is not the key. */
50
+ export class UnopenableVaultError extends Error {
51
+ constructor(message) {
52
+ super(message);
53
+ this.name = 'UnopenableVaultError';
54
+ }
55
+ }
56
+ // The outer AAD tuple (recovery-vault-v1.md): format and profile, the account,
57
+ // the vault, and the deployment. The team is NOT here any more — the vault
58
+ // stopped being per-team, and each team's binding moved inside the container.
59
+ function contextFor(identity, vaultId, wrapProfile, revision) {
60
+ return {
61
+ accountId: identity.accountId,
62
+ vaultServiceId: identity.vaultServiceId,
63
+ vaultId,
64
+ wrapProfile,
65
+ revision,
66
+ };
67
+ }
68
+ // The server assigns revisions itself: create is always 1, and an append under
69
+ // If-Match is always current + 1 (app/src/handoff/vault.ts). Nothing rewrites an
70
+ // existing version in place, so "which revision these bytes are" is knowable at
71
+ // wrap time — which is what makes binding the revision into the AAD possible.
72
+ //
73
+ // If a write ever landed as a different revision than we sealed for, the stored
74
+ // version would be unopenable. That is fail-closed rather than silent, but it
75
+ // should never happen, so it is checked instead of assumed.
76
+ function assertLanded(expected, actual) {
77
+ if (actual !== expected) {
78
+ throw new Error(`server stored revision ${actual} where ${expected} was sealed; ` +
79
+ 'that version cannot be opened and must be rewritten');
80
+ }
81
+ }
82
+ // Everything the server returns is attacker-controlled if the server is. The wrap
83
+ // profile decides which primitive and which plaintext contract apply, so it must
84
+ // match exactly before anything is derived or opened: an unknown or future
85
+ // profile has to be refused, not opened with today's cipher and passed onward as
86
+ // "authenticated".
87
+ function usableMeta(version) {
88
+ if (version.wrap_profile !== WRAP_PROFILE) {
89
+ throw new Error(`vault uses wrap profile '${version.wrap_profile}', which this version cannot open`);
90
+ }
91
+ if (version.kdf_meta.kdf !== 'argon2id') {
92
+ throw new Error(`vault uses kdf '${version.kdf_meta.kdf}', which this version cannot use`);
93
+ }
94
+ return { ...version.kdf_meta, kdf: 'argon2id' };
95
+ }
96
+ // Which slot the recovery key opens.
97
+ //
98
+ // The design forbids trial-unwrap: policy picks the slot, the key does not go
99
+ // looking for one that happens to work (:517). The policy here is the narrowest
100
+ // one that is true — this version writes exactly one recovery-key slot at
101
+ // create, and nothing it can do produces a second. More than one is therefore a
102
+ // state this client did not create, and guessing between them would be exactly
103
+ // the trial-unwrap the design rules out.
104
+ function recoverySlot(version) {
105
+ const slots = version.vdk_wraps.filter((w) => w.slot_type === 'recovery-key');
106
+ // No slot means the vault predates the wrap list: its bundle is sealed
107
+ // directly under a key derived from the recovery key. Null rather than an
108
+ // error — that vault is intact and its key still opens it, and a schema
109
+ // change must not be what makes recovery data unreadable. See
110
+ // `vdkFromRecoveryKey`.
111
+ if (slots.length === 0)
112
+ return null;
113
+ if (slots.length > 1) {
114
+ throw new UnopenableVaultError(`this vault has ${slots.length} recovery-key slots; this version writes one and ` +
115
+ 'will not choose between them');
116
+ }
117
+ return slots[0];
118
+ }
119
+ // The KDF header a slot carries, as the parameters to derive with.
120
+ //
121
+ // Read from the SLOT, not from the version's kdf_meta. They hold the same values
122
+ // — the create writes both from one variable — but only one of them can be the
123
+ // one that is read, or a server could serve two and each reader would answer
124
+ // differently. The design puts the parameters in the slot (:519-522), so that is
125
+ // the one.
126
+ //
127
+ // (The version's kdf_meta stays on the wire and stays bound into the bundle's
128
+ // AAD, where it protects stored bytes that were sealed with it. It is redundant
129
+ // with the slot header and goes when the bundle's AAD can be changed without
130
+ // making existing vaults unopenable.)
131
+ function slotKdf(slot) {
132
+ if (slot.kdf_profile !== 'argon2id') {
133
+ throw new UnopenableVaultError(`this vault's recovery slot uses kdf '${slot.kdf_profile}', which this version cannot use`);
134
+ }
135
+ return {
136
+ kdf: 'argon2id',
137
+ salt: slot.salt,
138
+ m: slot.kdf_params.m,
139
+ t: slot.kdf_params.t,
140
+ p: slot.kdf_params.p,
141
+ };
142
+ }
143
+ // What the slot's wrap is bound to. Built from the slot's own header, so the
144
+ // create and the open compute it from the same fields — the create from the
145
+ // slot it is about to send, the open from the slot it was handed back.
146
+ function slotContext(vaultId, recoveryGeneration, slot) {
147
+ return {
148
+ vaultId,
149
+ recoveryGeneration,
150
+ slotId: slot.slot_id,
151
+ wrapProfile: slot.wrap_profile,
152
+ slotType: 'recovery-key',
153
+ kdfProfile: slot.kdf_profile,
154
+ kdfParams: slot.kdf_params,
155
+ salt: slot.salt,
156
+ };
157
+ }
158
+ /**
159
+ * Get the vault's data key out of its recovery-key slot.
160
+ *
161
+ * This is the only place the recovery key is used against the server's copy of
162
+ * anything. Everything downstream takes the VDK, so a caller that already has
163
+ * it — from this machine's device slot — never reaches here, and never asks a
164
+ * person for the key.
165
+ */
166
+ export async function vdkFromRecoveryKey(version, recoveryKey) {
167
+ const slot = recoverySlot(version);
168
+ // ------------------------------------------------------------------------
169
+ // LEGACY, read and append only. A vault created before the wrap list has no
170
+ // slot: its bundle is sealed directly under the key derived from the recovery
171
+ // key, so that derived key IS the data key.
172
+ //
173
+ // Kept because a schema change must not be what makes someone's recovery data
174
+ // unreadable. The per-team vault is kept alive for exactly this reason and
175
+ // says so where it lives (app/src/handoff/vault.ts); nothing here has a better
176
+ // claim to be deleted than that one does.
177
+ //
178
+ // A vault on this path cannot gain a device slot: there is no key to wrap that
179
+ // the recovery key does not also derive, and wrapping the derived key would
180
+ // put a copy of it in the secure store while the vault stayed openable by
181
+ // derivation — two ways in with one secret behind both.
182
+ //
183
+ // A hostile server cannot use this as a downgrade. Serving `vdk_wraps: []`
184
+ // for a vault that HAS a slot sends the client down this path, and the key it
185
+ // derives is not the random data key the bundle was sealed under — the tag
186
+ // fails. The result is a refusal, not a weaker open.
187
+ //
188
+ // WHAT REMOVES IT: a count of account vaults with no wrap row, from the
189
+ // production database. Zero means nothing is reachable only through here, and
190
+ // then this branch and the tests below it go in one change.
191
+ // ------------------------------------------------------------------------
192
+ if (slot === null)
193
+ return deriveRecoveryKek(recoveryKey, usableMeta(version));
194
+ if (slot.wrap_profile !== RECOVERY_SLOT_WRAP_PROFILE) {
195
+ throw new UnopenableVaultError(`this vault's recovery slot uses wrap profile '${slot.wrap_profile}', ` +
196
+ 'which this version cannot open');
197
+ }
198
+ const kek = await deriveRecoveryKek(recoveryKey, slotKdf(slot));
199
+ try {
200
+ return unwrapVdk(kek, Buffer.from(slot.wrapped_vdk, 'base64'), slotContext(version.vault_id, version.recovery_generation, slot));
201
+ }
202
+ catch {
203
+ // A wrong key, a tampered slot, and a slot lifted from another vault or
204
+ // generation all land here and genuinely cannot be told apart — the tag
205
+ // fails and says nothing about why.
206
+ throw new WrongRecoveryKeyError('that recovery key does not open this vault — the key is wrong, or the ' +
207
+ 'stored slot does not match the vault and generation it belongs to');
208
+ }
209
+ }
210
+ // The account's vault and the identifiers the client cannot derive. A first run
211
+ // returns `version: null` rather than failing: those identifiers are what a
212
+ // create needs.
213
+ export async function readAccountVault(client) {
214
+ const out = await client.getAccountVault();
215
+ return {
216
+ identity: { accountId: out.accountId, vaultServiceId: out.vaultServiceId },
217
+ version: out.version,
218
+ };
219
+ }
220
+ /**
221
+ * The account's first write: mint the vault identity, the data key, and the one
222
+ * slot the recovery key opens.
223
+ *
224
+ * Returns the VDK. The caller needs it to save this machine's device slot, and
225
+ * deriving it again would mean running Argon2id a second time for a value that
226
+ * is already in hand. It is a Buffer in memory for the length of one command and
227
+ * is never written anywhere by this module.
228
+ */
229
+ export async function createVault(client, identity, recoveryKey, content) {
230
+ const vaultId = randomUUID();
231
+ // One salt for the vault's whole life, so one recovery key. It is written into
232
+ // both the slot header and the version's kdf_meta from this one variable —
233
+ // they cannot disagree because there is nothing here that could make them.
234
+ const meta = { ...DEFAULT_KDF, salt: newSalt() };
235
+ const vdk = newVdk();
236
+ const kek = await deriveRecoveryKek(recoveryKey, meta);
237
+ const header = {
238
+ slot_id: randomUUID(),
239
+ slot_type: 'recovery-key',
240
+ wrap_profile: RECOVERY_SLOT_WRAP_PROFILE,
241
+ kdf_profile: meta.kdf,
242
+ kdf_params: { m: meta.m, t: meta.t, p: meta.p },
243
+ salt: meta.salt,
244
+ };
245
+ const slot = {
246
+ ...header,
247
+ wrapped_vdk: wrapVdk(kek, vdk, slotContext(vaultId, INITIAL_RECOVERY_GENERATION, header)).toString('base64'),
248
+ };
249
+ const wrapped = wrapWithVdk(vdk, content, contextFor(identity, vaultId, WRAP_PROFILE, 1), meta);
250
+ const result = await client.putAccountVault({
251
+ vault_id: vaultId,
252
+ wrap_profile: WRAP_PROFILE,
253
+ ciphertext: wrapped.toString('base64'),
254
+ kdf_meta: meta,
255
+ operation_id: randomUUID(),
256
+ vdk_wraps: [slot],
257
+ });
258
+ assertLanded(1, result.revision);
259
+ assertGeneration(INITIAL_RECOVERY_GENERATION, result.recovery_generation);
260
+ // The generation is returned, not left for the caller to write as 1. The
261
+ // caller's next move is to address this machine's local slot with it, and a
262
+ // second place that says "a new vault is generation 1" is a second place that
263
+ // has to be found when re-issuance makes it false.
264
+ //
265
+ // The SERVER's value, after the assert above proved it is the one this slot
266
+ // was wrapped for. Returning the constant instead would satisfy "no default
267
+ // in the caller" while quietly making this the second place — which is the
268
+ // thing the sentence above claims not to do.
269
+ return {
270
+ revision: result.revision,
271
+ vaultId,
272
+ recoveryGeneration: result.recovery_generation,
273
+ vdk,
274
+ };
275
+ }
276
+ // The generation is bound into the slot's AEAD but is the server's to assign, so
277
+ // the one it recorded is checked against the one the slot was wrapped for. A
278
+ // mismatch means the slot cannot ever unwrap the VDK, and the recovery key just
279
+ // shown to a person opens nothing — a failure that would otherwise surface at
280
+ // restore time, which is the one moment there is no way back.
281
+ function assertGeneration(expected, actual) {
282
+ if (actual !== expected) {
283
+ throw new Error(`server recorded recovery generation ${actual} where the slot was wrapped for ` +
284
+ `${expected}; the recovery key would not open this vault and it must be rewritten`);
285
+ }
286
+ }
287
+ /**
288
+ * Append a version, given the data key.
289
+ *
290
+ * This is where the check that the key is *this vault's* key lives, and it stays
291
+ * on this side rather than in the shell below: a device slot opening is evidence
292
+ * about the slot, not about the vault. Both routes to a VDK therefore pass it.
293
+ */
294
+ export async function appendVaultVersionWithVdk(client, identity, existing, vdk, content) {
295
+ const meta = usableMeta(existing);
296
+ // Prove the key opens what is already stored before writing anything. A VDK
297
+ // that is not this vault's is perfectly valid as a key, and appending under it
298
+ // would store a version the real recovery key cannot open — the failure would
299
+ // surface at restore time, which is exactly the moment there is no way back.
300
+ // Opening the version we already fetched is the only check available, and it
301
+ // costs nothing.
302
+ try {
303
+ unwrapWithVdk(vdk, Buffer.from(existing.ciphertext, 'base64'), contextFor(identity, existing.vault_id, existing.wrap_profile, existing.revision), meta);
304
+ }
305
+ catch {
306
+ throw new WrongRecoveryKeyError('that key does not open this vault — nothing was uploaded. ' +
307
+ 'A version stored under the wrong key would be unopenable at restore time.');
308
+ }
309
+ const next = existing.revision + 1;
310
+ const wrapped = wrapWithVdk(vdk, content, contextFor(identity, existing.vault_id, existing.wrap_profile, next), meta);
311
+ const result = await client.putAccountVault({
312
+ vault_id: existing.vault_id,
313
+ wrap_profile: existing.wrap_profile,
314
+ ciphertext: wrapped.toString('base64'),
315
+ kdf_meta: meta,
316
+ operation_id: randomUUID(),
317
+ if_match: existing.etag,
318
+ // No vdk_wraps. An append re-seals the same bundle under the same VDK, so
319
+ // the slots that wrap that VDK are unchanged, and the server rejects a body
320
+ // that says otherwise.
321
+ });
322
+ assertLanded(next, result.revision);
323
+ return { revision: result.revision };
324
+ }
325
+ /** Open a version, given the data key. */
326
+ export function openVaultWithVdk(identity, version, vdk) {
327
+ const meta = usableMeta(version);
328
+ let content;
329
+ try {
330
+ content = unwrapWithVdk(vdk, Buffer.from(version.ciphertext, 'base64'), contextFor(identity, version.vault_id, version.wrap_profile, version.revision), meta);
331
+ }
332
+ catch {
333
+ // One message for a wrong key, for tampering, and for a rolled-back version:
334
+ // we genuinely cannot tell them apart, and guessing which it was would be a
335
+ // claim we cannot support.
336
+ throw new WrongRecoveryKeyError('could not open the vault: the key is wrong, or the stored version ' +
337
+ 'does not match what this account, vault, and revision were sealed against');
338
+ }
339
+ return { content, revision: version.revision };
340
+ }
341
+ // Later writes, from the recovery key. The shell: get the VDK out of the slot,
342
+ // then do the real thing. A caller with a device slot calls the real thing
343
+ // directly and never asks for the key.
344
+ export async function appendVaultVersion(client, identity, existing, recoveryKey, content) {
345
+ const vdk = await vdkFromRecoveryKey(existing, recoveryKey);
346
+ return appendVaultVersionWithVdk(client, identity, existing, vdk, content);
347
+ }
348
+ // Pull the current version and open it, from the recovery key. The revision the
349
+ // server calls current is bound into the AAD, so replaying an older ciphertext
350
+ // under a newer revision number fails the tag rather than quietly restoring a
351
+ // superseded key bundle.
352
+ // It no longer takes a client. Opening is now entirely local — the version was
353
+ // already fetched — and a parameter that is accepted and never used reads as a
354
+ // dependency this function has, which the next caller would then arrange for.
355
+ export async function openCurrentVault(identity, version, recoveryKey) {
356
+ const vdk = await vdkFromRecoveryKey(version, recoveryKey);
357
+ return openVaultWithVdk(identity, version, vdk);
358
+ }