# SeedPass Vault & Identity Specification — v1 (draft) Status: **draft, extracted from working code** — 2026-08-18 Reference implementations: Python (`src/seedpass/`), TypeScript (`js/packages/core/`) Conformance oracle: `scripts/cross_impl_check.py` (36 checks) + `js/packages/test-vectors/` ## 1. Purpose and scope One deterministic account tree, multiple management interfaces. This document specifies the on-disk formats, derivation rules, and interoperability requirements that let independent applications operate the **same** vault — SeedPass as the sovereign superset (root management, recovery, all secret kinds), and narrower clients such as BitLogin (identity and managed-account administration) implementing a subset. The core promise this spec exists to protect: > **An entry index is a permanent cryptographic coordinate.** "Alice is > managed account #184" must deterministically identify the same derived > identity for the life of the root — across implementations, devices, and > decades. Everything here is **extracted from code that ships and is cross-verified**, not aspiration. Where an implementation falls short of a requirement, that is listed in §12 rather than papered over. Out of scope, deliberately: authentication protocols, signing services, policy/authorization engines, organizations, payments, workflows. Those are application features. They store their data *in* this format (§8) but are not part of it. A format that absorbed them would make every implementation carry every application's complexity. ## 2. Terminology | Term | Meaning | |---|---| | **Parent seed** | A BIP-39 mnemonic (12/18/24 English words). The root of everything. | | **Profile / fingerprint** | One parent seed's vault. Identified by its fingerprint (§3.2). | | **Entry** | One record in the vault index, keyed by its **entry id**. | | **Entry id / index** | Decimal-string key of an entry; doubles as the derivation index for derived kinds (§6). Never reused (§7). | | **Managed account** | A `managed_account` entry: a child BIP-39 seed derived at the entry id, with its own fingerprint — a delegable sub-identity. | | **Implementation** | Any software reading/writing this format. | Normative words **MUST / MUST NOT / SHOULD / MAY** are used in the RFC-2119 sense. ## 3. Canonicalization and identity ### 3.1 Canonical mnemonic form Before **any** validation, derivation, or fingerprinting, a mnemonic MUST be canonicalized: ``` NFKD → trim → lowercase → collapse internal whitespace to single spaces ``` Deriving from a non-canonical string that merely passed a word-count check produces a vault no other implementation (or the user's written-down phrase) can reopen. Implementations MUST reject mnemonics that fail BIP-39 checksum validation after canonicalization — BIP-39 seed derivation itself accepts any string, so a typo silently creates an unrecoverable vault. ### 3.2 Profile fingerprint ``` fingerprint = uppercase( hex( SHA-256( utf8(canonical_mnemonic) ) )[0:16] ) ``` 16 uppercase hex characters. Identifies the profile directory and appears in `managed_account.fingerprint` for child seeds. ### 3.3 Master password normalization Password-derived keys (§4.2) normalize the password as `NFKD` then strip Python's `str.strip()` whitespace set (which differs from JS `trim()`; the Python set is normative). ## 4. Container formats ### 4.1 Vault index file (`seedpass_entries_db.json.enc`) Stored as either a bare ciphertext or a JSON wrapper `{"kdf": {...}, "ct": ""}`. The plaintext is the vault index (§5) as UTF-8 JSON. Payload formats, by prefix: | Prefix | Format | Write? | |---|---|---| | `V3\|` | 12-byte nonce ‖ AES-256-GCM ciphertext‖tag | **current — all writes** | | `V2:` | same construction, legacy prefix (with Fernet fallback on auth failure) | read-only | | (none) | legacy Fernet token | read-only | Key: the **index key** (see `docs/SPEC.md` key hierarchy): ``` seed = BIP39-seed(canonical_mnemonic) # no passphrase master = HKDF-SHA256(seed, salt=∅, info="seedpass:v1:master", 32) key = HKDF-SHA256(master, salt=∅, info="seedpass:v1:storage", 32) ``` The index key derives from the **seed, not the password** — changing the master password re-wraps only the parent seed file. ### 4.2 Parent seed file (`parent_seed.enc`) JSON wrapper `{"kdf", "ct"}`; `ct` is a V3 payload of the canonical mnemonic under a password-derived key: ``` salt = SHA-256(fingerprint)[0:16] key = PBKDF2-HMAC-SHA256(normalized_password, salt, iterations, 32) # default 200 000 ``` `kdf` records `{name: "pbkdf2", version: 1, params: {iterations}, salt_b64}`. Argon2id (`name: "argon2id"`, params `time_cost`/`memory_cost`/`parallelism`) is also valid. Implementations MUST bound KDF parameters read from disk before running them (the block sits outside the AEAD; unbounded values are a denial-of-service). ### 4.3 Per-profile config (`seedpass_config.json.enc`) Same encryption as the index, same key. Free-form JSON object; implementations MUST preserve keys they do not understand. ## 5. The vault index ```jsonc { "schema_version": 4, "entries": { "": { /* entry record, §5.2 */ } }, "_sync_meta": { // sync + allocation metadata (§7, §9) "strategy": "modified_ts_hash_tombstone_v2", "last_merge_ts": 0, "next_index": 0, // allocation watermark, §7 "tombstones": { "": { "deleted_ts", "entry_hash", "event_hash", "source" } }, "sources": [], "source_count": 0 }, "_system": { "index0": { /* derived state; not part of this spec */ } } } ``` - `schema_version` is currently **4**. An implementation MUST refuse to open a version above what it supports (a newer writer may have written fields it would destroy) and MUST migrate older versions forward before writing. - Entry ids are decimal strings matching `^(0|[1-9][0-9]*)$`, within JavaScript's safe-integer range. Both constraints are normative: they are what makes the two reference implementations compute identical allocation floors from one payload (Unicode digits and leading zeros are rejected, not interpreted). ### 5.2 Entry records Every entry carries the base fields: ``` type, kind # equal strings; `kind` may be absent in pre-v2 data (fall back to `type`) label # human name archived # boolean (legacy alias: `blacklisted`) date_added, date_modified # ISO-8601 modified_ts # unix seconds; drives sync conflict resolution notes # string tags # array of strings (freeform; `team:design` style encouraged) links # array (entry relationships) custom_fields # [{label, value, is_hidden?}] origin # optional provenance marker ``` Kind-specific fields (schema v4): | kind | fields | secret material | |---|---|---| | `password` | `length` (8–128), `gen_version` (absent ⇒ 1), `username?`, `url?` | derived (§6) | | `totp` | `deterministic?`, `period` (30), `digits` (6), `index?` **or** `secret?` | derived or stored | | `ssh` | `index` | derived | | `seed` | `index`, `word_count` ∈ {12,18,24} | derived | | `pgp` | `index`, `key_type` ("ed25519"), `user_id` | derived | | `nostr` | `index` | derived | | `key_value` | `key`, `value` | stored | | `managed_account` | `index`, `word_count`, `fingerprint` (child's, §3.2) | derived | | `document` | `content`, `file_type` | stored | **Redaction rule:** listing/metadata surfaces MUST NOT emit stored secret fields (`value`, `secret`, `content`, hidden custom fields); they are reported as `has_*` booleans. Plaintext appears only through operations whose stated purpose is producing it. ## 6. Deterministic derivation All derived secrets come from BIP-85 over the canonical parent seed (BIP-32/SLIP-10 secp256k1 master; entropy = `HMAC-SHA512(key="bip-entropy-from-k", child_private_key)`). | What | Path | Entropy → secret | |---|---|---| | Child mnemonic (`seed`, `managed_account`) | `m/83696968'/39'/0'/{word_count}'/{id}'` | 16/24/32 bytes → BIP-39 words | | Password (`password`) | `m/83696968'/32'/{id}'` | 64 bytes → generator v1/v2 (`gen_version`) | | SSH key (`ssh`) | `m/83696968'/32'/{index}'` | 32 bytes → Ed25519 | | PGP key (`pgp`) | `m/83696968'/32'/{index}'` | 32 bytes → Ed25519 (RSA is not reproducible; refuse) | | Nostr **entry** key (`nostr`) | `m/83696968'/39'/0'/32'/{index}'` | 32 bytes → secp256k1 nsec | | Deterministic TOTP | `m/83696968'/39'/1414485072'/{totp_index}'` | SHA-256(entropy[0:32])[0:20] → base32 | | Nostr **sync identity** | `m/83696968'/1237'/{account}'` (default account 0) | 32 bytes → the profile's relay identity | Notes with teeth: - **Password generation v1 is frozen.** A fix that would change v1 output goes into a new `gen_version`, never into v1. 18 frozen vectors enforce this; a failure there is never "update the expected value". - **The Nostr entry-key path is the app-39 path with `word_count=32`** — a historical quirk, now load-bearing. Do not "correct" it. - `ssh` and `pgp` share app 32 with passwords — a known, shared-with-Python namespace collision. Changing it requires a coordinated versioned change in every implementation (§13); until then it is the standard. ### 6.1 The two index namespaces - For `password`, `seed`, `managed_account`, and `nostr` entries, **the derivation index IS the entry id.** - **Deterministic TOTP has its own namespace**: `totp.index` is allocated as max over existing totp entries' `index` + 1, independent of entry ids. When a human says "account #N", that N is an **entry id**. Applications presenting managed accounts MUST display the entry id and SHOULD label it exactly "Account #N" so the recovery coordinate users memorize is the one that regenerates the identity. ## 7. Index allocation — the immutability rule **An id, once allocated, is never reallocated** — not after deletion, not after archive, not after sync. Reissuing a deleted #184 would hand a new entry the departed identity's exact derived secrets. Normative mechanism (implemented in both references, 2026-08-18): ``` floor(index) = max( _sync_meta.next_index # persisted watermark, default 0 , max(live entry ids) + 1 , max(tombstoned ids) + 1 ) # tombstones still in retention ``` - Allocation MUST return `floor`. - Every insert (including at an explicitly chosen id) and every index save MUST raise the watermark to at least `id + 1` / `floor`. The watermark never decreases. - Merge (§9) MUST set `next_index = max(both sides' watermarks, max(merged entry ids)+1, max(merged tombstone ids)+1)` — byte-identical across implementations. - Deletion of derived-kind entries SHOULD be discouraged in UIs in favor of `archived: true`; archive preserves the record, and the watermark protects the coordinate even when deletion happens anyway. The watermark lives in `_sync_meta` because every implementation already round-trips that block (it carries the tombstones), so pre-watermark builds preserve it without a schema bump. Pre-watermark vaults heal on their next allocation via the tombstone term, and permanently once any save persists the watermark. ## 8. Interoperability rules for multiple applications These are what let SeedPass and a narrower client (BitLogin) share one vault without requiring each other. 1. **Preservation.** An implementation MUST NOT drop or alter fields it does not understand — on entries, on the index top level, and in config — across a read-modify-write cycle. Backfilling the spec's own base fields (§5.2) with neutral defaults is permitted; losing or reinterpreting foreign data is not. Both references hold this today. 2. **Unknown kinds.** An implementation encountering an entry `kind` it does not understand MUST either (a) carry it through under rule 1, excluded from typed operations, or (b) refuse to write the vault. Silently dropping it is forbidden. (a) is strongly preferred and is what both references do as of 2026-08-18; (b) is the fail-closed floor. Records with no `kind` or `type` at all are garbage, not foreign data, and are rejected. 3. **Namespacing.** Application-specific metadata goes in namespaced fields on existing records (e.g. `bitlogin: { permissions: {...} }`) or in new record kinds with a namespaced prefix (e.g. `kind: "bitlogin_org"`). Core fields (§5.2) MUST NOT be repurposed. 4. **No shadow identity.** A subset client MUST NOT maintain its own mapping of "its" account numbers to derivation indexes. The entry id is the only account number. (This is the "no translation table" rule.) 5. **Subset writing.** A client MAY understand only some kinds (BitLogin: `managed_account`, `nostr`, its own namespaced kinds). It MUST NOT create entries of kinds it cannot correctly derive or validate. 6. **Round-trip conformance.** For any vault V and conforming implementations A and B: `open(A, save(A, V))` and interleaved `save(B, open(B, save(A, V)))` MUST preserve all records, ids, and unknown fields. This is mechanically checkable and SHOULD be in CI for every new implementation (the Python↔TypeScript harness is the template). ### 8.1 Capability declaration An implementation SHOULD be able to state its profile: ```json { "application": "bitlogin", "spec_version": 1, "capabilities": ["core", "identity.nostr", "identity.managed_accounts", "tags"], "kinds": ["nostr", "managed_account", "bitlogin_org"] } ``` Capability groups: **core** (container, index, allocation, preservation) — mandatory; **identity** (nostr keys, managed accounts); **secrets** (passwords, TOTP, SSH, PGP, key-value, documents); **advanced** (nested roots, portable backup, sync). SeedPass implements all four; an identity client needs only core + identity. ## 9. Synchronization (Nostr) Profile identity: BIP-85 app 1237 (§6). Events (NIP-01, BIP-340 signed): | kind | content | |---|---| | 30070 | manifest | | 30071 | snapshot chunk (encrypted, gzip'd, chunked) | | 30072 | delta (encrypted) | Merge strategy `modified_ts_hash_tombstone_v2`: higher `modified_ts` wins; equal timestamps break ties by canonical hash with field-level union; deletions propagate as tombstones which beat older entries and lose to newer ones; tombstone-vs-tombstone prefers newer `deleted_ts` then higher event hash. Tombstone retention is capped at **2048** (oldest evicted first) — which is why the allocation watermark (§7), not tombstone memory, carries the never-reuse guarantee. Merged `_sync_meta` fields (`last_merge_ts`, `next_index`, `sources`, `tombstones`) MUST be computed identically by every implementation: convergence is defined as byte-identical canonical state. Payload bodies are encrypted with keys derived from the parent seed; relays hold ciphertext only. Applications MUST NOT publish plaintext business or personal data in event content or tags. ## 10. Portable backup `format_version: 1`. Encrypted-by-default export (`cipher`, `checksum` = SHA-256 over canonical inner payload), decryptable from the parent seed alone ("seed-only"). Both references import/export it; Python verifies TS-written checksums in CI and vice versa. ## 11. Managed accounts for identity clients (informative) The intended BitLogin-style flow, stated in this spec's terms: - The **management root** is a SeedPass parent seed (possibly itself a `seed`/`managed_account` child of a personal root — delegate a subtree, never the main root). - Creating "Alice" = creating a `managed_account` entry. Its **entry id** is her permanent account number; her child seed derives per §6; her Nostr identity derives from that child seed. - The client provisions the *child* key into its online signer at creation time; the root returns to cold storage. Signing availability never requires the root online. - Recovery: root + entry id regenerates the identity from nothing. This is why §7 is absolute. - Roles, credentials, policies, org structure: the client's own data, stored under §8.3 namespacing. Not this spec. ### 11.1 Capsule unlock ("sign in with BitLogin") — informative flow, normative rules SeedPass's vault key derives from the seed, not the password (§4.1), so any alternative sign-in is necessarily a way the **parent seed reaches the client** — not an authentication check in front of the login screen. The intended mechanism: - An explicit owner action (requiring the master password, proving current unlock authority) writes a **capsule** beside the profile: the parent seed encrypted (NIP-44-style ECIES) to one or more authorized Nostr pubkeys. - Sign-in = prove control of an authorized key via a fresh challenge, then decrypt the capsule **locally**. The capsule slots in as one more seed resolver at the existing boundary (env → agent → capsule → password prompt); the vault crypto, derivations, and formats are untouched. - Revocation = delete or rotate the capsule. The password path always remains. Capsule creation, use, and revocation belong in the audit log. Two rules are **normative** for any implementation of this flow: 1. **Independence.** The unlocking key MUST NOT derive from the vault it unlocks. A key at index #N of this seed cannot be a way into this seed — the circularity makes recovery impossible and the "protection" illusory. 2. **Rank.** An identity MAY only unlock roots at or below its own trust domain. A *managed* identity's private key lives in its organization's signer, so capsule decryption would hand the signer the seed: a managed identity MUST NOT be able to unlock the root that provisioned it (or any ancestor). Delegated subtree roots — already inside the signer's trust domain — are the intended scope. And one rule of construction: **a signature is never the unlock.** Signatures are cheap, replayable artifacts; the challenge only proves freshness. Possession of the decryption capability is the gate. ## 12. Known conformance gaps (honest ledger) | Gap | Where | Consequence | Status | |---|---|---|---| | ~~Base-field normalization differed on foreign records~~ | — | — | **resolved 2026-08-18**: both implementations now carry foreign records byte-for-byte untouched (Python's legacy normalizer skips unknown kinds — its renames like `blacklisted`→`archived` would have *reinterpreted* foreign data); enforced by cross-impl Phase L | | `_system.index0` merge handles only the empty case | both | non-empty index0 refuses to merge (loudly) | by design until atlas milestone | | `ssh`/`pgp`/`password` share BIP-85 app 32 | both (spec-level) | namespace collision, shared with Python since v1 | frozen; change requires coordinated version bump | | RSA PGP not derivable | TypeScript | refuses rather than diverging | permanent (RSA generation is not reproducible) | ## 13. Change control - Any change to derivation output, container format, allocation, or merge semantics is a **versioned change**: it lands in every reference implementation in lockstep, behind a version field, with cross-impl tests proving old data still reads. The frozen-v1 password rule generalizes: **never alter the output of a version that exists in the wild.** - Fixtures under `js/packages/test-vectors/fixtures/` are generated by the Python reference (`scripts/generate_ts_port_fixtures.py`) and are the ground truth a new implementation tests against. - This document is descriptive of the code; where they disagree, the code + cross-impl suite win, and the document gets fixed.