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 fromHost, not fromX-Forwarded-Host. A spoofedHostchanges nothing. - The certificate schema is closed (
additionalProperties: false), so a request carrying aresourcekey 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.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | |
|---|---|---|---|---|
| lethe_notary-0.2.1.tar.gz | 90.7 kB | Without provenance | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| lethe_notary-0.2.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 141.9 kB
Release files / lethe_notary-0.2.1.tar.gz
| Download URL | lethe_notary-0.2.1.tar.gz |
|---|---|
| Size | 90.7 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
5a947b1222150b11785bc83bf1a793a0928447f7b762f95c84457999fcb82abb
|
|
BLAKE2b-256 checksum How to use checksums |
f4cbff81cc22e5195095ff5413adac8849bb50f95f7e8790594f03fb24de37c8
|
| 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.1-py3-none-any.whl
| Download URL | lethe_notary-0.2.1-py3-none-any.whl |
|---|---|
| Size | 51.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
7ed11b347fe59a0b1450d1c38afb9516cf4cb41ecc16710e8fb09b2d4e71256c
|
|
BLAKE2b-256 checksum How to use checksums |
22a4dadc04ae1172397cd14ddfe0b5758d8130ae2f4dbf6e55944ff00a3b68ec
|
| 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.1
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.