diff --git a/API.md b/API.md index 2cdf161..a9c4b11 100644 --- a/API.md +++ b/API.md @@ -125,4 +125,45 @@ The library exports the following constants related to PBKDF2 iterations: ```typescript export const RECOMMENDED_PBKDF2_ITERATIONS = 600000; export const LEGACY_PBKDF2_ITERATIONS = 100000; -``` \ No newline at end of file +``` +## Sigma Peer Profiles seed backups + +`SigmaSeedBackup` is a separate member of `DecryptedBackup`, never a +`BapMasterBackup`. Use `isSigmaSeedBackup(value)` to validate its structure; +`getBackupType` returns `SigmaSeed`. Existing encryption and legacy key formats +are unchanged. Older readers reject this envelope as an unknown JSON structure. + +```ts +const backup: SigmaSeedBackup = { + format: 'sigma-seed', + version: 1, + mnemonic, + profiles: [{ index: 0, bapId }], + nextProfileIndex: 1, + createdAt: Date.now(), +}; +const ciphertext = await encryptBackup(backup, backupPassword); +``` + +Version 1 permanently defines BRC157 peer profiles and an empty BIP39 passphrase; `backupPassword` protects the encrypted +file and is independent of that policy. Profiles are hardened peers at +`m/0'/N'`, where `N` is the profile index. Optional profile `metadata` must be a +JSON object, and the envelope supports an optional string `label`. + +Validation requires version 1, 12/15/18/21/24 mnemonic word counts, a nonempty profile list, +unique indices and BAP +IDs, and a safe integer `nextProfileIndex` greater than every used index. All +profile indices are in 0–2147483647; `nextProfileIndex` may be 2147483648 +to record exhaustion, at which point allocation must stop. `createdAt` is a +nonnegative safe integer timestamp in milliseconds. +The mnemonic word count encodes its entropy length; redundant `scheme`, +`entropyBytes`, and `passphrasePolicy` fields are rejected as unknown. +Unknown fields and mixed legacy discriminators (including top-level `rootPk`, +`xprv`, `wif`, or `ids`) are rejected. Numeric timestamps are preserved, including +zero. Legacy formats retain their existing ISO timestamp behavior. + +This package checks structure and word count only. The Sigma seed module must +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. diff --git a/CHANGELOG.md b/CHANGELOG.md index a184a21..714d3ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 0.0.14 + +### Fixed +- Snapshot validated backup data before asynchronous encryption so caller mutation cannot change the encrypted inventory. +- Keep CLI version synchronized with the package. Read custom PBKDF2 iteration counts in `dec` and `upg`, reject malformed counts, protect decrypted files with owner-only permissions, and avoid echoing malformed input JSON in errors. + +### Added +- Versioned Sigma seed backup envelopes, structural validation, and encrypted profile inventories, including partial-inventory tracking for phrase recovery. + +### Changed +- Version 1 defines the derivation and empty passphrase policy; mnemonic word count determines entropy length. Existing backup formats remain unchanged. +- Pin the integration suite to the published `bsv-bap` package instead of an undeclared sibling checkout. + ## 0.0.13 ### Fixed diff --git a/README.md b/README.md index 1cb1f18..bf1f1aa 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ npx bbackup --help **Common Options:** * `-p, --password `: (Required) The passphrase for encryption/decryption. * `-o, --output `: (Optional) Path for the output file. Defaults are sensible (e.g., `.bep` for encrypt, `.json` for decrypt). -* `-t, --iterations `: (Optional, for `enc` command) Number of PBKDF2 iterations. +* `-t, --iterations `: (Optional) PBKDF2 iterations: output count for `enc`, input count for `dec` and `upg`. Without it, readers try 600,000 then 100,000. `upg` always writes 600,000. Custom counts must be supplied when reading those files. For detailed options for each command, run: ```bash @@ -307,10 +307,10 @@ const derivedKey = alice.deriveChild(bobPub, invoiceNumber); ```bash # Encrypt a backup file -bbackup encrypt input.json -p "passphrase" -o encrypted.backup +bbackup enc input.json -p "passphrase" -o encrypted.backup # Decrypt a backup file -bbackup decrypt encrypted.backup -p "passphrase" -o decrypted.json +bbackup dec encrypted.backup -p "passphrase" -o decrypted.json ``` ## Security Features @@ -343,3 +343,67 @@ To migrate from legacy to Type 42 format: 2. **Choose a key name**: Select a meaningful identifier for your master key 3. **Create new backup**: Use `BapMasterBackup` interface 4. **Test thoroughly**: Verify encryption/decryption works as expected + +## Sigma Peer Profiles seed backups + +`SigmaSeedBackup` is a separate member of `DecryptedBackup`, never a +`BapMasterBackup`. Use `isSigmaSeedBackup(value)` to validate its structure; +`getBackupType` returns `SigmaSeed`. Existing encryption and legacy key formats +are unchanged. Older readers reject this envelope as an unknown JSON structure. + +```ts +const backup: SigmaSeedBackup = { + format: 'sigma-seed', + version: 1, + mnemonic, + profiles: [{ index: 0, bapId }], + nextProfileIndex: 1, + createdAt: Date.now(), +}; +const ciphertext = await encryptBackup(backup, backupPassword); +``` + +Version 1 permanently defines BRC157 peer profiles and an empty BIP39 passphrase; `backupPassword` protects the encrypted +file and is independent of that policy. Profiles are hardened peers at +`m/0'/N'`, where `N` is the profile index. Optional profile `metadata` must be a +JSON object, and the envelope supports an optional string `label`. + +Validation requires version 1, 12/15/18/21/24 mnemonic word counts, a nonempty profile list, +unique indices and BAP +IDs, and a safe integer `nextProfileIndex` greater than every used index. All +profile indices are in 0–2147483647; `nextProfileIndex` may be 2147483648 +to record exhaustion, at which point allocation must stop. `createdAt` is a +nonnegative safe integer timestamp in milliseconds. +The mnemonic word count encodes its entropy length; redundant `scheme`, +`entropyBytes`, and `passphrasePolicy` fields are rejected as unknown. +Unknown fields and mixed legacy discriminators (including top-level `rootPk`, +`xprv`, `wif`, or `ids`) are rejected. Numeric timestamps are preserved, including +zero. Legacy formats retain their existing ISO timestamp behavior. + +This package checks structure and word count only. The Sigma seed module must +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. + +### Seed backups in the CLI + +The same `enc`, `dec`, and `upg` commands accept complete and partial Sigma seed +backups. Format detection is automatic; no conversion flag is needed. Profile +indices, metadata, labels, numeric timestamps, and `inventoryComplete: false` +are preserved. `upg` changes encryption strength only; it never completes an +inventory or derives keys. Unknown versions, removed fields, and mixed legacy +markers fail without writing an output file. + +`dec -o backup.json` writes plaintext with owner-only permissions (0600), +including when overwriting an existing file. Without `-o`, `dec` deliberately +prints the entire decrypted backup, including mnemonic/private keys, to stdout. +Passwords passed with `-p` may appear in shell history and process arguments; +Touch ID can retrieve an already cached password on supported Macs. + +The CLI validates structure and supported mnemonic word count only. It does not +verify BIP39 checksum, derive or bind BAP IDs, generate seeds, or discover +profiles. Use a compatible Sigma application for those operations. An absent +`inventoryComplete` marker describes the inventory recorded when that backup +was saved; it does not prove no profiles were created later. Upgrading an old +backup does not discover or add those later profiles. diff --git a/bun.lock b/bun.lock index 3f8c9f4..0cf2ec5 100644 --- a/bun.lock +++ b/bun.lock @@ -16,6 +16,7 @@ "@bsv/sdk": "^2.1.6", "@types/bun": "^1.3.14", "@types/commander": "^2.12.5", + "bsv-bap": "0.3.7", "typescript": "^6.0.3", }, "peerDependencies": { @@ -59,14 +60,20 @@ "@types/node": ["@types/node@26.0.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-vf2YFi1iY9lHGwNJMs01biZFbKJkrZR1T6/MlzjhJLPdntOHLhTrDSnSVcdtvjihi4VQNlrFRIxLsDBlQpAipA=="], + "bsv-bap": ["bsv-bap@0.3.7", "", { "dependencies": { "@1sat/vault": "^0.0.8", "@1sat/wallet-mac": "^0.0.5", "commander": "^14.0.3", "schema-dts": "^1.1.5" }, "peerDependencies": { "@bsv/sdk": "^2.0.1" }, "bin": { "bap": "src/cli.ts" } }, "sha512-jpFxrqnT5WERCW4qhJEHzsGyFbg7cP7Wp2pC1exkGT6tLT91G7ZS9Seh2Pkfijb2ArHrR6OalOZMu7iII2Lpbw=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="], "fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="], + "schema-dts": ["schema-dts@1.1.5", "", {}, "sha512-RJr9EaCmsLzBX2NDiO5Z3ux2BVosNZN5jo0gWgsyKvxKIUL5R3swNvoorulAeL9kLB0iTSX7V6aokhla2m7xbg=="], + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "bsv-bap/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], } } diff --git a/cli/bbackup.ts b/cli/bbackup.ts index 87b96c1..851cd96 100644 --- a/cli/bbackup.ts +++ b/cli/bbackup.ts @@ -2,7 +2,8 @@ import fs from 'node:fs/promises'; import path from 'node:path'; -import { Command } from 'commander'; +import { Command, InvalidArgumentError } from 'commander'; +import { version } from '../package.json'; import { type DecryptedBackup, decryptBackup, @@ -15,7 +16,15 @@ const program = new Command(); program .name('bbackup') .description('CLI tool for managing and securing Bitcoin-related identity backups.') - .version('0.0.13'); + .version(version); + +function parseIterations(value: string): number { + const count = Number(value); + if (!/^\d+$/u.test(value) || !Number.isSafeInteger(count) || count < 1 || count > 4294967295) { + throw new InvalidArgumentError('Iterations must be a positive 32-bit integer.'); + } + return count; +} /** * Resolve a password from explicit flag, Touch ID cache, or error. @@ -71,7 +80,7 @@ program .option( '-t, --iterations ', 'Number of PBKDF2 iterations', - (val) => Number.parseInt(val, 10), + parseIterations, RECOMMENDED_PBKDF2_ITERATIONS ) .action( @@ -128,7 +137,12 @@ program }); } catch (error) { if (error instanceof Error) { - console.error('Encryption failed:', error.message); + console.error( + 'Encryption failed:', + error instanceof SyntaxError + ? 'Input file must contain valid backup JSON.' + : error.message + ); } else { console.error('An unknown error occurred during encryption:', error); } @@ -142,6 +156,11 @@ program program .command('dec ') .description('Decrypt an encrypted backup file.') + .option( + '-t, --iterations ', + 'Input PBKDF2 iterations (default: try 600000 and 100000)', + parseIterations + ) .option('-p, --password ', 'Passphrase for decryption') .option('--touchid', 'Use Touch ID to retrieve or cache password') .option( @@ -151,7 +170,7 @@ program .action( async ( inputFile: string, - options: { password?: string; output?: string; touchid?: boolean } + options: { password?: string; output?: string; touchid?: boolean; iterations?: number } ) => { // For dec, the cache key is the INPUT .bep file const password = await resolvePassword({ @@ -171,7 +190,11 @@ program } console.log('File content read, attempting decryption...'); - const decryptedPayload = await decryptBackup(encryptedString.trim(), password); + const decryptedPayload = await decryptBackup( + encryptedString.trim(), + password, + options.iterations + ); await maybeCachePassword({ password: options.password, @@ -183,7 +206,15 @@ program const absoluteOutputPath = path.resolve(options.output); const outputDir = path.dirname(absoluteOutputPath); await fs.mkdir(outputDir, { recursive: true }); - await fs.writeFile(absoluteOutputPath, JSON.stringify(decryptedPayload, null, 2), 'utf8'); + // Restrict existing files before writing plaintext; create new files privately. + const output = await fs.open(absoluteOutputPath, 'a', 0o600); + try { + await output.chmod(0o600); + await output.truncate(0); + await output.writeFile(JSON.stringify(decryptedPayload, null, 2), 'utf8'); + } finally { + await output.close(); + } console.log(`\nDecryption successful! Decrypted payload saved to: ${absoluteOutputPath}`); } else { console.log('\nDecryption successful!\n'); @@ -207,13 +238,18 @@ program program .command('upg ') .description('Upgrade an encrypted backup file to the recommended PBKDF2 iterations.') + .option( + '-t, --iterations ', + 'Input PBKDF2 iterations; output always uses 600000 (default: try 600000 and 100000)', + parseIterations + ) .option('-p, --password ', 'Passphrase for decryption and re-encryption') .option('--touchid', 'Use Touch ID to retrieve or cache password') .option('-o, --output ', 'Path to save the upgraded encrypted file') .action( async ( inputFile: string, - options: { password?: string; output?: string; touchid?: boolean } + options: { password?: string; output?: string; touchid?: boolean; iterations?: number } ) => { const password = await resolvePassword({ password: options.password, @@ -229,7 +265,11 @@ program const encryptedBackup = await fs.readFile(absoluteInputPath, 'utf-8'); console.log('Decrypting file...'); - const decryptedPayload = await decryptBackup(encryptedBackup, password); + const decryptedPayload = await decryptBackup( + encryptedBackup.trim(), + password, + options.iterations + ); console.log('File decrypted successfully.'); console.log(`Re-encrypting with ${RECOMMENDED_PBKDF2_ITERATIONS} iterations...`); diff --git a/package.json b/package.json index 5ad6b2a..e146ec5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "bitcoin-backup", "type": "module", - "version": "0.0.13", + "version": "0.0.14", "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", @@ -43,6 +43,7 @@ "@bsv/sdk": "^2.1.6", "@types/bun": "^1.3.14", "@types/commander": "^2.12.5", + "bsv-bap": "0.3.7", "typescript": "^6.0.3" }, "dependencies": { diff --git a/src/crypto.ts b/src/crypto.ts index 4806107..742c995 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -10,6 +10,7 @@ import type { YoursWalletBackup, YoursWalletZipBackup, } from './interfaces'; +import { hasSigmaSeedMarker, isSigmaSeedBackup } from './seed'; const { toArray, toBase64 } = Utils; @@ -69,20 +70,29 @@ export async function encryptData( passphrase: string, iterations?: number // Optional iterations for encryption ): Promise { + if ( + hasSigmaSeedMarker(payload as unknown as Record) && + !isSigmaSeedBackup(payload) + ) { + throw new Error('Invalid Sigma seed backup structure.'); + } const salt = globalThis.crypto.getRandomValues(new Uint8Array(SALT_LENGTH_BYTES)); const iv = globalThis.crypto.getRandomValues(new Uint8Array(IV_LENGTH_BYTES)); - // deriveKey will use its default (DEFAULT_PBKDF2_ITERATIONS) if iterations is undefined - const key = await deriveKey(passphrase, salt, iterations); - const payloadToEncrypt = { ...payload, - createdAt: payload.createdAt || new Date().toISOString(), + createdAt: isSigmaSeedBackup(payload) + ? payload.createdAt + : payload.createdAt || new Date().toISOString(), }; const jsonPayload = JSON.stringify(payloadToEncrypt); const dataToEncrypt = new TextEncoder().encode(jsonPayload); + // Snapshot the validated payload before key derivation yields to the caller. + // deriveKey will use its default (DEFAULT_PBKDF2_ITERATIONS) if iterations is undefined + const key = await deriveKey(passphrase, salt, iterations); + const encryptedContent = await globalThis.crypto.subtle.encrypt( { name: 'AES-GCM', iv: iv }, key, @@ -151,6 +161,11 @@ export async function decryptData( 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; diff --git a/src/guards.ts b/src/guards.ts index 9e8bd44..76a39aa 100644 --- a/src/guards.ts +++ b/src/guards.ts @@ -1,3 +1,7 @@ +import { hasSigmaSeedMarker, isSigmaSeedBackup } from './seed'; + +export { isSigmaSeedBackup } from './seed'; + import type { BapAccountBackup, BapMasterBackup, @@ -15,6 +19,7 @@ import type { * Type guard: checks if the backup is a legacy BAP master backup (xprv + mnemonic). */ export function isLegacyBackup(backup: DecryptedBackup): backup is BapMasterBackupLegacy { + if (hasSigmaSeedMarker(backup)) return false; return 'xprv' in backup && 'mnemonic' in backup && 'ids' in backup; } @@ -22,6 +27,7 @@ export function isLegacyBackup(backup: DecryptedBackup): backup is BapMasterBack * Type guard: checks if the backup is a Type 42 BAP master backup (rootPk). */ export function isType42Backup(backup: DecryptedBackup): backup is MasterBackupType42 { + if (hasSigmaSeedMarker(backup)) return false; return 'rootPk' in backup && 'ids' in backup && !('xprv' in backup); } @@ -36,6 +42,7 @@ export function isMasterBackup(backup: DecryptedBackup): backup is BapMasterBack * Type guard: checks if the backup is a BAP account backup (wif + id). */ export function isAccountBackup(backup: DecryptedBackup): backup is BapAccountBackup { + if (hasSigmaSeedMarker(backup)) return false; return 'wif' in backup && 'id' in backup && !('xprv' in backup) && !('rootPk' in backup); } @@ -43,6 +50,7 @@ export function isAccountBackup(backup: DecryptedBackup): backup is BapAccountBa * Type guard: checks if the backup is a bare WIF backup (wif only, no id/xprv/rootPk). */ export function isWifBackup(backup: DecryptedBackup): backup is WifBackup { + if (hasSigmaSeedMarker(backup)) return false; return 'wif' in backup && !('id' in backup) && !('xprv' in backup) && !('rootPk' in backup); } @@ -50,6 +58,7 @@ export function isWifBackup(backup: DecryptedBackup): backup is WifBackup { * Type guard: checks if the backup is a 1Sat Ordinals backup. */ export function isOneSatBackup(backup: DecryptedBackup): backup is OneSatBackup { + if (hasSigmaSeedMarker(backup)) return false; return ( 'ordPk' in backup && 'payPk' in backup && @@ -63,6 +72,7 @@ export function isOneSatBackup(backup: DecryptedBackup): backup is OneSatBackup * Type guard: checks if the backup is an encrypted vault backup. */ export function isVaultBackup(backup: DecryptedBackup): backup is VaultBackup { + if (hasSigmaSeedMarker(backup)) return false; return 'encryptedVault' in backup; } @@ -70,6 +80,7 @@ export function isVaultBackup(backup: DecryptedBackup): backup is VaultBackup { * Type guard: checks if the backup is a Yours Wallet JSON backup. */ export function isYoursWalletBackup(backup: DecryptedBackup): backup is YoursWalletBackup { + if (hasSigmaSeedMarker(backup)) return false; return ( 'payPk' in backup && 'ordPk' in backup && @@ -82,6 +93,7 @@ export function isYoursWalletBackup(backup: DecryptedBackup): backup is YoursWal * The chromeStorage object is the discriminator; manifest/settings/chunks are optional. */ export function isYoursWalletZipBackup(backup: DecryptedBackup): backup is YoursWalletZipBackup { + if (hasSigmaSeedMarker(backup)) return false; return ( 'chromeStorage' in backup && typeof (backup as { chromeStorage: unknown }).chromeStorage === 'object' && @@ -91,6 +103,7 @@ export function isYoursWalletZipBackup(backup: DecryptedBackup): backup is Yours /** Backup type name for display/logging purposes */ export type BackupTypeName = + | 'SigmaSeed' | 'Legacy' | 'Type42' | 'Account' @@ -105,6 +118,7 @@ export type BackupTypeName = * Returns a human-readable name for the backup type. */ export function getBackupType(backup: DecryptedBackup): BackupTypeName { + if (isSigmaSeedBackup(backup)) return 'SigmaSeed'; if (isLegacyBackup(backup)) return 'Legacy'; if (isType42Backup(backup)) return 'Type42'; if (isAccountBackup(backup)) return 'Account'; diff --git a/src/index.ts b/src/index.ts index ee48fad..3dbb01a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import type { // BapAccountBackup, // Removed as it's covered by export * // WifBackup // Removed as it's covered by export * } from './interfaces'; +import { hasSigmaSeedMarker, isSigmaSeedBackup } from './seed'; /** * Validates the structure of a payload intended for encryption. @@ -17,6 +18,7 @@ function isValidPayload(payload: unknown): payload is DecryptedBackup { // 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 ( @@ -109,7 +111,7 @@ export async function encryptBackup( ): Promise { if (!isValidPayload(payload)) { throw new Error( - 'Invalid payload: Payload must be an object matching BapMasterBackup, BapAccountBackup, WifBackup, OneSatBackup, VaultBackup, YoursWalletBackup, or YoursWalletZipBackup structure.' + 'Invalid payload: Payload must be an object matching SigmaSeedBackup, BapMasterBackup, BapAccountBackup, WifBackup, OneSatBackup, VaultBackup, YoursWalletBackup, or YoursWalletZipBackup structure.' ); } if (typeof passphrase !== 'string' || passphrase.length === 0) { diff --git a/src/interfaces.ts b/src/interfaces.ts index 3c2854e..b2a2a0e 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -120,7 +120,31 @@ export interface YoursWalletZipBackup { createdAt?: string; // ISO 8601 timestamp (populated by encryptBackup if not provided) } +/** JSON metadata preserved inside the encrypted seed envelope. */ +export type SigmaSeedJsonValue = + | null + | boolean + | number + | string + | SigmaSeedJsonValue[] + | { [key: string]: SigmaSeedJsonValue }; + +/** Explicit seed format. Legacy master-key consumers must never receive this as BapMasterBackup. */ +export interface SigmaSeedBackup { + format: 'sigma-seed'; + /** Version 1 fixes BRC157 peers at m/0'/i' and an empty BIP39 passphrase. */ + version: 1; + mnemonic: string; + profiles: { index: number; bapId: string; metadata?: { [key: string]: SigmaSeedJsonValue } }[]; + nextProfileIndex: number; + /** Absent means complete; false marks phrase-only recovery with unknown inventory. */ + inventoryComplete?: false; + createdAt: number; + label?: string; +} + export type DecryptedBackup = + | SigmaSeedBackup | BapMasterBackup | BapAccountBackup | WifBackup diff --git a/src/seed.ts b/src/seed.ts new file mode 100644 index 0000000..1c25230 --- /dev/null +++ b/src/seed.ts @@ -0,0 +1,106 @@ +import type { SigmaSeedBackup } from './interfaces'; + +const MAX_INDEX = 2147483647; +const fields = new Set([ + 'format', + 'version', + 'mnemonic', + 'profiles', + 'nextProfileIndex', + 'inventoryComplete', + 'createdAt', + 'label', +]); +const profileFields = new Set(['index', 'bapId', 'metadata']); + +function object(value: unknown): value is Record { + return ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null) + ); +} + +function index(value: unknown): value is number { + return ( + typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= MAX_INDEX + ); +} + +function json(value: unknown, ancestors = new Set()): boolean { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return true; + if (typeof value === 'number') return Number.isFinite(value); + if (!Array.isArray(value) && !object(value)) return false; + if (ancestors.has(value)) return false; + ancestors.add(value); + const valid = Object.values(value).every((item) => json(item, ancestors)); + ancestors.delete(value); + return valid; +} + +/** Reserve seed markers before legacy duck typing so mixed payloads fail closed. */ +export function hasSigmaSeedMarker(value: object): boolean { + if ( + [ + 'format', + 'entropyBytes', + 'passphrasePolicy', + 'profiles', + 'nextProfileIndex', + 'inventoryComplete', + ].some((key) => key in value) + ) + return true; + // VaultBackup already owns an optional scheme field. Preserve that existing contract. + return ( + 'scheme' in value && + (!('encryptedVault' in value) || + value.scheme === 'brc157-peer-profiles' || + ['rootPk', 'xprv', 'wif'].some((key) => key in value)) + ); +} + +/** Structural validation only; mnemonic checksum and BAP/key binding belong to the Sigma seed module. */ +export function isSigmaSeedBackup(value: unknown): value is SigmaSeedBackup { + if (!object(value) || Object.keys(value).some((key) => !fields.has(key))) return false; + if (value.format !== 'sigma-seed' || value.version !== 1) return false; + if ( + typeof value.mnemonic !== 'string' || + value.mnemonic.trim() !== value.mnemonic || + ![12, 15, 18, 21, 24].includes(value.mnemonic.split(/\s+/u).length) + ) + return false; + if ( + typeof value.nextProfileIndex !== 'number' || + !Number.isSafeInteger(value.nextProfileIndex) || + value.nextProfileIndex < 0 || + value.nextProfileIndex > MAX_INDEX + 1 || + typeof value.createdAt !== 'number' || + !Number.isSafeInteger(value.createdAt) || + value.createdAt < 0 + ) + return false; + if ('inventoryComplete' in value && value.inventoryComplete !== false) return false; + if ('label' in value && typeof value.label !== 'string') return false; + if (!Array.isArray(value.profiles) || value.profiles.length === 0) return false; + const indices = new Set(); + const ids = new Set(); + for (const profile of value.profiles) { + if (!object(profile) || Object.keys(profile).some((key) => !profileFields.has(key))) + return false; + if ( + !index(profile.index) || + profile.index >= value.nextProfileIndex || + indices.has(profile.index) + ) + return false; + if (typeof profile.bapId !== 'string' || !profile.bapId.trim() || ids.has(profile.bapId)) + return false; + if ('metadata' in profile && (!object(profile.metadata) || !json(profile.metadata))) + return false; + indices.add(profile.index); + ids.add(profile.bapId); + } + return true; +} diff --git a/test/cli.test.ts b/test/cli.test.ts new file mode 100644 index 0000000..01cae7c --- /dev/null +++ b/test/cli.test.ts @@ -0,0 +1,190 @@ +import { afterAll, beforeAll, expect, test } from 'bun:test'; +import { chmod, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { version } from '../package.json'; +import { type DecryptedBackup, decryptBackup, getBackupType } from '../src/index'; + +const password = 'public-cli-test-password'; +const mnemonic = + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'; +const seed = { + format: 'sigma-seed', + version: 1, + mnemonic, + profiles: [ + { index: 7, bapId: 'public-profile', metadata: { name: 'Public', nested: [true, null, 3] } }, + ], + nextProfileIndex: 8, + createdAt: 0, + label: 'Public fixture', +}; +let directory: string; +const cli = resolve('dist/cli/bbackup.js'); +async function run(...args: string[]) { + const child = Bun.spawn([process.execPath, cli, ...args], { stdout: 'pipe', stderr: 'pipe' }); + const [code, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + return { code, stdout, stderr }; +} +beforeAll(async () => { + directory = await mkdtemp(join(tmpdir(), 'bitcoin-backup-cli-')); + const build = Bun.spawn([process.execPath, 'run', 'build'], { stdout: 'pipe', stderr: 'pipe' }); + const [code, stdout, stderr] = await Promise.all([ + build.exited, + new Response(build.stdout).text(), + new Response(build.stderr).text(), + ]); + if (code) throw new Error(`CLI build failed: ${stdout}${stderr}`); +}, 30000); +afterAll(async () => { + if (directory) await rm(directory, { recursive: true, force: true }); +}); + +test('built CLI reports package version and all command help', async () => { + expect((await run('--version')).stdout.trim()).toBe(version); + for (const command of ['enc', 'dec', 'upg', 'forget']) + expect((await run(command, '--help')).code).toBe(0); +}); + +for (const partial of [false, true]) { + test(`built seed CLI roundtrip/upgrade preserves ${partial ? 'partial' : 'complete'} inventory`, async () => { + const payload = { ...seed, ...(partial ? { inventoryComplete: false } : {}) }; + const input = join(directory, `seed-${partial}.json`); + const encrypted = join(directory, `seed-${partial}.bep`); + const output = join(directory, `seed-${partial}-output.json`); + const upgraded = join(directory, `seed-${partial}-upgraded.bep`); + await writeFile(input, JSON.stringify(payload)); + const enc = await run('enc', input, '-p', password, '-t', '2', '-o', encrypted); + expect(enc.code).toBe(0); + expect(enc.stdout + enc.stderr).not.toContain(mnemonic); + expect(enc.stdout + enc.stderr).not.toContain(password); + await writeFile(output, 'old output'); + await chmod(output, 0o644); + const dec = await run('dec', encrypted, '-p', password, '-t', '2', '-o', output); + expect(dec.code).toBe(0); + expect(dec.stdout + dec.stderr).not.toContain(mnemonic); + expect(JSON.parse(await readFile(output, 'utf8'))).toEqual(payload); + expect((await stat(output)).mode & 0o777).toBe(0o600); + expect(getBackupType(JSON.parse(await readFile(output, 'utf8')))).toBe('SigmaSeed'); + expect((await run('upg', encrypted, '-p', password, '-t', '2', '-o', upgraded)).code).toBe(0); + expect(await decryptBackup(await readFile(upgraded, 'utf8'), password)).toEqual(payload); + const printed = await run('dec', upgraded, '-p', password); + expect(printed.code).toBe(0); + expect(printed.stdout).toContain(mnemonic); + }, 15000); +} + +test('built CLI rejects malformed seeds, removed fields and secret-bearing malformed JSON without outputs', async () => { + const input = join(directory, 'invalid.json'); + const output = join(directory, 'must-not-write.bep'); + for (const payload of [ + { ...seed, version: 2 }, + { ...seed, scheme: 'brc157-peer-profiles' }, + { ...seed, entropyBytes: 16 }, + { ...seed, passphrasePolicy: 'empty' }, + { ...seed, rootPk: 'public-key', ids: '' }, + { ...seed, profiles: [] }, + ]) { + await writeFile(input, JSON.stringify(payload)); + expect((await run('enc', input, '-p', password, '-t', '1', '-o', output)).code).not.toBe(0); + expect(await Bun.file(output).exists()).toBe(false); + } + await writeFile(input, `{"mnemonic":"${mnemonic}" BROKEN`); + const invalid = await run('enc', input, '-p', password, '-o', output); + expect(invalid.code).not.toBe(0); + expect(invalid.stdout + invalid.stderr).not.toContain(mnemonic); +}); + +test('built CLI fails wrong passwords and invalid iterations without touching existing output', async () => { + const encrypted = join(directory, 'wrong-password.bep'); + const input = join(directory, 'wrong-password.json'); + await writeFile(input, JSON.stringify(seed)); + expect((await run('enc', input, '-p', password, '-t', '2', '-o', encrypted)).code).toBe(0); + const output = join(directory, 'keep.json'); + await writeFile(output, 'keep existing'); + expect( + (await run('dec', encrypted, '-p', 'wrong-public-password', '-t', '2', '-o', output)).code + ).not.toBe(0); + expect(await readFile(output, 'utf8')).toBe('keep existing'); + for (const value of ['0', '-1', '1.5', '2junk', '4294967296']) { + expect((await run('dec', encrypted, '-p', password, '-t', value, '-o', output)).code).not.toBe( + 0 + ); + } + expect(await readFile(output, 'utf8')).toBe('keep existing'); + expect((await run('dec', encrypted)).code).not.toBe(0); +}); + +test('built CLI preserves every legacy envelope family and default paths', async () => { + const fixtures: DecryptedBackup[] = [ + { rootPk: 'public-root', ids: 'public-ids' }, + { xprv: 'public-xprv', mnemonic: '', ids: 'public-ids' }, + { wif: 'public-member', id: 'public-id' }, + { wif: 'public-wif' }, + { ordPk: 'public-ord', payPk: 'public-pay', identityPk: 'public-identity' }, + { encryptedVault: 'public-vault', scheme: 'custom-vault-v2' }, + { payPk: 'public-pay', ordPk: 'public-ord', mnemonic: 'public-legacy-words' }, + { chromeStorage: { public: true } }, + ]; + for (const [index, fixture] of fixtures.entries()) { + const payload = { ...fixture, createdAt: '2026-01-01T00:00:00.000Z' }; + const input = join(directory, `legacy-${index}.json`); + const encrypted = join(directory, `legacy-${index}_encrypted.bep`); + const upgraded = join(directory, `legacy-${index}_encrypted_upgraded.bep`); + await writeFile(input, JSON.stringify(payload)); + expect((await run('enc', input, '-p', password, '-t', '100000')).code).toBe(0); + expect((await run('upg', encrypted, '-p', password)).code).toBe(0); + expect(await decryptBackup(await readFile(upgraded, 'utf8'), password)).toEqual(payload); + } +}, 30000); + +test('built CLI default KDF roundtrip and unknown ciphertext structure rejection', async () => { + const input = join(directory, 'default.json'); + const encrypted = join(directory, 'default.bep'); + const output = join(directory, 'default-output.json'); + await writeFile(input, JSON.stringify(seed)); + expect((await run('enc', input, '-p', password, '-o', encrypted)).code).toBe(0); + expect((await run('dec', encrypted, '-p', password, '-o', output)).code).toBe(0); + expect(JSON.parse(await readFile(output, 'utf8'))).toEqual(seed); + const salt = new Uint8Array(16); + const iv = new Uint8Array(12); + const material = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(password), + 'PBKDF2', + false, + ['deriveKey'] + ); + const key = await crypto.subtle.deriveKey( + { name: 'PBKDF2', salt, iterations: 1, hash: 'SHA-256' }, + material, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt'] + ); + for (const payload of [ + { ...seed, version: 2 }, + { rootPk: 'public-root', ids: '', format: 'unknown-format' }, + ]) { + const ciphertext = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + new TextEncoder().encode(JSON.stringify(payload)) + ); + await writeFile( + encrypted, + Buffer.concat([salt, iv, new Uint8Array(ciphertext)]).toString('base64') + ); + await writeFile(output, 'preserve existing'); + for (const command of ['dec', 'upg']) { + const result = await run(command, encrypted, '-p', password, '-t', '1', '-o', output); + expect(result.code).not.toBe(0); + expect(result.stdout + result.stderr).not.toContain(mnemonic); + expect(await readFile(output, 'utf8')).toBe('preserve existing'); + } + } +}, 15000); diff --git a/test/fixtures/legacy-reader.ts b/test/fixtures/legacy-reader.ts new file mode 100644 index 0000000..f3ff9c3 --- /dev/null +++ b/test/fixtures/legacy-reader.ts @@ -0,0 +1,214 @@ +// Frozen pre-seed reader from bitcoin-backup e443e02, retained for compatibility tests. +import { Utils } from '@bsv/sdk'; +import type { + BapAccountBackup, + BapMasterBackup, + DecryptedBackup, + EncryptedBackup, + OneSatBackup, + VaultBackup, + WifBackup, + YoursWalletBackup, + YoursWalletZipBackup, +} from '../../src/interfaces'; + +const { toArray, toBase64 } = Utils; + +export const RECOMMENDED_PBKDF2_ITERATIONS = 600000; +export const LEGACY_PBKDF2_ITERATIONS = 100000; + +// This export will be what users see as the "current default" +export const DEFAULT_PBKDF2_ITERATIONS = RECOMMENDED_PBKDF2_ITERATIONS; + +const SALT_LENGTH_BYTES = 16; +const IV_LENGTH_BYTES = 12; +const AES_KEY_LENGTH_BITS = 256; + +/** + * Derives a cryptographic key from a passphrase and salt using PBKDF2 and AES-GCM. + * @param passphrase The passphrase to derive the key from. + * @param salt The salt to use for key derivation. + * @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( + passphrase: string, + salt: Uint8Array, + iterations: number = RECOMMENDED_PBKDF2_ITERATIONS // Default to new recommended standard +): Promise { + const passphraseBytes = Uint8Array.from(toArray(passphrase, 'utf8')); + + const keyMaterial = await globalThis.crypto.subtle.importKey( + 'raw', + passphraseBytes, + { name: 'PBKDF2' }, + false, + ['deriveKey'] + ); + + return globalThis.crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt, + iterations, + hash: 'SHA-256', + }, + keyMaterial, + { name: 'AES-GCM', length: AES_KEY_LENGTH_BITS }, + false, + ['encrypt', 'decrypt'] + ); +} + +/** + * Encrypts a backup payload object into a Base64 encoded string. + * The string concatenates salt, IV, and the encrypted content. + * @param iterations Optional number of PBKDF2 iterations. Defaults to DEFAULT_PBKDF2_ITERATIONS. + */ +export async function encryptData( + payload: DecryptedBackup, + passphrase: string, + iterations?: number // Optional iterations for encryption +): Promise { + const salt = globalThis.crypto.getRandomValues(new Uint8Array(SALT_LENGTH_BYTES)); + const iv = globalThis.crypto.getRandomValues(new Uint8Array(IV_LENGTH_BYTES)); + + // deriveKey will use its default (DEFAULT_PBKDF2_ITERATIONS) if iterations is undefined + const key = await deriveKey(passphrase, salt, iterations); + + const payloadToEncrypt = { + ...payload, + createdAt: payload.createdAt || new Date().toISOString(), + }; + + const jsonPayload = JSON.stringify(payloadToEncrypt); + const dataToEncrypt = new TextEncoder().encode(jsonPayload); + + const encryptedContent = await globalThis.crypto.subtle.encrypt( + { name: 'AES-GCM', iv: iv }, + key, + dataToEncrypt + ); + + const combined = new Uint8Array(salt.length + iv.length + encryptedContent.byteLength); + combined.set(salt, 0); + combined.set(iv, salt.length); + combined.set(new Uint8Array(encryptedContent), salt.length + iv.length); + + return toBase64(Array.from(combined)); +} + +/** + * Decrypts an encrypted backup string back into a backup payload object. + * Handles JSON-structured and legacy raw WIF backups. + * @param attemptIterations Optional. A specific iteration count, or an array of counts to try in order. + * Defaults to trying [DEFAULT_PBKDF2_ITERATIONS, LEGACY_PBKDF2_ITERATIONS]. + */ +export async function decryptData( + encryptedBackup: EncryptedBackup, + passphrase: string, + attemptIterations?: number | number[] +): Promise { + let combinedBytesNumbers: number[]; + try { + combinedBytesNumbers = toArray(encryptedBackup, 'base64'); + } catch (error) { + console.error('Failed to decode base64 string (toArray threw):', error); + throw new Error('Decryption failed: Invalid Base64 input.'); + } + + if (encryptedBackup.length > 0 && combinedBytesNumbers.length === 0) { + throw new Error('Decryption failed: Invalid Base64 input (decoded to empty).'); + } + + const combinedBytes = Uint8Array.from(combinedBytesNumbers); + + if (combinedBytes.length < SALT_LENGTH_BYTES + IV_LENGTH_BYTES) { + throw new Error('Decryption failed: Encrypted data is too short.'); + } + + const salt = combinedBytes.slice(0, SALT_LENGTH_BYTES); + const iv = combinedBytes.slice(SALT_LENGTH_BYTES, SALT_LENGTH_BYTES + IV_LENGTH_BYTES); + const encryptedCiphertext = combinedBytes.slice(SALT_LENGTH_BYTES + IV_LENGTH_BYTES); + + const iterationCountsToTry: number[] = + typeof attemptIterations === 'number' + ? [attemptIterations] + : Array.isArray(attemptIterations) + ? attemptIterations + : [RECOMMENDED_PBKDF2_ITERATIONS, LEGACY_PBKDF2_ITERATIONS]; // Updated default order + + let lastError: Error | null = null; + + for (const iterations of iterationCountsToTry) { + try { + const key = await deriveKey(passphrase, salt, iterations); + const decryptedArrayBuffer = await globalThis.crypto.subtle.decrypt( + { name: 'AES-GCM', iv: iv }, + key, + encryptedCiphertext + ); + const decryptedString = new TextDecoder().decode(decryptedArrayBuffer); + try { + const parsedJson = JSON.parse(decryptedString); + if (typeof parsedJson === 'object' && parsedJson !== null) { + 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; + } + } catch (decryptionError) { + lastError = decryptionError as Error; + // console.log(`Decryption attempt failed with ${iterations} iterations.`); // Optional: for debugging + if (decryptionError instanceof DOMException && decryptionError.name === 'OperationError') { + // This is the expected error for wrong key / corrupted data, continue to next iteration count + continue; + } + throw decryptionError; // Rethrow unexpected errors immediately + } + } + // If all iteration counts failed + console.error('All decryption attempts failed. Last error:', lastError); + if (lastError && lastError.name === 'OperationError') { + throw new Error( + 'Decryption failed: Invalid passphrase or corrupted data across all attempted iteration counts.' + ); + } + throw ( + lastError || + new Error( + 'Decryption failed: Invalid passphrase or corrupted data across all attempted iteration counts.' + ) + ); +} diff --git a/test/index.test.ts b/test/index.test.ts index 4f7a49f..533dec9 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -43,7 +43,7 @@ describe('Public API Functions (index.ts)', () => { // @ts-expect-error Testing invalid payload type encryptBackup(invalidStructurePayload, validPassphrase) ).rejects.toThrow( - 'Invalid payload: Payload must be an object matching BapMasterBackup, BapAccountBackup, WifBackup, OneSatBackup, VaultBackup, YoursWalletBackup, or YoursWalletZipBackup structure.' + 'Invalid payload: Payload must be an object matching SigmaSeedBackup, BapMasterBackup, BapAccountBackup, WifBackup, OneSatBackup, VaultBackup, YoursWalletBackup, or YoursWalletZipBackup structure.' ); }); @@ -52,7 +52,7 @@ describe('Public API Functions (index.ts)', () => { // @ts-expect-error Testing invalid payload type encryptBackup(null, validPassphrase) ).rejects.toThrow( - 'Invalid payload: Payload must be an object matching BapMasterBackup, BapAccountBackup, WifBackup, OneSatBackup, VaultBackup, YoursWalletBackup, or YoursWalletZipBackup structure.' + 'Invalid payload: Payload must be an object matching SigmaSeedBackup, BapMasterBackup, BapAccountBackup, WifBackup, OneSatBackup, VaultBackup, YoursWalletBackup, or YoursWalletZipBackup structure.' ); }); @@ -61,7 +61,7 @@ describe('Public API Functions (index.ts)', () => { // @ts-expect-error Testing invalid payload type encryptBackup('not an object', validPassphrase) ).rejects.toThrow( - 'Invalid payload: Payload must be an object matching BapMasterBackup, BapAccountBackup, WifBackup, OneSatBackup, VaultBackup, YoursWalletBackup, or YoursWalletZipBackup structure.' + 'Invalid payload: Payload must be an object matching SigmaSeedBackup, BapMasterBackup, BapAccountBackup, WifBackup, OneSatBackup, VaultBackup, YoursWalletBackup, or YoursWalletZipBackup structure.' ); }); diff --git a/test/seed-snapshot.test.ts b/test/seed-snapshot.test.ts new file mode 100644 index 0000000..17af12c --- /dev/null +++ b/test/seed-snapshot.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from 'bun:test'; +import { decryptBackup, encryptBackup, type SigmaSeedBackup } from '../src/index'; + +test('encryption snapshots the seed inventory before asynchronous key derivation', async () => { + const backup: SigmaSeedBackup = { + format: 'sigma-seed', + version: 1, + mnemonic: + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + profiles: [{ index: 0, bapId: 'public-test-profile', metadata: { name: 'Original' } }], + nextProfileIndex: 1, + createdAt: 0, + }; + const original = structuredClone(backup); + const pending = encryptBackup(backup, 'public-test-password', 1); + backup.profiles[0].metadata!.name = 'Changed'; + backup.profiles.length = 0; + expect(await decryptBackup(await pending, 'public-test-password', 1)).toEqual(original); +}); diff --git a/test/sigma-seed.test.ts b/test/sigma-seed.test.ts new file mode 100644 index 0000000..2209800 --- /dev/null +++ b/test/sigma-seed.test.ts @@ -0,0 +1,250 @@ +import { describe, expect, it } from 'bun:test'; +import { + type DecryptedBackup, + decryptBackup, + encryptBackup, + getBackupType, + isAccountBackup, + isLegacyBackup, + isMasterBackup, + isSigmaSeedBackup, + isType42Backup, + isWifBackup, + type SigmaSeedBackup, +} from '../src/index'; +import { decryptData as legacyDecrypt } from './fixtures/legacy-reader'; + +// Public BIP39 all-zero entropy vector. Never use for real keys. +const seed: SigmaSeedBackup = { + format: 'sigma-seed', + version: 1, + mnemonic: + 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + profiles: [ + { + index: 0, + bapId: 'public-test-profile', + metadata: { name: 'Example', nested: [null, true, 1] }, + }, + ], + nextProfileIndex: 1, + createdAt: 0, +}; +const password = 'public-test-password'; + +// Encrypt unvalidated JSON to exercise the untrusted decrypted payload boundary. +async function rawEncrypted(payload: unknown): Promise { + const salt = new Uint8Array(16); + const iv = new Uint8Array(12); + const material = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(password), + 'PBKDF2', + false, + ['deriveKey'] + ); + const key = await crypto.subtle.deriveKey( + { name: 'PBKDF2', salt, iterations: 1, hash: 'SHA-256' }, + material, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt'] + ); + const ciphertext = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + new TextEncoder().encode(JSON.stringify(payload)) + ); + return Buffer.concat([salt, iv, new Uint8Array(ciphertext)]).toString('base64'); +} + +describe('Sigma seed envelope', () => { + it('preserves legacy payloads under both new and frozen old readers', async () => { + const fixtures: Exclude[] = [ + { rootPk: 'public-structural-fixture', ids: 'public-ids' }, + { xprv: 'public-structural-fixture', mnemonic: 'legacy mnemonic', ids: 'public-ids' }, + { wif: 'public-structural-fixture', id: 'public-id' }, + { wif: 'public-structural-fixture' }, + { ordPk: 'public-ord', payPk: 'public-pay', identityPk: 'public-identity' }, + { encryptedVault: 'public-vault' }, + ]; + for (const fixture of fixtures) { + const payload = { ...fixture, createdAt: '2026-01-01T00:00:00.000Z' }; + const ciphertext = await encryptBackup(payload, password, 1); + expect(await decryptBackup(ciphertext, password, 1)).toEqual(payload); + expect(await legacyDecrypt(ciphertext, password, 1)).toEqual(payload); + } + }); + + it('is rejected by the frozen pre-seed reader after successful decryption', async () => { + const ciphertext = await encryptBackup(seed, password, 1); + await expect(legacyDecrypt(ciphertext, password, 1)).rejects.toThrow( + 'Invalid backup structure after JSON parse.' + ); + }); + + it('round trips with default encryption and preserves numeric createdAt zero and JSON metadata', async () => { + const restored = await decryptBackup(await encryptBackup(seed, password), password); + expect(restored).toEqual(seed); + expect(isSigmaSeedBackup(restored)).toBe(true); + expect(isMasterBackup(restored)).toBe(false); + expect(getBackupType(restored)).toBe('SigmaSeed'); + }); + + it('supports all mnemonic word counts and hardened peer indices', () => { + for (const wordCount of [12, 15, 18, 21, 24]) { + expect( + isSigmaSeedBackup({ + ...seed, + mnemonic: Array(wordCount).fill('abandon').join(' '), + }) + ).toBe(true); + } + expect(isSigmaSeedBackup({ ...seed, profiles: [], nextProfileIndex: 0 })).toBe(false); + expect(isSigmaSeedBackup({ ...seed, createdAt: 0.5 })).toBe(false); + expect( + isSigmaSeedBackup({ + ...seed, + profiles: [{ index: 2147483647, bapId: 'final-peer' }], + nextProfileIndex: 2147483648, + }) + ).toBe(true); + expect( + isSigmaSeedBackup({ + ...seed, + profiles: [{ index: 2147483646, bapId: 'last-available' }], + nextProfileIndex: 2147483647, + }) + ).toBe(true); + }); + + const malformed: Record[] = [ + { ...seed, version: 2 }, + { ...seed, format: 'future-seed' }, + { ...seed, scheme: 'unknown' }, + { ...seed, scheme: 'brc157-peer-profiles' }, + { ...seed, passphrasePolicy: 'empty' }, + { ...seed, entropyBytes: 16 }, + { ...seed, passphrasePolicy: 'optional' }, + { ...seed, rootPk: 'legacy-root', ids: 'legacy-ids' }, + { ...seed, xprv: 'legacy-root', ids: 'legacy-ids' }, + { ...seed, wif: 'legacy-key' }, + { ...seed, encryptedVault: 'legacy-vault' }, + { ...seed, unexpected: true }, + { ...seed, entropyBytes: 17 }, + { ...seed, mnemonic: 'abandon' }, + { ...seed, createdAt: '2026-01-01' }, + { ...seed, createdAt: -1 }, + { ...seed, nextProfileIndex: 0 }, + { ...seed, nextProfileIndex: 2147483649 }, + { ...seed, profiles: [{ index: 0.5, bapId: 'test' }] }, + { ...seed, profiles: [{ index: -1, bapId: 'test' }] }, + { ...seed, profiles: [{ index: 2147483648, bapId: 'test' }] }, + { ...seed, profiles: [{ index: 0, bapId: '' }] }, + { ...seed, profiles: [{ index: 0, bapId: 'test', metadata: [] }] }, + { ...seed, profiles: [{ index: 0, bapId: 'test', path: "m/0'/0'" }] }, + { + ...seed, + profiles: [ + { index: 0, bapId: 'one' }, + { index: 0, bapId: 'two' }, + ], + }, + { + ...seed, + profiles: [ + { index: 0, bapId: 'one' }, + { index: 1, bapId: 'one' }, + ], + nextProfileIndex: 2, + }, + ]; + for (const [i, payload] of malformed.entries()) { + it(`rejects malformed envelope ${i} at encryption and decryption boundaries`, async () => { + expect(isSigmaSeedBackup(payload)).toBe(false); + await expect( + encryptBackup(payload as unknown as DecryptedBackup, password, 1) + ).rejects.toThrow(); + await expect(decryptBackup(await rawEncrypted(payload), password, 1)).rejects.toThrow(); + }); + } + + const incompleteMarkers = [ + { format: 'unknown-seed-format' }, + { scheme: 'unknown-seed-scheme' }, + { profiles: [] }, + { nextProfileIndex: 0 }, + { entropyBytes: 16 }, + { passphrasePolicy: 'unknown' }, + { format: null }, + { scheme: null }, + ]; + const legacySources: DecryptedBackup[] = [ + { rootPk: 'public-root-fixture', ids: 'public-ids' }, + { xprv: 'public-xprv-fixture', mnemonic: 'legacy words', ids: 'public-ids' }, + { wif: 'public-member-fixture', id: 'public-member-id' }, + { wif: 'public-wif-fixture' }, + ]; + for (const [sourceIndex, source] of legacySources.entries()) { + for (const marker of incompleteMarkers) { + it(`rejects incomplete ${Object.keys(marker)[0]} marker mixed with legacy source ${sourceIndex}`, async () => { + const mixed = { ...source, ...marker }; + expect(isSigmaSeedBackup(mixed)).toBe(false); + for (const guard of [ + isLegacyBackup, + isType42Backup, + isMasterBackup, + isAccountBackup, + isWifBackup, + ]) { + expect(guard(mixed as unknown as DecryptedBackup)).toBe(false); + } + expect(getBackupType(mixed as unknown as DecryptedBackup)).toBe('Unknown'); + await expect( + encryptBackup(mixed as unknown as DecryptedBackup, password, 1) + ).rejects.toThrow(); + await expect(decryptBackup(await rawEncrypted(mixed), password, 1)).rejects.toThrow(); + }); + } + } + + it('rejects non-JSON metadata before encryption', async () => { + const cycle: Record = {}; + cycle.self = cycle; + for (const metadata of [{ number: Number.NaN }, { missing: undefined }, cycle, new Date()]) { + expect( + isSigmaSeedBackup({ ...seed, profiles: [{ index: 0, bapId: 'test', metadata }] }) + ).toBe(false); + } + }); +}); + +describe('partial seed inventory', () => { + it('round-trips the explicit incomplete inventory marker', async () => { + const partial: SigmaSeedBackup = { ...seed, inventoryComplete: false }; + expect(isSigmaSeedBackup(partial)).toBe(true); + expect( + await decryptBackup(await encryptBackup(partial, 'test-password'), 'test-password') + ).toEqual(partial); + }); + it.each([ + true, + 'false', + null, + 0, + ])('rejects invalid inventoryComplete %p', async (inventoryComplete) => { + const invalid = { ...seed, inventoryComplete }; + expect(isSigmaSeedBackup(invalid)).toBe(false); + await expect( + encryptBackup(invalid as unknown as SigmaSeedBackup, 'test-password') + ).rejects.toThrow(); + }); + it('reserves the marker on legacy payloads', async () => { + await expect( + encryptBackup( + { rootPk: 'fixture', ids: '', inventoryComplete: false } as unknown as SigmaSeedBackup, + 'test-password' + ) + ).rejects.toThrow(); + }); +}); diff --git a/test/type42-integration.test.ts b/test/type42-integration.test.ts index f4282d4..29a4c01 100644 --- a/test/type42-integration.test.ts +++ b/test/type42-integration.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'bun:test'; import { PrivateKey } from '@bsv/sdk'; -import { BAP } from '../../bap/src/index'; +import { BAP } from 'bsv-bap'; import { type BapMasterBackup, decryptBackup, encryptBackup } from '../src/index'; describe('Type 42 Integration with BAP', () => { @@ -53,6 +53,12 @@ describe('Type 42 Integration with BAP', () => { // 10. Verify we have the same identities const idKeys = bapRestored.listIds(); expect(idKeys.length).toBe(2); + expect(idKeys).toEqual(bap.listIds()); + for (const id of idKeys) { + expect(bapRestored.getId(id)?.getAccountKey().toWif()).toBe( + bap.getId(id)?.getAccountKey().toWif() + ); + } // Check that we can retrieve the identities and they have the right structure const restoredId1 = bapRestored.getId(idKeys[0]);