forked from WikiDeck/wikideck
+30
-11
@@ -1,13 +1,13 @@
|
||||
// Browser/Node WebCrypto only: this module never reads storage or sends data.
|
||||
const NETWORK = 'wikideck-randomx-testnet-4';
|
||||
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: 'wikideck-randomx-testnet-4';
|
||||
network: typeof NETWORK;
|
||||
kdf: 'PBKDF2-SHA256';
|
||||
iterations: 600000;
|
||||
cipher: 'AES-256-GCM';
|
||||
@@ -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,36 +58,50 @@ 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);
|
||||
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,
|
||||
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> {
|
||||
@@ -110,9 +128,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 +147,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 {
|
||||
|
||||
Reference in New Issue
Block a user