@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,256 @@
1
+ // ias credentials import, list, show, rotate and retire (docs/technical/manage-api.md §8). import reads the material (files,
2
+ // op:// references or EAS's credentials.json), takes its metadata with @imagina/ias-shared/credentials, makes the id
3
+ // (a UUID) and seals it in ias-vault-2 on this computer: for the recovery key and the runners of the list the team
4
+ // signed, both checked against 1Password, and signed with the team's key. The server gets the envelope and the
5
+ // metadata, never a secret; nothing here prints one.
6
+ import { randomUUID } from 'node:crypto';
7
+ import { resolve } from 'node:path';
8
+ import { loadProject } from '../../config.mjs';
9
+ import { ApiError, IasError } from '../../errors.mjs';
10
+ import { applicationOf, connect } from '../../manage.mjs';
11
+ import { PLAINTEXT_TYPES, credentials as inspect, digest, encodePlaintext, seal, verifyEnvelope } from '../../shared.mjs';
12
+ import { publicKeysOf, sealingKeys, vaultItem } from '../../team-vault.mjs';
13
+ import { MATERIAL_OPTIONS, defaultName, inferType, materialFromEas, materialFromOptions, sameMaterial } from './material.mjs';
14
+
15
+ const projectOf = ({ values, cwd }) => loadProject(resolve(cwd, values.project ?? '.'));
16
+ const CREDENTIAL_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
17
+ const ofTeam = (type) => PLAINTEXT_TYPES[type]?.scope === 'team';
18
+ const DAY = 24 * 60 * 60 * 1000;
19
+
20
+ /** The metadata of `material`, or an IasError that names the file or tool, never a secret. */
21
+ function metadataOf(type, material) {
22
+ try {
23
+ return inspect.metadataOf(type, material.inspect);
24
+ } catch (error) {
25
+ if (error instanceof inspect.CredentialError) throw new IasError(`${type}: ${error.message}`, { fix: 'check the file and its password or alias', code: 'credential_unreadable' });
26
+ throw error;
27
+ }
28
+ }
29
+
30
+ /** The plaintext to seal: the fields of the material, those that only the metadata knows, and the application. */
31
+ function plaintextOf(type, material, metadata, application) {
32
+ return encodePlaintext(type, { ...(ofTeam(type) ? {} : { application }), ...material.fields, ...(material.completes?.(metadata) ?? {}) });
33
+ }
34
+
35
+ /** Seals `plaintext` as version `version` of credential `id`, and checks the signature it just made. */
36
+ function sealCredential(plaintext, { id, type, version }, sealing) {
37
+ try {
38
+ const envelope = seal(plaintext, { credential: { id, type, version }, recipients: sealing.recipients, signingKey: sealing.signingKey });
39
+ verifyEnvelope(envelope, sealing.keys.signing.publicKey);
40
+ return envelope;
41
+ } finally {
42
+ plaintext.fill(0);
43
+ }
44
+ }
45
+
46
+ /** Warnings about an expiry within 30 days. */
47
+ function expiryWarning(name, metadata, output, now = Date.now()) {
48
+ if (!metadata?.expires) return;
49
+ const days = Math.floor((Date.parse(metadata.expires) - now) / DAY);
50
+ if (days < 0) output.warn(`${name} expired on ${metadata.expires}`);
51
+ else if (days <= 30) output.warn(`${name} expires in ${days} days (${metadata.expires})`);
52
+ }
53
+
54
+ /** The credentials the token can see: those of the application (if any) and the team's (with a personal token). */
55
+ async function visibleCredentials(client, application) {
56
+ const found = [];
57
+ if (application) found.push(...(await client.request('GET', `/apps/${application}/credentials`)).data.data);
58
+ try {
59
+ found.push(...(await client.request('GET', '/team/credentials')).data.data);
60
+ } catch (error) {
61
+ if (!(error instanceof ApiError) || error.code !== 'role_forbidden') throw error; // a robot token sees no team credentials
62
+ }
63
+ return found;
64
+ }
65
+
66
+ /** One credential by its id, from the lists (the API has no route for a single one). */
67
+ async function findCredential(client, id, application) {
68
+ const credential = (await visibleCredentials(client, application)).find((candidate) => candidate.id === id);
69
+ if (!credential) throw new IasError(`there is no credential ${id}${application ? ` in ${application} or the team` : ' in the team'}`, { fix: 'ias credentials list shows them; pass --app for one of another application', code: 'credential_not_found' });
70
+ return credential;
71
+ }
72
+
73
+ const optionalApplication = (context, project) => (context.values.app || project?.config?.app ? applicationOf(context, project) : undefined);
74
+
75
+ async function importCredentials(context) {
76
+ const { values, env, cwd, output } = context;
77
+ const project = projectOf(context);
78
+ const client = connect(context, project);
79
+ if (values.eas && context.positionals[0]) throw new IasError('--eas imports every credential of credentials.json', { fix: 'leave out the type, or import one with its own options' });
80
+ const type = values.eas ? undefined : (context.positionals[0] ?? inferType(values));
81
+ const materials = values.eas ? materialFromEas(resolve(cwd, values.eas)) : [{ type, ...materialFromOptions(type, values, { env, cwd }) }];
82
+ if (values.name && materials.length > 1) throw new IasError('--name names one credential, and credentials.json has several', { fix: 'import them without --name, and rename them in the panel' });
83
+ const needsApplication = materials.some(({ type }) => !ofTeam(type));
84
+ const application = needsApplication ? applicationOf(context, project) : optionalApplication(context, project);
85
+ const described = materials.map((material) => ({ ...material, metadata: metadataOf(material.type, material) }));
86
+ if (values.eas) {
87
+ const app = application ? (await client.request('GET', `/apps/${application}`)).data : null;
88
+ const certificate = described.find((entry) => entry.type === 'apple_distribution_certificate')?.metadata;
89
+ const profile = described.find((entry) => entry.type === 'apple_provisioning_profile')?.metadata;
90
+ const keystore = described.find((entry) => entry.type === 'android_upload_keystore')?.metadata;
91
+ const problems = [
92
+ ...(certificate && profile ? inspect.checkIos({ certificate, profile }, { teamId: app?.ios?.team_id ?? undefined, bundleId: app?.ios?.bundle_id ?? undefined, type: profile.type }) : []),
93
+ ...(keystore ? inspect.checkAndroid(keystore) : []),
94
+ ];
95
+ if (problems.length > 0 && !values.force) throw new IasError(`credentials.json has problems: ${problems.join('; ')}`, { fix: 'fix them in EAS or Apple Developer, or pass --force to import it anyway', code: 'credential_problems', details: problems });
96
+ for (const problem of problems) output.warn(problem);
97
+ }
98
+ const sealing = await sealingKeys(client, vaultItem(context, project, client.server), env, output);
99
+ const existing = await visibleCredentials(client, application);
100
+ const results = [];
101
+ for (const { type, metadata, ...material } of described) {
102
+ const name = values.name ?? defaultName(type, metadata);
103
+ const same = existing.find((credential) => sameMaterial(credential, type, metadata));
104
+ if (same && !values.force) {
105
+ output.log(`${type} «${name}» is already in the vault as ${same.id} (version ${same.version}): skipped. ias credentials rotate ${same.id} replaces it.`);
106
+ results.push({ skipped: true, id: same.id, type, name });
107
+ continue;
108
+ }
109
+ const id = randomUUID();
110
+ const envelope = sealCredential(plaintextOf(type, material, metadata, application), { id, type, version: 1 }, sealing);
111
+ const body = { id, type, name, metadata, envelope };
112
+ const recipients = envelope.recipients.length;
113
+ if (values['dry-run']) {
114
+ output.log(`dry run: ${type} «${name}» (${id}) sealed for ${recipients} key${recipients === 1 ? '' : 's'}, digest ${digest(envelope)}; nothing sent`);
115
+ results.push({ dry_run: true, id, type, name, metadata, digest: digest(envelope), recipients: envelope.recipients.map((wrap) => wrap.kid) });
116
+ continue;
117
+ }
118
+ const { data } = await client.request('POST', ofTeam(type) ? '/team/credentials' : `/apps/${application}/credentials`, { json: body });
119
+ output.log(`imported ${type} «${data.name}» as ${data.id}, version ${data.version}${data.expires_at ? `, until ${data.expires_at}` : ''} (${ofTeam(type) ? 'the team' : data.application})`);
120
+ expiryWarning(`«${data.name}»`, metadata, output);
121
+ results.push(data);
122
+ }
123
+ return { credentials: results };
124
+ }
125
+
126
+ async function list(context) {
127
+ const { output } = context;
128
+ const project = projectOf(context);
129
+ const client = connect(context, project);
130
+ const application = optionalApplication(context, project);
131
+ const found = (await visibleCredentials(client, application)).filter((credential) => context.values.all || !credential.retired_at);
132
+ if (found.length === 0) output.log(`No credentials${application ? ` in ${application} or the team` : ' in the team'}: ias credentials import adds them.`);
133
+ const now = Date.now();
134
+ for (const credential of found) {
135
+ const until = credential.expires_at ? `until ${credential.expires_at.slice(0, 10)} (${Math.floor((Date.parse(credential.expires_at) - now) / DAY)} days)` : 'no expiry';
136
+ output.log(`${credential.id} ${credential.type.padEnd(30)} v${credential.version} ${until}${credential.retired_at ? ' retired' : ''} ${credential.application ?? 'team'} «${credential.name}»`);
137
+ }
138
+ return { credentials: found };
139
+ }
140
+
141
+ async function show(context) {
142
+ const { values, env, output } = context;
143
+ const project = projectOf(context);
144
+ const client = connect(context, project);
145
+ const credential = await findCredential(client, context.positionals[0], optionalApplication(context, project));
146
+ const { data } = await client.request('GET', `/credentials/${credential.id}/envelope`);
147
+ const { envelope } = data;
148
+ const { data: vault } = await client.request('GET', '/vault');
149
+ const names = new Map([[vault.recovery_key?.kid, 'recovery key'], ...(vault.runners ?? []).map((runner) => [runner.kid, `runner ${runner.name ?? ''}`.trim()])]);
150
+ output.log(`${credential.id} ${credential.type} «${credential.name}» (${credential.application ?? 'the team'})`);
151
+ output.log(` version ${credential.version}${credential.retired_at ? `, retired on ${credential.retired_at}` : ''}`);
152
+ output.log(` expires ${credential.expires_at ?? 'never'}`);
153
+ output.log(` digest ${credential.digest}${digest(envelope) === credential.digest ? '' : ' (NOT the digest of the envelope the server gives)'}`);
154
+ for (const [key, value] of Object.entries(credential.metadata ?? {})) if (key !== 'entitlements') output.log(` ${key.padEnd(11)} ${Array.isArray(value) ? value.join(', ') : value}`);
155
+ output.log(` sealed for ${envelope.recipients.map((wrap) => `${names.get(wrap.kid) ?? 'an unknown key'} (${wrap.kid})`).join(', ')}`);
156
+ let verified = null;
157
+ if (values.verify) {
158
+ const keys = publicKeysOf(vaultItem(context, project, client.server), env);
159
+ if (!keys) throw new IasError('there is no item of the vault in 1Password to verify with', { fix: 'pass --item, or ask an owner to share the item with you' });
160
+ try {
161
+ verifyEnvelope(envelope, keys.signing.publicKey);
162
+ verified = true;
163
+ } catch (error) {
164
+ verified = false;
165
+ output.warn(`the envelope does NOT verify with the team's signing key of 1Password (${error.code})`);
166
+ }
167
+ }
168
+ output.log(` signed by ${envelope.signature?.kid === vault.signing_key?.kid ? 'the team' : 'an unknown key'} (${envelope.signature?.kid})${verified === true ? ', verified with 1Password' : ''}`);
169
+ return { ...credential, recipients: envelope.recipients.map((wrap) => wrap.kid), signature_kid: envelope.signature?.kid ?? null, verified };
170
+ }
171
+
172
+ async function rotate(context) {
173
+ const { values, env, cwd, output } = context;
174
+ const project = projectOf(context);
175
+ const client = connect(context, project);
176
+ const credential = await findCredential(client, context.positionals[0], optionalApplication(context, project));
177
+ if (credential.retired_at) throw new IasError(`${credential.id} is retired`, { fix: 'import the new material as a new credential: ias credentials import' });
178
+ const { type } = credential;
179
+ const material = materialFromOptions(type, values, { env, cwd });
180
+ const metadata = metadataOf(type, material);
181
+ const sealing = await sealingKeys(client, vaultItem(context, project, client.server), env, output);
182
+ const version = credential.version + 1;
183
+ const envelope = sealCredential(plaintextOf(type, material, metadata, credential.application), { id: credential.id, type, version }, sealing);
184
+ if (values['dry-run']) {
185
+ output.log(`dry run: version ${version} of «${credential.name}» sealed, digest ${digest(envelope)}; nothing sent`);
186
+ return { dry_run: true, id: credential.id, version, metadata, digest: digest(envelope) };
187
+ }
188
+ const { data } = await client.request('PUT', `/credentials/${credential.id}`, { json: { ...(values.name ? { name: values.name } : {}), metadata, envelope } });
189
+ output.log(`replaced «${data.name}» (${data.id}): version ${data.version}${data.expires_at ? `, until ${data.expires_at}` : ''}`);
190
+ expiryWarning(`«${data.name}»`, metadata, output);
191
+ return data;
192
+ }
193
+
194
+ async function retire(context) {
195
+ const { data } = await connect(context, projectOf(context)).request('POST', `/credentials/${context.positionals[0]}/retire`);
196
+ context.output.log(`retired «${data.name}» (${data.id}) on ${data.retired_at}: it is no longer used and stays in the inventory`);
197
+ return data;
198
+ }
199
+
200
+ const credentialId = { name: 'id', description: 'The id of the credential, from ias credentials list.', pattern: CREDENTIAL_ID };
201
+ const common = {
202
+ app: { type: 'string', value: '<code>', description: 'The application (app of ias.json by default).' },
203
+ };
204
+ const item = { item: { type: 'string', value: '<op://vault/item>', description: 'The 1Password item of the vault (IAS_VAULT_ITEM, vault.item of ias.json, or op://Private/IAS vault <host>).' } };
205
+ const types = Object.keys(PLAINTEXT_TYPES).filter((type) => type !== 'environment_variable');
206
+
207
+ export default {
208
+ name: 'credentials',
209
+ summary: 'The signing credentials in the sealed vault: import, list, show, replace and retire',
210
+ description: `Credentials are sealed on this computer (ias-vault-2, docs/technical/vault.md) for the team's recovery key and the authorised runners, and signed by the team; the keys come from the 1Password item of ias vault init. Types: ${types.join(', ')}.`,
211
+ subcommands: [
212
+ {
213
+ name: 'import',
214
+ summary: 'Seal a credential and upload it, with its metadata',
215
+ description: 'Name the type and give its files (paths or op:// references) and passwords (op://…, env:NAME or -), or --eas with the folder of an EAS credentials.json. The type can be left out when the options say it. A credential already in the vault (the same certificate, profile or key) is skipped; ias credentials rotate replaces it.',
216
+ positionals: [{ name: 'type', optional: true, description: `One of ${types.join(', ')}.`, pattern: /^[a-z][a-z0-9_]{0,63}$/ }],
217
+ options: {
218
+ ...common,
219
+ ...MATERIAL_OPTIONS,
220
+ eas: { type: 'string', value: '<folder>', description: 'The folder of an EAS credentials.json: imports its certificate, profile and keystore.' },
221
+ name: { type: 'string', value: '"…"', description: 'The name the panel shows (from the material by default).' },
222
+ force: { type: 'boolean', description: 'Import even if it is already in the vault or EAS\'s material has problems.' },
223
+ 'dry-run': { type: 'boolean', description: 'Read, check and seal, but send nothing.' },
224
+ ...item,
225
+ },
226
+ run: importCredentials,
227
+ },
228
+ {
229
+ name: 'list',
230
+ summary: 'The credentials of the application and the team, with their expiry',
231
+ options: { ...common, all: { type: 'boolean', description: 'Also the retired ones.' } },
232
+ run: list,
233
+ },
234
+ {
235
+ name: 'show',
236
+ summary: 'One credential: its metadata, version, digest, who can open it and who signed it',
237
+ positionals: [credentialId],
238
+ options: { ...common, verify: { type: 'boolean', description: 'Check the signature of its envelope with the team\'s signing key of 1Password.' }, ...item },
239
+ run: show,
240
+ },
241
+ {
242
+ name: 'rotate',
243
+ summary: 'Replace the material of a credential with a new version',
244
+ description: 'Takes the options of import for the type of the credential, and seals the next version.',
245
+ positionals: [credentialId],
246
+ options: { ...common, ...MATERIAL_OPTIONS, name: { type: 'string', value: '"…"', description: 'A new name.' }, 'dry-run': { type: 'boolean', description: 'Read, check and seal, but send nothing.' }, ...item },
247
+ run: rotate,
248
+ },
249
+ {
250
+ name: 'retire',
251
+ summary: 'Stop using a credential (owner); it stays in the inventory',
252
+ positionals: [credentialId],
253
+ run: retire,
254
+ },
255
+ ],
256
+ };
@@ -0,0 +1,159 @@
1
+ // The material of each type of credential, from the options of ias credentials import and rotate or from an EAS
2
+ // credentials.json: what inspect needs for the metadata (paths and passwords) and the fields of the plaintext that is
3
+ // sealed (docs/technical/vault.md §3.5). Passwords come as op://…, env:NAME or -; files as paths or op:// references.
4
+ import { readFileSync } from 'node:fs';
5
+ import { basename, join, resolve } from 'node:path';
6
+ import { IasError } from '../../errors.mjs';
7
+ import { filePath, secretValue } from '../../secret-input.mjs';
8
+ import { PLAINTEXT_TYPES } from '../../shared.mjs';
9
+
10
+ /** The options that give the material, by type. */
11
+ export const MATERIAL_OPTIONS = {
12
+ p12: { type: 'string', value: '<file|op://…>', description: 'apple_distribution_certificate: the .p12.' },
13
+ password: { type: 'string', value: '<op://…|env:NAME|->', description: 'The password of the .p12 (never the password itself).' },
14
+ profile: { type: 'string', value: '<file|op://…>', description: 'apple_provisioning_profile: the .mobileprovision.' },
15
+ keystore: { type: 'string', value: '<file|op://…>', description: 'android_upload_keystore: the .jks or PKCS12 keystore.' },
16
+ 'store-password': { type: 'string', value: '<op://…|env:NAME|->', description: 'The password of the keystore.' },
17
+ 'key-alias': { type: 'string', value: '<alias>', description: 'The alias of the upload key in the keystore.' },
18
+ 'key-password': { type: 'string', value: '<op://…|env:NAME|->', description: 'The password of the key (the keystore\'s by default).' },
19
+ p8: { type: 'string', value: '<file|op://…>', description: 'apns_key or asc_api_key: the .p8 (AuthKey_<id>.p8 names its key id).' },
20
+ 'key-id': { type: 'string', value: '<id>', description: 'The key id of the .p8 (10 characters), if its file name does not say it.' },
21
+ 'team-id': { type: 'string', value: '<id>', description: 'The Apple team of the .p8 (10 characters).' },
22
+ 'issuer-id': { type: 'string', value: '<uuid>', description: 'asc_api_key: the issuer id of App Store Connect.' },
23
+ 'service-account': { type: 'string', value: '<file|op://…>', description: 'google_service_account: the JSON key of the service account.' },
24
+ };
25
+
26
+ /** The type a set of options gives, when the command line does not name it. */
27
+ export function inferType(values) {
28
+ const found = [];
29
+ if (values.p12) found.push('apple_distribution_certificate');
30
+ if (values.profile) found.push('apple_provisioning_profile');
31
+ if (values.keystore) found.push('android_upload_keystore');
32
+ if (values.p8) found.push(values['issuer-id'] ? 'asc_api_key' : 'apns_key');
33
+ if (values['service-account']) found.push('google_service_account');
34
+ if (found.length === 1) return found[0];
35
+ throw new IasError(found.length === 0 ? 'which credential?' : `the options give ${found.length} credentials (${found.join(', ')})`, { fix: 'name the type and its files, such as ias credentials import android_upload_keystore --keystore upload.jks --store-password op://… --key-alias upload; or --eas <folder>' });
36
+ }
37
+
38
+ const need = (values, name, type) => {
39
+ if (!values[name]) throw new IasError(`${type} needs --${name}`, { fix: `ias credentials import --help lists the options of each type` });
40
+ return values[name];
41
+ };
42
+
43
+ /**
44
+ * The material of `type` from the options: { inspect, fields, name } with `inspect` the input of metadataOf and
45
+ * `fields` those of encodePlaintext (without application, which the caller adds for the types of an application).
46
+ */
47
+ export function materialFromOptions(type, values, { env, cwd }) {
48
+ const secret = (name) => secretValue(need(values, name, type), { env, option: `--${name}` });
49
+ const file = (name, as) => filePath(need(values, name, type), { env, cwd, name: as });
50
+ switch (type) {
51
+ case 'apple_distribution_certificate': {
52
+ const p12 = file('p12', 'certificate.p12');
53
+ const password = secret('password');
54
+ return { inspect: { p12, password }, fields: { p12: readFileSync(p12), password } };
55
+ }
56
+ case 'apple_provisioning_profile': {
57
+ const profile = file('profile', 'profile.mobileprovision');
58
+ return { inspect: { mobileprovision: profile }, fields: { mobileprovision: readFileSync(profile) } };
59
+ }
60
+ case 'android_upload_keystore': {
61
+ const keystore = file('keystore', 'upload.keystore');
62
+ const storePassword = secret('store-password');
63
+ const keyPassword = values['key-password'] ? secret('key-password') : storePassword;
64
+ const alias = need(values, 'key-alias', type);
65
+ return { inspect: { keystore, storePassword, alias, keyPassword }, fields: { keystore: readFileSync(keystore), store_password: storePassword, key_alias: alias, key_password: keyPassword } };
66
+ }
67
+ case 'apns_key':
68
+ case 'asc_api_key': {
69
+ const given = need(values, 'p8', type);
70
+ const p8 = filePath(given, { env, cwd, name: /^AuthKey_[A-Z0-9]{10}\.p8$/.test(basename(given)) ? basename(given) : 'key.p8' });
71
+ const issuerId = type === 'asc_api_key' ? need(values, 'issuer-id', type) : undefined;
72
+ return { inspect: { p8, keyId: values['key-id'], teamId: need(values, 'team-id', type), issuerId }, fields: { p8: readFileSync(p8) }, completes: (metadata) => (type === 'apns_key' ? { key_id: metadata.keyId, team_id: metadata.teamId } : { key_id: metadata.keyId, issuer_id: metadata.issuerId }) };
73
+ }
74
+ case 'google_service_account': {
75
+ const json = file('service-account', 'service-account.json');
76
+ return { inspect: { json }, fields: { json: readFileSync(json) } };
77
+ }
78
+ default:
79
+ throw new IasError(`there is no credential type ${type}`, { fix: `one of ${Object.keys(PLAINTEXT_TYPES).filter((name) => name !== 'environment_variable').join(', ')}` });
80
+ }
81
+ }
82
+
83
+ /**
84
+ * The material of an EAS credentials.json (eas credentials › Download credentials from EAS) in `dir`: the iOS
85
+ * distribution certificate, the profile and the Android keystore that it has. The passwords in the file are used and
86
+ * never shown.
87
+ */
88
+ export function materialFromEas(dir) {
89
+ let json;
90
+ try {
91
+ json = JSON.parse(readFileSync(join(dir, 'credentials.json'), 'utf8'));
92
+ } catch {
93
+ throw new IasError(`no readable credentials.json in ${dir}`, { fix: 'download it with eas credentials › Download credentials from EAS, into a folder outside the repository' });
94
+ }
95
+ const found = [];
96
+ let ios = json.ios;
97
+ if (ios && !ios.distributionCertificate) {
98
+ const targets = Object.keys(ios);
99
+ if (targets.length !== 1) throw new IasError(`credentials.json has iOS credentials for ${targets.length} targets`, { fix: 'only one target is supported: import the others with --p12 and --profile' });
100
+ ios = ios[targets[0]];
101
+ }
102
+ if (ios?.distributionCertificate) {
103
+ const p12 = resolve(dir, ios.distributionCertificate.path);
104
+ const password = String(ios.distributionCertificate.password ?? '');
105
+ found.push({ type: 'apple_distribution_certificate', inspect: { p12, password }, fields: { p12: readFileSync(p12), password } });
106
+ }
107
+ if (ios?.provisioningProfilePath) {
108
+ const profile = resolve(dir, ios.provisioningProfilePath);
109
+ found.push({ type: 'apple_provisioning_profile', inspect: { mobileprovision: profile }, fields: { mobileprovision: readFileSync(profile) } });
110
+ }
111
+ const keystore = json.android?.keystore;
112
+ if (keystore) {
113
+ const path = resolve(dir, keystore.keystorePath);
114
+ const storePassword = String(keystore.keystorePassword ?? '');
115
+ const keyPassword = String(keystore.keyPassword ?? keystore.keystorePassword ?? '');
116
+ found.push({ type: 'android_upload_keystore', inspect: { keystore: path, storePassword, alias: keystore.keyAlias, keyPassword }, fields: { keystore: readFileSync(path), store_password: storePassword, key_alias: keystore.keyAlias, key_password: keyPassword } });
117
+ }
118
+ if (found.length === 0) throw new IasError('credentials.json has neither iOS nor Android credentials', { fix: 'download it again with eas credentials' });
119
+ return found;
120
+ }
121
+
122
+ /** The name a credential gets when --name does not give one. */
123
+ export function defaultName(type, metadata) {
124
+ switch (type) {
125
+ case 'apple_distribution_certificate':
126
+ return metadata.commonName ?? `Apple certificate ${metadata.serial}`;
127
+ case 'apple_provisioning_profile':
128
+ return metadata.name ?? `Provisioning profile ${metadata.uuid}`;
129
+ case 'android_upload_keystore':
130
+ return `Upload keystore (${metadata.alias})`;
131
+ case 'apns_key':
132
+ return `APNs key ${metadata.keyId}`;
133
+ case 'asc_api_key':
134
+ return `App Store Connect API key ${metadata.keyId}`;
135
+ case 'google_service_account':
136
+ return `Google Play service account ${metadata.clientEmail}`;
137
+ default:
138
+ return type;
139
+ }
140
+ }
141
+
142
+ /** Whether a stored credential is the same material as `metadata` (the same certificate, profile, key…). */
143
+ export function sameMaterial(credential, type, metadata) {
144
+ if (credential.type !== type || credential.retired_at) return false;
145
+ const stored = credential.metadata ?? {};
146
+ switch (type) {
147
+ case 'apple_distribution_certificate':
148
+ case 'android_upload_keystore':
149
+ return Boolean(metadata.sha256) && stored.sha256 === metadata.sha256;
150
+ case 'apple_provisioning_profile':
151
+ return Boolean(metadata.uuid) && stored.uuid === metadata.uuid;
152
+ case 'apns_key':
153
+ case 'asc_api_key':
154
+ case 'google_service_account':
155
+ return Boolean(metadata.publicKeySha256) && stored.publicKeySha256 === metadata.publicKeySha256;
156
+ default:
157
+ return false;
158
+ }
159
+ }
@@ -0,0 +1,116 @@
1
+ // The part of the App Store Connect API (https://developer.apple.com/documentation/appstoreconnectapi) that ias
2
+ // devices sync uses: the team's devices, its bundle ids, certificates and provisioning profiles. Every request carries
3
+ // a JWT of the team's API key (ES256 with node:crypto, 20 minutes at most, as Apple asks). The key comes from the
4
+ // sealed vault, opened in memory on this computer; neither the key nor a token is ever printed, written or an argument.
5
+ import { createPrivateKey, sign } from 'node:crypto';
6
+ import { IasError } from '../../errors.mjs';
7
+
8
+ export const ASC_API = 'https://api.appstoreconnect.apple.com';
9
+
10
+ /** The address of the API: IAS_APPLE_API_URL for tests (https, or a local address), or Apple's. */
11
+ export function ascBase(env = {}) {
12
+ const text = env.IAS_APPLE_API_URL ?? ASC_API;
13
+ const url = URL.canParse(text) ? new URL(text) : null;
14
+ const local = url && (['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) || url.hostname.endsWith('.test'));
15
+ if (!url || (url.protocol !== 'https:' && !local)) throw new IasError(`IAS_APPLE_API_URL (${text}) is not an https address`, { fix: 'leave it unset to talk to Apple' });
16
+ return url.origin;
17
+ }
18
+
19
+ const base64url = (value) => Buffer.from(value).toString('base64url');
20
+
21
+ /** A JWT of the App Store Connect API for this key: { key_id, issuer_id, p8 } (the .p8 as bytes or text). */
22
+ export function ascToken({ key_id: keyId, issuer_id: issuerId, p8 }, { now = Date.now, lifetime = 1200 } = {}) {
23
+ const issuedAt = Math.floor(now() / 1000);
24
+ const unsigned = `${base64url(JSON.stringify({ alg: 'ES256', kid: keyId, typ: 'JWT' }))}.${base64url(JSON.stringify({ iss: issuerId, iat: issuedAt, exp: issuedAt + Math.min(lifetime, 1200), aud: 'appstoreconnect-v1' }))}`;
25
+ let key;
26
+ try {
27
+ key = createPrivateKey({ key: Buffer.isBuffer(p8) ? p8 : Buffer.from(String(p8)), format: 'pem' });
28
+ } catch {
29
+ throw new IasError('the App Store Connect API key of the vault is not a .p8 key', { fix: 'import the right one with ias credentials import asc_api_key', code: 'asc_key_invalid' });
30
+ }
31
+ return `${unsigned}.${sign('sha256', Buffer.from(unsigned), { key, dsaEncoding: 'ieee-p1363' }).toString('base64url')}`;
32
+ }
33
+
34
+ /** What Apple answered, in one line: its status and the title and detail of its first error. Never the token. */
35
+ function appleProblem(status, body) {
36
+ const error = Array.isArray(body?.errors) ? body.errors[0] : null;
37
+ return [`${status}`, error?.code, error?.title, error?.detail].filter(Boolean).join(' · ');
38
+ }
39
+
40
+ /**
41
+ * A client of the API with the key `key` ({ key_id, issuer_id, p8 }): { request(method, path, json?), all(path),
42
+ * devices(), registerDevice(), enableDevice(), bundleId(), certificates(), profilesOf(), devicesOf(), createProfile() }.
43
+ * A token lasts 20 minutes; a new one is made before it ends.
44
+ */
45
+ export function ascClient(key, { env = {}, fetch: fetchImpl = fetch, now = Date.now } = {}) {
46
+ const origin = ascBase(env);
47
+ let token = null;
48
+ let expires = 0;
49
+
50
+ async function request(method, path, json) {
51
+ if (!token || now() > expires - 60_000) {
52
+ token = ascToken(key, { now });
53
+ expires = now() + 1_200_000;
54
+ }
55
+ const url = path.startsWith('http') ? path : `${origin}${path}`;
56
+ if (new URL(url).origin !== origin) throw new IasError(`App Store Connect pointed at another server (${new URL(url).origin})`, { fix: 'nothing was sent there; try again', code: 'apple_api_error' });
57
+ let response;
58
+ try {
59
+ response = await fetchImpl(url, { method, headers: { authorization: `Bearer ${token}`, accept: 'application/json', ...(json ? { 'content-type': 'application/json' } : {}) }, body: json ? JSON.stringify(json) : undefined });
60
+ } catch (error) {
61
+ throw new IasError(`App Store Connect did not answer (${error.cause?.code ?? error.message})`, { fix: 'check the connection and run it again: what was done stays done', code: 'apple_api_unreachable' });
62
+ }
63
+ const text = await response.text();
64
+ let body = null;
65
+ try {
66
+ body = text ? JSON.parse(text) : null;
67
+ } catch {
68
+ body = null;
69
+ }
70
+ if (!response.ok) {
71
+ const fix = response.status === 401 ? 'the API key was revoked or is not this team\'s: import a valid one with ias credentials import asc_api_key' : response.status === 403 ? 'the API key needs the Admin or App Manager role in App Store Connect' : 'see what Apple said; what was done before stays done';
72
+ throw new IasError(`App Store Connect refused ${method} ${new URL(url).pathname}: ${appleProblem(response.status, body)}`, { fix, code: 'apple_api_error', details: { status: response.status, errors: body?.errors ?? null } });
73
+ }
74
+ return body;
75
+ }
76
+
77
+ /** Every item of a list, following links.next. */
78
+ async function all(path) {
79
+ const items = [];
80
+ for (let next = path, pages = 0; next && pages < 50; pages += 1) {
81
+ const page = await request('GET', next);
82
+ items.push(...(page?.data ?? []));
83
+ next = page?.links?.next ?? null;
84
+ }
85
+ return items;
86
+ }
87
+
88
+ return {
89
+ request,
90
+ all,
91
+ /** The team's iOS devices (iPhone, iPad, iPod). */
92
+ devices: () => all('/v1/devices?filter[platform]=IOS&limit=200'),
93
+ registerDevice: async ({ name, udid }) => (await request('POST', '/v1/devices', { data: { type: 'devices', attributes: { name, platform: 'IOS', udid } } })).data,
94
+ enableDevice: async (id) => (await request('PATCH', `/v1/devices/${encodeURIComponent(id)}`, { data: { type: 'devices', id, attributes: { status: 'ENABLED' } } })).data,
95
+ /** The bundle id resource of `identifier` (Apple's filter matches parts too: only the exact one counts). */
96
+ bundleId: async (identifier) => (await all(`/v1/bundleIds?filter[identifier]=${encodeURIComponent(identifier)}&filter[platform]=IOS&limit=200`)).find((item) => item.attributes?.identifier === identifier) ?? null,
97
+ certificates: () => all('/v1/certificates?filter[certificateType]=DISTRIBUTION,IOS_DISTRIBUTION&limit=200'),
98
+ profilesOf: (bundleIdId) => all(`/v1/bundleIds/${encodeURIComponent(bundleIdId)}/profiles?limit=200`),
99
+ devicesOf: (profileId) => all(`/v1/profiles/${encodeURIComponent(profileId)}/devices?limit=200`),
100
+ /** A new ad hoc profile of the bundle id with the certificate and the devices: { id, attributes: { profileContent, uuid, … } }. */
101
+ createProfile: async ({ name, bundleIdId, certificateId, deviceIds }) =>
102
+ (
103
+ await request('POST', '/v1/profiles', {
104
+ data: {
105
+ type: 'profiles',
106
+ attributes: { name, profileType: 'IOS_APP_ADHOC' },
107
+ relationships: {
108
+ bundleId: { data: { type: 'bundleIds', id: bundleIdId } },
109
+ certificates: { data: [{ type: 'certificates', id: certificateId }] },
110
+ devices: { data: deviceIds.map((id) => ({ type: 'devices', id })) },
111
+ },
112
+ },
113
+ })
114
+ ).data,
115
+ };
116
+ }
@@ -0,0 +1,65 @@
1
+ // ias devices list and sync (docs/technical/manage-api.md §15): the iPhones and iPads of the testers of an application. A tester
2
+ // registers one with the link of the application (Distribución › Dispositivos in the panel), a member approves it with
3
+ // a passkey, and ias devices sync, here, registers the approved ones in Apple, makes the ad hoc profile again with
4
+ // them, seals and signs it into the vault and asks a runner to sign the newest internal build again (sync.mjs).
5
+ import { resolve } from 'node:path';
6
+ import { loadProject } from '../../config.mjs';
7
+ import { UsageError } from '../../errors.mjs';
8
+ import { applicationOf, connect } from '../../manage.mjs';
9
+ import { sync } from './sync.mjs';
10
+
11
+ const projectOf = ({ values, cwd }) => loadProject(resolve(cwd, values.project ?? '.'));
12
+ const STATUSES = ['pending', 'approved', 'registered', 'rejected'];
13
+ const LABELS = { pending: 'pending', approved: 'approved', registered: 'in Apple', rejected: 'rejected' };
14
+
15
+ async function list(context) {
16
+ const { values, output } = context;
17
+ const project = projectOf(context);
18
+ if (values.status && !STATUSES.includes(values.status)) throw new UsageError(`--status ${values.status} is not a status`, { fix: STATUSES.join(', ') });
19
+ const code = applicationOf(context, project);
20
+ const client = connect(context, project);
21
+ const query = values.status ? `?status=${encodeURIComponent(values.status)}` : '';
22
+ const { data } = await client.request('GET', `/apps/${code}/devices${query}`);
23
+ if (data.link) output.log(`Registration link: ${data.link.url}`);
24
+ else output.log('No registration link yet: make it in the panel (Distribución › Dispositivos).');
25
+ if (data.data.length === 0) output.log(values.status ? `No ${LABELS[values.status]} devices.` : 'No devices have registered yet.');
26
+ for (const device of data.data) {
27
+ const profile = device.profile ? `profile v${device.profile.version}` : 'not in a profile';
28
+ output.log(`${device.udid.padEnd(40)} ${LABELS[device.status].padEnd(9)} ${(device.model ?? '?').padEnd(11)} ${profile.padEnd(16)} «${device.owner_name ?? device.name ?? 'no name'}»`);
29
+ }
30
+ const approved = data.data.filter((device) => device.status === 'approved').length;
31
+ if (approved > 0) output.log(`${approved} approved device${approved === 1 ? '' : 's'} wait${approved === 1 ? 's' : ''} for ias devices sync.`);
32
+ return { link: data.link, devices: data.data };
33
+ }
34
+
35
+ const common = {
36
+ app: { type: 'string', value: '<code>', description: 'The application (app of ias.json by default).' },
37
+ };
38
+
39
+ export default {
40
+ name: 'devices',
41
+ summary: 'The testers\' iPhones and iPads: list them, and register the approved ones in Apple and sign again',
42
+ description: 'A tester registers a device with the link of the application (the panel: Distribución › Dispositivos) and a member approves it with a passkey. ias devices sync, on this computer, opens the App Store Connect API key of the vault with the recovery key of 1Password, registers the approved devices in Apple, makes the ad hoc profile again with them, seals and signs it into the vault and asks a runner to sign the newest internal iOS build again, without building it.',
43
+ subcommands: [
44
+ {
45
+ name: 'list',
46
+ summary: 'The devices of the application, with their status and the registration link',
47
+ options: { ...common, status: { type: 'string', value: `<${STATUSES.join('|')}>`, description: 'Only the devices in this status.' } },
48
+ run: list,
49
+ },
50
+ {
51
+ name: 'sync',
52
+ summary: 'Register the approved devices in Apple, make the ad hoc profile again and sign the newest internal build again',
53
+ description: 'It shows what it will do in Apple and in the vault and asks to confirm it. The team\'s keys come from the 1Password item of ias vault init; the App Store Connect API key, from the vault (ias credentials import asc_api_key).',
54
+ options: {
55
+ ...common,
56
+ yes: { type: 'boolean', description: 'Do not ask: the devices were checked in the panel.' },
57
+ force: { type: 'boolean', description: 'Make the profile again even if it already lists every approved device.' },
58
+ 'no-resign': { type: 'boolean', description: 'Only the devices and the profile: do not sign the internal build again.' },
59
+ detach: { type: 'boolean', description: 'Ask for the resign and do not wait for it.' },
60
+ item: { type: 'string', value: '<op://vault/item>', description: 'The 1Password item of the vault (IAS_VAULT_ITEM, vault.item of ias.json, or op://Private/IAS vault <host>).' },
61
+ },
62
+ run: (context) => sync(context, projectOf(context)),
63
+ },
64
+ ],
65
+ };