From cf0595e2e8b4fbc92fa3d5916431e6c7b3fb83d6 Mon Sep 17 00:00:00 2001 From: Luke Rohenaz Date: Mon, 7 Sep 2026 14:50:47 -0400 Subject: [PATCH 1/4] OPL-4574: Add envelope v2 with key slots and device-p256 wrapping --- API.md | 39 ++ CHANGELOG.md | 9 + README.md | 16 + cli/bbackup.ts | 26 + package.json | 2 +- src/crypto.ts | 182 +++-- src/ecies.ts | 170 +++++ src/envelope.ts | 665 +++++++++++++++++++ src/guards.ts | 22 + src/index.ts | 121 +--- src/interfaces.ts | 12 + test/envelope-v2.test.ts | 388 +++++++++++ test/fixtures/envelope-v2/device-vector.json | 17 + 13 files changed, 1532 insertions(+), 137 deletions(-) create mode 100644 src/ecies.ts create mode 100644 src/envelope.ts create mode 100644 test/envelope-v2.test.ts create mode 100644 test/fixtures/envelope-v2/device-vector.json diff --git a/API.md b/API.md index a9c4b11..1e3c687 100644 --- a/API.md +++ b/API.md @@ -167,3 +167,42 @@ verify the mnemonic checksum, derive keys, and verify BAP ID bindings before using a restored seed. This format does not migrate or rekey existing accounts. The optional `inventoryComplete: false` marks phrase-only recovery with an unknown full profile inventory. Absence means complete; `true` and other values are invalid. For partial inventories, `nextProfileIndex` is only a structural bound over listed profiles, not proof that the next index is unused. Consumers must reconcile a complete backup before appending or deleting profiles or replacing a complete cloud inventory. + +## Envelope v2 — key slots and device-key wrapping + +`encryptBackup` still writes v1 (`salt(16) + iv(12) + AES-GCM`). `sealBackup` writes v2 (`BEP2` magic, `0x02` version, u16 header length, header JSON, 12-byte IV, AES-256-GCM payload). `decryptBackup` transparently opens v2 pbkdf2 slots; otherwise the v1 path is unchanged. + +```typescript +type SlotSpec = + | { type: 'pbkdf2'; id: string; passphrase: string; iterations?: number } + | { type: 'device-p256'; id: string; publicKey: string }; + +type Unlock = + | { passphrase: string } + | { slotId: string; passphrase: string } + | { slotId: string; unwrap: (wrapped: Uint8Array) => Promise }; + +sealBackup(payload: DecryptedBackup, slots: SlotSpec[]): Promise +openBackup(encrypted: EncryptedBackup, unlock: Unlock): Promise +inspectEnvelope(encrypted: EncryptedBackup): { version: 1 | 2; slots: Array<{ type: string; id: string; publicKey?: string; iterations?: number }>; descriptor?: DerivationDescriptor } +addSlot(encrypted: EncryptedBackup, unlock: Unlock, slot: SlotSpec): Promise +removeSlot(encrypted: EncryptedBackup, unlock: Unlock, slotId: string): Promise +rewrapBackup(encrypted: EncryptedBackup, unlock: Unlock, slots: SlotSpec[]): Promise +isEnvelopeV2(encrypted: EncryptedBackup): boolean +``` + +Slot `id` values are 1–63 chars matching `^[a-zA-Z0-9][a-zA-Z0-9._-]*$` and unique within the envelope. `device-p256` wrapping is ECDH P-256 + HKDF-SHA256 (`info "se-vault-v1"`) + AES-256-GCM; `wrapped` is `ephemeralPub(65) + nonce(12) + ciphertext + tag(16)`. Use `eciesEncrypt`/`eciesDecrypt` for software P-256 keys; hardware recipients unwrap via the `unwrap` callback. `removeSlot` refuses the last slot; `rewrapBackup` generates a new content key. + +## Derivation descriptor + +```typescript +export interface DerivationDescriptor { + scheme: 'brc157' | 'bip32' | 'type42' | 'brc42' | 'legacy-bip32-unhardened'; + path?: string; + parentIdentityKey?: string; + index?: number; + cohort?: string; +} +``` + +Optional `derivation?: DerivationDescriptor` exists on `WifBackup`, `BapAccountBackup`, `MasterBackupType42`, and `BapMasterBackupLegacy`. It does not affect type detection; use `isDerivationDescriptor(value)` to validate. `SigmaSeedBackup` is unchanged. Sealed v2 headers copy a valid payload descriptor for locked inspection. diff --git a/CHANGELOG.md b/CHANGELOG.md index 714d3ad..e61e981 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 0.1.0 + +### Added +- Versioned `BEP2` envelope (v2) with key slots: one encrypted file can be unlocked by multiple credentials (`pbkdf2` passphrase slots and `device-p256` P-256 ECIES slots). +- New API: `sealBackup`, `openBackup`, `inspectEnvelope`, `addSlot`, `removeSlot`, `rewrapBackup`, `isEnvelopeV2`, plus `eciesEncrypt`/`eciesDecrypt` and `SlotSpec`/`Unlock` types. +- Optional `DerivationDescriptor` (`scheme`, `path`, `parentIdentityKey`, `index`, `cohort`) on `WifBackup`, `BapAccountBackup`, `MasterBackupType42`, and `BapMasterBackupLegacy`, with `isDerivationDescriptor` guard. Header copies it for locked inspection. +- CLI: `bbackup slots ` prints `inspectEnvelope` output as JSON without a passphrase. +- `decryptBackup` transparently opens v2 pbkdf2 slots; `encryptBackup` still writes v1. Existing `.bep` files decrypt unchanged. + ## 0.0.14 ### Fixed diff --git a/README.md b/README.md index bf1f1aa..4dfb985 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ npx bbackup dec wallet.bep -p "your-strong-password-here" ## Features * **Chain-Agnostic Core:** Securely encrypt and decrypt WIF private keys, xprv/mnemonic phrases for HD wallets, or custom data structures from any blockchain or cryptographic application. +* **Envelope v2 key slots:** Seal one encrypted file for multiple credentials (passphrase slots plus device-bound P-256 slots) while existing `.bep` files keep decrypting unchanged. * **Strong Encryption:** Secures backup data using AES-256-GCM with PBKDF2 key derivation (see Security Model). * **Multiple Backup Formats:** Supports various backup structures like `BapMasterBackup`, `BapMemberBackup`, `WifBackup`, `OneSatBackup`, and `VaultBackup`. The type of backup is inferred from payload structure. (See [API Documentation](./API.md) for full type details). * **Handles Unencrypted Data:** Easily encrypt existing unencrypted backup objects. @@ -95,6 +96,20 @@ Decrypts an encrypted backup string. * `passphrase`: Decryption passphrase. * Returns: The `DecryptedBackupPayload`. Type is inferred. * Handles legacy WIFs and tries recommended then legacy iterations. +* Transparently opens v2 envelopes via their pbkdf2 slots; v1 files are unchanged. + +### Envelope v2 (`sealBackup` / `openBackup`) + +```typescript +const encrypted = await sealBackup(payload, [ + { type: 'pbkdf2', id: 'main', passphrase }, + { type: 'device-p256', id: 'phone', publicKey: devicePubHex }, +]); +const decrypted = await openBackup(encrypted, { passphrase }); +// or: await openBackup(encrypted, { slotId: 'phone', unwrap: async (wrapped) => hardwareUnwrap(wrapped) }); +``` + +Helpers: `inspectEnvelope`, `isEnvelopeV2`, `addSlot`, `removeSlot` (refuses the last slot), `rewrapBackup` (new content key), `eciesEncrypt`/`eciesDecrypt`. Optional `derivation` descriptors on key payloads are copied to the v2 header for locked inspection. *(For more detailed examples and advanced usage, please refer to the `test/` directory or consider creating an `examples/` directory in your project.)* @@ -118,6 +133,7 @@ npx bbackup --help | `bbackup enc ` | Encrypts a JSON input file. | `bbackup enc wallet.json -p "secret" -o wallet.bep` | | `bbackup dec ` | Decrypts a `.bep` file. | `bbackup dec wallet.bep -p "secret" -o wallet.json` | | `bbackup upg ` | Upgrades an encrypted file to recommended PBKDF2 iterations. | `bbackup upg old_wallet.bep -p "secret" -o upgraded_wallet.bep` | +| `bbackup slots ` | Prints envelope version and key slots as JSON (no passphrase). | `bbackup slots wallet.bep` | **Common Options:** * `-p, --password `: (Required) The passphrase for encryption/decryption. diff --git a/cli/bbackup.ts b/cli/bbackup.ts index 851cd96..1146418 100644 --- a/cli/bbackup.ts +++ b/cli/bbackup.ts @@ -8,6 +8,7 @@ import { type DecryptedBackup, decryptBackup, encryptBackup, + inspectEnvelope, RECOMMENDED_PBKDF2_ITERATIONS, } from '../src/index'; @@ -315,6 +316,31 @@ program } ); +// --- slots --- + +program + .command('slots ') + .description('Inspect envelope version and key slots without a passphrase.') + .action(async (file: string) => { + try { + const absoluteInputPath = path.resolve(file); + const encryptedString = await fs.readFile(absoluteInputPath, 'utf-8'); + if (!encryptedString.trim()) { + console.error('Error: Encrypted file is empty or contains only whitespace.'); + process.exit(1); + } + const info = inspectEnvelope(encryptedString.trim()); + console.log(JSON.stringify(info, null, 2)); + } catch (error) { + if (error instanceof Error) { + console.error('Error:', error.message); + } else { + console.error('An unknown error occurred during inspection:', error); + } + process.exit(1); + } + }); + // --- forget --- program diff --git a/package.json b/package.json index e146ec5..c6b3c42 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "bitcoin-backup", "type": "module", - "version": "0.0.14", + "version": "0.1.0", "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", diff --git a/src/crypto.ts b/src/crypto.ts index 742c995..537c958 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -31,7 +31,7 @@ const AES_KEY_LENGTH_BITS = 256; * @param iterations The number of PBKDF2 iterations to use. Defaults to DEFAULT_PBKDF2_ITERATIONS. * @returns A promise that resolves to the derived CryptoKey. */ -async function deriveKey( +export async function deriveKey( passphrase: string, salt: Uint8Array, iterations: number = RECOMMENDED_PBKDF2_ITERATIONS // Default to new recommended standard @@ -107,6 +107,140 @@ export async function encryptData( return toBase64(Array.from(combined)); } +/** + * Interprets a decrypted backup string: JSON payloads are matched by structure, + * anything that is not JSON is treated as a legacy raw WIF backup. + */ +export function parseDecryptedPayload(decryptedString: string): DecryptedBackup { + try { + const parsedJson = JSON.parse(decryptedString); + if (typeof parsedJson === 'object' && parsedJson !== null) { + if (hasSigmaSeedMarker(parsedJson)) { + if (!isSigmaSeedBackup(parsedJson)) throw new Error('Invalid Sigma seed backup structure.'); + return parsedJson; + } + if ('xprv' in parsedJson && 'ids' in parsedJson && 'mnemonic' in parsedJson) + return parsedJson as BapMasterBackup; + if ('rootPk' in parsedJson && 'ids' in parsedJson) return parsedJson as BapMasterBackup; + if ('wif' in parsedJson && 'id' in parsedJson) return parsedJson as BapAccountBackup; + // Check for YoursWalletBackup before OneSatBackup (more specific) + if ( + 'payPk' in parsedJson && + 'ordPk' in parsedJson && + ('mnemonic' in parsedJson || + 'payDerivationPath' in parsedJson || + 'ordDerivationPath' in parsedJson) + ) + return parsedJson as YoursWalletBackup; + if ( + 'chromeStorage' in parsedJson && + typeof parsedJson.chromeStorage === 'object' && + parsedJson.chromeStorage !== null + ) + return parsedJson as YoursWalletZipBackup; + if ('ordPk' in parsedJson && 'payPk' in parsedJson && 'identityPk' in parsedJson) + return parsedJson as OneSatBackup; + if ('encryptedVault' in parsedJson) return parsedJson as VaultBackup; + if ( + 'wif' in parsedJson && + !('id' in parsedJson) && + !('xprv' in parsedJson) && + !('rootPk' in parsedJson) + ) + return parsedJson as WifBackup; + } + throw new Error('Invalid backup structure after JSON parse.'); + } catch (jsonError) { + if (jsonError instanceof SyntaxError) return { wif: decryptedString } as WifBackup; + throw jsonError; + } +} + +/** + * Structural check that a payload matches one of the supported backup shapes. + */ +export function isValidPayload(payload: unknown): payload is DecryptedBackup { + if (!payload || typeof payload !== 'object') return false; + + // Narrow down type for property checks + const p = payload as Record; + if (hasSigmaSeedMarker(p)) return isSigmaSeedBackup(p); + + // Check for BapMasterBackup structure (legacy XPRV format) + if ( + 'xprv' in p && + typeof p.xprv === 'string' && + 'ids' in p && + typeof p.ids === 'string' && + 'mnemonic' in p && + typeof p.mnemonic === 'string' + ) { + return true; + } + + // Check for BapMasterBackup structure (Type 42 format) + if ( + 'rootPk' in p && + typeof p.rootPk === 'string' && + 'ids' in p && + typeof p.ids === 'string' && + !('xprv' in p) // Ensure it's not a legacy format + ) { + return true; + } + + // Check for BapAccountBackup structure + if ('wif' in p && typeof p.wif === 'string' && 'id' in p && typeof p.id === 'string') { + return true; + } + + // Check for WifBackup structure + if ( + 'wif' in p && + typeof p.wif === 'string' && + !('id' in p) && // Differentiates from BapAccountBackup + !('xprv' in p) && // Differentiates from BapMasterBackupLegacy + !('rootPk' in p) // Differentiates from MasterBackupType42 + ) { + return true; + } + + // Check for OneSatBackup structure + if ( + 'ordPk' in p && + typeof p.ordPk === 'string' && + 'payPk' in p && + typeof p.payPk === 'string' && + 'identityPk' in p && + typeof p.identityPk === 'string' + ) { + return true; + } + + // Check for VaultBackup structure - just needs encryptedVault + if ('encryptedVault' in p && typeof p.encryptedVault === 'string') { + return true; + } + + // Check for YoursWalletBackup structure - has payPk and ordPk like OneSat, but may have mnemonic + if ( + 'payPk' in p && + typeof p.payPk === 'string' && + 'ordPk' in p && + typeof p.ordPk === 'string' && + ('mnemonic' in p || 'payDerivationPath' in p || 'ordDerivationPath' in p) // Distinguishes from OneSatBackup + ) { + return true; + } + + // Check for YoursWalletZipBackup structure (parsed Yours Wallet ZIP) + if ('chromeStorage' in p && typeof p.chromeStorage === 'object' && p.chromeStorage !== null) { + return true; + } + + return false; +} + /** * Decrypts an encrypted backup string back into a backup payload object. * Handles JSON-structured and legacy raw WIF backups. @@ -157,51 +291,7 @@ export async function decryptData( key, encryptedCiphertext ); - const decryptedString = new TextDecoder().decode(decryptedArrayBuffer); - try { - const parsedJson = JSON.parse(decryptedString); - if (typeof parsedJson === 'object' && parsedJson !== null) { - if (hasSigmaSeedMarker(parsedJson)) { - if (!isSigmaSeedBackup(parsedJson)) - throw new Error('Invalid Sigma seed backup structure.'); - return parsedJson; - } - if ('xprv' in parsedJson && 'ids' in parsedJson && 'mnemonic' in parsedJson) - return parsedJson as BapMasterBackup; - if ('rootPk' in parsedJson && 'ids' in parsedJson) return parsedJson as BapMasterBackup; - if ('wif' in parsedJson && 'id' in parsedJson) return parsedJson as BapAccountBackup; - // Check for YoursWalletBackup before OneSatBackup (more specific) - if ( - 'payPk' in parsedJson && - 'ordPk' in parsedJson && - ('mnemonic' in parsedJson || - 'payDerivationPath' in parsedJson || - 'ordDerivationPath' in parsedJson) - ) - return parsedJson as YoursWalletBackup; - // Check for YoursWalletZipBackup (parsed Yours Wallet ZIP) - if ( - 'chromeStorage' in parsedJson && - typeof parsedJson.chromeStorage === 'object' && - parsedJson.chromeStorage !== null - ) - return parsedJson as YoursWalletZipBackup; - if ('ordPk' in parsedJson && 'payPk' in parsedJson && 'identityPk' in parsedJson) - return parsedJson as OneSatBackup; - if ('encryptedVault' in parsedJson) return parsedJson as VaultBackup; - if ( - 'wif' in parsedJson && - !('id' in parsedJson) && - !('xprv' in parsedJson) && - !('rootPk' in parsedJson) - ) - return parsedJson as WifBackup; - } - throw new Error('Invalid backup structure after JSON parse.'); - } catch (jsonError) { - if (jsonError instanceof SyntaxError) return { wif: decryptedString } as WifBackup; - throw jsonError; - } + return parseDecryptedPayload(new TextDecoder().decode(decryptedArrayBuffer)); } catch (decryptionError) { lastError = decryptionError as Error; // console.log(`Decryption attempt failed with ${iterations} iterations.`); // Optional: for debugging diff --git a/src/ecies.ts b/src/ecies.ts new file mode 100644 index 0000000..a647b3a --- /dev/null +++ b/src/ecies.ts @@ -0,0 +1,170 @@ +import { Utils } from '@bsv/sdk'; + +const { toArray, toBase64 } = Utils; + +const INFO = 'se-vault-v1'; + +function hexToBytes(hex: string): Uint8Array { + return Uint8Array.from(toArray(hex, 'hex')); +} + +function bytesToHex(bytes: Uint8Array): string { + return Utils.toHex(Array.from(bytes)); +} + +function bytesToBase64Url(bytes: Uint8Array): string { + const b64 = toBase64(Array.from(bytes)); + return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/u, ''); +} + +export function assertP256PublicKeyHex(publicKeyHex: string, label: string): Uint8Array { + return validateUncompressedHex(publicKeyHex, label).bytes; +} + +function validateUncompressedHex( + publicKeyHex: string, + label: string +): { x: Uint8Array; y: Uint8Array; bytes: Uint8Array } { + if (typeof publicKeyHex !== 'string' || !/^[0-9a-fA-F]+$/u.test(publicKeyHex)) { + throw new Error(`${label}: public key must be hex.`); + } + if (publicKeyHex.length !== 130) { + throw new Error(`${label}: public key must be 65-byte X9.63 uncompressed hex (130 hex chars).`); + } + const bytes = hexToBytes(publicKeyHex); + if (bytes.length !== 65 || bytes[0] !== 0x04) { + throw new Error(`${label}: public key must be 65-byte X9.63 uncompressed (0x04 || X || Y).`); + } + return { x: bytes.slice(1, 33), y: bytes.slice(33, 65), bytes }; +} + +async function importEcdhPublicKey(publicKeyHex: string, label: string): Promise { + const { x, y } = validateUncompressedHex(publicKeyHex, label); + const jwk: JsonWebKey = { + kty: 'EC', + crv: 'P-256', + x: bytesToBase64Url(x), + y: bytesToBase64Url(y), + ext: true, + }; + try { + return await globalThis.crypto.subtle.importKey( + 'jwk', + jwk, + { name: 'ECDH', namedCurve: 'P-256' }, + false, + [] + ); + } catch (error) { + throw new Error(`${label}: invalid P-256 public key (${(error as Error).message}).`); + } +} + +async function deriveKek(sharedSecret: ArrayBuffer): Promise { + const hkdfKey = await globalThis.crypto.subtle.importKey( + 'raw', + sharedSecret, + { name: 'HKDF' }, + false, + ['deriveBits'] + ); + const kekBytes = await globalThis.crypto.subtle.deriveBits( + { + name: 'HKDF', + hash: 'SHA-256', + salt: new Uint8Array(0), + info: new TextEncoder().encode(INFO), + }, + hkdfKey, + 256 + ); + return globalThis.crypto.subtle.importKey( + 'raw', + kekBytes, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ); +} + +/** + * Encrypts plaintext for a P-256 ECDH recipient. + * Layout: ephemeralPub(65, X9.63) || nonce(12) || AES-GCM(KEK, contentKey) + tag(16). + * KEK = HKDF-SHA256(sharedSecret, salt=empty, info="se-vault-v1", 32). + */ +export async function eciesEncrypt( + recipientPublicKeyHex: string, + plaintextBytes: Uint8Array +): Promise { + const recipientPublicKey = await importEcdhPublicKey(recipientPublicKeyHex, 'eciesEncrypt'); + const ephemeral = (await globalThis.crypto.subtle.generateKey( + { name: 'ECDH', namedCurve: 'P-256' }, + true, + ['deriveBits'] + )) as CryptoKeyPair; + const ephemeralRaw = new Uint8Array( + await globalThis.crypto.subtle.exportKey('raw', ephemeral.publicKey) + ); + if (ephemeralRaw.length !== 65 || ephemeralRaw[0] !== 0x04) { + throw new Error('eciesEncrypt: failed to export ephemeral P-256 public key.'); + } + const sharedSecret = await globalThis.crypto.subtle.deriveBits( + { name: 'ECDH', public: recipientPublicKey }, + ephemeral.privateKey, + 256 + ); + const kek = await deriveKek(sharedSecret); + const nonce = globalThis.crypto.getRandomValues(new Uint8Array(12)); + const ciphertext = new Uint8Array( + await globalThis.crypto.subtle.encrypt( + { name: 'AES-GCM', iv: nonce as BufferSource }, + kek, + plaintextBytes as BufferSource + ) + ); + const wrapped = new Uint8Array(ephemeralRaw.length + nonce.length + ciphertext.length); + wrapped.set(ephemeralRaw, 0); + wrapped.set(nonce, ephemeralRaw.length); + wrapped.set(ciphertext, ephemeralRaw.length + nonce.length); + return wrapped; +} + +/** + * Decrypts an ECIES wrap produced by `eciesEncrypt`. + */ +export async function eciesDecrypt( + privateKey: CryptoKey, + wrapped: Uint8Array +): Promise { + if (!(wrapped instanceof Uint8Array)) { + throw new Error('eciesDecrypt: wrapped must be a Uint8Array.'); + } + if (wrapped.length < 65 + 12 + 16) { + throw new Error('eciesDecrypt: wrapped bytes are truncated.'); + } + const ephemeralBytes = wrapped.slice(0, 65); + const nonce = wrapped.slice(65, 65 + 12); + const ciphertext = wrapped.slice(65 + 12); + if (ephemeralBytes[0] !== 0x04) { + throw new Error('eciesDecrypt: ephemeral public key must be X9.63 uncompressed.'); + } + const ephemeralHex = bytesToHex(ephemeralBytes); + const ephemeralPublicKey = await importEcdhPublicKey(ephemeralHex, 'eciesDecrypt'); + let sharedSecret: ArrayBuffer; + try { + sharedSecret = await globalThis.crypto.subtle.deriveBits( + { name: 'ECDH', public: ephemeralPublicKey }, + privateKey, + 256 + ); + } catch (error) { + throw new Error(`eciesDecrypt: ECDH failed (${(error as Error).message}).`); + } + const kek = await deriveKek(sharedSecret); + const plaintext = await globalThis.crypto.subtle.decrypt( + { name: 'AES-GCM', iv: nonce as BufferSource }, + kek, + ciphertext as BufferSource + ); + return new Uint8Array(plaintext); +} diff --git a/src/envelope.ts b/src/envelope.ts new file mode 100644 index 0000000..95bfc71 --- /dev/null +++ b/src/envelope.ts @@ -0,0 +1,665 @@ +import { Utils } from '@bsv/sdk'; +import { + deriveKey, + isValidPayload, + parseDecryptedPayload, + RECOMMENDED_PBKDF2_ITERATIONS, +} from './crypto'; +import { assertP256PublicKeyHex, eciesEncrypt } from './ecies'; +import { isDerivationDescriptor } from './guards'; +import type { DecryptedBackup, DerivationDescriptor, EncryptedBackup } from './interfaces'; +import { isSigmaSeedBackup } from './seed'; + +const { toArray, toBase64 } = Utils; + +export const ENVELOPE_MAGIC = 'BEP2'; +export const ENVELOPE_VERSION = 0x02; +const MAGIC_BYTES = [0x42, 0x45, 0x50, 0x32]; +const SALT_LENGTH_BYTES = 16; +const IV_LENGTH_BYTES = 12; +const CONTENT_KEY_LENGTH_BYTES = 32; +const EPHEMERAL_PUB_LENGTH = 65; +const NONCE_LENGTH = 12; +const GCM_TAG_LENGTH = 16; + +const SLOT_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/u; + +export type Pbkdf2Slot = { + type: 'pbkdf2'; + id: string; + salt: string; + iterations: number; + wrapped: string; +}; + +export type DeviceP256Slot = { + type: 'device-p256'; + id: string; + publicKey: string; + wrapped: string; +}; + +export type Slot = Pbkdf2Slot | DeviceP256Slot; + +export interface EnvelopeHeader { + v: 2; + slots: Slot[]; + descriptor?: DerivationDescriptor; +} + +export type SlotSpec = + | { type: 'pbkdf2'; id: string; passphrase: string; iterations?: number } + | { type: 'device-p256'; id: string; publicKey: string }; + +export type Unlock = + | { passphrase: string } + | { slotId: string; passphrase: string } + | { slotId: string; unwrap: (wrapped: Uint8Array) => Promise }; + +export interface InspectResult { + version: 1 | 2; + slots: Array<{ type: string; id: string; publicKey?: string; iterations?: number }>; + descriptor?: DerivationDescriptor; +} + +function b64encode(bytes: Uint8Array): string { + return toBase64(Array.from(bytes)); +} + +function b64decode(b64: string, label: string): Uint8Array { + let numbers: number[]; + try { + numbers = toArray(b64, 'base64'); + } catch { + throw new Error(`${label}: invalid Base64.`); + } + return Uint8Array.from(numbers); +} + +export function validateSlotId(id: unknown): void { + if (typeof id !== 'string' || id.length < 1 || id.length > 63 || !SLOT_ID_RE.test(id)) { + throw new Error('Invalid slot id: must be 1-63 chars matching ^[a-zA-Z0-9][a-zA-Z0-9._-]*$.'); + } +} + +function validateIterations(iterations: unknown): void { + if ( + typeof iterations !== 'number' || + !Number.isSafeInteger(iterations) || + iterations < 1 || + iterations > 4294967295 + ) { + throw new Error('Invalid iterations: must be a positive 32-bit integer.'); + } +} + +function buildPayloadJson(payload: DecryptedBackup): string { + const payloadToEncrypt = { + ...payload, + createdAt: isSigmaSeedBackup(payload) + ? payload.createdAt + : (payload as { createdAt?: unknown }).createdAt || new Date().toISOString(), + }; + return JSON.stringify(payloadToEncrypt); +} + +function getDescriptorFromPayload(payload: DecryptedBackup): DerivationDescriptor | undefined { + const maybe = (payload as { derivation?: unknown }).derivation; + if (maybe === undefined) return undefined; + if (!isDerivationDescriptor(maybe)) { + throw new Error('Invalid derivation descriptor on payload.'); + } + return maybe; +} + +function validateSlotSpecShape(slot: SlotSpec, seen: Set): void { + if (!slot || typeof slot !== 'object') throw new Error('Invalid slot: must be an object.'); + if (slot.type !== 'pbkdf2' && slot.type !== 'device-p256') { + throw new Error(`Unknown slot type '${(slot as { type: unknown }).type}'.`); + } + validateSlotId(slot.id); + if (seen.has(slot.id)) throw new Error(`Duplicate slot id '${slot.id}'.`); + seen.add(slot.id); + if (slot.type === 'pbkdf2') { + if (typeof slot.passphrase !== 'string' || slot.passphrase.length === 0) { + throw new Error('Invalid passphrase: Passphrase must be a non-empty string.'); + } + if (slot.passphrase.length < 8) { + throw new Error('Invalid passphrase: Passphrase must be at least 8 characters long.'); + } + if (slot.iterations !== undefined) validateIterations(slot.iterations); + } else { + assertP256PublicKeyHex(slot.publicKey, 'Invalid device publicKey'); + } +} + +async function wrapContentKey(slot: SlotSpec, contentKey: Uint8Array): Promise { + if (slot.type === 'pbkdf2') { + const iterations = slot.iterations ?? RECOMMENDED_PBKDF2_ITERATIONS; + const salt = globalThis.crypto.getRandomValues(new Uint8Array(SALT_LENGTH_BYTES)); + const kek = await deriveKey(slot.passphrase, salt as Uint8Array, iterations); + const iv = globalThis.crypto.getRandomValues(new Uint8Array(IV_LENGTH_BYTES)); + const ct = new Uint8Array( + await globalThis.crypto.subtle.encrypt( + { name: 'AES-GCM', iv: iv as BufferSource }, + kek, + contentKey as BufferSource + ) + ); + const wrapped = new Uint8Array(iv.length + ct.length); + wrapped.set(iv, 0); + wrapped.set(ct, iv.length); + return { + type: 'pbkdf2', + id: slot.id, + salt: b64encode(salt), + iterations, + wrapped: b64encode(wrapped), + }; + } + const wrappedBytes = await eciesEncrypt(slot.publicKey, contentKey); + return { + type: 'device-p256', + id: slot.id, + publicKey: slot.publicKey.toLowerCase(), + wrapped: b64encode(wrappedBytes), + }; +} + +async function unwrapPbkdf2Slot(slot: Pbkdf2Slot, passphrase: string): Promise { + const salt = b64decode(slot.salt, 'pbkdf2 slot salt'); + if (salt.length !== SALT_LENGTH_BYTES) { + throw new Error('Malformed envelope header: pbkdf2 salt must decode to 16 bytes.'); + } + const wrapped = b64decode(slot.wrapped, 'pbkdf2 slot wrapped'); + if (wrapped.length < IV_LENGTH_BYTES + GCM_TAG_LENGTH) { + throw new Error('Malformed envelope header: pbkdf2 wrapped bytes are truncated.'); + } + const iv = wrapped.slice(0, IV_LENGTH_BYTES); + const ct = wrapped.slice(IV_LENGTH_BYTES); + const kek = await deriveKey(passphrase, salt as Uint8Array, slot.iterations); + const pt = await globalThis.crypto.subtle.decrypt( + { name: 'AES-GCM', iv: iv as BufferSource }, + kek, + ct as BufferSource + ); + return new Uint8Array(pt); +} + +export function decodeBase64Envelope(encrypted: EncryptedBackup): Uint8Array { + let numbers: number[]; + try { + numbers = toArray(encrypted, 'base64'); + } catch { + throw new Error('Decryption failed: Invalid Base64 input.'); + } + if (encrypted.length > 0 && numbers.length === 0) { + throw new Error('Decryption failed: Invalid Base64 input (decoded to empty).'); + } + return Uint8Array.from(numbers); +} + +export function hasV2Magic(decoded: Uint8Array): boolean { + return ( + decoded.length >= 5 && + decoded[0] === MAGIC_BYTES[0] && + decoded[1] === MAGIC_BYTES[1] && + decoded[2] === MAGIC_BYTES[2] && + decoded[3] === MAGIC_BYTES[3] + ); +} + +export function parseEnvelope(decoded: Uint8Array): { + header: EnvelopeHeader; + iv: Uint8Array; + ciphertext: Uint8Array; +} { + if (decoded.length < 7) { + throw new Error('Malformed envelope header: bytes are truncated.'); + } + if ( + decoded[0] !== MAGIC_BYTES[0] || + decoded[1] !== MAGIC_BYTES[1] || + decoded[2] !== MAGIC_BYTES[2] || + decoded[3] !== MAGIC_BYTES[3] + ) { + throw new Error('Malformed envelope header: missing BEP2 magic.'); + } + const version = decoded[4]; + if (version !== ENVELOPE_VERSION) { + throw new Error(`Unknown envelope version ${version}.`); + } + const hdrLen = (decoded[5] << 8) | decoded[6]; + if (7 + hdrLen > decoded.length) { + throw new Error( + `Malformed envelope header: header claims ${hdrLen} bytes but only ${decoded.length - 7} remain.` + ); + } + const headerBytes = decoded.slice(7, 7 + hdrLen); + let headerJson: unknown; + try { + headerJson = JSON.parse(new TextDecoder().decode(headerBytes)); + } catch { + throw new Error('Malformed envelope header: invalid JSON.'); + } + if (headerJson === null || typeof headerJson !== 'object' || Array.isArray(headerJson)) { + throw new Error('Malformed envelope header: header must be an object.'); + } + const h = headerJson as Record; + if (h.v !== 2) { + throw new Error(`Unknown envelope version ${String(h.v)}.`); + } + if (!Array.isArray(h.slots) || h.slots.length === 0) { + throw new Error('Malformed envelope header: slots must be a non-empty array.'); + } + if ('descriptor' in h && h.descriptor !== undefined && !isDerivationDescriptor(h.descriptor)) { + throw new Error('Malformed envelope header: invalid descriptor.'); + } + const seen = new Set(); + const slots: Slot[] = []; + for (const raw of h.slots) { + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) { + throw new Error('Malformed envelope header: slot must be an object.'); + } + const s = raw as Record; + if (s.type !== 'pbkdf2' && s.type !== 'device-p256') { + throw new Error(`Unknown slot type '${String(s.type)}'.`); + } + if (typeof s.id !== 'string' || s.id.length < 1 || s.id.length > 63 || !SLOT_ID_RE.test(s.id)) { + throw new Error('Malformed envelope header: invalid slot id.'); + } + if (seen.has(s.id)) { + throw new Error(`Malformed envelope header: duplicate slot id '${s.id}'.`); + } + seen.add(s.id); + if (s.type === 'pbkdf2') { + if (typeof s.salt !== 'string' || typeof s.wrapped !== 'string') { + throw new Error('Malformed envelope header: pbkdf2 slot missing salt/wrapped.'); + } + if ( + typeof s.iterations !== 'number' || + !Number.isSafeInteger(s.iterations) || + s.iterations < 1 || + s.iterations > 4294967295 + ) { + throw new Error('Malformed envelope header: invalid pbkdf2 iterations.'); + } + let saltBytes: Uint8Array; + let wrappedBytes: Uint8Array; + try { + saltBytes = b64decode(s.salt, 'pbkdf2 slot salt'); + } catch { + throw new Error('Malformed envelope header: pbkdf2 salt is not valid Base64.'); + } + if (saltBytes.length !== SALT_LENGTH_BYTES) { + throw new Error('Malformed envelope header: pbkdf2 salt must decode to 16 bytes.'); + } + try { + wrappedBytes = b64decode(s.wrapped, 'pbkdf2 slot wrapped'); + } catch { + throw new Error('Malformed envelope header: pbkdf2 wrapped is not valid Base64.'); + } + if (wrappedBytes.length < IV_LENGTH_BYTES + GCM_TAG_LENGTH) { + throw new Error('Malformed envelope header: pbkdf2 wrapped bytes are truncated.'); + } + slots.push({ + type: 'pbkdf2', + id: s.id, + salt: s.salt, + iterations: s.iterations, + wrapped: s.wrapped, + }); + } else { + if (typeof s.publicKey !== 'string' || typeof s.wrapped !== 'string') { + throw new Error('Malformed envelope header: device-p256 slot missing publicKey/wrapped.'); + } + try { + assertP256PublicKeyHex(s.publicKey, 'Invalid device publicKey'); + } catch (error) { + throw new Error(`Malformed envelope header: ${(error as Error).message}`); + } + let wrappedBytes: Uint8Array; + try { + wrappedBytes = b64decode(s.wrapped, 'device-p256 slot wrapped'); + } catch { + throw new Error('Malformed envelope header: device-p256 wrapped is not valid Base64.'); + } + if (wrappedBytes.length < EPHEMERAL_PUB_LENGTH + NONCE_LENGTH + GCM_TAG_LENGTH) { + throw new Error('Malformed envelope header: device-p256 wrapped bytes are truncated.'); + } + if (wrappedBytes[0] !== 0x04) { + throw new Error( + 'Malformed envelope header: device-p256 ephemeral key must be 0x04-prefixed.' + ); + } + slots.push({ type: 'device-p256', id: s.id, publicKey: s.publicKey, wrapped: s.wrapped }); + } + } + const rest = decoded.slice(7 + hdrLen); + if (rest.length < IV_LENGTH_BYTES + GCM_TAG_LENGTH) { + throw new Error('Malformed envelope: bytes are truncated.'); + } + const iv = rest.slice(0, IV_LENGTH_BYTES); + const ciphertext = rest.slice(IV_LENGTH_BYTES); + const header: EnvelopeHeader = { + v: 2, + slots, + ...(h.descriptor !== undefined ? { descriptor: h.descriptor as DerivationDescriptor } : {}), + }; + return { header, iv, ciphertext }; +} + +function encodeEnvelope( + header: EnvelopeHeader, + iv: Uint8Array, + ciphertext: Uint8Array +): EncryptedBackup { + const headerJson = JSON.stringify(header); + const headerBytes = new TextEncoder().encode(headerJson); + if (headerBytes.length > 65535) { + throw new Error('Envelope header too large.'); + } + const out = new Uint8Array(4 + 1 + 2 + headerBytes.length + iv.length + ciphertext.length); + out[0] = MAGIC_BYTES[0]; + out[1] = MAGIC_BYTES[1]; + out[2] = MAGIC_BYTES[2]; + out[3] = MAGIC_BYTES[3]; + out[4] = ENVELOPE_VERSION; + out[5] = (headerBytes.length >> 8) & 0xff; + out[6] = headerBytes.length & 0xff; + out.set(headerBytes, 7); + out.set(iv, 7 + headerBytes.length); + out.set(ciphertext, 7 + headerBytes.length + iv.length); + return b64encode(out); +} + +async function importContentKey(contentKey: Uint8Array, usages: KeyUsage[]): Promise { + if (contentKey.length !== CONTENT_KEY_LENGTH_BYTES) { + throw new Error('Invalid content key length.'); + } + return globalThis.crypto.subtle.importKey( + 'raw', + contentKey as BufferSource, + { name: 'AES-GCM', length: 256 }, + false, + usages + ); +} + +async function resolveContentKey(header: EnvelopeHeader, unlock: Unlock): Promise { + if ('unwrap' in unlock && 'slotId' in unlock) { + const slot = header.slots.find((s) => s.id === unlock.slotId); + if (!slot) throw new Error(`Slot '${unlock.slotId}' not found.`); + const wrapped = b64decode(slot.wrapped, 'slot wrapped'); + const contentKey = await unlock.unwrap(wrapped); + if (!(contentKey instanceof Uint8Array) || contentKey.length !== CONTENT_KEY_LENGTH_BYTES) { + throw new Error('Invalid unwrap result: must return 32-byte content key.'); + } + return contentKey; + } + if ('slotId' in unlock && 'passphrase' in unlock) { + const slot = header.slots.find((s) => s.id === unlock.slotId); + if (!slot) throw new Error(`Slot '${unlock.slotId}' not found.`); + if (slot.type !== 'pbkdf2') { + throw new Error(`Slot '${unlock.slotId}' is not a pbkdf2 slot.`); + } + try { + const contentKey = await unwrapPbkdf2Slot(slot, unlock.passphrase); + if (contentKey.length !== CONTENT_KEY_LENGTH_BYTES) { + throw new Error('Decryption failed: Invalid content key.'); + } + return contentKey; + } catch (error) { + if (error instanceof DOMException && error.name === 'OperationError') { + throw new Error('Decryption failed: Invalid passphrase or corrupted data.'); + } + throw error; + } + } + if ('passphrase' in unlock && !('slotId' in unlock)) { + const pbkdf2Slots = header.slots.filter((s): s is Pbkdf2Slot => s.type === 'pbkdf2'); + if (pbkdf2Slots.length === 0) { + throw new Error('Decryption failed: No pbkdf2 slot available.'); + } + for (const slot of pbkdf2Slots) { + try { + const contentKey = await unwrapPbkdf2Slot(slot, unlock.passphrase); + if (contentKey.length === CONTENT_KEY_LENGTH_BYTES) return contentKey; + } catch (error) { + if (error instanceof DOMException && error.name === 'OperationError') continue; + throw error; + } + } + throw new Error('Decryption failed: Invalid passphrase or corrupted data.'); + } + throw new Error('Invalid unlock: must provide passphrase or slotId+unwrap.'); +} + +async function decryptPayload( + contentKey: Uint8Array, + iv: Uint8Array, + ciphertext: Uint8Array +): Promise { + const key = await importContentKey(contentKey, ['decrypt']); + let pt: ArrayBuffer; + try { + pt = await globalThis.crypto.subtle.decrypt( + { name: 'AES-GCM', iv: iv as BufferSource }, + key, + ciphertext as BufferSource + ); + } catch (error) { + if (error instanceof DOMException && error.name === 'OperationError') { + throw new Error('Decryption failed: Invalid passphrase or corrupted data.'); + } + throw error; + } + return parseDecryptedPayload(new TextDecoder().decode(pt)); +} + +export async function sealBackup( + payload: DecryptedBackup, + slots: SlotSpec[] +): Promise { + if (!isValidPayload(payload)) { + throw new Error( + 'Invalid payload: Payload must be an object matching SigmaSeedBackup, BapMasterBackup, BapAccountBackup, WifBackup, OneSatBackup, VaultBackup, YoursWalletBackup, or YoursWalletZipBackup structure.' + ); + } + if (!Array.isArray(slots) || slots.length === 0) { + throw new Error('Invalid slots: at least one slot is required.'); + } + const seen = new Set(); + for (const s of slots) validateSlotSpecShape(s, seen); + const descriptor = getDescriptorFromPayload(payload); + const payloadJson = buildPayloadJson(payload); + const payloadBytes = new TextEncoder().encode(payloadJson); + const contentKey = globalThis.crypto.getRandomValues(new Uint8Array(CONTENT_KEY_LENGTH_BYTES)); + const wrappedSlots: Slot[] = []; + for (const s of slots) { + wrappedSlots.push(await wrapContentKey(s, contentKey)); + } + const header: EnvelopeHeader = { + v: 2, + slots: wrappedSlots, + ...(descriptor !== undefined ? { descriptor } : {}), + }; + const iv = globalThis.crypto.getRandomValues(new Uint8Array(IV_LENGTH_BYTES)); + const key = await importContentKey(contentKey, ['encrypt']); + const ct = new Uint8Array( + await globalThis.crypto.subtle.encrypt( + { name: 'AES-GCM', iv: iv as BufferSource }, + key, + payloadBytes as BufferSource + ) + ); + return encodeEnvelope(header, iv, ct); +} + +export async function openBackup( + encrypted: EncryptedBackup, + unlock: Unlock +): Promise { + if (typeof encrypted !== 'string' || encrypted.length === 0) { + throw new Error('Invalid encryptedString: Must be a non-empty string.'); + } + const decoded = decodeBase64Envelope(encrypted); + if (!hasV2Magic(decoded)) { + throw new Error('Invalid envelope: not a v2 envelope.'); + } + const { header, iv, ciphertext } = parseEnvelope(decoded); + const contentKey = await resolveContentKey(header, unlock); + return decryptPayload(contentKey, iv, ciphertext); +} + +export async function openV2WithPassphrase( + encrypted: EncryptedBackup, + passphrase: string, + attemptIterations?: number | number[] +): Promise { + const decoded = decodeBase64Envelope(encrypted); + const { header, iv, ciphertext } = parseEnvelope(decoded); + let allowed: number[] | undefined; + if (typeof attemptIterations === 'number') allowed = [attemptIterations]; + else if (Array.isArray(attemptIterations)) allowed = attemptIterations; + const candidates = header.slots.filter((s): s is Pbkdf2Slot => s.type === 'pbkdf2'); + const filtered = + allowed === undefined ? candidates : candidates.filter((s) => allowed.includes(s.iterations)); + if (filtered.length === 0) { + if (allowed !== undefined && candidates.length > 0) { + throw new Error('Decryption failed: No v2 slot matches the attempted iterations.'); + } + throw new Error('Decryption failed: Invalid passphrase or corrupted data.'); + } + for (const slot of filtered) { + try { + const contentKey = await unwrapPbkdf2Slot(slot, passphrase); + if (contentKey.length !== CONTENT_KEY_LENGTH_BYTES) continue; + return await decryptPayload(contentKey, iv, ciphertext); + } catch (error) { + if (error instanceof DOMException && error.name === 'OperationError') continue; + if ( + error instanceof Error && + error.message === 'Decryption failed: Invalid passphrase or corrupted data.' + ) { + continue; + } + throw error; + } + } + throw new Error('Decryption failed: Invalid passphrase or corrupted data.'); +} + +export function inspectEnvelope(encrypted: EncryptedBackup): InspectResult { + if (typeof encrypted !== 'string' || encrypted.length === 0) { + throw new Error('Invalid encryptedString: Must be a non-empty string.'); + } + const decoded = decodeBase64Envelope(encrypted); + if (!hasV2Magic(decoded)) { + return { version: 1, slots: [] }; + } + const { header } = parseEnvelope(decoded); + return { + version: 2, + slots: header.slots.map((s) => + s.type === 'pbkdf2' + ? { type: s.type, id: s.id, iterations: s.iterations } + : { type: s.type, id: s.id, publicKey: s.publicKey } + ), + ...(header.descriptor !== undefined ? { descriptor: header.descriptor } : {}), + }; +} + +export function isEnvelopeV2(encrypted: EncryptedBackup): boolean { + try { + if (typeof encrypted !== 'string' || encrypted.length === 0) return false; + const decoded = decodeBase64Envelope(encrypted); + if (!hasV2Magic(decoded)) return false; + parseEnvelope(decoded); + return true; + } catch { + return false; + } +} + +export async function addSlot( + encrypted: EncryptedBackup, + unlock: Unlock, + slot: SlotSpec +): Promise { + const decoded = decodeBase64Envelope(encrypted); + if (!hasV2Magic(decoded)) throw new Error('Invalid envelope: not a v2 envelope.'); + const { header, iv, ciphertext } = parseEnvelope(decoded); + validateSlotSpecShape(slot, new Set(header.slots.map((s) => s.id))); + const contentKey = await resolveContentKey(header, unlock); + const wrapped = await wrapContentKey(slot, contentKey); + const newHeader: EnvelopeHeader = { + v: 2, + slots: [...header.slots, wrapped], + ...(header.descriptor !== undefined ? { descriptor: header.descriptor } : {}), + }; + return encodeEnvelope(newHeader, iv, ciphertext); +} + +export async function removeSlot( + encrypted: EncryptedBackup, + unlock: Unlock, + slotId: string +): Promise { + const decoded = decodeBase64Envelope(encrypted); + if (!hasV2Magic(decoded)) throw new Error('Invalid envelope: not a v2 envelope.'); + const { header, iv, ciphertext } = parseEnvelope(decoded); + if (!header.slots.some((s) => s.id === slotId)) { + throw new Error(`Slot '${slotId}' not found.`); + } + if (header.slots.length <= 1) { + throw new Error('Cannot remove the last slot.'); + } + await resolveContentKey(header, unlock); + const newHeader: EnvelopeHeader = { + v: 2, + slots: header.slots.filter((s) => s.id !== slotId), + ...(header.descriptor !== undefined ? { descriptor: header.descriptor } : {}), + }; + return encodeEnvelope(newHeader, iv, ciphertext); +} + +export async function rewrapBackup( + encrypted: EncryptedBackup, + unlock: Unlock, + slots: SlotSpec[] +): Promise { + if (!Array.isArray(slots) || slots.length === 0) { + throw new Error('Invalid slots: at least one slot is required.'); + } + const seen = new Set(); + for (const s of slots) validateSlotSpecShape(s, seen); + const decoded = decodeBase64Envelope(encrypted); + if (!hasV2Magic(decoded)) throw new Error('Invalid envelope: not a v2 envelope.'); + const { header, iv: oldIv, ciphertext: oldCiphertext } = parseEnvelope(decoded); + const oldContentKey = await resolveContentKey(header, unlock); + const payload = await decryptPayload(oldContentKey, oldIv, oldCiphertext); + const payloadJson = JSON.stringify(payload); + const payloadBytes = new TextEncoder().encode(payloadJson); + const newContentKey = globalThis.crypto.getRandomValues(new Uint8Array(CONTENT_KEY_LENGTH_BYTES)); + const wrappedSlots: Slot[] = []; + for (const s of slots) { + wrappedSlots.push(await wrapContentKey(s, newContentKey)); + } + const descriptor = getDescriptorFromPayload(payload); + const newHeader: EnvelopeHeader = { + v: 2, + slots: wrappedSlots, + ...(descriptor !== undefined ? { descriptor } : {}), + }; + const iv = globalThis.crypto.getRandomValues(new Uint8Array(IV_LENGTH_BYTES)); + const key = await importContentKey(newContentKey, ['encrypt']); + const ct = new Uint8Array( + await globalThis.crypto.subtle.encrypt( + { name: 'AES-GCM', iv: iv as BufferSource }, + key, + payloadBytes as BufferSource + ) + ); + return encodeEnvelope(newHeader, iv, ct); +} diff --git a/src/guards.ts b/src/guards.ts index 76a39aa..7b2bf2e 100644 --- a/src/guards.ts +++ b/src/guards.ts @@ -7,6 +7,7 @@ import type { BapMasterBackup, BapMasterBackupLegacy, DecryptedBackup, + DerivationDescriptor, MasterBackupType42, OneSatBackup, VaultBackup, @@ -15,6 +16,27 @@ import type { YoursWalletZipBackup, } from './interfaces'; +/** + * Type guard: checks if the value is a valid DerivationDescriptor. + */ +export function isDerivationDescriptor(value: unknown): value is DerivationDescriptor { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return false; + const v = value as Record; + const allowed = new Set(['scheme', 'path', 'parentIdentityKey', 'index', 'cohort']); + for (const key of Object.keys(v)) { + if (!allowed.has(key)) return false; + } + const schemes = new Set(['brc157', 'bip32', 'type42', 'brc42', 'legacy-bip32-unhardened']); + if (typeof v.scheme !== 'string' || !schemes.has(v.scheme)) return false; + if ('path' in v && typeof v.path !== 'string') return false; + if ('parentIdentityKey' in v && typeof v.parentIdentityKey !== 'string') return false; + if ('index' in v) { + if (typeof v.index !== 'number' || !Number.isSafeInteger(v.index) || v.index < 0) return false; + } + if ('cohort' in v && typeof v.cohort !== 'string') return false; + return true; +} + /** * Type guard: checks if the backup is a legacy BAP master backup (xprv + mnemonic). */ diff --git a/src/index.ts b/src/index.ts index 3dbb01a..81e05d5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,99 +1,29 @@ -import { decryptData, encryptData } from './crypto'; -import type { - DecryptedBackup, - EncryptedBackup, - // BapMasterBackup, // Removed as it's covered by export * - // BapAccountBackup, // Removed as it's covered by export * - // WifBackup // Removed as it's covered by export * -} from './interfaces'; -import { hasSigmaSeedMarker, isSigmaSeedBackup } from './seed'; +import { decryptData, encryptData, isValidPayload } from './crypto'; +import { decodeBase64Envelope, hasV2Magic, openV2WithPassphrase } from './envelope'; +import type { DecryptedBackup, EncryptedBackup } from './interfaces'; + +export { + addSlot, + type DeviceP256Slot, + type EnvelopeHeader, + type InspectResult, + inspectEnvelope, + isEnvelopeV2, + openBackup, + type Pbkdf2Slot, + removeSlot, + rewrapBackup, + type Slot, + type SlotSpec, + sealBackup, + type Unlock, +} from './envelope'; /** * Validates the structure of a payload intended for encryption. * @param payload The payload to validate. * @returns True if the payload is valid, false otherwise. */ -function isValidPayload(payload: unknown): payload is DecryptedBackup { - if (!payload || typeof payload !== 'object') return false; - - // Narrow down type for property checks - const p = payload as Record; - if (hasSigmaSeedMarker(p)) return isSigmaSeedBackup(p); - - // Check for BapMasterBackup structure (legacy XPRV format) - if ( - 'xprv' in p && - typeof p.xprv === 'string' && - 'ids' in p && - typeof p.ids === 'string' && - 'mnemonic' in p && - typeof p.mnemonic === 'string' - ) { - return true; - } - - // Check for BapMasterBackup structure (Type 42 format) - if ( - 'rootPk' in p && - typeof p.rootPk === 'string' && - 'ids' in p && - typeof p.ids === 'string' && - !('xprv' in p) // Ensure it's not a legacy format - ) { - return true; - } - - // Check for BapAccountBackup structure - if ('wif' in p && typeof p.wif === 'string' && 'id' in p && typeof p.id === 'string') { - return true; - } - - // Check for WifBackup structure - if ( - 'wif' in p && - typeof p.wif === 'string' && - !('id' in p) && // Differentiates from BapAccountBackup - !('xprv' in p) && // Differentiates from BapMasterBackupLegacy - !('rootPk' in p) // Differentiates from MasterBackupType42 - ) { - return true; - } - - // Check for OneSatBackup structure - if ( - 'ordPk' in p && - typeof p.ordPk === 'string' && - 'payPk' in p && - typeof p.payPk === 'string' && - 'identityPk' in p && - typeof p.identityPk === 'string' - ) { - return true; - } - - // Check for VaultBackup structure - just needs encryptedVault - if ('encryptedVault' in p && typeof p.encryptedVault === 'string') { - return true; - } - - // Check for YoursWalletBackup structure - has payPk and ordPk like OneSat, but may have mnemonic - if ( - 'payPk' in p && - typeof p.payPk === 'string' && - 'ordPk' in p && - typeof p.ordPk === 'string' && - ('mnemonic' in p || 'payDerivationPath' in p || 'ordDerivationPath' in p) // Distinguishes from OneSatBackup - ) { - return true; - } - - // Check for YoursWalletZipBackup structure (parsed Yours Wallet ZIP) - if ('chromeStorage' in p && typeof p.chromeStorage === 'object' && p.chromeStorage !== null) { - return true; - } - - return false; -} /** * Encrypts a backup payload object into an encrypted string. @@ -145,6 +75,15 @@ export async function decryptBackup( if (typeof passphrase !== 'string' || passphrase.length === 0) { throw new Error('Invalid passphrase: Passphrase must be a non-empty string.'); } + let decoded: Uint8Array | null = null; + try { + decoded = decodeBase64Envelope(encryptedString); + } catch { + decoded = null; + } + if (decoded && hasV2Magic(decoded)) { + return openV2WithPassphrase(encryptedString, passphrase, attemptIterations); + } return decryptData(encryptedString, passphrase, attemptIterations); } @@ -154,6 +93,8 @@ export { LEGACY_PBKDF2_ITERATIONS, RECOMMENDED_PBKDF2_ITERATIONS, } from './crypto'; +// Re-export ECIES helpers for device-key slots +export { eciesDecrypt, eciesEncrypt } from './ecies'; // Re-export type guards for backup type detection export * from './guards'; // Re-export interfaces for library consumers diff --git a/src/interfaces.ts b/src/interfaces.ts index b2a2a0e..f5d9fd0 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,9 +1,18 @@ +export interface DerivationDescriptor { + scheme: 'brc157' | 'bip32' | 'type42' | 'brc42' | 'legacy-bip32-unhardened'; + path?: string; // e.g. "m/0'/1'" for brc157 profiles + parentIdentityKey?: string; // compressed pubkey hex of the parent root, if derived + index?: number; + cohort?: string; // free label for legacy derivation cohorts +} + export interface BapMasterBackupLegacy { ids: string; // Encrypted data from bsv-bap's bap.exportIds() xprv: string; // Master extended private key mnemonic: string; // BIP39 mnemonic phrase label?: string; // User-defined label (optional) createdAt?: string; // ISO 8601 timestamp (populated by encryptBackup if not provided) + derivation?: DerivationDescriptor; } export interface MasterBackupType42 { @@ -11,6 +20,7 @@ export interface MasterBackupType42 { rootPk: string; // Master private key in WIF format (Type 42) label?: string; // User-defined label (optional) createdAt?: string; // ISO 8601 timestamp (populated by encryptBackup if not provided) + derivation?: DerivationDescriptor; } // Main interface that users import - supports both legacy and Type 42 formats @@ -21,12 +31,14 @@ export interface BapAccountBackup { id: string; // BAP ID for this account label?: string; // User-defined label (optional) createdAt?: string; // ISO 8601 timestamp (populated by encryptBackup if not provided) + derivation?: DerivationDescriptor; } export interface WifBackup { wif: string; label?: string; // User-defined label (optional) createdAt?: string; // ISO 8601 timestamp (populated by encryptBackup if a new WifBackup object is passed) + derivation?: DerivationDescriptor; } export interface OneSatBackup { diff --git a/test/envelope-v2.test.ts b/test/envelope-v2.test.ts new file mode 100644 index 0000000..c764482 --- /dev/null +++ b/test/envelope-v2.test.ts @@ -0,0 +1,388 @@ +import { describe, expect, it } from 'bun:test'; +import { readdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { Utils } from '@bsv/sdk'; +import { decodeBase64Envelope, hasV2Magic, parseEnvelope } from '../src/envelope'; +import { + addSlot, + decryptBackup, + eciesDecrypt, + encryptBackup, + inspectEnvelope, + isDerivationDescriptor, + isEnvelopeV2, + isWifBackup, + openBackup, + removeSlot, + rewrapBackup, + sealBackup, + type WifBackup, +} from '../src/index'; + +const passphraseA = 'strongPassphraseA123!'; +const passphraseB = 'otherPassphraseB456!'; + +const wifPayload: WifBackup = { + wif: 'L4rprVahLjG4LWdULUeoxaVyq9chGQzg8kSVgSWfBrdeyAZs9VLo', +}; + +async function deviceKeypair() { + const kp = (await globalThis.crypto.subtle.generateKey( + { name: 'ECDH', namedCurve: 'P-256' }, + true, + ['deriveBits'] + )) as CryptoKeyPair; + const raw = new Uint8Array(await globalThis.crypto.subtle.exportKey('raw', kp.publicKey)); + const hex = Utils.toHex(Array.from(raw)); + return { kp, hex }; +} + +describe('envelope v2 seal/open', () => { + it('round trips with one pbkdf2 slot', async () => { + const enc = await sealBackup(wifPayload, [ + { type: 'pbkdf2', id: 'main', passphrase: passphraseA }, + ]); + expect(isEnvelopeV2(enc)).toBe(true); + expect(inspectEnvelope(enc)).toMatchObject({ version: 2 }); + const dec = (await openBackup(enc, { passphrase: passphraseA })) as WifBackup; + expect(dec.wif).toBe(wifPayload.wif); + expect(dec.createdAt).toBeDefined(); + }); + + it('round trips with two pbkdf2 slots', async () => { + const enc = await sealBackup(wifPayload, [ + { type: 'pbkdf2', id: 'a', passphrase: passphraseA }, + { type: 'pbkdf2', id: 'b', passphrase: passphraseB }, + ]); + const decA = (await openBackup(enc, { slotId: 'a', passphrase: passphraseA })) as WifBackup; + const decB = (await openBackup(enc, { slotId: 'b', passphrase: passphraseB })) as WifBackup; + expect(decA.wif).toBe(wifPayload.wif); + expect(decB.wif).toBe(wifPayload.wif); + const decAny = (await openBackup(enc, { passphrase: passphraseB })) as WifBackup; + expect(decAny.wif).toBe(wifPayload.wif); + }); + + it('round trips with one device-p256 slot', async () => { + const { kp, hex } = await deviceKeypair(); + const enc = await sealBackup(wifPayload, [{ type: 'device-p256', id: 'dev1', publicKey: hex }]); + const info = inspectEnvelope(enc); + expect(info.version).toBe(2); + expect(info.slots[0].type).toBe('device-p256'); + expect(info.slots[0].publicKey).toBe(hex.toLowerCase()); + const dec = (await openBackup(enc, { + slotId: 'dev1', + unwrap: (wrapped) => eciesDecrypt(kp.privateKey, wrapped), + })) as WifBackup; + expect(dec.wif).toBe(wifPayload.wif); + }); + + it('round trips mixed pbkdf2 + device-p256', async () => { + const { kp, hex } = await deviceKeypair(); + const enc = await sealBackup(wifPayload, [ + { type: 'pbkdf2', id: 'pw', passphrase: passphraseA }, + { type: 'device-p256', id: 'dev', publicKey: hex }, + ]); + expect(inspectEnvelope(enc).slots.length).toBe(2); + const viaPw = (await openBackup(enc, { passphrase: passphraseA })) as WifBackup; + expect(viaPw.wif).toBe(wifPayload.wif); + const viaDev = (await openBackup(enc, { + slotId: 'dev', + unwrap: (w) => eciesDecrypt(kp.privateKey, w), + })) as WifBackup; + expect(viaDev.wif).toBe(wifPayload.wif); + }); + + it('decryptBackup opens v2 with pbkdf2 slot; wrong passphrase throws', async () => { + const enc = await sealBackup(wifPayload, [ + { type: 'pbkdf2', id: 'main', passphrase: passphraseA }, + ]); + const dec = (await decryptBackup(enc, passphraseA)) as WifBackup; + expect(dec.wif).toBe(wifPayload.wif); + await expect(decryptBackup(enc, 'wrongPassphrase123')).rejects.toThrow( + /Decryption failed: Invalid passphrase/ + ); + }); + + it('preserves createdAt rule like v1', async () => { + const dated = { ...wifPayload, createdAt: '2023-01-03T00:00:00.000Z' }; + const enc = await sealBackup(dated, [{ type: 'pbkdf2', id: 'a', passphrase: passphraseA }]); + const dec = (await openBackup(enc, { passphrase: passphraseA })) as WifBackup; + expect(dec.createdAt).toBe(dated.createdAt); + }); +}); + +describe('envelope v2 slot management', () => { + it('addSlot then open with the new slot', async () => { + const enc = await sealBackup(wifPayload, [ + { type: 'pbkdf2', id: 'a', passphrase: passphraseA }, + ]); + const enc2 = await addSlot( + enc, + { passphrase: passphraseA }, + { + type: 'pbkdf2', + id: 'b', + passphrase: passphraseB, + } + ); + const dec = (await openBackup(enc2, { slotId: 'b', passphrase: passphraseB })) as WifBackup; + expect(dec.wif).toBe(wifPayload.wif); + expect(inspectEnvelope(enc2).slots.length).toBe(2); + }); + + it('removeSlot refuses the last slot', async () => { + const enc = await sealBackup(wifPayload, [ + { type: 'pbkdf2', id: 'only', passphrase: passphraseA }, + ]); + await expect(removeSlot(enc, { passphrase: passphraseA }, 'only')).rejects.toThrow(/last slot/); + const enc2 = await addSlot( + enc, + { passphrase: passphraseA }, + { + type: 'pbkdf2', + id: 'second', + passphrase: passphraseB, + } + ); + const enc3 = await removeSlot(enc2, { passphrase: passphraseA }, 'second'); + expect(inspectEnvelope(enc3).slots.length).toBe(1); + const dec = (await openBackup(enc3, { passphrase: passphraseA })) as WifBackup; + expect(dec.wif).toBe(wifPayload.wif); + }); + + it('rewrapBackup changes the content key (ciphertext differs, payload identical)', async () => { + const enc = await sealBackup(wifPayload, [ + { type: 'pbkdf2', id: 'a', passphrase: passphraseA }, + ]); + const payloadBefore = await openBackup(enc, { passphrase: passphraseA }); + const enc2 = await rewrapBackup(enc, { passphrase: passphraseA }, [ + { type: 'pbkdf2', id: 'fresh', passphrase: passphraseB }, + ]); + expect(enc2).not.toBe(enc); + const payloadAfter = await openBackup(enc2, { passphrase: passphraseB }); + expect(payloadAfter).toEqual(payloadBefore); + const rawBefore = decodeBase64Envelope(enc); + const rawAfter = decodeBase64Envelope(enc2); + const parsedBefore = parseEnvelope(rawBefore); + const parsedAfter = parseEnvelope(rawAfter); + // Ciphertext (payload encryption) must differ because contentKey is new. + expect(Utils.toHex(Array.from(parsedAfter.ciphertext))).not.toBe( + Utils.toHex(Array.from(parsedBefore.ciphertext)) + ); + }); +}); + +describe('envelope v2 byte layout fixture', () => { + it('parses fixed device-p256 vector offsets and decrypts with private JWK', async () => { + const fixturePath = join(import.meta.dir, 'fixtures', 'envelope-v2', 'device-vector.json'); + const fixture = JSON.parse(await readFile(fixturePath, 'utf8')) as { + recipientPrivateJwk: JsonWebKey; + recipientPublicKeyHex: string; + contentKeyHex: string; + wrappedBase64: string; + ephemeralPubHex: string; + nonceHex: string; + ciphertextHex: string; + }; + const wrapped = Uint8Array.from(Utils.toArray(fixture.wrappedBase64, 'base64')); + expect(wrapped.length).toBeGreaterThanOrEqual(65 + 12 + 16); + const ephemeral = wrapped.slice(0, 65); + const nonce = wrapped.slice(65, 77); + const ciphertext = wrapped.slice(77); + expect(Utils.toHex(Array.from(ephemeral)).toLowerCase()).toBe( + fixture.ephemeralPubHex.toLowerCase() + ); + expect(Utils.toHex(Array.from(nonce)).toLowerCase()).toBe(fixture.nonceHex.toLowerCase()); + expect(Utils.toHex(Array.from(ciphertext)).toLowerCase()).toBe( + fixture.ciphertextHex.toLowerCase() + ); + const privateKey = await globalThis.crypto.subtle.importKey( + 'jwk', + fixture.recipientPrivateJwk, + { name: 'ECDH', namedCurve: 'P-256' }, + true, + ['deriveBits'] + ); + const plaintext = await eciesDecrypt(privateKey, wrapped); + expect(Utils.toHex(Array.from(plaintext)).toLowerCase()).toBe( + fixture.contentKeyHex.toLowerCase() + ); + }); + + it('v2 envelope byte layout has magic, version, header length, iv and ciphertext', async () => { + const enc = await sealBackup(wifPayload, [ + { type: 'pbkdf2', id: 'layout', passphrase: passphraseA }, + ]); + const raw = decodeBase64Envelope(enc); + expect(raw[0]).toBe(0x42); + expect(raw[1]).toBe(0x45); + expect(raw[2]).toBe(0x50); + expect(raw[3]).toBe(0x32); + expect(raw[4]).toBe(0x02); + const hdrLen = (raw[5] << 8) | raw[6]; + expect(hasV2Magic(raw)).toBe(true); + const { header, iv, ciphertext } = parseEnvelope(raw); + expect(header.v).toBe(2); + expect(header.slots.length).toBe(1); + expect(iv.length).toBe(12); + expect(ciphertext.length).toBeGreaterThan(16); + expect(7 + hdrLen + 12 + ciphertext.length).toBe(raw.length); + }); +}); + +describe('envelope v2 malformed inputs throw before subtle.decrypt', () => { + async function expectBeforeDecrypt(fn: () => Promise, pattern: RegExp) { + const original = globalThis.crypto.subtle.decrypt; + let called = false; + // @ts-expect-error spy + globalThis.crypto.subtle.decrypt = async (...args: unknown[]) => { + called = true; + // @ts-expect-error delegate + return original.apply(globalThis.crypto.subtle, args); + }; + try { + await expect(fn()).rejects.toThrow(pattern); + expect(called).toBe(false); + } finally { + globalThis.crypto.subtle.decrypt = original; + } + } + + it('malformed header throws', async () => { + const enc = await sealBackup(wifPayload, [ + { type: 'pbkdf2', id: 'a', passphrase: passphraseA }, + ]); + const raw = decodeBase64Envelope(enc); + const hdrLen = (raw[5] << 8) | raw[6]; + // Corrupt header JSON (flip a byte inside header). + const corrupted = new Uint8Array(raw); + corrupted[10] = corrupted[10] ^ 0xff; + const corruptedB64 = Utils.toBase64(Array.from(corrupted)); + // Header JSON corruption may either fail parse or fail slot validation; both must throw before decrypt. + // Use a definitely-invalid JSON header instead for determinism. + const badHeader = new TextEncoder().encode('{not-json'); + const out = new Uint8Array(4 + 1 + 2 + badHeader.length + 12 + 32); + out.set([0x42, 0x45, 0x50, 0x32, 0x02], 0); + out[5] = (badHeader.length >> 8) & 0xff; + out[6] = badHeader.length & 0xff; + out.set(badHeader, 7); + const badB64 = Utils.toBase64(Array.from(out)); + await expectBeforeDecrypt( + () => openBackup(badB64, { passphrase: passphraseA }), + /Malformed envelope header/ + ); + void corruptedB64; + void hdrLen; + }); + + it('unknown slot type throws', async () => { + const enc = await sealBackup(wifPayload, [ + { type: 'pbkdf2', id: 'a', passphrase: passphraseA }, + ]); + const raw = decodeBase64Envelope(enc); + const hdrLen = (raw[5] << 8) | raw[6]; + const headerJson = JSON.parse(new TextDecoder().decode(raw.slice(7, 7 + hdrLen))); + headerJson.slots[0].type = 'future-slot'; + const newHeaderBytes = new TextEncoder().encode(JSON.stringify(headerJson)); + const out = new Uint8Array(4 + 1 + 2 + newHeaderBytes.length + (raw.length - 7 - hdrLen)); + out.set([0x42, 0x45, 0x50, 0x32, 0x02], 0); + out[5] = (newHeaderBytes.length >> 8) & 0xff; + out[6] = newHeaderBytes.length & 0xff; + out.set(newHeaderBytes, 7); + out.set(raw.slice(7 + hdrLen), 7 + newHeaderBytes.length); + const badB64 = Utils.toBase64(Array.from(out)); + expect(enc).toBeDefined(); + await expectBeforeDecrypt( + () => openBackup(badB64, { passphrase: passphraseA }), + /Unknown slot type/ + ); + }); + + it('unknown version throws', async () => { + const enc = await sealBackup(wifPayload, [ + { type: 'pbkdf2', id: 'a', passphrase: passphraseA }, + ]); + const raw = decodeBase64Envelope(enc); + const tampered = new Uint8Array(raw); + tampered[4] = 0x03; + const badB64 = Utils.toBase64(Array.from(tampered)); + await expectBeforeDecrypt( + () => openBackup(badB64, { passphrase: passphraseA }), + /Unknown envelope version/ + ); + }); + + it('truncated bytes throw', async () => { + const enc = await sealBackup(wifPayload, [ + { type: 'pbkdf2', id: 'a', passphrase: passphraseA }, + ]); + const raw = decodeBase64Envelope(enc); + const hdrLen = (raw[5] << 8) | raw[6]; + // Truncate inside the header. + const cutHeader = raw.slice(0, 7 + hdrLen - 5); + await expectBeforeDecrypt( + () => openBackup(Utils.toBase64(Array.from(cutHeader)), { passphrase: passphraseA }), + /truncated|claims/ + ); + // Truncate inside the IV (header intact, body too short). + const cutIv = raw.slice(0, 7 + hdrLen + 5); + await expectBeforeDecrypt( + () => openBackup(Utils.toBase64(Array.from(cutIv)), { passphrase: passphraseA }), + /truncated/ + ); + // Header claiming more bytes than exist. + const inflated = new Uint8Array(raw); + inflated[5] = 0xff; + inflated[6] = 0xff; + const inflatedB64 = Utils.toBase64(Array.from(inflated)); + await expectBeforeDecrypt(() => openBackup(inflatedB64, { passphrase: passphraseA }), /claims/); + }); +}); + +describe('derivation descriptor', () => { + it('WifBackup with derivation still detects as Wif', async () => { + const payload: WifBackup = { + wif: 'L4rprVahLjG4LWdULUeoxaVyq9chGQzg8kSVgSWfBrdeyAZs9VLo', + derivation: { scheme: 'bip32', path: "m/0'/1'" }, + }; + expect(isWifBackup(payload)).toBe(true); + const enc = await sealBackup(payload, [{ type: 'pbkdf2', id: 'a', passphrase: passphraseA }]); + const info = inspectEnvelope(enc); + expect(info.descriptor).toEqual({ scheme: 'bip32', path: "m/0'/1'" }); + const dec = (await openBackup(enc, { passphrase: passphraseA })) as WifBackup; + expect(isWifBackup(dec)).toBe(true); + expect(dec.derivation).toEqual({ scheme: 'bip32', path: "m/0'/1'" }); + }); + + it('isDerivationDescriptor accepts valid, rejects unknown scheme', () => { + expect(isDerivationDescriptor({ scheme: 'brc157' })).toBe(true); + expect(isDerivationDescriptor({ scheme: 'bip32', path: "m/0'/1'" })).toBe(true); + expect(isDerivationDescriptor({ scheme: 'type42' })).toBe(true); + expect(isDerivationDescriptor({ scheme: 'brc42' })).toBe(true); + expect(isDerivationDescriptor({ scheme: 'legacy-bip32-unhardened', cohort: 'old' })).toBe(true); + expect(isDerivationDescriptor({ scheme: 'unknown' })).toBe(false); + expect(isDerivationDescriptor({})).toBe(false); + expect(isDerivationDescriptor(null)).toBe(false); + }); +}); + +describe('v1 fixtures still treated as v1', () => { + it('every file under test/fixtures/encrypted/ is not v2 and inspects as v1', async () => { + const dir = join(import.meta.dir, 'fixtures', 'encrypted'); + const files = await readdir(dir); + expect(files.length).toBeGreaterThan(0); + for (const file of files) { + const content = (await readFile(join(dir, file), 'utf8')).trim(); + expect(isEnvelopeV2(content)).toBe(false); + expect(inspectEnvelope(content)).toMatchObject({ version: 1, slots: [] }); + } + }); + + it('v1 encrypt/decrypt round trip still works alongside v2', async () => { + const payload: WifBackup = { wif: 'L4rprVahLjG4LWdULUeoxaVyq9chGQzg8kSVgSWfBrdeyAZs9VLo' }; + const v1 = await encryptBackup(payload, passphraseA); + expect(isEnvelopeV2(v1)).toBe(false); + const dec = (await decryptBackup(v1, passphraseA)) as WifBackup; + expect(dec.wif).toBe(payload.wif); + }); +}); diff --git a/test/fixtures/envelope-v2/device-vector.json b/test/fixtures/envelope-v2/device-vector.json new file mode 100644 index 0000000..bfa365f --- /dev/null +++ b/test/fixtures/envelope-v2/device-vector.json @@ -0,0 +1,17 @@ +{ + "recipientPrivateJwk": { + "crv": "P-256", + "d": "Z_2PsdzGhizlAR7hu68uHsOeXlQi_HX2UdxClB0O_aQ", + "ext": true, + "key_ops": ["deriveBits"], + "kty": "EC", + "x": "GHQTcbAkTCXmAhSr3Hi04yyRHkCzZl2rr8LMfOp9TQk", + "y": "OYvwqahFAY6WzqLvnQL3Rrn9T_oMU0qvwoapbF6h1J8" + }, + "recipientPublicKeyHex": "0418741371b0244c25e60214abdc78b4e32c911e40b3665dabafc2cc7cea7d4d09398bf0a9a845018e96cea2ef9d02f746b9fd4ffa0c534aafc286a96c5ea1d49f", + "contentKeyHex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f", + "wrappedBase64": "BOrONi/1O4i7edxeyZgyuSi4EQYNx9i5asRm1FheaxxmGVRHvaalFbcV4YprnGIlpl+nYlnOh48e/dBhxux+yASsh24T9ewDlzNCp9MYO+Xz6J1frC2wd/Z44+pcvrDMIKBQzyfCua5tYIs19mCTuJOQxOiYQD2V5lkx43E=", + "ephemeralPubHex": "04eace362ff53b88bb79dc5ec99832b928b811060dc7d8b96ac466d4585e6b1c66195447bda6a515b715e18a6b9c6225a65fa76259ce878f1efdd061c6ec7ec804", + "nonceHex": "ac876e13f5ec03973342a7d3", + "ciphertextHex": "183be5f3e89d5fac2db077f678e3ea5cbeb0cc20a050cf27c2b9ae6d608b35f66093b89390c4e898403d95e65931e371" +} From e2e3985117370275cc572d6cbf8ced7aba44df0e Mon Sep 17 00:00:00 2001 From: Luke Rohenaz Date: Mon, 7 Sep 2026 17:13:34 -0400 Subject: [PATCH 2/4] OPL-4574: Add updateBackupPayload to rewrite a payload under existing slots --- src/envelope.ts | 34 ++++++++++++++++++++++++++++++++++ src/index.ts | 1 + test/envelope-v2.test.ts | 32 +++++++++++++++++++++++++++++++- 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/src/envelope.ts b/src/envelope.ts index 95bfc71..5d79cd7 100644 --- a/src/envelope.ts +++ b/src/envelope.ts @@ -663,3 +663,37 @@ export async function rewrapBackup( ); return encodeEnvelope(newHeader, iv, ct); } + +/** + * Re-encrypts a new payload under the envelope's existing content key, keeping every slot. + * Use this to update a document without needing the credentials of every other slot. + */ +export async function updateBackupPayload( + encrypted: EncryptedBackup, + unlock: Unlock, + payload: DecryptedBackup +): Promise { + if (!isValidPayload(payload)) { + throw new Error('Invalid payload: Payload must match a supported backup structure.'); + } + const decoded = decodeBase64Envelope(encrypted); + if (!hasV2Magic(decoded)) throw new Error('Invalid envelope: not a v2 envelope.'); + const { header } = parseEnvelope(decoded); + const contentKey = await resolveContentKey(header, unlock); + const descriptor = getDescriptorFromPayload(payload); + const newHeader: EnvelopeHeader = { + v: 2, + slots: header.slots, + ...(descriptor !== undefined ? { descriptor } : {}), + }; + const iv = globalThis.crypto.getRandomValues(new Uint8Array(IV_LENGTH_BYTES)); + const key = await importContentKey(contentKey, ['encrypt']); + const ct = new Uint8Array( + await globalThis.crypto.subtle.encrypt( + { name: 'AES-GCM', iv: iv as BufferSource }, + key, + new TextEncoder().encode(buildPayloadJson(payload)) as BufferSource + ) + ); + return encodeEnvelope(newHeader, iv, ct); +} diff --git a/src/index.ts b/src/index.ts index 81e05d5..81fcb43 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,7 @@ export { type SlotSpec, sealBackup, type Unlock, + updateBackupPayload, } from './envelope'; /** diff --git a/test/envelope-v2.test.ts b/test/envelope-v2.test.ts index c764482..6ad7cd7 100644 --- a/test/envelope-v2.test.ts +++ b/test/envelope-v2.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'bun:test'; +import { describe, expect, it, updateBackupPayload } from 'bun:test'; import { readdir, readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { Utils } from '@bsv/sdk'; @@ -386,3 +386,33 @@ describe('v1 fixtures still treated as v1', () => { expect(dec.wif).toBe(payload.wif); }); }); + +describe('updateBackupPayload', () => { + it('replaces the payload and keeps every slot', async () => { + const pair = (await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, [ + 'deriveBits', + ])) as CryptoKeyPair; + const raw = new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey)); + const publicKey = Array.from(raw, (b) => b.toString(16).padStart(2, '0')).join(''); + const sealed = await sealBackup({ wif: 'first' }, [ + { type: 'pbkdf2', id: 'pw', passphrase: 'correct horse battery' }, + { type: 'device-p256', id: 'dev', publicKey }, + ]); + const updated = await updateBackupPayload( + sealed, + { passphrase: 'correct horse battery' }, + { + wif: 'second', + } + ); + expect(inspectEnvelope(updated).slots.map((s) => s.id)).toEqual(['pw', 'dev']); + expect(await openBackup(updated, { passphrase: 'correct horse battery' })).toMatchObject({ + wif: 'second', + }); + const viaDevice = await openBackup(updated, { + slotId: 'dev', + unwrap: (wrapped) => eciesDecrypt(pair.privateKey, wrapped), + }); + expect(viaDevice).toMatchObject({ wif: 'second' }); + }); +}); From cbe9981f08231dc4957a792aef86d11d47e93e45 Mon Sep 17 00:00:00 2001 From: Luke Rohenaz Date: Mon, 7 Sep 2026 17:14:58 -0400 Subject: [PATCH 3/4] OPL-4574: Fix updateBackupPayload test import --- test/envelope-v2.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/envelope-v2.test.ts b/test/envelope-v2.test.ts index 6ad7cd7..a49cf8a 100644 --- a/test/envelope-v2.test.ts +++ b/test/envelope-v2.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, updateBackupPayload } from 'bun:test'; +import { describe, expect, it } from 'bun:test'; import { readdir, readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { Utils } from '@bsv/sdk'; @@ -16,6 +16,7 @@ import { removeSlot, rewrapBackup, sealBackup, + updateBackupPayload, type WifBackup, } from '../src/index'; From d9e167da02c53c90437344d4006997832d0d3843 Mon Sep 17 00:00:00 2001 From: Luke Rohenaz Date: Mon, 7 Sep 2026 19:22:11 -0400 Subject: [PATCH 4/4] OPL-4574: Integrate slots into the bbackup CLI --- CHANGELOG.md | 3 +- README.md | 3 ++ cli/bbackup.ts | 101 ++++++++++++++++++++++++++++++++++++++++++++--- test/cli.test.ts | 34 ++++++++++++++++ 4 files changed, 134 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e61e981..b851350 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,8 @@ - Versioned `BEP2` envelope (v2) with key slots: one encrypted file can be unlocked by multiple credentials (`pbkdf2` passphrase slots and `device-p256` P-256 ECIES slots). - New API: `sealBackup`, `openBackup`, `inspectEnvelope`, `addSlot`, `removeSlot`, `rewrapBackup`, `isEnvelopeV2`, plus `eciesEncrypt`/`eciesDecrypt` and `SlotSpec`/`Unlock` types. - Optional `DerivationDescriptor` (`scheme`, `path`, `parentIdentityKey`, `index`, `cohort`) on `WifBackup`, `BapAccountBackup`, `MasterBackupType42`, and `BapMasterBackupLegacy`, with `isDerivationDescriptor` guard. Header copies it for locked inspection. -- CLI: `bbackup slots ` prints `inspectEnvelope` output as JSON without a passphrase. +- `updateBackupPayload` re-encrypts a new payload under an envelope's existing content key, keeping every slot. +- CLI: `bbackup enc --device-pubkey ` (repeatable) writes a v2 envelope sealed to the passphrase and each device key; `bbackup slot add|remove` manages slots; `bbackup slots ` inspects slots without a passphrase. - `decryptBackup` transparently opens v2 pbkdf2 slots; `encryptBackup` still writes v1. Existing `.bep` files decrypt unchanged. ## 0.0.14 diff --git a/README.md b/README.md index 4dfb985..f6b3d08 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,9 @@ npx bbackup --help | `bbackup enc ` | Encrypts a JSON input file. | `bbackup enc wallet.json -p "secret" -o wallet.bep` | | `bbackup dec ` | Decrypts a `.bep` file. | `bbackup dec wallet.bep -p "secret" -o wallet.json` | | `bbackup upg ` | Upgrades an encrypted file to recommended PBKDF2 iterations. | `bbackup upg old_wallet.bep -p "secret" -o upgraded_wallet.bep` | +| `bbackup enc --device-pubkey ` | Seal a v2 envelope to the passphrase and one or more device keys. | `bbackup enc wallet.json -p "secret" --device-pubkey 04ab…` | +| `bbackup slot add ` | Add a pbkdf2 or device slot, unlocking with `-p`. | `bbackup slot add wallet.bep -p "secret" --device-pubkey 04ab…` | +| `bbackup slot remove ` | Remove a slot; refuses the last one. | `bbackup slot remove wallet.bep device-1 -p "secret"` | | `bbackup slots ` | Prints envelope version and key slots as JSON (no passphrase). | `bbackup slots wallet.bep` | **Common Options:** diff --git a/cli/bbackup.ts b/cli/bbackup.ts index 1146418..d021419 100644 --- a/cli/bbackup.ts +++ b/cli/bbackup.ts @@ -5,11 +5,14 @@ import path from 'node:path'; import { Command, InvalidArgumentError } from 'commander'; import { version } from '../package.json'; import { + addSlot, type DecryptedBackup, decryptBackup, encryptBackup, inspectEnvelope, RECOMMENDED_PBKDF2_ITERATIONS, + removeSlot, + sealBackup, } from '../src/index'; const program = new Command(); @@ -84,10 +87,22 @@ program parseIterations, RECOMMENDED_PBKDF2_ITERATIONS ) + .option( + '--device-pubkey ', + 'Also seal to a P-256 device public key (65-byte X9.63 hex); writes a v2 envelope. Repeatable.', + (value: string, previous: string[] = []) => [...previous, value], + [] as string[] + ) .action( async ( inputFile: string, - options: { password?: string; output?: string; iterations: number; touchid?: boolean } + options: { + password?: string; + output?: string; + iterations: number; + touchid?: boolean; + devicePubkey: string[]; + } ) => { let outputFile = options.output; if (!outputFile) { @@ -118,11 +133,22 @@ program ); } - const encryptedBackupString = await encryptBackup( - decryptedPayload, - password, - options.iterations - ); + const encryptedBackupString = + options.devicePubkey.length === 0 + ? await encryptBackup(decryptedPayload, password, options.iterations) + : await sealBackup(decryptedPayload, [ + { + type: 'pbkdf2', + id: 'passphrase', + passphrase: password, + iterations: options.iterations, + }, + ...options.devicePubkey.map((publicKey, i) => ({ + type: 'device-p256' as const, + id: `device-${i + 1}`, + publicKey, + })), + ]); const absoluteOutputPath = path.resolve(outputFile); const outputDir = path.dirname(absoluteOutputPath); @@ -341,6 +367,69 @@ program } }); +// --- slot add / remove --- + +const slot = program.command('slot').description('Manage key slots on a v2 envelope.'); + +slot + .command('add ') + .description('Add a slot, unlocking with the existing passphrase.') + .option('-p, --password ', 'Passphrase of an existing pbkdf2 slot') + .option('--device-pubkey ', 'P-256 device public key (65-byte X9.63 hex) for the new slot') + .option('--new-password ', 'Passphrase for a new pbkdf2 slot') + .option('--id ', 'Slot id (defaults to device-N or passphrase-N)') + .action( + async ( + file: string, + options: { password?: string; devicePubkey?: string; newPassword?: string; id?: string } + ) => { + try { + if (!options.password) throw new Error('Password required. Use -p .'); + if (!!options.devicePubkey === !!options.newPassword) { + throw new Error('Give exactly one of --device-pubkey or --new-password.'); + } + const absolutePath = path.resolve(file); + const encrypted = (await fs.readFile(absolutePath, 'utf-8')).trim(); + const existing = inspectEnvelope(encrypted).slots.length; + const spec = options.devicePubkey + ? { + type: 'device-p256' as const, + id: options.id ?? `device-${existing + 1}`, + publicKey: options.devicePubkey, + } + : { + type: 'pbkdf2' as const, + id: options.id ?? `passphrase-${existing + 1}`, + passphrase: options.newPassword as string, + }; + const next = await addSlot(encrypted, { passphrase: options.password }, spec); + await fs.writeFile(absolutePath, next, { encoding: 'utf-8', mode: 0o600 }); + console.log(`Slot '${spec.id}' added to ${absolutePath}`); + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : error); + process.exit(1); + } + } + ); + +slot + .command('remove ') + .description('Remove a slot, unlocking with the existing passphrase. Refuses the last slot.') + .option('-p, --password ', 'Passphrase of an existing pbkdf2 slot') + .action(async (file: string, slotId: string, options: { password?: string }) => { + try { + if (!options.password) throw new Error('Password required. Use -p .'); + const absolutePath = path.resolve(file); + const encrypted = (await fs.readFile(absolutePath, 'utf-8')).trim(); + const next = await removeSlot(encrypted, { passphrase: options.password }, slotId); + await fs.writeFile(absolutePath, next, { encoding: 'utf-8', mode: 0o600 }); + console.log(`Slot '${slotId}' removed from ${absolutePath}`); + } catch (error) { + console.error('Error:', error instanceof Error ? error.message : error); + process.exit(1); + } + }); + // --- forget --- program diff --git a/test/cli.test.ts b/test/cli.test.ts index 01cae7c..33f7da6 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -188,3 +188,37 @@ test('built CLI default KDF roundtrip and unknown ciphertext structure rejection } } }, 15000); + +test('built CLI seals v2 with a device slot, manages slots, and still decrypts', async () => { + const input = join(directory, 'v2.json'); + const encrypted = join(directory, 'v2.bep'); + const output = join(directory, 'v2-output.json'); + await writeFile(input, JSON.stringify(seed)); + const pair = (await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, [ + 'deriveBits', + ])) as CryptoKeyPair; + const raw = new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey)); + const pubkey = Array.from(raw, (b) => b.toString(16).padStart(2, '0')).join(''); + expect( + (await run('enc', input, '-p', password, '-o', encrypted, '--device-pubkey', pubkey)).code + ).toBe(0); + const slots = JSON.parse((await run('slots', encrypted)).stdout) as { + version: number; + slots: { id: string }[]; + }; + expect(slots.version).toBe(2); + expect(slots.slots.map((s) => s.id)).toEqual(['passphrase', 'device-1']); + expect( + (await run('slot', 'add', encrypted, '-p', password, '--new-password', 'second-passphrase-1')) + .code + ).toBe(0); + expect((await run('slot', 'remove', encrypted, 'device-1', '-p', password)).code).toBe(0); + const after = JSON.parse((await run('slots', encrypted)).stdout) as { slots: { id: string }[] }; + expect(after.slots.map((s) => s.id)).toEqual(['passphrase', 'passphrase-3']); + expect((await run('dec', encrypted, '-p', 'second-passphrase-1', '-o', output)).code).toBe(0); + expect(JSON.parse(await readFile(output, 'utf8'))).toEqual(seed); + expect((await run('slot', 'remove', encrypted, 'passphrase', '-p', password)).code).toBe(0); + expect( + (await run('slot', 'remove', encrypted, 'passphrase-3', '-p', 'second-passphrase-1')).code + ).toBe(1); +}, 30000);