randomx
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
import type { Article } from './protocol.ts';
|
||||
|
||||
export const NETWORK = 'wikideck-randomx-testnet-2' as const;
|
||||
export const CONSENSUS = {
|
||||
version: 2, targetSeconds: 30, retargetInterval: 10, medianWindow: 11,
|
||||
commitments: 'sha256-domain-separated-counted-merkle-v1', randomx: '1.2.1', powEndian: 'big',
|
||||
maxFutureSeconds: 120, epochBlocks: 2048, epochDelay: 64,
|
||||
maxTransactions: 64, coinbaseMaturity: 2, maxBlockBytes: 64 * 1024,
|
||||
} as const;
|
||||
export type NftTransfer = {
|
||||
network: typeof NETWORK; chainId: string; cardHash: string; from: string; to: string;
|
||||
sequence: number; publicKey: string; signature: string;
|
||||
};
|
||||
export type BlockHeader = {
|
||||
version: 2; network: typeof NETWORK; chainId: string; height: number;
|
||||
previousHash: string; createdAt: number; article: Article;
|
||||
owner: string; publicKey: string; nonce: number; target: string; key: string;
|
||||
transactionsRoot: string; stateRoot: string;
|
||||
};
|
||||
export type TestnetBlock = BlockHeader & {
|
||||
hash: string; tokenId: string; powHash: string; signature: string; transactions: NftTransfer[];
|
||||
};
|
||||
export type MiningTemplate = {
|
||||
header: BlockHeader; transactions: NftTransfer[]; blob: string; nonceOffset: number;
|
||||
};
|
||||
export type NftState = Record<string, { owner: string; sequence: number; mintedHeight: number; pageId: number }>;
|
||||
export type TestnetSnapshot = {
|
||||
network: typeof NETWORK; chainId: string; genesis: string; tip: string; cumulativeWork: string;
|
||||
blocks: TestnetBlock[]; transfers: NftTransfer[]; pendingTransfers: NftTransfer[];
|
||||
nextArticle: Article; peers: number; algorithm: 'RandomX'; maturity: number;
|
||||
};
|
||||
export function nftTransferMessage(t: Omit<NftTransfer, 'publicKey' | 'signature'>): string {
|
||||
return JSON.stringify([NETWORK, 'nft-transfer', t.chainId, t.cardHash, t.from, t.to, t.sequence]);
|
||||
}
|
||||
export function articleTuple(a: Article): unknown[] {
|
||||
return [a.pageId, a.revisionId, a.title, a.extract, a.image ?? '', a.url, a.source, a.category];
|
||||
}
|
||||
export function headerMessage(h: BlockHeader): string {
|
||||
return JSON.stringify([NETWORK, h.version, h.chainId, h.height, h.previousHash, h.createdAt,
|
||||
articleTuple(h.article), h.owner, h.publicKey, h.target, h.key, h.transactionsRoot, h.stateRoot]);
|
||||
}
|
||||
export function blockClaimMessage(hash: string): string {
|
||||
return JSON.stringify([NETWORK, 'block-claim', hash]);
|
||||
}
|
||||
export function testnetOwnerOf(state: Pick<TestnetSnapshot, 'blocks' | 'transfers'>, hash: string): string | undefined {
|
||||
let owner = state.blocks.find(b => b.tokenId === hash)?.owner;
|
||||
for (const t of state.transfers) if (t.cardHash === hash) owner = t.to;
|
||||
return owner;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// 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); }
|
||||
}
|
||||
Reference in New Issue
Block a user