Files
bitlogin/docs/connection-vault.md
thePR0M3TH3ANandClaude Fable 5 7d2209d706 Second-audit hardening: phrase-gated sudo key, deterministic migration, policy tests
Authored by ChatGPT/Codex in a separate review pass. Reviewed and verified
here before committing rather than taken on trust; findings below.

The substantive change is that the vault sudo key now lives ONLY in the
phrase-gated recovery capsule. A newly written password credential capsule
carries just connection_vault_root, so an ordinary login restores
connectable-tier records without putting personal-tier material behind a
password capsule that is downloadable and therefore offline-guessable forever.
Pre-hardening capsules stay readable, password login does not return their
sudo key, and the next rotation scrubs it. That closes the gate this project
had explicitly set on personal-tier UI.

Legacy vault-enable migration now derives roots deterministically from
phrase-protected material and account_id, so two concurrent attempts cannot
mint divergent roots -- the failure mode the docs previously mitigated with
"enable the vault once, from one device".

Manual (user-chosen) passwords are REMOVED, not merely discouraged, and a new
securityPolicy.test.ts pins that along with several things tests do not
usually reach: the deployment CSP (default-src 'none', frame-ancestors
'none') plus six baseline headers, demo pages free of inline scripts and
remote fonts, and README documenting same-origin self-hosting only rather
than a remote demo embed. llms.txt was updated to match, which I checked
because that test only reads README.

What I verified, since a passing suite proves less than it appears to:

- All 12 fixes from the 2026-07-30 audit survived the rewrite of recover.ts,
  changePassword.ts, validation.ts, relay.ts, session.ts and element.ts.
  Checked semantically where grep cannot help: changePassword still checks
  publish success BEFORE building the tombstone, and vaultSetBinding still
  unbinds on origin === null rather than silently rebinding to the worker
  global.
- No dependency changes; the package.json diff is scripts only.
- Argon2id and KDF parameters unchanged. The NIP-44 diff ADDS a base64 length
  cap before decoding, a memory-DoS guard.
- Validation increased rather than decreased: 44 throws added, 33 removed.
- No disabled or skipped tests, no secrets in the diff.

typecheck clean, 165/165 tests, build green, and the account manager exercised
in a browser: creation correctly refused when relay quorum could not be met,
and on retry failed at the recovery capsule with "No login credential was
published" -- the §18.1 fail-closed ordering doing its job against a flaky
public relay set.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
2026-07-31 13:54:36 -04:00

24 KiB

BitLogin Connection Vault

Status: Design with core implementation Document version: 0.2 Date: July 30, 2026 Implementation status: §5–§11 and §13 are implemented in @bitlogin/core (src/vault/, plus the capsule fields in src/capsules/ and the migration in src/account/enableVault.ts), with test vectors pinned in src/vault/vault.test.ts and protocol round trips in vault.e2e.test.ts. The application-authorization layer (§12) is implemented in @bitlogin/widget in reveal mode for NWC: requestNwcConnection() with consent and one-tap origin-bound approval, Bitcoin Connect as the lazy-loaded wallet chooser, and a "Wallet connections" management screen. Brokered mode waits for a real execution boundary (§12.3). Personal-tier UI (sudo ceremony) is not yet built. See §18 for the decisions finalized at implementation time, including the sensitivity-tier model and the sudo key, which supersede this document where they differ. The companion vault-ux.md holds the user-experience walkthroughs that act as Phase D's acceptance criteria.

1. Summary

The BitLogin Connection Vault is a proposed encrypted, relay-backed store for portable application credentials such as:

  • Nostr Wallet Connect (NWC) connections
  • S3-compatible storage credentials
  • Future API tokens, signer grants, and application-specific secrets

The Connection Vault extends BitLogin from portable identity into portable application authorization:

BitLogin account
    |
    +-- portable Nostr identity
    +-- portable wallet connections
    +-- portable storage connections
    `-- future application credentials

The vault does not place wallet strings, storage access keys, or other frequently managed secrets directly inside the credential or recovery capsule. Instead, the capsules carry only a stable vault root. Each connection is stored as its own independently replaceable encrypted Nostr record.

This preserves the current capsule design principle: capsules contain only infrequently changed access material, while ordinary mutable state lives in separate records.

2. Motivation

Nostr applications commonly ask users to repeat setup that should be portable:

  • Paste or scan an NWC URI for every wallet-enabled application.
  • Re-enter an S3 endpoint, bucket, access-key ID, and secret key on every device.
  • Repeat the process after clearing browser data or switching clients.
  • Manually remember which application received which credential.

BitLogin already restores the same user-controlled identity on a clean device. The Connection Vault applies that portability to application connections while keeping each credential independently revocable and replaceable.

The product goal is:

Sign in once, connect a service once, and authorize compatible applications when needed.

3. Goals

The Connection Vault should:

  1. Restore connections on a clean device after BitLogin authentication or phrase recovery.
  2. Keep individual connection records separate to avoid multi-device last-write-wins clobbering.
  3. Support multiple connections of the same type.
  4. Encourage one credential per application or permission profile.
  5. Allow credentials to be revoked, replaced, renamed, and exported independently.
  6. Keep connection types and contents encrypted from relays where practical.
  7. Survive everyday Nostr identity rotation.
  8. Reuse BitLogin's existing cryptographic primitives, canonical serialization, padding, relay quorum, and validation rules.
  9. Permit later extension to native applications, browser extensions, remote signers, and hardware-backed authorization.

4. Non-goals

The Connection Vault is not:

  • A wallet.
  • An S3 proxy.
  • A guarantee that an application obeys a displayed permission request.
  • A replacement for provider-side NWC budgets or S3 IAM policies.
  • A defense against a malicious BitLogin build or compromised device.
  • A mechanism for silently sharing all stored credentials with every app.
  • A place to store cryptocurrency seed phrases.

5. Relationship to the identity capsules

5.1 Secrets do not live directly in the capsules

The credential capsule is replaced wholesale and should be rewritten only for credential events. The recovery capsule is intentionally minimal and is written only while the recovery phrase is present.

Putting mutable NWC and S3 credentials directly into either capsule would:

  • Expand the highest-value encrypted payload.
  • Require a capsule rewrite whenever a connection changes.
  • Increase multi-device replacement conflicts.
  • Couple ordinary app administration to password and recovery state.
  • Make independent deletion and rotation difficult.

Therefore connection records live outside both capsules.

5.2 Stable vault root

A future capsule schema may add one new infrequently changed field:

{
  "connection_vault_root": "base64url-32-random-bytes"
}

The same 32-byte random value must be present in both the credential and recovery payloads. It is generated once when the Connection Vault is enabled and does not change during normal use.

This root is not an application credential. It is key material used to locate, sign, encrypt, and decrypt the independent connection records.

A stable root is preferred over deriving the vault directly from the everyday Nostr private key because the everyday identity may be rotated. Wallet and storage connections should remain recoverable after that rotation.

5.3 Existing-account migration

Adding the vault root to an existing account requires the recovery phrase. A password-only migration is insufficient because it could update the credential capsule without updating the phrase-recovery path.

The migration flow should be:

User unlocks account
    |
    +-- enters and verifies recovery phrase
    +-- client generates connection_vault_root
    +-- client writes refreshed recovery capsule
    +-- client writes updated credential capsule
    `-- client verifies quorum readback for both

A migration must fail closed if either capsule cannot be written and verified.

6. Key hierarchy

The following construction is proposed for a future protocol version. Exact constants and test vectors must be finalized before implementation.

vault_prk = HKDF-Extract(
  salt = SHA256("bitlogin/connection-vault-root/v1"),
  IKM = connection_vault_root
)

Derive a Nostr-compatible signing identity:

vault_signing_material = ScalarExpand(
  vault_prk,
  "bitlogin/connection-vault-signing/v1"
)

Derive a record encryption key independently for every connection:

record_key = HKDF-Expand(
  vault_prk,
  "bitlogin/connection-record-encryption/v1"
    || 0x00
    || connection_id_bytes,
  32
)

The same raw material must never be used directly for both signing and content encryption.

7. Connection identifiers

Each connection receives a random 128-bit identifier encoded as unpadded base64url:

Vx7LgdZCsVbT_2uvB0YoGA

Requirements:

  • Generated with a cryptographically secure random generator.
  • Exactly 16 random bytes before encoding.
  • Never derived from an endpoint, wallet pubkey, application origin, label, or secret.
  • Stable for the lifetime of the logical connection.
  • Regenerated when importing as a distinct copy unless the user explicitly chooses to replace an existing connection.

The identifier is not a secret, but it must not contain meaningful metadata.

8. Nostr record format

8.1 Event

Each connection is stored as its own NIP-78-style addressable event:

{
  "pubkey": "<connection-vault-public-key>",
  "created_at": 1784750000,
  "kind": 30078,
  "tags": [["d", "bitlogin:connection:Vx7LgdZCsVbT_2uvB0YoGA"]],
  "content": "<encoded-encrypted-envelope>",
  "sig": "<vault-signature>"
}

The d tag deliberately contains only the generic BitLogin connection prefix and an opaque identifier. It does not reveal whether the record contains NWC, S3, or another credential type.

The event is signed by the derived Connection Vault identity, not the user's public everyday identity. This reduces direct linkage between the public profile and the existence or timing of stored application credentials.

8.2 Encrypted envelope

{
  "version": 1,
  "algorithm": "aes-256-gcm",
  "nonce": "<base64url-12-random-bytes>",
  "ciphertext": "<base64url>"
}

The plaintext is canonically serialized using RFC 8785 JCS, padded using the same fixed-bucket method as BitLogin capsules, and encrypted with the connection-specific record_key.

Associated data:

bitlogin|connection-record|v1|<vault-pubkey>|30078|<d-tag>

A fresh random 96-bit nonce is required for every publication. A nonce must never be reused with the same record key.

8.3 Common plaintext record

{
  "schema": "bitlogin.connection.v1",
  "connection_id": "Vx7LgdZCsVbT_2uvB0YoGA",
  "connection_type": "nwc",
  "state": "active",
  "label": "Satisfied spending wallet",
  "created_at": 1784750000,
  "updated_at": 1784750000,
  "credential": {
    "schema": "bitlogin.connection.nwc.v1"
  },
  "application_binding": {
    "origin": "https://satisfied.example",
    "app_pubkey": null
  },
  "notes": null
}

Required common fields:

  • schema
  • connection_id
  • connection_type
  • state
  • created_at
  • updated_at
  • credential

Allowed states:

active
suspended
deleted

The credential object is defined by a connection profile such as nwc-connections.md or s3c-format.md.

9. Per-record replacement

A connection update replaces only the event with the corresponding d tag. No global array of connections is rewritten during ordinary use.

This means two devices editing different connections do not clobber each other. Two devices editing the same connection still race according to Nostr addressable-event replacement rules.

Clients must use BitLogin's monotonic replacement timestamp rule:

new_created_at = max(current_time, previous_created_at + 1)

Clients should also keep a per-connection local generation or event-ID high-water mark and warn or fail closed on a detected regression.

10. Discovery

A client derives the vault public key after login and queries configured vault relays for:

kind: 30078
author: <vault-pubkey>

It then selects events whose d tag begins with:

bitlogin:connection:

Because Nostr relay filters generally match exact tag values rather than prefixes, the client may need to retrieve the vault identity's kind-30078 events and filter locally.

The initial design deliberately avoids a single mutable index event. A global index would recreate the multi-device clobbering problem that separate records are intended to solve.

A future optimization may add a rebuildable encrypted index, but the index must never be the sole source of truth.

11. Deletion and revocation

Deleting a BitLogin record does not revoke the underlying wallet or storage credential.

A safe deletion flow is:

  1. Offer or perform provider-side revocation first when supported.
  2. Replace the record with an encrypted tombstone using state: "deleted".
  3. Publish a NIP-09 deletion request for the previous event ID.
  4. Remove decrypted local copies and clear relevant grants.
  5. Retain the tombstone high-water mark so stale relay replicas are not silently resurrected.

The interface must distinguish:

  • Remove from BitLogin — deletes the portable record only.
  • Revoke credential — invalidates authority at the wallet or storage provider.

12. Application authorization

12.1 Default behavior

BitLogin must not expose every stored credential automatically after login. An application requests access to a particular connection or capability, and the user approves or denies it.

Conceptual API:

const connections = await bitlogin.connections.list({
  type: "nwc",
});

const grant = await bitlogin.connections.requestAccess({
  connectionId: connections[0].id,
  operations: ["pay_invoice", "get_balance"],
  reason: "Pay for AI inference",
});

12.2 Reveal versus broker

There are two authorization modes:

  1. Reveal: BitLogin returns the raw credential to the requesting app.
  2. Broker: BitLogin keeps the credential and performs approved operations on the app's behalf.

Brokered access is preferred because the application never receives the long-lived secret. Reveal mode is simpler but transfers all authority granted by the underlying credential to the host application.

12.3 Browser security boundary

An embedded same-origin web component is not a security boundary against its host page. A malicious page can inspect values returned to it and may be able to interfere with same-origin execution.

Strong brokered isolation therefore requires one of:

  • A cross-origin BitLogin authorization window or iframe with a carefully designed message protocol.
  • A BitLogin browser extension.
  • A native signer or credential broker.
  • A NIP-46-style remote authorization service.

The initial static widget may implement reveal mode, but the interface must state that the requesting origin receives the credential.

13. Recovery and export

13.1 Password login

The credential capsule restores the stable connection_vault_root, allowing the client to derive the same vault identity and decrypt connection records.

13.2 Phrase recovery

The recovery capsule restores the same root. A clean device can therefore recover connections without requiring the old password or prior browser data.

13.3 Everyday identity rotation

The Connection Vault root does not change when the public everyday Nostr identity rotates. Connection records remain at the same vault identity and do not require re-encryption solely because the public identity changed.

13.4 Account export

A full BitLogin recovery export should include:

  • The encrypted credential and recovery capsules.
  • Signed Connection Vault events.
  • The latest encrypted tombstones.
  • Vault relay hints and readback metadata.

The export must not contain decrypted NWC, S3, or other application credentials unless the user explicitly selects a plainly labeled dangerous plaintext export.

14. Connection profiles

Initial proposed profiles:

  • bitlogin.connection.nwc.v1 — Nostr Wallet Connect
  • bitlogin.connection.s3.v1 — S3-compatible object storage

Profiles define:

  • Required credential fields.
  • Import and export formats.
  • Validation rules.
  • Provider verification behavior.
  • Revocation behavior.
  • App-facing operations.
  • Profile-specific security warnings.

15. Implementation phases

Phase A: specification and test vectors

  • Finalize capsule schema migration.
  • Finalize key-derivation constants.
  • Publish JCS, padding, encryption, and signed-event test vectors.
  • Define valid and invalid profile fixtures.

Phase B: local Connection Vault

  • Add, edit, list, and remove connections locally.
  • Encrypt records with the vault root.
  • Add encrypted export and import.

Phase C: relay synchronization

  • Publish records to multiple vault relays.
  • Require quorum readback.
  • Add per-record rollback detection and replica repair.

Phase D: application authorization

  • Add permission prompts and connected-app management.
  • Support explicit credential reveal.
  • Add brokered operations where a safe execution boundary exists.

Phase E: hardened clients

  • Browser extension and native client.
  • Cross-origin authorization protocol.
  • Hardware-backed vault root where available.
  • Independent security review.

16. Open decisions

The following require implementation review before the design becomes stable:

  1. Exact new capsule schema identifiers and migration rules.
  2. Whether Connection Vault records should reuse the capsule padding buckets or define larger buckets for storage-provider metadata.
  3. Relay discovery and archival policy for the derived vault identity.
  4. The precise app-origin and app-pubkey binding model.
  5. Whether a rebuildable encrypted index is worthwhile.
  6. How brokered grants are represented and revoked.
  7. Whether the standalone S3C format should move to its own repository after a second independent implementation exists.

17. Security summary

The Connection Vault reduces setup friction, but it also concentrates valuable bearer credentials behind one account. Implementations must treat it as a high-value secret manager.

At minimum:

  • Never log decrypted credentials.
  • Never place credentials in analytics, exceptions, URLs, or application state.
  • Clear secret-bearing DOM fields immediately after import.
  • Keep plaintext values inside the crypto or broker worker for the shortest practical lifetime.
  • Require explicit approval before reveal or export.
  • Prefer separately scoped credentials for every application.
  • Prefer temporary credentials, budgets, prefixes, and provider-side policy.
  • Clearly distinguish BitLogin record deletion from provider-side revocation.
  • Assume a malicious or compromised client can steal all secrets available in that session.

18. Implementation-time decisions (2026-07-30, v0.2)

These were finalized when §5–§13 shipped in @bitlogin/core. Where this section and the earlier prose disagree, this section wins.

18.1 Phrase-gated sudo material

The vault uses two independent 32-byte roots:

{
  "connection_vault_root": "base64url-32-bytes",
  "vault_sudo_key": "base64url-32-bytes"
}

The phrase-gated recovery capsule carries both fields together. A newly written password credential capsule carries only connection_vault_root. This lets an ordinary login restore connectable-tier records without placing the personal-tier vault_sudo_key behind the same offline-guessable password capsule. Pre-hardening credential capsules that contain both remain readable for compatibility, but password login does not return their sudo key and the next password rotation scrubs it from the credential capsule.

Registration mints both roots for every new account; enableConnectionVault is the §5.3 ceremony for accounts that predate them, requires the recovery phrase, writes the recovery capsule first, fails closed, and is idempotent. Legacy migration derives the same roots deterministically from phrase-protected material and account_id, so two concurrent migration attempts cannot mint divergent roots.

18.2 The sensitivity-tier model and why the sudo key exists

Records carry a tier field:

  • connectable (NWC, scoped S3): record keys derive from connection_vault_root alone. The root is cacheable for the session, so daily wallet use is silent. These are the records the §12 grant flow may expose to application origins. The real security boundary is provider-side policy — wallet budgets, scoped IAM keys — and import UIs must push users toward it.
  • personal (stored passwords, secure notes): record keys additionally require vault_sudo_key as HKDF input (personal_prk = HKDF-Extract(SHA256("bitlogin/connection-vault-personal/v1"), root || sudo_key)). The sudo key is stored only in the phrase-gated recovery capsule and held in memory only during a sudo window (VaultSession.enableSudo/endSudo, which wipe it). Re-opening a window requires a phrase ceremony; an ordinary password login cannot provide the key. Personal-tier UI remains deferred until that ceremony has a separately reviewed interface. The app-facing API must be structurally blind to this tier: an embedded widget cannot do exact-origin matching, and revealing one site's password to another site's page is a phishing machine with a consent screen. Password-manager UX (autofill, origin matching) waits for the extension generation. Crypto seed phrases remain banned from the vault entirely.

Tiers are invisible on the wire: identical envelopes, buckets, and d-tag shapes, decryption is trial-based. A distinguishable ciphertext would tell every relay which records are "the sensitive ones".

18.3 Resolved open decisions (§16)

  1. Capsule schema: additive optional fields on the v1 schemas (§18.1) — a new schema id would lock old clients out of login entirely. Mixed-version caveat: an OLD client's password change rebuilds the credential capsule without the vault fields and strands connectable records until the next phrase ceremony repairs them. Deployments must update vendored widgets before enabling the vault for users.
  2. Padding buckets: the capsule buckets (1024/2048/4096) are reused. Every current profile fixture fits 1024 with room, and a shared bucket set keeps NWC and S3 records indistinguishable by size.
  3. Relay policy: vault records publish to the account's configured vault relays with the same publish-and-verify quorum bar as capsules (publishConnectionRecord / fetchConnectionRecordEvents).
  4. App binding: application_binding.origin is implemented; app_pubkey remains reserved.
  5. Encrypted index: not built; discovery lists the derived identity's kind-30078 events and filters the bitlogin:connection: prefix locally.
  6. Grants: deferred to the widget authorization layer (Phase D).
  7. S3C repository split: deferred until a second implementation exists.

Rollback resistance (§9) is per record: a local high-water mark stores the newest accepted created_at per connection id, and fetchConnectionRecordEvents withholds and reports anything older instead of serving it — a stale replica must never silently resurrect a revoked credential.

19. Known limitations (audit, 2026-07-31)

An adversarial audit of the implementation produced fixes for the worst findings (origin binding now enforced worker-side; destroy() wipes the root and poisons the session; equal-timestamp rollback tie-break; per-record listing isolation; quorum enforced before the migration's mint decision). The audit's implementation defects are remediated in the current working tree. These fundamental limits remain and must stay visible in product claims:

  1. No relay-enforced compare-and-swap on capsule writes. Deterministic legacy migration prevents concurrent vault-enable attempts from minting different roots, and recovery now performs collision checks before writes and stops after a failed recovery quorum. Relays still provide no atomic compare-and-swap primitive, so simultaneous phrase-recovery refreshes can produce signed siblings. The recovery hash chain detects such a fork when both siblings are visible; it cannot prevent one.
  2. Rollback protection is per-device local state. A fresh install has no high-water marks, so the oldest record a hostile relay serves is accepted and becomes the mark. Clearing or deleting the protected marker is likewise indistinguishable from a genuinely fresh device; AEAD detects modification of a marker that still exists, not its absence. A trusted remote monotonic anchor would close this; purely local state cannot.
  3. A tombstone does not destroy the prior ciphertext. Record keys are derived from the immutable connection_id, so the superseded event — which still contains the credential — remains decryptable by anyone who later obtains the vault root, and relays may retain it regardless of NIP-09. Deleting a connection in BitLogin is therefore NOT revocation: revoke at the wallet or provider, which §11 already says and which this makes cryptographically concrete.
  4. Legacy password capsules may still contain the sudo key. New registration, recovery, migration, and password-rotation writes omit it; password login deliberately ignores a legacy copy. An archived old capsule remains decryptable forever by its old password, so existing users who want the stronger separation must rotate and should assume previously downloaded capsules retain the old exposure.
  5. The embedded host remains fully trusted. CSP and same-origin vendoring reduce accidental script and supply-chain exposure, but code already executing on the application origin can drive the worker and export the unlocked identity. A cross-origin signer, browser extension, or comparable isolated surface is required to create a real boundary.

Closed audit items: listing fails closed below quorum and exposes truncation and unreadable records through the widget; per-record high-water marks are now AEAD-authenticated and stored under keyed, inventory-hiding identifiers, with fail-closed migration from the legacy plaintext format.