agmsg-cloud 0.1.0-rc.4 → 0.1.0-rc.6
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/dist/src/api.js +20 -1
- package/dist/src/commands/connect.js +110 -24
- package/dist/src/commands/fetch.js +36 -2
- package/dist/src/commands/login.js +6 -1
- package/dist/src/commands/pull.js +60 -2
- package/dist/src/commands/request.js +97 -33
- package/dist/src/commands/sync.js +21 -1
- package/dist/src/commands/vault.js +117 -24
- package/dist/src/commands/whoami.js +44 -0
- package/dist/src/config.js +29 -1
- package/dist/src/index.js +31 -3
- package/dist/src/machine-id.js +44 -0
- package/dist/src/oss.js +15 -1
- package/dist/src/preflight.js +34 -1
- package/dist/src/recovery-key.js +24 -9
- package/dist/src/slot-advice.js +85 -0
- package/dist/src/vault-container.js +39 -0
- package/dist/src/vault-filing.js +62 -0
- package/package.json +1 -1
package/dist/src/slot-advice.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { setupCommand } from './recovery-key.js';
|
|
1
2
|
import { shellArg } from './shell-arg.js';
|
|
2
3
|
/**
|
|
3
4
|
* Explain a slot that did not open, given the command that will now ask for the
|
|
@@ -65,6 +66,90 @@ export function adviseOnSlot(result, ctx) {
|
|
|
65
66
|
};
|
|
66
67
|
}
|
|
67
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* Explain a slot that did not open, given a command that will NOT ask for the
|
|
71
|
+
* recovery key — `connect`, which files the team's key into the vault silently
|
|
72
|
+
* or not at all.
|
|
73
|
+
*
|
|
74
|
+
* A second mapping rather than a second wording of the first: the sentences
|
|
75
|
+
* above all end in "the recovery key is typed and this run continues", and
|
|
76
|
+
* every one of them is false here. Nothing continues; the team is not in the
|
|
77
|
+
* vault, and what the person needs is the route that puts it there.
|
|
78
|
+
*
|
|
79
|
+
* The two live in one file, side by side, because they are one judgement read
|
|
80
|
+
* twice. `OpenResult['reason']` is a union and both switches are exhaustive, so
|
|
81
|
+
* a fifth reason cannot be answered in one of them and forgotten in the other —
|
|
82
|
+
* it fails to compile until both have looked at it.
|
|
83
|
+
*
|
|
84
|
+
* The `no-store` branch is why this exists at all. The prompting caller can say
|
|
85
|
+
* "the key is needed for each backup" and be done; here, the honest sentence is
|
|
86
|
+
* that this machine will never file silently, and a remedy that said "run
|
|
87
|
+
* `recovery setup` once and this stops happening" would be a route that does
|
|
88
|
+
* not exist on Windows or Linux.
|
|
89
|
+
*/
|
|
90
|
+
export function adviseOnSlotForSilentFiling(result, ctx) {
|
|
91
|
+
if (result.ok)
|
|
92
|
+
return { tone: 'silent', lines: [] };
|
|
93
|
+
// NAMED, and the argument is the point rather than a detail.
|
|
94
|
+
//
|
|
95
|
+
// `recovery setup` takes the team optionally now, and the no-argument form is
|
|
96
|
+
// the one its own documentation calls ordinary — which is why this printed it
|
|
97
|
+
// at first. But the broad form's success depends on every OTHER team the
|
|
98
|
+
// machine reports: one pass, no per-team isolation, so a `key.sh handoff`
|
|
99
|
+
// that fails for an unrelated team throws out of the loop and nothing is
|
|
100
|
+
// written. And if `remote.sh status --json` comes back short (agmsg#650) the
|
|
101
|
+
// target list can be empty, which refuses outright.
|
|
102
|
+
//
|
|
103
|
+
// Either way the person is sent to a command that can fail for a reason that
|
|
104
|
+
// has nothing to do with the team they just connected — and this is the one
|
|
105
|
+
// route they are given. Naming the team makes the remedy reach exactly the
|
|
106
|
+
// thing the sentence above it is about (raised in review).
|
|
107
|
+
const setup = `\`${setupCommand(ctx.team)}\``;
|
|
108
|
+
switch (result.reason) {
|
|
109
|
+
case 'no-slot':
|
|
110
|
+
return {
|
|
111
|
+
tone: 'note',
|
|
112
|
+
lines: [
|
|
113
|
+
'this machine has no key slot for this vault, so the team was not backed up.',
|
|
114
|
+
`Run ${setup} and type the recovery key once; after that this machine`,
|
|
115
|
+
'files new teams itself.',
|
|
116
|
+
],
|
|
117
|
+
};
|
|
118
|
+
case 'no-store':
|
|
119
|
+
// The one that must not promise a fix. There is no secure store to keep a
|
|
120
|
+
// slot in, so every future `connect` on this machine lands here too.
|
|
121
|
+
return {
|
|
122
|
+
tone: 'note',
|
|
123
|
+
lines: [
|
|
124
|
+
'this machine has no secure store, so it cannot back up a team without the recovery',
|
|
125
|
+
`key: the team was not backed up. Run ${setup} to back it up now.`,
|
|
126
|
+
'This machine will need the key each time; a machine with a secure store will not.',
|
|
127
|
+
],
|
|
128
|
+
};
|
|
129
|
+
case 'store-locked':
|
|
130
|
+
// Order matters here and only here: running setup against a locked store
|
|
131
|
+
// backs the team up but keeps no slot, so the next connect asks again.
|
|
132
|
+
return {
|
|
133
|
+
tone: 'note',
|
|
134
|
+
lines: [
|
|
135
|
+
'this machine has a secure store but it refused: ' + result.detail,
|
|
136
|
+
`The team was not backed up. Unlock the store, then run ${setup} —`,
|
|
137
|
+
'in that order, so a key slot is kept and later teams are filed without asking.',
|
|
138
|
+
],
|
|
139
|
+
};
|
|
140
|
+
case 'unusable':
|
|
141
|
+
return {
|
|
142
|
+
tone: 'warning',
|
|
143
|
+
lines: [
|
|
144
|
+
'this machine has a key slot for this vault and its contents did not hold up: ' +
|
|
145
|
+
result.detail,
|
|
146
|
+
`The team was not backed up. Run ${setup}, which asks for the recovery`,
|
|
147
|
+
'key and replaces the slot. Until then this team exists only on the machines',
|
|
148
|
+
'holding it.',
|
|
149
|
+
],
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
68
153
|
/** Render advice for a terminal. Empty string when there is nothing to say. */
|
|
69
154
|
export function renderSlotAdvice(advice) {
|
|
70
155
|
if (advice.lines.length === 0)
|
|
@@ -87,6 +87,45 @@ export function upsertTeam(container, entry) {
|
|
|
87
87
|
const teams = [...others, entry].sort((a, b) => (identityOf(a) < identityOf(b) ? -1 : 1));
|
|
88
88
|
return { format: CONTAINER_FORMAT, version: CONTAINER_VERSION, teams };
|
|
89
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* The same two key epochs, however they are ordered.
|
|
92
|
+
*
|
|
93
|
+
* A set comparison, not a sequence one: the epochs come out of a bundle the OSS
|
|
94
|
+
* side produced, and nothing promises the order is stable between two runs.
|
|
95
|
+
* Comparing them in order would report a change on a re-ordering and append a
|
|
96
|
+
* version identical to the one before it.
|
|
97
|
+
*/
|
|
98
|
+
export function sameEpochs(a, b) {
|
|
99
|
+
if (a.length !== b.length)
|
|
100
|
+
return false;
|
|
101
|
+
const left = [...a].sort();
|
|
102
|
+
const right = [...b].sort();
|
|
103
|
+
return left.every((v, i) => v === right[i]);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Put one team into the container, unless its epochs are already the ones in
|
|
107
|
+
* there.
|
|
108
|
+
*
|
|
109
|
+
* The `written` half is the point. `recovery setup` documents itself as
|
|
110
|
+
* idempotent — "a run where no team's epochs have moved writes nothing and
|
|
111
|
+
* leaves the revision where it was" — and that promise belongs to the container,
|
|
112
|
+
* not to the command that happened to make it first. `connect` files a team the
|
|
113
|
+
* moment it is created and is re-runnable, so without this a second `connect`
|
|
114
|
+
* on a team already in the vault appends a version identical to the one before
|
|
115
|
+
* it, and the revision history stops meaning "something changed here".
|
|
116
|
+
*
|
|
117
|
+
* Kept beside `upsertTeam` rather than in either caller, because two copies of
|
|
118
|
+
* "has this actually moved" that drifted would disagree about whether a write
|
|
119
|
+
* was needed — and the one that said yes would silently win.
|
|
120
|
+
*/
|
|
121
|
+
export function upsertTeamIfMoved(container, entry) {
|
|
122
|
+
const key = identityOf(entry);
|
|
123
|
+
const already = container.teams.find((t) => identityOf(t) === key);
|
|
124
|
+
if (already && sameEpochs(already.key_ids, entry.key_ids)) {
|
|
125
|
+
return { container, written: false };
|
|
126
|
+
}
|
|
127
|
+
return { container: upsertTeam(container, entry), written: true };
|
|
128
|
+
}
|
|
90
129
|
export function serializeContainer(container) {
|
|
91
130
|
return Buffer.from(JSON.stringify(container), 'utf8');
|
|
92
131
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { openDeviceSlot } from './device-slot.js';
|
|
5
|
+
import { keyHandoff, remoteBinding } from './oss.js';
|
|
6
|
+
import { parseContainer, serializeContainer, upsertTeamIfMoved } from './vault-container.js';
|
|
7
|
+
import { appendVaultVersionWithVdk, openVaultWithVdk, readAccountVault } from './vault-protocol.js';
|
|
8
|
+
import { bundleKeyIds, slotAddress } from './commands/vault.js';
|
|
9
|
+
/**
|
|
10
|
+
* File one team's handoff bundle into this account's vault, if that can be done
|
|
11
|
+
* silently.
|
|
12
|
+
*
|
|
13
|
+
* Fails closed in the sense that matters: it never invents a vault, never asks
|
|
14
|
+
* for a key, and never reports success for a write it did not make.
|
|
15
|
+
*/
|
|
16
|
+
export async function fileTeamInVaultSilently(config, client, team) {
|
|
17
|
+
const { identity, version } = await readAccountVault(client);
|
|
18
|
+
// No vault means there is no recovery key either — creating one here would
|
|
19
|
+
// mint a key and show it during a command nobody ran for that purpose.
|
|
20
|
+
if (!version)
|
|
21
|
+
return { filed: false, reason: 'no-vault' };
|
|
22
|
+
const opened = await openDeviceSlot(slotAddress(identity, version.vault_id, version.recovery_generation));
|
|
23
|
+
// Every way of not having a slot lands here, and none of them is an error:
|
|
24
|
+
// a machine that has never run `recovery setup`, a platform with no secure
|
|
25
|
+
// store, a locked keychain. The caller names the route instead.
|
|
26
|
+
if (!opened.ok)
|
|
27
|
+
return { filed: false, reason: 'no-slot', slot: opened };
|
|
28
|
+
const binding = await remoteBinding(config.scriptsDir, team);
|
|
29
|
+
const current = openVaultWithVdk(identity, version, opened.vdk);
|
|
30
|
+
const container = parseContainer(current.content);
|
|
31
|
+
const scratch = mkdtempSync(join(tmpdir(), 'agmsg-cloud-filing-'));
|
|
32
|
+
try {
|
|
33
|
+
const bundleFile = join(scratch, 'handoff.bundle');
|
|
34
|
+
await keyHandoff(config.scriptsDir, team, bundleFile);
|
|
35
|
+
const bundle = readFileSync(bundleFile);
|
|
36
|
+
// The same decision `recovery setup` makes, from the same function.
|
|
37
|
+
//
|
|
38
|
+
// `connect` is re-runnable — a second run on a team that is already
|
|
39
|
+
// connected continues to the registration rather than refusing — so
|
|
40
|
+
// without this it would append a version identical to the one before it
|
|
41
|
+
// every time. `recovery setup` documents itself as idempotent; a second
|
|
42
|
+
// writer that is not makes that sentence false about the account.
|
|
43
|
+
const { container: next, written } = upsertTeamIfMoved(container, {
|
|
44
|
+
server_instance_id: binding.serverInstanceId,
|
|
45
|
+
team_id: binding.teamId,
|
|
46
|
+
key_ids: bundleKeyIds(bundle),
|
|
47
|
+
bundle: bundle.toString('base64'),
|
|
48
|
+
});
|
|
49
|
+
if (!written) {
|
|
50
|
+
// Backed up, and this run is not what did it. Told apart from a write
|
|
51
|
+
// because the caller says "revision N" and there is no new N to say.
|
|
52
|
+
return { filed: true, wrote: false, revision: version.revision, teamCount: next.teams.length };
|
|
53
|
+
}
|
|
54
|
+
const result = await appendVaultVersionWithVdk(client, identity, version, opened.vdk, serializeContainer(next));
|
|
55
|
+
return { filed: true, wrote: true, revision: result.revision, teamCount: next.teams.length };
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
// The bundle is key material. Removed on every path this process controls,
|
|
59
|
+
// the same way the ceremony's snapshot is.
|
|
60
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
61
|
+
}
|
|
62
|
+
}
|
package/package.json
CHANGED