Files
wikideck/shared/wallet-backup.ts
T
2026-11-01 16:43:28 -05:00

165 lines
8.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Browser/Node WebCrypto only: this module never reads storage or sends data.
import { IS_MAINNET, NETWORK, NETWORK_GENESIS_TIME } from './network.ts';
import { expectedChainIdentity } from './chain-identity.ts';
const ITERATIONS = 600_000;
const MAX_BYTES = 32 * 1024;
const encoder = new TextEncoder();
type WalletBackupBase = {
format: 'wikideck-wallet-backup';
network: typeof NETWORK;
kdf: 'PBKDF2-SHA256';
iterations: 600000;
cipher: 'AES-256-GCM';
salt: string;
iv: string;
publicKey: string;
ciphertext: string;
};
export type WalletBackup = WalletBackupBase & (
| { version: 1; chainId?: never }
| { version: 2; chainId: string }
);
function encode(bytes: ArrayBuffer | Uint8Array<ArrayBuffer>): string {
return btoa(String.fromCharCode(...new Uint8Array(bytes)));
}
function decode(value: unknown, min: number, max: number): Uint8Array<ArrayBuffer> {
if (typeof value !== 'string' || value.length > Math.ceil(max / 3) * 4 ||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
throw new Error('Invalid backup encoding.');
}
const bytes = Uint8Array.from(atob(value), char => char.charCodeAt(0));
if (bytes.length < min || bytes.length > max || encode(bytes) !== value) {
throw new Error('Invalid backup field size or encoding.');
}
return bytes;
}
function passwordBytes(password: string, minimum: number): Uint8Array<ArrayBuffer> {
if (typeof password !== 'string' || password.length < minimum || password.length > 1024) {
throw new Error(`Password must contain ${minimum}–1024 characters.`);
}
return encoder.encode(password);
}
async function encryptionKey(password: string, salt: Uint8Array<ArrayBuffer>, usage: 'encrypt' | 'decrypt') {
const bytes = passwordBytes(password, usage === 'encrypt' ? 12 : 1);
try {
const material = await crypto.subtle.importKey('raw', bytes, 'PBKDF2', false, ['deriveKey']);
return await crypto.subtle.deriveKey(
{ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: ITERATIONS }, material,
{ name: 'AES-GCM', length: 256 }, false, [usage],
);
} finally { bytes.fill(0); }
}
// Fixed-order AAD authenticates every header field, including identity/network.
function additionalData(backup: WalletBackup): Uint8Array<ArrayBuffer> {
return encoder.encode(JSON.stringify([
backup.format, backup.version, backup.network,
...(backup.version === 2 ? [backup.chainId] : []), backup.kdf, backup.iterations,
backup.cipher, backup.salt, backup.iv, backup.publicKey,
]));
}
async function validateBackup(value: unknown): Promise<WalletBackup> {
if (!value || typeof value !== 'object' || Array.isArray(value) ||
![Object.prototype, null].includes(Object.getPrototypeOf(value))) {
throw new Error('Invalid wallet backup.');
}
const descriptors = Object.getOwnPropertyDescriptors(value);
const version = descriptors.version && Object.hasOwn(descriptors.version, 'value') ? descriptors.version.value : undefined;
if (version !== 1 && version !== 2) throw new Error('Unsupported wallet backup format, network, or cryptography.');
const names = ['format', 'version', 'network', ...(version === 2 ? ['chainId'] : []),
'kdf', 'iterations', 'cipher', 'salt', 'iv', 'publicKey', 'ciphertext'];
if (Reflect.ownKeys(value).length !== names.length || names.some(name =>
!Object.hasOwn(descriptors, name) || !Object.hasOwn(descriptors[name], 'value'))) {
throw new Error('Invalid wallet backup fields.');
}
const backup = value as WalletBackup;
if (backup.format !== 'wikideck-wallet-backup' || backup.network !== NETWORK ||
backup.kdf !== 'PBKDF2-SHA256' || backup.iterations !== ITERATIONS || backup.cipher !== 'AES-256-GCM') {
throw new Error('Unsupported wallet backup format, network, or cryptography.');
}
if (backup.version === 2) {
const expected = await expectedChainIdentity();
if (backup.chainId !== expected.chainId) throw new Error('Wallet backup is for a different chain.');
} else if (IS_MAINNET || NETWORK_GENESIS_TIME !== 1_792_411_200) {
// Legacy v1 headers had no chain identity, so accept them only on the original testnet.
throw new Error('Legacy wallet backups are supported only on the original testnet.');
}
decode(backup.salt, 16, 16);
decode(backup.iv, 12, 12);
decode(backup.publicKey, 91, 91);
decode(backup.ciphertext, 17, 4096);
if (encoder.encode(JSON.stringify(backup)).length > MAX_BYTES) throw new Error('Backup exceeds 32 KB.');
// Snapshot primitives so callers cannot change headers during awaits.
const snapshot: WalletBackupBase = { format: backup.format, network: backup.network,
kdf: backup.kdf, iterations: backup.iterations, cipher: backup.cipher,
salt: backup.salt, iv: backup.iv, publicKey: backup.publicKey, ciphertext: backup.ciphertext };
return backup.version === 2
? { ...snapshot, version: 2, chainId: backup.chainId }
: { ...snapshot, version: 1 };
}
async function validatePair(privateKey: CryptoKey, publicKey: string): Promise<string> {
if (!privateKey || privateKey.type !== 'private' || privateKey.algorithm.name !== 'ECDSA' ||
(privateKey.algorithm as EcKeyAlgorithm).namedCurve !== 'P-256' ||
!privateKey.extractable || !privateKey.usages.includes('sign')) {
throw new Error('An exportable P-256 ECDSA signing key is required.');
}
const spki = decode(publicKey, 91, 91);
const verifyingKey = await crypto.subtle.importKey('spki', spki,
{ name: 'ECDSA', namedCurve: 'P-256' }, true, ['verify']);
if (encode(await crypto.subtle.exportKey('spki', verifyingKey)) !== publicKey) {
throw new Error('Public key must be canonical SPKI DER.');
}
const challenge = crypto.getRandomValues(new Uint8Array(32));
const signature = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, privateKey, challenge);
if (!await crypto.subtle.verify({ name: 'ECDSA', hash: 'SHA-256' }, verifyingKey, signature, challenge)) {
throw new Error('Wallet key pair does not match.');
}
const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', spki));
return Array.from(digest, byte => byte.toString(16).padStart(2, '0')).join('');
}
export async function encryptWallet(privateKey: CryptoKey, publicKey: string, password: string): Promise<WalletBackup> {
passwordBytes(password, 12).fill(0);
await validatePair(privateKey, publicKey);
const { chainId } = await expectedChainIdentity();
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const backup: WalletBackup = { format: 'wikideck-wallet-backup', version: 2, network: NETWORK, chainId,
kdf: 'PBKDF2-SHA256', iterations: ITERATIONS, cipher: 'AES-256-GCM',
salt: encode(salt), iv: encode(iv), publicKey, ciphertext: '' };
const key = await encryptionKey(password, salt, 'encrypt');
const pkcs8 = new Uint8Array(await crypto.subtle.exportKey('pkcs8', privateKey));
try {
backup.ciphertext = encode(await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv, additionalData: additionalData(backup), tagLength: 128 }, key, pkcs8,
));
return validateBackup(backup);
} finally { pkcs8.fill(0); }
}
export async function decryptWallet(backup: unknown, password: string): Promise<{
privateKey: CryptoKey; publicKey: string; address: string;
}> {
const validated = await validateBackup(backup); // Bound work before PBKDF2.
const key = await encryptionKey(password, decode(validated.salt, 16, 16), 'decrypt');
let plaintext: Uint8Array<ArrayBuffer> | undefined;
try {
plaintext = new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM',
iv: decode(validated.iv, 12, 12), additionalData: additionalData(validated), tagLength: 128,
}, key, decode(validated.ciphertext, 17, 4096)));
const privateKey = await crypto.subtle.importKey('pkcs8', plaintext,
{ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign']);
const address = await validatePair(privateKey, validated.publicKey);
return { privateKey, publicKey: validated.publicKey, address };
} catch {
throw new Error('Cannot unlock backup: incorrect password, damaged backup, or mismatched wallet keys.');
} finally { plaintext?.fill(0); }
}