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.
- package/README.md +39 -2
- package/dist/src/api.js +517 -0
- package/dist/src/authenticated-digest.js +234 -0
- package/dist/src/browser.js +241 -0
- package/dist/src/ceremony.js +181 -0
- package/dist/src/commands/approve.js +392 -0
- package/dist/src/commands/connect.js +273 -0
- package/dist/src/commands/fetch.js +249 -0
- package/dist/src/commands/login.js +334 -0
- package/dist/src/commands/logout.js +74 -0
- package/dist/src/commands/pull.js +80 -0
- package/dist/src/commands/request.js +371 -0
- package/dist/src/commands/sync.js +138 -0
- package/dist/src/commands/vault.js +478 -0
- package/dist/src/commands/watch.js +47 -0
- package/dist/src/config.js +34 -0
- package/dist/src/credentials.js +374 -0
- package/dist/src/device-slot.js +148 -0
- package/dist/src/filelock.js +167 -0
- package/dist/src/index.js +242 -0
- package/dist/src/ledger.js +296 -0
- package/dist/src/machine-name.js +90 -0
- package/dist/src/oss-env.js +49 -0
- package/dist/src/oss.js +289 -0
- package/dist/src/paths.js +8 -0
- package/dist/src/pending.js +330 -0
- package/dist/src/pick-request.js +56 -0
- package/dist/src/preflight.js +257 -0
- package/dist/src/recovery-key.js +386 -0
- package/dist/src/sas.js +18 -0
- package/dist/src/secure-store.js +176 -0
- package/dist/src/shell-arg.js +18 -0
- package/dist/src/slot-advice.js +74 -0
- package/dist/src/vault-container.js +115 -0
- package/dist/src/vault-crypto.js +190 -0
- package/dist/src/vault-protocol.js +358 -0
- package/dist/src/version.js +57 -0
- package/node_modules/@agmsg-cloud/sas-core/dist/src/bech32.d.ts +17 -0
- package/node_modules/@agmsg-cloud/sas-core/dist/src/bech32.js +103 -0
- package/node_modules/@agmsg-cloud/sas-core/dist/src/index.d.ts +17 -0
- package/node_modules/@agmsg-cloud/sas-core/dist/src/index.js +147 -0
- package/node_modules/@agmsg-cloud/sas-core/package.json +30 -0
- package/package.json +50 -7
- 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
|
+
}
|