@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,57 @@
|
|
|
1
|
+
// ias usage: what the team of the token used in a month, metric by metric, with its limits and how its applications share
|
|
2
|
+
// it (docs/technical/manage-api.md §17, docs/technical/phase-5.md §2). A personal token only: the use belongs to the team, not to an application.
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { loadProject } from '../../config.mjs';
|
|
5
|
+
import { UsageError } from '../../errors.mjs';
|
|
6
|
+
import { connect } from '../../manage.mjs';
|
|
7
|
+
|
|
8
|
+
const MONTH = /^\d{4}-(0[1-9]|1[0-2])$/;
|
|
9
|
+
|
|
10
|
+
/** «1.5 GiB», «12 KiB», «800 B». */
|
|
11
|
+
export function bytesText(bytes) {
|
|
12
|
+
const units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
|
|
13
|
+
let value = bytes;
|
|
14
|
+
let unit = 0;
|
|
15
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
16
|
+
value /= 1024;
|
|
17
|
+
unit += 1;
|
|
18
|
+
}
|
|
19
|
+
return `${unit === 0 ? value : value.toFixed(value >= 100 ? 0 : 1)} ${units[unit]}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** An amount in the unit of its metric: minutes, bytes or a plain count. */
|
|
23
|
+
export function amountText(figure, value) {
|
|
24
|
+
if (figure.unit === 'bytes') return bytesText(value);
|
|
25
|
+
return figure.unit === 'minutes' ? `${value.toLocaleString('en-US')} min` : value.toLocaleString('en-US');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The lines that describe the month for people. */
|
|
29
|
+
export function describeUsage(report) {
|
|
30
|
+
const lines = [`${report.team.slug} (${report.team.name}) · ${report.month}${report.through ? ` · complete through ${report.through}` : ''}`];
|
|
31
|
+
const width = Math.max(...report.metrics.map((figure) => figure.metric.length));
|
|
32
|
+
for (const figure of report.metrics) {
|
|
33
|
+
const limit = figure.limit === null ? (figure.limitable ? 'no limit' : '') : `of ${amountText(figure, figure.limit)}${figure.limit === 0 ? ' (closed)' : ` (${Math.round((figure.value / figure.limit) * 100)} %)`}`;
|
|
34
|
+
const over = figure.limit !== null && figure.value >= figure.limit ? ' LIMIT REACHED' : '';
|
|
35
|
+
lines.push(` ${figure.metric.padEnd(width)} ${amountText(figure, figure.value).padStart(10)} ${limit}${over}`.trimEnd());
|
|
36
|
+
for (const share of figure.applications) lines.push(` ${(share.application?.code ?? 'no application').padEnd(32)} ${amountText(figure, share.value).padStart(10)}`);
|
|
37
|
+
}
|
|
38
|
+
if (report.current) lines.push('The metrics with a limit count today as it is now; the rest are complete up to yesterday.');
|
|
39
|
+
return lines;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function usage(context) {
|
|
43
|
+
const { values, output } = context;
|
|
44
|
+
if (values.month !== undefined && !MONTH.test(values.month)) throw new UsageError(`«${values.month}» is not a month`, { fix: 'write it as YYYY-MM, such as --month 2026-10' });
|
|
45
|
+
const client = connect(context, loadProject(resolve(context.cwd, values.project ?? '.')));
|
|
46
|
+
const { data } = await client.request('GET', `/team/usage${values.month ? `?month=${values.month}` : ''}`);
|
|
47
|
+
for (const line of describeUsage(data)) output.log(line);
|
|
48
|
+
return data;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export default {
|
|
52
|
+
name: 'usage',
|
|
53
|
+
summary: 'What the team used in a month, with its limits',
|
|
54
|
+
description: 'Builds (minutes per platform, artifacts), updates (OTA), notifications, synchronization and testers\' devices, per application. A limit is set by a platform administrator; at 100 % the builds, notifications or devices it covers are refused with 409 limit_exceeded.',
|
|
55
|
+
options: { month: { type: 'string', value: '<YYYY-MM>', description: 'The month (the current one by default).' } },
|
|
56
|
+
run: usage,
|
|
57
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// ias vault approvers: the passkeys that may approve a store build, signed by the team (ias-approvers-1, docs/technical/vault.md
|
|
2
|
+
// §2.4 and docs/technical/runner-api.md §1). It reads the owners' passkeys from the server, shows each one (member, passkey and
|
|
3
|
+
// fingerprint, worked out here) and what changes from the version the team signed before, which it verifies with the
|
|
4
|
+
// team key of 1Password; a person confirms, and it signs the next version and uploads it. The server is not trusted:
|
|
5
|
+
// a passkey it added would show up here as new, and a document it changed would not verify.
|
|
6
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
7
|
+
import { IasError } from '../../errors.mjs';
|
|
8
|
+
import { APPROVERS_FORMAT, approverAlgorithm, approverFingerprint, signApprovers, validateApprovers, verifyApprovers } from '../../shared.mjs';
|
|
9
|
+
import { confirm, teamKeys } from './team.mjs';
|
|
10
|
+
|
|
11
|
+
const ALGORITHMS = { '-7': 'ES256', '-257': 'RS256' };
|
|
12
|
+
const day = (iso) => (typeof iso === 'string' ? iso.slice(0, 10) : 'unknown date');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The passkeys the server lists, as approvers of the document: the algorithm and fingerprint worked out here (the
|
|
16
|
+
* server's are only compared), and those that cannot be one left out with a warning.
|
|
17
|
+
*/
|
|
18
|
+
function candidates(listed, output) {
|
|
19
|
+
const approvers = [];
|
|
20
|
+
for (const entry of listed) {
|
|
21
|
+
const label = `the passkey «${entry.passkey ?? '?'}» of ${entry.name ?? entry.member}`;
|
|
22
|
+
if (approvers.some((found) => found.approver.credential_id === entry.credential_id)) {
|
|
23
|
+
output.warn(`the server lists ${label} twice: kept once`);
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
const alg = approverAlgorithm(entry.public_key);
|
|
27
|
+
if (alg === null) {
|
|
28
|
+
output.warn(`${label} is not a P-256 or RSA (2048+) key: left out, it cannot approve a store build`);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (entry.alg !== alg) output.warn(`the server says ${label} uses alg ${entry.alg}, but its key is ${ALGORITHMS[alg]} (${alg}): signing ${alg}`);
|
|
32
|
+
const fingerprint = approverFingerprint(entry.public_key);
|
|
33
|
+
if (entry.fingerprint !== undefined && entry.fingerprint !== fingerprint) output.warn(`the server gives ${label} the fingerprint ${entry.fingerprint}, but its key has ${fingerprint}: the one shown here is worked out from the key`);
|
|
34
|
+
const approver = { credential_id: entry.credential_id, public_key: entry.public_key, alg, member: entry.member, name: entry.name };
|
|
35
|
+
const problem = validateApprovers({ format: APPROVERS_FORMAT, version: 1, rp_id: 'example.com', origins: ['https://example.com'], approvers: [approver], signature: { kid: 'A'.repeat(43), sig: 'A'.repeat(86) } });
|
|
36
|
+
if (!problem.valid) {
|
|
37
|
+
output.warn(`${label} cannot go in the document (${problem.code}): left out`);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
approvers.push({ approver, passkey: entry.passkey ?? null, fingerprint, registeredAt: entry.registered_at ?? null, lastUsedAt: entry.last_used_at ?? null });
|
|
41
|
+
}
|
|
42
|
+
return approvers;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The document the team signed before, verified with its key: { previous, version } (previous null if there is none).
|
|
47
|
+
* One that does not verify stops the command, unless --replace.
|
|
48
|
+
*/
|
|
49
|
+
function previousDocument(current, keys, context) {
|
|
50
|
+
const document = current?.document ?? null;
|
|
51
|
+
if (document === null) return { previous: null, version: 0 };
|
|
52
|
+
try {
|
|
53
|
+
const previous = verifyApprovers(document, keys.signing.publicKey);
|
|
54
|
+
return { previous, version: previous.version };
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (!context.values.replace) {
|
|
57
|
+
throw new IasError(`the approvers document of the server does not verify with the team's signing key (${error.code})`, {
|
|
58
|
+
fix: 'nothing was signed: someone may have changed it on the server; check the passkeys of the owners in the panel, and ias vault approvers --replace signs a new version over it',
|
|
59
|
+
code: 'approvers_document_invalid',
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
context.output.warn(`the approvers document of the server does not verify (${error.code}): --replace signs a new version over it, compared with nothing`);
|
|
63
|
+
return { previous: null, version: Number.isInteger(document.version) ? document.version : 0 };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function approvers(context, client, item) {
|
|
68
|
+
const { env, output } = context;
|
|
69
|
+
const { keys, signingKey } = await teamKeys(client, item, env);
|
|
70
|
+
const { data: listed } = await client.request('GET', '/team/approvers');
|
|
71
|
+
const { data: current } = await client.request('GET', '/team/approvers-document');
|
|
72
|
+
const { previous, version: previousVersion } = previousDocument(current, keys, context);
|
|
73
|
+
const found = candidates(listed.data ?? [], output);
|
|
74
|
+
if (found.length === 0) throw new IasError('no owner of the team has a passkey that can approve', { fix: 'an owner registers a passkey in the panel (System › Team), and then ias vault approvers signs it', code: 'no_approvers' });
|
|
75
|
+
|
|
76
|
+
const before = new Map((previous?.approvers ?? []).map((approver) => [approver.credential_id, approver]));
|
|
77
|
+
const rows = found.map((entry) => {
|
|
78
|
+
const signed = before.get(entry.approver.credential_id);
|
|
79
|
+
return { ...entry, change: !signed ? 'new' : isDeepStrictEqual(signed, entry.approver) ? 'signed' : 'changed' };
|
|
80
|
+
});
|
|
81
|
+
const listedIds = new Set(found.map((entry) => entry.approver.credential_id));
|
|
82
|
+
const removed = [...before.values()].filter((approver) => !listedIds.has(approver.credential_id));
|
|
83
|
+
const rpId = listed.rp_id;
|
|
84
|
+
const origins = listed.origins ?? [];
|
|
85
|
+
const sameTarget = previous !== null && previous.rp_id === rpId && isDeepStrictEqual([...previous.origins].sort(), [...origins].sort());
|
|
86
|
+
const unchanged = sameTarget && removed.length === 0 && rows.every((row) => row.change === 'signed');
|
|
87
|
+
|
|
88
|
+
output.progress(`rp_id ${rpId}${previous && previous.rp_id !== rpId ? ` (CHANGES: it was ${previous.rp_id})` : ''}`);
|
|
89
|
+
output.progress(`origins ${origins.join(', ')}${previous && !sameTarget && previous.rp_id === rpId ? ` (CHANGES: they were ${previous.origins.join(', ')})` : ''}`);
|
|
90
|
+
output.progress(previous ? `signed version ${previous.version}, verified with the team's key (uploaded ${day(current.uploaded_at)})` : 'signed nothing yet');
|
|
91
|
+
output.progress('');
|
|
92
|
+
for (const row of rows) {
|
|
93
|
+
const mark = { new: '+ NEW ', changed: '! CHANGED', signed: ' signed ' }[row.change];
|
|
94
|
+
output.progress(`${mark} ${row.approver.name} (${row.approver.member}) passkey «${row.passkey ?? '?'}», registered ${day(row.registeredAt)}${row.lastUsedAt ? `, last used ${day(row.lastUsedAt)}` : ''}`);
|
|
95
|
+
output.progress(` fingerprint ${row.fingerprint} ${ALGORITHMS[row.approver.alg]} credential ${row.approver.credential_id}`);
|
|
96
|
+
}
|
|
97
|
+
for (const approver of removed) output.progress(`- REMOVED ${approver.name} (${approver.member}) fingerprint ${approverFingerprint(approver.public_key)}`);
|
|
98
|
+
|
|
99
|
+
const result = {
|
|
100
|
+
rp_id: rpId,
|
|
101
|
+
origins,
|
|
102
|
+
previous_version: previous?.version ?? null,
|
|
103
|
+
approvers: rows.map(({ approver, passkey, fingerprint, change }) => ({ credential_id: approver.credential_id, alg: approver.alg, member: approver.member, name: approver.name, passkey, fingerprint, change })),
|
|
104
|
+
removed: removed.map((approver) => ({ credential_id: approver.credential_id, member: approver.member, name: approver.name, fingerprint: approverFingerprint(approver.public_key) })),
|
|
105
|
+
};
|
|
106
|
+
if (unchanged) {
|
|
107
|
+
output.log(`\nThe team already signed these ${rows.length} passkey${rows.length === 1 ? '' : 's'} (version ${previous.version}): nothing to sign.`);
|
|
108
|
+
return { ...result, version: previous.version, signed: false, in_sync: current.in_sync ?? null };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const version = Math.max(previousVersion, Number.isInteger(listed.document_version) ? listed.document_version : 0) + 1;
|
|
112
|
+
output.progress('');
|
|
113
|
+
output.progress('Check that each passkey is one of an owner you know, above all the NEW and CHANGED ones: from now on, the runners');
|
|
114
|
+
output.progress('build for the stores only what one of these passkeys approves. The server could have added one of its own.');
|
|
115
|
+
// Signed in memory first, so that a target the document cannot hold stops it before asking; uploaded after the yes.
|
|
116
|
+
let document;
|
|
117
|
+
try {
|
|
118
|
+
document = signApprovers({ version, rp_id: rpId, origins, approvers: rows.map((row) => row.approver) }, signingKey);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (!/^approvers\.(rp_id|origins?)$/.test(error.code)) throw error;
|
|
121
|
+
throw new IasError(`the server gives an rp_id or origins that cannot be signed (${error.code})`, { fix: `the document takes a domain and https origins exactly as the browser writes them: check APP_URL on the server (it gives ${rpId} and ${origins.join(', ')})`, code: 'approvers_target_invalid' });
|
|
122
|
+
}
|
|
123
|
+
verifyApprovers(document, keys.signing.publicKey);
|
|
124
|
+
await confirm(context, `Sign version ${version} with these ${rows.length} passkey${rows.length === 1 ? '' : 's'}?`);
|
|
125
|
+
const { data: stored } = await client.request('PUT', '/team/approvers-document', { json: document });
|
|
126
|
+
if (!isDeepStrictEqual(stored.document, document)) output.warn('the server answered with another document than the one signed here: ias vault approvers again compares them');
|
|
127
|
+
output.log(`Signed version ${version} of the approvers with the team's key (${keys.signing.kid}) and uploaded it: the runners take it with their next store job.`);
|
|
128
|
+
return { ...result, version, signed: true, in_sync: stored.in_sync ?? null };
|
|
129
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// ias vault init and status: the team's keys of the sealed vault (docs/technical/vault.md §2, docs/technical/manage-api.md §7). init makes
|
|
2
|
+
// the recovery key (X25519) and the signing key (Ed25519), keeps both private keys in a new 1Password item without ever
|
|
3
|
+
// printing them, and registers the public ones on the server, which waits for a member's approval with a passkey.
|
|
4
|
+
// status compares the keys of the server with the ones of 1Password and checks the signed list of runners. approvers,
|
|
5
|
+
// grant and revoke sign with the team's key what the runners trust (docs/technical/runner-api.md §1 and §2): approvers.mjs and
|
|
6
|
+
// runners.mjs.
|
|
7
|
+
import { resolve } from 'node:path';
|
|
8
|
+
import { loadProject } from '../../config.mjs';
|
|
9
|
+
import { IasError } from '../../errors.mjs';
|
|
10
|
+
import { connect, openUrl, sleep, waitForApproval } from '../../manage.mjs';
|
|
11
|
+
import { EXIT_CODE } from '../../output.mjs';
|
|
12
|
+
import { generateRecipient, generateSigningKey, recipientFromPrivateKey } from '../../shared.mjs';
|
|
13
|
+
import { ITEM_FIELDS, compareKeys, createItem, publicKeysOf, readField, signedRunners, signingKeyOf, vaultItem } from '../../team-vault.mjs';
|
|
14
|
+
import { approvers } from './approvers.mjs';
|
|
15
|
+
import { grant, revoke } from './runners.mjs';
|
|
16
|
+
|
|
17
|
+
const projectOf = ({ values, cwd }) => loadProject(resolve(cwd, values.project ?? '.'));
|
|
18
|
+
|
|
19
|
+
/** Checks that the private keys of the item match its public keys, without keeping or showing them. */
|
|
20
|
+
function checkPrivateKeys(item, keys, env) {
|
|
21
|
+
const recovery = readField(item, ITEM_FIELDS.recoveryPrivateKey, env);
|
|
22
|
+
let recoveryKid;
|
|
23
|
+
try {
|
|
24
|
+
recoveryKid = recovery === undefined ? undefined : recipientFromPrivateKey(recovery).kid;
|
|
25
|
+
} catch {
|
|
26
|
+
recoveryKid = undefined;
|
|
27
|
+
}
|
|
28
|
+
if (recoveryKid !== keys.recovery.kid) throw new IasError(`the recovery private key of ${item.reference} is missing or does not match its public key`, { fix: 'restore the item from 1Password\'s history, or move it away and run ias vault init again' });
|
|
29
|
+
signingKeyOf(item, keys, env);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function init(context) {
|
|
33
|
+
const { env, output } = context;
|
|
34
|
+
const project = projectOf(context);
|
|
35
|
+
const client = connect(context, project);
|
|
36
|
+
const item = vaultItem(context, project, client.server);
|
|
37
|
+
const { data: vault } = await client.request('GET', '/vault');
|
|
38
|
+
if (vault.recovery_key || vault.signing_key) {
|
|
39
|
+
throw new IasError(`the vault of ${client.server} already has its keys`, { fix: 'ias vault status compares them with the ones in 1Password (changing them is not in v1 of the API)', code: 'vault_keys_exist' });
|
|
40
|
+
}
|
|
41
|
+
let keys = publicKeysOf(item, env);
|
|
42
|
+
if (keys) {
|
|
43
|
+
output.progress(`${item.reference} already has the keys of the vault (an earlier ias vault init that was not approved): registering those.`);
|
|
44
|
+
} else {
|
|
45
|
+
const recovery = generateRecipient('recovery');
|
|
46
|
+
const signing = generateSigningKey();
|
|
47
|
+
createItem(item, { recovery, signing, server: client.server }, env);
|
|
48
|
+
keys = publicKeysOf(item, env);
|
|
49
|
+
if (!keys || keys.recovery.kid !== recovery.kid || keys.signing.kid !== signing.kid) throw new IasError(`1Password did not keep the keys in ${item.reference} as they were written`, { fix: 'look at the item in 1Password and run ias vault init again' });
|
|
50
|
+
output.progress(`Created ${item.reference} with the recovery key and the signing key of the team. Their private keys are only there.`);
|
|
51
|
+
}
|
|
52
|
+
checkPrivateKeys(item, keys, env);
|
|
53
|
+
output.log(`recovery key ${keys.recovery.kid}`);
|
|
54
|
+
output.log(`signing key ${keys.signing.kid}`);
|
|
55
|
+
const { status, data } = await client.request('POST', '/vault/keys', { json: { recovery_key: { public_key: keys.recovery.publicKey }, signing_key: { public_key: keys.signing.publicKey } } });
|
|
56
|
+
let registered = data;
|
|
57
|
+
if (status === 202) {
|
|
58
|
+
const { approval } = data;
|
|
59
|
+
const opened = (context.openUrl ?? ((url) => openUrl(url, env)))(approval.url);
|
|
60
|
+
output.progress(`${opened ? 'Opened' : 'Open'} ${approval.url}: check that the panel shows these two kids, and approve it with a passkey (until ${approval.expires_at}).`);
|
|
61
|
+
registered = await waitForApproval(client, approval, { wait: context.sleep ?? sleep, output });
|
|
62
|
+
}
|
|
63
|
+
const remote = compareKeys(registered, keys);
|
|
64
|
+
if (remote.some((entry) => !entry.ok)) throw new IasError('the server registered other keys than the ones of 1Password', { fix: 'do not seal anything: ias vault status shows both, and someone may be tampering with the server', code: 'vault_keys_mismatch' });
|
|
65
|
+
output.log(`The vault of ${client.server} has its keys: credentials are sealed for the recovery key and signed by the team from now on.`);
|
|
66
|
+
return { server: client.server, item: item.reference, recovery_key: keys.recovery, signing_key: keys.signing, format: registered.format };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function status(context) {
|
|
70
|
+
const { env, output } = context;
|
|
71
|
+
const project = projectOf(context);
|
|
72
|
+
const client = connect(context, project);
|
|
73
|
+
const item = vaultItem(context, project, client.server);
|
|
74
|
+
const { data: vault } = await client.request('GET', '/vault');
|
|
75
|
+
const keys = publicKeysOf(item, env);
|
|
76
|
+
const rows = compareKeys(vault, keys);
|
|
77
|
+
output.log(`server ${client.server} (${vault.format})`);
|
|
78
|
+
output.log(`item ${item.reference}${keys ? '' : ' (not in 1Password)'}`);
|
|
79
|
+
for (const row of rows) {
|
|
80
|
+
const label = `${row.name} key`.padEnd(13);
|
|
81
|
+
if (row.ok) output.log(`${label} ok ${row.local.kid}`);
|
|
82
|
+
else if (!row.server && !row.local) output.log(`${label} missing neither on the server nor in 1Password: ias vault init`);
|
|
83
|
+
else if (!row.server) output.log(`${label} missing not on the server (1Password has ${row.local.kid}): ias vault init registers it`);
|
|
84
|
+
else if (!row.local) output.log(`${label} unknown the server has ${row.server.kid}, and 1Password has none to compare`);
|
|
85
|
+
else output.log(`${label} MISMATCH the server has ${row.server.kid}, 1Password has ${row.local.kid}`);
|
|
86
|
+
}
|
|
87
|
+
let runners = { serial: null, runners: [], signed: false, problem: null };
|
|
88
|
+
if (keys && rows.every((row) => row.ok)) {
|
|
89
|
+
try {
|
|
90
|
+
const verified = signedRunners(vault, keys.signing, output);
|
|
91
|
+
runners = { serial: verified.serial, runners: verified.runners.map((runner) => runner.kid), signed: verified.serial !== null, problem: null };
|
|
92
|
+
output.log(verified.serial === null ? 'runners none signed by the team yet (ias vault grant, Phase 2)' : `runners ${verified.runners.length} in the list the team signed (serial ${verified.serial})`);
|
|
93
|
+
for (const runner of verified.runners) output.log(` ${runner.kid}`);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
runners.problem = error.code ?? 'runner_list_invalid';
|
|
96
|
+
output.log(`runners NOT VERIFIED ${error.what ?? error.message}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const ok = rows.every((row) => row.ok) && runners.problem === null;
|
|
100
|
+
const result = { server: client.server, item: item.reference, format: vault.format, ok, keys: rows.map(({ name, server, local, ok: same }) => ({ name, server_kid: server?.kid ?? null, local_kid: local?.kid ?? null, ok: same })), runners };
|
|
101
|
+
if (!ok) result[EXIT_CODE] = 1;
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const itemOption = { 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>).' } };
|
|
106
|
+
const yesOption = { yes: { type: 'boolean', short: 'y', description: 'Do not ask: you have already compared what it prints (without a terminal it is needed).' } };
|
|
107
|
+
const runnerArgument = { name: 'runner', description: 'The runner: its id, its name (in quotes if it has spaces) or its vault kid, as ias vault status and the panel list them.' };
|
|
108
|
+
|
|
109
|
+
/** Runs a command that signs with the team's key: the client, the 1Password item and then `run`. */
|
|
110
|
+
const signing = (run) => (context) => {
|
|
111
|
+
const project = projectOf(context);
|
|
112
|
+
const client = connect(context, project);
|
|
113
|
+
return run(context, client, vaultItem(context, project, client.server));
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export default {
|
|
117
|
+
name: 'vault',
|
|
118
|
+
summary: 'The keys of the sealed vault, and what the team signs with them: the approvers and the runners',
|
|
119
|
+
description: 'The recovery key opens every sealed credential and the signing key signs each envelope (docs/technical/vault.md), the passkeys that may approve a store build and the list of runners. Their private keys live only in 1Password; the server has the public ones.',
|
|
120
|
+
subcommands: [
|
|
121
|
+
{
|
|
122
|
+
name: 'init',
|
|
123
|
+
summary: 'Create the recovery and signing keys in 1Password and register them (owner, with a passkey)',
|
|
124
|
+
description: 'Makes both keys, creates the 1Password item with op (the private keys go through stdin and are never printed), and asks the server to register the public keys. A member approves it with a passkey in the panel, after checking that it shows the two kids printed here. If the item already exists from an attempt that was not approved, its keys are registered.',
|
|
125
|
+
options: itemOption,
|
|
126
|
+
run: init,
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
name: 'status',
|
|
130
|
+
summary: 'Compare the keys of the server with the ones of 1Password, and check the signed list of runners',
|
|
131
|
+
description: 'Reads only the public keys of 1Password. Exits with 1 if a key does not match or the list of runners does not verify.',
|
|
132
|
+
options: itemOption,
|
|
133
|
+
run: status,
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: 'approvers',
|
|
137
|
+
summary: 'Sign the passkeys of the owners that may approve a store build (owner)',
|
|
138
|
+
description: 'Reads the owners\' passkeys from the server and shows each one with its member and fingerprint, marking what changes from the version the team signed before (verified with the team\'s key). After you confirm, it signs the next version of the ias-approvers-1 document with the team\'s key of 1Password and uploads it: the runners only build for the stores what one of these passkeys approved. Run it again after an owner adds or removes a passkey; the panel says so in Today.',
|
|
139
|
+
options: { ...itemOption, ...yesOption, replace: { type: 'boolean', description: 'Sign over a document of the server that does not verify with the team\'s key (after checking why).' } },
|
|
140
|
+
run: signing(approvers),
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
name: 'grant',
|
|
144
|
+
summary: 'Let a registered runner open the team\'s credentials: the signed list and a wrap in each envelope',
|
|
145
|
+
description: 'Shows the runner\'s vault kid for you to compare with the one ias-runner register printed on its Mac. Then it signs a runner list with it, uploads it, and wraps every envelope of the team (credentials and secret variables) for it, opening each one with the recovery key of 1Password and signing it again. Envelopes that do not verify are never signed again: it says which.',
|
|
146
|
+
positionals: [runnerArgument],
|
|
147
|
+
options: { ...itemOption, ...yesOption },
|
|
148
|
+
run: signing(grant),
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: 'revoke',
|
|
152
|
+
summary: 'Take a runner revoked in the panel out of the signed list and of every envelope',
|
|
153
|
+
description: 'Revoke it in the panel first (System › Runners): the server stops serving it at once. This signs a runner list without it and removes its wrap from every envelope, signing each one again, and lists what it could read, which is what to renew if its Mac may have been compromised (docs/technical/vault.md §10).',
|
|
154
|
+
positionals: [runnerArgument],
|
|
155
|
+
options: { ...itemOption, ...yesOption },
|
|
156
|
+
run: signing(revoke),
|
|
157
|
+
},
|
|
158
|
+
],
|
|
159
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// ias vault grant and revoke: which runners can open the team's envelopes (docs/technical/vault.md §2.3 and §10, docs/technical/runner-api.md
|
|
2
|
+
// §2). grant shows the runner's kid for the person to compare with the one ias-runner register printed on that Mac, signs
|
|
3
|
+
// a runner list with it, uploads it, and wraps each envelope for it with the recovery key, signed again. revoke signs a
|
|
4
|
+
// list without it and removes its wrap from each envelope, after the panel has revoked it. Both check every signature
|
|
5
|
+
// with the team key of 1Password first: a list or an envelope that does not verify is never signed again.
|
|
6
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
7
|
+
import { ApiError, IasError } from '../../errors.mjs';
|
|
8
|
+
import { EXIT_CODE } from '../../output.mjs';
|
|
9
|
+
import { VaultError, recipientsOf, removeRecipient, rewrap, signRunnerList, verifyEnvelope, verifyRunnerList } from '../../shared.mjs';
|
|
10
|
+
import { confirm, findRunner, sealedEnvelopes, signedList, teamKeys } from './team.mjs';
|
|
11
|
+
|
|
12
|
+
/** Prints the runner for the person to compare with what its Mac says. */
|
|
13
|
+
function showRunner(runner, output) {
|
|
14
|
+
output.progress(`runner ${runner.name ?? '(the server does not list it)'}${runner.id ? ` ${runner.id}` : ''}`);
|
|
15
|
+
output.progress(`status ${runner.status}${runner.platforms.length > 0 ? `, ${runner.platforms.join(' and ')}` : ''}${runner.registeredAt ? `, registered ${runner.registeredAt.slice(0, 10)}` : ''}`);
|
|
16
|
+
output.progress(`vault kid ${runner.kid}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Signs and uploads the runner list `runners` with the next serial; checks what the server keeps. */
|
|
20
|
+
async function uploadList(client, { serial, runners }, signingKey, keys, output) {
|
|
21
|
+
const list = signRunnerList({ serial: (serial ?? 0) + 1, runners }, signingKey);
|
|
22
|
+
verifyRunnerList(list, keys.signing.publicKey);
|
|
23
|
+
const { data } = await client.request('PUT', '/vault/runner-list', { json: list });
|
|
24
|
+
if (!isDeepStrictEqual(data.runner_list, list)) output.warn('the server answered with another runner list than the one signed here: ias vault status checks it');
|
|
25
|
+
return list;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Changes each envelope with `change(envelope)` (which returns the new envelope, or null to leave it) and sends it with
|
|
30
|
+
* PATCH /credentials/{id}/recipients. A failure of one does not stop the others: { changed, kept, problems }.
|
|
31
|
+
*/
|
|
32
|
+
async function changeEnvelopes(client, sealed, change, keys, output, verb) {
|
|
33
|
+
const changed = [];
|
|
34
|
+
const kept = [];
|
|
35
|
+
const problems = [];
|
|
36
|
+
for (const entry of sealed) {
|
|
37
|
+
const where = `${entry.label}${entry.application ? ` (${entry.application})` : ' (the team)'}`;
|
|
38
|
+
try {
|
|
39
|
+
const { data } = await client.request('GET', `/credentials/${entry.id}/envelope`);
|
|
40
|
+
if (data.envelope?.credential?.id !== entry.id) {
|
|
41
|
+
problems.push({ ...entry, code: 'envelope_of_another_credential', reason: null });
|
|
42
|
+
output.warn(`${where}: the server gives the envelope of another credential (${data.envelope?.credential?.id ?? 'none'}): not signed again`);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const next = change(data.envelope);
|
|
46
|
+
if (next === null) {
|
|
47
|
+
kept.push(entry);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
verifyEnvelope(next, keys.signing.publicKey);
|
|
51
|
+
await client.request('PATCH', `/credentials/${entry.id}/recipients`, { json: { envelope: next } });
|
|
52
|
+
changed.push(entry);
|
|
53
|
+
output.progress(` ${verb} ${where}`);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
const code = error.code ?? 'error';
|
|
56
|
+
if (!(error instanceof ApiError) && !(error instanceof VaultError)) throw error;
|
|
57
|
+
problems.push({ ...entry, code, reason: error.data?.reason ?? null });
|
|
58
|
+
output.warn(`${where}: ${error instanceof VaultError ? `its envelope does not verify with the team's key or does not open (${code}): not signed again` : error.message}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { changed, kept, problems };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const describe = (entries) => entries.map(({ id, kind, label, application }) => ({ id, kind, label, application }));
|
|
65
|
+
|
|
66
|
+
export async function grant(context, client, item) {
|
|
67
|
+
const { env, output } = context;
|
|
68
|
+
const { vault, keys, signingKey, recoveryKey } = await teamKeys(client, item, env, { recovery: true });
|
|
69
|
+
const signed = signedList(vault, keys, output);
|
|
70
|
+
const runner = findRunner(vault, signed, context.positionals[0]);
|
|
71
|
+
if (!runner.server) throw new IasError(`the server does not list the runner ${runner.kid}`, { fix: 'register it on its Mac with ias-runner register first', code: 'runner_not_found' });
|
|
72
|
+
if (runner.status === 'revoked') throw new IasError(`«${runner.name}» is revoked`, { fix: 'a revoked runner comes back registered again, with new keys: ias-runner register --force on its Mac', code: 'runner_revoked' });
|
|
73
|
+
showRunner(runner, output);
|
|
74
|
+
output.progress('');
|
|
75
|
+
output.progress('Compare the vault kid with the one ias-runner register printed on that Mac. If they differ, stop: the server would');
|
|
76
|
+
output.progress('get the team\'s credentials wrapped for a key of its own.');
|
|
77
|
+
await confirm(context, `Authorize «${runner.name}» to open the team's credentials?`);
|
|
78
|
+
|
|
79
|
+
const inList = signed.runners.some((entry) => entry.kid === runner.kid);
|
|
80
|
+
const list = inList ? null : await uploadList(client, { serial: signed.serial, runners: [...signed.runners, { kid: runner.kid, publicKey: runner.publicKey }] }, signingKey, keys, output);
|
|
81
|
+
output.progress(list ? `Signed the runner list ${list.serial} with «${runner.name}» and uploaded it.` : `«${runner.name}» is already in the runner list ${signed.serial}.`);
|
|
82
|
+
const sealed = await sealedEnvelopes(client);
|
|
83
|
+
const target = [{ kid: runner.kid, publicKey: runner.publicKey }];
|
|
84
|
+
const { changed, kept, problems } = await changeEnvelopes(client, sealed, (envelope) => (recipientsOf(envelope).includes(runner.kid) ? null : rewrap(envelope, recoveryKey.privateKey, target, { signingKey })), keys, output, 'wrapped');
|
|
85
|
+
output.log(`«${runner.name}» can open ${changed.length + kept.length} of ${sealed.length} envelope${sealed.length === 1 ? '' : 's'} (${changed.length} wrapped now)${problems.length > 0 ? `; ${problems.length} could not be wrapped` : ''}.`);
|
|
86
|
+
const result = { runner: { id: runner.id, name: runner.name, kid: runner.kid }, runner_list: { serial: list?.serial ?? signed.serial, changed: list !== null }, wrapped: describe(changed), already: describe(kept), problems };
|
|
87
|
+
if (problems.length > 0) result[EXIT_CODE] = 1;
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function revoke(context, client, item) {
|
|
92
|
+
const { env, output } = context;
|
|
93
|
+
const { vault, keys, signingKey } = await teamKeys(client, item, env);
|
|
94
|
+
const signed = signedList(vault, keys, output);
|
|
95
|
+
const runner = findRunner(vault, signed, context.positionals[0]);
|
|
96
|
+
if (runner.server && runner.status !== 'revoked') {
|
|
97
|
+
throw new IasError(`«${runner.name}» is not revoked on the server`, { fix: 'revoke it in the panel first (System › Runners), so the server stops giving it jobs and envelopes at once; then run ias vault revoke again', code: 'runner_not_revoked' });
|
|
98
|
+
}
|
|
99
|
+
showRunner(runner, output);
|
|
100
|
+
await confirm(context, `Remove ${runner.name ? `«${runner.name}»` : runner.kid} from the runner list and from every envelope?`);
|
|
101
|
+
|
|
102
|
+
const inList = signed.runners.some((entry) => entry.kid === runner.kid);
|
|
103
|
+
const list = inList ? await uploadList(client, { serial: signed.serial, runners: signed.runners.filter((entry) => entry.kid !== runner.kid).map(({ kid, publicKey }) => ({ kid, publicKey })) }, signingKey, keys, output) : null;
|
|
104
|
+
output.progress(list ? `Signed the runner list ${list.serial} without it and uploaded it.` : 'It is not in the runner list the team signed.');
|
|
105
|
+
const sealed = await sealedEnvelopes(client);
|
|
106
|
+
const { changed, problems } = await changeEnvelopes(client, sealed, (envelope) => (recipientsOf(envelope).includes(runner.kid) ? removeRecipient(envelope, runner.kid, { signingKey }) : null), keys, output, 'removed');
|
|
107
|
+
output.log(`Removed its wrap from ${changed.length} envelope${changed.length === 1 ? '' : 's'}${problems.length > 0 ? `; ${problems.length} could not be changed` : ''}.`);
|
|
108
|
+
if (changed.length > 0) {
|
|
109
|
+
output.log('It could read these (their current versions). If its Mac may have been compromised, renew them (docs/technical/vault.md §10):');
|
|
110
|
+
for (const entry of changed) output.log(` ${entry.label}${entry.application ? ` (${entry.application})` : ' (the team)'}`);
|
|
111
|
+
}
|
|
112
|
+
const result = { runner: { id: runner.id, name: runner.name, kid: runner.kid }, runner_list: { serial: list?.serial ?? signed.serial, changed: list !== null }, removed: describe(changed), problems };
|
|
113
|
+
if (problems.length > 0) result[EXIT_CODE] = 1;
|
|
114
|
+
return result;
|
|
115
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// What ias vault approvers, grant and revoke share: the team's keys read from 1Password and checked against the
|
|
2
|
+
// server's, the confirmation of a person before signing, the runners the server lists and every sealed envelope of the
|
|
3
|
+
// team (credentials and secret variables). The private keys stay in memory; nothing here prints or writes one.
|
|
4
|
+
import { createInterface } from 'node:readline/promises';
|
|
5
|
+
import process from 'node:process';
|
|
6
|
+
import { ApiError, IasError } from '../../errors.mjs';
|
|
7
|
+
import { publicRecipient, recipientFromPrivateKey } from '../../shared.mjs';
|
|
8
|
+
import { ITEM_FIELDS, compareKeys, publicKeysOf, readField, signedRunners, signingKeyOf } from '../../team-vault.mjs';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* GET /vault and the keys of 1Password, checked against each other: { vault, keys, signingKey, recovery? }. With
|
|
12
|
+
* `recovery`, also the recovery key pair (its private key, to rewrap envelopes). Stops if the vault has no keys or the
|
|
13
|
+
* server's are not the item's.
|
|
14
|
+
*/
|
|
15
|
+
export async function teamKeys(client, item, env, { recovery = false } = {}) {
|
|
16
|
+
const { data: vault } = await client.request('GET', '/vault');
|
|
17
|
+
if (!vault.recovery_key || !vault.signing_key) throw new IasError('the vault has no keys yet', { fix: 'an owner of the team runs ias vault init', code: 'vault_not_ready' });
|
|
18
|
+
const keys = publicKeysOf(item, env);
|
|
19
|
+
if (!keys) throw new IasError(`there is no ${item.reference} in 1Password`, { fix: 'signing needs the item of the vault: ask an owner to share it with you, or point --item (IAS_VAULT_ITEM, vault.item of ias.json) at it' });
|
|
20
|
+
const mismatched = compareKeys(vault, keys).filter((entry) => !entry.ok);
|
|
21
|
+
if (mismatched.length > 0) {
|
|
22
|
+
throw new IasError(`the ${mismatched.map((entry) => entry.name).join(' and ')} key of the server is not the one in ${item.reference}`, {
|
|
23
|
+
fix: 'nothing was signed: ias vault status shows both; if the server changed them, someone may be trying to slip in a key of their own',
|
|
24
|
+
code: 'vault_keys_mismatch',
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
const signingKey = signingKeyOf(item, keys, env);
|
|
28
|
+
if (!recovery) return { vault, keys, signingKey };
|
|
29
|
+
const seed = readField(item, ITEM_FIELDS.recoveryPrivateKey, env);
|
|
30
|
+
let pair;
|
|
31
|
+
try {
|
|
32
|
+
pair = seed === undefined ? undefined : recipientFromPrivateKey(seed, 'recovery');
|
|
33
|
+
} catch {
|
|
34
|
+
pair = undefined;
|
|
35
|
+
}
|
|
36
|
+
if (!pair || pair.kid !== keys.recovery.kid) throw new IasError(`the recovery private key of ${item.reference} is missing or does not match its public key`, { fix: 'restore the item from 1Password\'s history; wrapping an envelope for a runner opens it with that key' });
|
|
37
|
+
return { vault, keys, signingKey, recoveryKey: pair };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Asks the person to confirm what the command printed: --yes, or «y» in a terminal. Without a terminal and without
|
|
42
|
+
* --yes it stops, because the point is that someone looked at the keys before the team signs them.
|
|
43
|
+
*/
|
|
44
|
+
export async function confirm(context, question) {
|
|
45
|
+
if (context.values.yes) return;
|
|
46
|
+
const stdin = context.stdin ?? process.stdin;
|
|
47
|
+
if (!stdin.isTTY) throw new IasError(`${question.replace(/\?$/, '')}: this needs a person to check it`, { fix: 'run it in a terminal, or pass --yes once you have compared what it printed', code: 'confirmation_required' });
|
|
48
|
+
const reader = createInterface({ input: stdin, output: context.output.json ? process.stderr : process.stdout });
|
|
49
|
+
try {
|
|
50
|
+
const answer = (await reader.question(`${question} [y/N] `)).trim();
|
|
51
|
+
if (!/^(y|yes|s|si|sí)$/i.test(answer)) throw new IasError('nothing was signed', { fix: 'run it again when the list is the one you expect', code: 'not_confirmed' });
|
|
52
|
+
} finally {
|
|
53
|
+
reader.close();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const RUNNER_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The runner `query` names (its id, its name or its vault kid) among the server's runners and the signed list: { id,
|
|
61
|
+
* name, status, kid, publicKey, platforms, registeredAt, server: true|false }. A kid of the signed list that the server
|
|
62
|
+
* no longer lists is found too, for revoke.
|
|
63
|
+
*/
|
|
64
|
+
export function findRunner(vault, signed, query) {
|
|
65
|
+
if (typeof query !== 'string' || query === '') throw new IasError('which runner?', { fix: 'give its id, name or kid: ias vault status and the panel (System › Runners) list them' });
|
|
66
|
+
const listed = Array.isArray(vault.runners) ? vault.runners : [];
|
|
67
|
+
const matches = listed.filter((runner) => runner.id === query || runner.kid === query || runner.name === query);
|
|
68
|
+
if (matches.length > 1) throw new IasError(`«${query}» names ${matches.length} runners`, { fix: `use the id of one: ${matches.map((runner) => `${runner.id} (${runner.name})`).join(', ')}` });
|
|
69
|
+
if (matches.length === 1) {
|
|
70
|
+
const [runner] = matches;
|
|
71
|
+
let checked;
|
|
72
|
+
try {
|
|
73
|
+
checked = publicRecipient({ kind: 'runner', kid: runner.kid, publicKey: runner.public_key });
|
|
74
|
+
} catch {
|
|
75
|
+
throw new IasError(`the server gives runner ${runner.id} a kid that is not the one of its key`, { fix: 'nothing was signed: the server may be tampered with; register the runner again', code: 'runner_key_mismatch' });
|
|
76
|
+
}
|
|
77
|
+
return { id: runner.id, name: runner.name, status: runner.status, kid: checked.kid, publicKey: checked.publicKey, platforms: runner.platforms ?? [], registeredAt: runner.registered_at ?? null, revokedAt: runner.revoked_at ?? null, server: true };
|
|
78
|
+
}
|
|
79
|
+
const inList = signed.runners.find((runner) => runner.kid === query);
|
|
80
|
+
if (inList) return { id: null, name: null, status: 'unknown', kid: inList.kid, publicKey: inList.publicKey, platforms: [], registeredAt: null, revokedAt: null, server: false };
|
|
81
|
+
throw new IasError(`there is no runner «${query}»${RUNNER_ID.test(query) ? '' : ' (a name with spaces goes in quotes)'}`, { fix: 'ias vault status and the panel (System › Runners) list them', code: 'runner_not_found' });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The runner list the team signed, as GET /vault gives it, checked with the team's key: { serial, runners }. */
|
|
85
|
+
export const signedList = (vault, keys, output) => signedRunners(vault, keys.signing, output);
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Every envelope of the team that a runner may need: the credentials of each application the token sees and the
|
|
89
|
+
* team's, and the secret variables of each environment. Retired credentials are left out: no build uses them.
|
|
90
|
+
* [{ id, kind: 'credential'|'variable', label, application }].
|
|
91
|
+
*/
|
|
92
|
+
export async function sealedEnvelopes(client) {
|
|
93
|
+
const sealed = [];
|
|
94
|
+
const { data: apps } = await client.request('GET', '/apps');
|
|
95
|
+
for (const app of apps.data) {
|
|
96
|
+
for (const credential of (await client.request('GET', `/apps/${app.code}/credentials`)).data.data) {
|
|
97
|
+
if (!credential.retired_at) sealed.push({ id: credential.id, kind: 'credential', label: `${credential.type} «${credential.name}» v${credential.version}`, application: app.code });
|
|
98
|
+
}
|
|
99
|
+
for (const variable of (await client.request('GET', `/apps/${app.code}/env`)).data.data) {
|
|
100
|
+
if (variable.visibility === 'secret') sealed.push({ id: variable.id, kind: 'variable', label: `${variable.environment} ${variable.name} v${variable.version}`, application: app.code });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
for (const credential of (await client.request('GET', '/team/credentials')).data.data) {
|
|
105
|
+
if (!credential.retired_at) sealed.push({ id: credential.id, kind: 'credential', label: `${credential.type} «${credential.name}» v${credential.version}`, application: null });
|
|
106
|
+
}
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (!(error instanceof ApiError) || error.code !== 'role_forbidden') throw error;
|
|
109
|
+
}
|
|
110
|
+
return sealed;
|
|
111
|
+
}
|