# X-39 Notaría — Bitcoin-anchored, post-quantum document proofs (and how to verify every claim here)

**URL:** <https://forum.dfinity.org/t/x-39-notaria-bitcoin-anchored-post-quantum-document-proofs-and-how-to-verify-every-claim-here/75722>\
**Category:** Showcase\
**Created:** [September 20, 2026, 7:25pm UTC](https://forum.dfinity.org/t/x-39-notaria-bitcoin-anchored-post-quantum-document-proofs-and-how-to-verify-every-claim-here/75722 "2026-09-20T19:25:13Z")\
**Posts on this page:** 1\
**Page:** 1

<div class="post-metadata">

**Author:** ![x39matrix-revolution](https://sea1.discourse-cdn.com/flex023/user_avatar/forum.dfinity.org/x39matrix-revolution/32/40461_2.png) [@x39matrix-revolution](https://forum.dfinity.org/u/x39matrix-revolution)\
**Post date:** [September 20, 2026, 7:25pm UTC](https://forum.dfinity.org/t/x-39-notaria-bitcoin-anchored-post-quantum-document-proofs-and-how-to-verify-every-claim-here/75722/1 "2026-09-20T19:25:13Z")

</div>

I’m José Luis Olivares (x39matrix), a solo developer from Sevilla, Spain. Since March I’ve been building X-39 Notaría on ICP: a notarization system where the existence and integrity of a document can be proven by a third party without trusting me, or any server I run.

I’d rather you verify it than believe it, so everything below comes with a command, a schema, or a public link.

What it does

SHA-256 is computed in the browser or on the device — files are never uploaded, only their hash. Each proof is anchored to Bitcoin through OpenTimestamps, and can carry an ML-DSA-87 (FIPS 204, post-quantum) signature.

When two parties negotiate before sealing, the conversation is recorded as a hash chain: each entry carries the hash of the message and the hash of the previous entry, never the text. You can prove nothing was inserted, removed or reordered — and you still cannot read what was said.

The protocol

The Android app exports a receipt (.json) plus its OpenTimestamps proof (.ots). The captured file never leaves the phone. A receipt contains:

sha256 — SHA-256 of the captured file  
captured\_at\_local — local capture time, ISO 8601 with offset  
signer\_pk — device’s ML-DSA-87 public key (base64)  
signer\_fp — SHA-256 of signer\_pk  
sig\_alg — ML-DSA-87 (FIPS 204)  
sig — signature over the canonical message below (base64)  
ots — OpenTimestamps proof for sha256  
ots\_status / bitcoin\_block — pending until confirmed, then bitcoin + block height

Two further slots (a co-signature and a second timestamp source) are reserved and currently null. I’d rather ship them empty than pretend they’re filled.

The canonical message that gets signed is exactly the literal prefix, the file hash and the capture time, joined by pipes:

X39-EVIDENCIA-v1|SHA256\_HEX|CAPTURED\_AT\_LOCAL

So a receipt verifies end to end with off-the-shelf tools:

# pip install dilithium-py

import base64, hashlib, json  
from dilithium\_py.ml\_dsa import ML\_DSA\_87

r = json.load(open(“receipt.json”))  
pk = base64.b64decode(r[“signer\_pk”])  
sig = base64.b64decode(r[“sig”])  
msg = f’X39-EVIDENCIA-v1|{r[“sha256”]}|{r[“captured\_at\_local”]}'.encode()

assert hashlib.sha256(pk).hexdigest() == r[“signer\_fp”] # key matches its fingerprint  
assert ML\_DSA\_87.verify(pk, msg, sig) # post-quantum signature is valid

And the timestamp, against your own node:

ots verify receipt.ots

Flip one byte of the file, the message or the signature and both checks fail. Nothing in that chain requires me.

On ICP — and what deliberately isn’t

Frontend canister tvkfy-xqaaa-aaaab-qheta-cai

custom domain: [https://x39matrix.org](https://x39matrix.org)  
native gateway: [https://tvkfy-xqaaa-aaaab-qheta-cai.icp0.io](https://tvkfy-xqaaa-aaaab-qheta-cai.icp0.io)

Same canister either way, so you don’t have to take my DNS on trust. It’s a standard asset canister, which means its module hash is DFINITY’s, not mine — checking that hash proves nothing about my code. What’s worth verifying is the content it serves, which matches the repository byte for byte:

dfx --identity anonymous canister info tvkfy-xqaaa-aaaab-qheta-cai --network ic

curl -s --compressed [https://x39matrix.org/verify\_bundle.py](https://x39matrix.org/verify_bundle.py) | sha256sum

The backend is deliberately not a canister, and I’d rather say why up front than be asked: it needs a full Bitcoin node to verify its own timestamps, and an air-gapped machine for the cold signature. Neither of those fits in a canister. The frontend is on ICP; the parts that must not be able to phone home are not on any network at all.

Two small machines, and why

The trust model runs on two Raspberry Pis, and the split between them is the point.

A Raspberry Pi 5 runs the backend and a full, unpruned Bitcoin Core node — validation only, no wallet on it. To be precise: I do not use the IC’s native Bitcoin integration. Proofs are stamped with OpenTimestamps against the public calendars, then verified locally against my own node.

After each ots upgrade the service runs ots verify against that node and stores two separate fields: bloque, what the proof declares (ots info), and verificado\_bloque, what my own node confirms. They’re deliberately kept apart — while they agree, all is well; the day they diverge, that’s an alarm. Overwriting one with the other would have destroyed the signal. When a proof carries several attestations, the parser keeps the earliest block, never the most favourable one. The whole cycle — stamp, mature, verify against my node, publish — runs unattended every six hours.

A Raspberry Pi 500 holds the COLD ML-DSA-87 key and has never been connected to a network. The keypair was generated there, signatures are produced there, and data crosses in and out only on a USB stick, following a written ceremony. Its fingerprint is pinned in the backend source, and the service self-checks at startup and aborts if the key in the database isn’t the pinned one:

8453a25a41d6fe8fcb5647600f042a7c303daaca79b80928534025711981c6a1

No signing key of mine lives on a networked machine. The evidence bundles carry a warm slot for exactly that, and it reads null — it has since I retired it in July 2026.

The post-quantum part, precisely

I’d rather be exact about where post-quantum crypto is used and where it isn’t.

Where it is — ML-DSA-87 (NIST FIPS 204), the highest of the three parameter sets:

- Every receipt from the Android app is signed on the device with its own ML-DSA-87 key: public key 2592 bytes, signature 4627 bytes.
- Sealed agreements carry a COLD ML-DSA-87 co-signature, produced on the air-gapped Raspberry Pi 500.

Where it isn’t, and I’d rather you read it here than find it yourself:

- The per-message chat signatures are Ed25519. Classical, fast, and enough for the live part; the post-quantum layer sits over the sealed result.
- The APK is signed with RSA-4096, because Android’s APK signature schemes don’t support post-quantum algorithms. That is a platform limit, not a design choice — run apksigner on it and you’ll see exactly that.

What it looks like in a real bundle. Download a live proof:

curl -s -o proof.zip [https://api.x39matrix.org/api/notaria/proof/31e1cf09a5b2a74cd978.zip](https://api.x39matrix.org/api/notaria/proof/31e1cf09a5b2a74cd978.zip)

open signatures.json, and this is what’s inside (truncated):

“cold”: {  
“algorithm”: “ML-DSA-87”,  
“fingerprint”: “8453a25a41d6fe8fcb5647600f042a7c303daaca79b80928534025711981c6a1”,  
“public\_key\_b64”: “EYAyKFkMjziHGqXzVF2I8CNDQMTSXPfvNwkhFET/vp8…”,  
“signature\_b64”: “sujU4/Kfkejn8ygHOUNwRAtBup2o1RAAZTZcEH+ngohK…”,  
“tier”: “COLD”,  
“verified\_at”: “2026-08-30T10:25:02.296158+00:00”  
},  
“warm”: null

The fingerprint in that block is the one pinned in the source, so you verify against a published key rather than against the key that came inside the evidence.

Why bother at all. The point of a notarization system is that the proof still holds years from now. A signature that becomes forgeable later is a proof with an expiry date nobody wrote on it. Bitcoin fixes when something existed; the post-quantum signature is about who sealed it, and it is meant to still mean something when the classical signatures no longer do.

Why the cold signature is there at all

It is the one thing an attacker cannot produce by owning my server. If someone takes the online Pi, they can tamper with the database and issue false proofs — but they cannot produce a valid COLD signature, because that key lives on a machine with no network at all.

It is also the layer that survives time, for the reason above. And it is checked against the published fingerprint, not against the key travelling inside the evidence — otherwise a forged bundle, self-consistent with its own key, would pass.

It is an addition, not the foundation. If X-39 disappeared tomorrow, the proof still stands on Bitcoin and the parties’ signatures alone. That is deliberate: nothing here should require trusting me.

That same proof is anchored in Bitcoin block 964449, content hash eda1fa21b0a3c67ddaede7f84bd2641810594670a1438cd955f88fa6098d6967.

Android app

x39 0.3.0 — exactly four permissions, no Google libraries, optional Tor-only anchoring through Orbot. The APK hash and the signing-certificate hash are published next to the download, so both can be checked before installing:

[https://x39matrix.org/app-movil](https://x39matrix.org/app-movil)

or straight off the canister, no DNS involved: [https://tvkfy-xqaaa-aaaab-qheta-cai.icp0.io/app/x39.apk](https://tvkfy-xqaaa-aaaab-qheta-cai.icp0.io/app/x39.apk)

Where it honestly stands

Closed beta, one person, AGPL-3.0. It does not replace a public notary or a qualified electronic signature, and the site says so.

I’m here to contribute and learn rather than to advertise. Happy to go deeper on any of this — and if something doesn’t hold up when you run it, tell me and I’ll fix it.
