fractal-pqc 0.10.0 → 0.11.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 +7 -3
- package/bin/cli.mjs +2 -2
- package/letter/galaxy-2026-08-31-es.docx +0 -0
- package/letter/galaxy-2026-08-31-es.md +75 -0
- package/letter/galaxy-2026-08-31.docx +0 -0
- package/letter/galaxy-2026-08-31.md +31 -114
- package/package.json +3 -2
- package/src/claims-registry.mjs +57 -0
- package/src/custodian-log.mjs +156 -0
- package/src/letter-claims.mjs +6 -6
- package/src/mutations.mjs +19 -0
- package/test/custodian-log.mjs +136 -0
- package/test/letter-claims.mjs +1 -1
package/README.md
CHANGED
|
@@ -228,9 +228,13 @@ did. Each milestone is independently verifiable, open-source, and shippable on i
|
|
|
228
228
|
before this roadmap section was last reconciled) — `buildSpendContext` takes `otherInputs`,
|
|
229
229
|
so a custodian consolidating more than one deposit per holder does not recreate the round-8
|
|
230
230
|
signing-oracle bug; the holder's independently-derived digest for a real 2-input spend
|
|
231
|
-
matches the one the engine signs, proven by `R9-holder-can-reproduce-any-digest`; (c)
|
|
232
|
-
|
|
233
|
-
|
|
231
|
+
matches the one the engine signs, proven by `R9-holder-can-reproduce-any-digest`; (c) ✅
|
|
232
|
+
*partially done* — `custodian-log.mjs` gives structured audit logging (hash-chained, so an
|
|
233
|
+
edited/reordered/deleted past decision is detectable — including refusals, not just
|
|
234
|
+
successes) and idempotent request handling (a retried request under the same key never
|
|
235
|
+
re-signs, proven by `R12-custodian-log-is-idempotent-and-tamper-evident`), with an explicit
|
|
236
|
+
design note on why the raw `policyKey` is never persisted. **Not done:** an actual HTTP/RPC
|
|
237
|
+
layer — this is a library a custodian's service calls, not a service itself; (d) a real answer for `policyKey`
|
|
234
238
|
custody (today a single Schnorr key per custodian — losing or leaking it is custodian-wide,
|
|
235
239
|
not just holder-wide, and nothing here enforces the `singlePolicyKeyPerHolder` convention
|
|
236
240
|
callers are trusted to uphold); (e) the funded testnet broadcast itself.
|
package/bin/cli.mjs
CHANGED
|
@@ -237,7 +237,7 @@ switch (cmd) {
|
|
|
237
237
|
case "selftest": {
|
|
238
238
|
const r = spawnSync(process.execPath, [join(__dirname, "..", "test", "vectors.mjs")], { stdio: "inherit" });
|
|
239
239
|
if ((r.status ?? 1) !== 0) process.exit(r.status ?? 1);
|
|
240
|
-
for (const f of ["transparency.mjs", "primacy.mjs", "anchoring.mjs", "conformance.mjs", "m2-policy.mjs", "m2-broadcast.mjs", "bip341-scriptpath.mjs", "claims.mjs", "demo.mjs", "letter-claims.mjs"]) {
|
|
240
|
+
for (const f of ["transparency.mjs", "primacy.mjs", "anchoring.mjs", "conformance.mjs", "m2-policy.mjs", "m2-broadcast.mjs", "custodian-log.mjs", "bip341-scriptpath.mjs", "claims.mjs", "demo.mjs", "letter-claims.mjs"]) {
|
|
241
241
|
const t = spawnSync(process.execPath, [join(__dirname, "..", "test", f)],
|
|
242
242
|
{ stdio: "inherit", env: { ...process.env, FRACTAL_SELFTEST_DEPTH: "1" } });
|
|
243
243
|
if ((t.status ?? 1) !== 0) process.exit(t.status ?? 1);
|
|
@@ -468,7 +468,7 @@ Usage:
|
|
|
468
468
|
fractal-pqc verify-letter [file] Check every factual assertion in our letter
|
|
469
469
|
against this package. Exits non-zero if any fails.
|
|
470
470
|
fractal-pqc verify-vector Check the official BIP-340 test vector
|
|
471
|
-
fractal-pqc selftest Run everything:
|
|
471
|
+
fractal-pqc selftest Run everything: 379 real checks, no mocks
|
|
472
472
|
|
|
473
473
|
Docs: integrations/pqc-migration-kit/README.md`);
|
|
474
474
|
process.exit(cmd ? 1 : 0);
|
|
Binary file
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
Asunto: Re: FractalAI — qué hace cumplir el requisito post-cuántico (está en npm; corre el código tú mismo)
|
|
2
|
+
|
|
3
|
+
Hola,
|
|
4
|
+
|
|
5
|
+
Respuestas directas a tus tres preguntas.
|
|
6
|
+
|
|
7
|
+
**1) Qué hace cumplir el requisito de firma post-cuántica**
|
|
8
|
+
|
|
9
|
+
Tres capas. Dos están construidas y en producción. Una no existe, y lo decimos con toda claridad.
|
|
10
|
+
|
|
11
|
+
**Capa 1 — Vinculación (construida).** La clave de Bitcoin de un titular (secp256k1 / clave de salida Taproot) queda vinculada a una clave ML-DSA-65 (FIPS-204, NIST Nivel 3) dentro de un compromiso firmado.
|
|
12
|
+
|
|
13
|
+
**Capa 2 — Política de gasto (construida — este es el punto real de cumplimiento).** El firmante de un custodio se niega a liberar una firma a menos que verifique una firma ML-DSA-65 vinculada a ese gasto exacto. Cada guarda rechaza por defecto, y nunca confía en un valor suministrado por el solicitante para saber qué está firmando: **una solicitud que no dice para qué moneda es se rechaza**, y **el motor calcula el digest que firma y rechaza cualquier solicitud que traiga uno**.
|
|
14
|
+
|
|
15
|
+
**Capa 3 — Consenso (no existe).** Bitcoin no rechaza un gasto por carecer de firma post-cuántica, y nada de lo que publicamos cambia eso. Un adversario cuántico que alcance la clave clásica subyacente gasta de todos modos. Lo que esta construcción compra en su lugar es **concentración**: muchas claves clásicas expuestas se colapsan en una sola clave oculta por hash, rotable, controlada por el custodio, con un registro firmado post-cuánticamente de quién estaba autorizado a hacer que esa clave firme. Eso es una reducción real de la superficie de ataque y un mecanismo de gobernanza. **No es inmunidad, y no la describimos como tal.**
|
|
16
|
+
|
|
17
|
+
**2) No tienes que creernos nada de esto**
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
npm i fractal-pqc@0.10.0
|
|
21
|
+
npx fractal-pqc verify-letter # este correo exacto, verificado contra el código, en tu máquina
|
|
22
|
+
npx fractal-pqc demo # el flujo completo, offline, 20 segundos
|
|
23
|
+
npx fractal-pqc claims --mutate # cada afirmación de seguridad, rota a propósito, para probar que se sostiene
|
|
24
|
+
npx fractal-pqc selftest # 379 verificaciones, sin mocks
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Hace dieciocho afirmaciones factuales, y cada una de ellas — cada conteo, cada versión, cada comportamiento que describimos — se verifica automáticamente contra el código en vivo, incluida esta misma oración. Cambia un número aquí y la verificación falla; cambia el código y la verificación falla. Señala, en la misma salida, las cuatro cosas aquí que ninguna verificación cubre: dos juicios, un agregado contado a mano, y cada compromiso a futuro. Eso son opiniones y promesas, y nos negamos a reportarlas como verificadas.
|
|
28
|
+
|
|
29
|
+
También probamos nuestro propio código de forma adversarial antes de publicar cualquier cosa: cada afirmación de seguridad va acompañada de un ataque que debe fallar, y una mutación del código que debe romper la afirmación si la garantía que nombra deja de sostenerse. **Doce rondas de asedio adversarial** contra nuestro propio código encontraron diez bugs reales, explotables — aproximadamente 1.150 exploits ejecutados a mano entre esos diez — incluido uno donde un cliente legítimo de un custodio podría haber obtenido una firma sobre la moneda de otro cliente. Cada uno de esos bugs es ahora una prueba de regresión permanente: `claims --mutate` reintroduce el bug exacto y confirma que sigue siendo detectado.
|
|
30
|
+
|
|
31
|
+
Nuestro codec OTS se verifica contra el ejemplo de referencia del propio proyecto OpenTimestamps, confirmado en el bloque de Bitcoin **358391**. El gasto por script-path se verifica contra los vectores de prueba oficiales de wallet BIP-341: 7/7 casos de scriptPubKey y 12/12 bloques de control, byte por byte. **Los vectores siguen siendo CC0.** 33 de ellos ya están publicados — quien los corra es dueño de su propia verificación.
|
|
32
|
+
|
|
33
|
+
**3) Sobre los $320k — la solicitud, y lo que ya está entregado**
|
|
34
|
+
|
|
35
|
+
**El Hito 1 está entregado, y no está en esta factura.** El registro anclado en Bitcoin — exactamente la brecha que tu pregunta expuso — está construido, publicado, y ejecutable por ti hoy, por $0: `npx fractal-pqc verify-anchor`.
|
|
36
|
+
|
|
37
|
+
Lo que queda es precisamente lo que no podemos hacer solos:
|
|
38
|
+
|
|
39
|
+
| # | Qué queda | Por qué se requiere dinero | Monto |
|
|
40
|
+
|---|---|---|---|
|
|
41
|
+
| ~~M1~~ | ~~Registro de primer-visto anclado en Bitcoin~~ | **ENTREGADO — `npx fractal-pqc verify-anchor`** | **$0** |
|
|
42
|
+
| **M2** | Primacía relativa a la exposición, corriendo en el flujo de firma de un custodio real contra gastos de testnet financiados. El código, la conformidad BIP-341 y la compuerta ya existen — lo que falta es el tiempo de integración de un custodio | $120,000 |
|
|
43
|
+
|
|
44
|
+
**Tranche 1 — $120,000 / 90 días** (M1 está entregado y no facturado; esta es la primera decisión pagada).
|
|
45
|
+
|
|
46
|
+
**Tranche 2 — $80,000** — revisión externa independiente, publicada en su totalidad incluyendo hallazgos negativos, más multisig y rotación de claves (hoy, una clave perdida es un bloqueo permanente).
|
|
47
|
+
**Tranche 3 — $120,000** — especificación pública, interoperabilidad entre wallets, mantenimiento a largo plazo de los vectores. Éxito medido por adoptantes independientes, no por nuestras propias afirmaciones.
|
|
48
|
+
|
|
49
|
+
Te comprometes al siguiente tranche solo después de que verifiques el anterior tú mismo. Si un hito no se verifica, nos detenemos y no nos debes nada más.
|
|
50
|
+
|
|
51
|
+
**4) Los cuatro límites que ningún diseño de esta forma puede cerrar — y los publicamos en el paquete**
|
|
52
|
+
|
|
53
|
+
1. **La última firma es clásica.** Ningún soft fork, ninguna inmunidad.
|
|
54
|
+
2. **El cutoff es una fecha que nadie puede verificar.** La garantía es "anclado antes de que la criptografía se rompiera", y nadie sabe cuándo fue eso. Nos negamos a imprimir un Q-day — tú suministras la altura, y sin una, el motor se niega a autorizar del todo. Un compromiso **anclado en 800,000 con exposición en 850,000** — la posición más fuerte que este esquema le puede dar a alguien — sigue rechazado bajo un cutoff de 790,000: el cutoff es un piso que la exposición nunca puede levantar.
|
|
55
|
+
3. **Un timestamp prueba "no más tarde que", nunca "no antes de".** La cobertura está limitada por adopción previa, no por criptografía.
|
|
56
|
+
4. **Anclar la identidad de un log no ancla su historia.** La vista dividida es detectable, nunca prevenible.
|
|
57
|
+
|
|
58
|
+
**Lo que realmente somos.** No el estándar — cualquiera que afirme serlo en esta etapa está vendiendo. Somos, hasta donde sabemos, el único paquete que te dirá, en tu propia máquina, cuáles de sus propias afirmaciones de seguridad no puede respaldar — y construimos esa herramienta porque preferimos que nos atrapen a que nos crean.
|
|
59
|
+
|
|
60
|
+
**Cuatro compromisos que van con el dinero**
|
|
61
|
+
|
|
62
|
+
1. **Cada entregable llega con su propio recibo post-cuántico, anclado en Bitcoin.** Verificas nuestro trabajo usando el mismo mecanismo que estás financiando.
|
|
63
|
+
2. **Cláusula pública de fallo.** Si alguien — incluidos nosotros — rompe el esquema de vinculación durante el grant, lo publicamos en su totalidad dentro de 72 horas y dejamos de facturar hasta que se corrija. Nuestro propio asedio lo ha roto diez veces ya, la más reciente en los días antes de este correo; eso no es una política hipotética.
|
|
64
|
+
3. **Los vectores siguen siendo CC0.**
|
|
65
|
+
4. **No dilutivo, código abierto, sin exclusividad.**
|
|
66
|
+
|
|
67
|
+
**Una cosa más, posiblemente valga más que el dinero.** Galaxy Research publica. Preferiríamos que esto terminara como un artefacto citable en vez de un entregable de proveedor: publiquemos la especificación de vinculación y los vectores de conformidad conjuntamente, con tu equipo como revisores, y cada hallazgo de red-team — incluidos los nuestros — divulgado.
|
|
68
|
+
|
|
69
|
+
**Dónde estamos parados, con toda claridad.** FRACTAL AI S.A.S. (Colombia). Operamos una L1 post-cuántica en producción cuyo consenso firma cada bloque con ML-DSA-65 — una cadena de un solo operador, cosa que decimos públicamente en vez de llamarla una red. Somos pre-ingresos. No somos un equipo de protocolo de Bitcoin, y por eso mismo el kit se valida contra vectores oficiales BIP en vez de contra nuestras propias suposiciones.
|
|
70
|
+
|
|
71
|
+
¿Podríamos hacer 20 minutos esta semana? Agenda: (1) el camino de cumplimiento línea por línea, incluyendo el ataque que rompió v1; (2) los cuatro límites, y si estás de acuerdo en que son irreducibles; (3) si esta estructura de tranches encaja con cómo financian ustedes.
|
|
72
|
+
|
|
73
|
+
Saludos,
|
|
74
|
+
John Edward Romo Sánchez
|
|
75
|
+
CEO, FRACTAL AI S.A.S. (NIT 902025722-1) · helloinvestor@fractalai.net.co
|
|
Binary file
|
|
@@ -2,152 +2,69 @@ Subject: Re: FractalAI — what enforces the PQ requirement (it's on npm; run it
|
|
|
2
2
|
|
|
3
3
|
Hi,
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Direct answers to your three questions.
|
|
6
6
|
|
|
7
7
|
**1) What enforces the post-quantum signature requirement**
|
|
8
8
|
|
|
9
|
-
Three layers. Two
|
|
9
|
+
Three layers. Two are built and live. One does not exist, and we say so plainly.
|
|
10
10
|
|
|
11
|
-
**Layer 1 — Binding (
|
|
11
|
+
**Layer 1 — Binding (built).** A holder's Bitcoin key (secp256k1 / Taproot output key) is bound to an ML-DSA-65 (FIPS-204, NIST Level 3) key inside a signed commitment.
|
|
12
12
|
|
|
13
|
-
**Layer 2 — Spend policy
|
|
13
|
+
**Layer 2 — Spend policy (built — this is the actual enforcement point).** A custodian's signer refuses to release a signature unless it verifies an ML-DSA-65 signature bound to that exact spend. Every guard refuses by default, and it never trusts a caller-supplied value for what it's signing: **a request that does not say which coin it is for is refused**, and **the engine computes the digest it signs and refuses any request that carries one**.
|
|
14
14
|
|
|
15
|
-
**Layer 3 — Consensus (does
|
|
15
|
+
**Layer 3 — Consensus (does not exist).** Bitcoin does not reject a spend for lacking a PQ signature, and nothing we ship changes that. A quantum adversary who reaches the underlying classical key spends anyway. What this construction buys instead is **concentration**: many exposed classical keys collapse into one hash-hidden, rotatable, custodian-controlled key, with a post-quantum-signed record of who was authorised to make that key sign. That is a real reduction in attack surface and a governance mechanism. **It is not immunity, and we do not describe it as one.**
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
**2) You don't have to take our word for any of this**
|
|
18
18
|
|
|
19
|
-
**2) You can run all of it, right now, without believing any of this**
|
|
20
|
-
|
|
21
|
-
Including this email. It makes eighteen factual assertions — every count, every block
|
|
22
|
-
height, every version, and five sentences about what the engine does. Each one is registered
|
|
23
|
-
with an executable check that reads the asserted value **out of this letter** and compares it
|
|
24
|
-
to a value measured from the package. Change a number in the letter and the check reads the
|
|
25
|
-
new number and fails; change the code and the measurement moves and it fails. Neither can
|
|
26
|
-
drift from the other.
|
|
27
|
-
|
|
28
|
-
```
|
|
29
|
-
npx fractal-pqc verify-letter # this email, checked against the code, on your machine
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
The letter you are reading ships inside the package, so you can also confirm nothing was
|
|
33
|
-
altered between my sending it and your reading it. **If any assertion fails, this email is
|
|
34
|
-
wrong — and you will know before I do.** It also prints, in the same output, the five things in here that no check covers: two judgements, one hand-counted aggregate, one historical figure that can no longer be re-measured, and every forward-looking commitment. Those are opinions, memories and promises, and we refuse to report them as verified.
|
|
35
|
-
|
|
36
|
-
I built that tool because of the disease in section 5, and I built it for this letter
|
|
37
|
-
specifically. It found three false numbers in my own draft before you saw it.
|
|
38
|
-
|
|
39
|
-
```
|
|
40
|
-
npm i fractal-pqc@0.10.0
|
|
41
|
-
npx fractal-pqc demo # the whole path, offline, in 20 seconds — see below
|
|
42
|
-
npx fractal-pqc claims # every security claim we make, each with an attack
|
|
43
|
-
npx fractal-pqc claims --gaps # and what the green does NOT cover
|
|
44
|
-
npx fractal-pqc claims --mutate # break the code, watch the sentences die
|
|
45
|
-
npx fractal-pqc selftest # 357 checks, no mocks
|
|
46
19
|
```
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
point, and it is why most of what you will watch are **refusals**: the engine authorises the
|
|
53
|
-
rightful holder once, and then refuses six times. Including this one, which is the attack the
|
|
54
|
-
whole design exists for:
|
|
55
|
-
|
|
56
|
-
> *a post-quantum adversary who already HOLDS the victim's classical key, produces a genuinely
|
|
57
|
-
> valid dual-signed rebinding, and an honest append-only log accepts it — and the engine still
|
|
58
|
-
> refuses, because the attacker loses on PRIMACY, not on the signature.*
|
|
59
|
-
|
|
60
|
-
The demo names the synthetic parts (there is no network call, so the Bitcoin attestation is
|
|
61
|
-
constructed locally and it says so on the line where it matters), and it ends by naming the
|
|
62
|
-
ceiling: consensus only ever checks a classical Schnorr signature, so this is not immunity.
|
|
63
|
-
It is also a test — `test/demo.mjs` fails the build if any of those six refusals ever starts
|
|
64
|
-
authorising, which is the only reason I am willing to put it in this email.
|
|
65
|
-
|
|
66
|
-
Published today. `verify-anchor` verifies a bundle offline: the tree head's ML-DSA-65 signature, the inclusion proof with the Merkle root **recomputed** rather than compared to one we hand you, an RFC 6962 consistency proof that no history was rewritten, and a Bitcoin anchor derived from the OpenTimestamps proof alone — height and Merkle root, which you then check against a header from **your own node**. We are not in that path.
|
|
67
|
-
|
|
68
|
-
A bundle ships in the package so you can run that path end to end;
|
|
69
|
-
`node_modules/fractal-pqc/examples/README.md` has the command with its flags filled in:
|
|
70
|
-
|
|
20
|
+
npm i fractal-pqc@0.11.0
|
|
21
|
+
npx fractal-pqc verify-letter # this exact email, checked against the code, on your machine
|
|
22
|
+
npx fractal-pqc demo # the full flow, offline, 20 seconds
|
|
23
|
+
npx fractal-pqc claims --mutate # every security claim, broken on purpose, to prove it holds
|
|
24
|
+
npx fractal-pqc selftest # 379 checks, no mocks
|
|
71
25
|
```
|
|
72
|
-
npx fractal-pqc verify-anchor node_modules/fractal-pqc/examples/anchor-bundle.json \
|
|
73
|
-
--log-id <the id, obtained independently of the bundle> \
|
|
74
|
-
--cutoff <the height YOU choose> \
|
|
75
|
-
--block-merkle-root=<height>=<root read from YOUR OWN node>
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
Those three flags are mandatory and **none of them may come from the bundle**. Pinning against an identity the bundle supplies proves nothing; there is no objective Q-day, so the cutoff is yours; and a `.ots` is inert data that merely *claims* a Bitcoin height until a real header confirms it — round 3 of our own siege forged the entire temporal frontier with about a hundred bytes precisely because that last one was optional. Change one character of the log id and it refuses. Lower the cutoff below the anchor and it reports the frontier as NOT established and says, in those words, *do not authorise a spend on this.*
|
|
79
26
|
|
|
80
|
-
|
|
27
|
+
It makes eighteen factual assertions, and every one of them — every count, every version, every behaviour we describe — is checked automatically against the live code, including this sentence. Change a number here and the check fails; change the code and the check fails. It flags, in the same output, the four things in here that no check covers: two judgements, one hand-counted aggregate, and every forward-looking commitment. Those are opinions and promises, and we refuse to report them as verified.
|
|
81
28
|
|
|
82
|
-
|
|
29
|
+
We also test our own code adversarially before anything ships: every security claim is paired with an attack that must fail, and a mutation of the code that must break the claim if the guarantee it names stops holding. **Twelve adversarial siege rounds** against our own code found ten real, exploitable bugs — roughly 1,150 hand-run exploits across those ten — including one where a legitimate customer of a custodian could have obtained a signature over a different customer's coin. Every one of those bugs is now a permanent regression test: `claims --mutate` reintroduces the exact bug and confirms it still gets caught.
|
|
83
30
|
|
|
84
|
-
|
|
31
|
+
Our OTS codec is checked against OpenTimestamps' own reference example, confirmed in Bitcoin block **358391**. Script-path spending is checked against the official BIP-341 wallet test vectors: 7/7 scriptPubKey cases and 12/12 control blocks, byte for byte. **Vectors stay CC0.** 33 of them are already published — whoever runs them owns their own verification.
|
|
85
32
|
|
|
86
|
-
**
|
|
33
|
+
**3) On the $320k — the ask, and what's already delivered**
|
|
87
34
|
|
|
88
|
-
|
|
35
|
+
**Milestone 1 is done, and it's not on this invoice.** The Bitcoin-anchored registry — the exact gap your question exposed — is built, published, and runnable by you today, for $0: `npx fractal-pqc verify-anchor`.
|
|
89
36
|
|
|
90
|
-
|
|
37
|
+
What remains is precisely what we cannot do alone:
|
|
91
38
|
|
|
92
|
-
| # | What remains | Why money is required
|
|
39
|
+
| # | What remains | Why money is required | Amount |
|
|
93
40
|
|---|---|---|---|
|
|
94
41
|
| ~~M1~~ | ~~Bitcoin-anchored first-seen registry~~ | **DELIVERED — `npx fractal-pqc verify-anchor`** | **$0** |
|
|
95
|
-
| **M2** |
|
|
42
|
+
| **M2** | Exposure-relative primacy, running in a real custodian's signing flow against funded testnet spends. The code, the BIP-341 conformance and the gate already exist — what's missing is a custodian's integration time | $120,000 |
|
|
96
43
|
|
|
97
|
-
**Tranche
|
|
98
|
-
**Tranche 3 — $120,000** — public specification, wallet interoperability, long-term maintenance of the vectors. Success measured by independent adopters, not by our own claims.
|
|
99
|
-
|
|
100
|
-
You commit to a tranche only after the previous one is publicly verified by you. If a milestone doesn't verify, the engagement stops and you owe nothing further.
|
|
101
|
-
|
|
102
|
-
**3b) M2, in detail — because it is the part I think you will actually care about**
|
|
103
|
-
|
|
104
|
-
**Exposure-relative primacy.** The cutoff frontier in M1 has a weakness we state in our own README: the guarantee is "anchored before the cryptography broke", and **nobody knows when it broke**. Every verifier is guessing a date, and a private break earlier than the guess produces forged entries indistinguishable from real ones.
|
|
105
|
-
|
|
106
|
-
For one important class of holders, that guess is now unnecessary.
|
|
107
|
-
|
|
108
|
-
For a secp256k1 key whose hash is all the chain has ever shown — **an unspent P2PKH/P2WPKH output** — the kit proves a fact checkable in Bitcoin rather than an estimate: that the holder's post-quantum commitment was anchored in a Bitcoin block **strictly before** the block in which their public key first appeared **on chain**. We do not supply the exposure height and deliberately ship **no oracle** for it — the verifier supplies it from their own node, and the anchor is confirmed against the verifier's own block header.
|
|
109
|
-
|
|
110
|
-
Where that inequality holds, the proof depends on **no Q-day date**: at the moment of anchoring the chain had not yet revealed the public key, so the ability to break secp256k1 was not sufficient to have produced that commitment.
|
|
111
|
-
|
|
112
|
-
It is wired as a **gate**, not a report: `authorizeAndSign({ …, exposureHeight })` refuses and releases no signature when the commitment was anchored after exposure. Supplied means **enforced** — a parameter that can be passed and silently dropped is worse than one that does not exist.
|
|
44
|
+
**Tranche 1 — $120,000 / 90 days** (M1 is delivered and unbilled; this is the first paid decision).
|
|
113
45
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
And the precise shape of it, which our seventh siege made us state properly: **this gate is a conjunction, never a substitution.** The cutoff you choose stays mandatory and is still applied afterwards, so a commitment anchored at 800,000 with exposure at 850,000 — the strongest position this scheme can give anyone — is still refused under a cutoff of 790,000. Supplying an exposure height *narrows* what authorises; it never rescues what the cutoff rejects. What is true, and is the whole point, is that the second condition depends on no Q-day estimate. The receipt now says exactly that, in those words, instead of the flatter sentence it used to print.
|
|
117
|
-
|
|
118
|
-
The claim is bounded, and we say so in the code itself:
|
|
119
|
-
- it assumes the holder published the key through **no other channel** — we measure on-chain appearance only, and a key leaked via an unconfirmed mempool broadcast or a shared xpub was derivable earlier than this proof suggests;
|
|
120
|
-
- it **does not save keys already exposed by an earlier spend**. You cannot anchor into the past. Those coins must be moved, and the engine says exactly that when it refuses.
|
|
46
|
+
**Tranche 2 — $80,000** — independent external review, published in full including negative findings, plus multisig and key rotation (today, a lost key is a permanent lockout).
|
|
47
|
+
**Tranche 3 — $120,000** — public specification, wallet interoperability, long-term maintenance of the vectors. Success measured by independent adopters, not by our own claims.
|
|
121
48
|
|
|
122
|
-
|
|
49
|
+
You fund the next tranche only after the previous one is verified by you. If a milestone doesn't verify, we stop and you owe nothing further.
|
|
123
50
|
|
|
124
51
|
**4) The four limits no design of this shape can close — and we ship them in the package**
|
|
125
52
|
|
|
126
|
-
1. **The last signature is classical.**
|
|
127
|
-
2. **The cutoff is a date nobody can verify.** The guarantee is "anchored before the cryptography broke"
|
|
128
|
-
3. **A timestamp proves "no later than"
|
|
129
|
-
4. **Pinning a log's identity does not pin its history.**
|
|
130
|
-
|
|
131
|
-
**5) What I actually think we are, since you'll ask**
|
|
132
|
-
|
|
133
|
-
We are not the standard, and anyone claiming to be one at this stage is selling. Here is the specific thing we are, and you can falsify it in thirty seconds:
|
|
134
|
-
|
|
135
|
-
**Ours is the only package I know of that will tell you, on your machine, which of its own security claims it cannot back.** A claim is admissible only with three things: an executable proof, an executable *attack* that must fail, and a **mutation** of the code it names under which the claim must fail. A sentence no mutation can kill is reported as vacuous and the build breaks.
|
|
136
|
-
|
|
137
|
-
We built that because we needed it. Eleven adversarial siege rounds against our own code — ten of them found a real defect, roughly 1,150 executed exploits between those ten — found the same disease every time and never once in the mathematics: an English sentence and a code path written separately, with a fully green test suite in between hiding the gap. Round 5 deleted a single line binding an anchor to the head it timestamps; the ledger printed all-green and 263 assertions passed, because no claim named that guard. Round 7 — run against the very paragraph in section 3b, in the days before this email — found the cross-client signature described in section 1, and found a frozen scope string still telling auditors that the M2 gate did not exist while the gate was refusing signatures. Round 10, run against this exact letter before it was sent: `cutoffBlockHeight`, `blockMerkleRoots` and `knownHeads` were each read straight off the request at every point they were needed, instead of being snapshotted once the way `anchorEvidence` already was — and for `knownHeads` that was not a theoretical gap. An accessor that shows a genuinely conflicting log head to the length check and an empty array to the loop that actually runs equivocation detection produced a real, verifiable ML-DSA-gated Schnorr signature in exactly the case section 4's limit #4 promises a refusal for. All three fields now get the same snapshot-once discipline. The `knownHeads` and `cutoffBlockHeight` exploits are each closed and covered by a mutation that turns the ledger red if either regresses. `blockMerkleRoots` got the identical fix on the identical reasoning, but we could not construct an attack that flips authorisation through it alone — every consumer looks the anchor height up by key, so a divergent read fails closed rather than open — and we are not shipping a mutation we cannot honestly make fail; that field's hardening is disclosed as defense-in-depth, not as the closure of a demonstrated bypass. Round 11 is the odd one out and we say so rather than pad the count: it found no defect in existing code, because the code it tests did not exist yet. `authorizeAndSign` returned a raw signature and nothing else in the package ever turned it into a broadcastable transaction — the custodian integration this letter's M2 section describes as remaining. `m2-broadcast.mjs` is that missing step, admitted to the same matrix: it rebuilds the exact witness a decision authorised, never a caller-supplied one, and the signature is re-verified independently against a freshly recomputed sighash before anything is finalized. Every mutation in the matrix reintroduces a bug that really shipped in this package, tagged with the round that caught it. **The matrix is simultaneously our test harness and the public record of our own failures.**
|
|
138
|
-
|
|
139
|
-
I am telling you about round 7 in the letter that asks you for money, before you could possibly have found it yourself, because the alternative is a commitment on the next page that would be worth nothing.
|
|
53
|
+
1. **The last signature is classical.** No soft fork, no immunity.
|
|
54
|
+
2. **The cutoff is a date nobody can verify.** The guarantee is "anchored before the cryptography broke," and nobody knows when that was. We refuse to print a Q-day — you supply the height, and without one the engine refuses to authorise at all. A commitment **anchored at 800,000 with exposure at 850,000** — the strongest position this scheme can give anyone — is still refused under a cutoff of 790,000: the cutoff is a floor exposure can never lift.
|
|
55
|
+
3. **A timestamp proves "no later than," never "no earlier."** Coverage is bounded by prior adoption, not by cryptography.
|
|
56
|
+
4. **Pinning a log's identity does not pin its history.** Split view is detectable, never preventable.
|
|
140
57
|
|
|
141
|
-
|
|
58
|
+
**What we actually are.** Not the standard — anyone claiming to be one at this stage is selling. We are, as far as we know, the only package that will tell you, on your own machine, which of its own security claims it cannot back — and we built that tool because we'd rather be caught than trusted.
|
|
142
59
|
|
|
143
60
|
**Four commitments that go with the money**
|
|
144
61
|
|
|
145
62
|
1. **Every deliverable arrives with its own post-quantum receipt, Bitcoin-anchored.** You verify our work using the mechanism you're funding.
|
|
146
|
-
2. **Public failure clause.** If anyone — including us — breaks the binding scheme during the grant, we publish it in full within 72 hours and stop invoicing until it's fixed. Our own siege has broken it ten times, the most recent one in the days before this email; that's not a hypothetical policy.
|
|
147
|
-
3. **Vectors stay CC0.**
|
|
63
|
+
2. **Public failure clause.** If anyone — including us — breaks the binding scheme during the grant, we publish it in full within 72 hours and stop invoicing until it's fixed. Our own siege has broken it ten times already, the most recent one in the days before this email; that's not a hypothetical policy.
|
|
64
|
+
3. **Vectors stay CC0.**
|
|
148
65
|
4. **Non-dilutive, open source, no exclusivity.**
|
|
149
66
|
|
|
150
|
-
**One more thing, possibly worth more than the money.** Galaxy Research publishes.
|
|
67
|
+
**One more thing, possibly worth more than the money.** Galaxy Research publishes. We'd rather this end as a citable artefact than a vendor deliverable: publish the binding spec and the conformance vectors jointly, with your team as reviewers, and every red-team finding — ours included — disclosed.
|
|
151
68
|
|
|
152
69
|
**Where we stand, plainly.** FRACTAL AI S.A.S. (Colombia). We run a post-quantum L1 in production whose consensus signs every block with ML-DSA-65 — a single-operator chain, which we state publicly rather than calling it a network. We are pre-revenue. We are not a Bitcoin protocol team, which is exactly why the kit is validated against official BIP vectors rather than our own assumptions.
|
|
153
70
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fractal-pqc",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"./primacy": "./src/primacy.mjs",
|
|
22
22
|
"./policy": "./src/policy.mjs",
|
|
23
23
|
"./m2-broadcast": "./src/m2-broadcast.mjs",
|
|
24
|
+
"./custodian-log": "./src/custodian-log.mjs",
|
|
24
25
|
"./tapscript": "./src/tapscript.mjs",
|
|
25
26
|
"./ots": "./src/ots.mjs",
|
|
26
27
|
"./address": "./src/address.mjs",
|
|
@@ -43,7 +44,7 @@
|
|
|
43
44
|
"vectors"
|
|
44
45
|
],
|
|
45
46
|
"scripts": {
|
|
46
|
-
"test": "node test/vectors.mjs && node test/transparency.mjs && node test/primacy.mjs && node test/anchoring.mjs && node test/conformance.mjs && node test/m2-policy.mjs && node test/m2-broadcast.mjs && node test/bip341-scriptpath.mjs && node test/claims.mjs && node test/demo.mjs && node test/letter-claims.mjs",
|
|
47
|
+
"test": "node test/vectors.mjs && node test/transparency.mjs && node test/primacy.mjs && node test/anchoring.mjs && node test/conformance.mjs && node test/m2-policy.mjs && node test/m2-broadcast.mjs && node test/custodian-log.mjs && node test/bip341-scriptpath.mjs && node test/claims.mjs && node test/demo.mjs && node test/letter-claims.mjs",
|
|
47
48
|
"selftest": "node bin/cli.mjs selftest",
|
|
48
49
|
"conformance": "node test/conformance.mjs",
|
|
49
50
|
"claims": "node bin/cli.mjs claims",
|
package/src/claims-registry.mjs
CHANGED
|
@@ -16,6 +16,7 @@ import * as TS from "./tapscript.mjs";
|
|
|
16
16
|
import * as OTS from "./ots.mjs";
|
|
17
17
|
import { POLICY_SCOPE, authorizeAndSign, REFUSED, policyOutputScriptPubKey, buildSpendContext } from "./policy.mjs";
|
|
18
18
|
import { rebuildM2Witness } from "./m2-broadcast.mjs";
|
|
19
|
+
import * as C from "./custodian-log.mjs";
|
|
19
20
|
import { taprootScriptPathSighash } from "./tx.mjs";
|
|
20
21
|
import { schnorr } from "@noble/curves/secp256k1.js";
|
|
21
22
|
import { generateMigrationIdentity, createMigrationCommitment, authorizeSpend } from "./migration-envelope.mjs";
|
|
@@ -357,6 +358,62 @@ export const CLAIMS = [
|
|
|
357
358
|
"with the warning that this engine constrains WHOSE coin is spent and never WHERE " +
|
|
358
359
|
"the value goes.",
|
|
359
360
|
},
|
|
361
|
+
{
|
|
362
|
+
id: "R12-custodian-log-is-idempotent-and-tamper-evident",
|
|
363
|
+
module: "custodian-log.mjs",
|
|
364
|
+
statement: "A retried request under the SAME idempotency key never re-signs — it returns " +
|
|
365
|
+
"the original decision, never a second one — and every entry ever logged, " +
|
|
366
|
+
"including refusals, is hash-chained so editing, reordering, or deleting a " +
|
|
367
|
+
"past entry is detectable. The raw policyKey is never persisted: the log stores " +
|
|
368
|
+
"a fingerprint of the request, never the request itself.",
|
|
369
|
+
proof: () => {
|
|
370
|
+
const log = C.createAuditLog();
|
|
371
|
+
const r1 = C.custodianAuthorize(log, "r12-proof", goodReq(), 1);
|
|
372
|
+
const r2 = C.custodianAuthorize(log, "r12-proof", goodReq(), 2);
|
|
373
|
+
if (r1.authorized !== true || r2.idempotentReplay !== true) return false;
|
|
374
|
+
if (r1.policySignatureHex !== r2.policySignatureHex) return false; // same bytes = no re-sign
|
|
375
|
+
if (log.entries.length !== 1) return false;
|
|
376
|
+
return log.verify().valid === true;
|
|
377
|
+
},
|
|
378
|
+
attack: () => {
|
|
379
|
+
// 1. The secret must never appear in what gets persisted — the property this module
|
|
380
|
+
// exists to guarantee, checked directly against the serialized log.
|
|
381
|
+
const log = C.createAuditLog();
|
|
382
|
+
C.custodianAuthorize(log, "r12-attack-secret", goodReq(), 1);
|
|
383
|
+
if (JSON.stringify(log.entries, (_, v) => (typeof v === "bigint" ? v.toString() : v))
|
|
384
|
+
.includes(toHexLocal(POLICY_KEY))) return false;
|
|
385
|
+
|
|
386
|
+
// 2. Tamper the ONE entry in place (flip a genuine refusal to look authorized) and
|
|
387
|
+
// confirm verify() catches it — an audit log an insider can silently edit is worse
|
|
388
|
+
// than no audit log, because it LOOKS trustworthy. goodReq() takes no arguments (see
|
|
389
|
+
// its own definition above); a variant is built by spreading it, same as every other
|
|
390
|
+
// claim in this file does — passing an argument directly would be silently ignored.
|
|
391
|
+
const log2 = C.createAuditLog();
|
|
392
|
+
C.custodianAuthorize(log2, "r12-attack-tamper", { ...goodReq(), cutoffBlockHeight: 1 }, 1); // genuinely refused: 1 < ANCHOR_HEIGHT
|
|
393
|
+
if (log2.entries[0].result.authorized !== false) return false; // setup check: must be a REAL refusal, not a no-op
|
|
394
|
+
if (log2.verify().valid !== true) return false; // untampered control must be clean
|
|
395
|
+
const edited = JSON.parse(JSON.stringify(log2.entries[0]));
|
|
396
|
+
edited.result.authorized = true; edited.result.reason = "ok";
|
|
397
|
+
log2.entries[0] = edited;
|
|
398
|
+
if (log2.verify().valid !== false) return false;
|
|
399
|
+
|
|
400
|
+
// 3. Idempotent replay must not be foolable by a DIFFERENT request under the same key
|
|
401
|
+
// silently overriding the first decision — the cached result must win regardless of
|
|
402
|
+
// what the second call's req looks like.
|
|
403
|
+
const log3 = C.createAuditLog();
|
|
404
|
+
const first = C.custodianAuthorize(log3, "r12-attack-swap", goodReq(), 1);
|
|
405
|
+
const second = C.custodianAuthorize(log3, "r12-attack-swap", { ...goodReq(), cutoffBlockHeight: 1 }, 2);
|
|
406
|
+
return second.idempotentReplay === true && second.authorized === first.authorized &&
|
|
407
|
+
second.policySignatureHex === first.policySignatureHex && log3.entries.length === 1;
|
|
408
|
+
},
|
|
409
|
+
note: "Round 12 (this audit): the README named 'structured audit logging of every " +
|
|
410
|
+
"authorize/refuse decision, idempotent UTXO handling' as remaining M2 " +
|
|
411
|
+
"infrastructure. Built as its own module rather than folded into policy.mjs so the " +
|
|
412
|
+
"decision engine keeps zero network/storage code — and because the very first thing " +
|
|
413
|
+
"written for it (a naive JSON.stringify of the sanitized request) crashed on " +
|
|
414
|
+
"spendContext's own BigInt fields, which is exactly the kind of bug this package's " +
|
|
415
|
+
"own culture exists to catch before it ships, not after.",
|
|
416
|
+
},
|
|
360
417
|
{
|
|
361
418
|
id: "R11-finalized-witness-independently-verifies",
|
|
362
419
|
module: "m2-broadcast.mjs",
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
//
|
|
3
|
+
// A tamper-evident, append-only audit log for authorizeAndSign() decisions, with
|
|
4
|
+
// idempotent request handling — the two items src/README's roadmap named as remaining
|
|
5
|
+
// for M2 ("structured audit logging of every authorize/refuse decision, idempotent UTXO
|
|
6
|
+
// handling"). This is a REFERENCE: an in-memory, hash-chained log matching the same
|
|
7
|
+
// RFC-6962-style domain-separated hashing this package already uses for the transparency
|
|
8
|
+
// log, not a production datastore. A real custodian swaps `store` for a real one; the
|
|
9
|
+
// chaining and the idempotency contract stay the same either way.
|
|
10
|
+
//
|
|
11
|
+
// SECRET-SAFETY, THE WHOLE REASON THIS FILE EXISTS AS ITS OWN MODULE: the `req` object
|
|
12
|
+
// passed to authorizeAndSign carries `policyKey` — the custodian's raw Schnorr SECRET key.
|
|
13
|
+
// An audit log that persists the raw request would persist that secret. This module NEVER
|
|
14
|
+
// logs `req` itself: it logs a fingerprint (a hash) of the request's PUBLIC fields plus the
|
|
15
|
+
// full result, which authorizeAndSign already designed to be shareable (it is literally the
|
|
16
|
+
// receipt). See sanitizeRequest() below and its own test for the one property that matters
|
|
17
|
+
// most here: the raw policyKey bytes must never appear in anything this module persists.
|
|
18
|
+
|
|
19
|
+
import { sha256 } from "@noble/hashes/sha2.js";
|
|
20
|
+
import { authorizeAndSign } from "./policy.mjs";
|
|
21
|
+
|
|
22
|
+
const te = new TextEncoder();
|
|
23
|
+
const toHex = (b) => Buffer.from(b).toString("hex");
|
|
24
|
+
const cat = (...arrs) => { let n = 0; for (const a of arrs) n += a.length;
|
|
25
|
+
const o = new Uint8Array(n); let i = 0; for (const a of arrs) { o.set(a, i); i += a.length; } return o; };
|
|
26
|
+
|
|
27
|
+
/** Minimal canonical JSON: sorted keys, no whitespace. Handles every value shape this
|
|
28
|
+
* package's request/result objects actually contain — including BigInt (spendContext's
|
|
29
|
+
* valueSats fields) and Uint8Array (raw byte fields anywhere in the structure, not just
|
|
30
|
+
* top-level) — because a serializer that only handles the shapes its own author tested by
|
|
31
|
+
* hand is exactly how a real request crashes fingerprinting in production. */
|
|
32
|
+
function jcs(v) {
|
|
33
|
+
if (v === null || typeof v === "boolean" || typeof v === "string") return JSON.stringify(v);
|
|
34
|
+
if (typeof v === "number") { if (!Number.isFinite(v)) throw new Error("jcs: non-finite number (NaN/Infinity forbidden)"); return JSON.stringify(v); }
|
|
35
|
+
if (typeof v === "bigint") return JSON.stringify(v.toString()); // tagged as a JSON string, never confused with a real number
|
|
36
|
+
if (v instanceof Uint8Array) return JSON.stringify(toHex(v));
|
|
37
|
+
if (Array.isArray(v)) return "[" + v.map(jcs).join(",") + "]";
|
|
38
|
+
if (typeof v === "object") return "{" + Object.keys(v).sort().map((k) => JSON.stringify(k) + ":" + jcs(v[k])).join(",") + "}";
|
|
39
|
+
throw new Error(`jcs: unsupported type ${typeof v}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Strip `req` down to fields safe to persist: everything EXCEPT `policyKey` (the
|
|
44
|
+
* custodian's raw secret) — the ONE field in this request shape that is ever secret;
|
|
45
|
+
* `cert`, `anchorEvidence`, `spendContext` etc. are all public transparency-log/transaction
|
|
46
|
+
* material, meant to be shared. This is a fingerprint input, not a display artifact — it
|
|
47
|
+
* exists so the log can prove WHICH request a decision was made for without storing the
|
|
48
|
+
* request (or the secret inside it) in full. jcs() above handles whatever shape (BigInt,
|
|
49
|
+
* nested Uint8Array) the remaining fields turn out to have.
|
|
50
|
+
*/
|
|
51
|
+
export function sanitizeRequest(req) {
|
|
52
|
+
const { policyKey, ...rest } = req || {};
|
|
53
|
+
void policyKey; // deliberately dropped — never touches the return value
|
|
54
|
+
return rest;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** SHA-256 fingerprint of the sanitized request, domain-separated from other hashes this
|
|
58
|
+
* package computes so a fingerprint can never be mistaken for (or collide in intent with)
|
|
59
|
+
* a leaf hash, a tree head hash, or a digest. */
|
|
60
|
+
export function requestFingerprint(req) {
|
|
61
|
+
return sha256(cat(te.encode("PQ-CUSTODIAN-LOG-v1-REQUEST\n"), te.encode(jcs(sanitizeRequest(req)))));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Build one hash-chained log entry. `prevHash` is the previous entry's own hash (or 32
|
|
66
|
+
* zero bytes for the first entry — a genesis convention, not a signature of anything).
|
|
67
|
+
* Chaining means altering, reordering, or deleting a past entry changes every hash after
|
|
68
|
+
* it — verify() below walks the chain and would catch that.
|
|
69
|
+
*/
|
|
70
|
+
function buildEntry({ seq, prevHash, idempotencyKey, timestampMs, req, result }) {
|
|
71
|
+
const fp = requestFingerprint(req);
|
|
72
|
+
const resultForLog = JSON.parse(JSON.stringify(result, (_, v) => (typeof v === "bigint" ? v.toString() : v)));
|
|
73
|
+
const body = { seq, idempotencyKey, timestampMs, requestFingerprintHex: toHex(fp), result: resultForLog };
|
|
74
|
+
const hash = sha256(cat(te.encode("PQ-CUSTODIAN-LOG-v1-ENTRY\n"), prevHash, te.encode(jcs(body))));
|
|
75
|
+
return { ...body, prevHashHex: toHex(prevHash), hashHex: toHex(hash) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const GENESIS = new Uint8Array(32);
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* An in-memory, hash-chained, idempotency-aware audit log. `entries` is exposed read-only
|
|
82
|
+
* (a caller inspects it; nothing here lets a caller MUTATE past entries in place, only
|
|
83
|
+
* append — the only way to alter history is to edit the array yourself, which verify()
|
|
84
|
+
* below is built to catch).
|
|
85
|
+
*/
|
|
86
|
+
export function createAuditLog() {
|
|
87
|
+
const entries = [];
|
|
88
|
+
const byIdempotencyKey = new Map();
|
|
89
|
+
return {
|
|
90
|
+
entries,
|
|
91
|
+
/** True if this exact idempotencyKey has already been logged. */
|
|
92
|
+
has(idempotencyKey) { return byIdempotencyKey.has(idempotencyKey); },
|
|
93
|
+
/** The cached entry for a previously-seen idempotencyKey, or undefined. */
|
|
94
|
+
find(idempotencyKey) { return byIdempotencyKey.get(idempotencyKey); },
|
|
95
|
+
/** Append a new entry. Throws if idempotencyKey was already used — callers should
|
|
96
|
+
* check has()/find() first; this guard exists so a bug that skips that check fails
|
|
97
|
+
* loud rather than silently duplicating a decision under one key. */
|
|
98
|
+
append(idempotencyKey, req, result, timestampMs) {
|
|
99
|
+
if (byIdempotencyKey.has(idempotencyKey)) {
|
|
100
|
+
throw new Error(`custodian-log: idempotencyKey '${idempotencyKey}' was already logged — ` +
|
|
101
|
+
"this append would duplicate a decision under one key. Call find() first.");
|
|
102
|
+
}
|
|
103
|
+
const prevHash = entries.length ? Buffer.from(entries[entries.length - 1].hashHex, "hex") : GENESIS;
|
|
104
|
+
const entry = buildEntry({ seq: entries.length, prevHash, idempotencyKey, timestampMs, req, result });
|
|
105
|
+
entries.push(entry);
|
|
106
|
+
byIdempotencyKey.set(idempotencyKey, entry);
|
|
107
|
+
return entry;
|
|
108
|
+
},
|
|
109
|
+
/**
|
|
110
|
+
* Walk the whole chain and recompute every hash from scratch. Detects tampering
|
|
111
|
+
* (an entry edited in place), reordering, and deletion (a gap in `seq`, or a
|
|
112
|
+
* `prevHashHex` that no longer matches the entry actually before it).
|
|
113
|
+
*/
|
|
114
|
+
verify() {
|
|
115
|
+
let prevHash = GENESIS;
|
|
116
|
+
for (let i = 0; i < entries.length; i++) {
|
|
117
|
+
const e = entries[i];
|
|
118
|
+
if (e.seq !== i) return { valid: false, brokenAt: i, reason: `seq ${e.seq} != index ${i} (reordered or deleted entry)` };
|
|
119
|
+
if (toHex(prevHash) !== e.prevHashHex) return { valid: false, brokenAt: i, reason: "prevHashHex does not match the actual previous entry (history rewritten)" };
|
|
120
|
+
// Recompute the hash straight from what WAS stored (seq/idempotencyKey/timestamp/
|
|
121
|
+
// fingerprint/result) — the same fields buildEntry() hashed originally. This is
|
|
122
|
+
// exactly what an independent auditor with only the persisted log (never the
|
|
123
|
+
// original req, which was never stored) could also do.
|
|
124
|
+
const body = { seq: e.seq, idempotencyKey: e.idempotencyKey, timestampMs: e.timestampMs,
|
|
125
|
+
requestFingerprintHex: e.requestFingerprintHex, result: e.result };
|
|
126
|
+
const hash = sha256(cat(te.encode("PQ-CUSTODIAN-LOG-v1-ENTRY\n"), prevHash, te.encode(jcs(body))));
|
|
127
|
+
if (toHex(hash) !== e.hashHex) return { valid: false, brokenAt: i, reason: "entry hash does not match its own recorded fields (entry was edited in place)" };
|
|
128
|
+
prevHash = hash;
|
|
129
|
+
}
|
|
130
|
+
return { valid: true, entries: entries.length };
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Authorize a spend through the audit log: idempotent (a repeated idempotencyKey returns
|
|
137
|
+
* the ORIGINAL result, never re-signs) and logged (every outcome — authorized or refused —
|
|
138
|
+
* is appended to the chain, so a refused attempt is part of the record too, not just
|
|
139
|
+
* successes).
|
|
140
|
+
*
|
|
141
|
+
* @param {ReturnType<typeof createAuditLog>} log
|
|
142
|
+
* @param {string} idempotencyKey caller-supplied, e.g. a UUID per spend request. The SAME
|
|
143
|
+
* key on a retry (network blip, process restart) returns the cached decision instead of
|
|
144
|
+
* authorizing again — authorizeAndSign has no notion of "this was already asked", so
|
|
145
|
+
* without this a retried request would draw a SECOND valid signature over the same coin.
|
|
146
|
+
* @param {object} req the exact request object authorizeAndSign() expects (including the
|
|
147
|
+
* real `policyKey`) — never itself persisted, see sanitizeRequest().
|
|
148
|
+
* @param {number} [timestampMs] defaults to Date.now(); pass explicitly for reproducible tests.
|
|
149
|
+
*/
|
|
150
|
+
export function custodianAuthorize(log, idempotencyKey, req, timestampMs) {
|
|
151
|
+
const existing = log.find(idempotencyKey);
|
|
152
|
+
if (existing) return { ...existing.result, idempotentReplay: true, replayOfSeq: existing.seq };
|
|
153
|
+
const result = authorizeAndSign(req);
|
|
154
|
+
log.append(idempotencyKey, req, result, timestampMs ?? Date.now());
|
|
155
|
+
return { ...result, idempotentReplay: false };
|
|
156
|
+
}
|
package/src/letter-claims.mjs
CHANGED
|
@@ -166,7 +166,7 @@ async function mount() {
|
|
|
166
166
|
return _mount;
|
|
167
167
|
}
|
|
168
168
|
|
|
169
|
-
const WORD_NUM = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10, eleven: 11 };
|
|
169
|
+
const WORD_NUM = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10, eleven: 11, twelve: 12 };
|
|
170
170
|
|
|
171
171
|
// ── the registry ───────────────────────────────────────────────────────────
|
|
172
172
|
export const LETTER_CLAIMS = [
|
|
@@ -230,7 +230,7 @@ export const LETTER_CLAIMS = [
|
|
|
230
230
|
{
|
|
231
231
|
id: "L8-siege-rounds",
|
|
232
232
|
what: "how many adversarial rounds the letter claims, vs how many the mutation matrix records",
|
|
233
|
-
extract: (t) => extract(t, /(Seven|Six|Eight|Five|Nine|Ten|Eleven) adversarial siege rounds/, "rounds"),
|
|
233
|
+
extract: (t) => extract(t, /(Seven|Six|Eight|Five|Nine|Ten|Eleven|Twelve) adversarial siege rounds/, "rounds"),
|
|
234
234
|
measure: () => siegeRounds(),
|
|
235
235
|
compare: (a, m) => WORD_NUM[String(a).toLowerCase()] === m,
|
|
236
236
|
falsify: (a) => (String(a).toLowerCase() === "seven" ? "Eight" : "Seven"),
|
|
@@ -507,11 +507,11 @@ export const LETTER_SCOPE = Object.freeze({
|
|
|
507
507
|
"you. Our own adversarial pass put a false block height next to a true one and this " +
|
|
508
508
|
"tool went green — not by reading the wrong value, but by never looking at it.",
|
|
509
509
|
notCovered: [
|
|
510
|
-
"'
|
|
510
|
+
"'the only package that will tell you...which of its own security claims it cannot back' " +
|
|
511
|
+
"— a claim about the world, unfalsifiable by us",
|
|
511
512
|
"'We are not the standard' — a judgement",
|
|
512
|
-
"'roughly
|
|
513
|
-
"
|
|
514
|
-
"longer exists. It cannot be re-measured today, so it is declared rather than checked.",
|
|
513
|
+
"'roughly 1,150 hand-run exploits' across the ten rounds that found real bugs — an " +
|
|
514
|
+
"aggregate we count by hand, not measured by any check",
|
|
515
515
|
"every forward-looking commitment (tranches, timelines, the failure clause)",
|
|
516
516
|
],
|
|
517
517
|
});
|
package/src/mutations.mjs
CHANGED
|
@@ -346,6 +346,25 @@ export const MUTATIONS = [
|
|
|
346
346
|
to: ` if (false) {`,
|
|
347
347
|
mustKill: ["R11-finalized-witness-independently-verifies"],
|
|
348
348
|
},
|
|
349
|
+
{
|
|
350
|
+
id: "M-R12-idempotency-ignored",
|
|
351
|
+
describes: "R12: custodianAuthorize stops checking for an existing entry, so a retried " +
|
|
352
|
+
"request under the same idempotency key re-authorises (and, with a real " +
|
|
353
|
+
"policyKey, re-signs) instead of returning the cached decision",
|
|
354
|
+
file: "src/custodian-log.mjs",
|
|
355
|
+
from: ` if (existing) return { ...existing.result, idempotentReplay: true, replayOfSeq: existing.seq };`,
|
|
356
|
+
to: ` if (false) return { ...existing.result, idempotentReplay: true, replayOfSeq: existing.seq };`,
|
|
357
|
+
mustKill: ["R12-custodian-log-is-idempotent-and-tamper-evident"],
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
id: "M-R12-tamper-undetected",
|
|
361
|
+
describes: "R12: verify() stops recomputing the hash chain (always reports clean), so an " +
|
|
362
|
+
"edited, reordered, or deleted past entry is never caught",
|
|
363
|
+
file: "src/custodian-log.mjs",
|
|
364
|
+
from: ` if (toHex(hash) !== e.hashHex) return { valid: false, brokenAt: i, reason: "entry hash does not match its own recorded fields (entry was edited in place)" };`,
|
|
365
|
+
to: ` if (false) return { valid: false, brokenAt: i, reason: "entry hash does not match its own recorded fields (entry was edited in place)" };`,
|
|
366
|
+
mustKill: ["R12-custodian-log-is-idempotent-and-tamper-evident"],
|
|
367
|
+
},
|
|
349
368
|
];
|
|
350
369
|
|
|
351
370
|
/**
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Custodian audit log — idempotency (no double-signing on retry) and tamper-evidence
|
|
2
|
+
// (a hash-chained history that catches edits, reordering, and deletion), plus the one
|
|
3
|
+
// property that matters most for a file that is ABOUT to be handed real secret material:
|
|
4
|
+
// the raw policyKey bytes must never appear anywhere this module persists. Real keys, real
|
|
5
|
+
// ML-DSA-65 + Schnorr signatures, no mocks.
|
|
6
|
+
|
|
7
|
+
import { schnorr } from "@noble/curves/secp256k1.js";
|
|
8
|
+
import { ml_dsa65 } from "@noble/post-quantum/ml-dsa.js";
|
|
9
|
+
import * as T from "./../src/transparency.mjs";
|
|
10
|
+
import * as P from "./../src/primacy.mjs";
|
|
11
|
+
import * as OTS from "./../src/ots.mjs";
|
|
12
|
+
import { generateMigrationIdentity, createMigrationCommitment, authorizeSpend } from "./../src/migration-envelope.mjs";
|
|
13
|
+
import { authorizeAndSign, buildSpendContext } from "./../src/policy.mjs";
|
|
14
|
+
import { createAuditLog, custodianAuthorize, sanitizeRequest, requestFingerprint } from "./../src/custodian-log.mjs";
|
|
15
|
+
|
|
16
|
+
let pass = 0, fail = 0;
|
|
17
|
+
function check(name, cond) {
|
|
18
|
+
if (cond) { pass++; console.log(` ✓ ${name}`); }
|
|
19
|
+
else { fail++; console.log(` ✗ ${name}`); }
|
|
20
|
+
}
|
|
21
|
+
const hex = (b) => Buffer.from(b).toString("hex");
|
|
22
|
+
const te = new TextEncoder();
|
|
23
|
+
|
|
24
|
+
console.log("Custodian audit log — idempotency + tamper-evidence + secret-safety\n");
|
|
25
|
+
|
|
26
|
+
const kp = ml_dsa65.keygen(new Uint8Array(32).fill(0x9c));
|
|
27
|
+
const leaves = (n) => Array.from({ length: n }, (_, i) => T.hashLeaf(te.encode(`c-${i}`)));
|
|
28
|
+
const ident = generateMigrationIdentity();
|
|
29
|
+
const cert = createMigrationCommitment(ident);
|
|
30
|
+
const entry = { classicalPub: cert.classicalPub, pqPublic: cert.pqPublic, classicalSig: cert.classicalSig, pqSig: cert.pqSig };
|
|
31
|
+
const other = createMigrationCommitment(generateMigrationIdentity());
|
|
32
|
+
const ENTRIES = [{ classicalPub: other.classicalPub, pqPublic: other.pqPublic, classicalSig: other.classicalSig, pqSig: other.pqSig }, entry];
|
|
33
|
+
const LEAVES = ENTRIES.map(P.entryLeaf);
|
|
34
|
+
const STH = T.signTreeHead({ secretKey: kp.secretKey, publicKey: kp.publicKey, treeSize: ENTRIES.length, rootHash: T.merkleRoot(LEAVES), timestampMs: 1_756_000_000_000 });
|
|
35
|
+
const HEAD_BYTES = T.sthSignedBytes({ version: STH.version, logId: STH.logId, treeSize: STH.treeSize, rootHash: T.fromHex(STH.rootHash), timestampMs: STH.timestampMs });
|
|
36
|
+
const OTS_DIGEST = OTS.otsSha256(HEAD_BYTES);
|
|
37
|
+
const ANCHOR_HEIGHT = 800_000;
|
|
38
|
+
const OTS_HEX = T.toHex(OTS.encodeOtsFile({ majorVersion: OTS.OTS_MAJOR_VERSION, fileHashOp: "sha256", fileDigest: OTS_DIGEST, timestamp: OTS.timestampWithAttestation(OTS_DIGEST, { kind: "bitcoin", height: ANCHOR_HEIGHT }) }));
|
|
39
|
+
const REAL_ROOT = (() => { const a = OTS.evaluateOts(OTS.decodeOtsFile(T.fromHex(OTS_HEX))).blockAttestations[0]; return a.merkleRootInternalHex.match(/../g).reverse().join(""); })();
|
|
40
|
+
const HEADERS = { [ANCHOR_HEIGHT]: REAL_ROOT };
|
|
41
|
+
const CUTOFF = 900_000;
|
|
42
|
+
const POLICY_KEY = new Uint8Array(32).fill(0x2b);
|
|
43
|
+
const ANCHORED_FH = P.certIsFirstSeen({ cert, entries: ENTRIES, sth: STH, expectedLogId: STH.logId,
|
|
44
|
+
cutoffBlockHeight: CUTOFF, otsHex: OTS_HEX, blockMerkleRoots: HEADERS, requireTemporalFrontier: true }).anchoredFactHash;
|
|
45
|
+
const SC = buildSpendContext({ anchoredFactHash: ANCHORED_FH, policyPublicKey: schnorr.getPublicKey(POLICY_KEY),
|
|
46
|
+
txid: "11".repeat(32), valueSats: 100_000n, outputs: [{ valueSats: 90_000n, scriptPubKey: new Uint8Array(34) }] });
|
|
47
|
+
const SPEND_SIG = authorizeSpend(ident, SC.digest);
|
|
48
|
+
const goodReq = (overrides = {}) => ({ cert, pqSignatureHex: SPEND_SIG,
|
|
49
|
+
anchorEvidence: { sth: STH, entries: ENTRIES, otsHex: OTS_HEX }, expectedLogId: STH.logId,
|
|
50
|
+
policyKey: POLICY_KEY, cutoffBlockHeight: CUTOFF, blockMerkleRoots: HEADERS,
|
|
51
|
+
spendContext: SC.spendContext, ...overrides });
|
|
52
|
+
|
|
53
|
+
console.log("★ SECRET-SAFETY — the reason this module exists as its own file:");
|
|
54
|
+
{
|
|
55
|
+
const safe = sanitizeRequest(goodReq());
|
|
56
|
+
check("sanitizeRequest strips policyKey entirely (not present as a key at all)",
|
|
57
|
+
!Object.prototype.hasOwnProperty.call(safe, "policyKey"));
|
|
58
|
+
const serialized = JSON.stringify(safe, (_, v) => (typeof v === "bigint" ? v.toString() : v));
|
|
59
|
+
check("the raw policyKey hex never appears anywhere in the sanitized/serialized request",
|
|
60
|
+
!serialized.includes(hex(POLICY_KEY)));
|
|
61
|
+
|
|
62
|
+
const log = createAuditLog();
|
|
63
|
+
custodianAuthorize(log, "req-1", goodReq(), 1_756_000_100_000);
|
|
64
|
+
const wholeLogSerialized = JSON.stringify(log.entries);
|
|
65
|
+
check("★★ the raw policyKey hex never appears anywhere in the PERSISTED LOG after a real authorization",
|
|
66
|
+
!wholeLogSerialized.includes(hex(POLICY_KEY)));
|
|
67
|
+
check("the log DOES contain the request fingerprint (it isn't just empty/useless)",
|
|
68
|
+
wholeLogSerialized.includes(hex(requestFingerprint(goodReq()))));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
console.log("\nBasic authorize + log:");
|
|
72
|
+
{
|
|
73
|
+
const log = createAuditLog();
|
|
74
|
+
const r = custodianAuthorize(log, "req-A", goodReq(), 1_756_000_100_000);
|
|
75
|
+
check("authorizes (control is not vacuous)", r.authorized === true);
|
|
76
|
+
check("not a replay on first use", r.idempotentReplay === false);
|
|
77
|
+
check("exactly one entry logged", log.entries.length === 1);
|
|
78
|
+
check("logged entry's result matches what was returned", log.entries[0].result.policySignatureHex === r.policySignatureHex);
|
|
79
|
+
check("chain verifies clean", log.verify().valid === true);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log("\n★ Idempotency — a retried request must NOT re-sign:");
|
|
83
|
+
{
|
|
84
|
+
const log = createAuditLog();
|
|
85
|
+
const r1 = custodianAuthorize(log, "req-B", goodReq(), 1_756_000_100_000);
|
|
86
|
+
const r2 = custodianAuthorize(log, "req-B", goodReq(), 1_756_000_200_000); // same key, later call
|
|
87
|
+
check("second call is flagged as a replay", r2.idempotentReplay === true);
|
|
88
|
+
check("★ the signature is BYTE-IDENTICAL across both calls — schnorr.sign draws fresh " +
|
|
89
|
+
"BIP-340 randomness each time it actually runs, so identical bytes here proves the " +
|
|
90
|
+
"second call did NOT re-sign, it returned the cached result",
|
|
91
|
+
r1.policySignatureHex === r2.policySignatureHex);
|
|
92
|
+
check("only ONE entry was ever logged for this idempotencyKey", log.entries.length === 1);
|
|
93
|
+
check("append() itself refuses to double-log the same key", (() => {
|
|
94
|
+
try { log.append("req-B", goodReq(), r1, 1); return false; }
|
|
95
|
+
catch { return true; }
|
|
96
|
+
})());
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
console.log("\nRefusals are logged too — the audit trail is not success-only:");
|
|
100
|
+
{
|
|
101
|
+
const log = createAuditLog();
|
|
102
|
+
const refused = custodianAuthorize(log, "req-C", goodReq({ cutoffBlockHeight: 1 }), 1_756_000_100_000);
|
|
103
|
+
check("this variant is genuinely refused", refused.authorized === false);
|
|
104
|
+
check("the refusal was still logged", log.entries.length === 1);
|
|
105
|
+
check("the logged result records the refusal reason", log.entries[0].result.reason === refused.reason);
|
|
106
|
+
check("chain still verifies (refusals don't corrupt the log)", log.verify().valid === true);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
console.log("\n★ Tamper-evidence — a hash-chained history catches what a plain array log would not:");
|
|
110
|
+
{
|
|
111
|
+
const log = createAuditLog();
|
|
112
|
+
custodianAuthorize(log, "req-D1", goodReq(), 1);
|
|
113
|
+
custodianAuthorize(log, "req-D2", goodReq({ cutoffBlockHeight: 1 }), 2); // a refusal, in the middle
|
|
114
|
+
custodianAuthorize(log, "req-D3", goodReq(), 3);
|
|
115
|
+
check("setup: 3 entries, clean chain", log.entries.length === 3 && log.verify().valid === true);
|
|
116
|
+
|
|
117
|
+
const editedResult = JSON.parse(JSON.stringify(log.entries[1].result));
|
|
118
|
+
editedResult.reason = "ok"; // an attacker hiding that req-D2 was ever refused
|
|
119
|
+
const tamperedInPlace = { ...log.entries[1], result: editedResult };
|
|
120
|
+
const savedOriginal = log.entries[1];
|
|
121
|
+
log.entries[1] = tamperedInPlace;
|
|
122
|
+
const afterEdit = log.verify();
|
|
123
|
+
check("★ editing a past entry's result in place is detected", afterEdit.valid === false && afterEdit.brokenAt === 1);
|
|
124
|
+
log.entries[1] = savedOriginal; // restore
|
|
125
|
+
|
|
126
|
+
const savedForDeletion = log.entries.splice(1, 1)[0];
|
|
127
|
+
const afterDelete = log.verify();
|
|
128
|
+
check("★ deleting a past entry is detected (seq no longer matches index, or prevHash breaks)",
|
|
129
|
+
afterDelete.valid === false);
|
|
130
|
+
log.entries.splice(1, 0, savedForDeletion); // restore
|
|
131
|
+
|
|
132
|
+
check("restored log verifies clean again (the detector isn't just always red)", log.verify().valid === true);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
console.log(`\n${pass} passed, ${fail} failed`);
|
|
136
|
+
process.exit(fail === 0 ? 0 : 1);
|
package/test/letter-claims.mjs
CHANGED
|
@@ -82,7 +82,7 @@ console.log("\nAdversarial: a tampered letter must be refused");
|
|
|
82
82
|
// ROUND 9: this hardcoded "Seven", so when the letter honestly moved to "Nine" the tamper
|
|
83
83
|
// stopped tampering and the suite reported it as refused. A perturbation that does not
|
|
84
84
|
// perturb is a false green — the same disease, in the test that guards against it.
|
|
85
|
-
["inflated siege rounds", (t) => t.replace(/\b(Five|Six|Seven|Eight|Nine|Ten|Eleven) adversarial siege rounds/,
|
|
85
|
+
["inflated siege rounds", (t) => t.replace(/\b(Five|Six|Seven|Eight|Nine|Ten|Eleven|Twelve) adversarial siege rounds/,
|
|
86
86
|
"Twenty adversarial siege rounds")],
|
|
87
87
|
["wrong version", (t) => t.replace(/fractal-pqc@[\d.]+/g, "fractal-pqc@9.9.9")],
|
|
88
88
|
["inflated BIP-341 cases", (t) => t.replace(/7\/7 scriptPubKey/, "9/9 scriptPubKey")],
|