@imagina-build/ias-cli 0.0.0-stage → 0.1.0

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 (78) hide show
  1. package/README.md +406 -2
  2. package/bin/ias.mjs +19 -0
  3. package/package.json +32 -5
  4. package/schema/ias.schema.json +535 -0
  5. package/shared/credentials/cms.mjs +105 -0
  6. package/shared/credentials/index.mjs +354 -0
  7. package/shared/credentials/plist.mjs +93 -0
  8. package/shared/vault/approvers-vectors.mjs +188 -0
  9. package/shared/vault/approvers.mjs +234 -0
  10. package/shared/vault/index.mjs +583 -0
  11. package/shared/vault/plaintext.mjs +115 -0
  12. package/shared/vault/test-vectors/ias-vault-2/approvers.json +2122 -0
  13. package/shared/vault/test-vectors/ias-vault-2/keys.json +43 -0
  14. package/shared/vault/test-vectors/ias-vault-2/malformed.json +1739 -0
  15. package/shared/vault/test-vectors/ias-vault-2/plaintexts.json +149 -0
  16. package/shared/vault/test-vectors/ias-vault-2/runners.json +580 -0
  17. package/shared/vault/test-vectors/ias-vault-2/sealed.json +567 -0
  18. package/shared/vault/test-vectors/ias-vault-2/tampered.json +989 -0
  19. package/shared/vault/test-vectors/keys.json +31 -0
  20. package/shared/vault/test-vectors/malformed.json +1341 -0
  21. package/shared/vault/test-vectors/sealed.json +384 -0
  22. package/shared/vault/test-vectors/tampered.json +610 -0
  23. package/shared/vault/vectors.mjs +720 -0
  24. package/src/cli.mjs +231 -0
  25. package/src/commands/apps/index.mjs +36 -0
  26. package/src/commands/auth/index.mjs +141 -0
  27. package/src/commands/build/follow.mjs +168 -0
  28. package/src/commands/build/index.mjs +110 -0
  29. package/src/commands/build/local.mjs +69 -0
  30. package/src/commands/build/profile.mjs +50 -0
  31. package/src/commands/build/source.mjs +71 -0
  32. package/src/commands/builds/index.mjs +148 -0
  33. package/src/commands/credentials/index.mjs +256 -0
  34. package/src/commands/credentials/material.mjs +159 -0
  35. package/src/commands/devices/asc.mjs +116 -0
  36. package/src/commands/devices/index.mjs +65 -0
  37. package/src/commands/devices/sync.mjs +223 -0
  38. package/src/commands/dist/index.mjs +108 -0
  39. package/src/commands/doctor/index.mjs +158 -0
  40. package/src/commands/env/index.mjs +163 -0
  41. package/src/commands/github/index.mjs +53 -0
  42. package/src/commands/init/index.mjs +224 -0
  43. package/src/commands/migrate/credentials.mjs +213 -0
  44. package/src/commands/migrate/eas-cli.mjs +153 -0
  45. package/src/commands/migrate/index.mjs +73 -0
  46. package/src/commands/migrate/numbers.mjs +85 -0
  47. package/src/commands/migrate/profiles.mjs +174 -0
  48. package/src/commands/migrate/project.mjs +249 -0
  49. package/src/commands/migrate/survey.mjs +205 -0
  50. package/src/commands/migrate/variables.mjs +183 -0
  51. package/src/commands/push/index.mjs +334 -0
  52. package/src/commands/sdk/index.mjs +81 -0
  53. package/src/commands/submit/index.mjs +85 -0
  54. package/src/commands/update/index.mjs +526 -0
  55. package/src/commands/usage/index.mjs +57 -0
  56. package/src/commands/vault/approvers.mjs +129 -0
  57. package/src/commands/vault/index.mjs +159 -0
  58. package/src/commands/vault/runners.mjs +115 -0
  59. package/src/commands/vault/team.mjs +111 -0
  60. package/src/commands/version/index.mjs +71 -0
  61. package/src/commands/webhooks/index.mjs +104 -0
  62. package/src/commands/workflows/index.mjs +88 -0
  63. package/src/config.mjs +261 -0
  64. package/src/dist.mjs +99 -0
  65. package/src/errors.mjs +158 -0
  66. package/src/http.mjs +45 -0
  67. package/src/legacy.mjs +73 -0
  68. package/src/manage.mjs +120 -0
  69. package/src/ota.mjs +122 -0
  70. package/src/output.mjs +36 -0
  71. package/src/process.mjs +45 -0
  72. package/src/schema.mjs +131 -0
  73. package/src/sdk.mjs +120 -0
  74. package/src/secret-input.mjs +27 -0
  75. package/src/secrets.mjs +37 -0
  76. package/src/shared.mjs +5 -0
  77. package/src/team-vault.mjs +152 -0
  78. package/src/token-store.mjs +93 -0
@@ -0,0 +1,583 @@
1
+ // The sealed vault of IAS (docs/technical/vault.md, PLAN-APPLICATION-SERVICES.md §2.5). Each secret is encrypted with a random
2
+ // data key, and the data key is wrapped for every recipient (the team's recovery key and each authorised runner) with
3
+ // an ephemeral X25519 key, HKDF-SHA256 and AES-256-GCM. In ias-vault-2 the team signs the whole envelope with its
4
+ // Ed25519 key (decision D11), and the runner checks that signature against the team key it pinned, so a server that
5
+ // seals an envelope of its own is caught. The server stores envelopes and checks their shape (validateEnvelope) but
6
+ // holds no private key, so it can never open one nor sign one. ias-vault-1, unsigned, stays for the tests. Only
7
+ // node:crypto.
8
+ import { Buffer } from 'node:buffer';
9
+ import { createCipheriv, createDecipheriv, createHash, createPrivateKey, createPublicKey, diffieHellman, hkdfSync, randomBytes, sign, verify } from 'node:crypto';
10
+
11
+ export const FORMAT_1 = 'ias-vault-1';
12
+ export const FORMAT_2 = 'ias-vault-2';
13
+ /** The format the CLI seals with and the server takes. ias-vault-1 is only for the tests (decision D11). */
14
+ export const FORMAT = FORMAT_2;
15
+ export const FORMATS = Object.freeze([FORMAT_1, FORMAT_2]);
16
+ /** The list of authorised runners, signed by the team (docs/technical/vault.md §9). */
17
+ export const RUNNERS_FORMAT = 'ias-vault-2/runners';
18
+
19
+ /** The limits of the format. The server enforces the same ones; changing them changes the format. */
20
+ export const LIMITS = Object.freeze({
21
+ envelopeBytes: 512 * 1024,
22
+ plaintextBytes: 256 * 1024,
23
+ recipients: 64,
24
+ /** Runners in the signed list: with the recovery key, an envelope for all of them still fits in `recipients`. */
25
+ runners: 63,
26
+ version: 2147483647,
27
+ });
28
+
29
+ /** Who a recipient key belongs to. It is metadata of the key, not part of an envelope. */
30
+ export const RECIPIENT_KINDS = Object.freeze(['recovery', 'runner']);
31
+
32
+ export const KEY_BYTES = 32;
33
+ export const IV_BYTES = 12;
34
+ export const TAG_BYTES = 16;
35
+ export const SIGNATURE_BYTES = 64;
36
+
37
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
38
+ const BASE64URL = /^[A-Za-z0-9_-]*$/;
39
+
40
+ /**
41
+ * What changes from one format to the other: the fields of the envelope, the pattern of credential.type (in
42
+ * ias-vault-2 it is the name of the management API, apple_distribution_certificate…) and whether the team signs it.
43
+ * Every string that goes into a derivation starts with the format, so an envelope of one never opens as the other.
44
+ */
45
+ const SPECS = Object.freeze({
46
+ [FORMAT_1]: { fields: ['format', 'credential', 'data', 'recipients'], type: /^[a-z][a-z0-9-]{0,63}$/, typeRule: 'a-z 0-9 -', signed: false },
47
+ [FORMAT_2]: { fields: ['format', 'credential', 'data', 'recipients', 'signature'], type: /^[a-z][a-z0-9_]{0,63}$/, typeRule: 'a-z 0-9 _', signed: true },
48
+ });
49
+
50
+ /** DER prefixes of X25519 and Ed25519 keys (RFC 8410): the raw 32 bytes follow them. */
51
+ const PKCS8_X25519 = Buffer.from('302e020100300506032b656e04220420', 'hex');
52
+ const SPKI_X25519 = Buffer.from('302a300506032b656e032100', 'hex');
53
+ const PKCS8_ED25519 = Buffer.from('302e020100300506032b657004220420', 'hex');
54
+ const SPKI_ED25519 = Buffer.from('302a300506032b6570032100', 'hex');
55
+
56
+ /** A vault failure. `code` is stable (docs/technical/vault.md §7) and the message never holds key material or plaintext. */
57
+ export class VaultError extends Error {
58
+ constructor(code, message) {
59
+ super(message);
60
+ this.name = 'VaultError';
61
+ this.code = code;
62
+ }
63
+ }
64
+
65
+ export const encode = (bytes) => Buffer.from(bytes).toString('base64url');
66
+
67
+ /** Decodes canonical unpadded base64url: anything else (padding, +/, stray bits) is null. */
68
+ export function decode(text) {
69
+ if (typeof text !== 'string' || !BASE64URL.test(text) || text.length % 4 === 1) return null;
70
+ const bytes = Buffer.from(text, 'base64url');
71
+ return bytes.toString('base64url') === text ? bytes : null;
72
+ }
73
+
74
+ const sha256 = (data) => createHash('sha256').update(data).digest();
75
+
76
+ /** The identifier of a key (a recipient's or the team's signing key): the SHA-256 of its raw 32-byte public key, in base64url. */
77
+ export function keyId(publicKey) {
78
+ const raw = typeof publicKey === 'string' ? decode(publicKey) : Buffer.from(publicKey);
79
+ if (raw === null || raw.length !== KEY_BYTES) throw new VaultError('key', 'a public key is 32 bytes in base64url');
80
+ return encode(sha256(raw));
81
+ }
82
+
83
+ const privateKeyObject = (raw) => createPrivateKey({ key: Buffer.concat([PKCS8_X25519, raw]), format: 'der', type: 'pkcs8' });
84
+ const publicKeyObject = (raw) => createPublicKey({ key: Buffer.concat([SPKI_X25519, raw]), format: 'der', type: 'spki' });
85
+ const rawPublicKey = (privateKey) => createPublicKey(privateKey).export({ format: 'der', type: 'spki' }).subarray(SPKI_X25519.length);
86
+
87
+ /** `n` bytes from the random source; vectors inject theirs (docs/technical/vault.md §4 gives the order). */
88
+ function take(random, n) {
89
+ const bytes = random(n);
90
+ if (!(bytes instanceof Uint8Array) || bytes.length !== n) throw new VaultError('random', `the random source must return ${n} bytes`);
91
+ return Buffer.from(bytes);
92
+ }
93
+
94
+ /** The rules of `format`, or a VaultError if the library does not know it. */
95
+ function specOf(format) {
96
+ const spec = SPECS[format];
97
+ if (!spec) throw new VaultError('format', `the format is one of: ${FORMATS.join(', ')}`);
98
+ return spec;
99
+ }
100
+
101
+ /** The formats a caller accepts: a subset of FORMATS, the current one by default. */
102
+ function acceptedFormats(formats = [FORMAT]) {
103
+ if (!Array.isArray(formats) || formats.length === 0 || formats.some((format) => !SPECS[format])) throw new VaultError('format', `formats is a list of: ${FORMATS.join(', ')}`);
104
+ return formats;
105
+ }
106
+
107
+ // ---------------------------------------------------------------------------------------------------------------------
108
+ // Keys.
109
+
110
+ /**
111
+ * A new recipient key pair: the team's recovery key or a runner's. The private key never goes to the server: the
112
+ * recovery one lives in 1Password, a runner's in its keychain. The server gets publicRecipient(key).
113
+ */
114
+ export function generateRecipient(kind, { random = randomBytes } = {}) {
115
+ if (!RECIPIENT_KINDS.includes(kind)) throw new VaultError('key', `a recipient is one of: ${RECIPIENT_KINDS.join(', ')}`);
116
+ return recipientFromPrivateKey(encode(take(random, KEY_BYTES)), kind);
117
+ }
118
+
119
+ /** The key pair of a private key (base64url of its 32 raw bytes), as generateRecipient returns it. */
120
+ export function recipientFromPrivateKey(privateKey, kind) {
121
+ const raw = decode(privateKey);
122
+ if (raw === null || raw.length !== KEY_BYTES) throw new VaultError('key', 'a private key is 32 bytes in base64url');
123
+ const publicKey = encode(rawPublicKey(privateKeyObject(raw)));
124
+ return { ...(kind === undefined ? {} : { kind }), kid: keyId(publicKey), publicKey, privateKey };
125
+ }
126
+
127
+ /** What the server stores of a recipient: everything but the private key. */
128
+ export function publicRecipient({ kind, kid, publicKey }) {
129
+ if (kid !== keyId(publicKey)) throw new VaultError('key', 'the kid does not match the public key');
130
+ return { ...(kind === undefined ? {} : { kind }), kid, publicKey };
131
+ }
132
+
133
+ /**
134
+ * A new signing key of the team (Ed25519, RFC 8032): { kid, publicKey, privateKey }, the private key being the 32-byte
135
+ * seed in base64url. It lives in 1Password beside the recovery key; the server and the runners get publicSigningKey().
136
+ */
137
+ export function generateSigningKey({ random = randomBytes } = {}) {
138
+ return signingKeyFromPrivateKey(encode(take(random, KEY_BYTES)));
139
+ }
140
+
141
+ /** The signing key pair of a private key (the base64url of its 32-byte Ed25519 seed). */
142
+ export function signingKeyFromPrivateKey(privateKey) {
143
+ const raw = decode(privateKey);
144
+ if (raw === null || raw.length !== KEY_BYTES) throw new VaultError('signing-key', 'a signing private key is a 32-byte Ed25519 seed in base64url');
145
+ const object = createPrivateKey({ key: Buffer.concat([PKCS8_ED25519, raw]), format: 'der', type: 'pkcs8' });
146
+ const publicKey = encode(createPublicKey(object).export({ format: 'der', type: 'spki' }).subarray(SPKI_ED25519.length));
147
+ return { kid: keyId(publicKey), publicKey, privateKey };
148
+ }
149
+
150
+ /** What the server and the runners keep of the team's signing key: { kid, publicKey }, the kid checked. */
151
+ export function publicSigningKey({ kid, publicKey }) {
152
+ if (kid !== keyId(publicKey)) throw new VaultError('signing-key', 'the kid does not match the signing public key');
153
+ return { kid, publicKey };
154
+ }
155
+
156
+ /** The team's private signing key, from its seed or its pair: { kid, object }. */
157
+ function signerOf(key) {
158
+ const seed = typeof key === 'string' ? key : key?.privateKey;
159
+ if (typeof seed !== 'string') throw new VaultError('signing-key', `an ${FORMAT_2} envelope is signed with the team's signing key (its private key)`);
160
+ const pair = signingKeyFromPrivateKey(seed);
161
+ return { kid: pair.kid, publicKey: pair.publicKey, object: createPrivateKey({ key: Buffer.concat([PKCS8_ED25519, decode(seed)]), format: 'der', type: 'pkcs8' }) };
162
+ }
163
+
164
+ /** The team's public signing key, the one a runner pinned: a base64url public key or { publicKey }. */
165
+ function verifierOf(key) {
166
+ const publicKey = typeof key === 'string' ? key : key?.publicKey;
167
+ const raw = typeof publicKey === 'string' ? decode(publicKey) : null;
168
+ if (raw === null || raw.length !== KEY_BYTES) throw new VaultError('signing-key', `an ${FORMAT_2} envelope is checked against the team's signing public key (32 bytes in base64url)`);
169
+ if (typeof key === 'object' && key.kid !== undefined && key.kid !== keyId(raw)) throw new VaultError('signing-key', 'the kid does not match the signing public key');
170
+ let object = null;
171
+ try {
172
+ object = createPublicKey({ key: Buffer.concat([SPKI_ED25519, raw]), format: 'der', type: 'spki' });
173
+ } catch {
174
+ object = null; // bytes that are no point of the curve: nothing verifies against them
175
+ }
176
+ return { kid: keyId(raw), object };
177
+ }
178
+
179
+ const signText = (signer, text) => encode(sign(null, text, signer.object));
180
+
181
+ function verifies(verifier, text, signature) {
182
+ if (verifier.object === null) return false;
183
+ try {
184
+ return verify(null, text, verifier.object, decode(signature));
185
+ } catch {
186
+ return false;
187
+ }
188
+ }
189
+
190
+ /** The recipients to wrap for, with their kid checked against the public key and no repeats. */
191
+ function targets(recipients, { allowEmpty = false } = {}) {
192
+ if (!Array.isArray(recipients) || (!allowEmpty && recipients.length === 0)) throw new VaultError('recipients', 'an envelope needs at least one recipient');
193
+ const seen = new Set();
194
+ return recipients.map((recipient) => {
195
+ const raw = decode(recipient?.publicKey);
196
+ if (raw === null || raw.length !== KEY_BYTES) throw new VaultError('key', 'a recipient public key is 32 bytes in base64url');
197
+ const kid = keyId(raw);
198
+ if (recipient.kid !== undefined && recipient.kid !== kid) throw new VaultError('key', `the kid ${recipient.kid} does not match its public key`);
199
+ if (seen.has(kid)) throw new VaultError('recipient.duplicate', `${kid} is a recipient twice`);
200
+ seen.add(kid);
201
+ return { kid, raw };
202
+ });
203
+ }
204
+
205
+ /** The private key of the caller: a base64url string or a key pair from generateRecipient. */
206
+ function ownKey(key) {
207
+ const pair = recipientFromPrivateKey(typeof key === 'string' ? key : key?.privateKey);
208
+ return { kid: pair.kid, raw: decode(pair.publicKey), object: privateKeyObject(decode(pair.privateKey)) };
209
+ }
210
+
211
+ // ---------------------------------------------------------------------------------------------------------------------
212
+ // The pieces of docs/technical/vault.md §4 and §5.
213
+
214
+ function credentialProblem(credential, spec) {
215
+ if (!isRecord(credential) || !sameKeys(credential, ['id', 'type', 'version'])) return ['credential', 'credential must be an object with exactly id, type and version'];
216
+ if (typeof credential.id !== 'string' || !ID.test(credential.id)) return ['credential.id', 'credential.id must be 1 to 128 characters of A-Z a-z 0-9 . _ - and start with a letter or digit'];
217
+ if (typeof credential.type !== 'string' || !spec.type.test(credential.type)) return ['credential.type', `credential.type must be 1 to 64 characters of ${spec.typeRule} and start with a letter`];
218
+ if (!Number.isInteger(credential.version) || credential.version < 1 || credential.version > LIMITS.version) return ['credential.version', `credential.version must be an integer from 1 to ${LIMITS.version}`];
219
+ return null;
220
+ }
221
+
222
+ /**
223
+ * The associated data that binds the data ciphertext to its credential (id, type and version) and, with `kid`, a
224
+ * wrap to its recipient as well. Neither can be moved to another credential, version, recipient or format.
225
+ */
226
+ export function associatedData({ id, type, version }, kid, format = FORMAT) {
227
+ specOf(format);
228
+ return Buffer.from(kid === undefined ? `${format}\ndata\n${id}\n${type}\n${version}` : `${format}\nwrap\n${id}\n${type}\n${version}\n${kid}`, 'utf8');
229
+ }
230
+
231
+ /** The key that wraps the data key for one recipient: HKDF-SHA256 of the X25519 secret, salted with both public keys. */
232
+ export function wrapKey(sharedSecret, ephemeralPublicKey, recipientPublicKey, format = FORMAT) {
233
+ specOf(format);
234
+ return Buffer.from(hkdfSync('sha256', sharedSecret, Buffer.concat([ephemeralPublicKey, recipientPublicKey]), Buffer.from(`${format}/x25519-hkdf-sha256-aes-256-gcm`, 'utf8'), KEY_BYTES));
235
+ }
236
+
237
+ /**
238
+ * What the team signs in an ias-vault-2 envelope (docs/technical/vault.md §5.1): every field of the envelope but the signature
239
+ * itself, one per line, with the kid of the signing key and the number of wraps. No field can hold a newline, so the
240
+ * text reads back one way only.
241
+ */
242
+ export function signedText({ credential, data, recipients }, signingKid) {
243
+ const lines = [FORMAT_2, 'signature', signingKid, credential.id, credential.type, String(credential.version), data.iv, data.ct, String(recipients.length)];
244
+ for (const wrap of recipients) lines.push(wrap.kid, wrap.epk, wrap.iv, wrap.wk);
245
+ return Buffer.from(lines.join('\n'), 'utf8');
246
+ }
247
+
248
+ function encrypt(key, iv, aad, plaintext) {
249
+ const cipher = createCipheriv('aes-256-gcm', key, iv, { authTagLength: TAG_BYTES });
250
+ cipher.setAAD(aad);
251
+ return Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]);
252
+ }
253
+
254
+ /** AES-256-GCM decryption of ciphertext‖tag; null when it does not authenticate. */
255
+ function decrypt(key, iv, aad, sealed) {
256
+ try {
257
+ const decipher = createDecipheriv('aes-256-gcm', key, iv, { authTagLength: TAG_BYTES });
258
+ decipher.setAAD(aad);
259
+ decipher.setAuthTag(sealed.subarray(sealed.length - TAG_BYTES));
260
+ return Buffer.concat([decipher.update(sealed.subarray(0, sealed.length - TAG_BYTES)), decipher.final()]);
261
+ } catch {
262
+ return null;
263
+ }
264
+ }
265
+
266
+ function wrapFor(dataKey, credential, { kid, raw }, random, format) {
267
+ const ephemeral = privateKeyObject(take(random, KEY_BYTES));
268
+ const epk = rawPublicKey(ephemeral);
269
+ const iv = take(random, IV_BYTES);
270
+ const shared = diffieHellman({ privateKey: ephemeral, publicKey: publicKeyObject(raw) });
271
+ return { kid, epk: encode(epk), iv: encode(iv), wk: encode(encrypt(wrapKey(shared, epk, raw, format), iv, associatedData(credential, kid, format), dataKey)) };
272
+ }
273
+
274
+ /** The envelope with the team's signature, when its format is signed; the fields in their order. */
275
+ function signed(envelope, signer) {
276
+ const { format, credential, data, recipients } = envelope;
277
+ if (!specOf(format).signed) return { format, credential, data, recipients };
278
+ return { format, credential, data, recipients, signature: { kid: signer.kid, sig: signText(signer, signedText(envelope, signer.kid)) } };
279
+ }
280
+
281
+ // ---------------------------------------------------------------------------------------------------------------------
282
+ // Sealing, checking and opening.
283
+
284
+ /**
285
+ * Seals `plaintext` (bytes or a UTF-8 string) for `recipients` ({ publicKey, kid? }) as a credential's version. In
286
+ * ias-vault-2, `signingKey` is the team's private signing key (seed or pair). The random source is called for the data
287
+ * key, the data IV and then, per recipient in order, the ephemeral private key and the wrap IV.
288
+ */
289
+ export function seal(plaintext, { credential, recipients, signingKey, format = FORMAT, random = randomBytes }) {
290
+ const spec = specOf(format);
291
+ const signer = spec.signed ? signerOf(signingKey) : null;
292
+ const binding = isRecord(credential) ? { id: credential.id, type: credential.type, version: credential.version } : credential;
293
+ const problem = credentialProblem(binding, spec);
294
+ if (problem) throw new VaultError(...problem);
295
+ const bytes = typeof plaintext === 'string' ? Buffer.from(plaintext, 'utf8') : plaintext instanceof Uint8Array ? Buffer.from(plaintext) : null;
296
+ if (bytes === null || bytes.length === 0 || bytes.length > LIMITS.plaintextBytes) throw new VaultError('plaintext', `the plaintext must be 1 to ${LIMITS.plaintextBytes} bytes`);
297
+ const to = targets(recipients);
298
+ if (to.length > LIMITS.recipients) throw new VaultError('recipients', `an envelope has at most ${LIMITS.recipients} recipients`);
299
+ const dataKey = take(random, KEY_BYTES);
300
+ try {
301
+ const iv = take(random, IV_BYTES);
302
+ return signed(
303
+ {
304
+ format,
305
+ credential: binding,
306
+ data: { iv: encode(iv), ct: encode(encrypt(dataKey, iv, associatedData(binding, undefined, format), bytes)) },
307
+ recipients: to.map((recipient) => wrapFor(dataKey, binding, recipient, random, format)),
308
+ },
309
+ signer,
310
+ );
311
+ } finally {
312
+ dataKey.fill(0);
313
+ }
314
+ }
315
+
316
+ /** An envelope given as JSON text or as an object, checked; throws VaultError with the code of the first problem. */
317
+ function parse(envelope, options) {
318
+ const result = validateEnvelope(envelope, options);
319
+ if (!result.valid) throw new VaultError(result.code, result.message);
320
+ return typeof envelope === 'string' ? JSON.parse(envelope) : structuredClone(envelope);
321
+ }
322
+
323
+ /** Throws signature-invalid unless the team's signature of `envelope` verifies with `verifier`. */
324
+ function checkSignature(envelope, verifier) {
325
+ if (!verifies(verifier, signedText(envelope, envelope.signature.kid), envelope.signature.sig)) {
326
+ throw new VaultError('signature-invalid', `the signature does not verify with the team's signing key ${verifier.kid}: the envelope was altered, or made by someone without that key`);
327
+ }
328
+ }
329
+
330
+ /**
331
+ * Checks the team's signature of an ias-vault-2 envelope against `signedBy`, the team's public signing key the runner
332
+ * pinned, and returns the envelope parsed. Throws signature.unknown when it is signed with another key and
333
+ * signature-invalid when the signature does not verify.
334
+ */
335
+ export function verifyEnvelope(envelope, signedBy) {
336
+ const verifier = verifierOf(signedBy);
337
+ const parsed = parse(envelope, { formats: [FORMAT_2], signingKid: verifier.kid });
338
+ checkSignature(parsed, verifier);
339
+ return parsed;
340
+ }
341
+
342
+ /** The data key of `envelope` for `own`, checked against the data ciphertext so that a wrong key is never handed on. */
343
+ function dataKeyFor(envelope, own) {
344
+ const { format } = envelope;
345
+ const wrap = envelope.recipients.find((candidate) => candidate.kid === own.kid);
346
+ if (!wrap) throw new VaultError('not-a-recipient', `the key ${own.kid} is not a recipient of this envelope`);
347
+ let key = null;
348
+ try {
349
+ const epk = decode(wrap.epk);
350
+ const shared = diffieHellman({ privateKey: own.object, publicKey: publicKeyObject(epk) });
351
+ key = decrypt(wrapKey(shared, epk, own.raw, format), decode(wrap.iv), associatedData(envelope.credential, wrap.kid, format), decode(wrap.wk));
352
+ } catch {
353
+ key = null; // a low-order ephemeral key makes the X25519 derivation fail
354
+ }
355
+ if (key === null) throw new VaultError('unwrap-failed', `the wrap for ${own.kid} does not open: it was altered, moved from another envelope or made for another key`);
356
+ const plaintext = decrypt(key, decode(envelope.data.iv), associatedData(envelope.credential, undefined, format), decode(envelope.data.ct));
357
+ if (plaintext === null) {
358
+ key.fill(0);
359
+ throw new VaultError('decrypt-failed', 'the data does not open with its data key: the ciphertext or its credential was altered, or the wrap comes from another envelope');
360
+ }
361
+ return { key, plaintext };
362
+ }
363
+
364
+ /** The envelope parsed for an operation of the team: its signature checked against the key that will sign it again. */
365
+ function parseSigned(envelope, { formats, signingKey }) {
366
+ const parsed = parse(envelope, { formats: acceptedFormats(formats) });
367
+ if (!specOf(parsed.format).signed) return { parsed, signer: null };
368
+ const signer = signerOf(signingKey);
369
+ const verifier = verifierOf(signer.publicKey);
370
+ if (parsed.signature.kid !== verifier.kid) throw new VaultError('signature.unknown', `the envelope is signed with ${parsed.signature.kid}, not with the team's key ${verifier.kid}`);
371
+ checkSignature(parsed, verifier);
372
+ return { parsed, signer };
373
+ }
374
+
375
+ /**
376
+ * Opens `envelope` with a recipient's private key and returns the plaintext as bytes. An ias-vault-2 envelope opens
377
+ * only after its signature verifies with `signedBy`, the team's public signing key. ias-vault-1 opens only when
378
+ * `formats` names it.
379
+ */
380
+ export function open(envelope, privateKey, { signedBy, formats } = {}) {
381
+ const accepted = acceptedFormats(formats);
382
+ const verifier = accepted.some((format) => SPECS[format].signed) && signedBy !== undefined ? verifierOf(signedBy) : null;
383
+ const parsed = parse(envelope, { formats: accepted, ...(verifier ? { signingKid: verifier.kid } : {}) });
384
+ if (specOf(parsed.format).signed) {
385
+ if (!verifier) throw new VaultError('signing-key', `an ${FORMAT_2} envelope opens only after checking its signature: pass the team's signing public key (signedBy)`);
386
+ checkSignature(parsed, verifier);
387
+ }
388
+ const { key, plaintext } = dataKeyFor(parsed, ownKey(privateKey));
389
+ key.fill(0);
390
+ return plaintext;
391
+ }
392
+
393
+ /**
394
+ * Opens the data key with `privateKey` (the recovery key, for `ias vault grant`) and wraps it for the recipients that
395
+ * do not have it yet. Recipients already in the envelope keep their wrap, so granting twice changes nothing. In
396
+ * ias-vault-2 the signature is checked first and made again with `signingKey`.
397
+ */
398
+ export function rewrap(envelope, privateKey, recipients, { signingKey, formats, random = randomBytes } = {}) {
399
+ const { parsed, signer } = parseSigned(envelope, { formats, signingKey });
400
+ const { key, plaintext } = dataKeyFor(parsed, ownKey(privateKey));
401
+ plaintext.fill(0);
402
+ try {
403
+ const present = new Set(parsed.recipients.map((wrap) => wrap.kid));
404
+ const added = targets(recipients, { allowEmpty: true }).filter(({ kid }) => !present.has(kid));
405
+ if (parsed.recipients.length + added.length > LIMITS.recipients) throw new VaultError('recipients', `an envelope has at most ${LIMITS.recipients} recipients`);
406
+ return signed({ ...parsed, recipients: [...parsed.recipients, ...added.map((recipient) => wrapFor(key, parsed.credential, recipient, random, parsed.format))] }, signer);
407
+ } finally {
408
+ key.fill(0);
409
+ }
410
+ }
411
+
412
+ /**
413
+ * The envelope without the wrap of `kid`, signed again in ias-vault-2. It does not undo what that recipient may already
414
+ * have read (docs/technical/vault.md §10). The last recipient cannot be removed: nobody could open the envelope again.
415
+ */
416
+ export function removeRecipient(envelope, kid, { signingKey, formats } = {}) {
417
+ const { parsed, signer } = parseSigned(envelope, { formats, signingKey });
418
+ if (!parsed.recipients.some((wrap) => wrap.kid === kid)) throw new VaultError('not-a-recipient', `${kid} is not a recipient of this envelope`);
419
+ if (parsed.recipients.length === 1) throw new VaultError('last-recipient', `${kid} is the only recipient: nobody could open the envelope without it`);
420
+ return signed({ ...parsed, recipients: parsed.recipients.filter((wrap) => wrap.kid !== kid) }, signer);
421
+ }
422
+
423
+ /** The kids an envelope is wrapped for, in order. */
424
+ export function recipientsOf(envelope, { formats } = {}) {
425
+ return parse(envelope, { formats: acceptedFormats(formats) }).recipients.map((wrap) => wrap.kid);
426
+ }
427
+
428
+ /**
429
+ * A SHA-256 of what the envelope protects (format, credential, data IV and ciphertext), in base64url. Granting or
430
+ * revoking recipients does not change it, nor does the signature, so a store build job can name the exact secrets it
431
+ * was approved with (§2.6).
432
+ */
433
+ export function digest(envelope, { formats } = {}) {
434
+ const { format, credential, data } = parse(envelope, { formats: acceptedFormats(formats) });
435
+ return encode(sha256(Buffer.from(`${format}\ndigest\n${credential.id}\n${credential.type}\n${credential.version}\n${data.iv}\n${data.ct}`, 'utf8')));
436
+ }
437
+
438
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
439
+ const sameKeys = (object, keys) => {
440
+ const own = Object.keys(object).sort();
441
+ return own.length === keys.length && [...keys].sort().every((key, index) => own[index] === key);
442
+ };
443
+ const listed = (names) => `${names.slice(0, -1).join(', ')} and ${names.at(-1)}`;
444
+
445
+ /** A canonical base64url field of `size` bytes (or `min` to `max`). */
446
+ function bytesProblem(value, path, { size, min = size, max = size }) {
447
+ const bytes = decode(value);
448
+ if (bytes === null || bytes.length < min || bytes.length > max) {
449
+ return [path, `${path} must be ${min === max ? min : `${min} to ${max}`} bytes in unpadded base64url`];
450
+ }
451
+ return null;
452
+ }
453
+
454
+ /** The shape of a signature: { kid, sig }, a 32-byte kid and a 64-byte Ed25519 signature. */
455
+ function signatureProblem(signature) {
456
+ if (!isRecord(signature) || !sameKeys(signature, ['kid', 'sig'])) return ['signature', 'signature must be an object with exactly kid and sig'];
457
+ return bytesProblem(signature.kid, 'signature.kid', { size: KEY_BYTES }) ?? bytesProblem(signature.sig, 'signature.sig', { size: SIGNATURE_BYTES });
458
+ }
459
+
460
+ function shapeProblem(envelope, { formats, requiredKids, signingKid }) {
461
+ if (!isRecord(envelope)) return ['envelope', 'the envelope must be a JSON object'];
462
+ if (!formats.includes(envelope.format)) return ['format', `the format must be ${formats.join(' or ')}`];
463
+ const spec = SPECS[envelope.format];
464
+ if (!sameKeys(envelope, spec.fields)) return ['envelope.fields', `an ${envelope.format} envelope must have exactly ${listed(spec.fields)}`];
465
+ const credential = credentialProblem(envelope.credential, spec);
466
+ if (credential) return credential;
467
+ const { data, recipients } = envelope;
468
+ if (!isRecord(data) || !sameKeys(data, ['iv', 'ct'])) return ['data', 'data must be an object with exactly iv and ct'];
469
+ const dataProblem = bytesProblem(data.iv, 'data.iv', { size: IV_BYTES }) ?? bytesProblem(data.ct, 'data.ct', { min: 1 + TAG_BYTES, max: LIMITS.plaintextBytes + TAG_BYTES });
470
+ if (dataProblem) return dataProblem;
471
+ if (!Array.isArray(recipients) || recipients.length === 0 || recipients.length > LIMITS.recipients) return ['recipients', `recipients must be a list of 1 to ${LIMITS.recipients} wraps`];
472
+ const seen = new Set();
473
+ for (const wrap of recipients) {
474
+ if (!isRecord(wrap) || !sameKeys(wrap, ['kid', 'epk', 'iv', 'wk'])) return ['recipient', 'each recipient must be an object with exactly kid, epk, iv and wk'];
475
+ const problem =
476
+ bytesProblem(wrap.kid, 'recipient.kid', { size: KEY_BYTES }) ??
477
+ bytesProblem(wrap.epk, 'recipient.epk', { size: KEY_BYTES }) ??
478
+ bytesProblem(wrap.iv, 'recipient.iv', { size: IV_BYTES }) ??
479
+ bytesProblem(wrap.wk, 'recipient.wk', { size: KEY_BYTES + TAG_BYTES });
480
+ if (problem) return problem;
481
+ if (seen.has(wrap.kid)) return ['recipient.duplicate', `${wrap.kid} is a recipient twice`];
482
+ seen.add(wrap.kid);
483
+ }
484
+ if (spec.signed) {
485
+ const problem = signatureProblem(envelope.signature);
486
+ if (problem) return problem;
487
+ }
488
+ const missing = requiredKids.find((kid) => !seen.has(kid));
489
+ if (missing !== undefined) return ['recipient.missing', `${missing} must be a recipient`];
490
+ if (spec.signed && signingKid !== undefined && envelope.signature.kid !== signingKid) return ['signature.unknown', `the envelope must be signed with the team's key ${signingKid}`];
491
+ return null;
492
+ }
493
+
494
+ /**
495
+ * Checks the shape of an envelope without opening it, which is all the server does: the fields, the format, the
496
+ * credential binding, the size of every value and, in ias-vault-2, the shape of the signature. `formats` are the
497
+ * formats taken (ias-vault-2 by default); `requiredKids` are kids that must be recipients (the server asks for the
498
+ * team's recovery key); `signingKid` is the kid the signature must have (the team's registered signing key). Returns
499
+ * { valid: true } or { valid: false, code, message } for the first problem. It does not verify the signature.
500
+ */
501
+ export function validateEnvelope(envelope, { formats = [FORMAT], requiredKids = [], signingKid } = {}) {
502
+ const accepted = acceptedFormats(formats);
503
+ let value = envelope;
504
+ if (typeof envelope === 'string') {
505
+ if (Buffer.byteLength(envelope, 'utf8') > LIMITS.envelopeBytes) return { valid: false, code: 'too-large', message: `the envelope must not exceed ${LIMITS.envelopeBytes} bytes` };
506
+ try {
507
+ value = JSON.parse(envelope);
508
+ } catch {
509
+ return { valid: false, code: 'json', message: 'the envelope is not JSON' };
510
+ }
511
+ }
512
+ const problem = shapeProblem(value, { formats: accepted, requiredKids, signingKid });
513
+ return problem ? { valid: false, code: problem[0], message: problem[1] } : { valid: true };
514
+ }
515
+
516
+ // ---------------------------------------------------------------------------------------------------------------------
517
+ // The list of authorised runners, signed by the team (docs/technical/vault.md §9).
518
+
519
+ /** What the team signs in a runner list: its serial and each runner's kid and public key, in order. */
520
+ export function runnerListText({ serial, runners }, signingKid) {
521
+ const lines = [FORMAT_2, 'runners', signingKid, String(serial), String(runners.length)];
522
+ for (const runner of runners) lines.push(runner.kid, runner.public_key);
523
+ return Buffer.from(lines.join('\n'), 'utf8');
524
+ }
525
+
526
+ /**
527
+ * The list of the runners the team authorised, signed with `signingKey`: { format, serial, runners, signature }.
528
+ * `runners` are { publicKey, kid? }; `serial` goes up by one with every change, so an older list can be told apart.
529
+ */
530
+ export function signRunnerList({ serial, runners }, signingKey) {
531
+ const signer = signerOf(signingKey);
532
+ if (!Number.isInteger(serial) || serial < 1 || serial > LIMITS.version) throw new VaultError('runners.serial', `the serial is an integer from 1 to ${LIMITS.version}`);
533
+ const entries = targets(runners, { allowEmpty: true }).map(({ kid, raw }) => ({ kid, public_key: encode(raw) }));
534
+ if (entries.length > LIMITS.runners) throw new VaultError('runners.list', `the list has at most ${LIMITS.runners} runners`);
535
+ const list = { format: RUNNERS_FORMAT, serial, runners: entries };
536
+ return { ...list, signature: { kid: signer.kid, sig: signText(signer, runnerListText(list, signer.kid)) } };
537
+ }
538
+
539
+ /**
540
+ * Checks the shape of a runner list, as validateEnvelope does for an envelope: { valid: true } or { valid: false, code,
541
+ * message }. With `signingKid`, the signature must carry it. Each kid must be the SHA-256 of its public key.
542
+ */
543
+ export function validateRunnerList(list, { signingKid } = {}) {
544
+ const fail = (code, message) => ({ valid: false, code, message });
545
+ if (!isRecord(list)) return fail('runners', 'the runner list must be a JSON object');
546
+ if (list.format !== RUNNERS_FORMAT) return fail('runners.format', `the format of the runner list must be ${RUNNERS_FORMAT}`);
547
+ if (!sameKeys(list, ['format', 'serial', 'runners', 'signature'])) return fail('runners.fields', 'the runner list must have exactly format, serial, runners and signature');
548
+ if (!Number.isInteger(list.serial) || list.serial < 1 || list.serial > LIMITS.version) return fail('runners.serial', `serial must be an integer from 1 to ${LIMITS.version}`);
549
+ if (!Array.isArray(list.runners) || list.runners.length > LIMITS.runners) return fail('runners.list', `runners must be a list of at most ${LIMITS.runners} runners`);
550
+ const seen = new Set();
551
+ for (const runner of list.runners) {
552
+ if (!isRecord(runner) || !sameKeys(runner, ['kid', 'public_key'])) return fail('runner', 'each runner must be an object with exactly kid and public_key');
553
+ const problem = bytesProblem(runner.kid, 'runner.kid', { size: KEY_BYTES }) ?? bytesProblem(runner.public_key, 'runner.public_key', { size: KEY_BYTES });
554
+ if (problem) return fail(...problem);
555
+ if (runner.kid !== keyId(runner.public_key)) return fail('runner.key', `${runner.kid} is not the kid of its public key`);
556
+ if (seen.has(runner.kid)) return fail('runner.duplicate', `${runner.kid} is in the list twice`);
557
+ seen.add(runner.kid);
558
+ }
559
+ const problem = signatureProblem(list.signature);
560
+ if (problem) return fail(...problem);
561
+ if (signingKid !== undefined && list.signature.kid !== signingKid) return fail('signature.unknown', `the runner list must be signed with the team's key ${signingKid}`);
562
+ return { valid: true };
563
+ }
564
+
565
+ /**
566
+ * The runner list checked against `signedBy`, the team's public signing key: its runners as recipients ({ kind:
567
+ * 'runner', kid, publicKey }) to seal for, and its serial. Throws on the first problem.
568
+ */
569
+ export function verifyRunnerList(list, signedBy) {
570
+ const verifier = verifierOf(signedBy);
571
+ const value = typeof list === 'string' ? JSON.parse(list) : list;
572
+ const result = validateRunnerList(value, { signingKid: verifier.kid });
573
+ if (!result.valid) throw new VaultError(result.code, result.message);
574
+ if (!verifies(verifier, runnerListText(value, value.signature.kid), value.signature.sig)) {
575
+ throw new VaultError('signature-invalid', `the runner list does not verify with the team's signing key ${verifier.kid}: it was altered, or made by someone without that key`);
576
+ }
577
+ return { serial: value.serial, runners: value.runners.map((runner) => ({ kind: 'runner', kid: runner.kid, publicKey: runner.public_key })) };
578
+ }
579
+
580
+ export { PLAINTEXT_TYPES, PlaintextError, decodePlaintext, encodePlaintext, validatePlaintext } from './plaintext.mjs';
581
+ // The passkeys that may approve a store job, signed by the team with the same key (docs/technical/vault.md §2.4). approvers.mjs
582
+ // imports this module; it only defines functions at its top level, so the cycle is safe either way it is entered.
583
+ export { APPROVERS_FORMAT, APPROVER_ALGORITHMS, APPROVER_LIMITS, approverAlgorithm, approverFingerprint, approversText, signApprovers, validateApprovers, verifyApprovers } from './approvers.mjs';