Skip to main content

lethe-notary

A paid countersigning witness for Lethe deletion certificates, billed per certificate over x402.

What it sells

A Lethe certificate is self-attestation, and says so in its own claim text: the operator generates the key, performs the deletion, signs the certificate, and stamps it with their own clock. docs/anchoring.md names the consequence — nothing in the artifact brings in a party the operator does not control.

This service is that party. Per certificate it adds three things the operator cannot manufacture:

An independent clock witnessed_at is the notary's, not the issuer's
An independent signature a second key, held by someone else, over the certificate's hash
A copy of the audit head, off-site the part that actually matters

The third is what the other two are for. Lethe's hash chain cannot detect tip-truncation: dropping the most recent entries leaves a shorter chain that is perfectly self-consistent, and verify_chain() still returns VALID. docs/anchoring.md says the only fix is a copy of the head living somewhere the operator cannot reach. This is that place, as a service — and tests/test_truncation_detection.py runs the whole scenario against a real database: three real deletions, notarized, then the operator drops entries. Lethe alone still says VALID. The witness log convicts them.

What a receipt does not say

The notary sees a 2.3 KB JSON document and nothing else. It has no access to the issuer's database, cannot observe the deletion, and cannot know that the key in the certificate belongs to the organisation presenting it. So a receipt attests that a certificate with this payload hash was presented at this time and was internally valid, and that it named this audit_head. It does not attest to the presenter's identity, or that the deletion happened, or that anything else in the certificate is true. The full text is served at /.well-known/notary and reproduced in every receipt.

Overclaiming here would poison the product. The value is a witness, not a blessing.

Why per-certificate payments, and why x402

The caller is an agent. Agents cannot complete signup flows, have no email addresses, and do not survive token rotation — but they sign structured payloads in milliseconds. x402 inverts the credential problem: the server trusts a signature on a payment instead of a token issued in advance. For a per-certificate service with no accounts, that is the entire integration.

Who pays. The data controller, buying evidence about their own compliance. Nothing here sits between a data subject and their erasure. GDPR Art. 12(5) requires actions under Arts. 15–22 to be free of charge; a paywall in that path would be unlawful and self-defeating for a tool whose product is provable compliance.

What is free, permanently: witness retrieval, the discovery document, and health. Witness retrieval is the query an operator runs during a dispute — charging for it, or being unreachable, at that moment would make the evidence worthless.

What is never charged for: a certificate that fails verification (the customer must not pay for a refusal), and a certificate already witnessed (no new work, no new money — and no second receipt disagreeing with the first about when it was seen).

Endpoints

POST /notarize paid verify a certificate, countersign, record the head
GET /challenge free a single-use nonce
POST /witness free every head witnessed for your key
GET /.well-known/notary free notary public key, key id, claim text, price
GET /health free

No accounts. Two proofs, both signatures: x402 for payment, and a signed challenge for reading your own witness log. An operator who can sign with the key their certificates name is, by construction, the party entitled to that log. Challenges are single-use — a replayable nonce is a bearer token with extra steps, which is what this avoids.

Never sign the bare nonce

You prove key control with the same key that signs your certificates, and the notary chooses the nonce. Signing a raw server-supplied string with that key is a signing oracle: a malicious or compromised notary can serve a canonical certificate payload as the nonce, and a naive client hands back a valid certificate signature. The attacker wraps it in an envelope and holds a certificate that verifies against your published key, attesting to deletions that never happened. This was demonstrated end to end against the first cut of this service.

So sign the domain-separated message, which /challenge hands you verbatim in the sign field:

lethe-notary/challenge/v1:<notary_key_id>:<nonce>

A certificate payload is canonical JSON and always begins with {, so a message with this prefix can never be one. The notary's own key id is included so a signature harvested by one notary does not authenticate at another. The server rejects a bare-nonce signature rather than accepting both forms — accepting both would leave the oracle open.

issued = requests.get(f"{NOTARY}/challenge").json()
requests.post(f"{NOTARY}/witness", json={
    "public_key": operator.public_key_b64(),
    "nonce": issued["nonce"],
    "signature": operator.sign(issued["sign"].encode()),   # NOT issued["nonce"]
})

Page the witness query to the end

/witness returns at most 1000 heads per call, with complete and next_after. Do not conclude anything from a page where complete is false. A head that was witnessed but not returned looks exactly like a head that was never witnessed, which is the opposite of what the evidence says. Pass after: next_after until complete is true.

Why the notary does not chain its own log

The witness log is append-only but not hash-chained, and that is deliberate: chaining it would only let the notary prove things to itself. Your receipt is the authoritative artifact — it is signed by the notary's published key, and you hold it. A notary that later denied witnessing your certificate would be contradicted by a signature it cannot repudiate. /witness is a convenience for finding what you were given; it is not the evidence. Keep your receipts.

The log is private

A public log would make the truncation argument stronger, but it would publish deletion metadata: which key deleted what, when, how often, under which table names. That is the operator's business, not the notary's. The log is append-only and readable only by whoever controls the key that wrote to it.

Running it

pip install -e ..               # lethe-delete, from the repo root
pip install -e .                # then the notary, from this directory
lethe-notary keygen --out notary.key          # back this up offline
export LETHE_NOTARY_KEY_FILE=notary.key
export LETHE_NOTARY_PAY_TO=0xYourAddress
export LETHE_NOTARY_PRICE='$0.02'
lethe-notary serve

If lethe-notary is not found, its console script landed in a Scripts/bin directory that is not on PATH. Every command also works as a module, which sidesteps PATH entirely:

python -m lethe_notary.cli serve

lethe-delete is installed from the repo root first, not pulled from PyPI: this package requires lethe-delete>=0.7, and until a release carrying that version is published, pip install -e . alone fails with no matching distribution. Installing the checkout first satisfies it from source.

On Windows PowerShell, environment variables are $env:NAME = "value", and the price must be in single quotes — $env:LETHE_NOTARY_PRICE = '$0.02' — so the shell does not try to expand $0.

The signing key is the service: everything a customer buys is a signature from it, and every receipt already issued becomes unverifiable if it is lost.

Variable Default
LETHE_NOTARY_KEY_FILE — required
LETHE_NOTARY_PAY_TO — required unless LETHE_NOTARY_FREE=1
LETHE_NOTARY_PRICE $0.01
LETHE_NOTARY_NETWORK eip155:84532 CAIP-2 only — see below
LETHE_NOTARY_FACILITATOR https://x402.org/facilitator must be https
LETHE_NOTARY_FREE unset run without charging, deliberately
LETHE_NOTARY_PUBLIC_URL unset the origin this notary answers on; publishes a catalogable resource identity — see below
LETHE_NOTARY_CDP_KEY_ID unset CDP API key id — required for a facilitator that authenticates, i.e. for mainnet
LETHE_NOTARY_CDP_KEY_SECRET unset the matching Ed25519 secret. Both or neither; half a pair is refused

It fails to start rather than serve wrongly: no PAY_TO and no explicit FREE=1 is a startup error, because one missing environment variable on a deploy should not silently give the service away. A plaintext facilitator is refused. And a preflight asks the facilitator, at boot, whether it can actually settle the configured scheme and network.

Networks must be CAIP-2

eip155:8453, not base. The server will build perfectly well-formed payment requirements from an alias, and the facilitator even advertises both forms — but a paying client normalizes to CAIP-2, finds no match, and refuses with no payment requirements match registered schemes. The notary looks healthy and nobody can buy anything. Startup refuses an alias and names the CAIP-2 form.

Being catalogable

A paid x402 service is indexed by catalogs from its 402 challenge. Set the origin the notary answers on and the challenge grows a resource identity:

export LETHE_NOTARY_PUBLIC_URL=https://notary.example.com

The 402 then carries resource (an absolute URL), extensions.bazaar.info and extensions.bazaar.schema — the three fields present on every one of 2000 catalogued entries sampled on 2026-09-15. Without it the 402 is still valid x402 and the notary works normally; it simply cannot be catalogued. Publishing a guessed origin would be worse than publishing none.

The resource url is never taken from the request, and that is deliberate. A sibling x402 service measured the alternative live on 2026-09-15: it echoed a client-supplied resource back into its 402, so an attacker could pay the minimum with resource=https://evil.example/owned and get their url catalogued against the victim's payout address — borrowing a settlement history for the price of one call, and publishing a javascript: URL into a catalog UI on the way.

The notary cannot be used that way, for reasons that stack:

  • It sells exactly one resource, so the path is a constant (/notarize), not a request parameter. There is no field to echo.
  • The origin comes from LETHE_NOTARY_PUBLIC_URL, never from the request — not from Host, not from X-Forwarded-Host. A spoofed Host changes nothing.
  • The certificate schema is closed (additionalProperties: false), so a request carrying a resource key is rejected before anything else runs.

LETHE_NOTARY_PUBLIC_URL is itself validated at startup, because it is published: absolute http(s) only (so javascript:, data: and //host/x are out), no userinfo (a credential must never be published — the lesson lethe.anchor already learned), printable ASCII only (which disposes of newlines, DEL, Unicode line separators, a space inside the host, and non-ASCII homographs — punycode a non-ASCII host first), https unless the host is localhost, and length-capped. Path, query and fragment are dropped: the path is ours to supply.

One claim worth not repeating: a control character here does not forge response-header structure. The challenge is base64'd, so a \r survives as data inside the payload, not as header syntax — verified. The check is still right, because a catalog renders this value and a URL with a newline in it is malformed wherever it lands; the header-forgery reason was inherited from another project's writeup and repeated here before it was checked.

Going to mainnet

The default is Base Sepolia — eip155:84532 — and it is a testnet on purpose.

A URL swap is not enough, and this section used to say otherwise. Probed 2026-09-15, from this repo:

facilitator kinds eip155:8453 (Base mainnet)
x402.org/facilitator 11 absent — its only EVM networks are eip155:84532 and the base-sepolia alias
facilitator.x402.rs 31 absent — 12 distinct eip155:* ids, none of them 8453

That is the whole verified claim: neither keyless facilitator advertises Base mainnet. It is deliberately not the claim that no keyless facilitator settles mainnet — a sibling project reports a keyless settlement on eip155:8453 and supplied a transaction hash, which this repo has not independently checked. Nor have we characterized every eip155:* id those lists contain; several are chain ids we could not identify, and calling them all testnets would be a guess.

Coinbase CDP does settle Base mainnet. Also not our measurement — what we did verify is that its /supported requires authentication (below), which is consistent with it being a credentialed facilitator and is the part that affects this code.

CDP requires an authenticated request — an EdDSA Bearer JWT, minted per call, bound to method, host and path, with a 120-second TTL. Setting LETHE_NOTARY_FACILITATOR to it and nothing else gets you a 401, because the notary sends no credential.

What that costs you is a startup failure rather than a production one, which is the one piece of luck here. CDP's /supported answers 401 to an unauthenticated caller, and the preflight calls it before the notary serves anything — so a credential-less mainnet config dies at boot, not at your first customer. Do not rely on that for every facilitator: one whose /supported is public but whose /settle is not would pass preflight and fail on the first payment.

That code now exists. lethe_notary.cdp_auth mints the JWT and PaymentGate.server() passes it to the facilitator client as an auth_provider, so a credentialed facilitator is a configuration change again. Set the credential alongside the network and the facilitator:

export LETHE_NOTARY_NETWORK=eip155:8453          # Base mainnet
export LETHE_NOTARY_FACILITATOR=https://...      # one that settles mainnet
export LETHE_NOTARY_CDP_KEY_ID=...               # both, or neither
export LETHE_NOTARY_CDP_KEY_SECRET=...

The token is minted per call and bound to the exact method, host and path being requested, with a 120-second life, so one captured anywhere cannot be replayed against a different endpoint or used for long. It is signed with the Ed25519 already in cryptography, which this package depends on anyway — mainnet auth adds no runtime dependency, and CI proves that by minting a token on an install that has no JWT library at all. The JWS is checked against PyJWT in the test suite rather than against this repo's own idea of a JWT.

Half a credential is refused at startup, for the reason FREE=1 next to PAY_TO is: with one variable set the notary sends no credential at all, so an operator who believes they cut over to mainnet is either 401ing or quietly still on a keyless testnet facilitator.

What this does not do is tell you the credential is right. It mints a token CDP should accept. If it is wrong, /supported answers 401 and the notary refuses to start — at boot, before a customer, which is the only good place to find out. When it is right the banner says so, because a credential is configured and the credential works are different facts:

              checked:     it accepted CDP credential <key id> — preflight was authenticated

Changing only the network is caught at boot. The preflight asks the facilitator what it can settle before the notary serves anything:

lethe-notary: facilitator https://x402.org/facilitator cannot settle scheme
'exact' on network 'eip155:8453' (...). Check .../supported for the kinds it
does settle, or point LETHE_NOTARY_FACILITATOR at one that covers your network.

Check the payee before the first real payment, not after. PAY_TO is checked for shape and EIP-55 checksum, which catches placeholders and typos — it cannot tell whether you control the address, and nothing later will.

The startup banner says so out loud, because the two configurations are one environment variable apart and otherwise print the same line. On testnet:

lethe-notary  key_id=…  $0.01 on eip155:84532 [TESTNET - payments are not real money]
              checked:     https://x402.org/facilitator reports it settles 'exact' on eip155:84532

On mainnet, where being wrong costs money, it also prints what it has not checked. (The facilitator is elided because mainnet needs one that settles it and a credential to reach it — see above; x402.org/facilitator would not get this far, it is refused at preflight.)

lethe-notary  key_id=…  $0.01 on eip155:8453 [MAINNET - real money]
              checked:     https://… reports it settles 'exact' on eip155:8453
              NOT checked: that you control 0x… — no code here can tell. Send one payment and confirm it arrives.
              NOT checked: that any payment has succeeded. This line means configured, not earning.

Only the checked: line is a measurement: preflight fetched the facilitator's /supported and it listed exact on this network. Everything else on the banner is a label on an environment variable, and the banner says which is which rather than letting a printed address read as a verified one.

An unrecognized network id says [unrecognized network - verify before serving] rather than guessing, and takes the mainnet caveats — unknown might be real money. /.well-known/notary reports the same network_kind, so a paying agent does not need its own table of chain ids to know what it is being quoted in.

Relationship to lethe

This package depends on lethe-delete; nothing in lethe depends on it, and nothing in lethe imports a payment SDK. Lethe is a self-hosted compliance library that runs against the operator's own database, and it must not grow a wallet. Keeping the notary a separate package is what makes it optional.

Buying one, to check it works

tools/pay.py is the buyer side of the x402 flow — the same steps a paying agent takes, written out.

python tools/make_test_certificate.py           # -> certificate.json
export LETHE_NOTARY_BUYER_KEY=0x...             # funded on the quoted network
python tools/pay.py --certificate certificate.json --notary http://127.0.0.1:8402

It prints what the notary quoted and whether that price is real money, then what the notarization cost.

Run it twice

The second run of the same certificate must report charged: false, and no second transfer may appear on the explorer. That is the idempotency guarantee the payment design rests on: the notary keys on the certificate's payload hash, so a retry after a lost response cannot be charged again. A repeat is not even quoted a price — the notary recognizes the certificate and answers with the original receipt before the payment gate is reached, so the caller never signs a second authorization.

This is verified in the test suite end to end, including against a real x402 client, with the facilitator stubbed. The half no test can cover is the chain itself: only running it twice with a funded key proves no second transfer settles. Do that once on testnet before pointing anything real at it.

Tests

pytest                    # offline
pytest -m live            # also contacts the real x402 facilitator
LETHE_TEST_DATABASE_URL=... pytest    # also runs the truncation scenario

CI runs this suite on every push, alongside ruff and mypy over lethe_notary/, and a no-extras install job that imports the whole paid path. That last one exists because this package declared bare x402 for three releases: it installs cleanly, starts, and cannot take a payment — the EVM mechanism raises ImportError, and without httpx the facilitator call fails at preflight. Both were invisible locally, where those packages arrive transitively.

Rate limits

/notarize allows 2/s (burst 20) per client, /challenge 5/s (burst 30); both answer 429 with retriable: true. Behind a proxy, pass --trust-proxy so the limiter buckets by X-Forwarded-For — only behind a proxy you control, since otherwise any caller mints a fresh identity per request and the limiter stops working for exactly the people it exists to stop.

Back up the witness log

One SQLite file holds the only off-site copy of every customer's audit heads. It runs in WAL mode with synchronous=FULL, so a receipt handed to a customer is on disk before the response goes out — but that is durability, not backup.

lethe-notary backup --out /backups/witness-$(date +%F).db

Uses SQLite's online backup, so it is a consistent snapshot taken while the notary keeps serving. Ship it somewhere else, on a schedule.

The dated filename is not decoration: SQLite's backup writes straight over its destination, so a fixed name would replace yesterday's copy with today's — and if today's log were empty or truncated, the good copy would be gone. Backing up onto an existing file is refused; --overwrite is there if replacing one is really the intent.

Rotating the notary key

Receipts name the key that signed them, so a rotation without a published key history silently invalidates every receipt already sold. Keep the retired keys published:

lethe-notary serve --previous-key <old base64 key> --previous-key <older>

They appear in keys at /.well-known/notary, and a verifier passes that list to verify_receipt(receipt, trusted_keys={key_id: public_key}) — the same shape Lethe uses for certificates.

If a receipt comes back with witness_recorded: false

The countersignature succeeded and you were charged, but the notary could not write its own copy. Keep the receipt — it is the evidence, and it is valid. What you do not have is coverage by the truncation check for that certificate's head until the operator reconciles. Re-presenting the certificate later is free.

Run one process

Idempotency — "this certificate is already witnessed, do not charge again" — is arbitrated by a per-certificate lock inside the process. Running several notary workers against one witness database reopens the double-charge race. Run a single process, or move that arbitration into the database, before scaling out.

Verified against a real client: tests/test_payment_wire.py (-m live) builds a genuine EIP-3009 payment with the x402 client SDK and drives it through the server's own charge path — decode, match, verify, settle. Two bugs lived in that gap until a real client was pointed at the server, because a stub cannot disagree with itself.

A successful /notarize now returns a payment block with the settlement's transaction hash, network and payer, so the buyer has an on-chain reference and the operator can reconcile without taking this service's word for it. A settlement that fails yields 402 and no receipt — the settle response is the only thing that says money moved, and a receipt handed out over a failed settlement is the mirror of charging for nothing.

Settlement is verified. A real payment has been settled end to end on Base Sepolia: the client signed an EIP-3009 authorization, the notary decoded it, matched it to its own requirements, and the facilitator moved 0.01 USDC from the buyer to the configured payee — transaction 0x27b42be68a… in block 46487830, method TransferWithAuthorization. Everything in this package has now been exercised against something real.

Testnet, though. Mainnet now has the code it needs — a credentialed facilitator, authenticated per call (see above) — but authenticated is not settled: no mainnet payment has been made through this package, and the first one deserves the same scrutiny as this testnet one got. Check the chain, not the 200.

Metadata

Release files for lethe-notary 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for lethe-notary 0.2.0
File Size Uploaded Provenance
lethe_notary-0.2.0.tar.gz 84.3 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for lethe-notary 0.2.0
File Interpreter ABI Platform
lethe_notary-0.2.0-py3-none-any.whl Python 3 none any Details

Total release size: 133.3 kB

Release files / lethe_notary-0.2.0.tar.gz

Download URL lethe_notary-0.2.0.tar.gz
Size 84.3 kB
Tags Source
SHA-256 checksum
How to use checksums
bbf5e8131eb8cb9855d0ca6de2613ab135b0065752f9d1eb8c0e56acc805a160
BLAKE2b-256 checksum
How to use checksums
3abf30325ac21e4e579a0158d33a4fb2b242de73842915a72f12a067ec5c52c3
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.12.14

Release files / lethe_notary-0.2.0-py3-none-any.whl

Download URL lethe_notary-0.2.0-py3-none-any.whl
Size 49.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
75b87c5608dc341357edf39cc621700fcdfad7e0538c3f3db13235293cf1d076
BLAKE2b-256 checksum
How to use checksums
c035faa66caabaf1642cd68103c37372dcb41e384646ebca7d939d2f893cf3f6
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.12.14

Security for lethe-notary 0.2.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Release history Release notifications | RSS feed

This release

0.2.0 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page