Skip to content

feat: NIP-SB — steganographic key backup#373

Closed
tlongwell-block wants to merge 17 commits intomainfrom
feat/nip-sb-steganographic-key-backup
Closed

feat: NIP-SB — steganographic key backup#373
tlongwell-block wants to merge 17 commits intomainfrom
feat/nip-sb-steganographic-key-backup

Conversation

@tlongwell-block
Copy link
Copy Markdown
Collaborator

@tlongwell-block tlongwell-block commented Apr 20, 2026

NIP-SB: Steganographic Key Backup

Spec, formal verification, and protocol demo for relay-based key recovery. Companion to NIP-AB (device pairing).

What it does

Lets a user back up their private key to any relay using just a password. The backup blobs are published as kind:30078 events signed by throwaway keypairs. To recover, the user needs their password and their public key.

Two distinct privacy properties

NIP-SB provides two properties that are often conflated — this spec separates them explicitly:

  1. Cryptographic unlinkability (the security property). No field in any blob references the user's real pubkey. An attacker who obtains a blob — even one they suspect is a NIP-SB backup — cannot determine which user it belongs to and cannot link it to other blobs in the same backup set. Crucially, each password guess is bound to a specific pubkey, preventing the batch-cracking accumulation attack that NIP-49 warned about. This property holds under standard computational assumptions (pseudorandomness of scrypt/HKDF). It does not depend on cover traffic or relay population.

  2. Steganographic cover (environment-dependent). Blobs share the same event structure as other kind:30078 application data. The degree to which they blend into ambient relay traffic depends on the relay's traffic distribution and has not been empirically validated against a statistical classifier. Steganographic cover provides defense-in-depth but the core security properties hold without it.

How it works

  1. Password + pubkey → length-prefixed concatenation → scrypt → derive N (3–16 real chunks), D (4–12 dummy blobs), encryption key, per-blob signing keys and d-tags
  2. Split the 32-byte nsec into N chunks, compute Reed-Solomon parity (P=2), generate D dummy payloads (all deterministic via HKDF)
  3. Pad each payload to constant size (deterministic HKDF-derived padding), encrypt with XChaCha20-Poly1305, publish all N+P+D blobs as kind:30078 events signed by throwaway keypairs — shuffled, with jittered timestamps
  4. Recovery: re-derive everything from password + pubkey + relay URL, query relay for all d-tags in random order, validate-then-select (filter by expected pubkey, validate all candidates, pick newest valid), decrypt, discard dummies, reassemble (RS erasure decode if up to 2 blobs are missing)

Relay-scoped derivation

Blob d-tags and signing keys are scoped to the relay they are published to — the normalized relay URL is mixed into the HKDF info parameter. This means:

  • Same backup on different relays produces completely different metadata on each relay
  • An attacker with multi-relay dumps cannot intersect metadata to identify the same backup set
  • Multi-relay publication becomes a privacy benefit instead of a liability
  • Zero additional scrypt cost — only HKDF calls absorb the relay differentiation
  • Recovery UX unchanged — wrong relay URL just produces d-tags that match nothing (no harm)

Relay URL normalization uses the WHATWG URL Standard (parse + serialize, exclude fragment). wss:// only, reject userinfo and query strings.

Key properties

  • No bootstrap problem — everything derives from password + pubkey, no stored salt
  • Injective encoding — length-prefixed password ensures distinct (password, pubkey) pairs always produce distinct KDF inputs
  • Fault tolerance — RS parity tolerates loss of up to 2 blobs
  • Safe partial re-publication — deterministic padding (HKDF-derived) means RS parity is consistent across generations; individual blobs can be refreshed without invalidating the backup set
  • Count obfuscation — variable dummy blobs hide the real chunk count (total 9–30 per user)
  • Accumulation resistance — attacker cannot build a target list from a relay dump; each password guess is bound to one pubkey. Per-target rejection cost is 1× scrypt (same as NIP-49); the improvement is in batch attacks where cost is multiplied by |users|
  • Cross-relay unlinkability — relay-scoped HKDF ensures different metadata per relay
  • d-tag squatting resistance — validate-then-select event ordering with pagination and authors-filter fallback
  • No server cooperation — works with any standard Nostr relay

Adversary-class analysis

Adversary Unlinkability Steganographic cover
External network observer Strong under TLS (traffic analysis may reveal backup sessions) Environment-dependent (traffic shape may be distinguishable)
Passive relay-dump Strong (computational) Environment-dependent, unvalidated
Active relay operator Strong at data layer; degraded at network layer (IP/timing correlation) Weak (burst pattern detectable)

What's in this PR

  • crates/sprout-core/src/backup/NIP-SB.md — formal spec (~900 lines)
  • crates/sprout-core/src/backup/NIP-SB.spthy — Tamarin formal verification (10 lemmas, all verified in ~150s)
  • crates/sprout-core/src/backup/nip_sb_demo.py — protocol demo with real crypto (~750 lines, all test cases pass including adversarial scenarios)
  • crates/sprout-core/src/kind.rsKIND_APP_SPECIFIC_DATA = 30078
  • crates/sprout-relay/src/handlers/ingest.rs — kind:30078 in relay allowlist

No runtime code changes beyond the kind allowlist. Spec, proof, and demo only. Implementation is a follow-up.

Adversarial review

The spec, model, and demo were hardened through multiple rounds of adversarial crossfire review (Claude Opus + GPT-5.4 Codex CLI). Final score: 9/10 APPROVE, zero critical issues. Key fixes from the review:

  • Injective encoding: length-prefixed password in KDF base construction
  • Validate-then-select: event ordering prevents malformed-newer-duplicate DoS
  • Deterministic padding: HKDF-derived padding enables safe partial re-publication
  • Corrected cost claims: 1× rejection cost (not N+2×), accumulation resistance framing
  • d-tag squatting mitigation: pagination to EOSE, authors-filter fallback
  • Network observer caveat: traffic analysis acknowledged (not "Complete")
  • Steganography claims: environment-dependent throughout all tables and comparisons
  • Adversary-scoped table claims: linkability/deniability cells now specify passive-dump adversary model and reference §Adversary Classes for active operator caveats
  • Step ordering fix: H_i scrypt derivation pulled into Step 3b before padding/RS (now Step 3c), eliminating forward reference
  • Relay requirements clarified: "no special relay support" refined to "no special protocol extensions" with note about standard authorization scopes

Tamarin verification

All 10 lemmas verified by tamarin-prover --prove in ~150 seconds. The model verifies core cryptographic properties (KDF chain, encryption, RS parity, dummy isolation) of a reduced symbolic model (N=3, P=2, D=2). It does NOT cover event-level validation, relay scoping, variable N/D, or traffic analysis — these are argued in the spec's security analysis.

Lemma Type Result Steps
Honest backup → recovery works exists-trace ✅ verified 16
Recovery with 1 erasure (RS) exists-trace ✅ verified 20
Recovery with 2 erasures (RS) exists-trace ✅ verified 16
nsec secret without password compromise all-traces ✅ verified 47
Password secret all-traces ✅ verified 2
Individual chunks secret all-traces ✅ verified 128
Parity blobs secret all-traces ✅ verified 716
Password compromise → nsec recoverable exists-trace ✅ verified 13
Compromise rule reachable exists-trace ✅ verified 2
KDF chain functional exists-trace ✅ verified 6

Demo test coverage

The Python demo exercises 12 test phases including adversarial scenarios:

  • Full recovery, 1-erasure, 2-erasure (3 patterns), AEAD-failure-as-erasure
  • 3-missing (failure), wrong password, different-user-same-password
  • Cross-relay isolation (different relay URLs → different d-tags, wrong URL rejected)
  • d-tag squatting resistance (impostor events with same d-tags ignored via pubkey filter)
  • Malformed-newer-duplicate resistance (corrupted newer event skipped, older valid event used)

Prior art

Evaluated against NIP-49, BIP-38, SLIP-39, Kintsugi (arXiv:2507.21122), Apollo (arXiv:2507.19484), PASSAT (arXiv:2102.13607), Shufflecake (CCS 2023), OPAQUE/Signal SVR, and Dark Crystal. See spec §Prior Art and §Comparison to Prior Art for details.

Related

Add NIP-SB, a protocol for backing up a Nostr private key to relays
using password-derived steganographic sharding. The backup is invisible
to relay operators and attackers — chunks are indistinguishable from
normal relay data, unlinkable to each other, and unlinkable to the user.

Recovery requires only the user's password and their public key.

Includes a Tamarin formal verification model (NIP-SB.spthy) with 7
verified lemmas covering correctness, confidentiality, chunk secrecy,
and password compromise semantics.
NIP-SB.md:
- Fix base64 padding claim (56 mod 3 = 2, padding IS required)
- Temper deniability language (passive dump, not active observer)
- Add authors filter to recovery REQ query
- Add explicit nsec scalar validation to recovery step 7
- Add chunks-are-byte-slices note to Limitations

NIP-SB.spthy:
- Note ciphertext strengthening artifact (model embeds blob index)
- Clarify all-chunks-required is structural, not lemma-verified

nip_sb_demo.py:
- Protocol demo with real crypto (scrypt, HKDF-SHA256,
  XChaCha20-Poly1305 via libsodium, secp256k1)
- Simulated relay as in-memory dict
- Tests: backup, recovery, wrong password, different user same
  password, relay dump perspective, base64 padding verification
- Run with: uv run crates/sprout-core/src/backup/nip_sb_demo.py
- Add NFKC password normalization (unicodedata.normalize)
- Implement reject-and-retry for signing key scalar derivation
  (signing-key, signing-key-1, ... up to 255, matching spec §Step 4)
- Accept both padded and unpadded base64 on input (spec §Encoding)
- Clarify demo scope: crypto protocol, not Nostr event layer
@tlongwell-block tlongwell-block force-pushed the feat/nip-sb-steganographic-key-backup branch from bf78649 to 85f3914 Compare April 20, 2026 22:07
tlongwell-block and others added 14 commits April 20, 2026 18:11
Reed-Solomon erasure coding (P=2) tolerates loss of up to 2 blobs.
Variable dummy blobs (D=4-12) obscure real chunk count.
Cover key derivation keeps scrypt budget at N+6.
Random-order publication and recovery with jittered delays.
GF(2^8) test vectors and RS encode/decode vectors in spec.
Demo exercises all erasure classes end-to-end.
… NIP-AB positioning

Tamarin model now includes parity blobs, dummy blobs, cover key, and
RS erasure recovery rules (1-erasure and 2-erasure). New lemmas for
erasure correctness and parity secrecy.

Spec adds explicit three-tier adversary table (external network observer,
passive relay dump, active relay operator) with protection level per tier.

NIP-AB relationship text updated: NIP-AB is the primary backup/multi-device
mechanism; NIP-SB is the secondary break-glass fallback.
…rivacy claim

Tamarin: replace broken placeholder-based 2-erasure rule with proper
double-erasure recovery functions (rs_recover_01_fst/snd, etc.) that
take only available symbols. Add all 6 double-erasure function pairs.
Fix header to accurately describe what the model proves.

Demo: add Phase 4d test for AEAD-failure-as-erasure path. Fix
malformed-content handling to treat as erasure per spec.

Spec: tighten active-operator privacy claim — acknowledge IP/session
visibility even when blob metadata is unlinkable to Nostr identity.
Adversary table: active relay operator row now says 'may identify the
client' instead of 'cannot determine which user' — consistent with the
detailed note later in the section.

Tamarin header: erasure lemmas described as 'representative cases' with
note that other patterns are structurally symmetric but not instantiated.
Add KIND_APP_SPECIFIC_DATA (30078) to the kind registry, ingest
allowlist, and pubkey-match bypass (same pattern as NIP-59 gift wrap —
throwaway signing keys for protocol-level operations).

Live test exercises full NIP-SB v3 cycle against a running Sprout relay:
publish N+P+D blobs via WebSocket with per-blob NIP-42 auth, recover
by d-tag-only queries (no authors filter), verify byte-for-byte key
reconstruction.
Per-blob auth as each throwaway key means the pubkey match check
passes naturally. Only the kind allowlist entry is needed.
The protocol demo (nip_sb_demo.py) is the reference implementation.
The live relay test is a Sprout-specific integration test that belongs
in sprout-test-client when the real implementation lands.
tamarin-prover --prove: 10/10 lemmas verified in ~150s.
Added verification results to proof header.
Includes v3 model: parity blobs, dummy blobs, cover key,
single-erasure and double-erasure RS recovery.
…ped HKDF

Two structural improvements to the NIP-SB spec:

1. Two-property reframe: cleanly separates cryptographic unlinkability
   (proven, computational — the core security property) from
   steganographic cover (environment-dependent, unvalidated — defense
   in depth). Adds adversary-class table with per-tier assessment of
   both properties. Adds active relay operator caveat: IP/timing
   correlation can group blobs at the network layer.

2. Relay-scoped HKDF: mixes the normalized relay URL into HKDF info
   strings for d-tags and signing keys. Same backup published to
   different relays now produces completely different metadata on each
   relay, eliminating the cross-relay durable fingerprint. Zero
   additional scrypt cost — only HKDF calls absorb the relay
   differentiation. Uses WHATWG URL Standard (parse + serialize,
   exclude fragment) for normalization. wss:// only, reject userinfo
   and query strings.

Recovery UX unchanged: user provides password + pubkey + relay URL.
Wrong relay URL produces d-tags that match nothing — no harm, client
can silently try every relay in the user's relay list.
11 rounds of adversarial crossfire review (codex CLI + opus subagent).
Final score: 9/10 APPROVE, zero critical issues.

Spec (NIP-SB.md):
- Injective encoding: length-prefixed password in base construction
- Corrected cost claims: 1x rejection cost, accumulation resistance framing
- Validate-then-select event ordering (prevents malformed-newer-duplicate DoS)
- Deterministic padding via HKDF (safe partial re-publication, consistent RS parity)
- Deterministic dummy payloads via HKDF from cover key
- Relay-scoped derivation in Overview diagram (was missing relay_url_bytes)
- Password rotation: per-relay with relay_url_bytes
- Signing key retry: relay_url_bytes in retry info strings
- created_at jitter: monotonic on refresh for NIP-33 compatibility
- d-tag squatting mitigation: pagination to EOSE, authors-filter fallback
- Cross-relay blob count correlation noted in Limitations
- Network observer: traffic analysis caveat
- Steganography claims: environment-dependent throughout tables
- Password max length: 65535 bytes (2-byte prefix)
- Multiple events per d-tag: validate all, select newest valid

Tamarin model (NIP-SB.spthy):
- Headline scoped to core cryptographic properties
- Relay URL abstraction documented
- Event-level attacks explicitly listed as not modeled
- Dummy payload abstraction documented
- Password compromise split-world modeling documented

Demo (nip_sb_demo.py):
- relay_url_bytes threaded through all HKDF calls
- make_base() helper with length-prefixed password
- Deterministic padding and dummy payloads
- created_at field, validate-then-select recovery
- Cross-relay isolation test (Phase 7b)
- d-tag squatting resistance test (Phase 7c)
- Malformed-newer-duplicate resistance test (Phase 7d)
- Strict base64 with unpadded acceptance
- Explicit simplifications disclaimer
…uirements

- Comparison tables: scope linkability/deniability claims to passive-dump
  adversary model, reference §Adversary Classes for active operator caveats
- Remove bold absolutism from prior-art table (None → No, Yes → Probabilistic)
- Fix step ordering: pull H_i scrypt derivation into Step 3b before
  padding/RS (now Step 3c), eliminating forward reference to Step 4
- Update all downstream step references (6 occurrences)
- Relay Requirements: distinguish standard access control from protocol
  extensions; acknowledge authorization scopes in Design Principle 7

Crossfire-reviewed by Claude Opus (8/10 APPROVE) and GPT-5.4 Codex
(5/10 → 8/10 → 9/10 APPROVE across three passes). No crypto bugs found
in spec, Tamarin model, or Python demo.
@tlongwell-block
Copy link
Copy Markdown
Collaborator Author

tlongwell-block commented Apr 22, 2026

superseded by #385

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant