connection-vault.md 5-13 as code, with the implementation-time decisions recorded in a new section 18 and vault-ux.md holding the agreed UX walkthroughs as the authorization layer's acceptance criteria. Capsules gain paired optional fields connection_vault_root and vault_sudo_key (both or neither, validated); registration mints them, login/change-password/recovery carry them forward, and enableConnectionVault is the phrase-gated migration for existing accounts -- recovery capsule written first, fail closed, idempotent by reusing roots found in either capsule so a partial failure can never mint twice and strand records. src/vault/ holds the finalized key hierarchy (HKDF/ScalarExpand constants pinned by test vectors that must only ever be reverted, never updated), per-connection encrypted records signed by a derived vault identity with the capsule JCS+padding+AES-GCM pipeline and shared buckets so record types are indistinguishable by size, the NWC profile with lossless URI parse/export, relay sync reusing the publish-and- verify quorum bar, and per-record high-water marks so a stale replica is withheld and reported rather than silently resurrecting a revoked credential. The two-tier model makes sudo mode cryptographically real: personal- tier record keys require vault_sudo_key as HKDF input, VaultSession holds it only inside an enableSudo/endSudo window (wiping on the way in and out), and outside a window personal records are invisible -- the same trial-decrypt returns null, not an error. The app-facing API is structurally blind to the tier by construction. 118 tests passing, including four end-to-end scenarios against mock relays: clean-device restore, password-change carry, legacy-account migration (wrong phrase refused), and rollback detection. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
21 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: consent UI, grants, Connected-apps
management) is not yet implemented — it is the widget generation's work.
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:
- Restore connections on a clean device after BitLogin authentication or phrase recovery.
- Keep individual connection records separate to avoid multi-device last-write-wins clobbering.
- Support multiple connections of the same type.
- Encourage one credential per application or permission profile.
- Allow credentials to be revoked, replaced, renamed, and exported independently.
- Keep connection types and contents encrypted from relays where practical.
- Survive everyday Nostr identity rotation.
- Reuse BitLogin's existing cryptographic primitives, canonical serialization, padding, relay quorum, and validation rules.
- 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:
schemaconnection_idconnection_typestatecreated_atupdated_atcredential
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:
- Offer or perform provider-side revocation first when supported.
- Replace the record with an encrypted tombstone using
state: "deleted". - Publish a NIP-09 deletion request for the previous event ID.
- Remove decrypted local copies and clear relevant grants.
- 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:
- Reveal: BitLogin returns the raw credential to the requesting app.
- 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 Connectbitlogin.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:
- Exact new capsule schema identifiers and migration rules.
- Whether Connection Vault records should reuse the capsule padding buckets or define larger buckets for storage-provider metadata.
- Relay discovery and archival policy for the derived vault identity.
- The precise app-origin and app-pubkey binding model.
- Whether a rebuildable encrypted index is worthwhile.
- How brokered grants are represented and revoked.
- 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 Two capsule fields, not one
The capsules gained two paired 32-byte roots, not just the vault root of §5.2:
{
"connection_vault_root": "base64url-32-bytes",
"vault_sudo_key": "base64url-32-bytes"
}
Validation enforces that they appear together or not at all (a capsule with
one but not the other is corrupt and must not grant a session). Registration
mints both 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 — a retry
after a partial failure REUSES roots found in either capsule rather than
minting twice, because minting twice would strand existing records.
18.2 The sensitivity-tier model and why the sudo key exists
Records carry a tier field:
connectable(NWC, scoped S3): record keys derive fromconnection_vault_rootalone. 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 requirevault_sudo_keyas HKDF input (personal_prk = HKDF-Extract(SHA256("bitlogin/connection-vault-personal/v1"), root || sudo_key)). The honest-client contract: the sudo key is never persisted outside the capsules and is held in memory only during a sudo window (VaultSession.enableSudo/endSudo, which wipe it). Re-opening a window requires re-decrypting a capsule — a fresh Argon2id run on the password, or the phrase — which is what makes "re-enter your password to reveal" a real key ceremony rather than the cosmetic gate §SF2 forbids. 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)
- 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.
- 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.
- Relay policy: vault records publish to the account's configured vault
relays with the same publish-and-verify quorum bar as capsules
(
publishConnectionRecord/fetchConnectionRecordEvents). - App binding:
application_binding.originis implemented;app_pubkeyremains reserved. - Encrypted index: not built; discovery lists the derived identity's
kind-30078 events and filters the
bitlogin:connection:prefix locally. - Grants: deferred to the widget authorization layer (Phase D).
- 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.