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
package/README.md
CHANGED
|
@@ -1,4 +1,41 @@
|
|
|
1
1
|
# agmsg-cloud
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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.
|
package/dist/src/api.js
ADDED
|
@@ -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
|
+
}
|