fractal-pqc 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -3
- package/package.json +1 -1
- package/src/claims-registry.mjs +90 -4
- package/src/mutations.mjs +27 -0
- package/src/policy.mjs +215 -28
- package/src/primacy.mjs +18 -4
- package/test/m2-policy.mjs +2 -2
- package/test/primacy.mjs +12 -12
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ without invalidating already-signed history. This kit is a concrete, honest firs
|
|
|
19
19
|
|
|
20
20
|
## What is REAL here (verified, not aspirational)
|
|
21
21
|
|
|
22
|
-
> **The security layer (v0.
|
|
22
|
+
> **The security layer (v0.7.0):** `transparency.mjs` (PQ-ANCHOR-v1 append-only log:
|
|
23
23
|
> inclusion + consistency proofs, ML-DSA-65 signed heads, equivocation detection),
|
|
24
24
|
> `primacy.mjs` (first-seen proven by complete enumeration + a Bitcoin-anchored temporal
|
|
25
25
|
> frontier), `policy.mjs` (fail-closed custodian gate), `tapscript.mjs` (BIP-341
|
|
@@ -94,7 +94,7 @@ verifyMigrationCommitment(cert).valid; // true
|
|
|
94
94
|
|
|
95
95
|
```bash
|
|
96
96
|
npx fractal-pqc verify-vector # assert the official BIP-340 test vector
|
|
97
|
-
npx fractal-pqc selftest # run the full real test suite (
|
|
97
|
+
npx fractal-pqc selftest # run the full real test suite (304 checks)
|
|
98
98
|
npx fractal-pqc keygen > key.json # generate a migration identity (testnet/experimental)
|
|
99
99
|
npx fractal-pqc keygen --taproot > tk.json # a Taproot key
|
|
100
100
|
npx fractal-pqc address --key tk.json --network tb # derive a real tb1p… Taproot address
|
|
@@ -132,7 +132,18 @@ Providing the anchor registry (first-seen, no-duplicate, immutable) is part of t
|
|
|
132
132
|
grant-funded roadmap below; this reference makes the anchor a **required verifier input**
|
|
133
133
|
so the property is never silently over-claimed.
|
|
134
134
|
|
|
135
|
-
## What this is NOT
|
|
135
|
+
## What this is NOT
|
|
136
|
+
|
|
137
|
+
> **ONE POLICY KEY PER HOLDER — OR BIND THE OUTPUT.** `authorizeAndSign` reads a 32-byte
|
|
138
|
+
> `spendDigest` chosen by the requester. Round 7 of our own siege used that to have one
|
|
139
|
+
> *legitimate* client of a custodian obtain a policy-key signature over **another client's
|
|
140
|
+
> sighash** — which is exactly the "concentration" deployment this package describes. The
|
|
141
|
+
> engine now **refuses any request that does not say which coin it is for**: supply
|
|
142
|
+
> `spendContext.prevoutScriptPubKey` (enforced against the holder's own anchored commitment)
|
|
143
|
+
> or explicitly assert `singlePolicyKeyPerHolder: true`. Silence is refused. Residual, stated
|
|
144
|
+
> rather than hidden: the engine checks the scriptPubKey the commitment governs, it does not
|
|
145
|
+
> recompute the BIP-341 sighash from the transaction.
|
|
146
|
+
(honest scope — do not overstate)
|
|
136
147
|
|
|
137
148
|
- **Not yet confirmed in a live mempool.** The sighash is proven consensus-correct against
|
|
138
149
|
the official BIP-341 vector, and the broadcaster is built + dry-run-tested — but an actual
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fractal-pqc",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Runnable reference for quantum-safe migration of a Bitcoin-style key: bind secp256k1/Taproot to ML-DSA-65 (FIPS-204), derive P2TR addresses, build+sign BIP-341 key-path spends (official-vector-verified), and broadcast on testnet. Real primitives, honest scope.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/claims-registry.mjs
CHANGED
|
@@ -12,7 +12,8 @@ import * as T from "./transparency.mjs";
|
|
|
12
12
|
import * as P from "./primacy.mjs";
|
|
13
13
|
import * as TS from "./tapscript.mjs";
|
|
14
14
|
import * as OTS from "./ots.mjs";
|
|
15
|
-
import { POLICY_SCOPE, authorizeAndSign, REFUSED } from "./policy.mjs";
|
|
15
|
+
import { POLICY_SCOPE, authorizeAndSign, REFUSED, policyOutputScriptPubKey } from "./policy.mjs";
|
|
16
|
+
import { schnorr } from "@noble/curves/secp256k1.js";
|
|
16
17
|
import { generateMigrationIdentity, createMigrationCommitment, authorizeSpend } from "./migration-envelope.mjs";
|
|
17
18
|
|
|
18
19
|
const te = new TextEncoder();
|
|
@@ -68,6 +69,10 @@ const goodReq = () => ({
|
|
|
68
69
|
anchorEvidence: { sth: STH, entries: ENTRIES, otsHex: OTS_HEX },
|
|
69
70
|
expectedLogId: STH.logId, policyKey: POLICY_KEY,
|
|
70
71
|
cutoffBlockHeight: CUTOFF, blockMerkleRoots: HEADERS,
|
|
72
|
+
// Round 7 made this explicit rather than defaulted: the engine refuses a request that
|
|
73
|
+
// does not say WHICH coin it is for. These fixtures are single-holder scenarios, so they
|
|
74
|
+
// assert the safe deployment instead of silently relying on it.
|
|
75
|
+
singlePolicyKeyPerHolder: true,
|
|
71
76
|
});
|
|
72
77
|
|
|
73
78
|
export const CLAIMS = [
|
|
@@ -224,6 +229,78 @@ export const CLAIMS = [
|
|
|
224
229
|
note: "The founder's insight: what is at risk is the PAST. A global Q-day is a guess; " +
|
|
225
230
|
"a key's exposure block is a fact in Bitcoin.",
|
|
226
231
|
},
|
|
232
|
+
{
|
|
233
|
+
id: "R7-output-binding-or-refusal",
|
|
234
|
+
module: "policy.mjs",
|
|
235
|
+
statement: "The engine never signs without knowing WHICH coin it signs for. Either the " +
|
|
236
|
+
"caller binds the spend to an output (enforced against the holder's own " +
|
|
237
|
+
"anchored commitment) or the caller explicitly asserts one policy key per " +
|
|
238
|
+
"holder. Silence is refused, and one holder's certificate can never authorise " +
|
|
239
|
+
"a spend of another holder's output.",
|
|
240
|
+
proof: () => {
|
|
241
|
+
// Bound to the output this holder's commitment actually governs: authorises, and the
|
|
242
|
+
// receipt SAYS it is bound.
|
|
243
|
+
const anchoredFactHash = authorizeAndSign(goodReq()).evidence.anchoredFactHash;
|
|
244
|
+
const spk = policyOutputScriptPubKey(anchoredFactHash, schnorr.getPublicKey(POLICY_KEY));
|
|
245
|
+
const r = authorizeAndSign({ ...goodReq(), spendContext: { prevoutScriptPubKey: spk } });
|
|
246
|
+
return r.authorized === true && r.evidence.outputBound === true;
|
|
247
|
+
},
|
|
248
|
+
attack: () => {
|
|
249
|
+
// 1. Silence must NOT be the permissive default. This is the whole finding: round 7
|
|
250
|
+
// had one legitimate client of a custodian buy a signature over another client's
|
|
251
|
+
// sighash precisely because omitting the binding authorised anyway.
|
|
252
|
+
const { singlePolicyKeyPerHolder: _drop, ...silent } = goodReq();
|
|
253
|
+
const q = authorizeAndSign(silent);
|
|
254
|
+
if (q.authorized !== false || q.policySignatureHex !== null) return false;
|
|
255
|
+
// 2. A DIFFERENT holder's output must be refused, not signed.
|
|
256
|
+
const other = "11".repeat(32);
|
|
257
|
+
const foreign = policyOutputScriptPubKey(other, schnorr.getPublicKey(POLICY_KEY));
|
|
258
|
+
const r = authorizeAndSign({ ...goodReq(), spendContext: { prevoutScriptPubKey: foreign } });
|
|
259
|
+
if (r.authorized !== false || r.policySignatureHex !== null) return false;
|
|
260
|
+
// 3. A malformed binding must refuse rather than fall back to the unbound path.
|
|
261
|
+
const bad = authorizeAndSign({ ...goodReq(), spendContext: { prevoutScriptPubKey: "00" } });
|
|
262
|
+
if (bad.authorized !== false) return false;
|
|
263
|
+
// 4. The asserted single-key mode still works, and the receipt does not claim binding.
|
|
264
|
+
const a = authorizeAndSign(goodReq());
|
|
265
|
+
return a.authorized === true && a.evidence.outputBound === false;
|
|
266
|
+
},
|
|
267
|
+
note: "Round 7's critical. `spendDigest` is 32 opaque bytes chosen by the requester; " +
|
|
268
|
+
"before this, the receipt named the HOLDER while the coin could be anyone's. " +
|
|
269
|
+
"RESIDUAL, stated rather than hidden: the engine checks that the scriptPubKey is " +
|
|
270
|
+
"the one this commitment governs, but does not recompute the BIP-341 sighash from " +
|
|
271
|
+
"the transaction — the caller must derive the digest from the prevout it presents.",
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
id: "R7-no-claim-without-the-proof-that-earns-it",
|
|
275
|
+
module: "policy.mjs",
|
|
276
|
+
statement: "No evidence field this engine emits can be supplied by the caller. Exposure " +
|
|
277
|
+
"claims are DERIVED from a proof this engine ran, and evidence that can change " +
|
|
278
|
+
"between the check and the use is refused rather than read twice.",
|
|
279
|
+
proof: () => {
|
|
280
|
+
const r = authorizeAndSign({ ...goodReq(), exposureHeight: ANCHOR_HEIGHT + 50_000 });
|
|
281
|
+
return r.evidence.exposureRelative === true && r.evidence.anchoredAtHeight === ANCHOR_HEIGHT;
|
|
282
|
+
},
|
|
283
|
+
attack: () => {
|
|
284
|
+
// 1. Inject the conclusion: a fabricated internal field must never become a claim.
|
|
285
|
+
const inj = authorizeAndSign({ ...goodReq(),
|
|
286
|
+
_exposureProof: { exposureRelative: true, anchoredAtHeight: 1,
|
|
287
|
+
exposureHeight: 9_999_999, strength: "anchored-before-exposure" } });
|
|
288
|
+
if (inj.authorized !== false) return false;
|
|
289
|
+
if (inj.evidence && inj.evidence.exposureRelative === true) return false;
|
|
290
|
+
// 2. Two-faced evidence: an accessor can return different bytes to the gate and to the
|
|
291
|
+
// authorisation. It must be refused outright, not read twice.
|
|
292
|
+
const g = goodReq();
|
|
293
|
+
const twoFaced = { get sth() { return g.anchorEvidence.sth; },
|
|
294
|
+
entries: g.anchorEvidence.entries, otsHex: g.anchorEvidence.otsHex };
|
|
295
|
+
const t = authorizeAndSign({ ...g, anchorEvidence: twoFaced });
|
|
296
|
+
return t.authorized === false && t.policySignatureHex === null;
|
|
297
|
+
},
|
|
298
|
+
note: "Round 7 found BOTH halves: `_exposureProof` travelled on the caller's object and " +
|
|
299
|
+
"`exposureRelative: true` was a hardcoded literal, so a plain JSON request produced " +
|
|
300
|
+
"this package's strongest claim with no proof behind it — even when the injected " +
|
|
301
|
+
"object said false. The TOCTOU discipline applied to spendDigest had not been " +
|
|
302
|
+
"applied to anchorEvidence one field further out.",
|
|
303
|
+
},
|
|
227
304
|
{
|
|
228
305
|
id: "R6-exposure-gate-enforced",
|
|
229
306
|
module: "policy.mjs",
|
|
@@ -501,9 +578,18 @@ export const UNCLAIMED_GUARDS = Object.freeze([
|
|
|
501
578
|
"blocks) — again stronger than a claim, again not in the ledger" },
|
|
502
579
|
{ where: "policy.mjs", guard: "the ORDER of the refusal guards",
|
|
503
580
|
why: "each guard is claimed individually; nothing claims that reordering them is safe" },
|
|
504
|
-
{ where: "
|
|
505
|
-
why: "
|
|
506
|
-
"
|
|
581
|
+
{ where: "policy.mjs", guard: "WHICH OUTPUT is being spent — unless spendContext is supplied",
|
|
582
|
+
why: "the engine reads a 32-byte spendDigest chosen by the requester. WITHOUT " +
|
|
583
|
+
"`spendContext.prevoutScriptPubKey` the receipt identifies the HOLDER, never the " +
|
|
584
|
+
"COIN: it records who was authorised to make the policy key sign SOMETHING. Round 7 " +
|
|
585
|
+
"used exactly this to have one legitimate client of a custodian buy a signature over " +
|
|
586
|
+
"another client's sighash. Supply spendContext (enforced when present) or use ONE " +
|
|
587
|
+
"POLICY KEY PER HOLDER, without exception" },
|
|
588
|
+
{ where: "policy.mjs / primacy.mjs", guard: "prose-to-code agreement",
|
|
589
|
+
why: "the ledger governs CODE -> CLAIM (kill the code and the claim dies). NOTHING " +
|
|
590
|
+
"governs PROSE -> CODE. Every one of round 7's findings landed in that hole, " +
|
|
591
|
+
"including a frozen *_SCOPE string that asserted the M2 gate did not exist while " +
|
|
592
|
+
"the gate was refusing signatures. This is the ledger's own known limit" },
|
|
507
593
|
{ where: "primacy.mjs", guard: "'never exposed' is the CALLER's assertion",
|
|
508
594
|
why: "the kit cannot verify non-exposure. It can only refuse when the field is absent, " +
|
|
509
595
|
"which it now does. Off-chain publication of a key is outside what we measure" },
|
package/src/mutations.mjs
CHANGED
|
@@ -238,6 +238,33 @@ export const MUTATIONS = [
|
|
|
238
238
|
to: ` return { equivocation: false, reason: "heads-agree" };`,
|
|
239
239
|
mustKill: ["R1-same-size-is-non-repudiable"],
|
|
240
240
|
},
|
|
241
|
+
{
|
|
242
|
+
id: "M-R7-output-binding-optional",
|
|
243
|
+
describes: "R7: silence becomes the permissive default again — the engine signs without " +
|
|
244
|
+
"ever knowing which coin it is signing for",
|
|
245
|
+
file: "src/policy.mjs",
|
|
246
|
+
from: ` if (!req || (!Object.hasOwn(req, "spendContext") && req.singlePolicyKeyPerHolder !== true)) {`,
|
|
247
|
+
to: ` if (false) {`,
|
|
248
|
+
mustKill: ["R7-output-binding-or-refusal"],
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
id: "M-R7-injected-conclusion-accepted",
|
|
252
|
+
describes: "R7: internal fields travel on the caller's request again, so a fabricated " +
|
|
253
|
+
"_exposureProof states this package's strongest claim with no proof behind it",
|
|
254
|
+
file: "src/policy.mjs",
|
|
255
|
+
from: ` if (k.startsWith("_")) {`,
|
|
256
|
+
to: ` if (k.startsWith("\u0000NEVER")) {`,
|
|
257
|
+
mustKill: ["R7-no-claim-without-the-proof-that-earns-it"],
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
id: "M-R7-two-faced-evidence",
|
|
261
|
+
describes: "R7: accessor-backed anchorEvidence is read twice again — the gate judges one " +
|
|
262
|
+
"anchor and the authorisation rests on another",
|
|
263
|
+
file: "src/policy.mjs",
|
|
264
|
+
from: ` if (d && typeof d.get === "function") {`,
|
|
265
|
+
to: ` if (false) {`,
|
|
266
|
+
mustKill: ["R7-no-claim-without-the-proof-that-earns-it"],
|
|
267
|
+
},
|
|
241
268
|
];
|
|
242
269
|
|
|
243
270
|
/**
|
package/src/policy.mjs
CHANGED
|
@@ -49,8 +49,46 @@ import { schnorr } from "@noble/curves/secp256k1.js";
|
|
|
49
49
|
import { verifySpend } from "./migration-envelope.mjs";
|
|
50
50
|
import { certIsFirstSeen, proveExposureRelativePrimacy } from "./primacy.mjs";
|
|
51
51
|
import { detectEquivocation } from "./transparency.mjs";
|
|
52
|
+
import { NUMS_INTERNAL_KEY, tapLeafHash, taprootOutputKeyWithTree } from "./tapscript.mjs";
|
|
52
53
|
|
|
53
54
|
const toHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
|
|
55
|
+
const fromHexP = (h) => {
|
|
56
|
+
if (typeof h !== "string" || h.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(h)) return null;
|
|
57
|
+
const out = new Uint8Array(h.length / 2);
|
|
58
|
+
for (let i = 0; i < out.length; i++) out[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16);
|
|
59
|
+
return out;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* THE CANONICAL POLICY LEAF — the thing that makes an authorisation about a COIN and not
|
|
64
|
+
* merely about a person.
|
|
65
|
+
*
|
|
66
|
+
* <32-byte anchoredFactHash> OP_DROP <32-byte policy x-only key> OP_CHECKSIG
|
|
67
|
+
*
|
|
68
|
+
* The pushed fact hash is consensus-inert (OP_DROP discards it) but it is COMMITTED: it is
|
|
69
|
+
* inside the leaf, so it is inside the TapLeaf hash, so it is inside the Taproot output key,
|
|
70
|
+
* so it is inside the scriptPubKey the chain already holds. One holder's commitment therefore
|
|
71
|
+
* produces exactly one output key, and no other holder's does.
|
|
72
|
+
*/
|
|
73
|
+
export function policyLeafScript(anchoredFactHashHex, policyXOnly) {
|
|
74
|
+
const fh = fromHexP(anchoredFactHashHex);
|
|
75
|
+
if (!fh || fh.length !== 32) return null;
|
|
76
|
+
if (!(policyXOnly instanceof Uint8Array) || policyXOnly.length !== 32) return null;
|
|
77
|
+
const out = new Uint8Array(1 + 32 + 1 + 1 + 32 + 1);
|
|
78
|
+
out[0] = 0x20; out.set(fh, 1); out[33] = 0x75; // push32 <factHash> OP_DROP
|
|
79
|
+
out[34] = 0x20; out.set(policyXOnly, 35); out[67] = 0xac; // push32 <policyPub> OP_CHECKSIG
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The P2TR scriptPubKey a given holder's commitment must produce: OP_1 PUSH32 <outputKey>. */
|
|
84
|
+
export function policyOutputScriptPubKey(anchoredFactHashHex, policyXOnly) {
|
|
85
|
+
const leaf = policyLeafScript(anchoredFactHashHex, policyXOnly);
|
|
86
|
+
if (!leaf) return null;
|
|
87
|
+
const { outputKey } = taprootOutputKeyWithTree(NUMS_INTERNAL_KEY, tapLeafHash(leaf));
|
|
88
|
+
const spk = new Uint8Array(34);
|
|
89
|
+
spk[0] = 0x51; spk[1] = 0x20; spk.set(outputKey, 2);
|
|
90
|
+
return spk;
|
|
91
|
+
}
|
|
54
92
|
|
|
55
93
|
export const REFUSED = Object.freeze({
|
|
56
94
|
NO_ANCHOR_EVIDENCE: "no-anchor-evidence",
|
|
@@ -59,6 +97,8 @@ export const REFUSED = Object.freeze({
|
|
|
59
97
|
PQ_INVALID: "pq-signature-invalid",
|
|
60
98
|
NO_POLICY_KEY: "policy-key-unavailable",
|
|
61
99
|
BAD_DIGEST: "spend-digest-malformed",
|
|
100
|
+
MALFORMED_REQUEST: "request-malformed",
|
|
101
|
+
OUTPUT_NOT_BOUND: "spend-not-bound-to-this-holder-output",
|
|
62
102
|
ERROR: "internal-error-fail-closed",
|
|
63
103
|
});
|
|
64
104
|
|
|
@@ -88,6 +128,22 @@ export function authorizeAndSign(req) {
|
|
|
88
128
|
cert, spendDigest, pqSignatureHex, anchorEvidence, expectedLogId, policyKey,
|
|
89
129
|
} = req || {};
|
|
90
130
|
|
|
131
|
+
// ROUND 7. `_exposureProof` used to be routed BACK through the caller's own object, so a
|
|
132
|
+
// plain JSON request carrying a fabricated `_exposureProof` made the engine emit this
|
|
133
|
+
// package's STRONGEST claim — exposure-relative primacy, invented heights, "no Q-day
|
|
134
|
+
// estimate is involved" — without the gate ever running, and even when the injected
|
|
135
|
+
// object said the opposite. That is round 6's defect (absent field buys the maximal
|
|
136
|
+
// claim) reappearing inside the file written to cure it. Internal state never travels
|
|
137
|
+
// on the request, and a request that carries any is refused rather than sanitised.
|
|
138
|
+
for (const k of Object.keys(req || {})) {
|
|
139
|
+
if (k.startsWith("_")) {
|
|
140
|
+
return refuse(REFUSED.MALFORMED_REQUEST,
|
|
141
|
+
`The request carries an internal field (${k}). Fields prefixed with "_" are produced ` +
|
|
142
|
+
`by this engine and never accepted from a caller: supplying one is an attempt to ` +
|
|
143
|
+
`state a conclusion instead of proving it.`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
91
147
|
// TOCTOU: `instanceof` + `.length` are both satisfied by a Proxy that returns
|
|
92
148
|
// different bytes on successive reads, so the ML-DSA check and the Schnorr signature
|
|
93
149
|
// saw DIFFERENT digests — one authorised spend bought a signature over another.
|
|
@@ -109,6 +165,29 @@ export function authorizeAndSign(req) {
|
|
|
109
165
|
"head and the complete ordered entry list. The anchor is the whole quantum property; " +
|
|
110
166
|
"without it there is nothing to enforce.");
|
|
111
167
|
}
|
|
168
|
+
|
|
169
|
+
// ROUND 7, THE SAME TOCTOU WE ALREADY FIXED ONCE — one field further out. `spendDigest`
|
|
170
|
+
// got `snapshot32` after an earlier siege; `anchorEvidence` did not, and it is read by
|
|
171
|
+
// the M2 gate, then again by certIsFirstSeen, then again by detectEquivocation. A single
|
|
172
|
+
// accessor split that seam: the exposure gate judged an EARLY anchor while authorisation
|
|
173
|
+
// was founded on a LATE one, and the receipt came out contradicting itself. Discipline is
|
|
174
|
+
// only discipline if it is applied to every caller-supplied input: refuse accessor-backed
|
|
175
|
+
// carriers, then snapshot ONCE and read nothing but the snapshot below.
|
|
176
|
+
for (const k of ["sth", "entries", "otsHex"]) {
|
|
177
|
+
const d = Object.getOwnPropertyDescriptor(anchorEvidence, k);
|
|
178
|
+
if (d && typeof d.get === "function") {
|
|
179
|
+
return refuse(REFUSED.NO_ANCHOR_EVIDENCE,
|
|
180
|
+
`anchorEvidence.${k} is accessor-backed, so it can return different bytes on ` +
|
|
181
|
+
`successive reads. Evidence that is allowed to change between the check and the ` +
|
|
182
|
+
`use is refused outright rather than read twice.`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const ev = Object.freeze({
|
|
186
|
+
sth: anchorEvidence.sth,
|
|
187
|
+
entries: anchorEvidence.entries.slice(),
|
|
188
|
+
otsHex: anchorEvidence.otsHex,
|
|
189
|
+
});
|
|
190
|
+
const subjectPub = cert?.classicalPub; // read ONCE; every consumer below uses this copy
|
|
112
191
|
if (req && req.cutoffBlockHeight != null &&
|
|
113
192
|
(typeof req.cutoffBlockHeight !== "number" || !Number.isSafeInteger(req.cutoffBlockHeight) ||
|
|
114
193
|
req.cutoffBlockHeight < 0)) {
|
|
@@ -144,7 +223,7 @@ export function authorizeAndSign(req) {
|
|
|
144
223
|
// contradiction instead of warning about it.
|
|
145
224
|
if (Array.isArray(req.knownHeads) && req.knownHeads.length) {
|
|
146
225
|
for (const other of req.knownHeads) {
|
|
147
|
-
const d = detectEquivocation(
|
|
226
|
+
const d = detectEquivocation(ev.sth, other);
|
|
148
227
|
if (d.equivocation === true) {
|
|
149
228
|
return refuse(REFUSED.ANCHOR_UNRESOLVED,
|
|
150
229
|
"This log signed two contradictory histories. Every proof it serves is void, " +
|
|
@@ -159,15 +238,23 @@ export function authorizeAndSign(req) {
|
|
|
159
238
|
// neither — shipping a stronger proof that nothing consumes — is how a package ends up
|
|
160
239
|
// claiming more than it enforces. This is the wiring.
|
|
161
240
|
//
|
|
162
|
-
// FAIL-CLOSED
|
|
163
|
-
//
|
|
164
|
-
// can be
|
|
241
|
+
// FAIL-CLOSED WHEN SUPPLIED: if the caller supplies `exposureHeight` as an OWN data
|
|
242
|
+
// property, it is ENFORCED — it cannot be passed and silently dropped, because a
|
|
243
|
+
// parameter that can be is worse than one that does not exist. Two honest bounds, both
|
|
244
|
+
// found by round 7 and both stated here rather than only in prose: (1) "supplied" means
|
|
245
|
+
// an own property, so an inherited or Proxy-hidden one is treated as ABSENT and falls
|
|
246
|
+
// back to the cutoff frontier — it never yields a false exposure claim, but it is not
|
|
247
|
+
// enforced either; (2) this gate is a CONJUNCTION, never a substitution: cutoffBlockHeight
|
|
248
|
+
// stays mandatory above and certIsFirstSeen still applies it below. Supplying an exposure
|
|
249
|
+
// height NARROWS what authorises. It never rescues an anchor the cutoff rejects.
|
|
250
|
+
let exposureProof = null;
|
|
165
251
|
if (req && Object.hasOwn(req, "exposureHeight")) {
|
|
252
|
+
const exposureHeight = req.exposureHeight; // ONE read; the gate and the evidence share it
|
|
166
253
|
const exp = proveExposureRelativePrimacy({
|
|
167
|
-
subjectClassicalPub:
|
|
168
|
-
entries:
|
|
169
|
-
exposureHeight
|
|
170
|
-
otsHex:
|
|
254
|
+
subjectClassicalPub: subjectPub,
|
|
255
|
+
entries: ev.entries, sth: ev.sth, expectedLogId,
|
|
256
|
+
exposureHeight,
|
|
257
|
+
otsHex: ev.otsHex, blockMerkleRoots: req.blockMerkleRoots,
|
|
171
258
|
});
|
|
172
259
|
if (!exp.exposureRelative) {
|
|
173
260
|
return refuse(REFUSED.ANCHOR_UNRESOLVED,
|
|
@@ -179,8 +266,9 @@ export function authorizeAndSign(req) {
|
|
|
179
266
|
: `Exposure-relative primacy could not be established (${exp.reason}).`,
|
|
180
267
|
{ exposure: exp });
|
|
181
268
|
}
|
|
182
|
-
//
|
|
183
|
-
|
|
269
|
+
// Held in a LOCAL. It never re-enters the caller's object, so nothing the caller
|
|
270
|
+
// supplies can impersonate a proof this engine did not run.
|
|
271
|
+
exposureProof = exp;
|
|
184
272
|
}
|
|
185
273
|
|
|
186
274
|
// PRIMACY, not membership. The anchor is the EARLIEST binding of this certificate's
|
|
@@ -190,9 +278,9 @@ export function authorizeAndSign(req) {
|
|
|
190
278
|
// below the cutoff separates them. `requireTemporalFrontier` defaults to true and this
|
|
191
279
|
// engine never turns it off.
|
|
192
280
|
const anchor = certIsFirstSeen({
|
|
193
|
-
cert, entries:
|
|
281
|
+
cert, entries: ev.entries, sth: ev.sth, expectedLogId,
|
|
194
282
|
cutoffBlockHeight: req.cutoffBlockHeight,
|
|
195
|
-
otsHex:
|
|
283
|
+
otsHex: ev.otsHex,
|
|
196
284
|
blockMerkleRoots: req.blockMerkleRoots,
|
|
197
285
|
requireTemporalFrontier: true,
|
|
198
286
|
});
|
|
@@ -206,6 +294,67 @@ export function authorizeAndSign(req) {
|
|
|
206
294
|
{ anchor });
|
|
207
295
|
}
|
|
208
296
|
|
|
297
|
+
// ── ROUND 7'S CRITICAL: THE ENGINE NEVER KNEW WHICH COIN IT WAS SIGNING ──────
|
|
298
|
+
// `spendDigest` is 32 opaque bytes chosen by whoever asks, and until now NOTHING tied
|
|
299
|
+
// them to an output. Our own siege stood up a SECOND, entirely legitimate client of the
|
|
300
|
+
// same custodian — its own first-seen certificate, honestly anchored strictly before its
|
|
301
|
+
// own exposure — and asked for a signature over the VICTIM's sighash. Every check passed
|
|
302
|
+
// and the policy key signed. The receipt named the bystander's fact hash while the coin
|
|
303
|
+
// belonged to someone else.
|
|
304
|
+
//
|
|
305
|
+
// This is not a corner case. It is precisely the deployment the paragraph below SELLS:
|
|
306
|
+
// one policy key across N tapscript leaves. Concentration without output binding turns
|
|
307
|
+
// every authorised client into a thief of every other client of the same custodian.
|
|
308
|
+
//
|
|
309
|
+
// The binding: the leaf governing a holder's output COMMITS to that holder's
|
|
310
|
+
// anchoredFactHash, so we can recompute the only scriptPubKey this certificate could
|
|
311
|
+
// legitimately govern and refuse if it is not the coin in front of us. Enforced whenever
|
|
312
|
+
// `spendContext` is supplied, with the same discipline as the cutoff: supplied means
|
|
313
|
+
// enforced, and refusal is the default on anything malformed.
|
|
314
|
+
// FAIL-CLOSED, NOT MERELY DOCUMENTED. A footgun you can step on by omission is a
|
|
315
|
+
// fail-open default with a paragraph next to it. There are exactly two safe
|
|
316
|
+
// configurations, and the caller must be in one of them ON PURPOSE:
|
|
317
|
+
// (a) supply `spendContext` and the engine binds the authorisation to the coin; or
|
|
318
|
+
// (b) assert `singlePolicyKeyPerHolder: true`, which is safe because a key that
|
|
319
|
+
// governs one holder's outputs cannot be aimed at another holder's coin.
|
|
320
|
+
// Silence is neither, and silence is what round 7 exploited.
|
|
321
|
+
let outputBinding = null;
|
|
322
|
+
if (!req || (!Object.hasOwn(req, "spendContext") && req.singlePolicyKeyPerHolder !== true)) {
|
|
323
|
+
return refuse(REFUSED.OUTPUT_NOT_BOUND,
|
|
324
|
+
"This request does not say WHICH COIN it is for. `spendDigest` is 32 opaque bytes " +
|
|
325
|
+
"chosen by the requester, so without a binding this engine would authorise the " +
|
|
326
|
+
"holder rather than the spend — and under one shared policy key that lets any " +
|
|
327
|
+
"authorised client obtain a signature over any other client's sighash. Our own " +
|
|
328
|
+
"round-7 siege did exactly that. Supply `spendContext.prevoutScriptPubKey` (the " +
|
|
329
|
+
"34-byte P2TR scriptPubKey of the output being spent) and it will be ENFORCED, or " +
|
|
330
|
+
"state `singlePolicyKeyPerHolder: true` if this policy key governs exactly one " +
|
|
331
|
+
"holder's outputs. We will not pick for you, and we will not default to the unsafe one.");
|
|
332
|
+
}
|
|
333
|
+
if (req && Object.hasOwn(req, "spendContext")) {
|
|
334
|
+
const sc = req.spendContext;
|
|
335
|
+
const spk = sc && sc.prevoutScriptPubKey;
|
|
336
|
+
const given = spk instanceof Uint8Array ? spk.slice()
|
|
337
|
+
: (typeof spk === "string" ? fromHexP(spk) : null);
|
|
338
|
+
if (!given || given.length !== 34 || given[0] !== 0x51 || given[1] !== 0x20) {
|
|
339
|
+
return refuse(REFUSED.OUTPUT_NOT_BOUND,
|
|
340
|
+
"spendContext.prevoutScriptPubKey must be the 34-byte P2TR scriptPubKey of the " +
|
|
341
|
+
"output being spent (OP_1 PUSH32 <output key>). It is the only thing that makes " +
|
|
342
|
+
"this authorisation about a COIN rather than about a person.");
|
|
343
|
+
}
|
|
344
|
+
const expected = policyOutputScriptPubKey(anchor.anchoredFactHash,
|
|
345
|
+
schnorr.getPublicKey(policyKey));
|
|
346
|
+
if (!expected || toHex(expected) !== toHex(given)) {
|
|
347
|
+
return refuse(REFUSED.OUTPUT_NOT_BOUND,
|
|
348
|
+
"This output is not governed by THIS holder's commitment. The Taproot output key " +
|
|
349
|
+
"recomputed from the anchored first-seen fact hash and the policy key does not " +
|
|
350
|
+
"match the scriptPubKey being spent, so authorising it would let one client of " +
|
|
351
|
+
"this custodian spend another client's coin.",
|
|
352
|
+
{ expectedScriptPubKeyHex: expected ? toHex(expected) : null,
|
|
353
|
+
presentedScriptPubKeyHex: toHex(given) });
|
|
354
|
+
}
|
|
355
|
+
outputBinding = { bound: true, scriptPubKeyHex: toHex(given) };
|
|
356
|
+
}
|
|
357
|
+
|
|
209
358
|
const spend = verifySpend(cert, digest, pqSignatureHex, {
|
|
210
359
|
anchoredFactHash: anchor.anchoredFactHash,
|
|
211
360
|
});
|
|
@@ -228,15 +377,38 @@ export function authorizeAndSign(req) {
|
|
|
228
377
|
ignoredUnsignedEntries: anchor.ignoredUnsignedEntries,
|
|
229
378
|
temporalFrontier: anchor.temporalFrontier,
|
|
230
379
|
quantumPropertyHolds: anchor.quantumPropertyHolds,
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
380
|
+
// DERIVED from the proof this engine ran, never a literal. Round 7 emitted a
|
|
381
|
+
// hardcoded `true` here, so an injected object produced the maximal claim with no
|
|
382
|
+
// proof behind it — including when the injected object itself said false.
|
|
383
|
+
...(exposureProof ? {
|
|
384
|
+
exposureRelative: exposureProof.exposureRelative === true,
|
|
385
|
+
anchoredAtHeight: exposureProof.anchoredAtHeight,
|
|
386
|
+
exposureHeight: exposureProof.exposureHeight,
|
|
387
|
+
strength: exposureProof.strength,
|
|
388
|
+
// HONEST WORDING, round 7. The previous sentence said "no Q-day estimate is
|
|
389
|
+
// involved" about an authorisation that had ALSO required, and applied, the
|
|
390
|
+
// caller's cutoff. The exposure gate is a CONJUNCTION: it narrows, it never
|
|
391
|
+
// rescues. An anchor the cutoff rejects is still rejected however strong the
|
|
392
|
+
// exposure proof is.
|
|
393
|
+
whyThisIsStronger:
|
|
394
|
+
`this authorisation satisfied BOTH frontiers: the cutoff you chose ` +
|
|
395
|
+
`(H=${req.cutoffBlockHeight}), which is an estimate, AND — additionally — that ` +
|
|
396
|
+
`the commitment was anchored strictly before the chain first revealed this ` +
|
|
397
|
+
`public key. Only the SECOND of those depends on no Q-day estimate. It is an ` +
|
|
398
|
+
`extra condition, not a replacement for the first.`,
|
|
239
399
|
} : {}),
|
|
400
|
+
// Stated on EVERY receipt, true or false, so its absence can never be mistaken for
|
|
401
|
+
// a guarantee that was never given.
|
|
402
|
+
outputBound: outputBinding !== null,
|
|
403
|
+
...(outputBinding ? { boundScriptPubKeyHex: outputBinding.scriptPubKeyHex } : {
|
|
404
|
+
outputBindingCaveat:
|
|
405
|
+
"no spendContext was supplied. This receipt identifies the HOLDER, not the COIN: " +
|
|
406
|
+
"it records who was authorised to make the policy key sign SOMETHING. It is only " +
|
|
407
|
+
"sound because the caller ASSERTED singlePolicyKeyPerHolder — an assertion this " +
|
|
408
|
+
"engine cannot verify. If that assertion is false, this receipt does not mean " +
|
|
409
|
+
"what it appears to mean.",
|
|
410
|
+
singlePolicyKeyPerHolderAsserted: true,
|
|
411
|
+
}),
|
|
240
412
|
digestSigned: toHex(digest),
|
|
241
413
|
logId: anchor.logId,
|
|
242
414
|
logPinned: true,
|
|
@@ -301,9 +473,20 @@ export const POLICY_SCOPE = Object.freeze({
|
|
|
301
473
|
"the policy key and nothing else, so a quantum adversary who reaches that key spends " +
|
|
302
474
|
"anyway. What this does is CONCENTRATE risk — N exposed classical keys become one " +
|
|
303
475
|
"hash-hidden, rotatable, custodian-controlled key — and produce a post-quantum-signed, " +
|
|
304
|
-
"verifiable record of who
|
|
305
|
-
"
|
|
306
|
-
"
|
|
476
|
+
"verifiable record of who was authorised to make that key sign. It is not immunity, and " +
|
|
477
|
+
"we will not describe it as one. NOTE THE CORRECTION: this string used to read 'who " +
|
|
478
|
+
"SHOULD have been allowed to spend', which claims a binding to a COIN that the engine " +
|
|
479
|
+
"only performs when `spendContext` is supplied — see outputBinding below.",
|
|
480
|
+
outputBinding:
|
|
481
|
+
"THE COIN IS ONLY BOUND WHEN YOU BIND IT. Supply `spendContext.prevoutScriptPubKey` and " +
|
|
482
|
+
"the engine recomputes the Taproot output key from THIS holder's anchoredFactHash and " +
|
|
483
|
+
"the policy key, and refuses any other output — enforced whenever supplied. OMIT IT and " +
|
|
484
|
+
"the authorisation is about the HOLDER, not the coin: the requester chooses 32 opaque " +
|
|
485
|
+
"digest bytes and nothing checks what they belong to. Round 7 of our own siege used that " +
|
|
486
|
+
"to have one legitimate client of a custodian obtain a policy-key signature over another " +
|
|
487
|
+
"client's sighash — which is exactly the 'concentration' deployment described above. " +
|
|
488
|
+
"Either supply spendContext, or run ONE POLICY KEY PER HOLDER. Every receipt states " +
|
|
489
|
+
"`outputBound` so its absence can never be mistaken for a guarantee.",
|
|
307
490
|
limitFour_historyNotPinned:
|
|
308
491
|
"pinning the log IDENTITY does not pin its HISTORY. Two same-size heads under the same " +
|
|
309
492
|
"logId both satisfy every check here. Split view is DETECTABLE (transparency." +
|
|
@@ -311,11 +494,15 @@ export const POLICY_SCOPE = Object.freeze({
|
|
|
311
494
|
"witness, mirror or gossip channel and the engine refuses on any contradiction; without " +
|
|
312
495
|
"them you are trusting the log to have shown you its only history.",
|
|
313
496
|
exposureRelativeGate:
|
|
314
|
-
"if `exposureHeight` is supplied it is ENFORCED, never advisory — a
|
|
315
|
-
"be passed and silently dropped is worse than one that does not
|
|
316
|
-
"authorisation requires the commitment to have been
|
|
317
|
-
"first revealed the key,
|
|
318
|
-
"
|
|
497
|
+
"if `exposureHeight` is supplied as an OWN property it is ENFORCED, never advisory — a " +
|
|
498
|
+
"parameter that can be passed and silently dropped is worse than one that does not " +
|
|
499
|
+
"exist. When enforced, authorisation ADDITIONALLY requires the commitment to have been " +
|
|
500
|
+
"anchored strictly before the chain first revealed the key, and THAT condition needs no " +
|
|
501
|
+
"Q-day estimate. It is a CONJUNCTION, never a substitution: cutoffBlockHeight stays " +
|
|
502
|
+
"mandatory and is still applied afterwards, so a commitment anchored at 800000 with " +
|
|
503
|
+
"exposure at 850000 is STILL refused under a cutoff of 790000. The gate narrows what " +
|
|
504
|
+
"authorises; it never rescues what the cutoff rejects. Omit it and the engine falls back " +
|
|
505
|
+
"to the cutoff frontier alone, which is a guess and says so.",
|
|
319
506
|
precondition:
|
|
320
507
|
"the true holder must have registered a dual-signed commitment BEFORE the cutoff. If " +
|
|
321
508
|
"they never did, no one is authorised — not them, not an attacker. Those coins are " +
|
package/src/primacy.mjs
CHANGED
|
@@ -668,10 +668,24 @@ export const EXPOSURE_SCOPE = Object.freeze({
|
|
|
668
668
|
"shared xpub, or anywhere off-chain was derivable earlier than this proof suggests. " +
|
|
669
669
|
"The adversary's window opens at min(any publication of the key, on-chain exposure); " +
|
|
670
670
|
"this kit measures the second.",
|
|
671
|
-
|
|
672
|
-
"policy.mjs
|
|
673
|
-
"
|
|
674
|
-
"
|
|
671
|
+
isAnAuthorisationGate:
|
|
672
|
+
"policy.mjs DOES consume this. If authorizeAndSign receives `exposureHeight` as an own " +
|
|
673
|
+
"property, exposure-relative primacy becomes a NECESSARY condition for signing and the " +
|
|
674
|
+
"engine refuses without it. It stays opt-in: omit the field and the engine falls back " +
|
|
675
|
+
"to the cutoff frontier. (This string previously said the opposite and stayed stale " +
|
|
676
|
+
"after M2 wired the gate — round 7 caught the package contradicting itself in its own " +
|
|
677
|
+
"machine-readable scope. That is the disease, with the polarity inverted.)",
|
|
678
|
+
narrowsNeverRescues:
|
|
679
|
+
"the gate is a CONJUNCTION, never a substitution. cutoffBlockHeight remains mandatory " +
|
|
680
|
+
"and certIsFirstSeen still applies it AFTER this check, so an anchor at 800000 with " +
|
|
681
|
+
"exposure at 850000 — the strongest position this scheme can give — is still REFUSED " +
|
|
682
|
+
"under a cutoff of 790000. Supplying an exposure height narrows what authorises; it " +
|
|
683
|
+
"never rescues what the cutoff rejects.",
|
|
684
|
+
blindToTheCertificate:
|
|
685
|
+
"proveExposureRelativePrimacy judges by `subjectClassicalPub` ALONE — it never sees the " +
|
|
686
|
+
"certificate presented. In policy.mjs that composes correctly, because certIsFirstSeen " +
|
|
687
|
+
"kills the post-Shor rebinding attacker afterwards. Used ON ITS OWN as a gate, this " +
|
|
688
|
+
"primitive AUTHORISES that attacker. If you reuse it, compose it as policy.mjs does.",
|
|
675
689
|
exposureHeightIsNotOurs:
|
|
676
690
|
"the verifier supplies it from their own node. An exposure height we asserted would be " +
|
|
677
691
|
"one more thing to trust us about, and the point is that it is independently checkable.",
|
package/test/m2-policy.mjs
CHANGED
|
@@ -99,7 +99,7 @@ const evidence = { sth, entries: entryList };
|
|
|
99
99
|
const HEADERS = { 800000: "aa".repeat(32) };
|
|
100
100
|
const base = { cert, spendDigest: digest, pqSignatureHex: pqSig, anchorEvidence: evidence,
|
|
101
101
|
expectedLogId: sth.logId, policyKey: policyPriv, cutoffBlockHeight: CUTOFF,
|
|
102
|
-
blockMerkleRoots: HEADERS };
|
|
102
|
+
blockMerkleRoots: HEADERS, singlePolicyKeyPerHolder: true };
|
|
103
103
|
{
|
|
104
104
|
check("REFUSES with no transparency-log evidence at all",
|
|
105
105
|
authorizeAndSign({ ...base, anchorEvidence: undefined }).reason === REFUSED.NO_ANCHOR_EVIDENCE);
|
|
@@ -108,7 +108,7 @@ const base = { cert, spendDigest: digest, pqSignatureHex: pqSig, anchorEvidence:
|
|
|
108
108
|
check("★ REFUSES when no Q-day cutoff is supplied — it will not guess it for you",
|
|
109
109
|
authorizeAndSign({ ...base, cutoffBlockHeight: undefined }).reason === REFUSED.NO_ANCHOR_EVIDENCE);
|
|
110
110
|
check("★ REFUSES when no block headers are supplied — the .ots alone is not a Bitcoin fact",
|
|
111
|
-
authorizeAndSign({ ...base, blockMerkleRoots: undefined }).detail.includes("never from the bundle"));
|
|
111
|
+
authorizeAndSign({ ...base, blockMerkleRoots: undefined, singlePolicyKeyPerHolder: true }).detail.includes("never from the bundle"));
|
|
112
112
|
check("REFUSES when the entry list is incomplete (primacy cannot be proven)",
|
|
113
113
|
authorizeAndSign({ ...base, anchorEvidence: { ...evidence, entries: entryList.slice(0, 1) } }).reason === REFUSED.ANCHOR_UNRESOLVED);
|
|
114
114
|
check("REFUSES when the log is the WRONG log",
|
package/test/primacy.mjs
CHANGED
|
@@ -189,7 +189,7 @@ console.log("\n★★★ ROUND 3 — what Bitcoin freezes is the PROOF OF CONTRO
|
|
|
189
189
|
const HEADERS = { [real.height]: real.root }; HDRS = HEADERS; // what a verifier gets from THEIR node
|
|
190
190
|
|
|
191
191
|
// (a) THE POSITIVE PATH — the test that did not exist in any previous round.
|
|
192
|
-
const ok = establishTemporalFrontier({ headBytes, otsHex, cutoffBlockHeight: 900_000, blockMerkleRoots: HEADERS });
|
|
192
|
+
const ok = establishTemporalFrontier({ headBytes, otsHex, cutoffBlockHeight: 900_000, blockMerkleRoots: HEADERS, singlePolicyKeyPerHolder: true });
|
|
193
193
|
check("★ (a) POSITIVE PATH: header-confirmed anchor below the cutoff ESTABLISHES the frontier",
|
|
194
194
|
ok.established === true && ok.headerConfirmed === true && ok.height === real.height);
|
|
195
195
|
|
|
@@ -208,17 +208,17 @@ console.log("\n★★★ ROUND 3 — what Bitcoin freezes is the PROOF OF CONTRO
|
|
|
208
208
|
f.timestamp.attestations = [...(f.timestamp.attestations || []), { kind: "bitcoin", height: 1 }];
|
|
209
209
|
return T.toHex(O.encodeOtsFile(f));
|
|
210
210
|
})();
|
|
211
|
-
const t = establishTemporalFrontier({ headBytes, otsHex: tampered, cutoffBlockHeight: 500, blockMerkleRoots: HEADERS });
|
|
211
|
+
const t = establishTemporalFrontier({ headBytes, otsHex: tampered, cutoffBlockHeight: 500, blockMerkleRoots: HEADERS, singlePolicyKeyPerHolder: true });
|
|
212
212
|
check("★ (d) REGRESSION: grafting {bitcoin, height:1} no longer downgrades a real anchor",
|
|
213
213
|
t.established === false);
|
|
214
214
|
check("...and the real anchor is still selected when the cutoff allows it",
|
|
215
215
|
establishTemporalFrontier({ headBytes, otsHex: tampered, cutoffBlockHeight: 900_000,
|
|
216
|
-
blockMerkleRoots: HEADERS }).height === real.height);
|
|
216
|
+
blockMerkleRoots: HEADERS, singlePolicyKeyPerHolder: true }).height === real.height);
|
|
217
217
|
|
|
218
218
|
// (e) a .ots forged from scratch, claiming a height the verifier never enumerated.
|
|
219
219
|
check("★ (e) FIX: a forged .ots claiming an unenumerated height is REFUSED, not skipped",
|
|
220
220
|
establishTemporalFrontier({ headBytes, otsHex: mkOts(123), cutoffBlockHeight: 900_000,
|
|
221
|
-
blockMerkleRoots: HEADERS }).reason === "no-attestation-header-confirmed");
|
|
221
|
+
blockMerkleRoots: HEADERS, singlePolicyKeyPerHolder: true }).reason === "no-attestation-header-confirmed");
|
|
222
222
|
|
|
223
223
|
// (f) THE DECISIVE ONE: squat a bare pair pre-cutoff, attach signatures after Shor.
|
|
224
224
|
const attacker2 = generateMigrationIdentity();
|
|
@@ -251,7 +251,7 @@ console.log("\n★★★ ROUND 3 — what Bitcoin freezes is the PROOF OF CONTRO
|
|
|
251
251
|
authorizeAndSign(req).detail.includes("never from the bundle you are judging"));
|
|
252
252
|
|
|
253
253
|
// …and AUTHORISES when every condition, including the header, is satisfied.
|
|
254
|
-
const good = authorizeAndSign({ ...req, blockMerkleRoots: HEADERS });
|
|
254
|
+
const good = authorizeAndSign({ ...req, blockMerkleRoots: HEADERS, singlePolicyKeyPerHolder: true });
|
|
255
255
|
check("★★★ POSITIVE PATH END TO END: authorised, with a header-confirmed frontier",
|
|
256
256
|
good.authorized === true && good.evidence.quantumPropertyHolds === true &&
|
|
257
257
|
good.evidence.temporalFrontier.headerConfirmed === true);
|
|
@@ -262,15 +262,15 @@ console.log("\n★★★ ROUND 3 — what Bitcoin freezes is the PROOF OF CONTRO
|
|
|
262
262
|
for (const bad of [NaN, "abc", Infinity, -1, 1.5, "900000", true, [900000]]) {
|
|
263
263
|
check(`★ cutoff ${JSON.stringify(bad) ?? String(bad)} is REFUSED, never coerced`,
|
|
264
264
|
establishTemporalFrontier({ headBytes: headBytes, otsHex, cutoffBlockHeight: bad,
|
|
265
|
-
blockMerkleRoots: HEADERS }).established === false);
|
|
265
|
+
blockMerkleRoots: HEADERS, singlePolicyKeyPerHolder: true }).established === false);
|
|
266
266
|
}
|
|
267
267
|
check("★ and the policy engine refuses a malformed cutoff with its own reason",
|
|
268
|
-
authorizeAndSign({ ...req, cutoffBlockHeight: NaN, blockMerkleRoots: HEADERS })
|
|
268
|
+
authorizeAndSign({ ...req, cutoffBlockHeight: NaN, blockMerkleRoots: HEADERS, singlePolicyKeyPerHolder: true })
|
|
269
269
|
.detail.includes("refused rather than coerced"));
|
|
270
270
|
|
|
271
271
|
check("★ a forged .ots does NOT authorise, even with everything else correct",
|
|
272
272
|
authorizeAndSign({ ...req, anchorEvidence: { ...req.anchorEvidence, otsHex: mkOts(123) },
|
|
273
|
-
blockMerkleRoots: HEADERS }).authorized === false);
|
|
273
|
+
blockMerkleRoots: HEADERS, singlePolicyKeyPerHolder: true }).authorized === false);
|
|
274
274
|
}
|
|
275
275
|
|
|
276
276
|
console.log("\nThe three irreducible limits, stated in the code itself:");
|
|
@@ -302,7 +302,7 @@ console.log("\n★ LIMIT #4 — pinning the log's IDENTITY does not pin its HIST
|
|
|
302
302
|
const d3 = sha256(te.encode("spend-3"));
|
|
303
303
|
const base3 = { cert: victimCert, spendDigest: d3, pqSignatureHex: authorizeSpend(victim, d3),
|
|
304
304
|
anchorEvidence: { sth: head.sth, entries, otsHex: OTS }, expectedLogId: LOG_ID,
|
|
305
|
-
policyKey: policyPriv3, cutoffBlockHeight: 900_000, blockMerkleRoots: HDRS };
|
|
305
|
+
policyKey: policyPriv3, cutoffBlockHeight: 900_000, blockMerkleRoots: HDRS, singlePolicyKeyPerHolder: true };
|
|
306
306
|
check("without knownHeads the engine authorises — it cannot see the other history",
|
|
307
307
|
authorizeAndSign(base3).authorized === true);
|
|
308
308
|
check("★★ WITH a contradicting head supplied, the engine REFUSES (not warns)",
|
|
@@ -335,7 +335,7 @@ console.log("\n★★★ PRIMACÍA RELATIVA A LA EXPOSICIÓN — no depende de a
|
|
|
335
335
|
{
|
|
336
336
|
const { proveExposureRelativePrimacy, EXPOSURE_SCOPE } = await import("../src/primacy.mjs");
|
|
337
337
|
const common = { subjectClassicalPub: victimCert.classicalPub, entries, sth: STH_A,
|
|
338
|
-
expectedLogId: LOG_ID, otsHex: OTS, blockMerkleRoots: HDRS };
|
|
338
|
+
expectedLogId: LOG_ID, otsHex: OTS, blockMerkleRoots: HDRS, singlePolicyKeyPerHolder: true };
|
|
339
339
|
|
|
340
340
|
// El ancla real está en el bloque 800000 (ver el montaje de arriba).
|
|
341
341
|
const before = proveExposureRelativePrimacy({ ...common, exposureHeight: 850_000 });
|
|
@@ -360,7 +360,7 @@ console.log("\n★★★ PRIMACÍA RELATIVA A LA EXPOSICIÓN — no depende de a
|
|
|
360
360
|
check("una altura de exposición malformada se rehúsa, no se coerciona",
|
|
361
361
|
proveExposureRelativePrimacy({ ...common, exposureHeight: NaN }).exposureRelative === false);
|
|
362
362
|
check("sin ancla confirmada por cabecera no se hace ninguna afirmación fuerte",
|
|
363
|
-
proveExposureRelativePrimacy({ ...common, blockMerkleRoots: undefined, exposureHeight: 850_000 })
|
|
363
|
+
proveExposureRelativePrimacy({ ...common, blockMerkleRoots: undefined, singlePolicyKeyPerHolder: true, exposureHeight: 850_000 })
|
|
364
364
|
.exposureRelative === false);
|
|
365
365
|
|
|
366
366
|
check("HONESTIDAD: declara a quién NO puede salvar (claves ya expuestas)",
|
|
@@ -375,7 +375,7 @@ console.log("\n★★★ M2 CABLEADO — el gate de exposición en la ruta de fi
|
|
|
375
375
|
const d4 = sha256(te.encode("spend-m2"));
|
|
376
376
|
const req4 = { cert: victimCert, spendDigest: d4, pqSignatureHex: authorizeSpend(victim, d4),
|
|
377
377
|
anchorEvidence: { sth: head.sth, entries, otsHex: OTS }, expectedLogId: LOG_ID,
|
|
378
|
-
policyKey: policyPriv4, cutoffBlockHeight: 900_000, blockMerkleRoots: HDRS };
|
|
378
|
+
policyKey: policyPriv4, cutoffBlockHeight: 900_000, blockMerkleRoots: HDRS, singlePolicyKeyPerHolder: true };
|
|
379
379
|
|
|
380
380
|
// El ancla está en el bloque 800000 (montaje de arriba).
|
|
381
381
|
const ok = authorizeAndSign({ ...req4, exposureHeight: 850_000 });
|