@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.
- package/README.md +406 -2
- package/bin/ias.mjs +19 -0
- package/package.json +32 -5
- package/schema/ias.schema.json +535 -0
- package/shared/credentials/cms.mjs +105 -0
- package/shared/credentials/index.mjs +354 -0
- package/shared/credentials/plist.mjs +93 -0
- package/shared/vault/approvers-vectors.mjs +188 -0
- package/shared/vault/approvers.mjs +234 -0
- package/shared/vault/index.mjs +583 -0
- package/shared/vault/plaintext.mjs +115 -0
- package/shared/vault/test-vectors/ias-vault-2/approvers.json +2122 -0
- package/shared/vault/test-vectors/ias-vault-2/keys.json +43 -0
- package/shared/vault/test-vectors/ias-vault-2/malformed.json +1739 -0
- package/shared/vault/test-vectors/ias-vault-2/plaintexts.json +149 -0
- package/shared/vault/test-vectors/ias-vault-2/runners.json +580 -0
- package/shared/vault/test-vectors/ias-vault-2/sealed.json +567 -0
- package/shared/vault/test-vectors/ias-vault-2/tampered.json +989 -0
- package/shared/vault/test-vectors/keys.json +31 -0
- package/shared/vault/test-vectors/malformed.json +1341 -0
- package/shared/vault/test-vectors/sealed.json +384 -0
- package/shared/vault/test-vectors/tampered.json +610 -0
- package/shared/vault/vectors.mjs +720 -0
- package/src/cli.mjs +231 -0
- package/src/commands/apps/index.mjs +36 -0
- package/src/commands/auth/index.mjs +141 -0
- package/src/commands/build/follow.mjs +168 -0
- package/src/commands/build/index.mjs +110 -0
- package/src/commands/build/local.mjs +69 -0
- package/src/commands/build/profile.mjs +50 -0
- package/src/commands/build/source.mjs +71 -0
- package/src/commands/builds/index.mjs +148 -0
- package/src/commands/credentials/index.mjs +256 -0
- package/src/commands/credentials/material.mjs +159 -0
- package/src/commands/devices/asc.mjs +116 -0
- package/src/commands/devices/index.mjs +65 -0
- package/src/commands/devices/sync.mjs +223 -0
- package/src/commands/dist/index.mjs +108 -0
- package/src/commands/doctor/index.mjs +158 -0
- package/src/commands/env/index.mjs +163 -0
- package/src/commands/github/index.mjs +53 -0
- package/src/commands/init/index.mjs +224 -0
- package/src/commands/migrate/credentials.mjs +213 -0
- package/src/commands/migrate/eas-cli.mjs +153 -0
- package/src/commands/migrate/index.mjs +73 -0
- package/src/commands/migrate/numbers.mjs +85 -0
- package/src/commands/migrate/profiles.mjs +174 -0
- package/src/commands/migrate/project.mjs +249 -0
- package/src/commands/migrate/survey.mjs +205 -0
- package/src/commands/migrate/variables.mjs +183 -0
- package/src/commands/push/index.mjs +334 -0
- package/src/commands/sdk/index.mjs +81 -0
- package/src/commands/submit/index.mjs +85 -0
- package/src/commands/update/index.mjs +526 -0
- package/src/commands/usage/index.mjs +57 -0
- package/src/commands/vault/approvers.mjs +129 -0
- package/src/commands/vault/index.mjs +159 -0
- package/src/commands/vault/runners.mjs +115 -0
- package/src/commands/vault/team.mjs +111 -0
- package/src/commands/version/index.mjs +71 -0
- package/src/commands/webhooks/index.mjs +104 -0
- package/src/commands/workflows/index.mjs +88 -0
- package/src/config.mjs +261 -0
- package/src/dist.mjs +99 -0
- package/src/errors.mjs +158 -0
- package/src/http.mjs +45 -0
- package/src/legacy.mjs +73 -0
- package/src/manage.mjs +120 -0
- package/src/ota.mjs +122 -0
- package/src/output.mjs +36 -0
- package/src/process.mjs +45 -0
- package/src/schema.mjs +131 -0
- package/src/sdk.mjs +120 -0
- package/src/secret-input.mjs +27 -0
- package/src/secrets.mjs +37 -0
- package/src/shared.mjs +5 -0
- package/src/team-vault.mjs +152 -0
- package/src/token-store.mjs +93 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
// What is not secret in the signing credentials of an app (docs/technical/vault.md §3, PLAN-APPLICATION-SERVICES.md §2.5 point
|
|
2
|
+
// 3): the metadata the CLI stores beside each sealed envelope, so that the panel shows the inventory and warns before
|
|
3
|
+
// anything expires without ever seeing a secret. Ported from jscheduler-app's scripts/store-signing.mjs and
|
|
4
|
+
// store-credentials.mjs, which read the same material from 1Password.
|
|
5
|
+
// Nothing here returns or prints a password or a key, and no password is ever a command argument (visible to `ps`):
|
|
6
|
+
// openssl and keytool get them through the environment. Fingerprints, serial numbers, names and dates are not secret.
|
|
7
|
+
import { Buffer } from 'node:buffer';
|
|
8
|
+
import { spawnSync } from 'node:child_process';
|
|
9
|
+
import { X509Certificate, createHash, createPrivateKey, createPublicKey } from 'node:crypto';
|
|
10
|
+
import { readFileSync } from 'node:fs';
|
|
11
|
+
import { basename, resolve } from 'node:path';
|
|
12
|
+
import process from 'node:process';
|
|
13
|
+
import { signedContent } from './cms.mjs';
|
|
14
|
+
import { parsePlist } from './plist.mjs';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The kinds of credential the vault holds: the names of the management API (docs/technical/manage-api.md §8.1 and §9), which in
|
|
18
|
+
* ias-vault-2 are also credential.type in an envelope (docs/technical/vault.md §3.3).
|
|
19
|
+
*/
|
|
20
|
+
export const CREDENTIAL_TYPES = Object.freeze([
|
|
21
|
+
'apple_distribution_certificate',
|
|
22
|
+
'apple_provisioning_profile',
|
|
23
|
+
'android_upload_keystore',
|
|
24
|
+
'apns_key',
|
|
25
|
+
'asc_api_key',
|
|
26
|
+
'google_service_account',
|
|
27
|
+
'environment_variable',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
/** The provisioning profile types, as Xcode and EAS name them. */
|
|
31
|
+
export const PROFILE_TYPES = Object.freeze(['app-store', 'ad-hoc', 'development', 'enterprise']);
|
|
32
|
+
|
|
33
|
+
const APPLE_ID = /^[A-Z0-9]{10}$/;
|
|
34
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
35
|
+
const PEM_CERTIFICATE = /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g;
|
|
36
|
+
|
|
37
|
+
/** A credential that cannot be read. The message names the file or the tool, never a secret. */
|
|
38
|
+
export class CredentialError extends Error {
|
|
39
|
+
constructor(message) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = 'CredentialError';
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const sha1 = (der) => createHash('sha1').update(der).digest('hex').toUpperCase();
|
|
46
|
+
const colonHex = (bytes) => bytes.toString('hex').toUpperCase().match(/../g).join(':');
|
|
47
|
+
const isoDate = (value) => new Date(value).toISOString();
|
|
48
|
+
|
|
49
|
+
/** The bytes of `source`: a path, or the bytes themselves. */
|
|
50
|
+
function bytesOf(source, what) {
|
|
51
|
+
if (source instanceof Uint8Array) return Buffer.from(source);
|
|
52
|
+
try {
|
|
53
|
+
return readFileSync(source);
|
|
54
|
+
} catch {
|
|
55
|
+
throw new CredentialError(`no ${what} at ${source}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The attributes of an X.509 name, as Node prints it ("CN=…\nOU=…"). */
|
|
60
|
+
function nameAttributes(name) {
|
|
61
|
+
const attributes = {};
|
|
62
|
+
for (const line of name.split('\n')) {
|
|
63
|
+
const separator = line.indexOf('=');
|
|
64
|
+
if (separator > 0) attributes[line.slice(0, separator)] ??= line.slice(separator + 1);
|
|
65
|
+
}
|
|
66
|
+
return attributes;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Subject, serial, dates and fingerprints of an X.509 certificate, none of them secret. */
|
|
70
|
+
export function describeCertificate(certificate) {
|
|
71
|
+
return {
|
|
72
|
+
subject: certificate.subject.replace(/\n/g, ', '),
|
|
73
|
+
commonName: nameAttributes(certificate.subject).CN ?? null,
|
|
74
|
+
serial: certificate.serialNumber,
|
|
75
|
+
validFrom: isoDate(certificate.validFrom),
|
|
76
|
+
expires: isoDate(certificate.validTo),
|
|
77
|
+
sha1: sha1(certificate.raw),
|
|
78
|
+
sha256: certificate.fingerprint256,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** What an Apple signing certificate is for, from its common name. */
|
|
83
|
+
function appleCertificateKind(commonName) {
|
|
84
|
+
if (/^(Apple|iPhone) Distribution:/.test(commonName ?? '')) return 'distribution';
|
|
85
|
+
if (/^(Apple Development|iPhone Developer):/.test(commonName ?? '')) return 'development';
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Replaces every secret in tool output, in case a tool ever echoes one. */
|
|
90
|
+
function scrub(text, secrets) {
|
|
91
|
+
let clean = String(text ?? '');
|
|
92
|
+
for (const secret of secrets) if (secret) clean = clean.split(secret).join('[secret]');
|
|
93
|
+
return clean;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Runs a tool with the secrets in its environment (never in its arguments) and returns its exit status and its
|
|
98
|
+
* output with the secrets scrubbed out.
|
|
99
|
+
*/
|
|
100
|
+
function runTool(command, args, secrets, label) {
|
|
101
|
+
const result = spawnSync(command, args, { encoding: 'utf8', env: { ...process.env, ...secrets }, stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: 16 * 1024 * 1024 });
|
|
102
|
+
if (result.error) throw new CredentialError(result.error.code === 'ENOENT' ? `${label}: ${command} is not installed` : `${label}: ${result.error.message}`);
|
|
103
|
+
const values = Object.values(secrets);
|
|
104
|
+
return { status: result.status, stdout: scrub(result.stdout, values), stderr: scrub(result.stderr, values) };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The first line of tool output that explains a failure. */
|
|
108
|
+
function reason(output, pattern) {
|
|
109
|
+
const lines = output.split('\n').map((line) => line.trim()).filter(Boolean);
|
|
110
|
+
return lines.find((line) => pattern.test(line)) ?? lines[0] ?? 'no output';
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The distribution (or development) certificate of a .p12 and its password: subject, Apple team, serial, SHA-1 (the
|
|
115
|
+
* identity codesign uses) and expiry. The p12 must hold exactly one identity: one certificate with its key. The key
|
|
116
|
+
* is never decrypted. Throws if the password is wrong.
|
|
117
|
+
*/
|
|
118
|
+
export function inspectP12(source, password, { openssl = 'openssl' } = {}) {
|
|
119
|
+
const path = typeof source === 'string' ? source : null;
|
|
120
|
+
if (path === null) throw new CredentialError('inspectP12 reads a .p12 from a path');
|
|
121
|
+
bytesOf(path, '.p12');
|
|
122
|
+
const secrets = { IAS_P12_PASSWORD: String(password ?? '') };
|
|
123
|
+
const args = ['pkcs12', '-in', path, '-passin', 'env:IAS_P12_PASSWORD', '-nokeys', '-clcerts', '-info'];
|
|
124
|
+
let result = runTool(openssl, args, secrets, 'openssl pkcs12');
|
|
125
|
+
// OpenSSL 3 and later only read the RC2 of older .p12 files (Keychain Access, EAS) with -legacy; LibreSSL always does.
|
|
126
|
+
if (result.status !== 0 && !/mac verify|invalid password/i.test(result.stderr)) {
|
|
127
|
+
const legacy = runTool(openssl, [...args, '-legacy'], secrets, 'openssl pkcs12 -legacy');
|
|
128
|
+
if (legacy.status === 0) result = legacy;
|
|
129
|
+
}
|
|
130
|
+
if (result.status !== 0) {
|
|
131
|
+
const wrongPassword = /mac verify|invalid password/i.test(result.stderr);
|
|
132
|
+
throw new CredentialError(wrongPassword ? `${basename(path)}: wrong password` : `${basename(path)} is not a .p12 that openssl can read: ${reason(result.stderr, /error|unsupported|unable/i)}`);
|
|
133
|
+
}
|
|
134
|
+
const certificates = [...result.stdout.matchAll(PEM_CERTIFICATE)].map(([block]) => new X509Certificate(block));
|
|
135
|
+
const keys = (result.stderr.match(/^(Shrouded Keybag|Key bag)/gim) ?? []).length;
|
|
136
|
+
if (certificates.length !== 1 || keys !== 1) {
|
|
137
|
+
throw new CredentialError(`${basename(path)} has ${certificates.length} certificates with a key and ${keys} keys; it should have one identity (one certificate and its key)`);
|
|
138
|
+
}
|
|
139
|
+
const [certificate] = certificates;
|
|
140
|
+
const description = describeCertificate(certificate);
|
|
141
|
+
return { ...description, team: nameAttributes(certificate.subject).OU ?? null, kind: appleCertificateKind(description.commonName) };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The type of a profile, by the rules Xcode and EAS use. */
|
|
145
|
+
function profileType(plist) {
|
|
146
|
+
if (plist.ProvisionsAllDevices === true) return 'enterprise';
|
|
147
|
+
if (Array.isArray(plist.ProvisionedDevices)) return plist.Entitlements?.['get-task-allow'] === true ? 'development' : 'ad-hoc';
|
|
148
|
+
return 'app-store';
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Entitlements as JSON: data values (rare) become base64. */
|
|
152
|
+
function plainEntitlements(entitlements) {
|
|
153
|
+
return JSON.parse(JSON.stringify(entitlements ?? {}, (key, value) => (value?.type === 'Buffer' && Array.isArray(value.data) ? Buffer.from(value.data).toString('base64') : value)));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* A provisioning profile (it is signed, not secret): UUID, name, type, team, bundle id, entitlements, how many
|
|
158
|
+
* devices it lists, the SHA-1 of the certificates it accepts, and its dates. Read without `security` or `plutil`, so
|
|
159
|
+
* it works off macOS too; Apple's signature is not checked here.
|
|
160
|
+
*/
|
|
161
|
+
export function inspectProfile(source) {
|
|
162
|
+
const bytes = bytesOf(source, 'provisioning profile');
|
|
163
|
+
let plist;
|
|
164
|
+
try {
|
|
165
|
+
plist = parsePlist(signedContent(bytes).toString('utf8'));
|
|
166
|
+
} catch (error) {
|
|
167
|
+
throw new CredentialError(`${typeof source === 'string' ? basename(source) : 'the profile'} is not a provisioning profile: ${error.message}`);
|
|
168
|
+
}
|
|
169
|
+
const team = plist.TeamIdentifier?.[0] ?? null;
|
|
170
|
+
const applicationIdentifier = plist.Entitlements?.['application-identifier'] ?? null;
|
|
171
|
+
const prefix = team ? `${team}.` : null;
|
|
172
|
+
return {
|
|
173
|
+
uuid: plist.UUID ?? null,
|
|
174
|
+
name: plist.Name ?? null,
|
|
175
|
+
type: profileType(plist),
|
|
176
|
+
team,
|
|
177
|
+
teamName: plist.TeamName ?? null,
|
|
178
|
+
appIdName: plist.AppIDName ?? null,
|
|
179
|
+
applicationIdentifier,
|
|
180
|
+
bundleId: applicationIdentifier && prefix && applicationIdentifier.startsWith(prefix) ? applicationIdentifier.slice(prefix.length) : null,
|
|
181
|
+
platforms: plist.Platform ?? [],
|
|
182
|
+
entitlements: plainEntitlements(plist.Entitlements),
|
|
183
|
+
devices: Array.isArray(plist.ProvisionedDevices) ? plist.ProvisionedDevices.length : 0,
|
|
184
|
+
certificates: (plist.DeveloperCertificates ?? []).filter(Buffer.isBuffer).map(sha1),
|
|
185
|
+
created: plist.CreationDate ?? null,
|
|
186
|
+
expires: plist.ExpirationDate ?? null,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* An Android keystore (.jks or PKCS12) with its passwords and alias, by keytool: the certificate of the alias, with
|
|
192
|
+
* its SHA-1 and SHA-256 (what Play Console and assetlinks.json show) and its expiry. The key password is checked by
|
|
193
|
+
* using the key: keytool -certreq signs a request with it and nothing is written.
|
|
194
|
+
*/
|
|
195
|
+
export function inspectKeystore(source, { storePassword, alias, keyPassword }, { keytool = 'keytool' } = {}) {
|
|
196
|
+
const path = typeof source === 'string' ? source : null;
|
|
197
|
+
if (path === null) throw new CredentialError('inspectKeystore reads a keystore from a path');
|
|
198
|
+
if (!alias) throw new CredentialError('the keystore alias is missing');
|
|
199
|
+
bytesOf(path, 'keystore');
|
|
200
|
+
const secrets = { IAS_STOREPASS: String(storePassword ?? ''), IAS_KEYPASS: String(keyPassword ?? storePassword ?? '') };
|
|
201
|
+
// English messages, whatever the locale of the JVM.
|
|
202
|
+
const common = ['-J-Duser.language=en', '-keystore', path, '-storepass:env', 'IAS_STOREPASS', '-alias', alias];
|
|
203
|
+
const exported = runTool(keytool, ['-exportcert', '-rfc', ...common], secrets, 'keytool -exportcert');
|
|
204
|
+
if (exported.status !== 0) throw new CredentialError(`${basename(path)}: ${reason(`${exported.stdout}\n${exported.stderr}`, /keytool error/i)} (wrong keystore password or alias?)`);
|
|
205
|
+
const request = runTool(keytool, ['-certreq', ...common, '-keypass:env', 'IAS_KEYPASS'], secrets, 'keytool -certreq');
|
|
206
|
+
if (request.status !== 0) throw new CredentialError(`${basename(path)}: ${reason(`${request.stdout}\n${request.stderr}`, /keytool error/i)} (wrong key password, or the alias has no key?)`);
|
|
207
|
+
const [pem] = exported.stdout.match(PEM_CERTIFICATE) ?? [];
|
|
208
|
+
if (!pem) throw new CredentialError(`${basename(path)}: keytool gave no certificate for ${alias}`);
|
|
209
|
+
return { alias, ...describeCertificate(new X509Certificate(pem)) };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The SHA-256 of a public key in SubjectPublicKeyInfo DER: identifies a key without revealing it. */
|
|
213
|
+
const publicKeySha256 = (privateKey) => colonHex(createHash('sha256').update(createPublicKey(privateKey).export({ format: 'der', type: 'spki' })).digest());
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* A .p8 key of APNs or of the App Store Connect API (an ES256 key that never expires), by the key id and the team
|
|
217
|
+
* given; the key id may come from the file name (AuthKey_<id>.p8). Returns the ids and the SHA-256 of the public key.
|
|
218
|
+
*/
|
|
219
|
+
export function inspectP8(source, { keyId, teamId, issuerId } = {}) {
|
|
220
|
+
const fromName = typeof source === 'string' ? /^AuthKey_([A-Z0-9]{10})\.p8$/.exec(basename(source))?.[1] : undefined;
|
|
221
|
+
const id = keyId ?? fromName;
|
|
222
|
+
if (!id || !APPLE_ID.test(id)) throw new CredentialError('the key id of a .p8 is 10 characters of A-Z and 0-9 (AuthKey_<id>.p8)');
|
|
223
|
+
if (fromName && keyId && fromName !== keyId) throw new CredentialError(`the file is the key ${fromName}, not ${keyId}`);
|
|
224
|
+
if (!teamId || !APPLE_ID.test(teamId)) throw new CredentialError('the Apple team of a .p8 is 10 characters of A-Z and 0-9');
|
|
225
|
+
if (issuerId !== undefined && !UUID.test(issuerId)) throw new CredentialError('the issuer id of an App Store Connect key is a UUID');
|
|
226
|
+
let key;
|
|
227
|
+
try {
|
|
228
|
+
key = createPrivateKey({ key: bytesOf(source, '.p8'), format: 'pem' });
|
|
229
|
+
} catch (error) {
|
|
230
|
+
if (error instanceof CredentialError) throw error;
|
|
231
|
+
throw new CredentialError(`${typeof source === 'string' ? basename(source) : 'the .p8'} is not a PEM private key`);
|
|
232
|
+
}
|
|
233
|
+
if (key.asymmetricKeyType !== 'ec' || key.asymmetricKeyDetails?.namedCurve !== 'prime256v1') throw new CredentialError('a .p8 of Apple is an EC P-256 key (ES256)');
|
|
234
|
+
return { keyId: id, teamId, ...(issuerId === undefined ? {} : { issuerId }), algorithm: 'ES256', publicKeySha256: publicKeySha256(key), expires: null };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** A Google service account key (JSON): its email, project and key id, and the SHA-256 of its public key. */
|
|
238
|
+
export function inspectServiceAccount(source) {
|
|
239
|
+
let json;
|
|
240
|
+
try {
|
|
241
|
+
json = typeof source === 'object' && !(source instanceof Uint8Array) ? source : JSON.parse(bytesOf(source, 'service account').toString('utf8'));
|
|
242
|
+
} catch (error) {
|
|
243
|
+
if (error instanceof CredentialError) throw error;
|
|
244
|
+
throw new CredentialError('the service account is not JSON');
|
|
245
|
+
}
|
|
246
|
+
if (json?.type !== 'service_account') throw new CredentialError('not a Google service account key (type is not service_account)');
|
|
247
|
+
if (typeof json.client_email !== 'string' || typeof json.project_id !== 'string') throw new CredentialError('the service account has no client_email or project_id');
|
|
248
|
+
let key;
|
|
249
|
+
try {
|
|
250
|
+
key = createPrivateKey({ key: json.private_key, format: 'pem' });
|
|
251
|
+
} catch {
|
|
252
|
+
throw new CredentialError('the service account has no readable private_key');
|
|
253
|
+
}
|
|
254
|
+
return { clientEmail: json.client_email, projectId: json.project_id, privateKeyId: json.private_key_id ?? null, publicKeySha256: publicKeySha256(key), expires: null };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const expired = (date, now) => !(Date.parse(date) > now);
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The problems of an iOS certificate and profile for an app, from their metadata: they are the checks of
|
|
261
|
+
* store-signing.mjs inspectIos. `teamId` and `bundleId` are checked when given; `type` is the profile the build
|
|
262
|
+
* needs ('app-store' for the stores).
|
|
263
|
+
*/
|
|
264
|
+
export function checkIos({ certificate, profile }, { teamId, bundleId, type = 'app-store', now = Date.now() } = {}) {
|
|
265
|
+
const problems = [];
|
|
266
|
+
if (teamId && profile.team !== teamId) problems.push(`the profile is for team ${profile.team}, not ${teamId}`);
|
|
267
|
+
if (teamId && bundleId && profile.applicationIdentifier !== `${teamId}.${bundleId}`) problems.push(`the profile is for ${profile.applicationIdentifier}, not ${teamId}.${bundleId}`);
|
|
268
|
+
if (profile.type !== type) problems.push(`the profile is ${profile.type}, not ${type}`);
|
|
269
|
+
if (type === 'app-store' && profile.entitlements['get-task-allow'] !== false) problems.push('the profile allows debugging (get-task-allow): not an App Store profile');
|
|
270
|
+
if (expired(profile.expires, now)) problems.push(`the profile expired on ${profile.expires}`);
|
|
271
|
+
if (!profile.certificates.includes(certificate.sha1)) problems.push('the profile does not include this certificate');
|
|
272
|
+
if (teamId && certificate.team !== teamId) problems.push(`the certificate is for team ${certificate.team}, not ${teamId}`);
|
|
273
|
+
const kind = type === 'development' ? 'development' : 'distribution';
|
|
274
|
+
if (certificate.kind !== kind) problems.push(`a ${type} profile needs a ${kind} certificate, and this one is ${certificate.kind ?? 'neither'} (${certificate.commonName})`);
|
|
275
|
+
if (expired(certificate.expires, now)) problems.push(`the certificate expired on ${certificate.expires}`);
|
|
276
|
+
return problems;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** The problems of an Android upload key: expired, or not one of the SHA-256 the app expects (Play, assetlinks.json). */
|
|
280
|
+
export function checkAndroid(keystore, { sha256 = [], now = Date.now() } = {}) {
|
|
281
|
+
const problems = [];
|
|
282
|
+
if (expired(keystore.expires, now)) problems.push(`the upload key certificate expired on ${keystore.expires}`);
|
|
283
|
+
if (sha256.length > 0 && !sha256.includes(keystore.sha256)) problems.push(`the upload key SHA-256 ${keystore.sha256} is not one of the expected ones`);
|
|
284
|
+
return problems;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** The iOS material of an app ({ p12, password, profile }): the metadata of both and their problems for `expected`. */
|
|
288
|
+
export function inspectIos({ p12, password, profile }, expected = {}, tools = {}) {
|
|
289
|
+
const certificate = inspectP12(p12, password, tools);
|
|
290
|
+
const profileData = inspectProfile(profile);
|
|
291
|
+
return { certificate, profile: profileData, problems: checkIos({ certificate, profile: profileData }, expected) };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** The Android material of an app ({ keystore, storePassword, alias, keyPassword }): its metadata and problems. */
|
|
295
|
+
export function inspectAndroid({ keystore, storePassword, alias, keyPassword }, expected = {}, tools = {}) {
|
|
296
|
+
const metadata = inspectKeystore(keystore, { storePassword, alias, keyPassword }, tools);
|
|
297
|
+
return { keystore: metadata, problems: checkAndroid(metadata, expected) };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* The metadata of what an EAS credentials.json in `dir` holds (eas credentials › Download credentials from EAS), as
|
|
302
|
+
* inspectIos and inspectAndroid give it. The passwords in the file are used and never returned.
|
|
303
|
+
*/
|
|
304
|
+
export function inspectEasCredentials(dir, expected = {}, tools = {}) {
|
|
305
|
+
let json;
|
|
306
|
+
try {
|
|
307
|
+
json = JSON.parse(readFileSync(resolve(dir, 'credentials.json'), 'utf8'));
|
|
308
|
+
} catch {
|
|
309
|
+
throw new CredentialError(`no readable credentials.json in ${dir}`);
|
|
310
|
+
}
|
|
311
|
+
const result = {};
|
|
312
|
+
let ios = json.ios;
|
|
313
|
+
if (ios && !ios.distributionCertificate) {
|
|
314
|
+
const targets = Object.keys(ios);
|
|
315
|
+
if (targets.length !== 1) throw new CredentialError(`credentials.json has iOS credentials for ${targets.length} targets; only one is supported`);
|
|
316
|
+
ios = ios[targets[0]];
|
|
317
|
+
}
|
|
318
|
+
if (ios) {
|
|
319
|
+
result.ios = inspectIos({ p12: resolve(dir, ios.distributionCertificate.path), password: ios.distributionCertificate.password, profile: resolve(dir, ios.provisioningProfilePath) }, expected.ios, tools);
|
|
320
|
+
}
|
|
321
|
+
const keystore = json.android?.keystore;
|
|
322
|
+
if (keystore) {
|
|
323
|
+
result.android = inspectAndroid({ keystore: resolve(dir, keystore.keystorePath), storePassword: keystore.keystorePassword, alias: keystore.keyAlias, keyPassword: keystore.keyPassword }, expected.android, tools);
|
|
324
|
+
}
|
|
325
|
+
if (Object.keys(result).length === 0) throw new CredentialError('credentials.json has neither iOS nor Android credentials');
|
|
326
|
+
return result;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* The metadata of a credential of `type`, exactly as the server stores it beside the envelope (docs/technical/vault.md §3.4):
|
|
331
|
+
* what the inspect function of the type returns. `input` holds the files and fields of the plaintext (docs/technical/vault.md
|
|
332
|
+
* §3.5) as paths or bytes: { p12, password }, { mobileprovision }, { keystore, storePassword, alias, keyPassword },
|
|
333
|
+
* { p8, keyId, teamId, issuerId? } or { json }. A secret variable has none: null.
|
|
334
|
+
*/
|
|
335
|
+
export function metadataOf(type, input, tools = {}) {
|
|
336
|
+
switch (type) {
|
|
337
|
+
case 'apple_distribution_certificate':
|
|
338
|
+
return inspectP12(input.p12, input.password, tools);
|
|
339
|
+
case 'apple_provisioning_profile':
|
|
340
|
+
return inspectProfile(input.mobileprovision);
|
|
341
|
+
case 'android_upload_keystore':
|
|
342
|
+
return inspectKeystore(input.keystore, { storePassword: input.storePassword, alias: input.alias, keyPassword: input.keyPassword }, tools);
|
|
343
|
+
case 'apns_key':
|
|
344
|
+
return inspectP8(input.p8, { keyId: input.keyId, teamId: input.teamId });
|
|
345
|
+
case 'asc_api_key':
|
|
346
|
+
return inspectP8(input.p8, { keyId: input.keyId, teamId: input.teamId, issuerId: input.issuerId });
|
|
347
|
+
case 'google_service_account':
|
|
348
|
+
return inspectServiceAccount(input.json);
|
|
349
|
+
case 'environment_variable':
|
|
350
|
+
return null;
|
|
351
|
+
default:
|
|
352
|
+
throw new CredentialError(`there is no credential type ${type}: it is one of ${CREDENTIAL_TYPES.join(', ')}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// A reader of XML property lists, enough for provisioning profiles: dict, array, string, integer, real, true, false,
|
|
2
|
+
// date and data. Dates come back as ISO strings and data as Buffers.
|
|
3
|
+
import { Buffer } from 'node:buffer';
|
|
4
|
+
|
|
5
|
+
const ENTITIES = { lt: '<', gt: '>', amp: '&', quot: '"', apos: "'" };
|
|
6
|
+
const TAG = /\s*<(\/?)([A-Za-z]+)(?:\s[^>]*?)?\s*(\/?)>/y;
|
|
7
|
+
|
|
8
|
+
const unescape = (text) =>
|
|
9
|
+
text.replace(/&(#x[0-9A-Fa-f]+|#[0-9]+|[a-z]+);/g, (entity, name) => {
|
|
10
|
+
if (name.startsWith('#x')) return String.fromCodePoint(Number.parseInt(name.slice(2), 16));
|
|
11
|
+
if (name.startsWith('#')) return String.fromCodePoint(Number.parseInt(name.slice(1), 10));
|
|
12
|
+
return ENTITIES[name] ?? entity;
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/** Parses an XML property list into plain values. Throws on anything else. */
|
|
16
|
+
export function parsePlist(xml) {
|
|
17
|
+
const text = String(xml)
|
|
18
|
+
.replace(/<\?xml[\s\S]*?\?>/g, '')
|
|
19
|
+
.replace(/<!DOCTYPE[^>]*>/gi, '')
|
|
20
|
+
.replace(/<!--[\s\S]*?-->/g, '');
|
|
21
|
+
let position = 0;
|
|
22
|
+
const fail = (what) => {
|
|
23
|
+
throw new Error(`not an XML property list (${what})`);
|
|
24
|
+
};
|
|
25
|
+
const tag = () => {
|
|
26
|
+
TAG.lastIndex = position;
|
|
27
|
+
const match = TAG.exec(text);
|
|
28
|
+
if (!match) fail(`expected a tag at ${position}`);
|
|
29
|
+
position = TAG.lastIndex;
|
|
30
|
+
return { close: match[1] === '/', name: match[2], empty: match[3] === '/' };
|
|
31
|
+
};
|
|
32
|
+
const content = (open) => {
|
|
33
|
+
if (open.empty) return '';
|
|
34
|
+
const end = text.indexOf(`</${open.name}>`, position);
|
|
35
|
+
if (end < 0) fail(`unclosed <${open.name}>`);
|
|
36
|
+
const raw = text.slice(position, end);
|
|
37
|
+
if (raw.includes('<')) fail(`markup inside <${open.name}>`);
|
|
38
|
+
position = end + open.name.length + 3;
|
|
39
|
+
return unescape(raw);
|
|
40
|
+
};
|
|
41
|
+
const value = (open, depth) => {
|
|
42
|
+
if (open.close || depth > 64) fail(`unexpected </${open.name}>`);
|
|
43
|
+
switch (open.name) {
|
|
44
|
+
case 'dict': {
|
|
45
|
+
const dict = {};
|
|
46
|
+
if (open.empty) return dict;
|
|
47
|
+
for (;;) {
|
|
48
|
+
const next = tag();
|
|
49
|
+
if (next.close && next.name === 'dict') return dict;
|
|
50
|
+
if (next.name !== 'key' || next.close) fail(`<${next.name}> where a <key> goes`);
|
|
51
|
+
// defineProperty, so that a key named __proto__ stays a key.
|
|
52
|
+
Object.defineProperty(dict, content(next), { value: value(tag(), depth + 1), enumerable: true, writable: true, configurable: true });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
case 'array': {
|
|
56
|
+
const array = [];
|
|
57
|
+
if (open.empty) return array;
|
|
58
|
+
for (;;) {
|
|
59
|
+
const next = tag();
|
|
60
|
+
if (next.close && next.name === 'array') return array;
|
|
61
|
+
array.push(value(next, depth + 1));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
case 'string':
|
|
65
|
+
return content(open);
|
|
66
|
+
case 'integer':
|
|
67
|
+
case 'real': {
|
|
68
|
+
const number = Number(content(open).trim());
|
|
69
|
+
if (!Number.isFinite(number)) fail(`a bad <${open.name}>`);
|
|
70
|
+
return number;
|
|
71
|
+
}
|
|
72
|
+
case 'true':
|
|
73
|
+
case 'false':
|
|
74
|
+
if (!open.empty) fail(`<${open.name}> with content`);
|
|
75
|
+
return open.name === 'true';
|
|
76
|
+
case 'date': {
|
|
77
|
+
const date = new Date(content(open).trim());
|
|
78
|
+
if (Number.isNaN(date.getTime())) fail('a bad <date>');
|
|
79
|
+
return date.toISOString();
|
|
80
|
+
}
|
|
81
|
+
case 'data':
|
|
82
|
+
return Buffer.from(content(open).replace(/\s+/g, ''), 'base64');
|
|
83
|
+
default:
|
|
84
|
+
return fail(`<${open.name}>`);
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
const root = tag();
|
|
88
|
+
if (root.name !== 'plist' || root.close || root.empty) fail('no <plist>');
|
|
89
|
+
const result = value(tag(), 0);
|
|
90
|
+
const end = tag();
|
|
91
|
+
if (!end.close || end.name !== 'plist' || text.slice(position).trim() !== '') fail('content after the value');
|
|
92
|
+
return result;
|
|
93
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// The test vectors of ias-approvers-1 (docs/technical/vault.md §2.4 and §8): test-vectors/ias-vault-2/approvers.json, signed with
|
|
2
|
+
// the team's test key of keys.json. vectors.mjs writes them with the rest; the tests fail if the file differs. The
|
|
3
|
+
// passkeys are public keys only, made from labels (P-256 and P-384) or fixed here (RSA), and the first document holds
|
|
4
|
+
// exactly the approvers of tests/Fixtures/runner-v1/approval-vectors.json, so the runner can check a whole approval:
|
|
5
|
+
// the document with the team key, then the assertion of that file with the passkey the document names.
|
|
6
|
+
import { Buffer } from 'node:buffer';
|
|
7
|
+
import { createECDH, createHash, createPublicKey } from 'node:crypto';
|
|
8
|
+
import { encode } from './index.mjs';
|
|
9
|
+
import { APPROVERS_FORMAT, APPROVER_LIMITS, approverAlgorithm, approverFingerprint, approversText, signApprovers, validateApprovers, verifyApprovers } from './approvers.mjs';
|
|
10
|
+
|
|
11
|
+
/** A P-256 or P-384 public key (SPKI, DER, base64url) whose private scalar is the SHA-256 or SHA-384 of `label`. */
|
|
12
|
+
function curveKey(label, curve = 'prime256v1') {
|
|
13
|
+
const [hash, crv, size] = curve === 'prime256v1' ? ['sha256', 'P-256', 32] : ['sha384', 'P-384', 48];
|
|
14
|
+
const ecdh = createECDH(curve);
|
|
15
|
+
ecdh.setPrivateKey(createHash(hash).update(`${APPROVERS_FORMAT} test vector\n${label}`, 'utf8').digest());
|
|
16
|
+
const point = ecdh.getPublicKey();
|
|
17
|
+
const jwk = { kty: 'EC', crv, x: encode(point.subarray(1, 1 + size)), y: encode(point.subarray(1 + size)) };
|
|
18
|
+
return encode(createPublicKey({ key: jwk, format: 'jwk' }).export({ format: 'der', type: 'spki' }));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** A credential id of 16 bytes made from `label`, as a browser would give one. */
|
|
22
|
+
const credentialId = (label) => encode(createHash('sha256').update(`${APPROVERS_FORMAT} test vector\ncredential ${label}`, 'utf8').digest().subarray(0, 16));
|
|
23
|
+
|
|
24
|
+
// RSA public keys made once with node:crypto (their private keys were thrown away): one of 2048 bits, which an approver
|
|
25
|
+
// may use (RS256), and one of 1024, which it may not.
|
|
26
|
+
const RSA_2048 = 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyOiOy7rHLrSxmUE-Gn_HR8t8wn0f5_dpJbSe7z3UdwFGm0LnBmdPc2IoNhtM4pWHYET0LY1wVqsX389TeTESdb1rB46uaQwsj85iWqvr6_AYqJDh22nLJ_Z_5JgiMZBBx5QSalEf3_xHSwKK_UljzniwE2Hwku1hRjOpKqorjKKV9frdpFhFqfF2VplbRoQh2HHxbNKHA-8p-XOzbPfRs2Pdkdg70sfCbxjc5aBdtWLkB1Y7kJLPdnsf0rUgG00s2xzk7nCKS3H2xGo4j0RgvdOGoEdM8ue_yhy_arSevmBJNOkJmLV4K8pPuIpbGdomHqX2-XUu2D3Dn1mqUJpklwIDAQAB';
|
|
27
|
+
const RSA_1024 = 'MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC5qBnOaxo_40x9jG9eOUD9VK4ElyA5t6oZKtJpzgPNuoG-VR62mEOj2owqbk1k1uTeSaYJ3ffrOmu2WYFZ1hLX4HeGqBIkIsxpyuiGsJG9ChPj4ENLLDFIemEKNscvZIPNTo8UT_u9qlOIEpQ9WNzRfKfzuEjov6i39UcSTRR1cwIDAQAB';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The approver of tests/Fixtures/runner-v1/approval-vectors.json (its test passkey P-256 and owner), written out here
|
|
31
|
+
* so this package does not read the repository; the tests check that both stay the same.
|
|
32
|
+
*/
|
|
33
|
+
export const RUNNER_V1_APPROVERS = Object.freeze({
|
|
34
|
+
version: 1,
|
|
35
|
+
rp_id: 'ias.imagina.build',
|
|
36
|
+
origins: ['https://ias.imagina.build', 'https://sync.domibi.com'],
|
|
37
|
+
approvers: [
|
|
38
|
+
{
|
|
39
|
+
credential_id: 'Gqerejj6atYqw9aLMbf5nw',
|
|
40
|
+
public_key: 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEI5jFKVlRvVohfg3qfjblCcGXBFdlmJ6UMBm2QcPXfTBKFu_jrRIV6k7_583rgV8p2LLeg7aQR6xRAck7_pycUA',
|
|
41
|
+
alg: -7,
|
|
42
|
+
member: '01930000-0000-7000-8000-00000000a001',
|
|
43
|
+
name: 'Owner de prueba',
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/** The test passkeys: { credential_id, public_key, alg, fingerprint }; alg is null for the ones no approver may use. */
|
|
49
|
+
function passkeys() {
|
|
50
|
+
const keys = {
|
|
51
|
+
'runner-v1-owner': { credential_id: RUNNER_V1_APPROVERS.approvers[0].credential_id, public_key: RUNNER_V1_APPROVERS.approvers[0].public_key },
|
|
52
|
+
'owner-phone': { credential_id: credentialId('owner-phone'), public_key: curveKey('owner-phone') },
|
|
53
|
+
'second-owner-rsa': { credential_id: credentialId('second-owner-rsa'), public_key: RSA_2048 },
|
|
54
|
+
'local-owner': { credential_id: credentialId('local-owner'), public_key: curveKey('local-owner') },
|
|
55
|
+
outsider: { credential_id: credentialId('outsider'), public_key: curveKey('outsider') },
|
|
56
|
+
'p-384': { credential_id: credentialId('p-384'), public_key: curveKey('p-384', 'secp384r1') },
|
|
57
|
+
'rsa-1024': { credential_id: credentialId('rsa-1024'), public_key: RSA_1024 },
|
|
58
|
+
};
|
|
59
|
+
return Object.fromEntries(Object.entries(keys).map(([name, key]) => [name, { ...key, alg: approverAlgorithm(key.public_key), fingerprint: approverFingerprint(key.public_key) }]));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Copies `document` and lets `change` alter the copy. */
|
|
63
|
+
const altered = (document, change) => {
|
|
64
|
+
const copy = structuredClone(document);
|
|
65
|
+
change(copy);
|
|
66
|
+
return copy;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** The vectors of approvers.json, made with the team's signing key and another team's (`signing` of keys.json). */
|
|
70
|
+
export function approverVectors(signing) {
|
|
71
|
+
const team = signing.team;
|
|
72
|
+
const keys = passkeys();
|
|
73
|
+
const approver = (key, member, name) => ({ credential_id: keys[key].credential_id, public_key: keys[key].public_key, alg: keys[key].alg, member, name });
|
|
74
|
+
const owner = '01930000-0000-7000-8000-00000000a001';
|
|
75
|
+
const secondOwner = '01930000-0000-7000-8000-00000000a002';
|
|
76
|
+
const steps = [
|
|
77
|
+
['approval-vectors', 'The list of tests/Fixtures/runner-v1/approval-vectors.json, signed by the team: the valid assertion of that file passes §3.3 with it, and each invalid one fails at its step.', RUNNER_V1_APPROVERS],
|
|
78
|
+
['two-owners', 'A later version: the first owner with a second passkey, and a second owner whose passkey is RSA (RS256); only the new origin.', {
|
|
79
|
+
version: 2,
|
|
80
|
+
rp_id: 'ias.imagina.build',
|
|
81
|
+
origins: ['https://ias.imagina.build'],
|
|
82
|
+
approvers: [{ ...RUNNER_V1_APPROVERS.approvers[0] }, approver('owner-phone', owner, 'Owner de prueba'), approver('second-owner-rsa', secondOwner, 'Segunda owner, con ñ y tilde: Úrsula')],
|
|
83
|
+
}],
|
|
84
|
+
['local', 'The panel of a development machine: an rp_id under .test, and http://localhost with a port.', {
|
|
85
|
+
version: 1,
|
|
86
|
+
rp_id: 'application-services.test',
|
|
87
|
+
origins: ['https://application-services.test', 'http://localhost:8000'],
|
|
88
|
+
approvers: [approver('local-owner', owner, 'Owner local')],
|
|
89
|
+
}],
|
|
90
|
+
];
|
|
91
|
+
const documents = steps.map(([name, description, input]) => {
|
|
92
|
+
const document = signApprovers(input, team.privateKey);
|
|
93
|
+
verifyApprovers(document, team.publicKey);
|
|
94
|
+
return { name, description, signedText: approversText(document, team.kid).toString('utf8'), fingerprints: Object.fromEntries(document.approvers.map((entry) => [entry.credential_id, approverFingerprint(entry.public_key)])), document };
|
|
95
|
+
});
|
|
96
|
+
const base = documents[1].document;
|
|
97
|
+
const malformed = [
|
|
98
|
+
['array', 'approvers', 'A list instead of an object.', { document: [base] }],
|
|
99
|
+
['not-json', 'approvers', 'Text that is not JSON.', { json: '{"format":"ias-approvers-1",' }],
|
|
100
|
+
['other-format', 'approvers.format', 'Another format.', { document: altered(base, (d) => (d.format = 'ias-approvers-2')) }],
|
|
101
|
+
['missing-signature', 'approvers.fields', 'No signature.', { document: altered(base, (d) => delete d.signature) }],
|
|
102
|
+
['extra-field', 'approvers.fields', 'A field besides format, version, rp_id, origins, approvers and signature.', { document: altered(base, (d) => (d.issued_at = '2026-10-21T10:00:00+00:00')) }],
|
|
103
|
+
['version-zero', 'approvers.version', 'Version 0.', { document: altered(base, (d) => (d.version = 0)) }],
|
|
104
|
+
['version-string', 'approvers.version', 'The version as a string.', { document: altered(base, (d) => (d.version = '2')) }],
|
|
105
|
+
['version-decimal', 'approvers.version', 'A version with decimals.', { document: altered(base, (d) => (d.version = 2.5)) }],
|
|
106
|
+
['rp-id-uppercase', 'approvers.rp_id', 'An rp_id in capitals.', { document: altered(base, (d) => (d.rp_id = 'IAS.imagina.build')) }],
|
|
107
|
+
['rp-id-url', 'approvers.rp_id', 'An rp_id with its scheme: it is a domain, not an origin.', { document: altered(base, (d) => (d.rp_id = 'https://ias.imagina.build')) }],
|
|
108
|
+
['rp-id-ip', 'approvers.rp_id', 'An IP address, which WebAuthn never takes as rp_id.', { document: altered(base, (d) => (d.rp_id = '127.0.0.1')) }],
|
|
109
|
+
['origins-empty', 'approvers.origins', 'No origins.', { document: altered(base, (d) => (d.origins = [])) }],
|
|
110
|
+
['origins-string', 'approvers.origins', 'One origin as a string, not a list.', { document: altered(base, (d) => (d.origins = 'https://ias.imagina.build')) }],
|
|
111
|
+
['origins-too-many', 'approvers.origins', `More than ${APPROVER_LIMITS.origins} origins.`, { document: altered(base, (d) => (d.origins = Array.from({ length: APPROVER_LIMITS.origins + 1 }, (_, index) => `https://panel-${index}.imagina.build`))) }],
|
|
112
|
+
['origin-with-path', 'approvers.origin', 'An origin with a trailing slash.', { document: altered(base, (d) => (d.origins = ['https://ias.imagina.build/'])) }],
|
|
113
|
+
['origin-http', 'approvers.origin', 'http for a host that is not localhost.', { document: altered(base, (d) => (d.origins = ['http://ias.imagina.build'])) }],
|
|
114
|
+
['origin-default-port', 'approvers.origin', 'The default port, which a browser leaves out of clientDataJSON.', { document: altered(base, (d) => (d.origins = ['https://ias.imagina.build:443'])) }],
|
|
115
|
+
['origin-uppercase', 'approvers.origin', 'A host in capitals.', { document: altered(base, (d) => (d.origins = ['https://IAS.imagina.build'])) }],
|
|
116
|
+
['origin-duplicate', 'approvers.origin', 'The same origin twice.', { document: altered(base, (d) => d.origins.push(d.origins[0])) }],
|
|
117
|
+
['approvers-empty', 'approvers.list', 'Nobody can approve: a list must have at least one passkey.', { document: altered(base, (d) => (d.approvers = [])) }],
|
|
118
|
+
['approvers-object', 'approvers.list', 'The approvers as an object keyed by credential id.', { document: altered(base, (d) => (d.approvers = Object.fromEntries(d.approvers.map((entry) => [entry.credential_id, entry])))) }],
|
|
119
|
+
['approver-email', 'approver', 'An approver with the member\'s email: the list carries only the five fields.', { document: altered(base, (d) => (d.approvers[0].email = 'owner@example.com')) }],
|
|
120
|
+
['approver-without-alg', 'approver', 'An approver without alg.', { document: altered(base, (d) => delete d.approvers[1].alg) }],
|
|
121
|
+
['credential-id-padded', 'approver.credential_id', 'Padding after a credential id.', { document: altered(base, (d) => (d.approvers[0].credential_id = `${d.approvers[0].credential_id}==`)) }],
|
|
122
|
+
['credential-id-empty', 'approver.credential_id', 'An empty credential id.', { document: altered(base, (d) => (d.approvers[0].credential_id = '')) }],
|
|
123
|
+
['public-key-pem', 'approver.public_key', 'The public key as PEM text.', { document: altered(base, (d) => (d.approvers[0].public_key = createPublicKey({ key: Buffer.from(d.approvers[0].public_key, 'base64url'), format: 'der', type: 'spki' }).export({ format: 'pem', type: 'spki' }))) }],
|
|
124
|
+
['public-key-raw-point', 'approver.public_key', 'The raw P-256 point (0x04, x, y) instead of a SubjectPublicKeyInfo.', { document: altered(base, (d) => (d.approvers[0].public_key = encode(Buffer.from(d.approvers[0].public_key, 'base64url').subarray(-65)))) }],
|
|
125
|
+
['alg-eddsa', 'approver.alg', 'alg -8 (EdDSA), which the runner does not check.', { document: altered(base, (d) => (d.approvers[0].alg = -8)) }],
|
|
126
|
+
['alg-string', 'approver.alg', 'alg as a string.', { document: altered(base, (d) => (d.approvers[0].alg = '-7')) }],
|
|
127
|
+
['alg-of-other-key', 'approver.key', 'alg -257 (RS256) for a P-256 key.', { document: altered(base, (d) => (d.approvers[0].alg = -257)) }],
|
|
128
|
+
['key-p-384', 'approver.key', 'A P-384 key under alg -7, which is P-256 only.', { document: altered(base, (d) => Object.assign(d.approvers[1], { credential_id: keys['p-384'].credential_id, public_key: keys['p-384'].public_key })) }],
|
|
129
|
+
['key-rsa-1024', 'approver.key', 'An RSA key of 1024 bits under alg -257.', { document: altered(base, (d) => Object.assign(d.approvers[2], { credential_id: keys['rsa-1024'].credential_id, public_key: keys['rsa-1024'].public_key })) }],
|
|
130
|
+
['member-empty', 'approver.member', 'An approver without a member.', { document: altered(base, (d) => (d.approvers[0].member = '')) }],
|
|
131
|
+
['name-newline', 'approver.name', 'A line break in a name, which would make the signed text read two ways.', { document: altered(base, (d) => (d.approvers[0].name = 'Owner de prueba\n-7')) }],
|
|
132
|
+
['name-empty', 'approver.name', 'An empty name.', { document: altered(base, (d) => (d.approvers[0].name = '')) }],
|
|
133
|
+
['name-too-long', 'approver.name', `A name of ${APPROVER_LIMITS.name + 1} characters.`, { document: altered(base, (d) => (d.approvers[0].name = 'ñ'.repeat(APPROVER_LIMITS.name + 1))) }],
|
|
134
|
+
['approver-duplicate', 'approver.duplicate', 'The same passkey twice.', { document: altered(base, (d) => d.approvers.push(structuredClone(d.approvers[0]))) }],
|
|
135
|
+
['signature-sig-63-bytes', 'signature.sig', 'A 63-byte signature.', { document: altered(base, (d) => (d.signature.sig = encode(Buffer.from(d.signature.sig, 'base64url').subarray(1)))) }],
|
|
136
|
+
['signature-kid-padded', 'signature.kid', 'Padding after the kid of the signature.', { document: altered(base, (d) => (d.signature.kid = `${d.signature.kid}=`)) }],
|
|
137
|
+
['signed-by-another-key', 'signature.unknown', 'The list signed by another team\'s key with its own kid (options.signingKid is the team\'s).', { document: signApprovers(base, signing['other-team'].privateKey), options: { signingKid: team.kid } }],
|
|
138
|
+
].map(([name, code, description, value]) => {
|
|
139
|
+
const result = validateApprovers('json' in value ? value.json : value.document, value.options ?? {});
|
|
140
|
+
if (result.valid || result.code !== code) throw new Error(`malformed approvers document ${name} gives ${result.valid ? 'valid' : result.code}, not ${code}`);
|
|
141
|
+
return { name, code, description, ...value };
|
|
142
|
+
});
|
|
143
|
+
const tampered = [
|
|
144
|
+
['approver-added-by-the-server', 'The server adds a passkey of its own: it could then approve any store job.', altered(base, (d) => d.approvers.push({ credential_id: keys.outsider.credential_id, public_key: keys.outsider.public_key, alg: keys.outsider.alg, member: '01930000-0000-7000-8000-00000000a009', name: 'Owner de prueba' }))],
|
|
145
|
+
['public-key-replaced', 'The first approver keeps its credential id and name, with the key of another passkey.', altered(base, (d) => (d.approvers[0].public_key = keys.outsider.public_key))],
|
|
146
|
+
['approver-removed', 'An approver removed without signing.', altered(base, (d) => d.approvers.pop())],
|
|
147
|
+
['origin-added', 'An origin of someone else added, where a passkey could be asked to sign.', altered(base, (d) => d.origins.push('https://ias.imagina.build.evil.example'))],
|
|
148
|
+
['rp-id-changed', 'Another rp_id.', altered(base, (d) => (d.rp_id = 'imagina.build'))],
|
|
149
|
+
['version-raised', 'The version raised without signing, to look newer than the one a runner has.', altered(base, (d) => (d.version = 9))],
|
|
150
|
+
['name-changed', 'The name of an approver changed.', altered(base, (d) => (d.approvers[2].name = 'Otra persona'))],
|
|
151
|
+
['approvers-reordered', 'The approvers in another order.', altered(base, (d) => d.approvers.reverse())],
|
|
152
|
+
['forged-under-the-team-kid', 'A list with the outsider\'s passkey signed by another key under the team\'s kid.', (() => {
|
|
153
|
+
const forged = signApprovers({ ...base, version: 3, approvers: [...base.approvers, { credential_id: keys.outsider.credential_id, public_key: keys.outsider.public_key, alg: keys.outsider.alg, member: owner, name: 'Owner de prueba' }] }, signing['other-team'].privateKey);
|
|
154
|
+
return { ...forged, signature: { kid: team.kid, sig: forged.signature.sig } };
|
|
155
|
+
})()],
|
|
156
|
+
].map(([name, description, document]) => {
|
|
157
|
+
if (!validateApprovers(document, { signingKid: team.kid }).valid) throw new Error(`tampered approvers document ${name} is not well formed`);
|
|
158
|
+
try {
|
|
159
|
+
verifyApprovers(document, team.publicKey);
|
|
160
|
+
throw new Error(`tampered approvers document ${name} verifies`);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if (error.code !== 'signature-invalid') throw new Error(`tampered approvers document ${name} fails with ${error.code ?? error.message}`);
|
|
163
|
+
}
|
|
164
|
+
return { name, description, error: 'signature-invalid', document };
|
|
165
|
+
});
|
|
166
|
+
const older = [
|
|
167
|
+
['older-than-seen', 'Version 1 when the runner has already seen version 2: a server that serves an older list, still signed, to bring back a removed passkey.', documents[0].document, 2],
|
|
168
|
+
].map(([name, description, document, minimumVersion]) => {
|
|
169
|
+
try {
|
|
170
|
+
verifyApprovers(document, team.publicKey, { minimumVersion });
|
|
171
|
+
throw new Error(`older approvers document ${name} verifies`);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (error.code !== 'version-backwards') throw new Error(`older approvers document ${name} fails with ${error.code ?? error.message}`);
|
|
174
|
+
}
|
|
175
|
+
return { name, description, options: { minimumVersion }, error: 'version-backwards', document };
|
|
176
|
+
});
|
|
177
|
+
return {
|
|
178
|
+
format: APPROVERS_FORMAT,
|
|
179
|
+
description: `The passkeys that may approve a store job (${APPROVERS_FORMAT}, docs/technical/vault.md §2.4 and docs/technical/runner-api.md §1), signed with the team key of keys.json (signing.team; other-team makes the forgeries). passkeys are public keys only: alg is the COSE algorithm an approver uses with it, or null if none may. documents are good ones, each with the signedText the signature covers and the fingerprint of each passkey; the first holds the approvers of tests/Fixtures/runner-v1/approval-vectors.json. malformed must be rejected with code (validateApprovers, with options); tampered are well formed but do not verify; older verify but are older than options.minimumVersion.`,
|
|
180
|
+
warning: 'Test vectors: the keys are public. Never use them for anything else.',
|
|
181
|
+
signing: { kid: team.kid, publicKey: team.publicKey },
|
|
182
|
+
passkeys: keys,
|
|
183
|
+
documents,
|
|
184
|
+
malformed,
|
|
185
|
+
tampered,
|
|
186
|
+
older,
|
|
187
|
+
};
|
|
188
|
+
}
|