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
39 changes: 39 additions & 0 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<Uint8Array> };

sealBackup(payload: DecryptedBackup, slots: SlotSpec[]): Promise<EncryptedBackup>
openBackup(encrypted: EncryptedBackup, unlock: Unlock): Promise<DecryptedBackup>
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<EncryptedBackup>
removeSlot(encrypted: EncryptedBackup, unlock: Unlock, slotId: string): Promise<EncryptedBackup>
rewrapBackup(encrypted: EncryptedBackup, unlock: Unlock, slots: SlotSpec[]): Promise<EncryptedBackup>
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.
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,15 @@
# 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.
- `updateBackupPayload` re-encrypts a new payload under an envelope's existing content key, keeping every slot.
- CLI: `bbackup enc --device-pubkey <hex>` (repeatable) writes a v2 envelope sealed to the passphrase and each device key; `bbackup slot add|remove` manages slots; `bbackup slots <file>` inspects slots without a passphrase.
- `decryptBackup` transparently opens v2 pbkdf2 slots; `encryptBackup` still writes v1. Existing `.bep` files decrypt unchanged.

## 0.0.14

### Fixed
Expand Down
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.)*

Expand All @@ -118,6 +133,10 @@ npx bbackup --help
| `bbackup enc <inputFile>` | Encrypts a JSON input file. | `bbackup enc wallet.json -p "secret" -o wallet.bep` |
| `bbackup dec <inputFile>` | Decrypts a `.bep` file. | `bbackup dec wallet.bep -p "secret" -o wallet.json` |
| `bbackup upg <inputFile>` | Upgrades an encrypted file to recommended PBKDF2 iterations. | `bbackup upg old_wallet.bep -p "secret" -o upgraded_wallet.bep` |
| `bbackup enc <input> --device-pubkey <hex>` | 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 <file>` | Add a pbkdf2 or device slot, unlocking with `-p`. | `bbackup slot add wallet.bep -p "secret" --device-pubkey 04ab…` |
| `bbackup slot remove <file> <id>` | Remove a slot; refuses the last one. | `bbackup slot remove wallet.bep device-1 -p "secret"` |
| `bbackup slots <file>` | Prints envelope version and key slots as JSON (no passphrase). | `bbackup slots wallet.bep` |

**Common Options:**
* `-p, --password <password>`: (Required) The passphrase for encryption/decryption.
Expand Down
127 changes: 121 additions & 6 deletions cli/bbackup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +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();
Expand Down Expand Up @@ -83,10 +87,22 @@ program
parseIterations,
RECOMMENDED_PBKDF2_ITERATIONS
)
.option(
'--device-pubkey <hex>',
'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) {
Expand Down Expand Up @@ -117,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);
Expand Down Expand Up @@ -315,6 +342,94 @@ program
}
);

// --- slots ---

program
.command('slots <file>')
.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);
}
});

// --- slot add / remove ---

const slot = program.command('slot').description('Manage key slots on a v2 envelope.');

slot
.command('add <file>')
.description('Add a slot, unlocking with the existing passphrase.')
.option('-p, --password <password>', 'Passphrase of an existing pbkdf2 slot')
.option('--device-pubkey <hex>', 'P-256 device public key (65-byte X9.63 hex) for the new slot')
.option('--new-password <password>', 'Passphrase for a new pbkdf2 slot')
.option('--id <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 <password>.');
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 <file> <slotId>')
.description('Remove a slot, unlocking with the existing passphrase. Refuses the last slot.')
.option('-p, --password <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 <password>.');
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
Expand Down
2 changes: 1 addition & 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.14",
"version": "0.1.0",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
Expand Down
Loading
Loading