Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 42 additions & 1 deletion API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
```
```
## 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.
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
70 changes: 67 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ npx bbackup --help
**Common Options:**
* `-p, --password <password>`: (Required) The passphrase for encryption/decryption.
* `-o, --output <outputFile>`: (Optional) Path for the output file. Defaults are sensible (e.g., `<input>.bep` for encrypt, `<input>.json` for decrypt).
* `-t, --iterations <iterations>`: (Optional, for `enc` command) Number of PBKDF2 iterations.
* `-t, --iterations <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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
7 changes: 7 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

58 changes: 49 additions & 9 deletions cli/bbackup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -71,7 +80,7 @@ program
.option(
'-t, --iterations <count>',
'Number of PBKDF2 iterations',
(val) => Number.parseInt(val, 10),
parseIterations,
RECOMMENDED_PBKDF2_ITERATIONS
)
.action(
Expand Down Expand Up @@ -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);
}
Expand All @@ -142,6 +156,11 @@ program
program
.command('dec <inputFile>')
.description('Decrypt an encrypted backup file.')
.option(
'-t, --iterations <count>',
'Input PBKDF2 iterations (default: try 600000 and 100000)',
parseIterations
)
.option('-p, --password <password>', 'Passphrase for decryption')
.option('--touchid', 'Use Touch ID to retrieve or cache password')
.option(
Expand All @@ -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({
Expand All @@ -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,
Expand All @@ -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');
Expand All @@ -207,13 +238,18 @@ program
program
.command('upg <inputFile>')
.description('Upgrade an encrypted backup file to the recommended PBKDF2 iterations.')
.option(
'-t, --iterations <count>',
'Input PBKDF2 iterations; output always uses 600000 (default: try 600000 and 100000)',
parseIterations
)
.option('-p, --password <password>', 'Passphrase for decryption and re-encryption')
.option('--touchid', 'Use Touch ID to retrieve or cache password')
.option('-o, --output <outputFile>', '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,
Expand All @@ -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...`);
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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": {
Expand Down
23 changes: 19 additions & 4 deletions src/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
YoursWalletBackup,
YoursWalletZipBackup,
} from './interfaces';
import { hasSigmaSeedMarker, isSigmaSeedBackup } from './seed';

const { toArray, toBase64 } = Utils;

Expand Down Expand Up @@ -69,20 +70,29 @@ export async function encryptData(
passphrase: string,
iterations?: number // Optional iterations for encryption
): Promise<EncryptedBackup> {
if (
hasSigmaSeedMarker(payload as unknown as Record<string, unknown>) &&
!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,
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading