@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,234 @@
1
+ // The list of the passkeys that may approve a store job (ias-approvers-1, docs/technical/runner-api.md §1 and docs/technical/vault.md
2
+ // §2.4), signed by the team with the same Ed25519 key as the envelopes and the runner list. A runner cannot ask the
3
+ // server which passkeys belong to an owner (a compromised server would add its own), so it checks the approval of a
4
+ // job against this document, after verifying it with the team key it pinned. ias vault approvers signs it; the server
5
+ // stores it and hands it out with each store job, and checks only its shape. Only node:crypto.
6
+ import { Buffer } from 'node:buffer';
7
+ import { createHash, createPrivateKey, createPublicKey, sign, verify } from 'node:crypto';
8
+ import { KEY_BYTES, LIMITS, SIGNATURE_BYTES, VaultError, decode, encode, keyId, signingKeyFromPrivateKey } from './index.mjs';
9
+
10
+ export const APPROVERS_FORMAT = 'ias-approvers-1';
11
+
12
+ /** The COSE algorithms a passkey of an approver may use (docs/technical/runner-api.md §3.3, step 6). */
13
+ export const APPROVER_ALGORITHMS = Object.freeze({ ES256: -7, RS256: -257 });
14
+
15
+ /** The limits of the document. The server enforces the same ones; changing them changes the format. */
16
+ export const APPROVER_LIMITS = Object.freeze({
17
+ approvers: 64,
18
+ origins: 8,
19
+ credentialIdBytes: 1023,
20
+ publicKeyBytes: 2048,
21
+ name: 100,
22
+ rsaBits: 2048,
23
+ hostname: 253,
24
+ });
25
+
26
+ const FIELDS = ['format', 'version', 'rp_id', 'origins', 'approvers', 'signature'];
27
+ const APPROVER_FIELDS = ['credential_id', 'public_key', 'alg', 'member', 'name'];
28
+ const MEMBER = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
29
+ const LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?';
30
+ const HOSTNAME = new RegExp(`^${LABEL}(?:\\.${LABEL})*$`);
31
+ const ORIGIN = new RegExp(`^(https)://(${LABEL}(?:\\.${LABEL})*)(?::([1-9][0-9]{0,4}))?$|^(http)://(localhost)(?::([1-9][0-9]{0,4}))?$`);
32
+ const DEFAULT_PORTS = { https: 443, http: 80 };
33
+ const CONTROL = /[\u0000-\u001f\u007f-\u009f]/;
34
+
35
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
36
+ const sameKeys = (object, keys) => {
37
+ const own = Object.keys(object).sort();
38
+ return own.length === keys.length && [...keys].sort().every((key, index) => own[index] === key);
39
+ };
40
+
41
+ /** A lowercase DNS name that is not an IP address (its last label is not only digits): what WebAuthn takes as rp_id. */
42
+ const isHostname = (value) => typeof value === 'string' && value.length <= APPROVER_LIMITS.hostname && HOSTNAME.test(value) && !/^[0-9]+$/.test(value.split('.').at(-1));
43
+
44
+ /**
45
+ * Whether `value` is an origin exactly as a browser writes it in clientDataJSON: https://<host>[:port], or
46
+ * http://localhost[:port] to try it locally; lowercase, no path, no default port.
47
+ */
48
+ function isOrigin(value) {
49
+ const match = typeof value === 'string' ? ORIGIN.exec(value) : null;
50
+ if (!match) return false;
51
+ const [scheme, host, port] = match[1] ? [match[1], match[2], match[3]] : [match[4], match[5], match[6]];
52
+ if (!isHostname(host)) return false;
53
+ return port === undefined || (Number(port) <= 65535 && Number(port) !== DEFAULT_PORTS[scheme]);
54
+ }
55
+
56
+ /**
57
+ * The COSE algorithm of a passkey's public key (SubjectPublicKeyInfo, DER, in base64url): -7 for a P-256 key and -257
58
+ * for RSA of 2048 bits or more, or null if it is anything else, or not in the canonical DER that node:crypto (and
59
+ * OpenSSL in PHP) writes again from it.
60
+ */
61
+ export function approverAlgorithm(publicKey) {
62
+ const der = decode(publicKey);
63
+ if (der === null || der.length === 0 || der.length > APPROVER_LIMITS.publicKeyBytes) return null;
64
+ let key;
65
+ try {
66
+ key = createPublicKey({ key: der, format: 'der', type: 'spki' });
67
+ } catch {
68
+ return null;
69
+ }
70
+ if (!key.export({ format: 'der', type: 'spki' }).equals(der)) return null;
71
+ if (key.asymmetricKeyType === 'ec' && key.asymmetricKeyDetails?.namedCurve === 'prime256v1') return APPROVER_ALGORITHMS.ES256;
72
+ if (key.asymmetricKeyType === 'rsa' && key.asymmetricKeyDetails?.modulusLength >= APPROVER_LIMITS.rsaBits) return APPROVER_ALGORITHMS.RS256;
73
+ return null;
74
+ }
75
+
76
+ /**
77
+ * The fingerprint of a passkey that `ias vault approvers` shows for a person to compare: the SHA-256 of its public key
78
+ * (the DER bytes), in base64url, as the kid of a vault key.
79
+ */
80
+ export function approverFingerprint(publicKey) {
81
+ const der = decode(publicKey);
82
+ if (der === null || der.length === 0) throw new VaultError('approver.public_key', 'a public key is a SubjectPublicKeyInfo in base64url');
83
+ return encode(createHash('sha256').update(der).digest());
84
+ }
85
+
86
+ /** The first problem of one approver, as [code, message], or null. */
87
+ function approverProblem(approver) {
88
+ if (!isRecord(approver) || !sameKeys(approver, APPROVER_FIELDS)) return ['approver', `each approver must be an object with exactly ${APPROVER_FIELDS.join(', ')}`];
89
+ const credentialId = decode(approver.credential_id);
90
+ if (credentialId === null || credentialId.length === 0 || credentialId.length > APPROVER_LIMITS.credentialIdBytes) return ['approver.credential_id', `credential_id must be 1 to ${APPROVER_LIMITS.credentialIdBytes} bytes in unpadded base64url`];
91
+ const der = decode(approver.public_key);
92
+ let parsed = null;
93
+ try {
94
+ parsed = der === null || der.length === 0 || der.length > APPROVER_LIMITS.publicKeyBytes ? null : createPublicKey({ key: der, format: 'der', type: 'spki' });
95
+ } catch {
96
+ parsed = null;
97
+ }
98
+ if (parsed === null || !parsed.export({ format: 'der', type: 'spki' }).equals(der)) return ['approver.public_key', `public_key must be a SubjectPublicKeyInfo in canonical DER, of at most ${APPROVER_LIMITS.publicKeyBytes} bytes, in unpadded base64url`];
99
+ if (!Object.values(APPROVER_ALGORITHMS).includes(approver.alg)) return ['approver.alg', `alg must be ${APPROVER_ALGORITHMS.ES256} (ES256) or ${APPROVER_ALGORITHMS.RS256} (RS256)`];
100
+ if (approverAlgorithm(approver.public_key) !== approver.alg) return ['approver.key', `the public key is not one of alg ${approver.alg}: ${approver.alg === APPROVER_ALGORITHMS.ES256 ? 'a P-256 key' : `an RSA key of ${APPROVER_LIMITS.rsaBits} bits or more`}`];
101
+ if (typeof approver.member !== 'string' || !MEMBER.test(approver.member)) return ['approver.member', 'member must be the id of a member: 1 to 128 characters of A-Z a-z 0-9 . _ - that start with a letter or digit'];
102
+ const { name } = approver;
103
+ if (typeof name !== 'string' || !name.isWellFormed() || CONTROL.test(name) || [...name].length === 0 || [...name].length > APPROVER_LIMITS.name) return ['approver.name', `name must be 1 to ${APPROVER_LIMITS.name} characters without control characters (no line breaks)`];
104
+ return null;
105
+ }
106
+
107
+ /** The first problem of a document, as [code, message], or null. */
108
+ function documentProblem(document, { signingKid }) {
109
+ if (!isRecord(document)) return ['approvers', 'the approvers document must be a JSON object'];
110
+ if (document.format !== APPROVERS_FORMAT) return ['approvers.format', `the format must be ${APPROVERS_FORMAT}`];
111
+ if (!sameKeys(document, FIELDS)) return ['approvers.fields', `the approvers document must have exactly ${FIELDS.join(', ')}`];
112
+ if (!Number.isInteger(document.version) || document.version < 1 || document.version > LIMITS.version) return ['approvers.version', `version must be an integer from 1 to ${LIMITS.version}`];
113
+ if (!isHostname(document.rp_id)) return ['approvers.rp_id', 'rp_id must be a lowercase domain name, such as ias.imagina.build'];
114
+ if (!Array.isArray(document.origins) || document.origins.length === 0 || document.origins.length > APPROVER_LIMITS.origins) return ['approvers.origins', `origins must be a list of 1 to ${APPROVER_LIMITS.origins} origins`];
115
+ const origins = new Set();
116
+ for (const origin of document.origins) {
117
+ if (!isOrigin(origin)) return ['approvers.origin', `${JSON.stringify(origin)} is not an origin as a browser writes it: https://<host>[:port], lowercase, without a path or the default port`];
118
+ if (origins.has(origin)) return ['approvers.origin', `${origin} is in origins twice`];
119
+ origins.add(origin);
120
+ }
121
+ if (!Array.isArray(document.approvers) || document.approvers.length === 0 || document.approvers.length > APPROVER_LIMITS.approvers) return ['approvers.list', `approvers must be a list of 1 to ${APPROVER_LIMITS.approvers} passkeys`];
122
+ const seen = new Set();
123
+ for (const approver of document.approvers) {
124
+ const problem = approverProblem(approver);
125
+ if (problem) return problem;
126
+ if (seen.has(approver.credential_id)) return ['approver.duplicate', `the passkey ${approver.credential_id} is in the list twice`];
127
+ seen.add(approver.credential_id);
128
+ }
129
+ const { signature } = document;
130
+ if (!isRecord(signature) || !sameKeys(signature, ['kid', 'sig'])) return ['signature', 'signature must be an object with exactly kid and sig'];
131
+ if (decode(signature.kid)?.length !== KEY_BYTES) return ['signature.kid', `signature.kid must be ${KEY_BYTES} bytes in unpadded base64url`];
132
+ if (decode(signature.sig)?.length !== SIGNATURE_BYTES) return ['signature.sig', `signature.sig must be ${SIGNATURE_BYTES} bytes in unpadded base64url`];
133
+ if (signingKid !== undefined && signature.kid !== signingKid) return ['signature.unknown', `the approvers document must be signed with the team's key ${signingKid}`];
134
+ return null;
135
+ }
136
+
137
+ /**
138
+ * What the team signs in an approvers document (docs/technical/vault.md §5.3): its version, rp_id, origins and each approver, one
139
+ * value per line, in order. No value can hold a line break, so the text reads back one way only.
140
+ */
141
+ export function approversText({ version, rp_id: rpId, origins, approvers }, signingKid) {
142
+ const lines = [APPROVERS_FORMAT, 'approvers', signingKid, String(version), rpId, String(origins.length), ...origins, String(approvers.length)];
143
+ for (const approver of approvers) lines.push(approver.credential_id, approver.public_key, String(approver.alg), approver.member, approver.name);
144
+ return Buffer.from(lines.join('\n'), 'utf8');
145
+ }
146
+
147
+ /**
148
+ * Checks the shape of an approvers document (an object or its JSON), as validateRunnerList does for the runner list:
149
+ * { valid: true } or { valid: false, code, message } for the first problem. With `signingKid`, the signature must carry
150
+ * it. It does not verify the signature; the server checks no more than this.
151
+ */
152
+ export function validateApprovers(document, { signingKid } = {}) {
153
+ let value = document;
154
+ if (typeof document === 'string') {
155
+ try {
156
+ value = JSON.parse(document);
157
+ } catch {
158
+ return { valid: false, code: 'approvers', message: 'the approvers document is not JSON' };
159
+ }
160
+ }
161
+ const problem = documentProblem(value, { signingKid });
162
+ return problem ? { valid: false, code: problem[0], message: problem[1] } : { valid: true };
163
+ }
164
+
165
+ /** The team's Ed25519 key from its seed or pair, for signing: { kid, object }. */
166
+ function signerOf(signingKey) {
167
+ const seed = typeof signingKey === 'string' ? signingKey : signingKey?.privateKey;
168
+ if (typeof seed !== 'string') throw new VaultError('signing-key', 'the approvers document is signed with the team\'s signing key (its private key)');
169
+ const pair = signingKeyFromPrivateKey(seed);
170
+ return { kid: pair.kid, object: createPrivateKey({ key: { kty: 'OKP', crv: 'Ed25519', d: seed, x: pair.publicKey }, format: 'jwk' }) };
171
+ }
172
+
173
+ /** The team's public key the runner pinned (base64url or { publicKey, kid? }): { kid, object }, object null if no key. */
174
+ function verifierOf(signedBy) {
175
+ const publicKey = typeof signedBy === 'string' ? signedBy : signedBy?.publicKey;
176
+ const raw = typeof publicKey === 'string' ? decode(publicKey) : null;
177
+ if (raw === null || raw.length !== KEY_BYTES) throw new VaultError('signing-key', 'the approvers document is checked against the team\'s signing public key (32 bytes in base64url)');
178
+ if (isRecord(signedBy) && signedBy.kid !== undefined && signedBy.kid !== keyId(raw)) throw new VaultError('signing-key', 'the kid does not match the signing public key');
179
+ let object = null;
180
+ try {
181
+ object = createPublicKey({ key: { kty: 'OKP', crv: 'Ed25519', x: publicKey }, format: 'jwk' });
182
+ } catch {
183
+ object = null; // bytes that are no point of the curve: nothing verifies against them
184
+ }
185
+ return { kid: keyId(raw), object };
186
+ }
187
+
188
+ /**
189
+ * The approvers document of `version`, signed with `signingKey` (the team's seed or pair): { format, version, rp_id,
190
+ * origins, approvers, signature }. Each approver is { credential_id, public_key, alg, member, name }; the order is kept
191
+ * and covered by the signature. Throws a VaultError with the code of validateApprovers for the first problem.
192
+ */
193
+ export function signApprovers({ version, rp_id: rpId, origins, approvers }, signingKey) {
194
+ const signer = signerOf(signingKey);
195
+ const unsigned = {
196
+ format: APPROVERS_FORMAT,
197
+ version,
198
+ rp_id: rpId,
199
+ origins: Array.isArray(origins) ? [...origins] : origins,
200
+ approvers: Array.isArray(approvers) ? approvers.map((approver) => (isRecord(approver) ? Object.fromEntries(APPROVER_FIELDS.filter((field) => field in approver).map((field) => [field, approver[field]])) : approver)) : approvers,
201
+ };
202
+ const problem = documentProblem({ ...unsigned, signature: { kid: signer.kid, sig: encode(Buffer.alloc(SIGNATURE_BYTES)) } }, {});
203
+ if (problem) throw new VaultError(...problem);
204
+ return { ...unsigned, signature: { kid: signer.kid, sig: encode(sign(null, approversText(unsigned, signer.kid), signer.object)) } };
205
+ }
206
+
207
+ /**
208
+ * The approvers document checked against `signedBy`, the team's public signing key the runner pinned: its shape, that
209
+ * it is signed with that key, that the signature verifies and, with `minimumVersion` (the highest version the runner
210
+ * has seen), that it is not older. Returns the document parsed; throws a VaultError otherwise (signature.unknown,
211
+ * signature-invalid, version-backwards, or a code of validateApprovers).
212
+ */
213
+ export function verifyApprovers(document, signedBy, { minimumVersion } = {}) {
214
+ const verifier = verifierOf(signedBy);
215
+ let value = document;
216
+ if (typeof document === 'string') {
217
+ try {
218
+ value = JSON.parse(document);
219
+ } catch {
220
+ throw new VaultError('approvers', 'the approvers document is not JSON');
221
+ }
222
+ }
223
+ const problem = documentProblem(value, { signingKid: verifier.kid });
224
+ if (problem) throw new VaultError(...problem);
225
+ let verified = false;
226
+ try {
227
+ verified = verifier.object !== null && verify(null, approversText(value, value.signature.kid), verifier.object, decode(value.signature.sig));
228
+ } catch {
229
+ verified = false;
230
+ }
231
+ if (!verified) throw new VaultError('signature-invalid', `the approvers document does not verify with the team's signing key ${verifier.kid}: it was altered, or made by someone without that key`);
232
+ if (minimumVersion !== undefined && value.version < minimumVersion) throw new VaultError('version-backwards', `the approvers document is version ${value.version}, older than version ${minimumVersion}, which this machine has already seen`);
233
+ return structuredClone(value);
234
+ }