@imagina-build/ias-cli 0.0.0 → 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 +31 -12
  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,720 @@
1
+ // The test vectors of the vault (vault/test-vectors, docs/technical/vault.md §8), shared by node:test, the PHP validation of the
2
+ // server and the runner. ias-vault-1 (unsigned, only for the tests) is at the top of the folder and ias-vault-2 (signed
3
+ // by the team) in test-vectors/ias-vault-2. Every random byte comes from a label, so they are the same on every machine:
4
+ // node packages/shared/vault/vectors.mjs writes them again; the tests fail if the files differ from buildVectors().
5
+ // The keys here are test keys, public on purpose: never use them for anything else.
6
+ import { Buffer } from 'node:buffer';
7
+ import { createHash, createPrivateKey, createPublicKey, diffieHellman, sign } from 'node:crypto';
8
+ import { mkdirSync, writeFileSync } from 'node:fs';
9
+ import { dirname, join } from 'node:path';
10
+ import process from 'node:process';
11
+ import { fileURLToPath } from 'node:url';
12
+ import {
13
+ FORMAT_1,
14
+ FORMAT_2,
15
+ LIMITS,
16
+ RUNNERS_FORMAT,
17
+ associatedData,
18
+ decode,
19
+ digest,
20
+ encode,
21
+ encodePlaintext,
22
+ open,
23
+ publicRecipient,
24
+ recipientFromPrivateKey,
25
+ removeRecipient,
26
+ rewrap,
27
+ runnerListText,
28
+ seal,
29
+ signRunnerList,
30
+ signedText,
31
+ signingKeyFromPrivateKey,
32
+ validateEnvelope,
33
+ validatePlaintext,
34
+ validateRunnerList,
35
+ verifyRunnerList,
36
+ wrapKey,
37
+ PLAINTEXT_TYPES,
38
+ } from './index.mjs';
39
+ import { approverVectors } from './approvers-vectors.mjs';
40
+
41
+ export const VECTORS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'test-vectors');
42
+
43
+ /** `n` (≤ 32) bytes that depend only on `label` and the format. */
44
+ const bytesOf = (format) => (label, n) => createHash('sha256').update(`${format} test vector\n${label}`, 'utf8').digest().subarray(0, n);
45
+ const bytes = bytesOf(FORMAT_1);
46
+ const bytes2 = bytesOf(FORMAT_2);
47
+
48
+ /** A random source that hands out `values` in order, and fails if they run out or a size does not match. */
49
+ export function queue(values) {
50
+ const pending = values.map((value) => (typeof value === 'string' ? decode(value) : value));
51
+ return (n) => {
52
+ const next = pending.shift();
53
+ if (!next || next.length !== n) throw new Error(`the vector expected ${next ? next.length : 'no more'} random bytes, not ${n}`);
54
+ return next;
55
+ };
56
+ }
57
+
58
+ /** The values a seal or a rewrap takes from its random source, labelled, in the order it takes them (docs/technical/vault.md §4). */
59
+ export function randomOrder({ dataKey, dataIv, wraps }) {
60
+ return [...(dataKey ? [dataKey, dataIv] : []), ...wraps.flatMap((wrap) => [wrap.ephemeralPrivateKey, wrap.iv])];
61
+ }
62
+
63
+ const KEYS = [
64
+ ['recovery', 'recovery'],
65
+ ['runner-a', 'runner'],
66
+ ['runner-b', 'runner'],
67
+ ['outsider', 'runner'],
68
+ ];
69
+
70
+ /** The recipient keys of both formats: the same four, made from ias-vault-1 labels. */
71
+ function keys() {
72
+ return Object.fromEntries(KEYS.map(([name, kind]) => [name, recipientFromPrivateKey(encode(bytes(`key ${name}`, 32)), kind)]));
73
+ }
74
+
75
+ /** The team's signing key and another team's, which the server has never registered. */
76
+ function signingKeys() {
77
+ return Object.fromEntries(['team', 'other-team'].map((name) => [name, signingKeyFromPrivateKey(encode(bytes2(`signing key ${name}`, 32)))]));
78
+ }
79
+
80
+ /** The X25519 secret and wrap key of a wrap, worked out here again from the raw keys, for other implementations. */
81
+ function intermediate(wrap, ephemeralPrivateKey, recipientPublicKey, credential, format) {
82
+ const privateKey = createPrivateKey({ key: Buffer.concat([Buffer.from('302e020100300506032b656e04220420', 'hex'), decode(ephemeralPrivateKey)]), format: 'der', type: 'pkcs8' });
83
+ const publicKey = createPublicKey({ key: Buffer.concat([Buffer.from('302a300506032b656e032100', 'hex'), decode(recipientPublicKey)]), format: 'der', type: 'spki' });
84
+ const shared = diffieHellman({ privateKey, publicKey });
85
+ return {
86
+ kid: wrap.kid,
87
+ ephemeralPrivateKey,
88
+ iv: wrap.iv,
89
+ sharedSecret: encode(shared),
90
+ hkdfSalt: encode(Buffer.concat([decode(wrap.epk), decode(recipientPublicKey)])),
91
+ wrapKey: encode(wrapKey(shared, decode(wrap.epk), decode(recipientPublicKey), format)),
92
+ aad: associatedData(credential, wrap.kid, format).toString('utf8'),
93
+ };
94
+ }
95
+
96
+ /** What each test key gets from an envelope: the plaintext, or the code of the error. */
97
+ function outcomes(envelope, all, openOptions) {
98
+ return Object.fromEntries(
99
+ Object.entries(all).map(([name, key]) => {
100
+ try {
101
+ return [name, { plaintext: encode(open(envelope, key.privateKey, openOptions)) }];
102
+ } catch (error) {
103
+ return [name, { error: error.code }];
104
+ }
105
+ }),
106
+ );
107
+ }
108
+
109
+ const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
110
+
111
+ /** The same bytes in non-canonical base64url: the lowest bit of the last character, which no byte uses, set. */
112
+ const strayBit = (text) => `${text.slice(0, -1)}${ALPHABET[ALPHABET.indexOf(text.at(-1)) | 1]}`;
113
+
114
+ /** Copies `envelope` and lets `change` alter the copy. */
115
+ const altered = (envelope, change) => {
116
+ const copy = structuredClone(envelope);
117
+ change(copy);
118
+ return copy;
119
+ };
120
+
121
+ /** base64url text with one bit of its byte at `index` (the last one by default) flipped. */
122
+ const flip = (text, index = -1) => {
123
+ const raw = decode(text);
124
+ raw[(raw.length + index) % raw.length] ^= 1;
125
+ return encode(raw);
126
+ };
127
+
128
+ // =====================================================================================================================
129
+ // ias-vault-1 (test-vectors/*.json). Unchanged since 20 Oct 2026: the tests prove the files still come out the same.
130
+
131
+ const V1 = { formats: [FORMAT_1] };
132
+
133
+ function sealed(all) {
134
+ const cases = [
135
+ {
136
+ name: 'text-for-recovery-and-runner-a',
137
+ description: 'A UTF-8 plaintext sealed for the recovery key and runner-a.',
138
+ credential: { id: '0d5e6c9a-4b8f-4c1e-9a3d-2f7b8e1c6a40', type: 'ios-distribution-certificate', version: 1 },
139
+ plaintext: Buffer.from('ias-vault-1 test vector: not a real secret', 'utf8'),
140
+ recipients: ['recovery', 'runner-a'],
141
+ },
142
+ {
143
+ name: 'binary-for-recovery',
144
+ description: 'Every byte value once, sealed for the recovery key only, as version 3 of another credential.',
145
+ credential: { id: 'android-upload-key', type: 'android-keystore', version: 3 },
146
+ plaintext: Buffer.from(Array.from({ length: 256 }, (_, index) => index)),
147
+ recipients: ['recovery'],
148
+ },
149
+ {
150
+ name: 'same-credential-other-data-key',
151
+ description: 'The credential of the first case sealed again with another data key, for runner-a and the recovery key. Its runner-a wrap is the one moved in tampered.json (wrap-of-other-envelope).',
152
+ credential: { id: '0d5e6c9a-4b8f-4c1e-9a3d-2f7b8e1c6a40', type: 'ios-distribution-certificate', version: 1 },
153
+ plaintext: Buffer.from('ias-vault-1 test vector: another plaintext', 'utf8'),
154
+ recipients: ['runner-a', 'recovery'],
155
+ },
156
+ ];
157
+ return cases.map(({ name, description, credential, plaintext, recipients }) => {
158
+ const random = {
159
+ dataKey: encode(bytes(`${name} data key`, 32)),
160
+ dataIv: encode(bytes(`${name} data iv`, 12)),
161
+ wraps: recipients.map((recipient) => ({ ephemeralPrivateKey: encode(bytes(`${name} ephemeral ${recipient}`, 32)), iv: encode(bytes(`${name} wrap iv ${recipient}`, 12)) })),
162
+ };
163
+ const envelope = seal(plaintext, { format: FORMAT_1, credential, recipients: recipients.map((recipient) => publicRecipient(all[recipient])), random: queue(randomOrder(random)) });
164
+ return {
165
+ name,
166
+ description,
167
+ plaintext: encode(plaintext),
168
+ credential,
169
+ recipients,
170
+ random,
171
+ aad: associatedData(credential, undefined, FORMAT_1).toString('utf8'),
172
+ wraps: envelope.recipients.map((wrap, index) => intermediate(wrap, random.wraps[index].ephemeralPrivateKey, all[recipients[index]].publicKey, credential, FORMAT_1)),
173
+ envelope,
174
+ digest: digest(envelope, V1),
175
+ opens: outcomes(envelope, all, V1),
176
+ };
177
+ });
178
+ }
179
+
180
+ function changes(sealedCases, all) {
181
+ const [first] = sealedCases;
182
+ const grantRandom = { wraps: [{ ephemeralPrivateKey: encode(bytes('grant ephemeral runner-b', 32)), iv: encode(bytes('grant wrap iv runner-b', 12)) }] };
183
+ const granted = rewrap(first.envelope, all.recovery.privateKey, [publicRecipient(all['runner-b']), publicRecipient(all['runner-a'])], { ...V1, random: queue(randomOrder(grantRandom)) });
184
+ const revoked = removeRecipient(granted, all['runner-a'].kid, V1);
185
+ return [
186
+ {
187
+ name: 'grant-runner-b',
188
+ description: 'ias vault grant: the recovery key opens the first case and wraps its data key for runner-b. runner-a is already a recipient and keeps its wrap.',
189
+ operation: 'rewrap',
190
+ from: first.name,
191
+ opener: 'recovery',
192
+ add: ['runner-b', 'runner-a'],
193
+ random: grantRandom,
194
+ wraps: [intermediate(granted.recipients[2], grantRandom.wraps[0].ephemeralPrivateKey, all['runner-b'].publicKey, first.credential, FORMAT_1)],
195
+ envelope: granted,
196
+ digest: digest(granted, V1),
197
+ opens: outcomes(granted, all, V1),
198
+ },
199
+ {
200
+ name: 'revoke-runner-a',
201
+ description: 'Revoking runner-a removes its wrap from the previous case; the data and the other wraps do not change.',
202
+ operation: 'remove',
203
+ from: 'grant-runner-b',
204
+ remove: 'runner-a',
205
+ envelope: revoked,
206
+ digest: digest(revoked, V1),
207
+ opens: outcomes(revoked, all, V1),
208
+ },
209
+ ];
210
+ }
211
+
212
+ function malformed(base) {
213
+ const wrap = base.recipients[0];
214
+ const cases = [
215
+ ['not-json', 'json', 'The text is not JSON.', { json: '{"format":"ias-vault-1",' }],
216
+ ['array', 'envelope', 'A JSON array instead of an object.', { envelope: [base] }],
217
+ ['null', 'envelope', 'JSON null.', { envelope: null }],
218
+ ['missing-recipients', 'envelope.fields', 'No recipients field.', { envelope: altered(base, (e) => delete e.recipients) }],
219
+ ['extra-field', 'envelope.fields', 'A field the format does not have (metadata goes beside the envelope, never inside).', { envelope: altered(base, (e) => (e.metadata = { team: 'ABCDE12345' })) }],
220
+ ['other-format', 'format', 'A format this version does not know.', { envelope: altered(base, (e) => (e.format = 'ias-vault-2')) }],
221
+ ['credential-string', 'credential', 'The credential is a string.', { envelope: altered(base, (e) => (e.credential = e.credential.id)) }],
222
+ ['credential-extra-field', 'credential', 'The credential has a field besides id, type and version.', { envelope: altered(base, (e) => (e.credential.application = 'jscheduler')) }],
223
+ ['credential-id-empty', 'credential.id', 'An empty credential id.', { envelope: altered(base, (e) => (e.credential.id = '')) }],
224
+ ['credential-id-newline', 'credential.id', 'A newline in the credential id, which would let it forge the associated data.', { envelope: altered(base, (e) => (e.credential.id = `${e.credential.id}\nwrap`)) }],
225
+ ['credential-id-too-long', 'credential.id', 'A credential id of 129 characters.', { envelope: altered(base, (e) => (e.credential.id = 'a'.repeat(129))) }],
226
+ ['credential-type-uppercase', 'credential.type', 'Upper case in the type.', { envelope: altered(base, (e) => (e.credential.type = 'iOS-distribution-certificate')) }],
227
+ ['credential-version-zero', 'credential.version', 'Version 0.', { envelope: altered(base, (e) => (e.credential.version = 0)) }],
228
+ ['credential-version-string', 'credential.version', 'The version as a string.', { envelope: altered(base, (e) => (e.credential.version = '1')) }],
229
+ ['credential-version-fraction', 'credential.version', 'A fractional version.', { envelope: altered(base, (e) => (e.credential.version = 1.5)) }],
230
+ ['credential-version-too-big', 'credential.version', 'A version above 2147483647.', { envelope: altered(base, (e) => (e.credential.version = LIMITS.version + 1)) }],
231
+ ['data-tag-field', 'data', 'The tag apart: in ias-vault-1 it is the last 16 bytes of ct.', { envelope: altered(base, (e) => (e.data.tag = encode(decode(e.data.ct).subarray(-16)))) }],
232
+ ['data-iv-16-bytes', 'data.iv', 'A 16-byte IV.', { envelope: altered(base, (e) => (e.data.iv = encode(bytes('iv 16', 16)))) }],
233
+ ['data-iv-padded', 'data.iv', 'Padding (=) after the base64url.', { envelope: altered(base, (e) => (e.data.iv = `${e.data.iv}=`)) }],
234
+ ['data-iv-standard-base64', 'data.iv', 'Standard base64 (+ and /) instead of base64url.', { envelope: altered(base, (e) => (e.data.iv = '+/+/+/+/+/+/+/+/')) }],
235
+ ['data-ct-tag-only', 'data.ct', 'Only 16 bytes: a tag with no ciphertext (the plaintext is never empty).', { envelope: altered(base, (e) => (e.data.ct = encode(decode(e.data.ct).subarray(-16)))) }],
236
+ ['recipients-empty', 'recipients', 'No recipients.', { envelope: altered(base, (e) => (e.recipients = [])) }],
237
+ ['recipients-object', 'recipients', 'Recipients as an object keyed by kid.', { envelope: altered(base, (e) => (e.recipients = Object.fromEntries(e.recipients.map((r) => [r.kid, r])))) }],
238
+ ['recipients-too-many', 'recipients', `${LIMITS.recipients + 1} recipients.`, { envelope: altered(base, (e) => (e.recipients = Array.from({ length: LIMITS.recipients + 1 }, (_, index) => ({ ...wrap, kid: encode(bytes(`kid ${index}`, 32)) })))) }],
239
+ ['recipient-missing-iv', 'recipient', 'A wrap without iv.', { envelope: altered(base, (e) => delete e.recipients[1].iv) }],
240
+ ['recipient-kind-field', 'recipient', 'A wrap that says what kind of recipient it is: the server knows that from the kid, the envelope does not carry it.', { envelope: altered(base, (e) => (e.recipients[0].kind = 'recovery')) }],
241
+ ['recipient-kid-31-bytes', 'recipient.kid', 'A 31-byte kid.', { envelope: altered(base, (e) => (e.recipients[0].kid = encode(decode(e.recipients[0].kid).subarray(1)))) }],
242
+ ['recipient-kid-hex', 'recipient.kid', 'The kid in hex instead of base64url.', { envelope: altered(base, (e) => (e.recipients[0].kid = decode(e.recipients[0].kid).toString('hex'))) }],
243
+ ['recipient-kid-stray-bits', 'recipient.kid', 'Non-canonical base64url: the last character of the 43 carries a bit beyond the 32 bytes it encodes, so two texts would give the same kid.', { envelope: altered(base, (e) => (e.recipients[0].kid = strayBit(e.recipients[0].kid))) }],
244
+ ['recipient-epk-33-bytes', 'recipient.epk', 'A 33-byte ephemeral key.', { envelope: altered(base, (e) => (e.recipients[1].epk = encode(Buffer.concat([decode(e.recipients[1].epk), Buffer.from([0])])))) }],
245
+ ['recipient-iv-empty', 'recipient.iv', 'An empty wrap IV.', { envelope: altered(base, (e) => (e.recipients[1].iv = '')) }],
246
+ ['recipient-wk-without-tag', 'recipient.wk', 'The wrapped key without its 16-byte tag (32 bytes instead of 48).', { envelope: altered(base, (e) => (e.recipients[1].wk = encode(decode(e.recipients[1].wk).subarray(0, 32)))) }],
247
+ ['recipient-duplicate', 'recipient.duplicate', 'The same kid twice.', { envelope: altered(base, (e) => e.recipients.push(structuredClone(e.recipients[1]))) }],
248
+ ];
249
+ return cases.map(([name, code, description, value]) => {
250
+ const result = validateEnvelope('json' in value ? value.json : value.envelope, V1);
251
+ if (result.valid || result.code !== code) throw new Error(`malformed vector ${name} gives ${result.valid ? 'valid' : result.code}, not ${code}`);
252
+ return { name, code, description, ...value };
253
+ });
254
+ }
255
+
256
+ function tampered(sealedCases, changed, all) {
257
+ const [first, , other] = sealedCases;
258
+ const base = first.envelope;
259
+ const runnerA = all['runner-a'].kid;
260
+ const fromOther = other.envelope.recipients.find((wrap) => wrap.kid === runnerA);
261
+ const fromOtherCredential = sealedCases[1].envelope.recipients[0];
262
+ const cases = [
263
+ ['ciphertext-bit', 'One bit of the first byte of data.ct flipped.', 'decrypt-failed', altered(base, (e) => (e.data.ct = flip(e.data.ct, 0)))],
264
+ ['tag-bit', 'One bit of the tag (the last byte of data.ct) flipped.', 'decrypt-failed', altered(base, (e) => (e.data.ct = flip(e.data.ct)))],
265
+ ['data-iv', 'Another data IV.', 'decrypt-failed', altered(base, (e) => (e.data.iv = encode(bytes('tampered data iv', 12))))],
266
+ ['ciphertext-of-other-envelope', 'The data of the other envelope of the same credential, under the wraps of this one.', 'decrypt-failed', altered(base, (e) => (e.data = structuredClone(other.envelope.data)))],
267
+ ['credential-version', 'The version raised from 1 to 2: every wrap was made for version 1.', 'unwrap-failed', altered(base, (e) => (e.credential.version = 2))],
268
+ ['credential-id', 'Another credential id.', 'unwrap-failed', altered(base, (e) => (e.credential.id = 'ffffffff-4b8f-4c1e-9a3d-2f7b8e1c6a40'))],
269
+ ['credential-type', 'Another credential type.', 'unwrap-failed', altered(base, (e) => (e.credential.type = 'ios-provisioning-profile'))],
270
+ ['kids-swapped', 'The kids of the recovery and runner-a wraps exchanged: runner-a finds the recovery wrap under its kid.', 'unwrap-failed', altered(base, (e) => ([e.recipients[0].kid, e.recipients[1].kid] = [e.recipients[1].kid, e.recipients[0].kid]))],
271
+ ['wrap-of-other-recipient', 'The recovery wrap copied under the kid of runner-a, in place of its own.', 'unwrap-failed', altered(base, (e) => (e.recipients[1] = { ...e.recipients[0], kid: runnerA }))],
272
+ ['wrap-of-other-credential', 'A recovery wrap of another credential, put under runner-a.', 'unwrap-failed', altered(base, (e) => (e.recipients[1] = { ...fromOtherCredential, kid: runnerA }))],
273
+ ['wrap-of-other-envelope', 'The runner-a wrap of another envelope of the same credential in place of its own: it opens, but its data key does not open this data.', 'decrypt-failed', altered(base, (e) => (e.recipients[1] = structuredClone(fromOther)))],
274
+ ['wrapped-key-bit', 'One bit of the runner-a wrapped key flipped.', 'unwrap-failed', altered(base, (e) => (e.recipients[1].wk = flip(e.recipients[1].wk)))],
275
+ ['wrap-iv', 'Another IV in the runner-a wrap.', 'unwrap-failed', altered(base, (e) => (e.recipients[1].iv = encode(bytes('tampered wrap iv', 12))))],
276
+ ['ephemeral-key', 'Another (valid) ephemeral public key in the runner-a wrap.', 'unwrap-failed', altered(base, (e) => (e.recipients[1].epk = all.outsider.publicKey))],
277
+ ['ephemeral-key-zero', 'An all-zero ephemeral key in the runner-a wrap: a low-order point whose X25519 secret is zero.', 'unwrap-failed', altered(base, (e) => (e.recipients[1].epk = encode(Buffer.alloc(32))))],
278
+ ['runner-a-removed', 'The runner-a wrap removed.', 'not-a-recipient', altered(base, (e) => (e.recipients = e.recipients.filter((wrap) => wrap.kid !== runnerA)))],
279
+ ];
280
+ const result = cases.map(([name, description, error, envelope]) => ({ name, description, key: 'runner-a', error, envelope }));
281
+ result.push({ name: 'outsider', description: 'A valid envelope opened with a key that is not a recipient.', key: 'outsider', error: 'not-a-recipient', envelope: base });
282
+ result.push({ name: 'revoked-runner-a', description: 'runner-a after its revocation (sealed.json, revoke-runner-a).', key: 'runner-a', error: 'not-a-recipient', envelope: changed[1].envelope });
283
+ result.push({ name: 'recovery-after-version-change', description: 'The recovery key also refuses the envelope whose version was raised.', key: 'recovery', error: 'unwrap-failed', envelope: result.find((entry) => entry.name === 'credential-version').envelope });
284
+ for (const entry of result) {
285
+ const shape = validateEnvelope(entry.envelope, V1);
286
+ if (!shape.valid) throw new Error(`tampered vector ${entry.name} is not well formed: ${shape.code}`);
287
+ try {
288
+ open(entry.envelope, all[entry.key].privateKey, V1);
289
+ throw new Error(`tampered vector ${entry.name} opens`);
290
+ } catch (error) {
291
+ if (error.code !== entry.error) throw new Error(`tampered vector ${entry.name} fails with ${error.code ?? error.message}, not ${entry.error}`);
292
+ }
293
+ }
294
+ return result;
295
+ }
296
+
297
+ function vectors1(all) {
298
+ const sealedCases = sealed(all);
299
+ const changed = changes(sealedCases, all);
300
+ const header = (description) => ({ format: FORMAT_1, description, warning: 'Test vectors: the keys are public. Never use them for anything else.' });
301
+ return {
302
+ 'keys.json': {
303
+ ...header('The test recipients. kid is the base64url SHA-256 of the raw 32-byte public key; privateKey is the raw 32-byte X25519 private key in base64url.'),
304
+ keys: all,
305
+ },
306
+ 'sealed.json': {
307
+ ...header('Envelopes made with injected randomness: an implementation must produce the same envelope from the same inputs, and every key in keys.json must get the outcome in opens. changes are a grant (rewrap) and a revocation on top of the first case.'),
308
+ cases: sealedCases,
309
+ changes: changed,
310
+ },
311
+ 'malformed.json': {
312
+ ...header('Envelopes the server must reject before storing them: each one breaks one rule of docs/technical/vault.md §5, and code is the first problem found. A case has json (text to parse) or envelope (a parsed value).'),
313
+ base: 'sealed.json, text-for-recovery-and-runner-a',
314
+ cases: malformed(sealedCases[0].envelope),
315
+ },
316
+ 'tampered.json': {
317
+ ...header('Well-formed envelopes that were altered: the server accepts their shape (it cannot tell), and opening them with key must fail with error.'),
318
+ base: 'sealed.json, text-for-recovery-and-runner-a',
319
+ cases: tampered(sealedCases, changed, all),
320
+ },
321
+ };
322
+ }
323
+
324
+ // =====================================================================================================================
325
+ // ias-vault-2 (test-vectors/ias-vault-2/*.json): signed by the team.
326
+
327
+ /** Fake files: bytes from a label, never real material. */
328
+ const fakeFile = (label, n = 32) => Buffer.concat([Buffer.from(`not a real ${label}: `, 'utf8'), bytes2(`file ${label}`, Math.min(n, 32))]);
329
+
330
+ /** A fake .p8: a PEM block around fake bytes, as the file would look. */
331
+ const fakeP8 = (label) => Buffer.from(`-----BEGIN PRIVATE KEY-----\n${bytes2(`p8 ${label}`, 32).toString('base64')}\n-----END PRIVATE KEY-----\n`, 'utf8');
332
+
333
+ /** One plaintext of each type, as `ias credentials import` and `ias env set` write them. Files are fake. */
334
+ const PLAINTEXT_EXAMPLES = {
335
+ apple_distribution_certificate: { p12: fakeFile('p12'), password: 'not-a-real-p12-password' },
336
+ apple_provisioning_profile: { application: 'jscheduler', mobileprovision: fakeFile('mobileprovision') },
337
+ android_upload_keystore: { application: 'jscheduler', keystore: fakeFile('keystore'), store_password: 'not-a-real-store-password', key_alias: 'upload', key_password: 'not-a-real-key-password' },
338
+ apns_key: { p8: fakeP8('apns'), key_id: 'ABCDE12345', team_id: 'CF798UCN8C' },
339
+ asc_api_key: { p8: fakeP8('asc'), key_id: 'FGHIJ67890', issuer_id: '6a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d' },
340
+ google_service_account: { application: 'jscheduler', json: Buffer.from('{"type":"service_account","project_id":"not-a-real-project"}\n', 'utf8') },
341
+ environment_variable: { application: 'jscheduler', environment: 'production', name: 'SENTRY_AUTH_TOKEN', value: 'not-a-real-sentry-token' },
342
+ };
343
+
344
+ const content = (plaintext) => JSON.parse(plaintext.toString('utf8'));
345
+
346
+ function sealed2(all, signing) {
347
+ const cases = [
348
+ {
349
+ name: 'keystore-for-recovery-and-runner-a',
350
+ description: 'An upload keystore of jscheduler (fake), sealed for the recovery key and runner-a and signed by the team.',
351
+ credential: { id: '00000000-0000-4000-8000-00000000e001', type: 'android_upload_keystore', version: 1 },
352
+ plaintext: encodePlaintext('android_upload_keystore', PLAINTEXT_EXAMPLES.android_upload_keystore),
353
+ recipients: ['recovery', 'runner-a'],
354
+ },
355
+ {
356
+ name: 'variable-for-recovery',
357
+ description: 'A secret environment variable (fake), sealed for the recovery key only, as ias env set does while there are no runners.',
358
+ credential: { id: '00000000-0000-4000-8000-00000000f002', type: 'environment_variable', version: 1 },
359
+ plaintext: encodePlaintext('environment_variable', PLAINTEXT_EXAMPLES.environment_variable),
360
+ recipients: ['recovery'],
361
+ },
362
+ {
363
+ name: 'certificate-version-2-for-three',
364
+ description: 'Version 2 of a distribution certificate of the team (fake), for the recovery key, runner-a and runner-b.',
365
+ credential: { id: '00000000-0000-4000-8000-00000000e010', type: 'apple_distribution_certificate', version: 2 },
366
+ plaintext: encodePlaintext('apple_distribution_certificate', PLAINTEXT_EXAMPLES.apple_distribution_certificate),
367
+ recipients: ['recovery', 'runner-a', 'runner-b'],
368
+ },
369
+ {
370
+ name: 'same-credential-other-data-key',
371
+ description: 'The credential of the first case sealed again with another data key, for runner-a and the recovery key. tampered.json moves its pieces into the first case.',
372
+ credential: { id: '00000000-0000-4000-8000-00000000e001', type: 'android_upload_keystore', version: 1 },
373
+ plaintext: encodePlaintext('android_upload_keystore', { ...PLAINTEXT_EXAMPLES.android_upload_keystore, keystore: fakeFile('other keystore') }),
374
+ recipients: ['runner-a', 'recovery'],
375
+ },
376
+ ];
377
+ const opening = { signedBy: signing.team.publicKey };
378
+ return cases.map(({ name, description, credential, plaintext, recipients }) => {
379
+ const random = {
380
+ dataKey: encode(bytes2(`${name} data key`, 32)),
381
+ dataIv: encode(bytes2(`${name} data iv`, 12)),
382
+ wraps: recipients.map((recipient) => ({ ephemeralPrivateKey: encode(bytes2(`${name} ephemeral ${recipient}`, 32)), iv: encode(bytes2(`${name} wrap iv ${recipient}`, 12)) })),
383
+ };
384
+ const envelope = seal(plaintext, { credential, recipients: recipients.map((recipient) => publicRecipient(all[recipient])), signingKey: signing.team.privateKey, random: queue(randomOrder(random)) });
385
+ return {
386
+ name,
387
+ description,
388
+ plaintext: encode(plaintext),
389
+ content: content(plaintext),
390
+ credential,
391
+ recipients,
392
+ signer: 'team',
393
+ random,
394
+ aad: associatedData(credential).toString('utf8'),
395
+ wraps: envelope.recipients.map((wrap, index) => intermediate(wrap, random.wraps[index].ephemeralPrivateKey, all[recipients[index]].publicKey, credential, FORMAT_2)),
396
+ signedText: signedText(envelope, signing.team.kid).toString('utf8'),
397
+ envelope,
398
+ digest: digest(envelope),
399
+ opens: outcomes(envelope, all, opening),
400
+ };
401
+ });
402
+ }
403
+
404
+ function changes2(sealedCases, all, signing) {
405
+ const [first] = sealedCases;
406
+ const opening = { signedBy: signing.team.publicKey };
407
+ const grantRandom = { wraps: [{ ephemeralPrivateKey: encode(bytes2('grant ephemeral runner-b', 32)), iv: encode(bytes2('grant wrap iv runner-b', 12)) }] };
408
+ const granted = rewrap(first.envelope, all.recovery.privateKey, [publicRecipient(all['runner-b']), publicRecipient(all['runner-a'])], { signingKey: signing.team.privateKey, random: queue(randomOrder(grantRandom)) });
409
+ const revoked = removeRecipient(granted, all['runner-a'].kid, { signingKey: signing.team.privateKey });
410
+ return [
411
+ {
412
+ name: 'grant-runner-b',
413
+ description: 'ias vault grant: the recovery key opens the first case, wraps its data key for runner-b and the team signs the envelope again. runner-a is already a recipient and keeps its wrap.',
414
+ operation: 'rewrap',
415
+ from: first.name,
416
+ opener: 'recovery',
417
+ add: ['runner-b', 'runner-a'],
418
+ signer: 'team',
419
+ random: grantRandom,
420
+ wraps: [intermediate(granted.recipients[2], grantRandom.wraps[0].ephemeralPrivateKey, all['runner-b'].publicKey, first.credential, FORMAT_2)],
421
+ signedText: signedText(granted, signing.team.kid).toString('utf8'),
422
+ envelope: granted,
423
+ digest: digest(granted),
424
+ opens: outcomes(granted, all, opening),
425
+ },
426
+ {
427
+ name: 'revoke-runner-a',
428
+ description: 'ias vault revoke: the wrap of runner-a removed from the previous case and the envelope signed again. The data and the other wraps do not change; the signature does.',
429
+ operation: 'remove',
430
+ from: 'grant-runner-b',
431
+ remove: 'runner-a',
432
+ signer: 'team',
433
+ signedText: signedText(revoked, signing.team.kid).toString('utf8'),
434
+ envelope: revoked,
435
+ digest: digest(revoked),
436
+ opens: outcomes(revoked, all, opening),
437
+ },
438
+ ];
439
+ }
440
+
441
+ /** `envelope` signed again with `key` (another team's key, or the team's after a change), its kid as `kid`. */
442
+ function resigned(envelope, key, kid = key.kid) {
443
+ const { signature, ...rest } = envelope;
444
+ return { ...rest, signature: { kid, sig: signWith(key, signedText(rest, kid)) } };
445
+ }
446
+
447
+ /** An Ed25519 signature of `text` with `key`, whatever it signs: what an insider, or a bug, could sign. */
448
+ function signWith(key, text) {
449
+ const object = createPrivateKey({ key: Buffer.concat([Buffer.from('302e020100300506032b657004220420', 'hex'), decode(key.privateKey)]), format: 'der', type: 'pkcs8' });
450
+ return encode(sign(null, text, object));
451
+ }
452
+
453
+ function malformed2(sealedCases, v1Envelope, all, signing) {
454
+ const base = sealedCases[0].envelope;
455
+ const wrap = base.recipients[0];
456
+ const forRunnerOnly = seal(encodePlaintext('google_service_account', PLAINTEXT_EXAMPLES.google_service_account), {
457
+ credential: { id: '00000000-0000-4000-8000-00000000e020', type: 'google_service_account', version: 1 },
458
+ recipients: [publicRecipient(all['runner-a'])],
459
+ signingKey: signing.team.privateKey,
460
+ random: queue([bytes2('runner only data key', 32), bytes2('runner only data iv', 12), bytes2('runner only ephemeral', 32), bytes2('runner only wrap iv', 12)]),
461
+ });
462
+ const team = signing.team.kid;
463
+ const required = { requiredKids: [all.recovery.kid] };
464
+ const cases = [
465
+ ['not-json', 'json', 'The text is not JSON.', { json: '{"format":"ias-vault-2",' }],
466
+ ['array', 'envelope', 'A JSON array instead of an object.', { envelope: [base] }],
467
+ ['null', 'envelope', 'JSON null.', { envelope: null }],
468
+ ['v1-envelope', 'format', 'A well-formed ias-vault-1 envelope (../sealed.json, text-for-recovery-and-runner-a): unsigned, so the server does not take it.', { envelope: v1Envelope }],
469
+ ['stripped-to-v1', 'format', 'The first case with its signature removed and its format turned into ias-vault-1, to pass as unsigned. It would not open as ias-vault-1 either: every derivation starts with the format.', { envelope: altered(base, (e) => { e.format = FORMAT_1; delete e.signature; }) }],
470
+ ['unknown-format', 'format', 'A format nobody knows yet.', { envelope: altered(base, (e) => (e.format = 'ias-vault-3')) }],
471
+ ['missing-signature', 'envelope.fields', 'No signature: an ias-vault-2 envelope always carries the team\'s.', { envelope: altered(base, (e) => delete e.signature) }],
472
+ ['missing-recipients', 'envelope.fields', 'No recipients field.', { envelope: altered(base, (e) => delete e.recipients) }],
473
+ ['extra-field', 'envelope.fields', 'A field the format does not have (metadata goes beside the envelope, never inside).', { envelope: altered(base, (e) => (e.metadata = { alias: 'upload' })) }],
474
+ ['credential-extra-field', 'credential', 'The credential has a field besides id, type and version.', { envelope: altered(base, (e) => (e.credential.application = 'jscheduler')) }],
475
+ ['credential-id-newline', 'credential.id', 'A newline in the credential id, which would let it forge the associated data and the signed text.', { envelope: altered(base, (e) => (e.credential.id = `${e.credential.id}\nwrap`)) }],
476
+ ['credential-type-hyphen', 'credential.type', 'The type with hyphens, as in ias-vault-1: in ias-vault-2 it is the name of the management API, with underscores.', { envelope: altered(base, (e) => (e.credential.type = 'android-upload-keystore')) }],
477
+ ['credential-type-uppercase', 'credential.type', 'Upper case in the type.', { envelope: altered(base, (e) => (e.credential.type = 'Android_upload_keystore')) }],
478
+ ['credential-version-zero', 'credential.version', 'Version 0.', { envelope: altered(base, (e) => (e.credential.version = 0)) }],
479
+ ['credential-version-string', 'credential.version', 'The version as a string.', { envelope: altered(base, (e) => (e.credential.version = '1')) }],
480
+ ['credential-version-fraction', 'credential.version', 'A fractional version.', { envelope: altered(base, (e) => (e.credential.version = 1.5)) }],
481
+ ['data-tag-field', 'data', 'The tag apart: it is the last 16 bytes of ct.', { envelope: altered(base, (e) => (e.data.tag = encode(decode(e.data.ct).subarray(-16)))) }],
482
+ ['data-iv-padded', 'data.iv', 'Padding (=) after the base64url.', { envelope: altered(base, (e) => (e.data.iv = `${e.data.iv}=`)) }],
483
+ ['data-ct-tag-only', 'data.ct', 'Only 16 bytes: a tag with no ciphertext.', { envelope: altered(base, (e) => (e.data.ct = encode(decode(e.data.ct).subarray(-16)))) }],
484
+ ['recipients-empty', 'recipients', 'No recipients.', { envelope: altered(base, (e) => (e.recipients = [])) }],
485
+ ['recipients-too-many', 'recipients', `${LIMITS.recipients + 1} recipients.`, { envelope: altered(base, (e) => (e.recipients = Array.from({ length: LIMITS.recipients + 1 }, (_, index) => ({ ...wrap, kid: encode(bytes2(`kid ${index}`, 32)) })))) }],
486
+ ['recipient-kind-field', 'recipient', 'A wrap that says what kind of recipient it is.', { envelope: altered(base, (e) => (e.recipients[0].kind = 'recovery')) }],
487
+ ['recipient-kid-stray-bits', 'recipient.kid', 'Non-canonical base64url in a kid.', { envelope: altered(base, (e) => (e.recipients[0].kid = strayBit(e.recipients[0].kid))) }],
488
+ ['recipient-wk-without-tag', 'recipient.wk', 'The wrapped key without its 16-byte tag.', { envelope: altered(base, (e) => (e.recipients[1].wk = encode(decode(e.recipients[1].wk).subarray(0, 32)))) }],
489
+ ['recipient-duplicate', 'recipient.duplicate', 'The same kid twice.', { envelope: altered(base, (e) => e.recipients.push(structuredClone(e.recipients[1]))) }],
490
+ ['signature-null', 'signature', 'The signature is null.', { envelope: altered(base, (e) => (e.signature = null)) }],
491
+ ['signature-string', 'signature', 'The signature as a bare string, without its kid.', { envelope: altered(base, (e) => (e.signature = e.signature.sig)) }],
492
+ ['signature-array', 'signature', 'The signature as a list.', { envelope: altered(base, (e) => (e.signature = [e.signature])) }],
493
+ ['signature-extra-field', 'signature', 'A field besides kid and sig: the algorithm is always Ed25519, it is not said.', { envelope: altered(base, (e) => (e.signature.alg = 'Ed25519')) }],
494
+ ['signature-missing-kid', 'signature', 'A signature without its kid.', { envelope: altered(base, (e) => delete e.signature.kid) }],
495
+ ['signature-kid-31-bytes', 'signature.kid', 'A 31-byte signing kid.', { envelope: altered(base, (e) => (e.signature.kid = encode(decode(e.signature.kid).subarray(1)))) }],
496
+ ['signature-kid-padded', 'signature.kid', 'Padding (=) after the signing kid.', { envelope: altered(base, (e) => (e.signature.kid = `${e.signature.kid}=`)) }],
497
+ ['signature-sig-empty', 'signature.sig', 'An empty signature.', { envelope: altered(base, (e) => (e.signature.sig = '')) }],
498
+ ['signature-sig-63-bytes', 'signature.sig', 'A signature of 63 bytes instead of 64.', { envelope: altered(base, (e) => (e.signature.sig = encode(decode(e.signature.sig).subarray(1)))) }],
499
+ ['signature-sig-65-bytes', 'signature.sig', 'A signature of 65 bytes instead of 64.', { envelope: altered(base, (e) => (e.signature.sig = encode(Buffer.concat([decode(e.signature.sig), Buffer.from([0])])))) }],
500
+ ['signature-sig-stray-bits', 'signature.sig', 'Non-canonical base64url: the last of the 86 characters carries bits beyond the 64 bytes.', { envelope: altered(base, (e) => (e.signature.sig = strayBit(e.signature.sig))) }],
501
+ ['signature-sig-standard-base64', 'signature.sig', 'The signature in standard base64, with + and / and padding.', { envelope: altered(base, (e) => (e.signature.sig = decode(e.signature.sig).toString('base64'))) }],
502
+ ['signature-sig-hex', 'signature.sig', 'The signature in hex (128 characters) instead of base64url.', { envelope: altered(base, (e) => (e.signature.sig = decode(e.signature.sig).toString('hex'))) }],
503
+ ['recovery-key-missing', 'recipient.missing', 'A well-signed envelope sealed for runner-a only: the server requires the recovery key (options.requiredKids), or nobody could grant it to another runner. The API answers recovery_key_missing.', { envelope: forRunnerOnly, options: required }],
504
+ ['signed-by-another-key', 'signature.unknown', 'The first case signed, correctly, by another team\'s key (other-team) with its own kid. The server takes only its registered key (options.signingKid) and the API answers signature_key_unknown; the runner refuses it the same way, with the key it pinned.', { envelope: resigned(base, signing['other-team']), options: { signingKid: team } }],
505
+ ['signature-kid-of-a-recipient', 'signature.unknown', 'The kid of the signature replaced by the recovery key\'s kid: a recipient kid is not a signing key.', { envelope: altered(base, (e) => (e.signature.kid = all.recovery.kid)), options: { signingKid: team } }],
506
+ ];
507
+ return cases.map(([name, code, description, value]) => {
508
+ const result = validateEnvelope('json' in value ? value.json : value.envelope, value.options ?? {});
509
+ if (result.valid || result.code !== code) throw new Error(`malformed vector ${name} gives ${result.valid ? 'valid' : result.code}, not ${code}`);
510
+ return { name, code, description, ...value };
511
+ });
512
+ }
513
+
514
+ function tampered2(sealedCases, changed, all, signing) {
515
+ const [first, , , other] = sealedCases;
516
+ const base = first.envelope;
517
+ const runnerA = all['runner-a'].kid;
518
+ const fromOther = other.envelope.recipients.find((wrap) => wrap.kid === runnerA);
519
+ const team = signing.team;
520
+ /** Altered, then signed again by the team: the signature verifies and the encryption still refuses it. */
521
+ const teamSigned = (envelope) => resigned(envelope, team);
522
+ const bySomeoneElse = seal(encodePlaintext('android_upload_keystore', { ...PLAINTEXT_EXAMPLES.android_upload_keystore, keystore: fakeFile('keystore of an attacker') }), {
523
+ credential: first.credential,
524
+ recipients: [publicRecipient(all.recovery), publicRecipient(all['runner-a'])],
525
+ signingKey: signing['other-team'].privateKey,
526
+ random: queue([bytes2('attacker data key', 32), bytes2('attacker data iv', 12), bytes2('attacker ephemeral recovery', 32), bytes2('attacker wrap iv recovery', 12), bytes2('attacker ephemeral runner-a', 32), bytes2('attacker wrap iv runner-a', 12)]),
527
+ });
528
+ const unsigned = 'signature-invalid';
529
+ const cases = [
530
+ ['ciphertext-bit', 'One bit of the first byte of data.ct flipped.', 'runner-a', unsigned, altered(base, (e) => (e.data.ct = flip(e.data.ct, 0)))],
531
+ ['tag-bit', 'One bit of the tag (the last byte of data.ct) flipped.', 'runner-a', unsigned, altered(base, (e) => (e.data.ct = flip(e.data.ct)))],
532
+ ['data-iv', 'Another data IV.', 'runner-a', unsigned, altered(base, (e) => (e.data.iv = encode(bytes2('tampered data iv', 12))))],
533
+ ['ciphertext-of-other-envelope', 'The data of the other envelope of the same credential, under the wraps of this one.', 'runner-a', unsigned, altered(base, (e) => (e.data = structuredClone(other.envelope.data)))],
534
+ ['credential-version', 'The version raised from 1 to 2.', 'runner-a', unsigned, altered(base, (e) => (e.credential.version = 2))],
535
+ ['credential-id', 'Another credential id.', 'runner-a', unsigned, altered(base, (e) => (e.credential.id = '00000000-0000-4000-8000-0000000000ff'))],
536
+ ['credential-type', 'Another credential type.', 'runner-a', unsigned, altered(base, (e) => (e.credential.type = 'google_service_account'))],
537
+ ['kids-swapped', 'The kids of the recovery and runner-a wraps exchanged.', 'runner-a', unsigned, altered(base, (e) => ([e.recipients[0].kid, e.recipients[1].kid] = [e.recipients[1].kid, e.recipients[0].kid]))],
538
+ ['wraps-reordered', 'The two wraps, intact, in the other order: each still opens for its key, but the signature covers the order of the list.', 'runner-a', unsigned, altered(base, (e) => e.recipients.reverse())],
539
+ ['wrap-of-other-recipient', 'The recovery wrap copied under the kid of runner-a, in place of its own.', 'runner-a', unsigned, altered(base, (e) => (e.recipients[1] = { ...e.recipients[0], kid: runnerA }))],
540
+ ['wrap-of-other-envelope', 'The runner-a wrap of another envelope of the same credential in place of its own.', 'runner-a', unsigned, altered(base, (e) => (e.recipients[1] = structuredClone(fromOther)))],
541
+ ['wrapped-key-bit', 'One bit of the runner-a wrapped key flipped.', 'runner-a', unsigned, altered(base, (e) => (e.recipients[1].wk = flip(e.recipients[1].wk)))],
542
+ ['wrap-iv', 'Another IV in the runner-a wrap.', 'runner-a', unsigned, altered(base, (e) => (e.recipients[1].iv = encode(bytes2('tampered wrap iv', 12))))],
543
+ ['ephemeral-key', 'Another (valid) ephemeral public key in the runner-a wrap.', 'runner-a', unsigned, altered(base, (e) => (e.recipients[1].epk = all.outsider.publicKey))],
544
+ ['wrap-removed-by-the-server', 'The runner-a wrap removed without signing again, as a server would do it: the recovery key no longer trusts the envelope. Removing a wrap is ias vault revoke, which signs again (sealed.json, revoke-runner-a).', 'recovery', unsigned, altered(base, (e) => (e.recipients = e.recipients.filter((wrap) => wrap.kid !== runnerA)))],
545
+ ['wrap-added-by-the-server', 'A wrap for outsider added without signing (a copy of runner-a\'s under its kid), as a server that wanted to grant itself would do: the signature no longer verifies.', 'outsider', unsigned, altered(base, (e) => e.recipients.push({ ...e.recipients[1], kid: all.outsider.kid }))],
546
+ ['signature-bit', 'One bit of the signature flipped.', 'runner-a', unsigned, altered(base, (e) => (e.signature.sig = flip(e.signature.sig, 0)))],
547
+ ['signature-of-other-envelope', 'The signature of the other envelope of the same credential.', 'runner-a', unsigned, altered(base, (e) => (e.signature = structuredClone(other.envelope.signature)))],
548
+ ['forged-under-the-team-kid', 'The first case signed by another key but with the kid of the team: the server sees its registered kid and takes it; the runner checks the signature and refuses it.', 'runner-a', unsigned, resigned(base, signing['other-team'], team.kid)],
549
+ ['sealed-by-someone-else', 'docs/technical/vault.md §11: someone with the public keys (a compromised server) seals its own keystore for the recovery key and runner-a, under the id of the first case, and signs it with its own key under the team\'s kid. Well formed, sealed for registered keys, the registered signing kid: only the runner\'s check of the signature stops it.', 'runner-a', unsigned, { ...bySomeoneElse, signature: { kid: team.kid, sig: bySomeoneElse.signature.sig } }],
550
+ ['signed-again-after-version-change', 'The version raised and the envelope signed again by the team (a bug, or the team key in the wrong hands): the signature verifies, and every wrap still refuses another version.', 'runner-a', 'unwrap-failed', teamSigned(altered(base, (e) => (e.credential.version = 2)))],
551
+ ['signed-again-after-ciphertext-bit', 'One bit of data.ct flipped and the envelope signed again by the team: the data does not authenticate.', 'runner-a', 'decrypt-failed', teamSigned(altered(base, (e) => (e.data.ct = flip(e.data.ct, 0))))],
552
+ ['signed-again-with-wrap-of-other-envelope', 'The runner-a wrap of another envelope, signed again by the team: it opens, but its data key does not open this data.', 'runner-a', 'decrypt-failed', teamSigned(altered(base, (e) => (e.recipients[1] = structuredClone(fromOther))))],
553
+ ['signed-again-with-kids-swapped', 'The kids of the two wraps exchanged and signed again by the team.', 'runner-a', 'unwrap-failed', teamSigned(altered(base, (e) => ([e.recipients[0].kid, e.recipients[1].kid] = [e.recipients[1].kid, e.recipients[0].kid])))],
554
+ ['signed-again-with-ephemeral-key-zero', 'An all-zero ephemeral key in the runner-a wrap, signed again by the team.', 'runner-a', 'unwrap-failed', teamSigned(altered(base, (e) => (e.recipients[1].epk = encode(Buffer.alloc(32)))))],
555
+ ];
556
+ const result = cases.map(([name, description, key, error, envelope]) => ({ name, description, key, error, envelope }));
557
+ result.push({ name: 'outsider', description: 'A valid envelope opened with a key that is not a recipient.', key: 'outsider', error: 'not-a-recipient', envelope: base });
558
+ result.push({ name: 'revoked-runner-a', description: 'runner-a after its revocation (sealed.json, revoke-runner-a).', key: 'runner-a', error: 'not-a-recipient', envelope: changed[1].envelope });
559
+ const server = { requiredKids: [all.recovery.kid], signingKid: team.kid };
560
+ for (const entry of result) {
561
+ const shape = validateEnvelope(entry.envelope, server);
562
+ if (!shape.valid) throw new Error(`tampered vector ${entry.name} is not well formed: ${shape.code}`);
563
+ try {
564
+ open(entry.envelope, all[entry.key].privateKey, { signedBy: team.publicKey });
565
+ throw new Error(`tampered vector ${entry.name} opens`);
566
+ } catch (error) {
567
+ if (error.code !== entry.error) throw new Error(`tampered vector ${entry.name} fails with ${error.code ?? error.message}, not ${entry.error}`);
568
+ }
569
+ }
570
+ return result;
571
+ }
572
+
573
+ function runnerLists(all, signing) {
574
+ const team = signing.team;
575
+ const listOf = (serial, names) => signRunnerList({ serial, runners: names.map((name) => publicRecipient(all[name])) }, team.privateKey);
576
+ const steps = [
577
+ ['first-runner', 'runner-a is authorised: the first list.', 1, ['runner-a']],
578
+ ['second-runner', 'runner-b is authorised too.', 2, ['runner-a', 'runner-b']],
579
+ ['runner-a-revoked', 'runner-a is revoked: the list goes on without it, with a higher serial.', 3, ['runner-b']],
580
+ ['empty', 'Every runner revoked: an empty list is still signed.', 4, []],
581
+ ];
582
+ const lists = steps.map(([name, description, serial, names]) => {
583
+ const list = listOf(serial, names);
584
+ return { name, description, runners: names, signedText: runnerListText(list, team.kid).toString('utf8'), list, verified: verifyRunnerList(list, team.publicKey) };
585
+ });
586
+ const base = lists[1].list;
587
+ const malformedCases = [
588
+ ['array', 'runners', 'A list instead of an object.', [base]],
589
+ ['other-format', 'runners.format', 'Another format.', altered(base, (l) => (l.format = 'ias-vault-1/runners'))],
590
+ ['missing-signature', 'runners.fields', 'No signature.', altered(base, (l) => delete l.signature)],
591
+ ['extra-field', 'runners.fields', 'A field besides format, serial, runners and signature.', altered(base, (l) => (l.issued_at = '2026-10-21T10:00:00+00:00'))],
592
+ ['serial-zero', 'runners.serial', 'Serial 0.', altered(base, (l) => (l.serial = 0))],
593
+ ['serial-string', 'runners.serial', 'The serial as a string.', altered(base, (l) => (l.serial = '2'))],
594
+ ['runners-object', 'runners.list', 'The runners as an object keyed by kid.', altered(base, (l) => (l.runners = Object.fromEntries(l.runners.map((r) => [r.kid, r]))))],
595
+ ['runner-name-field', 'runner', 'A runner with a name: names are the server\'s, the list is only keys.', altered(base, (l) => (l.runners[0].name = 'Mac mini'))],
596
+ ['runner-kid-padded', 'runner.kid', 'Padding after a kid.', altered(base, (l) => (l.runners[0].kid = `${l.runners[0].kid}=`))],
597
+ ['runner-public-key-31-bytes', 'runner.public_key', 'A 31-byte public key.', altered(base, (l) => (l.runners[0].public_key = encode(decode(l.runners[0].public_key).subarray(1))))],
598
+ ['runner-kid-of-other-key', 'runner.key', 'The kid of runner-b beside the public key of runner-a.', altered(base, (l) => (l.runners[0].kid = l.runners[1].kid))],
599
+ ['runner-duplicate', 'runner.duplicate', 'The same runner twice.', altered(base, (l) => l.runners.push(structuredClone(l.runners[0])))],
600
+ ['signature-sig-63-bytes', 'signature.sig', 'A 63-byte signature.', altered(base, (l) => (l.signature.sig = encode(decode(l.signature.sig).subarray(1))))],
601
+ ['signed-by-another-key', 'signature.unknown', 'The list signed by another team\'s key with its own kid (options.signingKid is the team\'s).', signRunnerList({ serial: 2, runners: [publicRecipient(all['runner-a']), publicRecipient(all.outsider)] }, signing['other-team'].privateKey), { signingKid: team.kid }],
602
+ ].map(([name, code, description, list, options]) => {
603
+ const result = validateRunnerList(list, options ?? {});
604
+ if (result.valid || result.code !== code) throw new Error(`malformed runner list ${name} gives ${result.valid ? 'valid' : result.code}, not ${code}`);
605
+ return { name, code, description, list, ...(options ? { options } : {}) };
606
+ });
607
+ const tamperedCases = [
608
+ ['runner-added-by-the-server', 'outsider added by the server without signing: the CLI must never seal for it.', altered(base, (l) => l.runners.push({ kid: all.outsider.kid, public_key: all.outsider.publicKey }))],
609
+ ['runner-removed-without-signing', 'runner-b removed without signing.', altered(base, (l) => l.runners.pop())],
610
+ ['serial-raised', 'The serial raised without signing.', altered(base, (l) => (l.serial = 9))],
611
+ ['runners-reordered', 'The two runners in the other order.', altered(base, (l) => l.runners.reverse())],
612
+ ['forged-under-the-team-kid', 'The list with outsider signed by another key under the team\'s kid.', (() => {
613
+ const forged = signRunnerList({ serial: 3, runners: [publicRecipient(all['runner-a']), publicRecipient(all.outsider)] }, signing['other-team'].privateKey);
614
+ return { ...forged, signature: { kid: team.kid, sig: forged.signature.sig } };
615
+ })()],
616
+ ].map(([name, description, list]) => {
617
+ if (!validateRunnerList(list, { signingKid: team.kid }).valid) throw new Error(`tampered runner list ${name} is not well formed`);
618
+ try {
619
+ verifyRunnerList(list, team.publicKey);
620
+ throw new Error(`tampered runner list ${name} verifies`);
621
+ } catch (error) {
622
+ if (error.code !== 'signature-invalid') throw new Error(`tampered runner list ${name} fails with ${error.code ?? error.message}`);
623
+ }
624
+ return { name, description, error: 'signature-invalid', list };
625
+ });
626
+ return { lists, malformed: malformedCases, tampered: tamperedCases };
627
+ }
628
+
629
+ function plaintexts() {
630
+ const good = Object.entries(PLAINTEXT_EXAMPLES).map(([type, fields]) => {
631
+ const plaintext = encodePlaintext(type, fields);
632
+ return { type, scope: PLAINTEXT_TYPES[type].scope, text: plaintext.toString('utf8'), plaintext: encode(plaintext) };
633
+ });
634
+ const keystore = JSON.parse(good.find((entry) => entry.type === 'android_upload_keystore').text);
635
+ const variable = JSON.parse(good.find((entry) => entry.type === 'environment_variable').text);
636
+ const asc = JSON.parse(good.find((entry) => entry.type === 'asc_api_key').text);
637
+ const text = (value) => JSON.stringify(value);
638
+ const bad = [
639
+ ['not-json', 'android_upload_keystore', 'plaintext.json', 'Not JSON.', { text: '{"type":"android_upload_keystore",' }],
640
+ ['not-utf-8', 'android_upload_keystore', 'plaintext.json', 'Bytes that are not UTF-8.', { plaintext: encode(Buffer.from([0x7b, 0xff, 0x7d])) }],
641
+ ['array', 'android_upload_keystore', 'plaintext.json', 'A JSON list.', { text: text([keystore]) }],
642
+ ['missing-field', 'android_upload_keystore', 'plaintext.fields', 'Without key_password.', { text: text({ ...keystore, key_password: undefined }) }],
643
+ ['extra-field', 'android_upload_keystore', 'plaintext.fields', 'A field the type does not have.', { text: text({ ...keystore, sha256: 'AA' }) }],
644
+ ['other-type', 'android_upload_keystore', 'plaintext.type', 'The fields of a keystore under another type field.', { text: text({ ...keystore, type: 'apple_provisioning_profile' }) }],
645
+ ['file-empty', 'android_upload_keystore', 'plaintext.keystore', 'An empty keystore.', { text: text({ ...keystore, keystore: '' }) }],
646
+ ['file-standard-base64', 'android_upload_keystore', 'plaintext.keystore', 'The keystore in standard base64 with padding.', { text: text({ ...keystore, keystore: Buffer.from(keystore.keystore, 'base64url').toString('base64') }) }],
647
+ ['password-number', 'android_upload_keystore', 'plaintext.store_password', 'A password that is a number.', { text: text({ ...keystore, store_password: 123456 }) }],
648
+ ['application-uppercase', 'android_upload_keystore', 'plaintext.application', 'An application code in capitals.', { text: text({ ...keystore, application: 'jScheduler' }) }],
649
+ ['variable-expo-public', 'environment_variable', 'plaintext.name', 'An EXPO_PUBLIC_ variable: those are always plain, never sealed.', { text: text({ ...variable, name: 'EXPO_PUBLIC_API_KEY' }) }],
650
+ ['variable-environment', 'environment_variable', 'plaintext.environment', 'An environment that is not one of the three.', { text: text({ ...variable, environment: 'staging' }) }],
651
+ ['asc-issuer', 'asc_api_key', 'plaintext.issuer_id', 'An issuer id that is not a UUID.', { text: text({ ...asc, issuer_id: 'not-a-uuid' }) }],
652
+ ['unknown-type', 'build_secret', 'plaintext.type', 'A type without a plaintext.', { text: text({ type: 'build_secret', value: 'x' }) }],
653
+ ].map(([name, type, code, description, value]) => {
654
+ const raw = 'text' in value ? Buffer.from(value.text, 'utf8') : decode(value.plaintext);
655
+ const result = validatePlaintext(type, raw);
656
+ if (result.valid || result.code !== code) throw new Error(`bad plaintext ${name} gives ${result.valid ? 'valid' : result.code}, not ${code}`);
657
+ return { name, type, code, description, ...value };
658
+ });
659
+ return { good, bad };
660
+ }
661
+
662
+ function vectors2(all) {
663
+ const signing = signingKeys();
664
+ const sealedCases = sealed2(all, signing);
665
+ const changed = changes2(sealedCases, all, signing);
666
+ const v1Envelope = sealed(all)[0].envelope;
667
+ const header = (description) => ({ format: FORMAT_2, description, warning: 'Test vectors: the keys are public. Never use them for anything else.' });
668
+ const runners = runnerLists(all, signing);
669
+ const plain = plaintexts();
670
+ return {
671
+ 'ias-vault-2/keys.json': {
672
+ ...header('The test keys of ias-vault-2. recipients are the X25519 keys of ../keys.json (the same four). signing are Ed25519 keys: team is the team\'s, registered on the server and pinned by the runners; other-team is someone else\'s. privateKey is the 32-byte Ed25519 seed in base64url, publicKey the raw 32-byte public key and kid its base64url SHA-256, as for recipients.'),
673
+ recipients: all,
674
+ signing,
675
+ },
676
+ 'ias-vault-2/sealed.json': {
677
+ ...header('Envelopes made with injected randomness and signed by the team (Ed25519 is deterministic): an implementation must produce the same envelope from the same inputs, signedText is what the signature covers, and every key gets the outcome in opens when it checks the signature against the team key. changes are a grant (rewrap) and a revocation on top of the first case, each signed again.'),
678
+ server: { formats: [FORMAT_2], requiredKids: [all.recovery.kid], signingKid: signing.team.kid },
679
+ cases: sealedCases,
680
+ changes: changed,
681
+ },
682
+ 'ias-vault-2/malformed.json': {
683
+ ...header('Envelopes the server must reject before storing them: each one breaks one rule of docs/technical/vault.md §6, and code is the first problem found. A case has json (text to parse) or envelope (a parsed value), and options when the rule needs them: requiredKids (the recovery key) or signingKid (the registered signing key).'),
684
+ base: 'sealed.json, keystore-for-recovery-and-runner-a',
685
+ cases: malformed2(sealedCases, v1Envelope, all, signing),
686
+ },
687
+ 'ias-vault-2/tampered.json': {
688
+ ...header('Well-formed envelopes that were altered. The server accepts their shape with its options (server: requiredKids and signingKid), because it does not verify signatures; opening them with key, checking the signature against the team key, must fail with error. Most fail at the signature (signature-invalid); the ones signed again by the team show that the encryption still binds everything.'),
689
+ base: 'sealed.json, keystore-for-recovery-and-runner-a',
690
+ server: { formats: [FORMAT_2], requiredKids: [all.recovery.kid], signingKid: signing.team.kid },
691
+ cases: tampered2(sealedCases, changed, all, signing),
692
+ },
693
+ 'ias-vault-2/runners.json': {
694
+ ...header(`The list of authorised runners (${RUNNERS_FORMAT}), signed by the team: lists are good ones, each with its signedText and the runners it gives; malformed must be rejected with code (validateRunnerList); tampered are well formed but their signature does not verify.`),
695
+ ...runners,
696
+ },
697
+ 'ias-vault-2/approvers.json': approverVectors(signing),
698
+ 'ias-vault-2/plaintexts.json': {
699
+ ...header('What an envelope holds for each credential type (docs/technical/vault.md §3.5): good has one plaintext per type, with fake files; bad must be refused with code by validatePlaintext(type, bytes). text is the UTF-8 of the plaintext; plaintext, its bytes in base64url.'),
700
+ ...plain,
701
+ },
702
+ };
703
+ }
704
+
705
+ /** Every vector file, by its path inside test-vectors, as the JSON value it holds. */
706
+ export function buildVectors() {
707
+ const all = keys();
708
+ return { ...vectors1(all), ...vectors2(all) };
709
+ }
710
+
711
+ export const serialize = (value) => `${JSON.stringify(value, null, 2)}\n`;
712
+
713
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
714
+ for (const [name, value] of Object.entries(buildVectors())) {
715
+ const path = join(VECTORS_DIR, name);
716
+ mkdirSync(dirname(path), { recursive: true });
717
+ writeFileSync(path, serialize(value));
718
+ console.log(`wrote ${path}`);
719
+ }
720
+ }