V2.1 (#21)
Working version Reviewed-on: #21
This commit was merged in pull request #21.
This commit is contained in:
Executable
+16
@@ -0,0 +1,16 @@
|
||||
import { CONSENSUS, MAX_TARGET, NETWORK } from './testnet.ts';
|
||||
import { NETWORK_GENESIS_TIME } from './network.ts';
|
||||
|
||||
export type ChainIdentity = { chainId: string; genesis: string };
|
||||
let identity: Promise<ChainIdentity> | undefined;
|
||||
|
||||
/** Derive the same public manifest as the node from this immutable browser build. */
|
||||
export function expectedChainIdentity(): Promise<ChainIdentity> {
|
||||
if (!identity) identity = (async () => {
|
||||
const digestHex = async (value: string) => Array.from(new Uint8Array(await crypto.subtle.digest(
|
||||
'SHA-256', new TextEncoder().encode(value))), byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
const chainId = await digestHex(JSON.stringify([NETWORK, CONSENSUS, NETWORK_GENESIS_TIME, MAX_TARGET]));
|
||||
return { chainId, genesis: await digestHex(`${NETWORK}:genesis:${chainId}`) };
|
||||
})();
|
||||
return identity;
|
||||
}
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
/** Selection is fixed at server startup / browser build, never learned from an untrusted RPC. */
|
||||
export type NetworkProfile = 'testnet' | 'mainnet';
|
||||
export function networkProfile(value: string | undefined): NetworkProfile {
|
||||
const profile = value ?? 'mainnet';
|
||||
if (profile !== 'testnet' && profile !== 'mainnet') {
|
||||
throw new Error('WIKIDECK_NETWORK must be testnet or mainnet');
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
const isNodeRuntime = typeof process !== 'undefined' && !!process.versions?.node;
|
||||
export const PROFILE = networkProfile(isNodeRuntime
|
||||
? process.env.WIKIDECK_NETWORK : import.meta.env?.VITE_WIKIDECK_NETWORK);
|
||||
export const IS_MAINNET = PROFILE === 'mainnet';
|
||||
export const NETWORK = IS_MAINNET ? 'wikideck-randomx-mainnet-1' as const : 'wikideck-randomx-testnet-6' as const;
|
||||
const TESTNET_GENESIS_TIME = 1_792_411_200;
|
||||
const DEFAULT_MAINNET_GENESIS_TIME = 1_793_491_200; // 2026-11-01 00:00 UTC; fixed launch identity, never derived from the current clock.
|
||||
function genesisTime(raw: string | undefined, profile: NetworkProfile): number {
|
||||
const value = raw ?? String(profile === 'mainnet' ? DEFAULT_MAINNET_GENESIS_TIME : TESTNET_GENESIS_TIME);
|
||||
if (!/^(0|[1-9]\d{8,10})$/.test(value)) throw new Error('WIKIDECK_GENESIS_TIME must be a canonical Unix-seconds integer');
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1_700_000_000 || parsed > 4_102_444_800) {
|
||||
throw new Error('WIKIDECK_GENESIS_TIME is outside the supported range');
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
export const NETWORK_GENESIS_TIME = genesisTime(isNodeRuntime
|
||||
? process.env.WIKIDECK_GENESIS_TIME : import.meta.env?.VITE_WIKIDECK_GENESIS_TIME, PROFILE);
|
||||
if (isNodeRuntime) {
|
||||
const imageProfile = process.env.WIKIDECK_IMAGE_NETWORK;
|
||||
const imageGenesisTime = process.env.WIKIDECK_IMAGE_GENESIS_TIME;
|
||||
if (imageProfile !== undefined && imageProfile !== PROFILE) {
|
||||
throw new Error('Runtime network profile does not match the immutable image manifest');
|
||||
}
|
||||
if (imageGenesisTime !== undefined && Number(imageGenesisTime) !== NETWORK_GENESIS_TIME) {
|
||||
throw new Error('Runtime genesis time does not match the immutable image manifest');
|
||||
}
|
||||
if (imageGenesisTime !== undefined && !/^(0|[1-9]\d{8,10})$/.test(imageGenesisTime)) {
|
||||
throw new Error('Image genesis time manifest is malformed');
|
||||
}
|
||||
}
|
||||
export const NETWORK_LABEL = IS_MAINNET ? 'MAINNET' : 'TESTNET';
|
||||
Executable
+105
@@ -0,0 +1,105 @@
|
||||
import { NETWORK, type CanonicalPage, type TestnetBlock, type TestnetSnapshot, type TestnetSummary } from './testnet.ts';
|
||||
import { expectedChainIdentity } from './chain-identity.ts';
|
||||
|
||||
const MAX_PAGE_BYTES = 2 * 1024 * 1024;
|
||||
const MAX_HISTORY_BYTES = 64 * 1024 * 1024;
|
||||
const MAX_CLIENT_BLOCKS = 10000;
|
||||
const hex = /^[a-f0-9]{64}$/;
|
||||
/** Bounded transport and continuity checks, NOT independent consensus/SPV verification. */
|
||||
export class StateClient {
|
||||
private cache: { chainId: string; blocks: TestnetBlock[]; bytes: number } | undefined;
|
||||
private inFlight: Promise<TestnetSnapshot> | undefined;
|
||||
private readonly fetcher: typeof fetch;
|
||||
constructor(fetcher: typeof fetch = globalThis.fetch) {
|
||||
// Browser Web APIs require the Window/global receiver, not this StateClient instance.
|
||||
this.fetcher = fetcher.bind(globalThis);
|
||||
}
|
||||
async json<T>(path: string, signal: AbortSignal): Promise<T> {
|
||||
const response = await this.fetcher(`/api/${path}`, { cache: 'no-store', redirect: 'error', signal });
|
||||
if (!/^application\/json(?:\s*;|$)/i.test(response.headers.get('content-type') ?? '') || !response.body) throw new Error('Node must return JSON.');
|
||||
const announced = response.headers.get('content-length');
|
||||
if (announced !== null && (!/^\d+$/.test(announced) || Number(announced) > MAX_PAGE_BYTES)) {
|
||||
await response.body.cancel(); throw new Error('Node response exceeds client page limit.');
|
||||
}
|
||||
const reader = response.body.getReader(), chunks: Uint8Array[] = [];
|
||||
let bytes = 0;
|
||||
try {
|
||||
for (;;) {
|
||||
const item = await reader.read(); if (item.done) break;
|
||||
bytes += item.value.byteLength;
|
||||
if (bytes > MAX_PAGE_BYTES) throw new Error('Node response exceeds client page limit.');
|
||||
chunks.push(item.value);
|
||||
}
|
||||
} finally { await reader.cancel(); reader.releaseLock(); }
|
||||
const joined = new Uint8Array(bytes); let offset = 0;
|
||||
for (const chunk of chunks) { joined.set(chunk, offset); offset += chunk.byteLength; }
|
||||
const data = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(joined));
|
||||
if (!response.ok) throw Object.assign(new Error(typeof data.error === 'string' ? data.error : 'Node RPC failed.'), { status: response.status });
|
||||
return data;
|
||||
}
|
||||
load(): Promise<TestnetSnapshot> {
|
||||
if (this.inFlight) return this.inFlight;
|
||||
const result = this.loadFresh();
|
||||
this.inFlight = result;
|
||||
void result.finally(() => { if (this.inFlight === result) this.inFlight = undefined; }).catch(() => {});
|
||||
return result;
|
||||
}
|
||||
private async summary(signal: AbortSignal) {
|
||||
const s = await this.json<TestnetSummary>('summary', signal);
|
||||
const expected = await expectedChainIdentity();
|
||||
if (s.network !== NETWORK || s.chainId !== expected.chainId || s.genesis !== expected.genesis ||
|
||||
s.algorithm !== 'RandomX' || !hex.test(s.chainId) || !hex.test(s.tip) || !hex.test(s.genesis) ||
|
||||
!Number.isSafeInteger(s.height) || s.height < 0 || !Array.isArray(s.pendingTransfers) || s.pendingTransfers.length > 256 ||
|
||||
!/^\d{1,256}$/.test(s.cumulativeWork)) throw new Error('Incompatible node summary.');
|
||||
if (s.height > MAX_CLIENT_BLOCKS) throw new Error('This node exceeds the browser history limit (10,000 blocks). A paged explorer/wallet is required; no partial ownership state will be shown.');
|
||||
return s;
|
||||
}
|
||||
private async page(s: TestnetSummary, offset: number, signal: AbortSignal, limit = 16) {
|
||||
const p = await this.json<CanonicalPage>(`canonical?tip=${s.tip}&offset=${offset}&limit=${limit}`, signal);
|
||||
const count = Math.min(limit, s.height - offset), next = offset + count < s.height ? offset + count : null;
|
||||
if (p.network !== s.network || p.chainId !== s.chainId || p.tip !== s.tip || p.height !== s.height || p.offset !== offset ||
|
||||
p.next !== next || !Array.isArray(p.blocks) || p.blocks.length !== count) throw new Error('Invalid canonical pagination.');
|
||||
for (let i = 0; i < p.blocks.length; i++) {
|
||||
const b = p.blocks[i];
|
||||
if (b.network !== s.network || b.chainId !== s.chainId || b.height !== offset + i + 1 || !hex.test(b.hash) ||
|
||||
!Array.isArray(b.transactions) || b.transactions.length > 64) throw new Error('Invalid canonical block page.');
|
||||
}
|
||||
return p;
|
||||
}
|
||||
private async loadFresh(): Promise<TestnetSnapshot> {
|
||||
const signal = AbortSignal.timeout(30000);
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
const s = await this.summary(signal);
|
||||
let blocks: TestnetBlock[] = [], bytes = 0;
|
||||
if (this.cache?.chainId === s.chainId && this.cache.blocks.length <= s.height) {
|
||||
const cached = this.cache;
|
||||
if (!cached.blocks.length || cached.blocks.at(-1)!.hash === s.tip ||
|
||||
(await this.page(s, cached.blocks.length - 1, signal, 1)).blocks[0]?.hash === cached.blocks.at(-1)!.hash) {
|
||||
blocks = cached.blocks.slice(); bytes = cached.bytes;
|
||||
}
|
||||
}
|
||||
while (blocks.length < s.height) {
|
||||
const p = await this.page(s, blocks.length, signal);
|
||||
const encodedBytes = new TextEncoder().encode(JSON.stringify(p.blocks)).byteLength;
|
||||
bytes += encodedBytes;
|
||||
if (bytes > MAX_HISTORY_BYTES) throw new Error('Browser history exceeds 64 MiB; no incomplete ownership state will be used.');
|
||||
for (const b of p.blocks) {
|
||||
if (b.previousHash !== (blocks.at(-1)?.hash ?? s.genesis)) throw new Error('Canonical block continuity mismatch.');
|
||||
blocks.push(b);
|
||||
}
|
||||
}
|
||||
if ((blocks.at(-1)?.hash ?? s.genesis) !== s.tip) throw new Error('Canonical tip does not match loaded history.');
|
||||
const final = await this.summary(signal);
|
||||
if (final.chainId !== s.chainId || final.tip !== s.tip || final.height !== s.height) throw Object.assign(new Error('Canonical tip changed during loading.'), { status: 409 });
|
||||
this.cache = { chainId: s.chainId, blocks, bytes };
|
||||
const { height: _height, revision: _revision, ...metadata } = final;
|
||||
return { ...metadata, blocks: blocks.slice(), transfers: blocks.flatMap(b => b.transactions) };
|
||||
} catch (error) {
|
||||
if (attempt === 0 && (error as { status?: number }).status === 409) { this.cache = undefined; continue; }
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
throw new Error('Could not load a stable canonical chain.');
|
||||
}
|
||||
}
|
||||
+47
-9
@@ -1,39 +1,67 @@
|
||||
import type { Article } from './protocol.ts';
|
||||
import type { MintStatsSnapshot } from './enrichment.ts';
|
||||
|
||||
export const NETWORK = 'wikideck-randomx-testnet-4' as const;
|
||||
import { NETWORK } from './network.ts';
|
||||
export { NETWORK } from './network.ts';
|
||||
export const CONSENSUS = {
|
||||
version: 4, targetSeconds: 30, retargetInterval: 10, medianWindow: 11,
|
||||
version: 6, targetSeconds: 15 * 60, 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,
|
||||
articleSource: 'wikipedia-random-with-image-and-stats-v1',
|
||||
maxTransactions: 64, maxTradeCards: 8, coinbaseMaturity: 2, maxBlockBytes: 64 * 1024,
|
||||
articleSource: 'wikipedia-random-with-image-and-stats-v1', atomicTrades: 'signed-two-party-bundle-v1',
|
||||
} as const;
|
||||
export const MAX_TARGET = `0fff${'f'.repeat(60)}`;
|
||||
export type NftTransfer = {
|
||||
network: typeof NETWORK; chainId: string; cardHash: string; from: string; to: string;
|
||||
sequence: number; publicKey: string; signature: string;
|
||||
};
|
||||
export type TradeLeg = { cardHash: string; sequence: number };
|
||||
export type NftTrade = {
|
||||
type: 'trade'; network: typeof NETWORK; chainId: string; proposer: string; counterparty: string;
|
||||
offered: TradeLeg[]; requested: TradeLeg[];
|
||||
proposerPublicKey: string; proposerSignature: string;
|
||||
counterpartyPublicKey: string; counterpartySignature: string;
|
||||
};
|
||||
export type TestnetTransaction = NftTransfer | NftTrade;
|
||||
export function isNftTrade(transaction: TestnetTransaction): transaction is NftTrade {
|
||||
return (transaction as NftTrade).type === 'trade';
|
||||
}
|
||||
export type BlockHeader = {
|
||||
version: 4; network: typeof NETWORK; chainId: string; height: number;
|
||||
version: 6; network: typeof NETWORK; chainId: string; height: number;
|
||||
previousHash: string; createdAt: number; article: Article; statsSnapshot: MintStatsSnapshot;
|
||||
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[];
|
||||
hash: string; tokenId: string; powHash: string; signature: string; transactions: TestnetTransaction[];
|
||||
};
|
||||
export type MiningTemplate = {
|
||||
header: BlockHeader; transactions: NftTransfer[]; blob: string; nonceOffset: number;
|
||||
header: BlockHeader; transactions: TestnetTransaction[]; blob: string; nonceOffset: number;
|
||||
};
|
||||
export type NftState = Record<string, { owner: string; sequence: number; mintedHeight: number; pageId: number }>;
|
||||
/** Persistent overlay: a block stores only NFT entries it changed, sharing its parent's state. */
|
||||
export type NftStateLayer = { kind: 'layer'; parent: NftStateView; changes: NftState };
|
||||
export type NftStateView = NftState | NftStateLayer;
|
||||
export type TestnetSnapshot = {
|
||||
network: typeof NETWORK; chainId: string; genesis: string; tip: string; cumulativeWork: string;
|
||||
blocks: TestnetBlock[]; transfers: NftTransfer[]; pendingTransfers: NftTransfer[];
|
||||
blocks: TestnetBlock[]; transfers: TestnetTransaction[]; pendingTransfers: TestnetTransaction[];
|
||||
peers: number; algorithm: 'RandomX'; maturity: number;
|
||||
};
|
||||
export type TestnetSummary = Omit<TestnetSnapshot, 'blocks' | 'transfers'> & { height: number; revision: number };
|
||||
export type CanonicalPage = { network: typeof NETWORK; chainId: string; tip: string; height: number;
|
||||
offset: number; next: number | null; blocks: TestnetBlock[] };
|
||||
export type TestnetDeck = {
|
||||
network: typeof NETWORK; chainId: string; address: string; tip: string;
|
||||
offset: number; limit: number; total: number; next: number | null;
|
||||
nfts: { block: TestnetBlock; sequence: number; confirmations: 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 nftTradeMessage(t: Pick<NftTrade, 'network' | 'chainId' | 'proposer' | 'counterparty' | 'offered' | 'requested'>): string {
|
||||
return JSON.stringify([NETWORK, 'nft-trade', t.chainId, t.proposer, t.counterparty,
|
||||
t.offered.map(leg => [leg.cardHash, leg.sequence]), t.requested.map(leg => [leg.cardHash, leg.sequence])]);
|
||||
}
|
||||
export function articleTuple(a: Article): unknown[] {
|
||||
return [a.pageId, a.revisionId, a.title, a.extract, a.image ?? '', a.url, a.source, a.category];
|
||||
}
|
||||
@@ -51,8 +79,18 @@ export function headerMessage(h: BlockHeader): string {
|
||||
export function blockClaimMessage(hash: string): string {
|
||||
return JSON.stringify([NETWORK, 'block-claim', hash]);
|
||||
}
|
||||
export function transactionCardIds(transaction: TestnetTransaction): string[] {
|
||||
return isNftTrade(transaction)
|
||||
? [...transaction.offered, ...transaction.requested].map(leg => leg.cardHash)
|
||||
: [transaction.cardHash];
|
||||
}
|
||||
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;
|
||||
for (const tx of state.transfers) {
|
||||
if (isNftTrade(tx)) {
|
||||
if (tx.offered.some(leg => leg.cardHash === hash)) owner = tx.counterparty;
|
||||
if (tx.requested.some(leg => leg.cardHash === hash)) owner = tx.proposer;
|
||||
} else if (tx.cardHash === hash) owner = tx.to;
|
||||
}
|
||||
return owner;
|
||||
}
|
||||
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
import { z } from 'zod';
|
||||
import { NETWORK, CONSENSUS } from './testnet.ts';
|
||||
|
||||
const hex = z.string().regex(/^[a-f0-9]{64}$/);
|
||||
const signature = z.string().regex(/^[A-Za-z0-9+/]+={0,2}$/).length(88);
|
||||
const leg = z.object({ cardHash: hex, sequence: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER) }).strict();
|
||||
|
||||
export const tradeOfferSchema = z.object({
|
||||
format: z.literal('wikideck-trade-offer'),
|
||||
version: z.literal(1),
|
||||
network: z.literal(NETWORK),
|
||||
chainId: hex,
|
||||
proposer: hex,
|
||||
counterparty: hex,
|
||||
offered: z.array(leg).min(1).max(CONSENSUS.maxTradeCards),
|
||||
requested: z.array(leg).min(1).max(CONSENSUS.maxTradeCards),
|
||||
proposerPublicKey: z.string().regex(/^[A-Za-z0-9+/]+={0,2}$/).max(256),
|
||||
proposerSignature: signature,
|
||||
}).strict().superRefine((offer, ctx) => {
|
||||
if (offer.proposer === offer.counterparty) ctx.addIssue({ code: 'custom', message: 'Trade parties must differ' });
|
||||
const ids = [...offer.offered, ...offer.requested].map(item => item.cardHash);
|
||||
if (new Set(ids).size !== ids.length) ctx.addIssue({ code: 'custom', message: 'Each NFT may appear only once in a trade' });
|
||||
});
|
||||
export type TradeOffer = z.infer<typeof tradeOfferSchema>;
|
||||
+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