Skip to content

Latest commit

 

History

History
183 lines (121 loc) · 11.1 KB

File metadata and controls

183 lines (121 loc) · 11.1 KB

Security

1. Vault -- lib/vault.ts

The vault encrypts sensitive account data (private keys, mnemonics) at rest using Web Crypto APIs.

Encryption scheme:

  1. User password fed to PBKDF2 with SHA-256, 210,000 iterations, random 32-byte salt, producing a 256-bit AES key.
  2. AES-256-GCM encrypts the vault payload JSON with a random 12-byte IV.
  3. Stored in browser.storage.local under key keyVault:
{
    "version": 1,
    "salt": "<base64>",
    "iv": "<base64>",
    "ciphertext": "<base64>"
}

Auto-lock: Configurable timeout (default 15 minutes / 900,000ms). When the timer fires, lock() zeroes all in-memory key material and sets _decrypted = null and _cryptoKey = null. The background script also calls clearWalletProviders() on lock to disconnect and discard cached wallet provider instances. On Chrome, service worker termination also naturally clears memory. When the vault auto-locks, a full-screen overlay blocks all UI until the password is entered.

The configured interval is stored as autoLockMs in browser.storage.local, but _autoLockMs is module-level in-memory state that resets to the 15-minute default on every service-worker cold start. restoreAutoLockSetting() re-reads the persisted autoLockMs (defaulting to 15 min when absent) and re-arms the timer; it is called on background startup and after every successful vault_unlock, so the user's chosen interval — not the default — governs locking after the SW restarts (bug #10).

Service-worker keep-alive: On Chrome MV3 the service worker is torn down frequently (including around page refreshes), which wipes the in-memory decrypted key and makes a timed-mode vault appear locked well before the configured interval. While the vault is unlocked in timed-lock mode (autoLockMs > 0), vault.ts arms a periodic browser.alarms keep-alive ('vault-keepalive', ~30s period — Chrome clamps the minimum). The onAlarm listener in background.ts does a trivial async storage read on each tick, resetting the SW idle timer so the worker stays alive until the auto-lock actually fires. The alarm is cleared on every lock() and is never used to persist the decrypted key — it only holds the worker open, preserving the security model. It is a graceful no-op where browser.alarms is unavailable (Safari's persistent background page, tests).

Brute-force protection: The useVaultUnlock hook enforces escalating lockout after failed password attempts. Every 5 consecutive failures trigger a lockout: 1 min, 5 min, 15 min, 30 min (cap). The countdown is displayed in the UI and the input is disabled during lockout. A successful unlock resets the counter. The counter is shared across hook instances (module-level state) so remounting components does not reset it; however, it resets on full page reload (extension restart).

Vault destroy (vault.destroy()): Irreversibly wipes the encrypted vault from browser.storage.local and clears all in-memory state. The vault_destroy RPC handler also clears wallet providers, cancels pending signer requests, and removes account metadata from storage. Exposed via "Forgot password?" on the full-screen lock overlay with a confirmation step.


2. In-Memory Key Format -- MemoryVaultPayload

Private keys are stored differently on disk vs in memory:

Layer Format Zeroable?
Disk (JSON) Account.privkey: string (hex) N/A
Memory MemoryAccount.privkeyBytes: Uint8Array Yes
Disk (JSON) Account.mnemonic: string N/A
Memory MemoryAccount.mnemonicBytes: Uint8Array Yes

On unlock(), hex strings are converted to Uint8Array via toMemoryAccount(). On lock(), every account's privkeyBytes and mnemonicBytes are zeroed with .fill(0) before the reference is nulled. This prevents hex strings (which are immutable JS strings and cannot be zeroed) from lingering in the GC heap.

On save() and reEncrypt(), memory format is serialized back to JSON via toStoragePayload().


3. Private Key Handling

vault.getPrivkey() returns a copy of the private key as Uint8Array(32) -- new Uint8Array(acct.privkeyBytes). The caller MUST zero the returned array after use with privkey.fill(0) in a try/finally block. Because it's a copy, the caller's fill(0) does not affect the vault's internal state.

const privkey = vault.getPrivkey();
if (!privkey) throw new Error('No private key');
try {
    return await cryptoSignEvent(event, privkey);
} finally {
    privkey.fill(0);
}

4. Vault reEncrypt() Method

Changes the vault password without exposing private keys as intermediate hex strings:

  1. Validates vault is unlocked and new password meets minimum length (8 chars, or empty for never-lock mode)
  2. Generates new random salt + derives new AES key
  3. Serializes MemoryVaultPayload -> VaultPayload JSON -> encrypts with new key
  4. Stores new encrypted vault, replaces internal _cryptoKey

This avoids the old getDecryptedPayload() + lock() + create() pattern which created an intermediate JSON copy with hex private key strings.


5. NIP-49 Zeroing (lib/crypto/nip49.ts)

  • ncryptsecEncode: The input privkeyBytes is zeroed in a finally block after encryption.
  • ncryptsecDecode: The decrypted Uint8Array view is zeroed after extracting the hex string.

6. NIP-04 Error Normalization (lib/crypto/nip04.ts)

AES-CBC decrypt errors are caught and re-thrown as a generic "Decryption failed" message. This prevents padding oracle attacks where different error messages for "wrong padding" vs "wrong key" would leak information about the plaintext.


7. NIP-46 Connect Secret (lib/nip46.ts)

The nostrconnect:// QR code flow includes a connectSecret parameter:

  • A random 16-byte hex string is generated and included in the QR URI
  • The Nip46Client validates that the incoming connect request's params[1] matches the secret
  • After successful validation, the secret is cleared (one-time use)
  • Requests with wrong or missing secrets are silently ignored

8. Privileged Method Gating

The PRIVILEGED_METHODS set is auto-derived in background.ts from every handler map that is not a page-facing NIP-07/WebLN/relay-query method, plus configUpdated. It contains all sensitive operations (representative list):

  • Vault lifecycle: vault_unlock, vault_lock, vault_create, vault_isLocked, vault_exists, vault_listAccounts, vault_addAccount, vault_removeAccount, vault_setActiveAccount, vault_getActivePubkey, vault_setAutoLock, vault_getAutoLock, vault_exportNsec, vault_exportNcryptsec, vault_importNcryptsec, vault_changePassword, vault_getActiveAccountType
  • Signer permissions: signer_getPermissions, signer_getPermissionsForDomain, signer_clearPermissions, signer_savePermission, signer_getPermissionsRaw, signer_getPermissionsForDomainRaw, signer_copyPermissions, signer_getUseGlobalDefaults, signer_setUseGlobalDefaults, signer_setupNewAccountPermissions
  • Pending requests: signer_getPending, signer_resolve, signer_resolveBatch
  • Account switching: switchAccount
  • Onboarding: onboarding_validateNsec, onboarding_validateNcryptsec, onboarding_validateNpub, onboarding_connectNip46, onboarding_generateAccount, onboarding_exportNcryptsec, onboarding_saveReadOnly, onboarding_createVault, onboarding_addToVault, onboarding_initNostrConnect, onboarding_pollNostrConnect, onboarding_cancelNostrConnect
  • Config: configUpdated
  • Domain management & identity injection: requestHostPermission, enableForCurrentDomain, addAllowedDomain, removeAllowedDomain, getAllowedDomains, isDomainAllowed, isDomainDismissed, hasHostPermission, setIdentityDisabled, getIdentityDisabledSites
  • Activity log: getActivityLog, clearActivityLog
  • Profile & mute list: getProfileMetadata, getProfileMetadataBatch, updateProfileCache, getMyMuteList, fetchMuteList
  • Publishing: publishRelayList, publishMuteList, signAndPublishEvent, signEvent
  • NIP-46 sessions: nip46_getSessionInfo, nip46_revokeSession
  • Health checks: checkRelayHealth

All gated by: sender.id === browser.runtime.id && sender.url.startsWith(extensionBaseUrl).


8b. Wallet Credential Storage

Wallet credentials are stored encrypted inside the vault as part of the Account object:

Config type Sensitive field Storage
NWC connectionString (contains secret key) account.walletConfig.connectionString inside AES-256-GCM vault
LNbits adminKey (full access token) account.walletConfig.adminKey inside AES-256-GCM vault

Both values are encrypted at rest (same PBKDF2 + AES-256-GCM scheme as private keys). The walletConfig field is stripped from SafeAccount (used by public APIs) -- only SafeAccountWithWallet retains it, and that type is restricted to internal background wallet handlers.

The LNbits admin key grants full wallet control (send, receive, read balance) and is treated with the same sensitivity as private keys. It is never exposed to content scripts or page context.


8c. Payment Authorization Flow

When a page calls window.webln.sendPayment(bolt11):

  1. Vault lock check -- request is rejected if the vault is locked.
  2. Wallet config check -- request is rejected if no walletConfig exists on the active account.
  3. Permission check -- signerPermissions.check(origin, 'webln_sendPayment'):
    • 'deny' -- immediately rejected.
    • 'allow' -- proceeds to payment.
    • 'ask' -- queues a prompt via signer.queueRequest() with type: 'webln_sendPayment'. The user sees an approval popup and can approve/deny, optionally with "remember" to save the decision for future requests from that origin.
  4. Provider connection -- if the provider is not connected, connect() is called.
  5. Payment execution -- provider.payInvoice(bolt11) sends the payment.

The auto-approve threshold (walletThreshold_{accountId}) is stored in browser.storage.local and managed via wallet_setAutoApproveThreshold / wallet_getAutoApproveThreshold privileged methods.


9. Rate Limiting

There is no request rate limiting — the only throttled methods were the removed Web-of-Trust computation methods and the page-facing relay-query channel, all of which have been deleted. vault_unlock is protected by the privilege gate (only callable from extension pages) and PBKDF2's 210,000 iterations which make brute-force impractical (~200ms per attempt).


10. Profile Verification

When fetching kind:0 profile metadata, events are validated for matching event.pubkey and event.kind before being cached:

if (event.pubkey !== pubkey || event.kind !== 0) return;

11. Data Sanitization

When returning account objects from public APIs, sensitive fields are stripped:

const { privkeyBytes, mnemonicBytes, ...safe } = acct;
return safe;

getActiveAccount(), getAccountById(), and listAccounts() all strip key bytes. getDecryptedPayload() reconstructs hex format for JSON export but is only callable when unlocked.