Files
bitlogin/docs/connection-vault.md
T
thePR0M3TH3ANandClaude Fable 5 a465e1d8fa Implement the Connection Vault protocol layer (phases A-C)
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
2026-07-30 12:29:46 -04:00

617 lines
21 KiB
Markdown

# 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:
```text
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:
```json
{
"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:
```text
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.
```text
vault_prk = HKDF-Extract(
salt = SHA256("bitlogin/connection-vault-root/v1"),
IKM = connection_vault_root
)
```
Derive a Nostr-compatible signing identity:
```text
vault_signing_material = ScalarExpand(
vault_prk,
"bitlogin/connection-vault-signing/v1"
)
```
Derive a record encryption key independently for every connection:
```text
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:
```text
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:
```json
{
"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
```json
{
"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:
```text
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
```json
{
"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:
```text
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:
```text
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:
```text
kind: 30078
author: <vault-pubkey>
```
It then selects events whose `d` tag begins with:
```text
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:
```javascript
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 Two capsule fields, not one
The capsules gained **two** paired 32-byte roots, not just the vault root of
§5.2:
```json
{
"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 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 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)
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.