@cello-protocol/daemon 0.0.181 → 0.0.183
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/agent-id-migration.d.ts +9 -0
- package/dist/agent-id-migration.d.ts.map +1 -1
- package/dist/agent-id-migration.js +73 -1
- package/dist/agent-id-migration.js.map +1 -1
- package/dist/agent-selection.d.ts +73 -0
- package/dist/agent-selection.d.ts.map +1 -0
- package/dist/agent-selection.js +80 -0
- package/dist/agent-selection.js.map +1 -0
- package/dist/agent-settings-keys.d.ts.map +1 -1
- package/dist/agent-settings-keys.js +18 -0
- package/dist/agent-settings-keys.js.map +1 -1
- package/dist/assignment-verify.d.ts +62 -0
- package/dist/assignment-verify.d.ts.map +1 -0
- package/dist/assignment-verify.js +185 -0
- package/dist/assignment-verify.js.map +1 -0
- package/dist/backup-restore.d.ts +102 -0
- package/dist/backup-restore.d.ts.map +1 -0
- package/dist/backup-restore.js +340 -0
- package/dist/backup-restore.js.map +1 -0
- package/dist/bin/cello-daemon.js +91 -49
- package/dist/bin/cello-daemon.js.map +1 -1
- package/dist/close-commitment.d.ts +67 -0
- package/dist/close-commitment.d.ts.map +1 -0
- package/dist/close-commitment.js +78 -0
- package/dist/close-commitment.js.map +1 -0
- package/dist/close-session-handler.d.ts +20 -0
- package/dist/close-session-handler.d.ts.map +1 -1
- package/dist/close-session-handler.js +351 -58
- package/dist/close-session-handler.js.map +1 -1
- package/dist/consortium-bootstrap.d.ts +2 -0
- package/dist/consortium-bootstrap.d.ts.map +1 -1
- package/dist/consortium-bootstrap.js +182 -9
- package/dist/consortium-bootstrap.js.map +1 -1
- package/dist/content-encryption-status.d.ts +97 -0
- package/dist/content-encryption-status.d.ts.map +1 -0
- package/dist/content-encryption-status.js +122 -0
- package/dist/content-encryption-status.js.map +1 -0
- package/dist/content-park-client.d.ts +10 -0
- package/dist/content-park-client.d.ts.map +1 -1
- package/dist/content-park-client.js +84 -3
- package/dist/content-park-client.js.map +1 -1
- package/dist/content-park.d.ts.map +1 -1
- package/dist/content-park.js +323 -21
- package/dist/content-park.js.map +1 -1
- package/dist/daemon.d.ts +13 -1
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +1168 -141
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-session-suspects.d.ts.map +1 -1
- package/dist/delivery-session-suspects.js +5 -0
- package/dist/delivery-session-suspects.js.map +1 -1
- package/dist/directory-auth-posture.d.ts +87 -0
- package/dist/directory-auth-posture.d.ts.map +1 -0
- package/dist/directory-auth-posture.js +134 -0
- package/dist/directory-auth-posture.js.map +1 -0
- package/dist/directory-bootstrap.d.ts +66 -3
- package/dist/directory-bootstrap.d.ts.map +1 -1
- package/dist/directory-bootstrap.js +133 -7
- package/dist/directory-bootstrap.js.map +1 -1
- package/dist/document-delivery-transport.d.ts +22 -2
- package/dist/document-delivery-transport.d.ts.map +1 -1
- package/dist/document-delivery-transport.js +11 -3
- package/dist/document-delivery-transport.js.map +1 -1
- package/dist/document-handlers.d.ts.map +1 -1
- package/dist/document-handlers.js +119 -0
- package/dist/document-handlers.js.map +1 -1
- package/dist/document-layer.d.ts +0 -2
- package/dist/document-layer.d.ts.map +1 -1
- package/dist/document-layer.js.map +1 -1
- package/dist/inbound-sessions.d.ts +31 -1
- package/dist/inbound-sessions.d.ts.map +1 -1
- package/dist/inbound-sessions.js +276 -16
- package/dist/inbound-sessions.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/initiate-session-handler.d.ts.map +1 -1
- package/dist/initiate-session-handler.js +34 -3
- package/dist/initiate-session-handler.js.map +1 -1
- package/dist/ipc-server.d.ts +5 -1
- package/dist/ipc-server.d.ts.map +1 -1
- package/dist/ipc-server.js +19 -3
- package/dist/ipc-server.js.map +1 -1
- package/dist/manifest-deps.d.ts +0 -11
- package/dist/manifest-deps.d.ts.map +1 -1
- package/dist/manifest-deps.js +28 -2
- package/dist/manifest-deps.js.map +1 -1
- package/dist/manifest-validity.d.ts +153 -0
- package/dist/manifest-validity.d.ts.map +1 -0
- package/dist/manifest-validity.js +268 -0
- package/dist/manifest-validity.js.map +1 -0
- package/dist/notification-handlers.d.ts.map +1 -1
- package/dist/notification-handlers.js +15 -0
- package/dist/notification-handlers.js.map +1 -1
- package/dist/outbound-sessions.d.ts +31 -0
- package/dist/outbound-sessions.d.ts.map +1 -1
- package/dist/outbound-sessions.js +217 -17
- package/dist/outbound-sessions.js.map +1 -1
- package/dist/park-envelope.d.ts +95 -1
- package/dist/park-envelope.d.ts.map +1 -1
- package/dist/park-envelope.js +195 -4
- package/dist/park-envelope.js.map +1 -1
- package/dist/refusal-reasons.d.ts +72 -0
- package/dist/refusal-reasons.d.ts.map +1 -0
- package/dist/refusal-reasons.js +69 -0
- package/dist/refusal-reasons.js.map +1 -0
- package/dist/register-handler.d.ts.map +1 -1
- package/dist/register-handler.js +76 -0
- package/dist/register-handler.js.map +1 -1
- package/dist/registration-manager.d.ts.map +1 -1
- package/dist/registration-manager.js +20 -1
- package/dist/registration-manager.js.map +1 -1
- package/dist/relay-only.d.ts +133 -0
- package/dist/relay-only.d.ts.map +1 -0
- package/dist/relay-only.js +183 -0
- package/dist/relay-only.js.map +1 -0
- package/dist/restart-seal-resolver.d.ts.map +1 -1
- package/dist/restart-seal-resolver.js +6 -0
- package/dist/restart-seal-resolver.js.map +1 -1
- package/dist/retry-queue.d.ts +49 -2
- package/dist/retry-queue.d.ts.map +1 -1
- package/dist/retry-queue.js +55 -10
- package/dist/retry-queue.js.map +1 -1
- package/dist/roster-freshness.d.ts +169 -0
- package/dist/roster-freshness.d.ts.map +1 -0
- package/dist/roster-freshness.js +249 -0
- package/dist/roster-freshness.js.map +1 -0
- package/dist/seal-certificate-pull.d.ts +17 -1
- package/dist/seal-certificate-pull.d.ts.map +1 -1
- package/dist/seal-certificate-pull.js +37 -3
- package/dist/seal-certificate-pull.js.map +1 -1
- package/dist/seal-coordinator.d.ts +17 -1
- package/dist/seal-coordinator.d.ts.map +1 -1
- package/dist/seal-coordinator.js +71 -0
- package/dist/seal-coordinator.js.map +1 -1
- package/dist/seal-failure-store.d.ts +104 -0
- package/dist/seal-failure-store.d.ts.map +1 -0
- package/dist/seal-failure-store.js +126 -0
- package/dist/seal-failure-store.js.map +1 -0
- package/dist/seal-flows.d.ts.map +1 -1
- package/dist/seal-flows.js +35 -6
- package/dist/seal-flows.js.map +1 -1
- package/dist/seal-upgrade.d.ts +20 -5
- package/dist/seal-upgrade.d.ts.map +1 -1
- package/dist/seal-upgrade.js +28 -5
- package/dist/seal-upgrade.js.map +1 -1
- package/dist/session-assignment-parser.d.ts +41 -5
- package/dist/session-assignment-parser.d.ts.map +1 -1
- package/dist/session-assignment-parser.js +77 -14
- package/dist/session-assignment-parser.js.map +1 -1
- package/dist/session-ceremony.d.ts +35 -0
- package/dist/session-ceremony.d.ts.map +1 -1
- package/dist/session-ceremony.js +97 -0
- package/dist/session-ceremony.js.map +1 -1
- package/dist/session-connection-gater.d.ts +46 -7
- package/dist/session-connection-gater.d.ts.map +1 -1
- package/dist/session-connection-gater.js +163 -12
- package/dist/session-connection-gater.js.map +1 -1
- package/dist/session-content-handlers.d.ts +12 -0
- package/dist/session-content-handlers.d.ts.map +1 -1
- package/dist/session-content-handlers.js +149 -8
- package/dist/session-content-handlers.js.map +1 -1
- package/dist/session-node-manager.d.ts +653 -40
- package/dist/session-node-manager.d.ts.map +1 -1
- package/dist/session-node-manager.js +5938 -374
- package/dist/session-node-manager.js.map +1 -1
- package/dist/session-read-handlers.d.ts +27 -0
- package/dist/session-read-handlers.d.ts.map +1 -1
- package/dist/session-read-handlers.js +127 -6
- package/dist/session-read-handlers.js.map +1 -1
- package/dist/session-relay-client.d.ts +177 -2
- package/dist/session-relay-client.d.ts.map +1 -1
- package/dist/session-relay-client.js +564 -16
- package/dist/session-relay-client.js.map +1 -1
- package/dist/session-salt-agreement.d.ts +338 -0
- package/dist/session-salt-agreement.d.ts.map +1 -0
- package/dist/session-salt-agreement.js +478 -0
- package/dist/session-salt-agreement.js.map +1 -0
- package/dist/signal-submission.d.ts.map +1 -1
- package/dist/signal-submission.js +10 -1
- package/dist/signal-submission.js.map +1 -1
- package/dist/signaling-connect.d.ts +18 -1
- package/dist/signaling-connect.d.ts.map +1 -1
- package/dist/signaling-connect.js +206 -6
- package/dist/signaling-connect.js.map +1 -1
- package/dist/types.d.ts +75 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/vocabulary.d.ts.map +1 -1
- package/dist/vocabulary.js +10 -0
- package/dist/vocabulary.js.map +1 -1
- package/dist/wire-content-hash.d.ts +68 -0
- package/dist/wire-content-hash.d.ts.map +1 -1
- package/dist/wire-content-hash.js +83 -0
- package/dist/wire-content-hash.js.map +1 -1
- package/package.json +5 -5
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* assignment-verify.ts — DOD-M15-ASSIGN-1.
|
|
3
|
+
*
|
|
4
|
+
* `session-assignment-parser.ts` shape-validates a session assignment. Its header said the
|
|
5
|
+
* signature "is verified downstream by the transport/session layer against the directory's pinned
|
|
6
|
+
* key" — and that site did not exist: `buildSessionEstablishmentTbs` was called to SIGN, and
|
|
7
|
+
* nowhere to verify. This module is the downstream the comment promised.
|
|
8
|
+
*
|
|
9
|
+
* SEPARATE FROM THE PARSER on purpose. Parsing answers "is this the right shape"; verifying answers
|
|
10
|
+
* "did the right party say it". Keeping them apart means the second cannot be quietly skipped by a
|
|
11
|
+
* caller that only wanted the first — which is how the gap existed.
|
|
12
|
+
*/
|
|
13
|
+
// `verify` (plain Ed25519) is deliberately NOT imported: the single-key branch that used it was
|
|
14
|
+
// removed with the downgrade refusal (review F1). Verifying `directory_signature` against the
|
|
15
|
+
// `directory_pubkey` riding beside it in the same unsigned frame checks a key against itself, and
|
|
16
|
+
// having that call available is how a future edit reintroduces the bypass.
|
|
17
|
+
import { verifyFrostSignature, CONTEXT_SESSION_ESTABLISHMENT } from "@cello-protocol/crypto";
|
|
18
|
+
import { buildSessionEstablishmentTbs, computeGenesisPrevRoot } from "@cello-protocol/protocol-types";
|
|
19
|
+
/**
|
|
20
|
+
* DOD-M15-ASSIGN-1 — verify a session assignment's signature before anything trusts it.
|
|
21
|
+
*
|
|
22
|
+
* REFUSES, never logs-and-continues. The whole finding here is a comment naming a verification the
|
|
23
|
+
* tree does not perform, so a check that ran and then let the session proceed anyway would be the
|
|
24
|
+
* same defect with more code. `DOD-M15-ASSIGN-1`'s receiver gate reads this assignment; gating on a
|
|
25
|
+
* document nobody verified relocates trust rather than closing it.
|
|
26
|
+
*/
|
|
27
|
+
export async function verifyAssignmentSignature(assignment, persistence, logger, agentName, correlationId) {
|
|
28
|
+
// RECOMPUTED, not taken from the frame — and that is a strengthening rather than a workaround.
|
|
29
|
+
// `genesis_prev_root` is not on the wire at all: the directory derives it from
|
|
30
|
+
// (pubA, pubB, session_id, timestamp) and so must we. A value we derive cannot be chosen for us,
|
|
31
|
+
// so the TBS we verify against is anchored to the assignment's own participants and id.
|
|
32
|
+
const genesisPrevRoot = computeGenesisPrevRoot(assignment.participant_a.pubkey, assignment.participant_b.pubkey, assignment.session_id, assignment.session_timestamp);
|
|
33
|
+
const tbs = buildSessionEstablishmentTbs(assignment.session_id, assignment.participant_a.pubkey, assignment.participant_b.pubkey, genesisPrevRoot, assignment.session_timestamp, assignment.initiator_session_peer_id, assignment.initiator_session_addrs, assignment.counterparty_session_peer_id, assignment.counterparty_session_addrs, assignment.transport_mode);
|
|
34
|
+
/**
|
|
35
|
+
* LOAD THE REGISTRATION BEFORE BRANCHING ON `signature_type` — review F1, and this ordering is
|
|
36
|
+
* the entire fix.
|
|
37
|
+
*
|
|
38
|
+
* `signature_type` rides in the frame and is covered by no signature. The parser reads any value
|
|
39
|
+
* that is not the string "frost" — including an ABSENT field — as "single". So while this load
|
|
40
|
+
* lived inside the frost branch, a hostile directory disabled every check below by omitting one
|
|
41
|
+
* field: it put its own freshly-minted key in `directory_pubkey`, signed a TBS naming an
|
|
42
|
+
* impostor as the counterparty, and the single-key branch verified that signature against that
|
|
43
|
+
* same key and returned ok. The anti-circularity comparison, the threshold verify and the
|
|
44
|
+
* fail-closed were all simply stepped over.
|
|
45
|
+
*
|
|
46
|
+
* Reading the registration first closes it: an agent that HAS a threshold registration knows its
|
|
47
|
+
* assignments are FROST-signed, so a non-FROST one is a downgrade attempt and is refused by
|
|
48
|
+
* name — never quietly routed to a weaker check.
|
|
49
|
+
*/
|
|
50
|
+
const reg = await persistence.loadRegistrationState();
|
|
51
|
+
if (!reg) {
|
|
52
|
+
// FAIL CLOSED. Without our own registration we cannot know whose quorum should have signed,
|
|
53
|
+
// so the signature is unverifiable — which is not the same as valid.
|
|
54
|
+
logger.warn("session.assignment.verify.no_identity", {
|
|
55
|
+
agentName, correlationId,
|
|
56
|
+
impact: "this agent has no persisted registration, so the signer of its own session assignment cannot be established; the session was refused rather than accepted unverified",
|
|
57
|
+
});
|
|
58
|
+
return {
|
|
59
|
+
ok: false,
|
|
60
|
+
reason: "assignment_unverifiable_no_registration",
|
|
61
|
+
guidance: "This agent has no registration on record, so the directory's session assignment cannot be checked against the key that should have signed it. Re-register with cello register-agent, then try again.",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
if (assignment.signature_type !== "frost") {
|
|
65
|
+
/**
|
|
66
|
+
* THE DOWNGRADE REFUSAL. Checked against the producer, not assumed: the directory constructs
|
|
67
|
+
* `signature_type: "frost"` unconditionally at a single site (`directory-node.ts`), with no
|
|
68
|
+
* branch that can emit anything else. There is no legitimate producer of a single-key session
|
|
69
|
+
* assignment for a registered agent, so this is not a compatibility path being closed — it is
|
|
70
|
+
* a shape that only an attacker or a broken directory can send.
|
|
71
|
+
*/
|
|
72
|
+
logger.error("session.assignment.signature_type_downgraded", {
|
|
73
|
+
agentName, correlationId,
|
|
74
|
+
offeredType: assignment.signature_type,
|
|
75
|
+
impact: "the assignment claimed a weaker signature type than this agent's registration can produce, which would have routed it to a check that verifies a key against itself; it was refused, and no session was established",
|
|
76
|
+
});
|
|
77
|
+
return {
|
|
78
|
+
ok: false,
|
|
79
|
+
reason: "assignment_signature_type_downgraded",
|
|
80
|
+
guidance: "The directory returned a session assignment that does not carry a threshold signature, though this agent is registered with one. Nothing was established and no message was sent. Retry the session; if it repeats, the directory you reached is not producing valid assignments and cello status will show which one that is.",
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
{
|
|
84
|
+
// THE ANTI-CIRCULARITY CHECK. `signer_pubkey` comes from the frame; `reg.primaryPubkey` comes
|
|
85
|
+
// from this machine's own registration. Comparing them is what makes verifying against
|
|
86
|
+
// `signer_pubkey` mean anything at all.
|
|
87
|
+
const expected = reg.primaryPubkey.toLowerCase();
|
|
88
|
+
const offered = Buffer.from(assignment.signer_pubkey).toString("hex").toLowerCase();
|
|
89
|
+
if (offered !== expected) {
|
|
90
|
+
logger.error("session.assignment.signer_mismatch", {
|
|
91
|
+
agentName, correlationId,
|
|
92
|
+
expectedPrefix: expected.slice(0, 16), offeredPrefix: offered.slice(0, 16),
|
|
93
|
+
impact: "the assignment was signed by a key that is not this agent's own threshold group key; it was refused, and no session was established",
|
|
94
|
+
});
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
reason: "assignment_signer_not_this_agent",
|
|
98
|
+
guidance: "The directory returned a session assignment signed by a key that is not this agent's own. Nothing was established and no message was sent. This is refused rather than reported as an error because the observation cannot distinguish a directory fault from an attempt to broker a session on your behalf; cause undetermined.",
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
if (!verifyFrostSignature(assignment.directory_signature, tbs, CONTEXT_SESSION_ESTABLISHMENT, assignment.signer_pubkey)) {
|
|
102
|
+
logger.error("session.assignment.signature_invalid", {
|
|
103
|
+
agentName, correlationId, signatureType: "frost",
|
|
104
|
+
impact: "the assignment's threshold signature did not verify over its own contents; it was refused, and no session was established",
|
|
105
|
+
});
|
|
106
|
+
return {
|
|
107
|
+
ok: false,
|
|
108
|
+
reason: "assignment_signature_invalid",
|
|
109
|
+
guidance: "The directory's session assignment did not verify against the key that should have signed it. Nothing was established. Retry the session; if it repeats, the directory you reached is not producing valid assignments and cello status will show which one that is.",
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
return { ok: true };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* DOD-M15-RESPONDER-VERIFY-1 — the RESPONDER's half, which until now did not exist.
|
|
117
|
+
*
|
|
118
|
+
* The initiator verifies its assignment against its own threshold group key (above). The responder
|
|
119
|
+
* did not verify at all: it logged `session.inbound.assignment.unverified` and proceeded, so every
|
|
120
|
+
* field it then acted on — the dialer it opens its receiver to, the `signer_pubkey` it persists as
|
|
121
|
+
* the seal trust anchor — was whatever the directory said.
|
|
122
|
+
*
|
|
123
|
+
* ─── Why this could not simply reuse the function above ────────────────────────────────────────
|
|
124
|
+
*
|
|
125
|
+
* That one compares `signer_pubkey` against the agent's OWN persisted `primaryPubkey`. The responder
|
|
126
|
+
* is not the signer — the assignment is signed by the INITIATOR's quorum — so it has no equivalent
|
|
127
|
+
* value, and verifying a frame's signature against a key from the same frame is circular: mint a
|
|
128
|
+
* key, sign with it, name it. That circularity is why the check was deferred rather than written.
|
|
129
|
+
*
|
|
130
|
+
* ─── What breaks the circle ────────────────────────────────────────────────────────────────────
|
|
131
|
+
*
|
|
132
|
+
* The TOFU pin. For a counterparty this agent has completed a session with, `expectedSignerHex` is
|
|
133
|
+
* what THIS daemon recorded then — a value no directory can retroactively change. Verifying against
|
|
134
|
+
* that is a real check: a compromised directory can produce a signature, but not one that verifies
|
|
135
|
+
* under a key it does not hold.
|
|
136
|
+
*
|
|
137
|
+
* ─── Two modes, and the weaker one is honest about being weaker ────────────────────────────────
|
|
138
|
+
*
|
|
139
|
+
* PINNED (`expectedSignerHex` given): the signature must verify under the PINNED key. Non-circular.
|
|
140
|
+
* This is the mode that matters, and it covers every repeat counterparty.
|
|
141
|
+
*
|
|
142
|
+
* UNPINNED (first contact): there is nothing independent to verify against, so this checks INTERNAL
|
|
143
|
+
* CONSISTENCY only — the signature verifies over the TBS recomputed from the assignment's own
|
|
144
|
+
* contents, under the key the frame names. That does NOT authenticate the directory, and it is not
|
|
145
|
+
* claimed to. What it does catch is a TAMPERED or garbage assignment — one whose fields were altered
|
|
146
|
+
* after signing, or whose signature is noise — which today reaches the seal path unchallenged and
|
|
147
|
+
* gets PINNED, poisoning every later session with that counterparty. Refusing it here is what stops
|
|
148
|
+
* a bad first contact becoming a permanent one.
|
|
149
|
+
*/
|
|
150
|
+
export function verifyInboundAssignment(assignment, expectedSignerHex) {
|
|
151
|
+
if (assignment.signature_type !== "frost") {
|
|
152
|
+
return {
|
|
153
|
+
ok: false,
|
|
154
|
+
reason: "inbound_assignment_not_frost",
|
|
155
|
+
detail: `signature_type was ${String(assignment.signature_type)}; the directory produces frost for every session assignment`,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
const signer = assignment.signer_pubkey;
|
|
159
|
+
if (!signer || signer.length !== 32) {
|
|
160
|
+
return { ok: false, reason: "inbound_assignment_no_signer", detail: "signer_pubkey missing or not 32 bytes" };
|
|
161
|
+
}
|
|
162
|
+
// PINNED beats named. When we hold an independent value, the frame's own claim about who signed is
|
|
163
|
+
// not consulted at all — using it would reintroduce the circularity the pin exists to break.
|
|
164
|
+
const verifyAgainst = expectedSignerHex !== null ? Buffer.from(expectedSignerHex, "hex") : signer;
|
|
165
|
+
if (expectedSignerHex !== null && Buffer.from(signer).toString("hex").toLowerCase() !== expectedSignerHex.toLowerCase()) {
|
|
166
|
+
return {
|
|
167
|
+
ok: false,
|
|
168
|
+
reason: "inbound_assignment_signer_not_pinned",
|
|
169
|
+
detail: "the assignment names a different signer than this agent recorded for this counterparty",
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
const genesisPrevRoot = computeGenesisPrevRoot(assignment.participant_a.pubkey, assignment.participant_b.pubkey, assignment.session_id, assignment.session_timestamp);
|
|
173
|
+
const tbs = buildSessionEstablishmentTbs(assignment.session_id, assignment.participant_a.pubkey, assignment.participant_b.pubkey, genesisPrevRoot, assignment.session_timestamp, assignment.initiator_session_peer_id, assignment.initiator_session_addrs, assignment.counterparty_session_peer_id, assignment.counterparty_session_addrs, assignment.transport_mode);
|
|
174
|
+
if (!verifyFrostSignature(assignment.directory_signature, tbs, CONTEXT_SESSION_ESTABLISHMENT, new Uint8Array(verifyAgainst))) {
|
|
175
|
+
return {
|
|
176
|
+
ok: false,
|
|
177
|
+
reason: "inbound_assignment_signature_invalid",
|
|
178
|
+
detail: expectedSignerHex !== null
|
|
179
|
+
? "the signature did not verify under the key this agent recorded for this counterparty"
|
|
180
|
+
: "the signature did not verify over the assignment's own recomputed contents — the frame is tampered or malformed",
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
return { ok: true, mode: expectedSignerHex !== null ? "pinned" : "internal" };
|
|
184
|
+
}
|
|
185
|
+
//# sourceMappingURL=assignment-verify.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"assignment-verify.js","sourceRoot":"","sources":["../src/assignment-verify.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOD-M15-BACKUP-1 — export an agent's identity, and put it back on another machine.
|
|
3
|
+
*
|
|
4
|
+
* ─── What a backup has to contain, and why the obvious answer is wrong ─────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* The DoD line says *"Backup = exporting the SQLCipher database for transport."* Exporting the
|
|
7
|
+
* database alone produces a file nobody can ever open.
|
|
8
|
+
*
|
|
9
|
+
* The database is encrypted and its key is a separate 32-byte file at `<db>.key` — `sqlcipher-db.ts`
|
|
10
|
+
* calls it "the ONE plaintext key file on disk". A fresh daemon on a new machine mints its own key,
|
|
11
|
+
* which cannot open a database encrypted under a different one. An operator restoring a
|
|
12
|
+
* database-only archive would be told it worked, and find out on the day they needed it that the
|
|
13
|
+
* contents are unreadable forever.
|
|
14
|
+
*
|
|
15
|
+
* So the archive carries BOTH, and the round-trip test restores into a directory holding a
|
|
16
|
+
* different key precisely to prove it.
|
|
17
|
+
*
|
|
18
|
+
* ─── The consequence of that, which the tool must say out loud ─────────────────────────────────
|
|
19
|
+
*
|
|
20
|
+
* A file containing that key IS the agent. Whoever holds it can sign as them, read every
|
|
21
|
+
* transcript, and use their identity. Nothing about the word "backup" suggests a file this
|
|
22
|
+
* dangerous, so the response says it at the moment one is written. `DOD-M15-CLAIM-SCREEN-1`'s rule
|
|
23
|
+
* applies to affordances as much as claims: silence is not an option.
|
|
24
|
+
*
|
|
25
|
+
* ─── Why `VACUUM INTO` rather than copying the file ────────────────────────────────────────────
|
|
26
|
+
*
|
|
27
|
+
* The daemon holds the database open with a write lock. Copying the file byte-for-byte while it is
|
|
28
|
+
* open can capture a torn page or miss a WAL frame, producing an archive that restores into a
|
|
29
|
+
* corrupt database — the worst outcome available here, because it looks like a successful backup.
|
|
30
|
+
* `VACUUM INTO` asks SQLite for a consistent snapshot, and on SQLCipher the target inherits the
|
|
31
|
+
* source's cipher settings, so the snapshot is encrypted under the same key.
|
|
32
|
+
*
|
|
33
|
+
* ─── Restore is validated in full BEFORE anything is touched ───────────────────────────────────
|
|
34
|
+
*
|
|
35
|
+
* Restore OVERWRITES. A truncated or wrong file that is accepted destroys the working agent the
|
|
36
|
+
* operator still had — the backup tool becomes the thing that loses the identity. So the container
|
|
37
|
+
* is parsed, its checksum verified and its payloads decompressed entirely in memory first; only
|
|
38
|
+
* then does anything reach the disk.
|
|
39
|
+
*
|
|
40
|
+
* ─── V1 scope, per the DoD ─────────────────────────────────────────────────────────────────────
|
|
41
|
+
*
|
|
42
|
+
* Export plus overwrite-restore. MERGE — restoring onto a device that has its own live state — is
|
|
43
|
+
* explicitly deferred, and this module does not pretend otherwise: restore replaces, and says so.
|
|
44
|
+
*/
|
|
45
|
+
import type { Logger } from "./types.js";
|
|
46
|
+
export type BackupResult = {
|
|
47
|
+
ok: true;
|
|
48
|
+
path: string;
|
|
49
|
+
bytes: number;
|
|
50
|
+
guidance: string;
|
|
51
|
+
} | {
|
|
52
|
+
ok: false;
|
|
53
|
+
reason: string;
|
|
54
|
+
guidance: string;
|
|
55
|
+
};
|
|
56
|
+
export type RestoreResult = {
|
|
57
|
+
ok: true;
|
|
58
|
+
dbPath: string;
|
|
59
|
+
guidance: string;
|
|
60
|
+
} | {
|
|
61
|
+
ok: false;
|
|
62
|
+
reason: string;
|
|
63
|
+
guidance: string;
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Take a consistent snapshot of the live database and write a portable archive.
|
|
67
|
+
*
|
|
68
|
+
* `dbPath` is the live database. It may be open in this process; `VACUUM INTO` is safe against that
|
|
69
|
+
* and is why the snapshot goes through SQLite rather than a file copy.
|
|
70
|
+
*/
|
|
71
|
+
export declare function createBackup(opts: {
|
|
72
|
+
dbPath: string;
|
|
73
|
+
outPath: string;
|
|
74
|
+
logger: Logger;
|
|
75
|
+
keyPath?: string;
|
|
76
|
+
/** A backup is not a scratch file: replacing one is deliberate, never incidental. */
|
|
77
|
+
overwrite?: boolean;
|
|
78
|
+
}): Promise<BackupResult>;
|
|
79
|
+
/** Read an archive's metadata without restoring it. */
|
|
80
|
+
export declare function inspectBackup(archivePath: string): Promise<{
|
|
81
|
+
ok: true;
|
|
82
|
+
createdAt: number;
|
|
83
|
+
hasKey: boolean;
|
|
84
|
+
dbBytes: number;
|
|
85
|
+
} | {
|
|
86
|
+
ok: false;
|
|
87
|
+
reason: string;
|
|
88
|
+
}>;
|
|
89
|
+
/**
|
|
90
|
+
* Replace this machine's database and key with the archive's.
|
|
91
|
+
*
|
|
92
|
+
* OVERWRITE, not merge — merge is explicitly deferred, and pretending otherwise would silently pick
|
|
93
|
+
* a winner between two divergent histories. The caller is responsible for the daemon not holding
|
|
94
|
+
* the database open; the handler enforces that.
|
|
95
|
+
*/
|
|
96
|
+
export declare function restoreBackup(opts: {
|
|
97
|
+
archivePath: string;
|
|
98
|
+
dbPath: string;
|
|
99
|
+
logger: Logger;
|
|
100
|
+
keyPath?: string;
|
|
101
|
+
}): Promise<RestoreResult>;
|
|
102
|
+
//# sourceMappingURL=backup-restore.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"backup-restore.d.ts","sourceRoot":"","sources":["../src/backup-restore.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AAQH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AAoCzC,MAAM,MAAM,YAAY,GACpB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAC3D;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC;AAEpD,MAAM,MAAM,aAAa,GACrB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GAC9C;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC;AAYpD;;;;;GAKG;AACH,wBAAsB,YAAY,CAAC,IAAI,EAAE;IACvC,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,qFAAqF;IACrF,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB,GAAG,OAAO,CAAC,YAAY,CAAC,CA0FxB;AAED,uDAAuD;AACvD,wBAAsB,aAAa,CACjC,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAAC,CAS5G;AA0DD;;;;;;GAMG;AACH,wBAAsB,aAAa,CAAC,IAAI,EAAE;IACxC,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,GAAG,OAAO,CAAC,aAAa,CAAC,CA6FzB"}
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOD-M15-BACKUP-1 — export an agent's identity, and put it back on another machine.
|
|
3
|
+
*
|
|
4
|
+
* ─── What a backup has to contain, and why the obvious answer is wrong ─────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* The DoD line says *"Backup = exporting the SQLCipher database for transport."* Exporting the
|
|
7
|
+
* database alone produces a file nobody can ever open.
|
|
8
|
+
*
|
|
9
|
+
* The database is encrypted and its key is a separate 32-byte file at `<db>.key` — `sqlcipher-db.ts`
|
|
10
|
+
* calls it "the ONE plaintext key file on disk". A fresh daemon on a new machine mints its own key,
|
|
11
|
+
* which cannot open a database encrypted under a different one. An operator restoring a
|
|
12
|
+
* database-only archive would be told it worked, and find out on the day they needed it that the
|
|
13
|
+
* contents are unreadable forever.
|
|
14
|
+
*
|
|
15
|
+
* So the archive carries BOTH, and the round-trip test restores into a directory holding a
|
|
16
|
+
* different key precisely to prove it.
|
|
17
|
+
*
|
|
18
|
+
* ─── The consequence of that, which the tool must say out loud ─────────────────────────────────
|
|
19
|
+
*
|
|
20
|
+
* A file containing that key IS the agent. Whoever holds it can sign as them, read every
|
|
21
|
+
* transcript, and use their identity. Nothing about the word "backup" suggests a file this
|
|
22
|
+
* dangerous, so the response says it at the moment one is written. `DOD-M15-CLAIM-SCREEN-1`'s rule
|
|
23
|
+
* applies to affordances as much as claims: silence is not an option.
|
|
24
|
+
*
|
|
25
|
+
* ─── Why `VACUUM INTO` rather than copying the file ────────────────────────────────────────────
|
|
26
|
+
*
|
|
27
|
+
* The daemon holds the database open with a write lock. Copying the file byte-for-byte while it is
|
|
28
|
+
* open can capture a torn page or miss a WAL frame, producing an archive that restores into a
|
|
29
|
+
* corrupt database — the worst outcome available here, because it looks like a successful backup.
|
|
30
|
+
* `VACUUM INTO` asks SQLite for a consistent snapshot, and on SQLCipher the target inherits the
|
|
31
|
+
* source's cipher settings, so the snapshot is encrypted under the same key.
|
|
32
|
+
*
|
|
33
|
+
* ─── Restore is validated in full BEFORE anything is touched ───────────────────────────────────
|
|
34
|
+
*
|
|
35
|
+
* Restore OVERWRITES. A truncated or wrong file that is accepted destroys the working agent the
|
|
36
|
+
* operator still had — the backup tool becomes the thing that loses the identity. So the container
|
|
37
|
+
* is parsed, its checksum verified and its payloads decompressed entirely in memory first; only
|
|
38
|
+
* then does anything reach the disk.
|
|
39
|
+
*
|
|
40
|
+
* ─── V1 scope, per the DoD ─────────────────────────────────────────────────────────────────────
|
|
41
|
+
*
|
|
42
|
+
* Export plus overwrite-restore. MERGE — restoring onto a device that has its own live state — is
|
|
43
|
+
* explicitly deferred, and this module does not pretend otherwise: restore replaces, and says so.
|
|
44
|
+
*/
|
|
45
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
46
|
+
import { gzipSync, gunzipSync } from "node:zlib";
|
|
47
|
+
import { readFile, writeFile, rm, mkdir, chmod, open as fsOpen, rename } from "node:fs/promises";
|
|
48
|
+
import { existsSync } from "node:fs";
|
|
49
|
+
import { dirname, join } from "node:path";
|
|
50
|
+
import { openEncryptedDatabase, dbKeyPathFor } from "./sqlcipher-db.js";
|
|
51
|
+
/**
|
|
52
|
+
* Write 0600, fsync, rename — and fsync the directory so the rename itself survives a power loss.
|
|
53
|
+
*
|
|
54
|
+
* Restore OVERWRITES an identity. A partially written database is the one failure that opens and is
|
|
55
|
+
* WRONG rather than failing loudly, because page 1 is enough for `sqlite_master` to read. Rename is
|
|
56
|
+
* atomic: the path is either the old file or the whole new one.
|
|
57
|
+
*/
|
|
58
|
+
async function writeFileDurably(path, bytes) {
|
|
59
|
+
const tmp = `${path}.restore-${randomBytes(6).toString("hex")}`;
|
|
60
|
+
const fh = await fsOpen(tmp, "wx", 0o600);
|
|
61
|
+
try {
|
|
62
|
+
await fh.write(bytes);
|
|
63
|
+
await fh.sync();
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
await fh.close();
|
|
67
|
+
}
|
|
68
|
+
await rename(tmp, path);
|
|
69
|
+
const dir = await fsOpen(dirname(path), "r");
|
|
70
|
+
try {
|
|
71
|
+
await dir.sync();
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
await dir.close();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Magic + version, so a wrong file is refused by shape before anything else is attempted. */
|
|
78
|
+
const MAGIC = "CELLO-BACKUP-v1";
|
|
79
|
+
/**
|
|
80
|
+
* THE SENSITIVITY NOTICE. One string, used by every path that writes an archive, so a second call
|
|
81
|
+
* site cannot ship a quieter version of it.
|
|
82
|
+
*/
|
|
83
|
+
const SENSITIVITY = "TREAT THIS FILE LIKE A PRIVATE KEY. It contains your agent's encrypted database AND the key that " +
|
|
84
|
+
"opens it, because a backup without the key restores to something nobody can read. Anyone who has " +
|
|
85
|
+
"this file can sign as your agent and read every transcript in it. Store it where you would store " +
|
|
86
|
+
"a private key — not a shared drive, not a chat message.";
|
|
87
|
+
/**
|
|
88
|
+
* Take a consistent snapshot of the live database and write a portable archive.
|
|
89
|
+
*
|
|
90
|
+
* `dbPath` is the live database. It may be open in this process; `VACUUM INTO` is safe against that
|
|
91
|
+
* and is why the snapshot goes through SQLite rather than a file copy.
|
|
92
|
+
*/
|
|
93
|
+
export async function createBackup(opts) {
|
|
94
|
+
const keyPath = opts.keyPath ?? dbKeyPathFor(opts.dbPath);
|
|
95
|
+
if (!existsSync(opts.dbPath)) {
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
reason: "database_not_found",
|
|
99
|
+
guidance: `No database at ${opts.dbPath}. Start the daemon at least once before taking a backup — there is no identity to export yet.`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
if (!existsSync(keyPath)) {
|
|
103
|
+
// Refused rather than exported-without-key: an archive that cannot be opened is worse than no
|
|
104
|
+
// archive, because the operator stops worrying about it.
|
|
105
|
+
return {
|
|
106
|
+
ok: false,
|
|
107
|
+
reason: "key_file_not_found",
|
|
108
|
+
guidance: `The database at ${opts.dbPath} has no key file at ${keyPath}. Exporting the database without it would produce a backup nobody can ever open, so nothing was written.`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
if (existsSync(opts.outPath) && opts.overwrite !== true) {
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
reason: "archive_exists",
|
|
115
|
+
guidance: `${opts.outPath} already exists. Replacing a backup silently is a way to lose an identity while believing you hold two copies of it — pass overwrite to replace it deliberately, or choose another path.`,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
// A snapshot beside the real database, then read and discard it. `VACUUM INTO` refuses to write
|
|
119
|
+
// to an existing path, so the name is fresh per call.
|
|
120
|
+
// BESIDE THE ARCHIVE, not in the shared temp dir (review F11). On macOS `$TMPDIR` is per-user
|
|
121
|
+
// 0700, but on Linux it is `/tmp` and SQLite creates the snapshot at `0644 & ~umask`. The contents
|
|
122
|
+
// are encrypted — verified empirically, the header is ciphertext and it is unopenable without the
|
|
123
|
+
// key — so that is ciphertext exposure rather than key exposure. It is still a full copy of the
|
|
124
|
+
// database, and the `finally` below only covers throws: a SIGKILL mid-backup would leave it there.
|
|
125
|
+
const snapshot = join(dirname(opts.outPath), `.cello-backup-snapshot-${randomBytes(8).toString("hex")}.db`);
|
|
126
|
+
try {
|
|
127
|
+
const key = await readFile(keyPath);
|
|
128
|
+
const db = openEncryptedDatabase(opts.dbPath, new Uint8Array(key));
|
|
129
|
+
try {
|
|
130
|
+
db.exec(`VACUUM INTO '${snapshot.replace(/'/g, "''")}'`);
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
db.close();
|
|
134
|
+
}
|
|
135
|
+
const dbBytes = await readFile(snapshot);
|
|
136
|
+
const envelope = {
|
|
137
|
+
magic: MAGIC,
|
|
138
|
+
version: 1,
|
|
139
|
+
created_at: Date.now(),
|
|
140
|
+
db_sha256: createHash("sha256").update(dbBytes).digest("hex"),
|
|
141
|
+
db_b64: dbBytes.toString("base64"),
|
|
142
|
+
key_b64: key.toString("base64"),
|
|
143
|
+
};
|
|
144
|
+
const packed = gzipSync(Buffer.from(JSON.stringify(envelope), "utf8"));
|
|
145
|
+
await writeFile(opts.outPath, packed, { mode: 0o600 });
|
|
146
|
+
/**
|
|
147
|
+
* CHMOD AFTER THE WRITE, because `mode` is honoured only at `O_CREAT` — review F5, measured on
|
|
148
|
+
* this machine:
|
|
149
|
+
*
|
|
150
|
+
* existing 0644 file + writeFile(mode 0600) → still 0644
|
|
151
|
+
* existing 0666 file + writeFile(mode 0600) → still 0666
|
|
152
|
+
* fresh file → 0600
|
|
153
|
+
*
|
|
154
|
+
* So the `--force` path — overwriting yesterday's archive — kept whatever mode was already
|
|
155
|
+
* there. A file that arrived at 0644 (scp, a shell redirect under umask 022, an iCloud
|
|
156
|
+
* round-trip) would hold a WORLD-READABLE plaintext signing key. The comment claimed the
|
|
157
|
+
* property; only the create path enforced it.
|
|
158
|
+
*/
|
|
159
|
+
await chmod(opts.outPath, 0o600);
|
|
160
|
+
opts.logger.info("agent.backup.written", {
|
|
161
|
+
path: opts.outPath,
|
|
162
|
+
bytes: packed.length,
|
|
163
|
+
impact: "the agent's identity, key shares, contacts, transcripts and seals are now exportable to another machine",
|
|
164
|
+
guidance: SENSITIVITY,
|
|
165
|
+
});
|
|
166
|
+
return { ok: true, path: opts.outPath, bytes: packed.length, guidance: SENSITIVITY };
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
170
|
+
opts.logger.error("agent.backup.failed", { path: opts.outPath, reason });
|
|
171
|
+
return {
|
|
172
|
+
ok: false,
|
|
173
|
+
reason: "backup_failed",
|
|
174
|
+
guidance: `The backup could not be written: ${reason}. Nothing was changed on this machine.`,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
await rm(snapshot, { force: true }).catch(() => { });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/** Read an archive's metadata without restoring it. */
|
|
182
|
+
export async function inspectBackup(archivePath) {
|
|
183
|
+
const parsed = await parseArchive(archivePath);
|
|
184
|
+
if (!parsed.ok)
|
|
185
|
+
return { ok: false, reason: parsed.reason };
|
|
186
|
+
return {
|
|
187
|
+
ok: true,
|
|
188
|
+
createdAt: parsed.envelope.created_at,
|
|
189
|
+
hasKey: parsed.envelope.key_b64.length > 0,
|
|
190
|
+
dbBytes: parsed.dbBytes.length,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Parse and FULLY VALIDATE an archive in memory.
|
|
195
|
+
*
|
|
196
|
+
* Everything that can fail happens here, before `restoreBackup` touches the disk. That ordering is
|
|
197
|
+
* the whole safety property: a restore that fails must leave the machine exactly as it was.
|
|
198
|
+
*/
|
|
199
|
+
async function parseArchive(archivePath) {
|
|
200
|
+
if (!existsSync(archivePath))
|
|
201
|
+
return { ok: false, reason: "archive_not_found" };
|
|
202
|
+
let raw;
|
|
203
|
+
try {
|
|
204
|
+
raw = await readFile(archivePath);
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
return { ok: false, reason: `archive_unreadable: ${err instanceof Error ? err.message : String(err)}` };
|
|
208
|
+
}
|
|
209
|
+
let json;
|
|
210
|
+
try {
|
|
211
|
+
json = gunzipSync(raw).toString("utf8");
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
// A truncated file usually dies here — gzip carries its own length and CRC, which is why the
|
|
215
|
+
// container is compressed rather than raw JSON.
|
|
216
|
+
return { ok: false, reason: "archive_corrupt_or_not_a_cello_backup" };
|
|
217
|
+
}
|
|
218
|
+
let envelope;
|
|
219
|
+
try {
|
|
220
|
+
envelope = JSON.parse(json);
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return { ok: false, reason: "archive_corrupt_or_not_a_cello_backup" };
|
|
224
|
+
}
|
|
225
|
+
if (envelope.magic !== MAGIC || envelope.version !== 1) {
|
|
226
|
+
return { ok: false, reason: "archive_wrong_format_or_version" };
|
|
227
|
+
}
|
|
228
|
+
if (typeof envelope.db_b64 !== "string" || typeof envelope.key_b64 !== "string") {
|
|
229
|
+
return { ok: false, reason: "archive_missing_payload" };
|
|
230
|
+
}
|
|
231
|
+
const dbBytes = Buffer.from(envelope.db_b64, "base64");
|
|
232
|
+
const keyBytes = Buffer.from(envelope.key_b64, "base64");
|
|
233
|
+
if (dbBytes.length === 0)
|
|
234
|
+
return { ok: false, reason: "archive_missing_database" };
|
|
235
|
+
if (keyBytes.length !== 32) {
|
|
236
|
+
// Without a well-formed key the restored database is unopenable — the exact failure this unit
|
|
237
|
+
// exists to prevent, so it is refused rather than restored hopefully.
|
|
238
|
+
return { ok: false, reason: "archive_missing_or_malformed_key" };
|
|
239
|
+
}
|
|
240
|
+
const actual = createHash("sha256").update(dbBytes).digest("hex");
|
|
241
|
+
if (actual !== envelope.db_sha256)
|
|
242
|
+
return { ok: false, reason: "archive_checksum_mismatch" };
|
|
243
|
+
return { ok: true, envelope, dbBytes, keyBytes };
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Replace this machine's database and key with the archive's.
|
|
247
|
+
*
|
|
248
|
+
* OVERWRITE, not merge — merge is explicitly deferred, and pretending otherwise would silently pick
|
|
249
|
+
* a winner between two divergent histories. The caller is responsible for the daemon not holding
|
|
250
|
+
* the database open; the handler enforces that.
|
|
251
|
+
*/
|
|
252
|
+
export async function restoreBackup(opts) {
|
|
253
|
+
const keyPath = opts.keyPath ?? dbKeyPathFor(opts.dbPath);
|
|
254
|
+
const parsed = await parseArchive(opts.archivePath);
|
|
255
|
+
if (!parsed.ok) {
|
|
256
|
+
opts.logger.error("agent.restore.refused", {
|
|
257
|
+
archivePath: opts.archivePath,
|
|
258
|
+
reason: parsed.reason,
|
|
259
|
+
impact: "nothing was written — the agent currently on this machine is untouched",
|
|
260
|
+
});
|
|
261
|
+
return {
|
|
262
|
+
ok: false,
|
|
263
|
+
reason: parsed.reason,
|
|
264
|
+
guidance: `That file was not restored: ${parsed.reason}. NOTHING on this machine was changed — the ` +
|
|
265
|
+
`archive is validated completely before anything is written, because a restore overwrites ` +
|
|
266
|
+
`and a bad archive must never be able to destroy the agent you still have.`,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
// 0700, matching `resolveDbKey`'s own mkdir. On a fresh machine THIS is what creates the CELLO
|
|
271
|
+
// directory, and it holds a signing key (review F12).
|
|
272
|
+
await mkdir(dirname(opts.dbPath), { recursive: true, mode: 0o700 });
|
|
273
|
+
/**
|
|
274
|
+
* ORDER AND ATOMICITY — review F6/F7, and the previous comment here had it backwards.
|
|
275
|
+
*
|
|
276
|
+
* It said "key first, because a database without its key is the unopenable state". On a machine
|
|
277
|
+
* that ALREADY has an agent that is true but harmless — a key/db mismatch throws
|
|
278
|
+
* `db_encryption_key_mismatch`, loudly, and re-running the restore fixes it.
|
|
279
|
+
*
|
|
280
|
+
* On a FRESH machine — the actual restore use case — key-first is the dangerous order. Crash
|
|
281
|
+
* between the two writes and the key is present with NO database, so `resolveDbKey` loads the
|
|
282
|
+
* key and SQLCipher CREATES A FRESH EMPTY ONE. The daemon boots clean with zero agents and says
|
|
283
|
+
* nothing. That is the only crash state that opens, is wrong, and is silent.
|
|
284
|
+
*
|
|
285
|
+
* Worse, a crash MID-write left a truncated database whose page 1 was present, so
|
|
286
|
+
* `sqlite_master` read and the open SUCCEEDED against a partial file.
|
|
287
|
+
*
|
|
288
|
+
* Both are closed by writing to a temp sibling, fsync'ing, and renaming — rename is atomic, so
|
|
289
|
+
* the file either is the old one or is the whole new one, never half. The sidecars go first:
|
|
290
|
+
* stale WAL frames encrypted under the OLD key must not be present when the new database
|
|
291
|
+
* appears. Then the database, then the key — so the surviving crash state is db-present /
|
|
292
|
+
* key-absent, which `resolveDbKey` refuses loudly by design.
|
|
293
|
+
*
|
|
294
|
+
* The key write is fsync'd for the reason `sqlcipher-db.ts` gives where it writes the same file:
|
|
295
|
+
* "so a crash right after a fresh install can't lose the key while the encrypted DB exists".
|
|
296
|
+
* Returning ok:true with the key only in page cache re-opens that hole.
|
|
297
|
+
*/
|
|
298
|
+
await rm(`${opts.dbPath}-wal`, { force: true }).catch(() => { });
|
|
299
|
+
await rm(`${opts.dbPath}-shm`, { force: true }).catch(() => { });
|
|
300
|
+
await writeFileDurably(opts.dbPath, parsed.dbBytes);
|
|
301
|
+
await writeFileDurably(keyPath, parsed.keyBytes);
|
|
302
|
+
}
|
|
303
|
+
catch (err) {
|
|
304
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
305
|
+
opts.logger.error("agent.restore.failed", { archivePath: opts.archivePath, reason });
|
|
306
|
+
return {
|
|
307
|
+
ok: false,
|
|
308
|
+
reason: "restore_write_failed",
|
|
309
|
+
guidance: `The restore could not complete: ${reason}. The database may be in a partial state — restore again from the same archive before starting the daemon.`,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
// Prove it opens, here, rather than letting the daemon discover it at next boot.
|
|
313
|
+
try {
|
|
314
|
+
const db = openEncryptedDatabase(opts.dbPath, new Uint8Array(parsed.keyBytes));
|
|
315
|
+
db.close();
|
|
316
|
+
}
|
|
317
|
+
catch (err) {
|
|
318
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
319
|
+
opts.logger.error("agent.restore.unopenable", { dbPath: opts.dbPath, reason });
|
|
320
|
+
return {
|
|
321
|
+
ok: false,
|
|
322
|
+
reason: "restored_database_unopenable",
|
|
323
|
+
guidance: `The archive was written but the restored database could not be opened: ${reason}. This should not happen — the archive carries its own key. Keep the archive and report it.`,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
const createdAt = new Date(parsed.envelope.created_at).toISOString();
|
|
327
|
+
opts.logger.info("agent.restore.completed", {
|
|
328
|
+
dbPath: opts.dbPath,
|
|
329
|
+
backupCreatedAt: createdAt,
|
|
330
|
+
impact: "this machine's agent database and key were REPLACED by the archive's",
|
|
331
|
+
});
|
|
332
|
+
return {
|
|
333
|
+
ok: true,
|
|
334
|
+
dbPath: opts.dbPath,
|
|
335
|
+
guidance: `Restored from a backup taken ${createdAt}. This machine's previous agent database was ` +
|
|
336
|
+
`REPLACED, not merged — anything that happened here since that backup is gone. Restart the ` +
|
|
337
|
+
`daemon (cello logout && cello login) so it opens the restored database.`,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
//# sourceMappingURL=backup-restore.js.map
|