This commit is contained in:
2026-10-29 11:45:51 -04:00
parent b3a1179703
commit b427fa4217
35 changed files with 469 additions and 158 deletions
+26 -9
View File
@@ -1,12 +1,12 @@
// Browser/Node WebCrypto only: this module never reads storage or sends data.
import { NETWORK } from './network.ts';
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();
export type WalletBackup = {
type WalletBackupBase = {
format: 'wikideck-wallet-backup';
version: 1;
network: typeof NETWORK;
kdf: 'PBKDF2-SHA256';
iterations: 600000;
@@ -16,6 +16,10 @@ export type WalletBackup = {
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)));
@@ -54,27 +58,38 @@ async function encryptionKey(password: string, salt: Uint8Array<ArrayBuffer>, us
// 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.format, backup.version, backup.network,
...(backup.version === 2 ? [backup.chainId] : []), backup.kdf, backup.iterations,
backup.cipher, backup.salt, backup.iv, backup.publicKey,
]));
}
function validateBackup(value: unknown): WalletBackup {
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 names = ['format', 'version', 'network', 'kdf', 'iterations', 'cipher', 'salt', 'iv', 'publicKey', 'ciphertext'];
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.version !== 1 || backup.network !== NETWORK ||
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);
@@ -82,6 +97,7 @@ function validateBackup(value: unknown): WalletBackup {
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,
...(backup.version === 2 ? { chainId: backup.chainId } : {}),
kdf: backup.kdf, iterations: backup.iterations, cipher: backup.cipher,
salt: backup.salt, iv: backup.iv, publicKey: backup.publicKey, ciphertext: backup.ciphertext };
}
@@ -110,9 +126,10 @@ async function validatePair(privateKey: CryptoKey, publicKey: string): Promise<s
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: 1, network: NETWORK,
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');
@@ -128,7 +145,7 @@ export async function encryptWallet(privateKey: CryptoKey, publicKey: string, pa
export async function decryptWallet(backup: unknown, password: string): Promise<{
privateKey: CryptoKey; publicKey: string; address: string;
}> {
const validated = validateBackup(backup); // Bound work before PBKDF2.
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 {