Files
nostr_quantum_preparation/plans/g56-02-non-replaceable-proof-carrier.md
T

8.9 KiB
Raw Blame History

G56-02 Implementation Plan: Non-Replaceable Proof Carrier with Append-Only Upgrades

Date: 2026-07-24
Finding: G56-02 — Earliest-valid-anchor rule is not implemented; relay discovery selects the latest replaceable event
Approach: Switch the proof carrier from replaceable kind 11112 to non-replaceable kind 9999, with append-only upgrade events and earliest-anchor selection
No migration needed: the project has not been publicly released

Problem summary

Kind 11112 is in the 10000–19999 range, which Nostr relays treat as replaceable — they keep only the latest event per pubkey. This means:

  1. An attacker who breaks secp256k1 can publish a new kind 11112 that replaces the original on relays
  2. The code queries with limit: 1 and selects the newest created_at, which is the opposite of the proposal's "earliest valid Bitcoin anchor wins" rule
  3. The original event — with its earlier Bitcoin anchor — can be hidden

Design

Two-event structure (unchanged conceptually, changed kind)

Event 1: Kind 1 Announcement (non-replaceable, already correct)

  • Contains the human-readable attestation text and PQ keys/signatures in algorithm tags
  • Signed by the user's existing secp256k1 identity
  • Its full signed JSON hash is what gets submitted to OpenTimestamps
  • No changes needed — kind 1 is already in the 0–9999 non-replaceable range

Event 2: Kind 9999 Proof Carrier (non-replaceable, changed from 11112)

  • References Event 1 by e tag
  • Carries the sha256 tag (hash of Event 1's full signed JSON)
  • Carries the ots tag (base64 .ots proof)
  • New ots_status tag: "pending" or "confirmed"
  • New upgrade_of tag: references the previous proof carrier event ID (only on upgrade events)
  • Published as append-only — upgrades publish a NEW event, never replace

Append-only upgrade flow

1. User signs in → generates seed → derives PQ keys → signs kind 1 announcement
2. Submit kind 1 hash to OTS calendars → get pending .ots proof
3. Publish kind 1 announcement (permanent, non-replaceable)
4. Publish kind 9999 proof carrier with ots_status="pending" (permanent, non-replaceable)
5. Wait 10-30 min for Bitcoin confirmation
6. Upgrade the .ots proof via /ots-upgrade helper
7. Publish a NEW kind 9999 proof carrier with ots_status="confirmed" and upgrade_of="<pending event id>"
8. Both proof carriers remain on relays permanently — client picks the one with the earliest valid Bitcoin anchor

Re-migration flow (user lost seed, runs process again years later)

1. User signs in with same Nostr identity → generates new seed → derives new PQ keys
2. Publishes a new kind 1 announcement (new PQ keys, new block height)
3. Publishes new kind 9999 proof carriers (pending, then confirmed)
4. Client sees multiple kind 1 announcements + multiple proof carriers
5. Earliest valid Bitcoin anchor across all of them is the canonical root
6. Later migrations are valid if published while secp256k1 is still trustworthy

Earliest-anchor selection logic

A new pure function selectCanonicalProofCarrier(candidates, kind1Event):

  1. Input: array of kind 9999 events for a pubkey, plus the kind 1 announcement they reference
  2. Filter: only candidates whose e tag matches the kind 1 event ID
  3. Filter: only candidates whose sha256 tag matches hashFullEvent(kind1Event)
  4. Filter: only candidates with a valid secp256k1 signature
  5. For each remaining candidate, parse and verify its OTS proof:
    • If ots_status is "confirmed" and the OTS proof has a valid Bitcoin attestation → record the Bitcoin block height
    • If ots_status is "pending" or the OTS proof has no Bitcoin attestation → skip (not yet anchored)
  6. Sort valid candidates by Bitcoin block height (ascending)
  7. Return the one with the earliest block height
  8. If tie, sort by event ID lexicographically (deterministic tie-break)
  9. If no candidate has a confirmed Bitcoin anchor, return the best pending candidate with a note

Files to change

1. www/js/pq-crypto.mjs

Change NIP_QR_KIND:

  • Line 430: change 11112 to 9999
  • Update comment: "Kind 9999 is a non-replaceable event (0–9999 range). Each publication is permanent. Upgrades publish a new event with upgrade_of referencing the previous one."

Update buildKind11112Wrapper() → rename to buildProofCarrier():

  • Add ['ots_status', 'pending'] tag (or 'confirmed' when applicable)
  • Add optional ['upgrade_of', previousEventId] tag when upgrading
  • Keep existing e, sha256, ots tags

Update buildUpgradedEvent():

  • Instead of copying tags and replacing the ots tag, build a completely new proof carrier event
  • Include ots_status: 'confirmed'
  • Include upgrade_of: <original proof carrier event id>
  • Keep the same e and sha256 tags (they reference the same kind 1 event)

Add selectCanonicalProofCarrier(candidates, kind1Event):

  • Pure function, no network calls
  • Takes array of proof carrier events and the kind 1 announcement
  • Returns { canonical, allCandidates, errors } where canonical is the event with the earliest valid Bitcoin anchor
  • OTS verification is async, so this function should be async and call verifyOtsProof() for each candidate's OTS proof

2. www/verify.html

Update queryRelayForEvent():

  • Change kind from NIP_QR_KIND (which will be 9999) — already uses the constant, so this is automatic
  • Remove limit: 1 — fetch all events
  • Return an array of events instead of a single event

Update verifyEvent():

  • Accept an array of proof carrier candidates
  • Call selectCanonicalProofCarrier() to pick the canonical one
  • Display all candidates in the UI with their OTS status and Bitcoin anchor height
  • Highlight the canonical (earliest-anchor) one
  • Still verify the kind 1 announcement and PQ signatures as before

Update the relay query tab:

  • Query for both kind 1 and kind 9999 events for the pubkey
  • Match proof carriers to announcements by e tag
  • Display the full migration history

3. www/index.html

Update queryRelayForKind11112() → rename to queryRelayForProofCarriers():

  • Remove limit: 1
  • Return an array

Update the sign/publish flow:

  • After signing the kind 1 announcement and getting the pending OTS proof, publish the kind 9999 proof carrier with ots_status: 'pending'

Update publishUpgradedEvent():

  • Build a NEW proof carrier event (not a replacement)
  • Include ots_status: 'confirmed' and upgrade_of: <pending event id>
  • Publish it as a new event
  • Do NOT try to replace the old event

Update the resume workflow:

  • Fetch all kind 9999 proof carriers for the pubkey
  • Find the one that matches the persisted kind 1 event hash
  • If a confirmed proof carrier already exists, the workflow is complete
  • If only a pending proof carrier exists, continue the upgrade polling

Update savePendingOts():

  • Store both the pending proof carrier event ID and the kind 1 event ID
  • Store the sha256 target digest explicitly
  • On resume, validate that fetched events match persisted IDs

4. test/pq-crypto.test.mjs

Update existing tests:

  • Any test referencing kind 11112 → change to 9999

Add new tests:

  • buildProofCarrier includes ots_status tag
  • buildUpgradedEvent includes ots_status: 'confirmed' and upgrade_of tag
  • selectCanonicalProofCarrier returns the event with the earliest Bitcoin anchor
  • selectCanonicalProofCarrier handles ties deterministically
  • selectCanonicalProofCarrier skips pending-only candidates when confirmed ones exist
  • selectCanonicalProofCarrier rejects candidates with wrong e tag or sha256 tag
  • selectCanonicalProofCarrier rejects candidates with invalid secp signatures
  • Multiple migration attempts (re-migration) — earliest anchor wins across all

5. nip_proposal.md

  • Change kind 11112 to kind 9999
  • Update the description: "non-replaceable event (0–9999 range)"
  • Add ots_status and upgrade_of tags to the tag specification
  • Update the verification rule: "fetch all kind 9999 events, select earliest valid Bitcoin anchor"
  • Update the proof states section: upgrades publish new events, not replacements

6. README.md

  • Update any reference to kind 11112 → kind 9999
  • Update the migration flow description
  • Update the implementation status table

Acceptance criteria

  1. No event in the system is replaceable — both kind 1 and kind 9999 are in the 0–9999 range
  2. Upgrades publish new events with upgrade_of referencing the previous one — no event is ever replaced
  3. Relay queries fetch all candidates, not limit: 1
  4. selectCanonicalProofCarrier deterministically picks the earliest valid Bitcoin anchor
  5. A later forged proof carrier cannot hide or supersede an earlier valid one
  6. Re-migration (publishing a second migration attempt) works naturally — all events are permanent
  7. All existing tests pass (updated for kind 9999)
  8. New tests for append-only upgrades and earliest-anchor selection pass
  9. The verify page displays the full candidate history and highlights the canonical one