A content notary built on the AT Protocol.
README.md

notary #

A content notary built on the AT Protocol.

notary timestamps the digest of any content into your atproto repository. Since every atproto commit is signed by your identity key, a stamp becomes a tamper-evident, publicly verifiable attestation that you asserted "this content existed" at a given time — and anyone can check it without trusting the PDS that serves it.

You never upload the content itself, only its sha256 digest. The thing you notarize stays private; only the proof is public.

$ notary stamp contract.pdf
notarized
  digest : sha256:b9dc3966…fadf0
  subject: contract.pdf
  uri    : at://did:plc:zzk6…/coffee.a-cup-of.notary.stamp/3mt37oktj522n

$ notary verify contract.pdf at://did:plc:zzk6…/coffee.a-cup-of.notary.stamp/3mt37oktj522n
signature valid, and stamp included in the signed repository.
MATCH: this content was notarized by did:plc:zzk6…

Install #

The code is hosted on a Tangled knot, which does not serve Go's module-resolution protocol, so there is no go install. Clone the repository and build the binary:

go build -o notary .

Usage #

Stamp #

Notarize a file (writes one record to your repo):

notary stamp --handle you.example.com --password <app-password> contract.pdf

Credentials can also come from the environment:

export NOTARY_HANDLE=you.example.com
export NOTARY_PASSWORD=<app-password>      # create an *app password*, not your main one
notary stamp contract.pdf
notary stamp --hash sha256:b9dc39…        # notarize a digest you computed elsewhere

Flags: --subject (label, defaults to the filename), --note (free text), --pds (host URL, otherwise resolved from your handle).

Verify #

Verification needs no credentials — it only reads public data:

notary verify contract.pdf at://did:plc:…/coffee.a-cup-of.notary.stamp/3mt37oktj522n
notary verify --hash sha256:b9dc39… at://…      # verify a digest you already have

Exit code is 0 on a match, non-zero on a mismatch or a failed proof.

List #

notary list you.example.com          # or a did:plc:…

How verification works #

notary verify proves three independent things, none of which require trusting the server that hands you the data:

  1. Authentic identity key. The DID is resolved through the PLC directory (or did:web), giving the account's public signing key from its DID document — published independently of the PDS.
  2. Signed commit. com.atproto.sync.getRecord returns a CAR proof: the signed repo commit, the Merkle Search Tree nodes on the path to the record, and the record block. The commit signature is checked against the key from step 1.
  3. Inclusion + integrity. The record is walked from the signed MST root using only the blocks in the proof (so it really is in the signed tree), then its hash field is compared to the digest of your content.

If a malicious PDS altered the record, changed the tree, or forged a commit, one of these checks fails.

What it does and does not prove #

  • ✅ Attribution & integrity. Identity X asserted digest H, and the record is genuinely part of X's signed repository. Tampering with either the content or the record is detected.
  • ✅ Privacy of the content. Only the digest leaves your machine.
  • ✅ Server-independence. Anyone can verify from the public DID + a proof CAR; the PDS is untrusted. A third party who keeps the proof CAR retains evidence even if the record is later deleted.
  • ⚠️ Time is author-asserted. createdAt and the record key (a TID) are set by the author, who could backdate them. For a time attestation a third party trusts, anchor the commit — the relay firehose observes every commit at wall-clock time, so a witnessed firehose cursor (or cross-notarizing the commit CID elsewhere) upgrades "owner-asserted" to "witnessed". This is a natural next feature, not yet implemented.
  • ⚠️ Everything is public. atproto repositories are fully public; the stamp records (digests, subjects, notes) are visible to anyone. Never put secrets in a --subject or --note.

Lexicon #

Records use the coffee.a-cup-of.notary.stamp lexicon (see lexicons/). If you fork this under a domain you control, change the reverse-domain NSID accordingly (the prefix is defined once in main.go).

License #

MIT — see LICENSE.