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
package/README.md CHANGED
@@ -1,4 +1,41 @@
1
1
  # agmsg-cloud
2
2
 
3
- This package name is reserved for the agmsg cloud companion CLI.
4
- Nothing to see here yet.
3
+ The companion CLI for the agmsg cloud service. It signs a machine in, puts a
4
+ team you already run onto the service, and joins that team from a second
5
+ machine — with the key handoff confirmed by a code the two people compare out
6
+ loud.
7
+
8
+ This is a prerelease. It is published under the `next` tag, so it is not what
9
+ `npm install agmsg-cloud` gives you:
10
+
11
+ ```
12
+ npm install -g agmsg-cloud@next
13
+ agmsg-cloud # prints what it can do
14
+ agmsg-cloud version # which build this is
15
+ ```
16
+
17
+ Node 22 or newer.
18
+
19
+ ## What it does
20
+
21
+ ```
22
+ agmsg-cloud login sign this machine in
23
+ agmsg-cloud connect <team> put a team this machine runs onto the service
24
+ agmsg-cloud sync <team> (on a new machine) ask to join, and wait
25
+ agmsg-cloud approve <team> (on a machine that has the keys) answer that request
26
+ agmsg-cloud recovery setup create the account's recovery key and back up every
27
+ active team this machine's store reports
28
+
29
+ ```
30
+
31
+ `sync` and `approve` are two halves of one ceremony: each screen shows eight
32
+ digits, the two people read them to each other, and the keys move only if they
33
+ match. Nothing is carried between the machines by hand.
34
+
35
+ ## Where it lives
36
+
37
+ Home: <https://agmsg.ai>. The open-source project it builds on is
38
+ [agmsg](https://agmsg.cc).
39
+
40
+ Issues and source: <https://github.com/JugemuAI/agmsg-cloud> — the repository is
41
+ private during the prerelease.
@@ -0,0 +1,517 @@
1
+ import { isCanonicalAgeRecipient } from '@agmsg-cloud/sas-core';
2
+ // Wire validation.
3
+ //
4
+ // Every response is checked here, at the boundary, rather than cast and trusted.
5
+ // The reason is one specific failure repeated three times in this arc: a body
6
+ // that is not what it claims arrives as `undefined` somewhere downstream, and
7
+ // downstream cannot tell "malformed" from "absent". In the reconciliation path
8
+ // that difference decides whether a live enrollment is abandoned and its nonce
9
+ // deleted, so a body this client cannot vouch for has to be an error, not an
10
+ // empty answer.
11
+ //
12
+ // Validated as a CLASS, not where the bug was found. Checking only the endpoint
13
+ // that happened to be reported leaves the same hole in every sibling.
14
+ const HEX64 = /^[0-9a-f]{64}$/;
15
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
16
+ const STATUSES = [
17
+ 'requester_committed',
18
+ 'both_committed',
19
+ 'requester_opened',
20
+ 'opened',
21
+ 'consumed',
22
+ 'expired',
23
+ 'failed',
24
+ 'refused',
25
+ ];
26
+ // Ranges, not types.
27
+ //
28
+ // `typeof x === 'string'` is a statement about JavaScript; what each of these
29
+ // fields has to be is narrower, and a value that is the right TYPE with the
30
+ // wrong MEANING passes straight through a type check into whatever reads it.
31
+ // The revision that prompted this is the clearest case — a number, but 3.5 —
32
+ // and the same gap was sitting in every neighbouring field.
33
+ const BASE64 = /^[A-Za-z0-9+/]*={0,2}$/;
34
+ function isTimestamp(value) {
35
+ if (typeof value !== 'string')
36
+ return false;
37
+ return Number.isFinite(Date.parse(value));
38
+ }
39
+ // Exported for the one caller that has to check an id the wire never carried:
40
+ // `pull --team-id` is handed straight to the OSS side without passing this
41
+ // module's response validation at all.
42
+ export function isUuid(value) {
43
+ return typeof value === 'string' && UUID.test(value);
44
+ }
45
+ function isRecipient(value) {
46
+ return typeof value === 'string' && isCanonicalAgeRecipient(value);
47
+ }
48
+ function isBase64(value) {
49
+ return typeof value === 'string' && value.length % 4 === 0 && BASE64.test(value);
50
+ }
51
+ // Non-empty and bounded. An unbounded label is a display surface fed by the
52
+ // server; a zero-length one is not a label.
53
+ function isLabel(value) {
54
+ return typeof value === 'string' && value.length > 0 && value.length <= 256;
55
+ }
56
+ function isObject(value) {
57
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
58
+ }
59
+ function optionalHex(value) {
60
+ return value === null || (typeof value === 'string' && HEX64.test(value));
61
+ }
62
+ function isTranscript(value) {
63
+ if (!isObject(value))
64
+ return false;
65
+ if (!isUuid(value['id']))
66
+ return false;
67
+ if (!STATUSES.includes(value['status']))
68
+ return false;
69
+ if (!isLabel(value['label']))
70
+ return false;
71
+ if (typeof value['commitment'] !== 'string' || !HEX64.test(value['commitment']))
72
+ return false;
73
+ if (!optionalHex(value['approver_commitment']))
74
+ return false;
75
+ if (!optionalHex(value['opening_nonce']))
76
+ return false;
77
+ if (!optionalHex(value['approver_nonce']))
78
+ return false;
79
+ if (!optionalHex(value['request_nonce']))
80
+ return false;
81
+ if (!optionalHex(value['handoff_digest']))
82
+ return false;
83
+ // The device key is the value the whole ceremony is about. Accepting any
84
+ // string here would let a non-recipient reach the commitment check and fail
85
+ // there instead — a permanent failure for what is really a malformed reply.
86
+ if (value['device_pubkey'] !== null && !isRecipient(value['device_pubkey'])) {
87
+ return false;
88
+ }
89
+ if (!isTimestamp(value['created_at']) || !isTimestamp(value['expires_at']))
90
+ return false;
91
+ return true;
92
+ }
93
+ // The Argon2id parameters the recovery key is derived with. Numbers that arrive
94
+ // as strings, or a missing cost, would otherwise reach the derivation and change
95
+ // what key comes out.
96
+ // A revision the append path can add one to and still be exact.
97
+ //
98
+ // Checked as a safe INTEGER, not merely as a number. The current revision is
99
+ // read here and sealed into the AAD as `existing.revision + 1`; a wire value of
100
+ // 3.5 becomes 4.5 in the AAD, the server stores the integer 4, and the mismatch
101
+ // is only noticed after a version nobody can open has been written. The safe
102
+ // bound is what makes the `+ 1` exact rather than approximate.
103
+ function isRevision(value) {
104
+ // The SUCCESSOR has to be safe, not just the value. MAX_SAFE_INTEGER passes
105
+ // Number.isSafeInteger and then `+ 1` lands on 2^53, where distinct integers
106
+ // stop being distinct — so a later mismatch check could compare two different
107
+ // revisions and find them equal. The comment above used to claim this
108
+ // property while the predicate did not enforce it.
109
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 1 && Number.isSafeInteger(value + 1);
110
+ }
111
+ // A wrap slot off the wire. Every field is stored by this client into an AEAD's
112
+ // additional data or fed to the KDF, so each is checked for its kind rather than
113
+ // for being present: a cost that arrives as a string changes what key comes out,
114
+ // and a slot_type this version does not know is a slot it must not try to open.
115
+ //
116
+ // 'recovery-key' is the only type. The device slot is not one of these: it lives
117
+ // in this machine's secure store, never on the server (:528-530). A response
118
+ // carrying one is either a server that stored what it must not, or a response
119
+ // this client should not be following — both are malformed here.
120
+ function isVdkWrap(value) {
121
+ if (!isObject(value))
122
+ return false;
123
+ if (!isUuid(value['slot_id']))
124
+ return false;
125
+ if (value['slot_type'] !== 'recovery-key')
126
+ return false;
127
+ if (typeof value['wrap_profile'] !== 'string' || value['wrap_profile'].length === 0)
128
+ return false;
129
+ if (typeof value['kdf_profile'] !== 'string' || value['kdf_profile'].length === 0)
130
+ return false;
131
+ if (typeof value['salt'] !== 'string' || value['salt'].length === 0)
132
+ return false;
133
+ if (!isBase64(value['wrapped_vdk']))
134
+ return false;
135
+ const params = value['kdf_params'];
136
+ if (!isObject(params))
137
+ return false;
138
+ for (const cost of ['m', 't', 'p']) {
139
+ const n = params[cost];
140
+ if (typeof n !== 'number' || !Number.isInteger(n) || n <= 0)
141
+ return false;
142
+ }
143
+ return true;
144
+ }
145
+ function isKdfMeta(value) {
146
+ if (!isObject(value))
147
+ return false;
148
+ if (typeof value['kdf'] !== 'string' || typeof value['salt'] !== 'string')
149
+ return false;
150
+ for (const cost of ['m', 't', 'p']) {
151
+ const n = value[cost];
152
+ if (typeof n !== 'number' || !Number.isInteger(n) || n <= 0)
153
+ return false;
154
+ }
155
+ return true;
156
+ }
157
+ function requireTranscript(value) {
158
+ if (!isTranscript(value))
159
+ throw new CourierError(200, 'malformed_enrollment');
160
+ return value;
161
+ }
162
+ export class CourierError extends Error {
163
+ status;
164
+ code;
165
+ constructor(status, code) {
166
+ super(`courier request failed: ${status} ${code}`);
167
+ this.status = status;
168
+ this.code = code;
169
+ this.name = 'CourierError';
170
+ }
171
+ }
172
+ export class CourierClient {
173
+ baseUrl;
174
+ secret;
175
+ fetchImpl;
176
+ constructor(opts) {
177
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, '');
178
+ this.secret = opts.secret;
179
+ this.fetchImpl = opts.fetchImpl ?? fetch;
180
+ }
181
+ async call(method, path, body) {
182
+ const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
183
+ method,
184
+ headers: {
185
+ authorization: `Bearer ${this.secret}`,
186
+ ...(body === undefined ? {} : { 'content-type': 'application/json' }),
187
+ },
188
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
189
+ });
190
+ if (res.status === 204)
191
+ return undefined;
192
+ if (!res.ok) {
193
+ let code = 'unknown';
194
+ try {
195
+ const parsed = (await res.json());
196
+ if (typeof parsed.error === 'string')
197
+ code = parsed.error;
198
+ }
199
+ catch {
200
+ // no/'' JSON body — keep the generic code, never echo the raw response
201
+ }
202
+ throw new CourierError(res.status, code);
203
+ }
204
+ return res.status === 200 || res.status === 201 ? res.json() : undefined;
205
+ }
206
+ // B: ask to be added, carrying ONLY the commitment digest and a label. The
207
+ // device key and the opening nonce are deliberately absent — a request that
208
+ // held them before A has committed is what would let A choose its own
209
+ // contribution to match.
210
+ async createEnrollment(input) {
211
+ const out = await this.call('POST', '/v1/enrollments', input);
212
+ if (!isObject(out) ||
213
+ !isUuid(out['request_id']) ||
214
+ !isTimestamp(out['expires_at'])) {
215
+ throw new CourierError(200, 'malformed_create_response');
216
+ }
217
+ return { request_id: out['request_id'], expires_at: out['expires_at'] };
218
+ }
219
+ // The live ceremonies this caller may drive: the org's, for a key holder;
220
+ // its own, for anyone else.
221
+ //
222
+ // The shape is checked rather than asserted. A cast turns a body that is not
223
+ // what it claims into `undefined` and then into a TypeError several frames
224
+ // away, where a caller trying to decide "did my request reach the server?"
225
+ // cannot tell a malformed answer from any other kind of failure — and that
226
+ // difference decides whether a live enrollment is abandoned.
227
+ async listEnrollments() {
228
+ const out = await this.call('GET', '/v1/enrollments');
229
+ if (!isObject(out) || !Array.isArray(out['enrollments'])) {
230
+ throw new CourierError(200, 'malformed_enrollments_response');
231
+ }
232
+ // ONE malformed row fails the whole list. A caller asking "is my request
233
+ // still live?" reads a short list as an answer, and silently dropping the
234
+ // rows we could not parse would make that answer wrong in the one direction
235
+ // that costs a live enrollment.
236
+ return out['enrollments'].map(requireTranscript);
237
+ }
238
+ // Either side: read one ceremony. B polls this to learn A's commitment, which
239
+ // it MUST have before opening its own.
240
+ async getEnrollment(requestId) {
241
+ return requireTranscript(await this.call('GET', `/v1/enrollments/${encodeURIComponent(requestId)}`));
242
+ }
243
+ // A: fix a commitment to its nonce, without revealing it.
244
+ /**
245
+ * Ask the server to settle which request this approver is answering, when it
246
+ * was given no id.
247
+ *
248
+ * Called BEFORE an attempt is consumed and before any commitment is built.
249
+ * §4.1 charges the send rather than the reply, so a "there are two" discovered
250
+ * at commitment time is discovered too late: it could only be given back by
251
+ * the local ledger believing the server's account of what it did, and the
252
+ * server is inside the threat model.
253
+ */
254
+ async claimSoleLiveRequest() {
255
+ const body = (await this.call('POST', '/v1/enrollments/claim', {}));
256
+ // The same check the create response gets. Not a security boundary — the
257
+ // claim is enforced server-side — but it is the same wire surface, and one
258
+ // field validated more weakly than its neighbours is how a second, looser
259
+ // spelling of a timestamp gets in (raised in review).
260
+ if (typeof body.request_id !== 'string' || !isTimestamp(body.claim_expires_at)) {
261
+ throw new Error('courier returned a claim without a request id');
262
+ }
263
+ return { requestId: body.request_id, claimExpiresAt: body.claim_expires_at };
264
+ }
265
+ async submitApproverCommitment(requestId, approverCommitment,
266
+ // True when this approver named no request id and had the server settle
267
+ // which one this is, before these bytes were built. It asks the server to
268
+ // check that claim is still ours and still live.
269
+ requireSoleLiveRequest = false) {
270
+ return requireTranscript(await this.call('POST', `/v1/enrollments/${encodeURIComponent(requestId)}/approver-commitment`, {
271
+ approver_commitment: approverCommitment,
272
+ ...(requireSoleLiveRequest ? { require_sole_live_request: true } : {}),
273
+ }));
274
+ }
275
+ // B: reveal the device key and the nonce it committed to.
276
+ async submitOpening(requestId, input) {
277
+ return requireTranscript(await this.call('POST', `/v1/enrollments/${encodeURIComponent(requestId)}/opening`, input));
278
+ }
279
+ // A: reveal its nonce, after verifying B's opening.
280
+ async submitApproverOpening(requestId, approverNonce,
281
+ // Sent WITH the nonce, because the commitment is over both. The server
282
+ // cannot verify the opening without it, and B cannot derive the SAS
283
+ // without it (spec 3.3).
284
+ handoffDigest) {
285
+ return requireTranscript(await this.call('POST', `/v1/enrollments/${encodeURIComponent(requestId)}/approver-opening`, {
286
+ approver_nonce: approverNonce,
287
+ handoff_digest: handoffDigest,
288
+ }));
289
+ }
290
+ // Either side: report that the ceremony failed — a code mismatch, a
291
+ // cancellation, a timeout noticed locally. The attempt still counts; that is
292
+ // the point of reporting it rather than walking away.
293
+ async abortEnrollment(requestId) {
294
+ await this.call('DELETE', `/v1/enrollments/${encodeURIComponent(requestId)}`);
295
+ }
296
+ // A: approve by uploading the sealed bundle. operation_id is the idempotency key.
297
+ async uploadBundle(requestId, input) {
298
+ const out = await this.call('POST', `/v1/enrollments/${encodeURIComponent(requestId)}/bundle`, input);
299
+ if (!isObject(out) || !isUuid(out['device_id'])) {
300
+ throw new CourierError(200, 'malformed_bundle_response');
301
+ }
302
+ return { device_id: out['device_id'] };
303
+ }
304
+ // A: the rotate recipient set (every registered device's public key).
305
+ // Register THIS machine as the team's first device. Only the capability that
306
+ // claimed the team may do it.
307
+ //
308
+ // Success covers the re-run: the server answers 201 both when it creates the
309
+ // row and when this capability was already registered under this same key,
310
+ // because in both cases what the caller asked for is true. It distinguishes
311
+ // the two failures that used to share one code — this capability holding a
312
+ // different key, and this key belonging to another capability — which no
313
+ // client can tell apart from the outside.
314
+ async bootstrapDevice(input) {
315
+ const out = (await this.call('POST', '/v1/devices', input));
316
+ return { deviceId: out.device_id };
317
+ }
318
+ // Which team is called X, asked of the CONTROL plane.
319
+ //
320
+ // The core has a name lookup and the gateway keeps it closed on purpose: it
321
+ // answers across every team on the server, so forwarding it would tell one
322
+ // org that another org's team exists. The control plane knows whose team is
323
+ // whose, so it can answer inside the caller's org — which is why the second
324
+ // machine resolves the name here and pulls by id, rather than handing a
325
+ // name to the data plane.
326
+ async resolveTeamByName(name) {
327
+ const out = await this.call('GET', `/v1/edge/teams?name=${encodeURIComponent(name)}`);
328
+ // A response this cannot read is not an answer about which teams exist,
329
+ // and it must not be turned into one. `teams` must be PRESENT and an
330
+ // array, with an empty array the only way to say "none" — the same rule
331
+ // the vault boundary above follows, for the same reason: absence here is
332
+ // a normal state that the caller acts on, and an unreadable 200 collapsed
333
+ // into it becomes the assertion "this organization has no team by that
334
+ // name".
335
+ if (!isObject(out) || !Array.isArray(out['teams'])) {
336
+ throw new CourierError(200, 'malformed_teams_response');
337
+ }
338
+ // Every member, or none. Dropping a malformed row would be worse than
339
+ // dropping the whole response: `pull` refuses when a name matches more
340
+ // than one team, so a single unreadable row beside a valid one would
341
+ // reduce the ambiguous set to one and pull whichever row survived —
342
+ // exactly the case the refusal exists to prevent, reached with wire data
343
+ // nobody could parse.
344
+ for (const t of out['teams']) {
345
+ if (!isObject(t) ||
346
+ !isUuid(t['team_id']) ||
347
+ !(typeof t['team_name'] === 'string' || t['team_name'] === null)) {
348
+ throw new CourierError(200, 'malformed_teams_response');
349
+ }
350
+ }
351
+ return out['teams'].map((t) => ({
352
+ teamId: t.team_id,
353
+ teamName: t.team_name,
354
+ }));
355
+ }
356
+ async listDevices() {
357
+ const out = await this.call('GET', '/v1/devices');
358
+ if (!isObject(out) || !Array.isArray(out['devices'])) {
359
+ throw new CourierError(200, 'malformed_devices_response');
360
+ }
361
+ for (const d of out['devices']) {
362
+ // `devicePubkey`, not `device_pubkey`: the server maps its columns before
363
+ // responding. The validator and the interface both named the column, so
364
+ // every real response was rejected as malformed — which nothing noticed,
365
+ // because nothing had ever called this against a live server.
366
+ if (!isObject(d) || !isRecipient(d['devicePubkey']) || !isUuid(d['id'])) {
367
+ throw new CourierError(200, 'malformed_devices_response');
368
+ }
369
+ // `thisMachine` DECIDES A REFUSAL, so it is checked like one.
370
+ //
371
+ // Absent or boolean, nothing else. `"true"`, `null` and `0` are all
372
+ // truthy-or-falsy in some reading, and any of them would make the
373
+ // pre-flight below treat a row as "not this machine" while ALSO treating
374
+ // the response as coming from a server that answers the question — so an
375
+ // ordinary rejoin would be refused locally, without a single request
376
+ // reaching the server that could have corrected it.
377
+ //
378
+ // Refused here rather than coerced: this value gates a decision no other
379
+ // check re-derives, and the pre-flight's own catch turns a malformed
380
+ // response into "go ahead and run the ceremony", which is the safe
381
+ // direction (raised in review).
382
+ if (d['thisMachine'] !== undefined && typeof d['thisMachine'] !== 'boolean') {
383
+ throw new CourierError(200, 'malformed_devices_response');
384
+ }
385
+ }
386
+ return out['devices'];
387
+ }
388
+ // B: fetch the blobs sealed to this device.
389
+ async fetchBlobs() {
390
+ const out = await this.call('GET', '/v1/courier');
391
+ if (!isObject(out) || !Array.isArray(out['blobs'])) {
392
+ throw new CourierError(200, 'malformed_courier_response');
393
+ }
394
+ for (const b of out['blobs']) {
395
+ if (!isObject(b) ||
396
+ !isUuid(b['id']) ||
397
+ !isUuid(b['operation_id']) ||
398
+ // Decoded and sealed to this device's key; a non-base64 body would be
399
+ // decoded to something shorter and handed to age.
400
+ !isBase64(b['ciphertext'])) {
401
+ throw new CourierError(200, 'malformed_courier_response');
402
+ }
403
+ }
404
+ return out['blobs'];
405
+ }
406
+ // B: ack a stored blob (deletes it server-side).
407
+ async ackBlob(blobId) {
408
+ await this.call('DELETE', `/v1/courier/${encodeURIComponent(blobId)}`);
409
+ }
410
+ // The account's vault, plus the two identifiers the client cannot derive.
411
+ //
412
+ // `vault` is null when the account has none yet — a normal first run, not an
413
+ // error: the identity fields are exactly what a create needs, so answering
414
+ // 404 would leave the client with nothing to seal against.
415
+ //
416
+ // Checked rather than cast, like getVault below and for the same reason:
417
+ // every field here is attacker-controlled if the server is, and account_id
418
+ // and vault_service_id go straight into the AEAD's additional data.
419
+ async getAccountVault() {
420
+ const out = await this.call('GET', '/v1/vault');
421
+ // Both identifiers are UUIDs and both go straight into the AEAD's
422
+ // additional data, so "a non-empty string" is not the check. A response
423
+ // that got this far with something else in these fields is malformed, and
424
+ // sealing under it would produce a vault that opens only for whatever sent
425
+ // it.
426
+ if (!isObject(out) || !isUuid(out['account_id']) || !isUuid(out['vault_service_id'])) {
427
+ throw new CourierError(200, 'malformed_vault_response');
428
+ }
429
+ const identity = {
430
+ accountId: out['account_id'],
431
+ vaultServiceId: out['vault_service_id'],
432
+ };
433
+ // `vault` must be PRESENT, with null the only way to say "none". Accepting
434
+ // a missing field as "no vault yet" turns a malformed 200 into a first run:
435
+ // the client would create a vault, under an identity it was handed by a
436
+ // response it could not parse, over whatever is actually stored.
437
+ if (!('vault' in out))
438
+ throw new CourierError(200, 'malformed_vault_response');
439
+ const vault = out['vault'];
440
+ if (vault === null)
441
+ return { ...identity, version: null };
442
+ if (!isObject(vault) ||
443
+ !isUuid(vault['vault_id']) ||
444
+ !isRevision(vault['revision']) ||
445
+ typeof vault['wrap_profile'] !== 'string' ||
446
+ typeof vault['etag'] !== 'string' ||
447
+ !isBase64(vault['ciphertext']) ||
448
+ !isKdfMeta(vault['kdf_meta']) ||
449
+ // Checked with the same predicate as `revision`, and for a related
450
+ // reason: the generation is compared and carried, and a value that is not
451
+ // a safe integer stops behaving like the number it is printed as. There
452
+ // is no fallback for an absent one — the server's column is NOT NULL, so
453
+ // a response without it is malformed, and treating it as generation 1
454
+ // would let a server that omits the field address this machine's slots.
455
+ !isRevision(vault['recovery_generation']) ||
456
+ // The list may be empty on a vault created before the wrap list existed.
457
+ // That is a real state of the stored data, and refusing it here would
458
+ // report "the server is malformed" for a vault the server stored
459
+ // correctly. What cannot be opened is decided where the opening happens.
460
+ !Array.isArray(vault['vdk_wraps']) ||
461
+ !vault['vdk_wraps'].every(isVdkWrap)) {
462
+ throw new CourierError(200, 'malformed_vault_response');
463
+ }
464
+ return { ...identity, version: vault };
465
+ }
466
+ // Store a wrapped version of the ACCOUNT's vault. Same contract as putVault
467
+ // below, minus the team: there is one vault per account now.
468
+ async putAccountVault(input) {
469
+ const out = await this.call('PUT', '/v1/vault', input);
470
+ if (!isObject(out) ||
471
+ !isRevision(out['revision']) ||
472
+ typeof out['etag'] !== 'string' ||
473
+ // Required, with no default. The caller compares this against the
474
+ // generation it bound into the slot it just uploaded, and a missing value
475
+ // filled in as 1 would make that comparison pass by construction — it
476
+ // would check the client's own assumption rather than what was recorded.
477
+ !isRevision(out['recovery_generation'])) {
478
+ throw new CourierError(200, 'malformed_vault_response');
479
+ }
480
+ return {
481
+ revision: out['revision'],
482
+ etag: out['etag'],
483
+ recovery_generation: out['recovery_generation'],
484
+ };
485
+ }
486
+ // Store a wrapped recovery version. Omit if_match to create the vault; pass the
487
+ // etag of the version you read to append. operation_id is the idempotency key —
488
+ // reusing it with a different payload is a 409, never a silent overwrite.
489
+ async putVault(teamId, input) {
490
+ const out = await this.call('PUT', `/v1/vault/${encodeURIComponent(teamId)}`, input);
491
+ if (!isObject(out) || !isRevision(out['revision']) || typeof out['etag'] !== 'string') {
492
+ throw new CourierError(200, 'malformed_vault_response');
493
+ }
494
+ return { revision: out['revision'], etag: out['etag'] };
495
+ }
496
+ // The current wrapped version. Device-approved only; the ciphertext is opaque
497
+ // to the server and every field here is attacker-controlled if the server is.
498
+ //
499
+ // Which is why it is checked rather than cast. The comment above already says
500
+ // the fields are attacker-controlled — a cast makes that sentence advisory. A
501
+ // missing revision or etag would flow into the append path as undefined and be
502
+ // compared as one, and a kdf_meta that is not an object would reach the key
503
+ // derivation as whatever it happens to be.
504
+ async getVault(teamId) {
505
+ const out = await this.call('GET', `/v1/vault/${encodeURIComponent(teamId)}`);
506
+ if (!isObject(out) ||
507
+ typeof out['vault_id'] !== 'string' ||
508
+ !isRevision(out['revision']) ||
509
+ typeof out['wrap_profile'] !== 'string' ||
510
+ typeof out['etag'] !== 'string' ||
511
+ !isBase64(out['ciphertext']) ||
512
+ !isKdfMeta(out['kdf_meta'])) {
513
+ throw new CourierError(200, 'malformed_vault_response');
514
+ }
515
+ return out;
516
+ }
517
+ }