@sentropic/h2a 0.96.1 → 0.96.2
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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +74 -27
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/runtime/drive/cluster-mesh-outer.d.ts +44 -0
- package/dist/runtime/drive/cluster-mesh-outer.d.ts.map +1 -0
- package/dist/runtime/drive/cluster-mesh-outer.js +70 -0
- package/dist/runtime/drive/cluster-mesh-outer.js.map +1 -0
- package/dist/runtime/drive/pty-actuator.d.ts +54 -0
- package/dist/runtime/drive/pty-actuator.d.ts.map +1 -0
- package/dist/runtime/drive/pty-actuator.js +371 -0
- package/dist/runtime/drive/pty-actuator.js.map +1 -0
- package/dist/runtime/local-files/paths.js +1 -1
- package/dist/runtime/local-files/paths.js.map +1 -1
- package/dist/runtime/local-files/presence.d.ts.map +1 -1
- package/dist/runtime/local-files/presence.js +10 -1
- package/dist/runtime/local-files/presence.js.map +1 -1
- package/dist/runtime/mcp/agent-launch.d.ts +2 -1
- package/dist/runtime/mcp/agent-launch.d.ts.map +1 -1
- package/dist/runtime/mcp/agent-launch.js +48 -8
- package/dist/runtime/mcp/agent-launch.js.map +1 -1
- package/dist/runtime/mcp/tools.d.ts.map +1 -1
- package/dist/runtime/mcp/tools.js +19 -6
- package/dist/runtime/mcp/tools.js.map +1 -1
- package/dist/runtime/memory/admit-client.d.ts +30 -0
- package/dist/runtime/memory/admit-client.d.ts.map +1 -0
- package/dist/runtime/memory/admit-client.js +44 -0
- package/dist/runtime/memory/admit-client.js.map +1 -0
- package/dist/runtime/memory/d11-ceremony.d.ts +731 -0
- package/dist/runtime/memory/d11-ceremony.d.ts.map +1 -0
- package/dist/runtime/memory/d11-ceremony.js +1282 -0
- package/dist/runtime/memory/d11-ceremony.js.map +1 -0
- package/dist/runtime/memory/graphify-contracts-v2.vendored.d.ts +922 -0
- package/dist/runtime/memory/graphify-contracts-v2.vendored.d.ts.map +1 -0
- package/dist/runtime/memory/graphify-contracts-v2.vendored.js +2 -0
- package/dist/runtime/memory/graphify-contracts-v2.vendored.js.map +1 -0
- package/dist/runtime/memory/note-builder.d.ts +83 -0
- package/dist/runtime/memory/note-builder.d.ts.map +1 -0
- package/dist/runtime/memory/note-builder.js +89 -0
- package/dist/runtime/memory/note-builder.js.map +1 -0
- package/dist/runtime/memory/port-v1.d.ts +235 -0
- package/dist/runtime/memory/port-v1.d.ts.map +1 -0
- package/dist/runtime/memory/port-v1.js +129 -0
- package/dist/runtime/memory/port-v1.js.map +1 -0
- package/dist/runtime/memory/port-v2-adapter.d.ts +117 -0
- package/dist/runtime/memory/port-v2-adapter.d.ts.map +1 -0
- package/dist/runtime/memory/port-v2-adapter.js +140 -0
- package/dist/runtime/memory/port-v2-adapter.js.map +1 -0
- package/dist/runtime/memory/port-v2-compat.d.ts +50 -0
- package/dist/runtime/memory/port-v2-compat.d.ts.map +1 -0
- package/dist/runtime/memory/port-v2-compat.js +102 -0
- package/dist/runtime/memory/port-v2-compat.js.map +1 -0
- package/dist/runtime/memory/preflight.d.ts +17 -0
- package/dist/runtime/memory/preflight.d.ts.map +1 -0
- package/dist/runtime/memory/preflight.js +18 -0
- package/dist/runtime/memory/preflight.js.map +1 -0
- package/dist/runtime/memory/promote-client.d.ts +172 -0
- package/dist/runtime/memory/promote-client.d.ts.map +1 -0
- package/dist/runtime/memory/promote-client.js +168 -0
- package/dist/runtime/memory/promote-client.js.map +1 -0
- package/dist/runtime/memory/recall-client.d.ts +74 -0
- package/dist/runtime/memory/recall-client.d.ts.map +1 -0
- package/dist/runtime/memory/recall-client.js +91 -0
- package/dist/runtime/memory/recall-client.js.map +1 -0
- package/package.json +4 -1
- package/skills/h2a-run/SKILL.md +39 -6
|
@@ -0,0 +1,1282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D11 ceremony orchestrator — WP11 slice 5 (build brief). Anti-fabrication
|
|
3
|
+
* FIXED in two rounds, each closing a hole an independent review NO-GO'd.
|
|
4
|
+
* See "D11 FIX — ROUND 1" and "D11 FIX — ROUND 2" below for what changed, in
|
|
5
|
+
* order, and why; the rest of this doc describes the ceremony's unchanged
|
|
6
|
+
* shape.
|
|
7
|
+
*
|
|
8
|
+
* Composes the double-consensus END TO END: launch the two independent legs,
|
|
9
|
+
* collect their INLINE verdicts, gate on slice 3's `checkDoubleConsensusPreconditions`
|
|
10
|
+
* (the SAME structural check, not a reimplementation) as a cheap pre-write
|
|
11
|
+
* reject, write the verdict + attestation artifacts, READ THEM BACK from a
|
|
12
|
+
* REAL, construction-time-bound durable store and cryptographically verify
|
|
13
|
+
* them (ROUND 1 + ROUND 2 together), and only then dispatch through
|
|
14
|
+
* `promoteNoteWithDoubleConsensus` — now fed the READ, VERIFIED verdicts,
|
|
15
|
+
* never the caller-controlled inline ones.
|
|
16
|
+
*
|
|
17
|
+
* SCOPE (bounded), same shape as the rest of `runtime/memory/`: the actual
|
|
18
|
+
* model-leg launching and the verdict/attestation FILE WRITES are INJECTED
|
|
19
|
+
* (`deps.launchLeg`, `deps.writeVerdict`, `deps.writeAttestation`,
|
|
20
|
+
* `deps.port`) and stubbed in tests — never implemented for real here. This
|
|
21
|
+
* module is the ORCHESTRATION only: what gets called, in what order, and
|
|
22
|
+
* what refuses the ceremony before the next step ever runs. Real Ed25519
|
|
23
|
+
* signature verification (`verifySignature`, the default), the concrete
|
|
24
|
+
* `trustedKeystore` backing store, AND (ROUND 2) the default verdict-artifact
|
|
25
|
+
* READER (`readVerdict`, a real path-bound filesystem reader) ARE
|
|
26
|
+
* implemented for real — crypto, and "read back exactly what is actually
|
|
27
|
+
* persisted", are not "out of scope, inject a stub" concerns the way
|
|
28
|
+
* launching a model or writing a file are. The keystore's actual key
|
|
29
|
+
* material/distribution and the durable store's actual deployment location
|
|
30
|
+
* remain a WP5 concern (see ROUND 1 §1 and ROUND 2 below).
|
|
31
|
+
*
|
|
32
|
+
* ===========================================================================
|
|
33
|
+
* D11 FIX — ROUND 1 — the anti-fabrication anchor (signature verification).
|
|
34
|
+
* ===========================================================================
|
|
35
|
+
*
|
|
36
|
+
* THE HOLE (proven by an independent review): before this fix, `launchLeg`
|
|
37
|
+
* returned verdicts INLINE (caller-controlled), `writeVerdict` returned ref
|
|
38
|
+
* strings that were NEVER READ BACK, and the consensus gate checked only the
|
|
39
|
+
* caller-supplied inline verdicts. A `writeVerdict` that returns invented ref
|
|
40
|
+
* strings without ever creating a file still reached `promoteNote` with
|
|
41
|
+
* `promoted: true` — a caller fabricating "2 GO" verdicts as plain JavaScript
|
|
42
|
+
* objects could promote a note with no real evidence anywhere. This is the
|
|
43
|
+
* classic fabricated-verdict hole: an assertion was trusted where a document
|
|
44
|
+
* should have been demanded.
|
|
45
|
+
*
|
|
46
|
+
* THE FIX, in three parts:
|
|
47
|
+
*
|
|
48
|
+
* 1. `createD11Ceremony({ trustedKeystore, verifySignature? })` — a FACTORY
|
|
49
|
+
* that CLOSES OVER the trusted keystore (and, optionally, the signature
|
|
50
|
+
* verifier). It returns the actual `runD11Ceremony(input, deps)` function.
|
|
51
|
+
* This is the load-bearing design choice: `trustedKeystore` is a
|
|
52
|
+
* CONSTRUCTION-TIME input, never part of the per-call `RunD11CeremonyDeps`.
|
|
53
|
+
* At the time of THIS round, `readVerdict` and `launchLeg` were BOTH still
|
|
54
|
+
* per-call (caller-injected, untrusted); if signature verification used a
|
|
55
|
+
* per-call-supplied keystore too, a caller could simply hand the ceremony
|
|
56
|
+
* ITS OWN keystore (mapping its own throwaway key to whatever leg it
|
|
57
|
+
* likes) and sign its own fabrication — the hole would just move one layer
|
|
58
|
+
* down. The keystore must come from a trusted CONSTRUCTION site (a
|
|
59
|
+
* conductor/bootstrap that wires `createD11Ceremony` once, not a value an
|
|
60
|
+
* arbitrary per-call caller of the returned `runD11Ceremony` can swap).
|
|
61
|
+
* `trustedKeystore` is documented here as the LOCAL anchor for today
|
|
62
|
+
* (an in-process map/interface from leg identity to an Ed25519 public
|
|
63
|
+
* key); it is designed to be swapped later for the WP5 system-wide
|
|
64
|
+
* identity keystore WITHOUT a rewrite of this module — `D11TrustedKeystore`
|
|
65
|
+
* is a one-method lookup interface (`getPublicKey(leg)`) precisely so a
|
|
66
|
+
* different backing store (network-fetched, rotated, cross-repo) can
|
|
67
|
+
* implement it later; the ceremony's own logic never changes.
|
|
68
|
+
*
|
|
69
|
+
* 2. `readVerdict(ref) => Promise<VerdictArtifact | null>` — at THIS round, a
|
|
70
|
+
* per-call dep. It OPENS+READS the verdict artifact actually persisted at
|
|
71
|
+
* `ref` (or returns `null` if absent/unreadable — the exact shape of the
|
|
72
|
+
* proven counter-example: a `writeVerdict` that never created a file).
|
|
73
|
+
* Distinct from `MemoryVerdict` (slice 3's in-memory, caller-asserted
|
|
74
|
+
* shape): `VerdictArtifact` additionally carries a `signature` — proof the
|
|
75
|
+
* artifact was actually produced by whoever holds the claimed leg's
|
|
76
|
+
* private key, not merely narrated by whoever called this ceremony.
|
|
77
|
+
* ROUND 2 (below) moves this dep to construction-time too — read that
|
|
78
|
+
* section for why leaving it per-call was itself still a hole.
|
|
79
|
+
*
|
|
80
|
+
* 3. The gate (fail-closed at every step, `promoteNote` NEVER reached on any
|
|
81
|
+
* failure): after `launchLeg` → `writeVerdict` produce the two refs, for
|
|
82
|
+
* EACH ref —
|
|
83
|
+
* a. `readVerdict(ref)` → REFUSE if `null` (closes the proven
|
|
84
|
+
* counter-example structurally: an invented ref with no real file).
|
|
85
|
+
* b. Look up the trusted public key for the artifact's OWN CLAIMED
|
|
86
|
+
* `leg` (`trustedKeystore.getPublicKey(artifact.leg)`) — REFUSE if
|
|
87
|
+
* the keystore has no entry (unknown leg OR an empty keystore; a
|
|
88
|
+
* missing key is never treated as "skip verification", it is always
|
|
89
|
+
* a refusal — I5 fail-closed applies to the keystore lookup too).
|
|
90
|
+
* Verify the artifact's `signature` against THAT key over the exact
|
|
91
|
+
* payload `{noteId, verdict, leg, at}` — REFUSE if invalid. Because
|
|
92
|
+
* the key is looked up BY THE CLAIMED LEG, a verdict that declares
|
|
93
|
+
* leg A but was actually signed with leg B's key (even though B's
|
|
94
|
+
* key IS in the keystore) fails here: leg A's public key cannot
|
|
95
|
+
* verify a signature produced by leg B's private key.
|
|
96
|
+
* c. REFUSE unless the READ `verdict === "GO"`.
|
|
97
|
+
* d. REFUSE unless the READ `noteId` equals the note actually being
|
|
98
|
+
* promoted (`input.note.noteId`) — ANTI-REPLAY. The signed payload
|
|
99
|
+
* in (b) already binds `noteId` into what the signature covers, so a
|
|
100
|
+
* genuinely-signed verdict for note X cannot be replayed to promote
|
|
101
|
+
* note Y: either its `noteId` fails this pin (this check), or an
|
|
102
|
+
* attacker who edits the `noteId` field to match Y invalidates the
|
|
103
|
+
* signature (check (b) fires instead). Either way: REFUSE.
|
|
104
|
+
* Then, across the two READ artifacts (off the READ content only, never
|
|
105
|
+
* the inline verdicts or the legSpecs):
|
|
106
|
+
* e. REFUSE if the two artifacts' `leg`s are not structurally distinct.
|
|
107
|
+
* f. REFUSE if either artifact's `leg.session === authorId` (separation
|
|
108
|
+
* of powers, re-checked off what was actually read — a `readVerdict`
|
|
109
|
+
* that returns a different leg than the one that was launched is
|
|
110
|
+
* still caught here).
|
|
111
|
+
* Then, per artifact again:
|
|
112
|
+
* g. REFUSE unless the READ content is COHERENT with the INLINE verdict
|
|
113
|
+
* `launchLeg` returned for that same position (`noteId`, `verdict`,
|
|
114
|
+
* `leg` must all match) — a mismatch means what got signed/persisted
|
|
115
|
+
* disagrees with what the leg claimed inline, which is refused
|
|
116
|
+
* rather than silently preferring one over the other.
|
|
117
|
+
* Only if ALL of (a)-(g) pass for both legs: the FINAL verdicts and
|
|
118
|
+
* attestation fed to `promoteNoteWithDoubleConsensus` are built from the
|
|
119
|
+
* READ, VERIFIED artifacts (never the inline ones) — so even a bug in this
|
|
120
|
+
* module's own gate logic cannot cause a fabricated inline verdict to
|
|
121
|
+
* reach the port, because the inline verdicts are never what gets
|
|
122
|
+
* promoted. `promoteNoteWithDoubleConsensus` re-runs
|
|
123
|
+
* `checkDoubleConsensusPreconditions` on that verified data as an
|
|
124
|
+
* unconditional second layer (unchanged from slice 3) before ever
|
|
125
|
+
* touching the port.
|
|
126
|
+
*
|
|
127
|
+
* ===========================================================================
|
|
128
|
+
* D11 FIX — ROUND 2 (this build) — `readVerdict` moves to construction time.
|
|
129
|
+
* ===========================================================================
|
|
130
|
+
*
|
|
131
|
+
* THE HOLE ROUND 1 LEFT (proven by an independent review, a SECOND, separate
|
|
132
|
+
* leg from the one that forced ROUND 1): ROUND 1 moved `trustedKeystore` and
|
|
133
|
+
* `verifySignature` to construction-time, but left `readVerdict` as a
|
|
134
|
+
* PER-CALL, caller-injected dep. An adversarial per-call caller can inject a
|
|
135
|
+
* `readVerdict` that returns a NON-NULL, TRUSTED-SIGNED artifact that exists
|
|
136
|
+
* ONLY in memory — a ref string pointing at NO durable file anywhere — and
|
|
137
|
+
* the gate above (§3a) only refuses `null`; a fabricated-but-signed in-memory
|
|
138
|
+
* object sails through every check in ROUND 1 §3. ROUND 1's signature
|
|
139
|
+
* verification only actually defends anything IF the per-call caller lacks
|
|
140
|
+
* the trusted legs' private keys — but nothing in this module, or in what it
|
|
141
|
+
* composes, ESTABLISHES that. Key custody is a WP5 wiring concern that is not
|
|
142
|
+
* yet specified; assuming the caller can't sign is exactly the kind of
|
|
143
|
+
* unverified assumption D11 exists to eliminate. We do not rely on it here.
|
|
144
|
+
* The deeper problem: a trust-critical READER that the untrusted per-call
|
|
145
|
+
* caller can inject is an EMPTY ANCHOR — no matter how strong the
|
|
146
|
+
* verification logic downstream of it is, the caller controls whether that
|
|
147
|
+
* logic ever sees real data at all. This is the same injected-deps lesson
|
|
148
|
+
* ROUND 1 only half-applied (it anchored the VERIFIER but not the READER).
|
|
149
|
+
*
|
|
150
|
+
* THE FIX:
|
|
151
|
+
*
|
|
152
|
+
* 1. `readVerdict` moves from `RunD11CeremonyDeps` (per-call) to
|
|
153
|
+
* `CreateD11CeremonyOptions` (construction-time) — EXACTLY like
|
|
154
|
+
* `trustedKeystore` and `verifySignature`, and for the identical reason:
|
|
155
|
+
* the per-call caller of the returned `runD11Ceremony(input, deps)` is the
|
|
156
|
+
* UNTRUSTED adversary this whole ceremony exists to defend against. A
|
|
157
|
+
* trust-critical dependency that adversary can inject or override is not
|
|
158
|
+
* a defense, it is a hook for the exact fabrication being defended
|
|
159
|
+
* against. `RunD11CeremonyDeps` no longer has a `readVerdict` field at
|
|
160
|
+
* all (a type-level guarantee, not merely a runtime check) — and even if
|
|
161
|
+
* a caller attaches an extra `readVerdict` property to the `deps` object
|
|
162
|
+
* at runtime anyway (JS does not enforce the TS shape), `runD11Ceremony`
|
|
163
|
+
* never reads `deps.readVerdict` — it always uses the reader CLOSED OVER
|
|
164
|
+
* at construction. Proven in tests: an attached-but-unused
|
|
165
|
+
* `deps.readVerdict` records zero calls.
|
|
166
|
+
*
|
|
167
|
+
* 2. The DEFAULT construction-time reader, `defaultReadVerdict`, is a REAL
|
|
168
|
+
* in-module durable-store reader with PATH-BINDING: `ref` IS a filesystem
|
|
169
|
+
* path, and the reader does a genuine `readFile` at exactly that path,
|
|
170
|
+
* then JSON-parses and shape-validates the result. It returns `null` on
|
|
171
|
+
* ANY failure — missing file, unreadable, malformed JSON, wrong shape —
|
|
172
|
+
* which is exactly ROUND 1's proven counter-example (an invented ref with
|
|
173
|
+
* no real file behind it) and stays refused for the identical reason.
|
|
174
|
+
* Because the reader always dereferences the REAL filesystem at the ref's
|
|
175
|
+
* own path, what it returns is BOUND to that ref — it is literally what
|
|
176
|
+
* is stored there — never a caller-fabricated in-memory object: there is
|
|
177
|
+
* no code path by which an in-memory `VerdictArtifact`, however validly
|
|
178
|
+
* signed, can reach `promoteNote` without first being real bytes on disk
|
|
179
|
+
* at the exact path the ceremony itself derived from `writeVerdict`'s
|
|
180
|
+
* return value. `readVerdict` remains overridable AT CONSTRUCTION for
|
|
181
|
+
* tests only — a test may supply a fake store-reader (e.g. an in-memory
|
|
182
|
+
* `Map<ref, VerdictArtifact>`) to `createD11Ceremony({ readVerdict: ... })`
|
|
183
|
+
* so tests don't need real disk I/O for every scenario — but that
|
|
184
|
+
* override is wired ONCE, at the same trusted construction site as
|
|
185
|
+
* `trustedKeystore`, never by the per-call caller under test.
|
|
186
|
+
*
|
|
187
|
+
* 3. Everything from ROUND 1 §3 is UNCHANGED: signature-by-claimed-leg,
|
|
188
|
+
* GO, anti-replay (noteId pin), cross-artifact distinctness, separation
|
|
189
|
+
* of powers off the READ content, and coherence with the inline verdict.
|
|
190
|
+
* ROUND 2 only changes WHERE `readVerdict` comes from and WHAT its
|
|
191
|
+
* default implementation actually does; it adds no new gate steps because
|
|
192
|
+
* none were missing — the missing piece was that the existing gate could
|
|
193
|
+
* be handed fabricated input before it ever ran.
|
|
194
|
+
*
|
|
195
|
+
* WHAT THIS DOES NOT CLAIM (residual, stated honestly): the CONSTRUCTION
|
|
196
|
+
* SITE — whoever calls `createD11Ceremony({ trustedKeystore, readVerdict })`
|
|
197
|
+
* — remains a trusted wiring point (WP5 domain, unchanged from ROUND 1 §1):
|
|
198
|
+
* its choice of keystore, its choice of durable-store reader (or acceptance
|
|
199
|
+
* of the real filesystem default), and the actual key material/store
|
|
200
|
+
* deployment behind both are not re-verified by this module. That is ONE
|
|
201
|
+
* trusted wiring point exercised once at bootstrap, not any of the
|
|
202
|
+
* potentially many per-call callers of the returned `runD11Ceremony` — a
|
|
203
|
+
* much smaller, much more auditable surface than ROUND 1 left. This module
|
|
204
|
+
* defends against a per-call caller fabricating a plausible-looking verdict,
|
|
205
|
+
* signed or not, durable or not; it does not defend against a compromised
|
|
206
|
+
* private key for a leg genuinely registered in the trusted keystore, a
|
|
207
|
+
* compromised/malicious construction site, or a durable store an attacker
|
|
208
|
+
* can write to directly (bypassing `writeVerdict`) — those remain
|
|
209
|
+
* key-management, wiring, and storage-access concerns, not gaps in this
|
|
210
|
+
* gate.
|
|
211
|
+
*
|
|
212
|
+
* ===========================================================================
|
|
213
|
+
* D11 FIX — ROUND 3 (this build) — `defaultReadVerdict` is now PATH-CONFINED.
|
|
214
|
+
* ===========================================================================
|
|
215
|
+
*
|
|
216
|
+
* THE HOLE ROUND 2 LEFT (proven by a THIRD, independent review, a separate
|
|
217
|
+
* leg from the two that forced ROUNDS 1 and 2): ROUND 2 moved `readVerdict`
|
|
218
|
+
* to construction time, closing the "fabricated in-memory artifact" hole.
|
|
219
|
+
* But the DEFAULT construction-time reader, `defaultReadVerdict`, still did
|
|
220
|
+
* a plain `readFile(ref)` — and `ref` is `writeVerdict`'s PER-CALL,
|
|
221
|
+
* caller-controlled return value. Moving the READER to construction time
|
|
222
|
+
* defends against a caller substituting a DIFFERENT reader; it does nothing
|
|
223
|
+
* to confine WHERE the real, trusted, construction-time reader looks, when
|
|
224
|
+
* that location is still a bare string the untrusted per-call caller
|
|
225
|
+
* chose. Three attacks reached `promoteNote` with `promoted: true` at
|
|
226
|
+
* ROUND 2 (@824b633e):
|
|
227
|
+
* 1. TRAVERSAL — `writeVerdict` returns a `../../…` ref pointing at some
|
|
228
|
+
* OTHER, genuinely signed verdict file outside the intended zone (e.g.
|
|
229
|
+
* one written by a real leg for a DIFFERENT ceremony run entirely).
|
|
230
|
+
* 2. SYMLINK — `writeVerdict` "writes" its ref location as a symlink to
|
|
231
|
+
* an attacker-controlled file (or makes an ancestor directory a
|
|
232
|
+
* symlink); `readFile` follows it transparently.
|
|
233
|
+
* 3. TOCTOU — `writeVerdict`'s ref looks confined and could even pass a
|
|
234
|
+
* naive check, but the file at that path is swapped between the
|
|
235
|
+
* moment any confinement check runs and the moment bytes are actually
|
|
236
|
+
* read — a real risk IF that check and that read are two separate
|
|
237
|
+
* pathname-based filesystem operations ("realpath(ref), then
|
|
238
|
+
* separately re-open(ref)" is itself racy this way).
|
|
239
|
+
*
|
|
240
|
+
* THE FIX, REUSING the path-confinement technique already proven and MERGED
|
|
241
|
+
* in this repo — `packages/h2a-runtime/src/identity-cull/cull.ts` (PR
|
|
242
|
+
* #160): `realpathSync` canonicalization of a TRUSTED root,
|
|
243
|
+
* `openSync(..., O_RDONLY | O_DIRECTORY | O_NOFOLLOW)` descriptor-relative
|
|
244
|
+
* directory walking that refuses a symlink at ANY path component, and — the
|
|
245
|
+
* TOCTOU-closing move — a SINGLE held file descriptor carried from open
|
|
246
|
+
* through the `fstatSync` regular-file check to the actual read, never a
|
|
247
|
+
* second, pathname-based open. This module reimplements the same
|
|
248
|
+
* primitives locally (`h2a` does not depend on `h2a-runtime` at build time;
|
|
249
|
+
* the peer dependency is optional) — the TECHNIQUE is reused, not
|
|
250
|
+
* reinvented, per that module's own comments on the same primitives.
|
|
251
|
+
*
|
|
252
|
+
* 1. `createD11Ceremony({ ..., authorizedRoot })` — a new construction-time
|
|
253
|
+
* option, REQUIRED whenever the REAL default reader is in use (no custom
|
|
254
|
+
* `readVerdict` override): constructing without it throws synchronously,
|
|
255
|
+
* the same fail-loud pattern as a missing `trustedKeystore`. A real
|
|
256
|
+
* filesystem reader with no confinement root IS the hole this round
|
|
257
|
+
* closes; there is no safe default for it.
|
|
258
|
+
*
|
|
259
|
+
* 2. `deriveVerdictRef(authorizedRoot, noteId, leg)` — a new, exported,
|
|
260
|
+
* PURE function (no filesystem access): the ONE location a leg's verdict
|
|
261
|
+
* for a note may ever be read from — `beneath(authorizedRoot,
|
|
262
|
+
* sanitized(noteId), sanitized(legId))`. Both `noteId` and the leg's
|
|
263
|
+
* `{model, session}` are sanitized (reject `/`, `\`, NUL, `.`, `..`) —
|
|
264
|
+
* REFUSED outright (thrown, caught, turned into a ceremony refusal)
|
|
265
|
+
* rather than silently stripped, so a hostile value can never be
|
|
266
|
+
* "cleaned" into some OTHER, ambiguous, colliding path. This is derived
|
|
267
|
+
* from `legSpec1`/`legSpec2` — the ceremony's OWN dispatch decision,
|
|
268
|
+
* already checked structurally distinct and non-author BEFORE launch —
|
|
269
|
+
* never from anything `launchLeg` or `writeVerdict` claim back. (A
|
|
270
|
+
* deliberate, narrow exception to I1's "opaque, never parsed/derived
|
|
271
|
+
* from" stance for `noteId`/leg identity: here they are used ONLY to
|
|
272
|
+
* build a confined filesystem path, via sanitize-or-refuse, never
|
|
273
|
+
* split/parsed/compared piecewise.)
|
|
274
|
+
*
|
|
275
|
+
* 3. Per leg, BEFORE any filesystem access on `writeVerdict`'s ref: the
|
|
276
|
+
* ceremony computes the derived path and requires
|
|
277
|
+
* `resolve(writeVerdictRef) === derivedPath`, EXACTLY, string-for-string
|
|
278
|
+
* — REFUSED otherwise, before `readVerdict` is ever called with it. This
|
|
279
|
+
* alone closes TRAVERSAL structurally: a `../`-laden ref can never be
|
|
280
|
+
* textually identical to the clean, sanitized, ceremony-derived path, so
|
|
281
|
+
* it is refused without a single filesystem call ever touching it.
|
|
282
|
+
*
|
|
283
|
+
* 4. `defaultReadVerdict(ref, authorizedRoot)` (signature change from
|
|
284
|
+
* ROUND 2 — now confined) opens the verdict file via the cull.ts-style
|
|
285
|
+
* descriptor-relative O_NOFOLLOW walk from the realpath'd
|
|
286
|
+
* `authorizedRoot` down through `ref`'s path components — refusing a
|
|
287
|
+
* symlink at ANY component (closes SYMLINK) — then, from the ONE
|
|
288
|
+
* resulting file descriptor: `fstatSync`s it (must be a regular file),
|
|
289
|
+
* re-derives its canonical path via `realpath("/proc/self/fd/<fd>")` as
|
|
290
|
+
* defense-in-depth confirmation it is still beneath `authorizedRoot`,
|
|
291
|
+
* and reads its bytes from THAT SAME descriptor — never a second,
|
|
292
|
+
* pathname-based open (closes TOCTOU: there is no gap between "this is
|
|
293
|
+
* confirmed a regular file beneath the root" and "these are the bytes
|
|
294
|
+
* verified" — both statements are about the identical open file
|
|
295
|
+
* description).
|
|
296
|
+
*
|
|
297
|
+
* 5. When a construction site supplies a CUSTOM `readVerdict` (an
|
|
298
|
+
* alternate, non-default store — still construction-time-only,
|
|
299
|
+
* unchanged from ROUND 2), the ref-derivation VALIDATION in (3) still
|
|
300
|
+
* applies whenever `authorizedRoot` is ALSO configured (defense in
|
|
301
|
+
* depth, cheap, store-agnostic — pure string comparison); it is skipped
|
|
302
|
+
* only when a construction site both overrides the reader AND supplies
|
|
303
|
+
* no `authorizedRoot`, in which case that override remains, as in
|
|
304
|
+
* ROUND 2, a fully trusted construction-time decision this module does
|
|
305
|
+
* not second-guess.
|
|
306
|
+
*
|
|
307
|
+
* Everything from ROUND 1 §3 and ROUND 2 remains UNCHANGED: signature-by-
|
|
308
|
+
* claimed-leg, GO, anti-replay (noteId pin), cross-artifact distinctness,
|
|
309
|
+
* separation of powers off the READ content, and coherence with the inline
|
|
310
|
+
* verdict. ROUND 3 adds a confinement gate BEFORE that pipeline runs; it
|
|
311
|
+
* removes no existing check.
|
|
312
|
+
*
|
|
313
|
+
* ===========================================================================
|
|
314
|
+
* D11 FIX — ROUND 3 §B (this build) — `authorId` is now BOUND to a verified
|
|
315
|
+
* author signature; `input.authorId` is no longer trusted for anything.
|
|
316
|
+
* ===========================================================================
|
|
317
|
+
*
|
|
318
|
+
* THE HOLE: `RunD11CeremonyInput.authorId` was a bare, per-call,
|
|
319
|
+
* caller-supplied string. Every separation-of-powers check compared a leg
|
|
320
|
+
* against WHATEVER `authorId` the caller happened to supply — a caller could
|
|
321
|
+
* simply lie (supply an `authorId` distinct from every leg) even when the
|
|
322
|
+
* TRUE author was genuinely one of the two legs reviewing its own note, and
|
|
323
|
+
* the ceremony had no way to detect it.
|
|
324
|
+
*
|
|
325
|
+
* WHY NOT a trusted-author field on the note itself: graphify verified
|
|
326
|
+
* (@67bf73c7) that `MemoryNote` carries NO trusted author field — graphify
|
|
327
|
+
* authors nothing at admission (its anti-cycle boundary, §8, forbids it from
|
|
328
|
+
* verifying signatures there). So this cannot be closed by reading a field
|
|
329
|
+
* graphify stamped; it must be closed INSIDE the ceremony, the same way the
|
|
330
|
+
* verdict-fabrication hole was: bind the claim to an unforgeable signature,
|
|
331
|
+
* verified against a trust anchor this module already owns.
|
|
332
|
+
*
|
|
333
|
+
* THE FIX — reuses the EXACT SAME trust anchor as verdict signatures, no
|
|
334
|
+
* second trust root:
|
|
335
|
+
*
|
|
336
|
+
* 1. The note carries an AUTHOR SIGNATURE in its open extension field
|
|
337
|
+
* (`note["h2a.author_signature"]`, `D11CeremonyNote`'s `[key: string]:
|
|
338
|
+
* unknown` slot — `MemoryNoteInput`/`note-builder.ts` already carry such
|
|
339
|
+
* an open slot; graphify stores it OPAQUE/unverified, it never inspects
|
|
340
|
+
* or verifies it). The shape, `AuthorSignature`, mirrors `VerdictArtifact`:
|
|
341
|
+
* `{authorLeg: LegIdentity, noteId, signature}` — a base64 Ed25519
|
|
342
|
+
* signature over the canonicalized `{authorLeg, noteId}` payload, binding
|
|
343
|
+
* the identity claim to BOTH who claims it and which note it is for
|
|
344
|
+
* (anti-replay, identical shape to how verdict signatures bind `noteId`).
|
|
345
|
+
* `authorLeg` reuses `LegIdentity` (not a new taxonomy, I4) precisely so
|
|
346
|
+
* it can be looked up in the SAME `trustedKeystore` a leg's key is.
|
|
347
|
+
*
|
|
348
|
+
* 2. `runD11Ceremony` RE-VERIFIES this signature — FIRST, before any other
|
|
349
|
+
* check — against the SAME construction-time `trustedKeystore` +
|
|
350
|
+
* `verifySignature` already closed over for verdicts (`verifyNoteAuthor`,
|
|
351
|
+
* below). No per-call/caller-injected dependency is consulted for this
|
|
352
|
+
* (injected_deps_hide_fabrication_hole discipline, same as ROUND 1/2/3's
|
|
353
|
+
* other anchors): an attacker who controls `deps` cannot make their own
|
|
354
|
+
* verifier say yes.
|
|
355
|
+
*
|
|
356
|
+
* 3. On success, the VERIFIED signer's identity — `authorLeg.session` — is
|
|
357
|
+
* what EVERY separation-of-powers check in this module now compares
|
|
358
|
+
* against, replacing every prior use of `input.authorId`.
|
|
359
|
+
* `RunD11CeremonyInput.authorId` remains in the type (callers may still
|
|
360
|
+
* pass it) but is STRUCTURALLY NEVER READ for a trust decision anywhere
|
|
361
|
+
* in this module — at most an unverified, decorative hint. Lying about it
|
|
362
|
+
* has zero effect: it is not consulted.
|
|
363
|
+
*
|
|
364
|
+
* 4. FAIL-CLOSED: a note with no author signature, a malformed one, one
|
|
365
|
+
* whose `noteId` does not match the note it is attached to, or one that
|
|
366
|
+
* does not verify against the trusted keystore, REFUSES the entire
|
|
367
|
+
* ceremony immediately — `launchLeg` is never called, exactly like the
|
|
368
|
+
* other pre-launch fail-closed checks (I5).
|
|
369
|
+
*
|
|
370
|
+
* PARKED (owner-gated, not this round): having graphify itself stamp a
|
|
371
|
+
* trusted `authored_by` field at admission (via `AdmissionDeps`) — a
|
|
372
|
+
* heavier, graphify-contract change. This round stays entirely h2a-side,
|
|
373
|
+
* zero graphify change, closing the hole with a mechanism this module
|
|
374
|
+
* already had (signature verification against its own trust anchor).
|
|
375
|
+
*
|
|
376
|
+
* ===========================================================================
|
|
377
|
+
* D11 FIX — ROUND 4 (this build) — same-note verdict REPLAY is now refused
|
|
378
|
+
* (a fresh, gate-issued ceremony nonce), and separation-of-powers is now
|
|
379
|
+
* enforced on CANONICAL CRYPTO PRINCIPALS (public-key fingerprints), not
|
|
380
|
+
* session strings.
|
|
381
|
+
* ===========================================================================
|
|
382
|
+
*
|
|
383
|
+
* THE TWO HOLES (proven by an independent review, a FOURTH, separate leg from
|
|
384
|
+
* the three that forced ROUNDS 1-3): at ROUND 3 (@64e3f8a4), the signed
|
|
385
|
+
* verdict payload was `{noteId, verdict, leg, at}` — signed, noteId-pinned
|
|
386
|
+
* against cross-note replay, but with NO freshness, NO nonce, NO one-shot
|
|
387
|
+
* consumption. An attacker holding NO private key at all could REPLAY the
|
|
388
|
+
* already-signed BYTES of two OLD, genuinely-produced GO verdicts for the
|
|
389
|
+
* SAME noteId (from a prior, real ceremony) straight into a FRESH ceremony's
|
|
390
|
+
* confined artifact locations and mint a fresh promotion — BLOCKER 1,
|
|
391
|
+
* SAME-NOTE REPLAY. Separately, separation-of-powers derived and compared
|
|
392
|
+
* `authorLeg.session` / `leg.session` STRINGS only, never the verified
|
|
393
|
+
* PUBLIC KEY those sessions claim to speak for; `D11TrustedKeystore` never
|
|
394
|
+
* enforced that a public key maps to at most one identity. If the same
|
|
395
|
+
* keystore mapped ONE Ed25519 key to TWO (or three) different
|
|
396
|
+
* `{model, session}` identities, the SAME cryptographic principal could
|
|
397
|
+
* author a note under one alias and review it — genuinely signing, no forged
|
|
398
|
+
* signature anywhere — under another, or "independently" review it TWICE
|
|
399
|
+
* under two reviewer aliases — BLOCKER 2, CRYPTO AUTHOR/REVIEWER (and
|
|
400
|
+
* REVIEWER/REVIEWER) ALIAS. Both reach `promoteNote({promoted:true})` at
|
|
401
|
+
* @64e3f8a4 without ever forging a signature.
|
|
402
|
+
*
|
|
403
|
+
* FIX 1 — REPLAY: a fresh, gate-issued, unpredictable ONE-SHOT ceremony
|
|
404
|
+
* nonce.
|
|
405
|
+
*
|
|
406
|
+
* 1. `runD11Ceremony` generates `ceremonyNonce = randomBytes(32).toString(
|
|
407
|
+
* "base64")` (`node:crypto`, NOT `Math.random`/`Date.now` — unpredictable)
|
|
408
|
+
* as the FIRST thing it does, every call. Never persisted, never reused
|
|
409
|
+
* across calls — a fresh value exists ONLY for the lifetime of this one
|
|
410
|
+
* ceremony invocation ("stateless one-shot": no server-side nonce store
|
|
411
|
+
* is needed because the GATE regenerates its own value every run and
|
|
412
|
+
* trusts nothing else).
|
|
413
|
+
* 2. The nonce is threaded to `launchLeg(note, legSpec, ceremonyNonce)` (new
|
|
414
|
+
* 3rd arg), so an honest leg's inline verdict — now `LegVerdict =
|
|
415
|
+
* MemoryVerdict & {ceremonyNonce}` (a LOCAL extension; slice 3's own
|
|
416
|
+
* `MemoryVerdict` is untouched, I4) — can carry it through `writeVerdict`
|
|
417
|
+
* into what actually gets signed. `VerdictArtifact` and
|
|
418
|
+
* `verdictSignedPayload` gain `ceremonyNonce`; the signature now covers
|
|
419
|
+
* `{noteId, verdict, leg, at, ceremonyNonce}`.
|
|
420
|
+
* 3. THE GATE (load-bearing): for each READ artifact, (a) REQUIRE
|
|
421
|
+
* `artifact.ceremonyNonce === ceremonyNonce` (THIS run's generated value)
|
|
422
|
+
* — reject a stale/replayed artifact immediately, before any signature
|
|
423
|
+
* math; AND (b), independently, verify the signature over a payload
|
|
424
|
+
* REBUILT from the GATE's OWN `ceremonyNonce` variable — never
|
|
425
|
+
* `artifact.ceremonyNonce` as the source of truth for what to verify. A
|
|
426
|
+
* replayed old artifact's real signed bytes covered a DIFFERENT (or
|
|
427
|
+
* absent) nonce, so (b) alone already fails to verify against a payload
|
|
428
|
+
* built with the CURRENT run's nonce, even hypothetically without (a).
|
|
429
|
+
* Both (a) and (b) independently refuse a same-note replay — proven by
|
|
430
|
+
* the ROUND 4 mutation-check in the test file (each neutralized alone,
|
|
431
|
+
* then together, then restored — see that file's comments for the
|
|
432
|
+
* ACTUAL, reproduced results).
|
|
433
|
+
* 4. `coherentWithInline` is extended to also require
|
|
434
|
+
* `artifact.ceremonyNonce === inline.ceremonyNonce`.
|
|
435
|
+
*
|
|
436
|
+
* FIX 2 — ALIAS: separation-of-powers on canonical CRYPTO PRINCIPALS.
|
|
437
|
+
*
|
|
438
|
+
* 1. `canonicalKeyFingerprint(pem)` — `createPublicKey` then re-export as
|
|
439
|
+
* SPKI DER, SHA-256 of that DER, base64. Two differently-formatted PEM
|
|
440
|
+
* encodings of the identical key canonicalize to the SAME fingerprint
|
|
441
|
+
* (robust to encoding, unlike a raw PEM string compare); an unparseable
|
|
442
|
+
* key throws (caught and turned into a ceremony refusal, I5).
|
|
443
|
+
* 2. `verifyNoteAuthor` resolves and returns the VERIFIED author's
|
|
444
|
+
* fingerprint alongside its `authorId` — the SAME public key it already
|
|
445
|
+
* looked up to verify the author signature, so no extra trust decision is
|
|
446
|
+
* introduced.
|
|
447
|
+
* 3. Separation-of-powers now requires THREE PAIRWISE-DISTINCT crypto
|
|
448
|
+
* principals (author, reviewer1, reviewer2), by fingerprint, fail-closed
|
|
449
|
+
* on any missing/unparseable key:
|
|
450
|
+
* - PRE-LAUNCH: `legSpec1`/`legSpec2`'s trusted keys are resolved and
|
|
451
|
+
* fingerprinted BEFORE `launchLeg` is ever called (symmetric with the
|
|
452
|
+
* existing pre-launch session-string checks) — refuses before a
|
|
453
|
+
* single model call is ever made.
|
|
454
|
+
* - POST-READ: re-checked off the ACTUAL READ artifacts' `leg`s'
|
|
455
|
+
* fingerprints (the SAME trusted keys that just verified each
|
|
456
|
+
* artifact's signature) — a `readVerdict` returning a different leg
|
|
457
|
+
* than the one launched is still caught, symmetric with ROUND 1's
|
|
458
|
+
* existing "off the READ content only" discipline.
|
|
459
|
+
* The existing session-string distinctness checks (`sameLegSpec`,
|
|
460
|
+
* `.session === verifiedAuthorId`) are KEPT, unchanged, as defense in
|
|
461
|
+
* depth; the fingerprint checks are the AUTHORITATIVE crypto layer added
|
|
462
|
+
* this round — a `.session` alias backed by a shared key now refuses even
|
|
463
|
+
* when every session string looks pairwise distinct (the
|
|
464
|
+
* reviewer/reviewer variant ROUND 3 could not catch, since `sameLegSpec`
|
|
465
|
+
* only compares `{model, session}`, never the key behind them).
|
|
466
|
+
*
|
|
467
|
+
* Everything from ROUNDS 1-3 remains UNCHANGED: path confinement,
|
|
468
|
+
* signature-by-claimed-leg, GO, cross-note noteId anti-replay, the two
|
|
469
|
+
* artifacts' structural (session) distinctness, coherence with the inline
|
|
470
|
+
* verdict, and author-signature verification. ROUND 4 adds a freshness gate
|
|
471
|
+
* and a crypto-principal gate; it removes no existing check.
|
|
472
|
+
*
|
|
473
|
+
* ===========================================================================
|
|
474
|
+
* (Unchanged) FLOW summary and invariants.
|
|
475
|
+
* ===========================================================================
|
|
476
|
+
*
|
|
477
|
+
* I5 — fail-closed at EVERY injected/lookup step: `deps` itself,
|
|
478
|
+
* `deps.legSpecs`, `deps.launchLeg`, `deps.writeVerdict`, `deps.writeAttestation`,
|
|
479
|
+
* `deps.port`, the CONSTRUCTION-TIME `readVerdict` (ROUND 2), and the
|
|
480
|
+
* `trustedKeystore` lookup are all treated as untrusted or fallible — absent,
|
|
481
|
+
* wrong shape, throwing, rejecting, or (for the keystore, and for
|
|
482
|
+
* `readVerdict`'s return) simply "nothing there" all REFUSE with a structured
|
|
483
|
+
* `{promoted:false, reason}`, never a silent success. A ceremony that fails
|
|
484
|
+
* partway NEVER reaches `promoteNote` (slice 3's raw dispatch) — proven in
|
|
485
|
+
* tests via counting stubs showing later steps are never invoked once an
|
|
486
|
+
* earlier one refuses.
|
|
487
|
+
*
|
|
488
|
+
* I1 — durable identity slot: `note.noteId`, `note.principal_owner`,
|
|
489
|
+
* `authorId`, and every `legSpec.{model,session}` / `leg.{model,session}` are
|
|
490
|
+
* OPAQUE strings here — compared with `===` only (via slice 3's own
|
|
491
|
+
* equality, or the local `sameLegSpec` mirror below), never parsed, split or
|
|
492
|
+
* derived-from. The `trustedKeystore` is looked up BY the opaque `LegIdentity`
|
|
493
|
+
* value, not by any string built from parsing it.
|
|
494
|
+
* I4 — no new capabilities vocabulary: this module reuses `MemoryVerdict`,
|
|
495
|
+
* `IndependenceAttestation`, `LegIdentity`, `checkDoubleConsensusPreconditions`
|
|
496
|
+
* and `promoteNoteWithDoubleConsensus` UNCHANGED from `./promote-client.ts`;
|
|
497
|
+
* `LegSpec` is a type alias of `LegIdentity`, not a second taxonomy, and
|
|
498
|
+
* `D11CeremonyResult` reuses slice 3's own `PromoteNoteResult` shape — a
|
|
499
|
+
* ceremony IS a (composed) promotion attempt, not a new outcome vocabulary.
|
|
500
|
+
* `VerdictArtifact` is the one genuinely NEW shape this fix introduces — the
|
|
501
|
+
* READ, signed record `MemoryVerdict` never was.
|
|
502
|
+
*/
|
|
503
|
+
import { createHash, createPublicKey, randomBytes, verify as verifyEd25519Signature } from "node:crypto";
|
|
504
|
+
import { closeSync, constants, fstatSync, openSync, readFileSync, realpathSync } from "node:fs";
|
|
505
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
506
|
+
import { canonicalize } from "../../canonical.js";
|
|
507
|
+
import { checkDoubleConsensusPreconditions, promoteNoteWithDoubleConsensus } from "./promote-client.js";
|
|
508
|
+
/** The note extension key `AuthorSignature` is carried under (`D11CeremonyNote`'s open `[key: string]: unknown` slot). */
|
|
509
|
+
export const AUTHOR_SIGNATURE_KEY = "h2a.author_signature";
|
|
510
|
+
/** A fixed, descriptive orchestrator id — NOT a minted identity (I1); this module names the mechanism, not a person/session. */
|
|
511
|
+
const ORCHESTRATOR_ID = "h2a:d11-ceremony";
|
|
512
|
+
/**
|
|
513
|
+
* Placeholder refs for the PRE-write, PRE-read precondition gate (a cheap
|
|
514
|
+
* early reject on the INLINE verdicts, before any write happens) — no real
|
|
515
|
+
* ref exists yet at that point. Never appear in the evidence handed to the
|
|
516
|
+
* port; discarded the moment the real refs come back from `writeVerdict`.
|
|
517
|
+
*/
|
|
518
|
+
const PENDING_LEG1_REF = "__d11_ceremony_pending_leg1_ref__";
|
|
519
|
+
const PENDING_LEG2_REF = "__d11_ceremony_pending_leg2_ref__";
|
|
520
|
+
function refuse(reason) {
|
|
521
|
+
return { outcome: { promoted: false, reason }, localOnly: true };
|
|
522
|
+
}
|
|
523
|
+
function sameLegSpec(a, b) {
|
|
524
|
+
return a.model === b.model && a.session === b.session;
|
|
525
|
+
}
|
|
526
|
+
function errorReason(err) {
|
|
527
|
+
return err instanceof Error ? err.message : String(err);
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* D11 FIX ROUND 4 FIX 1 — the signed payload now includes `ceremonyNonce`,
|
|
531
|
+
* but deliberately as a SEPARATE, EXPLICIT parameter (`ceremonyNonce`,
|
|
532
|
+
* passed by the caller), never read off `artifact.ceremonyNonce`. The gate
|
|
533
|
+
* (below) ALWAYS calls this with THIS RUN's own generated nonce — the ONE
|
|
534
|
+
* source of truth for what a valid signature must cover this run — so a
|
|
535
|
+
* replayed artifact whose real signed bytes covered a DIFFERENT (stale)
|
|
536
|
+
* nonce fails to verify here even if the artifact's self-reported
|
|
537
|
+
* `ceremonyNonce` field were tampered to match.
|
|
538
|
+
*/
|
|
539
|
+
function verdictSignedPayload(artifact, ceremonyNonce) {
|
|
540
|
+
return { noteId: artifact.noteId, verdict: artifact.verdict, leg: artifact.leg, at: artifact.at, ceremonyNonce };
|
|
541
|
+
}
|
|
542
|
+
function toMemoryVerdict(artifact) {
|
|
543
|
+
return { noteId: artifact.noteId, verdict: artifact.verdict, leg: artifact.leg, at: artifact.at };
|
|
544
|
+
}
|
|
545
|
+
function coherentWithInline(artifact, inline) {
|
|
546
|
+
return (artifact.noteId === inline.noteId &&
|
|
547
|
+
artifact.verdict === inline.verdict &&
|
|
548
|
+
artifact.leg.model === inline.leg.model &&
|
|
549
|
+
artifact.leg.session === inline.leg.session &&
|
|
550
|
+
artifact.ceremonyNonce === inline.ceremonyNonce);
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* The default, real signature verifier: Ed25519 over the canonicalized
|
|
554
|
+
* payload, via `node:crypto` — the same primitive `./signature.ts` uses
|
|
555
|
+
* elsewhere in h2a, applied here to a raw base64 signature string (this
|
|
556
|
+
* seam's shape) rather than the `H2ASignature{by,alg,value}` envelope.
|
|
557
|
+
*/
|
|
558
|
+
export function defaultVerifySignature(payload, signature, publicKeyPem) {
|
|
559
|
+
let key;
|
|
560
|
+
try {
|
|
561
|
+
key = createPublicKey({ key: publicKeyPem, format: "pem" });
|
|
562
|
+
}
|
|
563
|
+
catch {
|
|
564
|
+
return false;
|
|
565
|
+
}
|
|
566
|
+
let raw;
|
|
567
|
+
try {
|
|
568
|
+
raw = Buffer.from(signature, "base64");
|
|
569
|
+
}
|
|
570
|
+
catch {
|
|
571
|
+
return false;
|
|
572
|
+
}
|
|
573
|
+
try {
|
|
574
|
+
const message = Buffer.from(canonicalize(payload), "utf8");
|
|
575
|
+
return verifyEd25519Signature(null, message, key, raw);
|
|
576
|
+
}
|
|
577
|
+
catch {
|
|
578
|
+
return false;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* D11 FIX ROUND 2: shape-validate a value read back from the durable store
|
|
583
|
+
* before trusting it as a `VerdictArtifact` at all — a malformed or
|
|
584
|
+
* unrelated JSON blob at a path must never be handed to the signature check
|
|
585
|
+
* as if it were a real artifact (it would simply fail signature
|
|
586
|
+
* verification, but failing CLOSED here, before that, is cheaper and
|
|
587
|
+
* clearer about why).
|
|
588
|
+
*/
|
|
589
|
+
function isVerdictArtifactShape(value) {
|
|
590
|
+
if (typeof value !== "object" || value === null)
|
|
591
|
+
return false;
|
|
592
|
+
const v = value;
|
|
593
|
+
if (typeof v.noteId !== "string")
|
|
594
|
+
return false;
|
|
595
|
+
if (v.verdict !== "GO" && v.verdict !== "NO-GO")
|
|
596
|
+
return false;
|
|
597
|
+
if (typeof v.at !== "number")
|
|
598
|
+
return false;
|
|
599
|
+
// D11 FIX ROUND 4 FIX 1 — a verdict artifact with no (or empty)
|
|
600
|
+
// ceremonyNonce is not a valid shape at all — refused HERE, before ever
|
|
601
|
+
// reaching the freshness/signature gate below.
|
|
602
|
+
if (typeof v.ceremonyNonce !== "string" || v.ceremonyNonce.length === 0)
|
|
603
|
+
return false;
|
|
604
|
+
if (typeof v.signature !== "string")
|
|
605
|
+
return false;
|
|
606
|
+
if (typeof v.leg !== "object" || v.leg === null)
|
|
607
|
+
return false;
|
|
608
|
+
const leg = v.leg;
|
|
609
|
+
return typeof leg.model === "string" && typeof leg.session === "string";
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* D11 FIX ROUND 4 FIX 2 — the canonical CRYPTO PRINCIPAL fingerprint for an
|
|
613
|
+
* Ed25519 public key: parse the PEM, re-export as SPKI DER (a canonical
|
|
614
|
+
* encoding independent of the ORIGINAL PEM's own formatting/line-wrapping),
|
|
615
|
+
* then SHA-256 that DER. Two differently-formatted PEM encodings of the
|
|
616
|
+
* IDENTICAL key canonicalize to the SAME fingerprint — robust to encoding,
|
|
617
|
+
* unlike a raw PEM string compare. Throws on an unparseable key; callers
|
|
618
|
+
* MUST catch this and turn it into a fail-closed ceremony refusal (I5) —
|
|
619
|
+
* there is no safe default fingerprint for a key that cannot even be parsed.
|
|
620
|
+
*/
|
|
621
|
+
export function canonicalKeyFingerprint(publicKeyPem) {
|
|
622
|
+
const key = createPublicKey({ key: publicKeyPem, format: "pem" });
|
|
623
|
+
const der = key.export({ type: "spki", format: "der" });
|
|
624
|
+
return createHash("sha256").update(der).digest("base64");
|
|
625
|
+
}
|
|
626
|
+
// ---------------------------------------------------------------------------
|
|
627
|
+
// D11 FIX ROUND 3 §B — author-signature verification. Reuses the SAME
|
|
628
|
+
// construction-time `trustedKeystore` + `verifySignature` verdict
|
|
629
|
+
// signatures use — no second trust root, no per-call-injected verifier.
|
|
630
|
+
// ---------------------------------------------------------------------------
|
|
631
|
+
/** Mirrors `isVerdictArtifactShape`'s shape-before-signature discipline: refuse a malformed shape BEFORE ever handing it to signature verification. */
|
|
632
|
+
function isAuthorSignatureShape(value) {
|
|
633
|
+
if (typeof value !== "object" || value === null)
|
|
634
|
+
return false;
|
|
635
|
+
const v = value;
|
|
636
|
+
if (typeof v.noteId !== "string")
|
|
637
|
+
return false;
|
|
638
|
+
if (typeof v.signature !== "string")
|
|
639
|
+
return false;
|
|
640
|
+
if (typeof v.authorLeg !== "object" || v.authorLeg === null)
|
|
641
|
+
return false;
|
|
642
|
+
const authorLeg = v.authorLeg;
|
|
643
|
+
return typeof authorLeg.model === "string" && typeof authorLeg.session === "string";
|
|
644
|
+
}
|
|
645
|
+
function authorSignedPayload(sig) {
|
|
646
|
+
return { authorLeg: sig.authorLeg, noteId: sig.noteId };
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* D11 FIX ROUND 3 §B — verify `note[AUTHOR_SIGNATURE_KEY]` against the SAME
|
|
650
|
+
* construction-time `trustedKeystore` + `verifySignature` verdict
|
|
651
|
+
* signatures are checked against (no second trust root — the module doc's
|
|
652
|
+
* ROUND 3 §B explains why). Returns the VERIFIED signer's `authorLeg.session`
|
|
653
|
+
* on success; a structured refusal reason on ANY failure — missing
|
|
654
|
+
* signature, malformed shape, a `noteId` that does not match the note it is
|
|
655
|
+
* attached to (anti-replay), an unknown/untrusted claimed identity, or an
|
|
656
|
+
* invalid signature. Never throws; never falls back to any caller-supplied
|
|
657
|
+
* value.
|
|
658
|
+
*/
|
|
659
|
+
function verifyNoteAuthor(note, trustedKeystore, verifySignature) {
|
|
660
|
+
const raw = note[AUTHOR_SIGNATURE_KEY];
|
|
661
|
+
if (!isAuthorSignatureShape(raw)) {
|
|
662
|
+
return {
|
|
663
|
+
ok: false,
|
|
664
|
+
reason: `note has no valid author signature (${AUTHOR_SIGNATURE_KEY}) — refusing (fail-closed)`
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
if (raw.noteId !== note.noteId) {
|
|
668
|
+
return {
|
|
669
|
+
ok: false,
|
|
670
|
+
reason: "the note's author signature noteId does not match the note itself — refusing (anti-replay)"
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
const publicKey = trustedKeystore.getPublicKey(raw.authorLeg);
|
|
674
|
+
if (typeof publicKey !== "string" || publicKey.length === 0) {
|
|
675
|
+
return {
|
|
676
|
+
ok: false,
|
|
677
|
+
reason: "no trusted public key for the note's claimed author — refusing (fail-closed; unknown author or empty keystore)"
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
let signatureOk;
|
|
681
|
+
try {
|
|
682
|
+
signatureOk = verifySignature(authorSignedPayload(raw), raw.signature, publicKey);
|
|
683
|
+
}
|
|
684
|
+
catch (err) {
|
|
685
|
+
return { ok: false, reason: `author signature verification threw — ${errorReason(err)}` };
|
|
686
|
+
}
|
|
687
|
+
if (!signatureOk) {
|
|
688
|
+
return {
|
|
689
|
+
ok: false,
|
|
690
|
+
reason: "the note's author signature is invalid — refusing (fabricated, tampered, or signed by the wrong key)"
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
// D11 FIX ROUND 4 FIX 2 — resolve the canonical fingerprint of the SAME
|
|
694
|
+
// trusted key that just verified this signature. Fail-closed (I5) on an
|
|
695
|
+
// unparseable key, the same discipline as every other lookup in this
|
|
696
|
+
// module.
|
|
697
|
+
let authorKeyFingerprint;
|
|
698
|
+
try {
|
|
699
|
+
authorKeyFingerprint = canonicalKeyFingerprint(publicKey);
|
|
700
|
+
}
|
|
701
|
+
catch (err) {
|
|
702
|
+
return {
|
|
703
|
+
ok: false,
|
|
704
|
+
reason: `the note's author trusted public key is not a parseable Ed25519 key — refusing (fail-closed) — ${errorReason(err)}`
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
return { ok: true, authorId: raw.authorLeg.session, authorKeyFingerprint };
|
|
708
|
+
}
|
|
709
|
+
// ---------------------------------------------------------------------------
|
|
710
|
+
// D11 FIX ROUND 3 — path-confinement primitives. REUSES the technique
|
|
711
|
+
// proven and MERGED in `packages/h2a-runtime/src/identity-cull/cull.ts`
|
|
712
|
+
// (PR #160): `realpathSync` canonicalization of a trusted root,
|
|
713
|
+
// `openSync(..., O_RDONLY | O_DIRECTORY | O_NOFOLLOW)` descriptor-relative
|
|
714
|
+
// directory walking that refuses a symlink at ANY path component
|
|
715
|
+
// (`openNoFollowDirectory`/`descriptorPath` below mirror cull.ts's own
|
|
716
|
+
// functions of the same names and shape), and a SINGLE held file
|
|
717
|
+
// descriptor carried from open through the `fstatSync` check to the read —
|
|
718
|
+
// never a second, pathname-based open. `h2a` does not depend on
|
|
719
|
+
// `h2a-runtime` at build time (the peer dependency is optional), so the
|
|
720
|
+
// primitives are reimplemented locally rather than imported; the technique,
|
|
721
|
+
// not the module, is what is reused.
|
|
722
|
+
// ---------------------------------------------------------------------------
|
|
723
|
+
/**
|
|
724
|
+
* Reject `/`, `\`, NUL, `.` and `..` outright rather than stripping them —
|
|
725
|
+
* a hostile `noteId`/leg identity value must never be silently "cleaned"
|
|
726
|
+
* into some OTHER, ambiguous, possibly-colliding path segment.
|
|
727
|
+
*/
|
|
728
|
+
function sanitizePathSegment(value, label) {
|
|
729
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
730
|
+
throw new Error(`${label} must be a non-empty string to derive a confined verdict path`);
|
|
731
|
+
}
|
|
732
|
+
if (value === "." || value === "..") {
|
|
733
|
+
throw new Error(`${label} refuses "." or ".." as a path segment`);
|
|
734
|
+
}
|
|
735
|
+
if (value.includes("/") || value.includes("\\") || value.includes("\0")) {
|
|
736
|
+
throw new Error(`${label} refuses a path separator or NUL byte in a path segment`);
|
|
737
|
+
}
|
|
738
|
+
return value;
|
|
739
|
+
}
|
|
740
|
+
/** Mirrors cull.ts's `isInside` — `relative()`-based containment check, no `..`, never absolute. */
|
|
741
|
+
function isBeneathRoot(candidate, root) {
|
|
742
|
+
const rel = relative(root, candidate);
|
|
743
|
+
return rel !== "" && rel !== "." && !rel.startsWith("..") && !isAbsolute(rel);
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* D11 FIX ROUND 3: the ONE location a leg's verdict for a note may ever be
|
|
747
|
+
* read from — `beneath(authorizedRoot, sanitized(noteId), sanitized(legId))`.
|
|
748
|
+
* Pure (no filesystem access) so the ceremony can compute it and compare it
|
|
749
|
+
* against `writeVerdict`'s per-call return value BEFORE touching disk.
|
|
750
|
+
* Derived from the ceremony's OWN dispatch decision (the `legSpec` it
|
|
751
|
+
* actually launched, already checked distinct/non-author before launch) —
|
|
752
|
+
* never from anything `launchLeg`/`writeVerdict` claim back.
|
|
753
|
+
*/
|
|
754
|
+
export function deriveVerdictRef(authorizedRoot, noteId, leg) {
|
|
755
|
+
const root = resolve(authorizedRoot);
|
|
756
|
+
const noteSegment = sanitizePathSegment(noteId, "noteId");
|
|
757
|
+
const model = sanitizePathSegment(leg?.model, "leg.model");
|
|
758
|
+
const session = sanitizePathSegment(leg?.session, "leg.session");
|
|
759
|
+
// NB-03: length-prefix `model` so the (model, session) → segment mapping is
|
|
760
|
+
// injective. A bare `${model}__${session}` join is ambiguous — ("claude",
|
|
761
|
+
// "3__test") and ("claude__3", "test") both yield "claude__3__test.json", which
|
|
762
|
+
// would alias two DISTINCT legs to ONE verdict file and silently collapse the
|
|
763
|
+
// double-consensus into a single verdict. Encoding model's length ahead of it
|
|
764
|
+
// makes the model|session boundary unambiguous regardless of underscores in
|
|
765
|
+
// either value (the segment decodes to exactly one (model, session) pair).
|
|
766
|
+
const legSegment = sanitizePathSegment(`${model.length}_${model}_${session}.json`, "leg");
|
|
767
|
+
const derived = resolve(root, noteSegment, legSegment);
|
|
768
|
+
if (!isBeneathRoot(derived, root)) {
|
|
769
|
+
// Defense in depth: sanitizePathSegment above should already make this
|
|
770
|
+
// unreachable (no `/`/`..` can survive into a segment), but a derived
|
|
771
|
+
// path is never trusted without this check regardless.
|
|
772
|
+
throw new Error("derived verdict ref escaped authorizedRoot");
|
|
773
|
+
}
|
|
774
|
+
return derived;
|
|
775
|
+
}
|
|
776
|
+
/** Mirrors cull.ts's `descriptorPath` — a safe, descriptor-relative `/proc/self/fd/<fd>[/<name>]` path. */
|
|
777
|
+
function descriptorPath(fd, name) {
|
|
778
|
+
if (name !== undefined && (name.length === 0 || name.includes(sep) || name === "." || name === "..")) {
|
|
779
|
+
throw new Error(`unsafe descriptor-relative verdict path segment: ${name}`);
|
|
780
|
+
}
|
|
781
|
+
return name === undefined ? `/proc/self/fd/${fd}` : `/proc/self/fd/${fd}/${name}`;
|
|
782
|
+
}
|
|
783
|
+
/** Mirrors cull.ts's `openNoFollowDirectory` — open a directory descriptor, refusing a symlink. */
|
|
784
|
+
function openNoFollowDirectory(path) {
|
|
785
|
+
const fd = openSync(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
|
|
786
|
+
try {
|
|
787
|
+
if (!fstatSync(fd).isDirectory())
|
|
788
|
+
throw new Error(`not a directory: ${path}`);
|
|
789
|
+
return fd;
|
|
790
|
+
}
|
|
791
|
+
catch (error) {
|
|
792
|
+
closeSync(fd);
|
|
793
|
+
throw error;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
/** The path components of `target` relative to `root`, or `null` if `target` is not strictly beneath `root`. */
|
|
797
|
+
function componentsBeneathRoot(root, target) {
|
|
798
|
+
const rel = relative(root, target);
|
|
799
|
+
if (rel === "" || rel === "." || rel.startsWith("..") || isAbsolute(rel))
|
|
800
|
+
return null;
|
|
801
|
+
const parts = rel.split(sep).filter((part) => part.length > 0);
|
|
802
|
+
return parts.length > 0 ? parts : null;
|
|
803
|
+
}
|
|
804
|
+
/**
|
|
805
|
+
* D11 FIX ROUND 3 — confined-open + single-descriptor read. Walks from the
|
|
806
|
+
* realpath'd `authorizedRoot` down through `ref`'s path components, opening
|
|
807
|
+
* EVERY component (directories AND the final file) with `O_NOFOLLOW` —
|
|
808
|
+
* refusing a symlink anywhere in the chain (closes SYMLINK). The final file
|
|
809
|
+
* descriptor is `fstatSync`'d (must be a regular file), its canonical path
|
|
810
|
+
* re-derived via `realpath(/proc/self/fd/<fd>)` and re-checked beneath the
|
|
811
|
+
* root (defense in depth), and then READ FROM THAT SAME DESCRIPTOR — never
|
|
812
|
+
* a second, pathname-based open (closes TOCTOU: no gap between "confirmed a
|
|
813
|
+
* regular file beneath the root" and "these are the verified bytes"; both
|
|
814
|
+
* are about the identical open file description).
|
|
815
|
+
*
|
|
816
|
+
* Returns `null` on ANY failure — missing path, escaped root, a symlink at
|
|
817
|
+
* any component, not a regular file — never throws.
|
|
818
|
+
*/
|
|
819
|
+
function readConfinedFileBytes(authorizedRoot, ref) {
|
|
820
|
+
let resolvedRoot;
|
|
821
|
+
let resolvedRef;
|
|
822
|
+
try {
|
|
823
|
+
resolvedRoot = realpathSync(resolve(authorizedRoot));
|
|
824
|
+
resolvedRef = resolve(ref);
|
|
825
|
+
}
|
|
826
|
+
catch {
|
|
827
|
+
return null;
|
|
828
|
+
}
|
|
829
|
+
const components = componentsBeneathRoot(resolvedRoot, resolvedRef);
|
|
830
|
+
if (!components)
|
|
831
|
+
return null;
|
|
832
|
+
const openedFds = [];
|
|
833
|
+
try {
|
|
834
|
+
let currentFd;
|
|
835
|
+
try {
|
|
836
|
+
currentFd = openNoFollowDirectory(resolvedRoot);
|
|
837
|
+
}
|
|
838
|
+
catch {
|
|
839
|
+
return null;
|
|
840
|
+
}
|
|
841
|
+
openedFds.push(currentFd);
|
|
842
|
+
for (let index = 0; index < components.length - 1; index += 1) {
|
|
843
|
+
let nextFd;
|
|
844
|
+
try {
|
|
845
|
+
nextFd = openNoFollowDirectory(descriptorPath(currentFd, components[index]));
|
|
846
|
+
}
|
|
847
|
+
catch {
|
|
848
|
+
return null;
|
|
849
|
+
}
|
|
850
|
+
openedFds.push(nextFd);
|
|
851
|
+
currentFd = nextFd;
|
|
852
|
+
}
|
|
853
|
+
const finalName = components[components.length - 1];
|
|
854
|
+
let fileFd;
|
|
855
|
+
try {
|
|
856
|
+
fileFd = openSync(descriptorPath(currentFd, finalName), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
857
|
+
}
|
|
858
|
+
catch {
|
|
859
|
+
return null;
|
|
860
|
+
}
|
|
861
|
+
openedFds.push(fileFd);
|
|
862
|
+
let stat;
|
|
863
|
+
try {
|
|
864
|
+
stat = fstatSync(fileFd);
|
|
865
|
+
}
|
|
866
|
+
catch {
|
|
867
|
+
return null;
|
|
868
|
+
}
|
|
869
|
+
if (!stat.isFile())
|
|
870
|
+
return null;
|
|
871
|
+
let canonicalOpened;
|
|
872
|
+
try {
|
|
873
|
+
canonicalOpened = realpathSync(descriptorPath(fileFd));
|
|
874
|
+
}
|
|
875
|
+
catch {
|
|
876
|
+
return null;
|
|
877
|
+
}
|
|
878
|
+
if (!isBeneathRoot(canonicalOpened, resolvedRoot))
|
|
879
|
+
return null;
|
|
880
|
+
try {
|
|
881
|
+
return readFileSync(fileFd);
|
|
882
|
+
}
|
|
883
|
+
catch {
|
|
884
|
+
return null;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
finally {
|
|
888
|
+
for (const fd of openedFds) {
|
|
889
|
+
try {
|
|
890
|
+
closeSync(fd);
|
|
891
|
+
}
|
|
892
|
+
catch {
|
|
893
|
+
// best-effort close — the descriptor may already be invalid after a failure above
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
/**
|
|
899
|
+
* D11 FIX ROUND 2/3 — the default, REAL, CONFINED durable-store reader.
|
|
900
|
+
* `ref` IS a filesystem path, opened only via the confined,
|
|
901
|
+
* descriptor-relative, O_NOFOLLOW walk (`readConfinedFileBytes`, ROUND 3)
|
|
902
|
+
* beneath `authorizedRoot` — never a bare `readFile(ref)` — then
|
|
903
|
+
* JSON-parsed and shape-validated. Returns `null` on ANY failure — missing
|
|
904
|
+
* file, escaped root, a symlink anywhere in the chain, permission error,
|
|
905
|
+
* malformed JSON, or a well-formed-but-wrong-shape value — never throws.
|
|
906
|
+
* What it returns, when non-null, is PATH-BOUND: literally the bytes
|
|
907
|
+
* actually persisted at `ref`, confirmed beneath `authorizedRoot`, never a
|
|
908
|
+
* value the caller constructed in memory and never bytes read via a path
|
|
909
|
+
* that could have been re-resolved after any check.
|
|
910
|
+
*/
|
|
911
|
+
export async function defaultReadVerdict(ref, authorizedRoot) {
|
|
912
|
+
const bytes = readConfinedFileBytes(authorizedRoot, ref);
|
|
913
|
+
if (!bytes)
|
|
914
|
+
return null;
|
|
915
|
+
let parsed;
|
|
916
|
+
try {
|
|
917
|
+
parsed = JSON.parse(bytes.toString("utf8"));
|
|
918
|
+
}
|
|
919
|
+
catch {
|
|
920
|
+
return null;
|
|
921
|
+
}
|
|
922
|
+
return isVerdictArtifactShape(parsed) ? parsed : null;
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* Factory: builds the actual `runD11Ceremony` function, CLOSED OVER
|
|
926
|
+
* `trustedKeystore` (and, optionally, `verifySignature` and `readVerdict`).
|
|
927
|
+
* This is the anti-fabrication anchor (D11 FIX ROUND 1 §1 + ROUND 2 §1):
|
|
928
|
+
* signature verification always uses THIS keystore, and the verdict
|
|
929
|
+
* read-back always uses THIS reader (real filesystem by default) — never
|
|
930
|
+
* one a caller of the returned function can inject per-call. Throws
|
|
931
|
+
* synchronously on a missing/malformed `trustedKeystore` — a
|
|
932
|
+
* construction-site wiring bug should fail loudly and immediately, not
|
|
933
|
+
* silently produce a ceremony that can never verify anything.
|
|
934
|
+
*/
|
|
935
|
+
export function createD11Ceremony(options) {
|
|
936
|
+
if (!options || typeof options.trustedKeystore?.getPublicKey !== "function") {
|
|
937
|
+
throw new Error("createD11Ceremony requires a trustedKeystore with getPublicKey(leg) — construction-time, not caller-swappable");
|
|
938
|
+
}
|
|
939
|
+
const trustedKeystore = options.trustedKeystore;
|
|
940
|
+
const verifySignature = typeof options.verifySignature === "function" ? options.verifySignature : defaultVerifySignature;
|
|
941
|
+
const hasCustomReadVerdict = typeof options.readVerdict === "function";
|
|
942
|
+
const authorizedRoot = typeof options.authorizedRoot === "string" ? options.authorizedRoot : undefined;
|
|
943
|
+
// D11 FIX ROUND 3: a real filesystem reader with no confinement root is
|
|
944
|
+
// exactly the hole this round closes — fail loudly at construction, the
|
|
945
|
+
// same pattern as a missing `trustedKeystore`, rather than silently
|
|
946
|
+
// producing a ceremony whose default reader is unconfined.
|
|
947
|
+
if (!hasCustomReadVerdict && !authorizedRoot) {
|
|
948
|
+
throw new Error("createD11Ceremony requires authorizedRoot when no custom readVerdict is supplied — the real default reader must be confined (D11 FIX ROUND 3)");
|
|
949
|
+
}
|
|
950
|
+
const readVerdict = hasCustomReadVerdict
|
|
951
|
+
? options.readVerdict
|
|
952
|
+
: (ref) => defaultReadVerdict(ref, authorizedRoot);
|
|
953
|
+
/**
|
|
954
|
+
* Run the D11 ceremony end to end. See the module doc for the full flow.
|
|
955
|
+
* Any refusal at any step returns a structured `{promoted:false, reason}`
|
|
956
|
+
* with `localOnly: true`, and guarantees `promoteNote` (slice 3's raw
|
|
957
|
+
* dispatch, the only thing that ever touches the injected port for a
|
|
958
|
+
* promotion) is NEVER reached.
|
|
959
|
+
*/
|
|
960
|
+
return async function runD11Ceremony(input, deps) {
|
|
961
|
+
// =========================================================================
|
|
962
|
+
// D11 FIX ROUND 4 FIX 1 — the FIRST thing every ceremony run does: mint a
|
|
963
|
+
// fresh, unpredictable, gate-owned nonce (`node:crypto.randomBytes`, NOT
|
|
964
|
+
// Math.random/Date.now). Never persisted, never reused — a "stateless
|
|
965
|
+
// one-shot": THIS run's own value is the ONE source of truth every read
|
|
966
|
+
// artifact is checked against below (see the ROUND 4 module doc).
|
|
967
|
+
// =========================================================================
|
|
968
|
+
const ceremonyNonce = randomBytes(32).toString("base64");
|
|
969
|
+
if (!deps) {
|
|
970
|
+
return refuse("no ceremony dependencies injected — refusing (fail-closed, I5)");
|
|
971
|
+
}
|
|
972
|
+
// =========================================================================
|
|
973
|
+
// D11 FIX ROUND 3 §B — verify the note's AUTHOR SIGNATURE FIRST, against
|
|
974
|
+
// the SAME construction-time trustedKeystore + verifySignature used for
|
|
975
|
+
// verdict signatures (no second trust root). Every separation-of-powers
|
|
976
|
+
// check below compares against the VERIFIED signer's identity —
|
|
977
|
+
// `verifiedAuthorId` — never `input.authorId` (an unverified, per-call
|
|
978
|
+
// hint this module structurally never reads for a trust decision).
|
|
979
|
+
// FAIL-CLOSED: no signature, a malformed one, a noteId mismatch, or one
|
|
980
|
+
// that does not verify against the trusted keystore refuses immediately —
|
|
981
|
+
// launchLeg is never called, exactly like the other pre-launch checks.
|
|
982
|
+
// =========================================================================
|
|
983
|
+
const authorVerification = verifyNoteAuthor(input.note, trustedKeystore, verifySignature);
|
|
984
|
+
if (!authorVerification.ok) {
|
|
985
|
+
return refuse(authorVerification.reason);
|
|
986
|
+
}
|
|
987
|
+
const verifiedAuthorId = authorVerification.authorId;
|
|
988
|
+
const authorKeyFingerprint = authorVerification.authorKeyFingerprint;
|
|
989
|
+
// D11 FIX ROUND 2: `readVerdict` is intentionally NOT destructured from
|
|
990
|
+
// `deps` here — it is not a field of `RunD11CeremonyDeps` at all. Even if
|
|
991
|
+
// a caller attaches a `readVerdict` property to the object passed as
|
|
992
|
+
// `deps` anyway (JS does not enforce the TS shape at runtime), it is
|
|
993
|
+
// never read: the CLOSED-OVER `readVerdict` from `createD11Ceremony`
|
|
994
|
+
// (above) is what's used below, unconditionally.
|
|
995
|
+
const { launchLeg, writeVerdict, writeAttestation, legSpecs, port, ctx } = deps;
|
|
996
|
+
if (!Array.isArray(legSpecs) || legSpecs.length !== 2) {
|
|
997
|
+
return refuse("exactly 2 legSpecs are required to run a double-consensus ceremony");
|
|
998
|
+
}
|
|
999
|
+
const [legSpec1, legSpec2] = legSpecs;
|
|
1000
|
+
// --- Separation of powers, BEFORE anything is launched. ---
|
|
1001
|
+
if (sameLegSpec(legSpec1, legSpec2)) {
|
|
1002
|
+
return refuse("the two legSpecs are not structurally distinct (same model+session) — refusing before launch");
|
|
1003
|
+
}
|
|
1004
|
+
if (legSpec1.session === verifiedAuthorId || legSpec2.session === verifiedAuthorId) {
|
|
1005
|
+
return refuse("a legSpec's session equals the note's VERIFIED author — separation of powers requires launching only independent reviewers");
|
|
1006
|
+
}
|
|
1007
|
+
// =========================================================================
|
|
1008
|
+
// D11 FIX ROUND 4 FIX 2 — separation of powers on CANONICAL CRYPTO
|
|
1009
|
+
// PRINCIPALS, PRE-LAUNCH. The session-string checks above are kept as
|
|
1010
|
+
// defense in depth, but a public key can back TWO (or three) distinct
|
|
1011
|
+
// `{model, session}` identities in the SAME trusted keystore — a
|
|
1012
|
+
// `.session` alias the checks above cannot see. Resolve + fingerprint
|
|
1013
|
+
// BOTH legSpecs' trusted keys BEFORE a single model call is made; refuse
|
|
1014
|
+
// fail-closed on a missing/unparseable key (cannot establish
|
|
1015
|
+
// distinctness, so it is never treated as "assume distinct").
|
|
1016
|
+
// =========================================================================
|
|
1017
|
+
let legSpec1KeyFingerprint;
|
|
1018
|
+
let legSpec2KeyFingerprint;
|
|
1019
|
+
try {
|
|
1020
|
+
const legSpec1PublicKey = trustedKeystore.getPublicKey(legSpec1);
|
|
1021
|
+
if (typeof legSpec1PublicKey !== "string" || legSpec1PublicKey.length === 0) {
|
|
1022
|
+
return refuse("legSpec1: no trusted public key — refusing before launch (fail-closed; cannot establish crypto-principal distinctness)");
|
|
1023
|
+
}
|
|
1024
|
+
const legSpec2PublicKey = trustedKeystore.getPublicKey(legSpec2);
|
|
1025
|
+
if (typeof legSpec2PublicKey !== "string" || legSpec2PublicKey.length === 0) {
|
|
1026
|
+
return refuse("legSpec2: no trusted public key — refusing before launch (fail-closed; cannot establish crypto-principal distinctness)");
|
|
1027
|
+
}
|
|
1028
|
+
legSpec1KeyFingerprint = canonicalKeyFingerprint(legSpec1PublicKey);
|
|
1029
|
+
legSpec2KeyFingerprint = canonicalKeyFingerprint(legSpec2PublicKey);
|
|
1030
|
+
}
|
|
1031
|
+
catch (err) {
|
|
1032
|
+
return refuse(`could not resolve a canonical key fingerprint before launch — refusing (fail-closed) — ${errorReason(err)}`);
|
|
1033
|
+
}
|
|
1034
|
+
if (legSpec1KeyFingerprint === authorKeyFingerprint || legSpec2KeyFingerprint === authorKeyFingerprint) {
|
|
1035
|
+
return refuse("a legSpec's trusted public key is the SAME canonical crypto principal as the note's VERIFIED author (key-fingerprint alias) — separation of powers requires launching only independent reviewers — refusing before launch");
|
|
1036
|
+
}
|
|
1037
|
+
if (legSpec1KeyFingerprint === legSpec2KeyFingerprint) {
|
|
1038
|
+
return refuse("legSpec1 and legSpec2 resolve to the SAME canonical crypto principal (key-fingerprint alias) — refusing before launch (two reviewer identities must not share one key)");
|
|
1039
|
+
}
|
|
1040
|
+
if (typeof launchLeg !== "function") {
|
|
1041
|
+
return refuse("no launchLeg injected — refusing (fail-closed, I5)");
|
|
1042
|
+
}
|
|
1043
|
+
// --- Launch both legs CONCURRENTLY: neither sees the other's verdict. ---
|
|
1044
|
+
// D11 FIX ROUND 4 FIX 1: both legs receive THIS run's ceremonyNonce as a
|
|
1045
|
+
// 3rd arg — an honest leg threads it into its returned LegVerdict.
|
|
1046
|
+
let v1;
|
|
1047
|
+
let v2;
|
|
1048
|
+
try {
|
|
1049
|
+
[v1, v2] = await Promise.all([
|
|
1050
|
+
launchLeg(input.note, legSpec1, ceremonyNonce),
|
|
1051
|
+
launchLeg(input.note, legSpec2, ceremonyNonce)
|
|
1052
|
+
]);
|
|
1053
|
+
}
|
|
1054
|
+
catch (err) {
|
|
1055
|
+
return refuse(`launchLeg failed: ${errorReason(err)}`);
|
|
1056
|
+
}
|
|
1057
|
+
// --- Cheap pre-write reject on the INLINE (caller-controlled) verdicts. ---
|
|
1058
|
+
// This is a courtesy that saves a write + read round trip on an obviously
|
|
1059
|
+
// bad ceremony; it is NOT the authority — the READ, signature-verified
|
|
1060
|
+
// gate below (D11 FIX §3) is what actually protects `promoteNote`.
|
|
1061
|
+
const precheckAttestation = {
|
|
1062
|
+
leg1: v1.leg,
|
|
1063
|
+
leg2: v2.leg,
|
|
1064
|
+
distinctModels: v1.leg.model !== v2.leg.model,
|
|
1065
|
+
distinctSessions: v1.leg.session !== v2.leg.session,
|
|
1066
|
+
verdictsWrittenBeforeCrossVisibility: true,
|
|
1067
|
+
orchestrator: ORCHESTRATOR_ID
|
|
1068
|
+
};
|
|
1069
|
+
const precheck = checkDoubleConsensusPreconditions({
|
|
1070
|
+
verdicts: [v1, v2],
|
|
1071
|
+
attestation: precheckAttestation,
|
|
1072
|
+
leg1Ref: PENDING_LEG1_REF,
|
|
1073
|
+
leg2Ref: PENDING_LEG2_REF,
|
|
1074
|
+
authorId: verifiedAuthorId
|
|
1075
|
+
});
|
|
1076
|
+
if (!precheck.ok) {
|
|
1077
|
+
return refuse(`double-consensus preconditions not met: ${precheck.reason}`);
|
|
1078
|
+
}
|
|
1079
|
+
if (typeof writeVerdict !== "function") {
|
|
1080
|
+
return refuse("no writeVerdict injected — refusing (fail-closed, I5)");
|
|
1081
|
+
}
|
|
1082
|
+
let leg1Ref;
|
|
1083
|
+
let leg2Ref;
|
|
1084
|
+
try {
|
|
1085
|
+
leg1Ref = await writeVerdict(v1);
|
|
1086
|
+
leg2Ref = await writeVerdict(v2);
|
|
1087
|
+
}
|
|
1088
|
+
catch (err) {
|
|
1089
|
+
return refuse(`writeVerdict failed: ${errorReason(err)}`);
|
|
1090
|
+
}
|
|
1091
|
+
// =========================================================================
|
|
1092
|
+
// D11 FIX ROUND 3 §3 — path confinement, BEFORE any filesystem access on
|
|
1093
|
+
// writeVerdict's per-call, caller-controlled ref. Active whenever
|
|
1094
|
+
// `authorizedRoot` is configured (always, when using the real default
|
|
1095
|
+
// reader — construction requires it; also as defense in depth when a
|
|
1096
|
+
// construction site supplies both a custom reader AND an authorizedRoot).
|
|
1097
|
+
// The ceremony derives the ONE location each leg's verdict may be read
|
|
1098
|
+
// from, from data it already trusts at this point (the note being
|
|
1099
|
+
// promoted, and the legSpec it actually dispatched) — and requires the
|
|
1100
|
+
// per-call ref to resolve to EXACTLY that path, string-for-string,
|
|
1101
|
+
// REFUSING otherwise. This closes TRAVERSAL structurally: a `../`-laden
|
|
1102
|
+
// ref can never be textually identical to the clean, sanitized,
|
|
1103
|
+
// ceremony-derived path.
|
|
1104
|
+
// =========================================================================
|
|
1105
|
+
let leg1ReadRef = leg1Ref;
|
|
1106
|
+
let leg2ReadRef = leg2Ref;
|
|
1107
|
+
if (authorizedRoot) {
|
|
1108
|
+
let expectedRef1;
|
|
1109
|
+
let expectedRef2;
|
|
1110
|
+
try {
|
|
1111
|
+
expectedRef1 = deriveVerdictRef(authorizedRoot, input.note.noteId, legSpec1);
|
|
1112
|
+
expectedRef2 = deriveVerdictRef(authorizedRoot, input.note.noteId, legSpec2);
|
|
1113
|
+
}
|
|
1114
|
+
catch (err) {
|
|
1115
|
+
return refuse(`could not derive a confined verdict path: ${errorReason(err)}`);
|
|
1116
|
+
}
|
|
1117
|
+
if (resolve(leg1Ref) !== expectedRef1) {
|
|
1118
|
+
return refuse("leg1: writeVerdict's ref does not resolve to the ceremony-derived confined path — refusing (traversal/relocation refused before any file access)");
|
|
1119
|
+
}
|
|
1120
|
+
if (resolve(leg2Ref) !== expectedRef2) {
|
|
1121
|
+
return refuse("leg2: writeVerdict's ref does not resolve to the ceremony-derived confined path — refusing (traversal/relocation refused before any file access)");
|
|
1122
|
+
}
|
|
1123
|
+
// From here on, ONLY the ceremony's own derived strings are ever
|
|
1124
|
+
// handed to readVerdict — never the raw per-call ref, even though the
|
|
1125
|
+
// two are required equal at this point.
|
|
1126
|
+
leg1ReadRef = expectedRef1;
|
|
1127
|
+
leg2ReadRef = expectedRef2;
|
|
1128
|
+
}
|
|
1129
|
+
// =========================================================================
|
|
1130
|
+
// D11 FIX §3 — the READ, signature-verified gate. Everything from here on
|
|
1131
|
+
// is what actually protects `promoteNote`; nothing before this point does.
|
|
1132
|
+
// `readVerdict` here is ALWAYS the construction-time closure (ROUND 2) —
|
|
1133
|
+
// a real function is guaranteed (default or a construction-time override),
|
|
1134
|
+
// so there is no "readVerdict absent" branch left to guard: a missing
|
|
1135
|
+
// durable artifact now surfaces as `readVerdict(ref)` resolving to `null`
|
|
1136
|
+
// (checked immediately below), not as this dep being absent.
|
|
1137
|
+
// =========================================================================
|
|
1138
|
+
let artifact1;
|
|
1139
|
+
let artifact2;
|
|
1140
|
+
try {
|
|
1141
|
+
[artifact1, artifact2] = await Promise.all([readVerdict(leg1ReadRef), readVerdict(leg2ReadRef)]);
|
|
1142
|
+
}
|
|
1143
|
+
catch (err) {
|
|
1144
|
+
return refuse(`readVerdict failed: ${errorReason(err)}`);
|
|
1145
|
+
}
|
|
1146
|
+
if (!artifact1) {
|
|
1147
|
+
return refuse("readVerdict found no artifact at leg1's ref — refusing (no file at the claimed ref; the fabrication hole this closes)");
|
|
1148
|
+
}
|
|
1149
|
+
if (!artifact2) {
|
|
1150
|
+
return refuse("readVerdict found no artifact at leg2's ref — refusing (no file at the claimed ref; the fabrication hole this closes)");
|
|
1151
|
+
}
|
|
1152
|
+
// --- Per artifact: freshness, fingerprint, signature (against the CLAIMED leg's trusted key), GO, anti-replay. ---
|
|
1153
|
+
let artifact1KeyFingerprint;
|
|
1154
|
+
let artifact2KeyFingerprint;
|
|
1155
|
+
for (const [label, artifact] of [
|
|
1156
|
+
["leg1", artifact1],
|
|
1157
|
+
["leg2", artifact2]
|
|
1158
|
+
]) {
|
|
1159
|
+
// D11 FIX ROUND 4 FIX 1 — freshness, checked FIRST and cheaply: REQUIRE
|
|
1160
|
+
// this artifact was signed for THIS ceremony's own, freshly generated
|
|
1161
|
+
// nonce. A stale/replayed artifact (from ANY prior ceremony, even one
|
|
1162
|
+
// that genuinely, validly signed it) is refused here before any
|
|
1163
|
+
// signature math runs.
|
|
1164
|
+
if (artifact.ceremonyNonce !== ceremonyNonce) {
|
|
1165
|
+
return refuse(`${label}: the read verdict's ceremonyNonce does not match this ceremony's freshly generated nonce — refusing (stale or replayed artifact; anti-replay)`);
|
|
1166
|
+
}
|
|
1167
|
+
const publicKey = trustedKeystore.getPublicKey(artifact.leg);
|
|
1168
|
+
if (typeof publicKey !== "string" || publicKey.length === 0) {
|
|
1169
|
+
return refuse(`${label}: no trusted public key for the claimed leg — refusing (fail-closed; unknown leg or empty keystore)`);
|
|
1170
|
+
}
|
|
1171
|
+
// D11 FIX ROUND 4 FIX 2 — resolve the canonical crypto-principal
|
|
1172
|
+
// fingerprint for the CLAIMED leg's trusted key (the SAME key about to
|
|
1173
|
+
// verify the signature below). Used by the cross-artifact checks
|
|
1174
|
+
// after this loop.
|
|
1175
|
+
let fingerprint;
|
|
1176
|
+
try {
|
|
1177
|
+
fingerprint = canonicalKeyFingerprint(publicKey);
|
|
1178
|
+
}
|
|
1179
|
+
catch (err) {
|
|
1180
|
+
return refuse(`${label}: trusted public key is not a parseable Ed25519 key — refusing (fail-closed) — ${errorReason(err)}`);
|
|
1181
|
+
}
|
|
1182
|
+
if (label === "leg1") {
|
|
1183
|
+
artifact1KeyFingerprint = fingerprint;
|
|
1184
|
+
}
|
|
1185
|
+
else {
|
|
1186
|
+
artifact2KeyFingerprint = fingerprint;
|
|
1187
|
+
}
|
|
1188
|
+
let signatureOk;
|
|
1189
|
+
try {
|
|
1190
|
+
// D11 FIX ROUND 4 FIX 1 — the payload is rebuilt from THIS RUN's OWN
|
|
1191
|
+
// `ceremonyNonce` variable, never `artifact.ceremonyNonce` — see
|
|
1192
|
+
// `verdictSignedPayload` and the ROUND 4 module doc for why this,
|
|
1193
|
+
// independently of the explicit check above, already refuses a
|
|
1194
|
+
// same-note replay.
|
|
1195
|
+
signatureOk = verifySignature(verdictSignedPayload(artifact, ceremonyNonce), artifact.signature, publicKey);
|
|
1196
|
+
}
|
|
1197
|
+
catch (err) {
|
|
1198
|
+
return refuse(`${label}: signature verification threw — ${errorReason(err)}`);
|
|
1199
|
+
}
|
|
1200
|
+
if (!signatureOk) {
|
|
1201
|
+
return refuse(`${label}: signature invalid for the claimed leg — refusing (fabricated, tampered, or signed by the wrong key)`);
|
|
1202
|
+
}
|
|
1203
|
+
if (artifact.verdict !== "GO") {
|
|
1204
|
+
return refuse(`${label}: the read verdict is not GO — refusing`);
|
|
1205
|
+
}
|
|
1206
|
+
if (artifact.noteId !== input.note.noteId) {
|
|
1207
|
+
return refuse(`${label}: the read verdict's noteId does not match the note being promoted — refusing (anti-replay)`);
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
// --- Across the two READ artifacts, off the READ content only. ---
|
|
1211
|
+
if (sameLegSpec(artifact1.leg, artifact2.leg)) {
|
|
1212
|
+
return refuse("the two read verdict artifacts are not independent — the same leg was read twice — refusing");
|
|
1213
|
+
}
|
|
1214
|
+
if (artifact1.leg.session === verifiedAuthorId || artifact2.leg.session === verifiedAuthorId) {
|
|
1215
|
+
return refuse("a read verdict artifact's leg session equals the note's VERIFIED author — separation of powers requires an independent reviewer — refusing");
|
|
1216
|
+
}
|
|
1217
|
+
// D11 FIX ROUND 4 FIX 2 — separation of powers on the ACTUAL READ
|
|
1218
|
+
// artifacts' CANONICAL CRYPTO PRINCIPALS, POST-READ. Re-checked off the
|
|
1219
|
+
// fingerprints resolved from the SAME trusted keys that just verified
|
|
1220
|
+
// each artifact's signature — a `readVerdict` returning a different leg
|
|
1221
|
+
// than the one launched (and thus a different key) is still caught
|
|
1222
|
+
// here, symmetric with the existing "off the READ content only"
|
|
1223
|
+
// discipline for the session-string checks above. This is the
|
|
1224
|
+
// AUTHORITATIVE crypto check; the session-string checks above are kept
|
|
1225
|
+
// as defense in depth.
|
|
1226
|
+
if (artifact1KeyFingerprint === authorKeyFingerprint || artifact2KeyFingerprint === authorKeyFingerprint) {
|
|
1227
|
+
return refuse("a read verdict artifact's leg resolves to the SAME canonical crypto principal as the note's VERIFIED author (key-fingerprint alias) — separation of powers requires an independent reviewer — refusing");
|
|
1228
|
+
}
|
|
1229
|
+
if (artifact1KeyFingerprint === artifact2KeyFingerprint) {
|
|
1230
|
+
return refuse("the two read verdict artifacts resolve to the SAME canonical crypto principal (key-fingerprint alias) — refusing (two reviewer identities must not share one key)");
|
|
1231
|
+
}
|
|
1232
|
+
// --- Per artifact again: coherence with the INLINE verdict launchLeg returned. ---
|
|
1233
|
+
if (!coherentWithInline(artifact1, v1)) {
|
|
1234
|
+
return refuse("leg1: the read verdict artifact is not coherent with launchLeg's inline verdict — refusing");
|
|
1235
|
+
}
|
|
1236
|
+
if (!coherentWithInline(artifact2, v2)) {
|
|
1237
|
+
return refuse("leg2: the read verdict artifact is not coherent with launchLeg's inline verdict — refusing");
|
|
1238
|
+
}
|
|
1239
|
+
// --- Build the FINAL verdicts + attestation from the READ, VERIFIED artifacts. ---
|
|
1240
|
+
// Never the inline v1/v2 from here on — a bug in the checks above cannot
|
|
1241
|
+
// cause a fabricated inline verdict to be what actually gets promoted.
|
|
1242
|
+
const verifiedVerdicts = [
|
|
1243
|
+
toMemoryVerdict(artifact1),
|
|
1244
|
+
toMemoryVerdict(artifact2)
|
|
1245
|
+
];
|
|
1246
|
+
const attestation = {
|
|
1247
|
+
leg1: artifact1.leg,
|
|
1248
|
+
leg2: artifact2.leg,
|
|
1249
|
+
distinctModels: artifact1.leg.model !== artifact2.leg.model,
|
|
1250
|
+
distinctSessions: artifact1.leg.session !== artifact2.leg.session,
|
|
1251
|
+
verdictsWrittenBeforeCrossVisibility: true,
|
|
1252
|
+
orchestrator: ORCHESTRATOR_ID
|
|
1253
|
+
};
|
|
1254
|
+
if (typeof writeAttestation !== "function") {
|
|
1255
|
+
return refuse("no writeAttestation injected — refusing (fail-closed, I5)");
|
|
1256
|
+
}
|
|
1257
|
+
let attestationRef;
|
|
1258
|
+
try {
|
|
1259
|
+
attestationRef = await writeAttestation(attestation);
|
|
1260
|
+
}
|
|
1261
|
+
catch (err) {
|
|
1262
|
+
return refuse(`writeAttestation failed: ${errorReason(err)}`);
|
|
1263
|
+
}
|
|
1264
|
+
if (!port) {
|
|
1265
|
+
return refuse("no memory producer port injected — refusing (fail-closed, I5)");
|
|
1266
|
+
}
|
|
1267
|
+
// `promoteNoteWithDoubleConsensus` re-runs `checkDoubleConsensusPreconditions`
|
|
1268
|
+
// on this VERIFIED data as an unconditional second layer (unchanged from
|
|
1269
|
+
// slice 3) before ever touching the port.
|
|
1270
|
+
return promoteNoteWithDoubleConsensus({
|
|
1271
|
+
noteId: input.note.noteId,
|
|
1272
|
+
ctx,
|
|
1273
|
+
verdicts: verifiedVerdicts,
|
|
1274
|
+
attestation,
|
|
1275
|
+
attestationRef,
|
|
1276
|
+
leg1Ref,
|
|
1277
|
+
leg2Ref,
|
|
1278
|
+
authorId: verifiedAuthorId
|
|
1279
|
+
}, port);
|
|
1280
|
+
};
|
|
1281
|
+
}
|
|
1282
|
+
//# sourceMappingURL=d11-ceremony.js.map
|