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
+16
View File
@@ -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;
}
+30 -5
View File
@@ -1,16 +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 ?? 'testnet';
const profile = value ?? 'mainnet';
if (profile !== 'testnet' && profile !== 'mainnet') {
throw new Error('WIKIDECK_NETWORK must be testnet or mainnet');
}
return profile;
}
export const PROFILE = networkProfile(typeof process !== 'undefined' && process.versions?.node
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;
// Candidate mainnet genesis: 2027-01-01 00:00 UTC. NOT a commitment to a launch date.
export const NETWORK_GENESIS_TIME = IS_MAINNET ? 1_798_761_600 : 1_792_411_200;
export const NETWORK_LABEL = IS_MAINNET ? 'MAINNET CANDIDATE' : 'TESTNET';
const TESTNET_GENESIS_TIME = 1_792_411_200;
const DEFAULT_MAINNET_GENESIS_TIME = 1_798_761_600; // 2027-01-01 UTC candidate; deployment must freeze one value.
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';
+4 -1
View File
@@ -1,4 +1,5 @@
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;
@@ -45,7 +46,9 @@ export class StateClient {
}
private async summary(signal: AbortSignal) {
const s = await this.json<TestnetSummary>('summary', signal);
if (s.network !== NETWORK || s.algorithm !== 'RandomX' || !hex.test(s.chainId) || !hex.test(s.tip) || !hex.test(s.genesis) ||
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.');
+1
View File
@@ -10,6 +10,7 @@ export const CONSENSUS = {
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;
+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 {