146 lines
7.1 KiB
TypeScript
146 lines
7.1 KiB
TypeScript
// Browser/Node WebCrypto only: this module never reads storage or sends data.
|
||
const NETWORK = 'wikideck-randomx-testnet-2';
|
||
const ITERATIONS = 600_000;
|
||
const MAX_BYTES = 32 * 1024;
|
||
const encoder = new TextEncoder();
|
||
|
||
export type WalletBackup = {
|
||
format: 'wikideck-wallet-backup';
|
||
version: 1;
|
||
network: 'wikideck-randomx-testnet-2';
|
||
kdf: 'PBKDF2-SHA256';
|
||
iterations: 600000;
|
||
cipher: 'AES-256-GCM';
|
||
salt: string;
|
||
iv: string;
|
||
publicKey: string;
|
||
ciphertext: 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.kdf, backup.iterations,
|
||
backup.cipher, backup.salt, backup.iv, backup.publicKey,
|
||
]));
|
||
}
|
||
|
||
function validateBackup(value: unknown): WalletBackup {
|
||
if (!value || typeof value !== 'object' || Array.isArray(value) ||
|
||
![Object.prototype, null].includes(Object.getPrototypeOf(value))) {
|
||
throw new Error('Invalid wallet backup.');
|
||
}
|
||
const names = ['format', 'version', 'network', 'kdf', 'iterations', 'cipher', 'salt', 'iv', 'publicKey', 'ciphertext'];
|
||
const descriptors = Object.getOwnPropertyDescriptors(value);
|
||
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.version !== 1 || 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.');
|
||
}
|
||
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.
|
||
return { format: backup.format, version: backup.version, network: backup.network,
|
||
kdf: backup.kdf, iterations: backup.iterations, cipher: backup.cipher,
|
||
salt: backup.salt, iv: backup.iv, publicKey: backup.publicKey, ciphertext: backup.ciphertext };
|
||
}
|
||
|
||
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 salt = crypto.getRandomValues(new Uint8Array(16));
|
||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||
const backup: WalletBackup = { format: 'wikideck-wallet-backup', version: 1, network: NETWORK,
|
||
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 = 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); }
|
||
}
|