agmsg-cloud 0.0.1 → 0.1.0-rc.4
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 +39 -2
- package/dist/src/api.js +517 -0
- package/dist/src/authenticated-digest.js +234 -0
- package/dist/src/browser.js +241 -0
- package/dist/src/ceremony.js +181 -0
- package/dist/src/commands/approve.js +392 -0
- package/dist/src/commands/connect.js +273 -0
- package/dist/src/commands/fetch.js +249 -0
- package/dist/src/commands/login.js +334 -0
- package/dist/src/commands/logout.js +74 -0
- package/dist/src/commands/pull.js +80 -0
- package/dist/src/commands/request.js +371 -0
- package/dist/src/commands/sync.js +138 -0
- package/dist/src/commands/vault.js +478 -0
- package/dist/src/commands/watch.js +47 -0
- package/dist/src/config.js +34 -0
- package/dist/src/credentials.js +374 -0
- package/dist/src/device-slot.js +148 -0
- package/dist/src/filelock.js +167 -0
- package/dist/src/index.js +242 -0
- package/dist/src/ledger.js +296 -0
- package/dist/src/machine-name.js +90 -0
- package/dist/src/oss-env.js +49 -0
- package/dist/src/oss.js +289 -0
- package/dist/src/paths.js +8 -0
- package/dist/src/pending.js +330 -0
- package/dist/src/pick-request.js +56 -0
- package/dist/src/preflight.js +257 -0
- package/dist/src/recovery-key.js +386 -0
- package/dist/src/sas.js +18 -0
- package/dist/src/secure-store.js +176 -0
- package/dist/src/shell-arg.js +18 -0
- package/dist/src/slot-advice.js +74 -0
- package/dist/src/vault-container.js +115 -0
- package/dist/src/vault-crypto.js +190 -0
- package/dist/src/vault-protocol.js +358 -0
- package/dist/src/version.js +57 -0
- package/node_modules/@agmsg-cloud/sas-core/dist/src/bech32.d.ts +17 -0
- package/node_modules/@agmsg-cloud/sas-core/dist/src/bech32.js +103 -0
- package/node_modules/@agmsg-cloud/sas-core/dist/src/index.d.ts +17 -0
- package/node_modules/@agmsg-cloud/sas-core/dist/src/index.js +147 -0
- package/node_modules/@agmsg-cloud/sas-core/package.json +30 -0
- package/package.json +50 -7
- package/bin/agmsg-cloud.js +0 -4
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
// The environment every OSS script inherits from this CLI.
|
|
3
|
+
//
|
|
4
|
+
// One place, because the thing it says is a property of "this process runs
|
|
5
|
+
// agmsg as a step of something bigger", not of any particular command. Setting
|
|
6
|
+
// it at each spawn site would mean three copies of one decision, and the one
|
|
7
|
+
// that got missed would be the one printing the wrong route to an operator.
|
|
8
|
+
//
|
|
9
|
+
// `AGMSG_OPERATOR_GUIDANCE=caller` tells the OSS scripts that whoever invoked
|
|
10
|
+
// them owns the "what to do next" lines. It does not silence errors, warnings,
|
|
11
|
+
// progress or prompts, and it does not silence facts — only the closing
|
|
12
|
+
// guidance, which for a plain install names routes this product does not have:
|
|
13
|
+
// no server-side recovery (there is one here), `key.sh show --reveal-secret`
|
|
14
|
+
// (people using this never meet key.sh), and carrying a snapshot out of band
|
|
15
|
+
// (there is a ceremony here that exists so nobody has to).
|
|
16
|
+
//
|
|
17
|
+
// Taking that on is an obligation, not a mute button: OSS no longer says those
|
|
18
|
+
// things, so this side has to.
|
|
19
|
+
export const OSS_GUIDANCE_ENV = 'AGMSG_OPERATOR_GUIDANCE';
|
|
20
|
+
export function ossChildEnv(env = process.env) {
|
|
21
|
+
return { ...env, [OSS_GUIDANCE_ENV]: 'caller' };
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Start an OSS script. The only way this CLI does.
|
|
25
|
+
*
|
|
26
|
+
* A wrapper rather than a rule to remember, because "every spawn site must
|
|
27
|
+
* also pass the environment" is the kind of rule that holds until someone adds
|
|
28
|
+
* a fourth site. There is nowhere here to forget it: the environment is not a
|
|
29
|
+
* parameter, and a caller that wanted to skip it would have to stop using this
|
|
30
|
+
* function, which is visible in a way a missing option is not.
|
|
31
|
+
*
|
|
32
|
+
* stdio stays the caller's choice — `connect` and `pull` inherit it so the
|
|
33
|
+
* operator sees progress and can answer prompts; the wrappers in oss.ts pipe
|
|
34
|
+
* it because they parse what comes back.
|
|
35
|
+
*/
|
|
36
|
+
/**
|
|
37
|
+
* Start an OSS script whose output the operator watches, and may answer.
|
|
38
|
+
*
|
|
39
|
+
* Two functions rather than one with a mode, because the pipe variant's
|
|
40
|
+
* streams are non-null and the inherited one's are not — a single signature
|
|
41
|
+
* loses that and hands every caller a null check it does not need.
|
|
42
|
+
*/
|
|
43
|
+
export function spawnOssInherit(command, args) {
|
|
44
|
+
return spawn(command, [...args], { stdio: 'inherit', env: ossChildEnv() });
|
|
45
|
+
}
|
|
46
|
+
/** Start an OSS script whose output this CLI reads. */
|
|
47
|
+
export function spawnOssPiped(command, args) {
|
|
48
|
+
return spawn(command, [...args], { stdio: ['pipe', 'pipe', 'pipe'], env: ossChildEnv() });
|
|
49
|
+
}
|
package/dist/src/oss.js
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { closeSync, constants, openSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { spawnOssPiped } from './oss-env.js';
|
|
5
|
+
// Thin wrappers over the OSS tooling. The proprietary courier never touches key
|
|
6
|
+
// material: sealing/opening the bundle and the handoff/unlock steps all run
|
|
7
|
+
// through `age` and the OSS `key.sh` / `remote.sh` on the operator's machine.
|
|
8
|
+
// This module only shells out to them.
|
|
9
|
+
// Run a command, piping `input` to stdin and collecting stdout as a Buffer.
|
|
10
|
+
// Rejects with the exit code and captured stderr (never the input) on failure.
|
|
11
|
+
function run(cmd, args, input) {
|
|
12
|
+
return new Promise((resolve, reject) => {
|
|
13
|
+
const child = spawnOssPiped(cmd, args);
|
|
14
|
+
const out = [];
|
|
15
|
+
const err = [];
|
|
16
|
+
child.stdout.on('data', (d) => out.push(d));
|
|
17
|
+
child.stderr.on('data', (d) => err.push(d));
|
|
18
|
+
child.on('error', reject);
|
|
19
|
+
child.on('close', (code) => {
|
|
20
|
+
if (code === 0)
|
|
21
|
+
resolve(Buffer.concat(out));
|
|
22
|
+
else
|
|
23
|
+
reject(new Error(`${cmd} exited ${code}: ${Buffer.concat(err).toString().trim()}`));
|
|
24
|
+
});
|
|
25
|
+
if (input !== undefined)
|
|
26
|
+
child.stdin.end(input);
|
|
27
|
+
else
|
|
28
|
+
child.stdin.end();
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
/** A finished run, whatever its exit code. For callers where non-zero is an ANSWER. */
|
|
32
|
+
function runAllowingFailure(cmd, args) {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
const child = spawnOssPiped(cmd, args);
|
|
35
|
+
const out = [];
|
|
36
|
+
const err = [];
|
|
37
|
+
child.stdout.on('data', (d) => out.push(d));
|
|
38
|
+
child.stderr.on('data', (d) => err.push(d));
|
|
39
|
+
child.on('error', reject);
|
|
40
|
+
child.on('close', (code) => resolve({
|
|
41
|
+
code: code ?? -1,
|
|
42
|
+
stdout: Buffer.concat(out).toString(),
|
|
43
|
+
stderr: Buffer.concat(err).toString().trim(),
|
|
44
|
+
}));
|
|
45
|
+
child.stdin.end();
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Whether this machine has a team by this name — the question `unlock` asks.
|
|
50
|
+
*
|
|
51
|
+
* NOT `remoteBinding`, and the difference was measured rather than assumed.
|
|
52
|
+
* `remoteBinding` additionally requires the binding to be ACTIVE, and against
|
|
53
|
+
* the real scripts:
|
|
54
|
+
*
|
|
55
|
+
* team status <team> --json unlock <team>
|
|
56
|
+
* connected exit 0, state active past the team check
|
|
57
|
+
* DISCONNECTED exit 0, state disconnected past the team check
|
|
58
|
+
* never existed exit 1 "agmsg: team not found"
|
|
59
|
+
*
|
|
60
|
+
* So a disconnected team is one `unlock` accepts and `remoteBinding` refuses.
|
|
61
|
+
* A caller that used the stricter check to decide whether a name is known would
|
|
62
|
+
* turn a working command into a refusal — which is the shape of defect this arc
|
|
63
|
+
* has already paid for twice. The exit code is the answer; the state is not.
|
|
64
|
+
*
|
|
65
|
+
* A non-zero exit is reported WITH what the store said, so a broken script does
|
|
66
|
+
* not get presented to the operator as a mistyped name.
|
|
67
|
+
*/
|
|
68
|
+
export async function localTeamLookup(scriptsDir, team) {
|
|
69
|
+
const r = await runAllowingFailure('bash', [
|
|
70
|
+
join(scriptsDir, 'remote.sh'),
|
|
71
|
+
'status',
|
|
72
|
+
team,
|
|
73
|
+
'--json',
|
|
74
|
+
]);
|
|
75
|
+
return r.code === 0 ? { known: true, said: '' } : { known: false, said: r.stderr };
|
|
76
|
+
}
|
|
77
|
+
// Generate a device age identity at `identityPath`; returns its public recipient.
|
|
78
|
+
export async function generateDeviceIdentity(identityPath) {
|
|
79
|
+
await run('age-keygen', ['-o', identityPath]);
|
|
80
|
+
const pub = (await run('age-keygen', ['-y', identityPath])).toString().trim();
|
|
81
|
+
return pub;
|
|
82
|
+
}
|
|
83
|
+
// The public recipient of an existing device identity file.
|
|
84
|
+
export async function publicKeyOf(identityPath) {
|
|
85
|
+
return (await run('age-keygen', ['-y', identityPath])).toString().trim();
|
|
86
|
+
}
|
|
87
|
+
// Seal plaintext to a device's public recipient (binary age ciphertext).
|
|
88
|
+
export function sealToRecipient(recipient, plaintext) {
|
|
89
|
+
return run('age', ['-r', recipient, '-e'], plaintext);
|
|
90
|
+
}
|
|
91
|
+
// Open a sealed blob with the local device identity.
|
|
92
|
+
export function decryptWithIdentity(identityPath, ciphertext) {
|
|
93
|
+
return run('age', ['-d', '-i', identityPath], ciphertext);
|
|
94
|
+
}
|
|
95
|
+
// OSS `key.sh handoff <team> --out <file>` — export the one secret handoff bundle
|
|
96
|
+
// (confirmed snapshot chain + every epoch identity) for `team`.
|
|
97
|
+
export async function keyHandoff(scriptsDir, team, outFile) {
|
|
98
|
+
await run('bash', [join(scriptsDir, 'key.sh'), 'handoff', team, '--out', outFile]);
|
|
99
|
+
}
|
|
100
|
+
// OSS `remote-sync.sh verify-age-handoff` — the digest the joiner's `unlock`
|
|
101
|
+
// will compare the human-carried value against.
|
|
102
|
+
//
|
|
103
|
+
// It is NOT recomputed here. `unlock` compares `snapshot_sha256`, which is
|
|
104
|
+
// SHA-256 over the RFC 8785 canonical JSON of the bundle's latest snapshot — not
|
|
105
|
+
// over the bundle file. A second implementation of that in TypeScript would
|
|
106
|
+
// agree until the day it did not, and the disagreement would surface as two
|
|
107
|
+
// people reading the same digits aloud while the gate refuses them. So the
|
|
108
|
+
// approver asks the same code the joiner will ask.
|
|
109
|
+
//
|
|
110
|
+
// The verification writes the bundle's epoch identities into `outDir`. That is
|
|
111
|
+
// not new exposure: the bundle handed to it already contains those identities
|
|
112
|
+
// and is already on disk beside it — but the directory holds private key
|
|
113
|
+
// material and must be treated exactly like the bundle.
|
|
114
|
+
export async function verifyHandoffDigest(scriptsDir, team, bundleFile, outDir) {
|
|
115
|
+
const out = await run('bash', [
|
|
116
|
+
join(scriptsDir, 'remote-sync.sh'),
|
|
117
|
+
'verify-age-handoff',
|
|
118
|
+
'--team',
|
|
119
|
+
team,
|
|
120
|
+
'--bundle',
|
|
121
|
+
bundleFile,
|
|
122
|
+
'--out-dir',
|
|
123
|
+
outDir,
|
|
124
|
+
]);
|
|
125
|
+
const text = out.toString().trim();
|
|
126
|
+
const lines = text.split('\n').filter((l) => l.trim() !== '');
|
|
127
|
+
// The contract is ONE line of JSON on stdout (the shell wrapper writes nothing
|
|
128
|
+
// there; every human-facing line goes to stderr). Taking the last line would
|
|
129
|
+
// quietly absorb anything that started appearing before it, which is how an
|
|
130
|
+
// ABI change becomes a silent behaviour change instead of a loud failure.
|
|
131
|
+
if (lines.length !== 1) {
|
|
132
|
+
throw new Error(`verify-age-handoff wrote ${lines.length} lines to stdout; the contract is exactly one JSON result`);
|
|
133
|
+
}
|
|
134
|
+
let parsed;
|
|
135
|
+
try {
|
|
136
|
+
parsed = JSON.parse(lines[0]);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
throw new Error('verify-age-handoff did not produce a JSON result');
|
|
140
|
+
}
|
|
141
|
+
const result = parsed;
|
|
142
|
+
// Shape-checked rather than trusted: a digest read out of an unexpected
|
|
143
|
+
// response would be confirmed by a human and then never match.
|
|
144
|
+
if (result.type !== 'age_handoff_verified' || typeof result.snapshot_sha256 !== 'string') {
|
|
145
|
+
throw new Error('verify-age-handoff returned an unrecognised result');
|
|
146
|
+
}
|
|
147
|
+
if (!/^[a-f0-9]{64}$/.test(result.snapshot_sha256)) {
|
|
148
|
+
throw new Error('verify-age-handoff returned a digest that is not a SHA-256 hex string');
|
|
149
|
+
}
|
|
150
|
+
return result.snapshot_sha256;
|
|
151
|
+
}
|
|
152
|
+
// The full binding, for callers that have to record WHICH remote a team's
|
|
153
|
+
// material came from. `remoteTeamId` stays for callers that only address the
|
|
154
|
+
// team, so neither has to know about the other's fields.
|
|
155
|
+
export async function remoteBinding(scriptsDir, team) {
|
|
156
|
+
const out = await run('bash', [join(scriptsDir, 'remote.sh'), 'status', team, '--json']);
|
|
157
|
+
const text = out.toString().trim();
|
|
158
|
+
if (!text)
|
|
159
|
+
throw new Error(`team '${team}' has never been connected to a remote`);
|
|
160
|
+
const status = JSON.parse(text);
|
|
161
|
+
if (status.state !== 'active') {
|
|
162
|
+
throw new Error(`team '${team}' is not connected (state: ${String(status.state)})`);
|
|
163
|
+
}
|
|
164
|
+
if (typeof status.remote_team_id !== 'string' || !status.remote_team_id) {
|
|
165
|
+
throw new Error(`team '${team}' has no remote team id recorded`);
|
|
166
|
+
}
|
|
167
|
+
if (typeof status.server_instance_id !== 'string' || !status.server_instance_id) {
|
|
168
|
+
// Refuse rather than file the entry under an empty instance: the binding is
|
|
169
|
+
// what tells a restore that this bundle belongs to the remote this machine
|
|
170
|
+
// is talking to.
|
|
171
|
+
throw new Error(`team '${team}' has no server instance id recorded`);
|
|
172
|
+
}
|
|
173
|
+
return { teamId: status.remote_team_id, serverInstanceId: status.server_instance_id };
|
|
174
|
+
}
|
|
175
|
+
export async function connectedTeams(scriptsDir) {
|
|
176
|
+
const out = await run('bash', [join(scriptsDir, 'remote.sh'), 'status', '--json']);
|
|
177
|
+
const teams = [];
|
|
178
|
+
for (const line of out.toString().split('\n')) {
|
|
179
|
+
const text = line.trim();
|
|
180
|
+
if (text === '')
|
|
181
|
+
continue;
|
|
182
|
+
let status;
|
|
183
|
+
try {
|
|
184
|
+
status = JSON.parse(text);
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
// One unreadable line must not decide the whole answer either way:
|
|
188
|
+
// skipping it silently would file a backup that quietly omits a team, so
|
|
189
|
+
// it is refused instead. A status output this client cannot parse is not
|
|
190
|
+
// a shorter list of teams.
|
|
191
|
+
// Worded so it does not read as a command to paste: it quotes DATA, and
|
|
192
|
+
// the checker is right that an interpolation inside something
|
|
193
|
+
// command-shaped is worth refusing on sight.
|
|
194
|
+
throw new Error(`the team status this machine printed contains a line this version cannot read: ${JSON.stringify(text.slice(0, 80))}`);
|
|
195
|
+
}
|
|
196
|
+
// A state this version does not know is not quietly dropped and not quietly
|
|
197
|
+
// treated as filable: an unknown word here is the same shape as a line that
|
|
198
|
+
// did not parse, and it gets the same refusal.
|
|
199
|
+
if (status.state !== 'active' && status.state !== 'disconnected') {
|
|
200
|
+
throw new Error(`the team status this machine printed describes a state this version cannot read: ${JSON.stringify(String(status.state).slice(0, 40))}`);
|
|
201
|
+
}
|
|
202
|
+
if (typeof status.local_team !== 'string' ||
|
|
203
|
+
typeof status.remote_team_id !== 'string' ||
|
|
204
|
+
!status.remote_team_id ||
|
|
205
|
+
typeof status.server_instance_id !== 'string' ||
|
|
206
|
+
!status.server_instance_id) {
|
|
207
|
+
throw new Error(`remote.sh status --json described a team without the ids a vault entry needs`);
|
|
208
|
+
}
|
|
209
|
+
teams.push({
|
|
210
|
+
team: status.local_team,
|
|
211
|
+
teamId: status.remote_team_id,
|
|
212
|
+
serverInstanceId: status.server_instance_id,
|
|
213
|
+
state: status.state,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
return teams;
|
|
217
|
+
}
|
|
218
|
+
export async function remoteTeamId(scriptsDir, team) {
|
|
219
|
+
const out = await run('bash', [join(scriptsDir, 'remote.sh'), 'status', team, '--json']);
|
|
220
|
+
const text = out.toString().trim();
|
|
221
|
+
if (!text)
|
|
222
|
+
throw new Error(`team '${team}' has never been connected to a remote`);
|
|
223
|
+
const status = JSON.parse(text);
|
|
224
|
+
if (status.state !== 'active') {
|
|
225
|
+
// A disconnected binding's remote_team_id is a leftover. Writing a vault
|
|
226
|
+
// version under it would file the backup against a team this machine is no
|
|
227
|
+
// longer bound to.
|
|
228
|
+
throw new Error(`team '${team}' is not connected (state: ${String(status.state)})`);
|
|
229
|
+
}
|
|
230
|
+
if (typeof status.remote_team_id !== 'string' || !status.remote_team_id) {
|
|
231
|
+
throw new Error(`team '${team}' has no remote team id recorded`);
|
|
232
|
+
}
|
|
233
|
+
return status.remote_team_id;
|
|
234
|
+
}
|
|
235
|
+
// OSS `remote.sh unlock <team> --authenticated-bundle-stdin`.
|
|
236
|
+
//
|
|
237
|
+
// The disaster-restore ingress. We pass the exact Buffer the AEAD produced,
|
|
238
|
+
// straight down the pipe — no temporary file, no path. A path would reopen the
|
|
239
|
+
// window this mode exists to close: what we authenticated and what gets imported
|
|
240
|
+
// must be the same bytes, not the same filename. It also means the decrypted
|
|
241
|
+
// bundle never lands on disk in the clear.
|
|
242
|
+
//
|
|
243
|
+
// remote.sh performs no authentication here; it trusts that the caller did. That
|
|
244
|
+
// obligation is ours: only call this with a buffer that came out of
|
|
245
|
+
// unwrapWithVdk, whose AAD bound the vault, team, and profile we expected. The
|
|
246
|
+
// courier path must keep using unlockBundle with an out-of-band digest, because
|
|
247
|
+
// age's recipient encryption does not authenticate the sender.
|
|
248
|
+
export async function unlockAuthenticatedBundle(scriptsDir, team, bundle) {
|
|
249
|
+
await run('bash', [join(scriptsDir, 'remote.sh'), 'unlock', team, '--authenticated-bundle-stdin'], bundle);
|
|
250
|
+
}
|
|
251
|
+
// OSS `remote.sh unlock <team> --bundle <file> [--confirm-digest <digest>]`.
|
|
252
|
+
export async function unlockBundle(scriptsDir, team, bundleFile, confirmDigest) {
|
|
253
|
+
const args = [join(scriptsDir, 'remote.sh'), 'unlock', team, '--bundle', bundleFile];
|
|
254
|
+
if (confirmDigest)
|
|
255
|
+
args.push('--confirm-digest', confirmDigest);
|
|
256
|
+
await run('bash', args);
|
|
257
|
+
}
|
|
258
|
+
// The canonical age snapshot, exported locally, and its digest (§3.2, §3.2.1).
|
|
259
|
+
//
|
|
260
|
+
// This is what A commits to BEFORE either side opens. It is the public
|
|
261
|
+
// key-state description — no identity material — so what it leaves behind if
|
|
262
|
+
// this process is killed is a snapshot in a file the user owns, not a secret.
|
|
263
|
+
// The bundle that does carry identities is generated after the human answers.
|
|
264
|
+
//
|
|
265
|
+
// The digest is taken from the FILE, not from the script's stderr line. The
|
|
266
|
+
// spec defines it as the SHA-256 of the canonical JSON, and the file is that
|
|
267
|
+
// JSON; the stderr line is a human-facing surface whose wording can change
|
|
268
|
+
// without anything failing. Reading the definition rather than the message
|
|
269
|
+
// also means A and B compute the same value from the same rule.
|
|
270
|
+
export async function exportSnapshotDigest(scriptsDir, team, outPath) {
|
|
271
|
+
await run('bash', [
|
|
272
|
+
join(scriptsDir, 'remote-sync.sh'),
|
|
273
|
+
'export-age-snapshot',
|
|
274
|
+
'--team',
|
|
275
|
+
team,
|
|
276
|
+
'--out',
|
|
277
|
+
outPath,
|
|
278
|
+
]);
|
|
279
|
+
// O_NOFOLLOW: the path is one we just asked another program to create, and
|
|
280
|
+
// reading a snapshot through a symlink someone else planted would hash a
|
|
281
|
+
// file this machine never wrote.
|
|
282
|
+
const fd = openSync(outPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
283
|
+
try {
|
|
284
|
+
return createHash('sha256').update(readFileSync(fd)).digest('hex');
|
|
285
|
+
}
|
|
286
|
+
finally {
|
|
287
|
+
closeSync(fd);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
// Where this machine keeps its device identity (the age private key that blobs
|
|
4
|
+
// are sealed to). It never leaves the machine; 0600, outside any repo.
|
|
5
|
+
export function deviceIdentityPath(env = process.env) {
|
|
6
|
+
const base = env.AGMSG_CLOUD_HOME ?? join(homedir(), '.agmsg-cloud');
|
|
7
|
+
return join(base, 'device.key');
|
|
8
|
+
}
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { deriveApproverCommitment, isCanonicalAgeRecipient } from '@agmsg-cloud/sas-core';
|
|
6
|
+
import { readIfPresent, uniqueTempPath, withFileLock } from './filelock.js';
|
|
7
|
+
// Same scope as the §4.1 ledger: the canonical server origin. A record from one
|
|
8
|
+
// deployment must never satisfy another, so the origin is part of the key rather
|
|
9
|
+
// than a field a reader could forget to check.
|
|
10
|
+
function originTag(serverOrigin) {
|
|
11
|
+
return createHash('sha256').update(serverOrigin).digest('hex').slice(0, 16);
|
|
12
|
+
}
|
|
13
|
+
export function pendingDir(env = process.env) {
|
|
14
|
+
const base = env['AGMSG_CLOUD_HOME'] ?? join(homedir(), '.agmsg-cloud');
|
|
15
|
+
return join(base, 'pending');
|
|
16
|
+
}
|
|
17
|
+
function recordPath(dir, role, origin, key) {
|
|
18
|
+
return join(dir, `${role}-${originTag(origin)}-${key}.json`);
|
|
19
|
+
}
|
|
20
|
+
// 0700 on the directory as well as 0600 on the file: a world-readable directory
|
|
21
|
+
// leaks which ceremonies are in flight and when, even if the nonces themselves
|
|
22
|
+
// stay unreadable.
|
|
23
|
+
function ensureDir(dir) {
|
|
24
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
25
|
+
chmodSync(dir, 0o700);
|
|
26
|
+
}
|
|
27
|
+
// Written to a temporary name and renamed, so a reader never sees a half-written
|
|
28
|
+
// record, and created 0600 from the start rather than chmod'ed afterwards — a
|
|
29
|
+
// window where the nonce is world-readable is still a window.
|
|
30
|
+
function writeRecord(path, record) {
|
|
31
|
+
// Per-process temp name: a shared one is renamed over by every writer, so two
|
|
32
|
+
// processes reserving at once lose one of the two records — and a lost record
|
|
33
|
+
// is a nonce nobody can open with.
|
|
34
|
+
const tmp = uniqueTempPath(path);
|
|
35
|
+
writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600 });
|
|
36
|
+
chmodSync(tmp, 0o600);
|
|
37
|
+
renameSync(tmp, path);
|
|
38
|
+
}
|
|
39
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
40
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
41
|
+
function validTimestamp(value) {
|
|
42
|
+
if (typeof value !== 'string')
|
|
43
|
+
return false;
|
|
44
|
+
const ms = Date.parse(value);
|
|
45
|
+
return Number.isFinite(ms) && new Date(ms).toISOString() === value;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Read a record, checking every field rather than only that the file is JSON.
|
|
49
|
+
*
|
|
50
|
+
* An earlier version accepted anything that parsed, so `{}` came back as an
|
|
51
|
+
* existing record: minting was suppressed and undefined values went on to be
|
|
52
|
+
* used as a nonce. "Corrupt" has to mean "does not satisfy the shape", or the
|
|
53
|
+
* refusal protects only against a truncated write.
|
|
54
|
+
*
|
|
55
|
+
* The expected role, origin and key are passed in and compared against the
|
|
56
|
+
* contents, so a record cannot be moved or renamed into another ceremony's slot.
|
|
57
|
+
*/
|
|
58
|
+
function readRecord(path, expected) {
|
|
59
|
+
const raw = readIfPresent(path);
|
|
60
|
+
if (raw === null)
|
|
61
|
+
return null;
|
|
62
|
+
const fail = () => {
|
|
63
|
+
throw new Error(`pending record is unreadable: ${path}`);
|
|
64
|
+
};
|
|
65
|
+
let parsed;
|
|
66
|
+
try {
|
|
67
|
+
parsed = JSON.parse(raw);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return fail();
|
|
71
|
+
}
|
|
72
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
73
|
+
return fail();
|
|
74
|
+
const r = parsed;
|
|
75
|
+
if (r['role'] !== expected.role)
|
|
76
|
+
return fail();
|
|
77
|
+
if (r['serverOrigin'] !== expected.serverOrigin)
|
|
78
|
+
return fail();
|
|
79
|
+
if (typeof r['commitmentHex'] !== 'string' || !HEX64.test(r['commitmentHex']))
|
|
80
|
+
return fail();
|
|
81
|
+
if (typeof r['nonceHex'] !== 'string' || !HEX64.test(r['nonceHex']))
|
|
82
|
+
return fail();
|
|
83
|
+
if (!validTimestamp(r['createdAt']))
|
|
84
|
+
return fail();
|
|
85
|
+
if (expected.role === 'requester') {
|
|
86
|
+
// The commitment IS the file key for a requester, so a mismatch means this
|
|
87
|
+
// record belongs to a different ceremony.
|
|
88
|
+
if (r['commitmentHex'] !== expected.key)
|
|
89
|
+
return fail();
|
|
90
|
+
if (typeof r['devicePubkey'] !== 'string' || !isCanonicalAgeRecipient(r['devicePubkey'])) {
|
|
91
|
+
return fail();
|
|
92
|
+
}
|
|
93
|
+
if (r['requestId'] !== undefined && (typeof r['requestId'] !== 'string' || !UUID.test(r['requestId']))) {
|
|
94
|
+
return fail();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
if (typeof r['requestId'] !== 'string' || r['requestId'] !== expected.key)
|
|
99
|
+
return fail();
|
|
100
|
+
if (r['devicePubkey'] !== undefined)
|
|
101
|
+
return fail();
|
|
102
|
+
// Required, and required STRICTLY. A record written before the digest was
|
|
103
|
+
// part of the commitment cannot be repaired by attaching today's snapshot:
|
|
104
|
+
// the stored commitment was computed without one, so the pair would not
|
|
105
|
+
// reproduce it, and the server would refuse the opening after the human
|
|
106
|
+
// had already compared digits. Fail here and let a fresh attempt start
|
|
107
|
+
// (raised in review).
|
|
108
|
+
if (typeof r['handoffDigestHex'] !== 'string' || !HEX64.test(r['handoffDigestHex'])) {
|
|
109
|
+
return fail();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// A requester never has one. Allowing it would let a file written for one
|
|
113
|
+
// role be read as the other's.
|
|
114
|
+
if (expected.role === 'requester' && r['handoffDigestHex'] !== undefined)
|
|
115
|
+
return fail();
|
|
116
|
+
return r;
|
|
117
|
+
}
|
|
118
|
+
export function fileMode(path) {
|
|
119
|
+
return statSync(path).mode & 0o777;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Reserve the requester's opening nonce for a commitment.
|
|
123
|
+
*
|
|
124
|
+
* Called BEFORE the commitment is posted. If a record already exists for this
|
|
125
|
+
* commitment, it is returned unchanged — the same commitment must always open to
|
|
126
|
+
* the same nonce.
|
|
127
|
+
*/
|
|
128
|
+
export function reserveRequesterNonce(input, env = process.env) {
|
|
129
|
+
const dir = pendingDir(env);
|
|
130
|
+
ensureDir(dir);
|
|
131
|
+
const path = recordPath(dir, 'requester', input.serverOrigin, input.commitmentHex);
|
|
132
|
+
const expected = { role: 'requester', serverOrigin: input.serverOrigin, key: input.commitmentHex };
|
|
133
|
+
// Read and write under one lock. Split apart, two processes both see nothing
|
|
134
|
+
// and both write — and the loser's nonce is the one the server is holding a
|
|
135
|
+
// commitment for.
|
|
136
|
+
return withFileLock(path, () => {
|
|
137
|
+
const existing = readRecord(path, expected);
|
|
138
|
+
if (existing)
|
|
139
|
+
return existing;
|
|
140
|
+
const record = {
|
|
141
|
+
role: 'requester',
|
|
142
|
+
commitmentHex: input.commitmentHex,
|
|
143
|
+
nonceHex: input.nonceHex,
|
|
144
|
+
devicePubkey: input.devicePubkey,
|
|
145
|
+
serverOrigin: input.serverOrigin,
|
|
146
|
+
createdAt: new Date().toISOString(),
|
|
147
|
+
};
|
|
148
|
+
writeRecord(path, record);
|
|
149
|
+
return record;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Reserve the approver's nonce for one enrollment request.
|
|
154
|
+
*
|
|
155
|
+
* The mint callback runs ONLY when no record exists. After a crash this returns
|
|
156
|
+
* the nonce already committed to, because replacing a fixed commitment is
|
|
157
|
+
* indistinguishable from a substitution attack.
|
|
158
|
+
*/
|
|
159
|
+
/**
|
|
160
|
+
* Reserve the approver's (nonce, digest, commitment) — as one thing.
|
|
161
|
+
*
|
|
162
|
+
* `handoffDigestHex` is exported OUTSIDE this lock, because exporting a
|
|
163
|
+
* snapshot spawns a child process and the lock is synchronous. That is safe
|
|
164
|
+
* for one reason, and it is worth stating: a caller that loses the race
|
|
165
|
+
* DISCARDS its own digest. It returns the stored record whole, so the value
|
|
166
|
+
* that ends up committed to, opened with, and compared by a human is the one
|
|
167
|
+
* on disk — never a second snapshot taken concurrently (raised in review).
|
|
168
|
+
*
|
|
169
|
+
* The trio is written in a single record, and read back through the validator
|
|
170
|
+
* that requires all three, so a partially written or pre-digest record fails
|
|
171
|
+
* rather than being completed from the current state.
|
|
172
|
+
*/
|
|
173
|
+
/**
|
|
174
|
+
* Does this approver record reproduce its own commitment from its own nonce and
|
|
175
|
+
* digest?
|
|
176
|
+
*
|
|
177
|
+
* The three values are only meaningful together, and a record that fails this
|
|
178
|
+
* cannot be used for anything: it would post a commitment the server never saw,
|
|
179
|
+
* or open one it cannot satisfy. Which of the three is wrong is unknowable from
|
|
180
|
+
* here, so there is nothing to repair.
|
|
181
|
+
*
|
|
182
|
+
* Shared, because two callers now ask it for different reasons and they must
|
|
183
|
+
* not drift: reserving refuses to continue from a record like this, and the
|
|
184
|
+
* budget refuses to treat one as evidence that a run is re-sending a commitment
|
|
185
|
+
* it has already been charged for (raised in review).
|
|
186
|
+
*/
|
|
187
|
+
export function approverRecordAgreesWithItself(record) {
|
|
188
|
+
if (record.role !== 'approver')
|
|
189
|
+
return false;
|
|
190
|
+
if (typeof record.handoffDigestHex !== 'string')
|
|
191
|
+
return false;
|
|
192
|
+
return (deriveApproverCommitment(new Uint8Array(Buffer.from(record.nonceHex, 'hex')), record.handoffDigestHex) === record.commitmentHex);
|
|
193
|
+
}
|
|
194
|
+
export function reserveApproverNonce(input, mint, env = process.env) {
|
|
195
|
+
if (!HEX64.test(input.handoffDigestHex)) {
|
|
196
|
+
throw new Error('handoff digest must be 64 lowercase hexadecimal characters');
|
|
197
|
+
}
|
|
198
|
+
const dir = pendingDir(env);
|
|
199
|
+
ensureDir(dir);
|
|
200
|
+
const path = recordPath(dir, 'approver', input.serverOrigin, input.requestId);
|
|
201
|
+
const expected = { role: 'approver', serverOrigin: input.serverOrigin, key: input.requestId };
|
|
202
|
+
// The mint callback runs INSIDE the lock, so two processes cannot each mint a
|
|
203
|
+
// nonce for one request. Two different nonces for one fixed commitment is,
|
|
204
|
+
// from the requester's side, indistinguishable from a substitution.
|
|
205
|
+
return withFileLock(path, () => {
|
|
206
|
+
const existing = readRecord(path, expected);
|
|
207
|
+
if (existing) {
|
|
208
|
+
// The three values are only meaningful together. A record whose stored
|
|
209
|
+
// commitment does not reproduce from its own nonce and digest is one
|
|
210
|
+
// this cannot use — it would post a commitment the server never saw, or
|
|
211
|
+
// open one it cannot satisfy — and there is nothing to repair, because
|
|
212
|
+
// which of the three is wrong is unknowable from here.
|
|
213
|
+
if (!approverRecordAgreesWithItself(existing)) {
|
|
214
|
+
throw new Error(`pending approver record does not agree with itself: ${path}\n\n` +
|
|
215
|
+
'Remove it and start a fresh approval; its commitment cannot be reproduced from the nonce and digest stored beside it.');
|
|
216
|
+
}
|
|
217
|
+
return existing;
|
|
218
|
+
}
|
|
219
|
+
const minted = mint(input.handoffDigestHex);
|
|
220
|
+
const record = {
|
|
221
|
+
role: 'approver',
|
|
222
|
+
commitmentHex: minted.commitmentHex,
|
|
223
|
+
nonceHex: minted.nonceHex,
|
|
224
|
+
handoffDigestHex: input.handoffDigestHex,
|
|
225
|
+
requestId: input.requestId,
|
|
226
|
+
serverOrigin: input.serverOrigin,
|
|
227
|
+
createdAt: new Date().toISOString(),
|
|
228
|
+
};
|
|
229
|
+
writeRecord(path, record);
|
|
230
|
+
return record;
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
// Record the id the server assigned, once the commitment is accepted. Only ever
|
|
234
|
+
// adds the id; the nonce and commitment are never touched.
|
|
235
|
+
export function attachRequestId(input, env = process.env) {
|
|
236
|
+
const path = recordPath(pendingDir(env), 'requester', input.serverOrigin, input.commitmentHex);
|
|
237
|
+
const expected = { role: 'requester', serverOrigin: input.serverOrigin, key: input.commitmentHex };
|
|
238
|
+
withFileLock(path, () => {
|
|
239
|
+
const existing = readRecord(path, expected);
|
|
240
|
+
if (!existing)
|
|
241
|
+
return;
|
|
242
|
+
writeRecord(path, { ...existing, requestId: input.requestId });
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
export function loadRequesterRecord(input, env = process.env) {
|
|
246
|
+
return readRecord(recordPath(pendingDir(env), 'requester', input.serverOrigin, input.commitmentHex), { role: 'requester', serverOrigin: input.serverOrigin, key: input.commitmentHex });
|
|
247
|
+
}
|
|
248
|
+
export function loadApproverRecord(input, env = process.env) {
|
|
249
|
+
return readRecord(recordPath(pendingDir(env), 'approver', input.serverOrigin, input.requestId), {
|
|
250
|
+
role: 'approver',
|
|
251
|
+
serverOrigin: input.serverOrigin,
|
|
252
|
+
key: input.requestId,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* The requester's unfinished ceremonies against one server, oldest first.
|
|
257
|
+
*
|
|
258
|
+
* Resuming matters for more than convenience. §4.1 charges an attempt when the
|
|
259
|
+
* commitment is posted, so starting over instead of finishing an existing
|
|
260
|
+
* request spends a second attempt for one enrollment — five crashes would
|
|
261
|
+
* exhaust the day's budget without a single completed comparison.
|
|
262
|
+
*/
|
|
263
|
+
export function listResumableRequesterRecords(serverOrigin, env = process.env) {
|
|
264
|
+
const dir = pendingDir(env);
|
|
265
|
+
const prefix = `requester-${originTag(serverOrigin)}-`;
|
|
266
|
+
let names;
|
|
267
|
+
try {
|
|
268
|
+
names = readdirSync(dir);
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
return [];
|
|
272
|
+
}
|
|
273
|
+
const out = [];
|
|
274
|
+
for (const name of names) {
|
|
275
|
+
if (!name.startsWith(prefix) || !name.endsWith('.json'))
|
|
276
|
+
continue;
|
|
277
|
+
const key = name.slice(prefix.length, -'.json'.length);
|
|
278
|
+
const record = readRecord(join(dir, name), {
|
|
279
|
+
role: 'requester',
|
|
280
|
+
serverOrigin,
|
|
281
|
+
key,
|
|
282
|
+
});
|
|
283
|
+
// Records WITHOUT an id are returned too. One of them may still have landed
|
|
284
|
+
// on the server — the response can be lost after the row exists — and
|
|
285
|
+
// dropping it here left the caller starting over while the ledger's open
|
|
286
|
+
// attempt refused every retry. Deciding which case it is needs the server,
|
|
287
|
+
// so that decision belongs to the caller.
|
|
288
|
+
if (record)
|
|
289
|
+
out.push(record);
|
|
290
|
+
}
|
|
291
|
+
return out.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
|
|
292
|
+
}
|
|
293
|
+
// Called once a ceremony reaches a terminal state. Not called on a mere failure
|
|
294
|
+
// to reach the server: a record whose commitment may have landed must survive,
|
|
295
|
+
// or the next run would mint a replacement for a commitment the server holds.
|
|
296
|
+
export function clearRecord(input, env = process.env) {
|
|
297
|
+
rmSync(recordPath(pendingDir(env), input.role, input.serverOrigin, input.key), { force: true });
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Drop every pending record for one server, and say how many.
|
|
301
|
+
*
|
|
302
|
+
* For sign-out: a ceremony half-started against a service this machine is
|
|
303
|
+
* leaving has nothing left to complete, and its nonce is the opening of a
|
|
304
|
+
* commitment nobody will ever ask about again.
|
|
305
|
+
*
|
|
306
|
+
* Selected by the origin TAG that is already part of every filename, so this
|
|
307
|
+
* cannot drift from the naming above the way a second list of origins would.
|
|
308
|
+
* Records for other servers are untouched: signing out of one deployment is
|
|
309
|
+
* not signing out of the others.
|
|
310
|
+
*/
|
|
311
|
+
export function clearRecordsForOrigin(serverOrigin, env = process.env) {
|
|
312
|
+
const dir = pendingDir(env);
|
|
313
|
+
if (!existsSync(dir))
|
|
314
|
+
return 0;
|
|
315
|
+
const tag = originTag(serverOrigin);
|
|
316
|
+
let removed = 0;
|
|
317
|
+
for (const name of readdirSync(dir)) {
|
|
318
|
+
if (!name.endsWith('.json'))
|
|
319
|
+
continue;
|
|
320
|
+
// `<role>-<tag>-<key>.json`. Matching the tag as its own dash-delimited
|
|
321
|
+
// field, not as a substring: a key that happened to contain the tag would
|
|
322
|
+
// otherwise take another server's record with it.
|
|
323
|
+
const parts = name.slice(0, -'.json'.length).split('-');
|
|
324
|
+
if (parts[1] !== tag)
|
|
325
|
+
continue;
|
|
326
|
+
rmSync(join(dir, name), { force: true });
|
|
327
|
+
removed += 1;
|
|
328
|
+
}
|
|
329
|
+
return removed;
|
|
330
|
+
}
|