// SERO · Tu llave, sin nosotros. // Derivación de llaves de las billeteras SERO, publicada como código abierto. // // Copyright (c) 2026 Ostermann Laboratories S.à r.l. · Licencia MIT (ver LICENSE.txt). // // Este archivo no se escribió a mano: lo genera sero-latam/scripts/publicar-llave.mjs extrayendo // el mismo código que corre dentro de la billetera, y se comprueba contra // direcciones de prueba fijas (vectores.json) antes de publicarse. Con él, cualquiera puede // reconstruir su llave privada sin SERO, sin internet y sin pedirle permiso a nadie. // // Nada de lo que escribas aquí sale de tu dispositivo. import { mnemonicToSeedSync, validateMnemonic } from "@scure/bip39"; import { wordlist as spanishWordlist } from "@scure/bip39/wordlists/spanish"; type IdentifierType = "cedula" | "matricula"; type CulturalSecretMaterial = { slot: 1 | 2; book: number; m: number; n: number }; type CulturalDerivationMaterial = { v: "SERO_FLASH_DERIVATION_V1"; network: "BSC"; curve: "secp256k1"; security: "standard" | "advanced"; identifier: { type: IdentifierType; value: string }; avatar: { id: number }; secrets: CulturalSecretMaterial[]; }; type ReactivationSecretData = { book: number | null; range: string }; // ─── Curva, keccak256 y estiramiento (de src/seroCrypto.ts) ────────────────── const P = 0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2fn; const N = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n; const GX = 0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798n; const GY = 0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8n; const MASK_64 = (1n << 64n) - 1n; type Point = { x: bigint; y: bigint } | null; function mod(value: bigint, modulo = P) { const result = value % modulo; return result >= 0n ? result : result + modulo; } function invert(number: bigint, modulo = P) { if (number === 0n) throw new Error("Division by zero"); let a = mod(number, modulo); let b = modulo; let x = 0n; let y = 1n; let u = 1n; let v = 0n; while (a !== 0n) { const quotient = b / a; const remainder = b % a; const nextX = x - u * quotient; const nextY = y - v * quotient; b = a; a = remainder; x = u; y = v; u = nextX; v = nextY; } if (b !== 1n) throw new Error("Inverse does not exist"); return mod(x, modulo); } function pointDouble(point: Point): Point { if (!point) return null; if (point.y === 0n) return null; const slope = mod(3n * point.x * point.x * invert(2n * point.y)); const x = mod(slope * slope - 2n * point.x); const y = mod(slope * (point.x - x) - point.y); return { x, y }; } function pointAdd(a: Point, b: Point): Point { if (!a) return b; if (!b) return a; if (a.x === b.x) { if (mod(a.y + b.y) === 0n) return null; return pointDouble(a); } const slope = mod((b.y - a.y) * invert(b.x - a.x)); const x = mod(slope * slope - a.x - b.x); const y = mod(slope * (a.x - x) - a.y); return { x, y }; } function multiplyBase(scalar: bigint): Point { let n = mod(scalar, N); if (n === 0n) return null; let result: Point = null; let addend: Point = { x: GX, y: GY }; while (n > 0n) { if (n & 1n) result = pointAdd(result, addend); addend = pointDouble(addend); n >>= 1n; } return result; } function bytesToBigInt(bytes: Uint8Array) { let result = 0n; for (const byte of bytes) result = (result << 8n) + BigInt(byte); return result; } function bigIntToBytes(value: bigint, length = 32) { const output = new Uint8Array(length); let current = value; for (let index = length - 1; index >= 0; index -= 1) { output[index] = Number(current & 0xffn); current >>= 8n; } return output; } export function bytesToHex(bytes: Uint8Array) { return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); } export function isValidPrivateKey(privateKey: Uint8Array) { if (privateKey.length !== 32) return false; const value = bytesToBigInt(privateKey); return value > 0n && value < N; } function uncompressedPublicKey(privateKey: Uint8Array) { if (!isValidPrivateKey(privateKey)) throw new Error("Invalid private key"); const point = multiplyBase(bytesToBigInt(privateKey)); if (!point) throw new Error("Invalid public key"); const output = new Uint8Array(65); output[0] = 4; output.set(bigIntToBytes(point.x), 1); output.set(bigIntToBytes(point.y), 33); return output; } function rotl64(value: bigint, shift: number) { const amount = BigInt(shift % 64); if (amount === 0n) return value & MASK_64; return ((value << amount) | (value >> (64n - amount))) & MASK_64; } const KECCAK_ROTATION = [ 0, 1, 62, 28, 27, 36, 44, 6, 55, 20, 3, 10, 43, 25, 39, 41, 45, 15, 21, 8, 18, 2, 61, 56, 14, ]; const KECCAK_ROUND_CONSTANTS = [ 0x0000000000000001n, 0x0000000000008082n, 0x800000000000808an, 0x8000000080008000n, 0x000000000000808bn, 0x0000000080000001n, 0x8000000080008081n, 0x8000000000008009n, 0x000000000000008an, 0x0000000000000088n, 0x0000000080008009n, 0x000000008000000an, 0x000000008000808bn, 0x800000000000008bn, 0x8000000000008089n, 0x8000000000008003n, 0x8000000000008002n, 0x8000000000000080n, 0x000000000000800an, 0x800000008000000an, 0x8000000080008081n, 0x8000000000008080n, 0x0000000080000001n, 0x8000000080008008n, ]; function keccakF1600(state: bigint[]) { for (const rc of KECCAK_ROUND_CONSTANTS) { const c = new Array(5); const d = new Array(5); const b = new Array(25).fill(0n); for (let x = 0; x < 5; x += 1) { c[x] = state[x] ^ state[x + 5] ^ state[x + 10] ^ state[x + 15] ^ state[x + 20]; } for (let x = 0; x < 5; x += 1) { d[x] = c[(x + 4) % 5] ^ rotl64(c[(x + 1) % 5], 1); } for (let x = 0; x < 5; x += 1) { for (let y = 0; y < 5; y += 1) { state[x + 5 * y] = (state[x + 5 * y] ^ d[x]) & MASK_64; } } for (let x = 0; x < 5; x += 1) { for (let y = 0; y < 5; y += 1) { b[y + 5 * ((2 * x + 3 * y) % 5)] = rotl64(state[x + 5 * y], KECCAK_ROTATION[x + 5 * y]); } } for (let x = 0; x < 5; x += 1) { for (let y = 0; y < 5; y += 1) { state[x + 5 * y] = (b[x + 5 * y] ^ ((~b[((x + 1) % 5) + 5 * y]) & b[((x + 2) % 5) + 5 * y])) & MASK_64; } } state[0] = (state[0] ^ rc) & MASK_64; } } function readLaneLE(bytes: Uint8Array, offset: number) { let lane = 0n; for (let index = 0; index < 8; index += 1) { lane |= BigInt(bytes[offset + index] ?? 0) << BigInt(8 * index); } return lane; } function writeLaneLE(value: bigint, output: Uint8Array, offset: number) { for (let index = 0; index < 8; index += 1) { output[offset + index] = Number((value >> BigInt(8 * index)) & 0xffn); } } export function keccak256(input: Uint8Array) { const rate = 136; const state = new Array(25).fill(0n); let offset = 0; while (offset + rate <= input.length) { for (let index = 0; index < rate / 8; index += 1) { state[index] ^= readLaneLE(input, offset + index * 8); } keccakF1600(state); offset += rate; } const block = new Uint8Array(rate); block.set(input.slice(offset)); block[input.length - offset] ^= 0x01; block[rate - 1] ^= 0x80; for (let index = 0; index < rate / 8; index += 1) { state[index] ^= readLaneLE(block, index * 8); } keccakF1600(state); const output = new Uint8Array(32); let outOffset = 0; for (let index = 0; outOffset < output.length; index += 1) { const lane = new Uint8Array(8); writeLaneLE(state[index], lane, 0); output.set(lane.slice(0, Math.min(8, output.length - outOffset)), outOffset); outOffset += 8; } return output; } function toChecksumAddress(rawAddressHex: string) { const clean = rawAddressHex.toLowerCase().replace(/^0x/, ""); const hash = bytesToHex(keccak256(new TextEncoder().encode(clean))); let result = "0x"; for (let index = 0; index < clean.length; index += 1) { result += Number.parseInt(hash[index] ?? "0", 16) >= 8 ? clean[index].toUpperCase() : clean[index]; } return result; } // V4 cultural derivation: Argon2id (RFC 9106). Memory-hard, so guessing human-chosen // passages on GPUs/ASICs costs orders of magnitude more than with PBKDF2. // Changing any of these values changes every V4 address — never edit them. export const CULTURAL_ARGON2ID_V4 = { memoryKiB: 65_536, iterations: 3, parallelism: 1, hashLength: 32 } as const; // Loaded on demand (activation only), so the app's startup bundle is unchanged. // hash-wasm (WebAssembly) is ~10x faster; @noble/hashes is the pure-JS fallback for // devices where WebAssembly is unavailable. Both produce byte-identical output. export async function argon2idStretch(password: Uint8Array, salt: Uint8Array): Promise { const { memoryKiB, iterations, parallelism, hashLength } = CULTURAL_ARGON2ID_V4; try { const { argon2id } = await import("hash-wasm"); return await argon2id({ password, salt, memorySize: memoryKiB, iterations, parallelism, hashLength, outputType: "binary" }); } catch { const { argon2idAsync } = await import("@noble/hashes/argon2"); return argon2idAsync(password, salt, { m: memoryKiB, t: iterations, p: parallelism, dkLen: hashLength }); } } export function privateKeyToAddress(privateKey: Uint8Array) { const publicKey = uncompressedPublicKey(privateKey).slice(1); const hash = keccak256(publicKey); return toChecksumAddress(`0x${bytesToHex(hash.slice(-20))}`); } // ─── Derivación (de src/App.tsx) ───────────────────────────────────────────── function utf8Bytes(value: string) { return new TextEncoder().encode(value); } function concatBytes(...chunks: Uint8Array[]) { const length = chunks.reduce((total, chunk) => total + chunk.length, 0); const output = new Uint8Array(length); let offset = 0; for (const chunk of chunks) { output.set(chunk, offset); offset += chunk.length; } return output; } function canonicalStringify(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonicalStringify).join(",")}]`; if (value && typeof value === "object") { const entries = Object.entries(value as Record).sort(([a], [b]) => a.localeCompare(b)); return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalStringify(entry)}`).join(",")}}`; } return JSON.stringify(value); } function deriveCandidate(seed: Uint8Array, label: string, round: number) { return keccak256(concatBytes(seed, utf8Bytes(`:${label}:${round}`))); } function derivePrivateKeyFromBytes(seed: Uint8Array, label: string) { for (let round = 0; round < 1024; round += 1) { const candidate = deriveCandidate(seed, label, round); if (isValidPrivateKey(candidate)) return candidate; } throw new Error("No se pudo derivar una clave BSC válida"); } const CULTURAL_KDF_V4_NAMESPACE = "SERO_FLASH_CULTURAL_BSC_KDF_V4"; function deriveCulturalSalt(namespace: string, material: CulturalDerivationMaterial): Uint8Array { const saltSource = [ namespace, material.network, material.security, `avatar:${material.avatar.id}`, `id:${material.identifier.type}:${material.identifier.value}`, ].join("|"); return keccak256(utf8Bytes(saltSource)); } async function derivePrivateKeyFromMaterialV4(material: CulturalDerivationMaterial) { const canonical = canonicalStringify(material); const stretched = await argon2idStretch(utf8Bytes(canonical), deriveCulturalSalt(CULTURAL_KDF_V4_NAMESPACE, material)); try { return derivePrivateKeyFromBytes(stretched, "SERO_FLASH_CULTURAL_BSC_EOA_V4"); } finally { stretched.fill(0); } } function derivePrivateKeyFromWords(words: string[]) { const phrase = words.join(" ").trim().toLocaleLowerCase("es"); const seed = mnemonicToSeedSync(phrase); return derivePrivateKeyFromBytes(seed, "SERO_FLASH_MAXIMUM_BSC_EOA_V1"); } const RANGE_DIGIT_MAX = 7; function normalizeRangeNumber(value: string) { return value.replace(/\D/g, "").slice(0, RANGE_DIGIT_MAX); } function splitRangeText(value: string) { const [left = "", right = ""] = value.split("-"); return { left: normalizeRangeNumber(left), right: normalizeRangeNumber(right), }; } function parseRangeText(value: string) { const { left, right } = splitRangeText(value); const m = Number(left); const n = Number(right); if (!left || !right || !Number.isInteger(m) || !Number.isInteger(n)) return null; if (m <= 0 || n < m) return null; return { m, n, label: `${m}-${n}` }; } function secretMaterialFromManual(secret: ReactivationSecretData, slot: 1 | 2): CulturalSecretMaterial { const range = parseRangeText(secret.range); if (!secret.book || !range) throw new Error("Falta un secreto SERO"); return { slot, book: secret.book, m: range.m, n: range.n, }; } const CEDULA_MAX_DIGITS = 10; function stripCedulaDigits(value: string) { return value.replace(/\D/g, "").slice(0, CEDULA_MAX_DIGITS); } const MATRICULA_MAX_LENGTH = 12; function normalizeMatricula(value: string) { return value .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") .toUpperCase() .replace(/[^A-Z0-9-]/g, "") .replace(/-{2,}/g, "-") .slice(0, MATRICULA_MAX_LENGTH); } function normalizeIdentifierValue(type: IdentifierType, value: string) { return type === "cedula" ? stripCedulaDigits(value) : normalizeMatricula(value); } // ─── Interfaz pública ──────────────────────────────────────────────────────── export const AVATARES = [{"id":1,"nombre":"Tonina del Orinoco"},{"id":2,"nombre":"Caimán del Orinoco"},{"id":3,"nombre":"Turpial"},{"id":4,"nombre":"Jaguar"},{"id":5,"nombre":"Manatí caribeño"},{"id":6,"nombre":"Iguana"},{"id":7,"nombre":"Guacamaya"},{"id":8,"nombre":"Chigüire"},{"id":9,"nombre":"Rana tropical"},{"id":10,"nombre":"Oso frontino"},{"id":11,"nombre":"Tortuga Carey"},{"id":12,"nombre":"Pereza"},{"id":13,"nombre":"Cachicamo"},{"id":14,"nombre":"Oso palmero"},{"id":15,"nombre":"Rabipelado"},{"id":16,"nombre":"Colibrí"},{"id":17,"nombre":"Mono Araguato"}]; export const LIBROS = [{"id":1,"nombre":"La Biblia"},{"id":2,"nombre":"La Ilíada"},{"id":3,"nombre":"La Odisea"},{"id":4,"nombre":"La Divina Comedia"},{"id":5,"nombre":"Don Quijote de la Mancha"},{"id":6,"nombre":"Tristán e Isolda"},{"id":7,"nombre":"Cien años de soledad"},{"id":8,"nombre":"Doña Bárbara"},{"id":9,"nombre":"Meditaciones"},{"id":10,"nombre":"Los miserables"},{"id":11,"nombre":"El conde de Montecristo"},{"id":12,"nombre":"Guerra y paz"},{"id":13,"nombre":"La comunidad del anillo"},{"id":14,"nombre":"Danza de dragones"},{"id":15,"nombre":"Duna"},{"id":16,"nombre":"Fundación"},{"id":17,"nombre":"El catalejo lacado"}]; export type Secreto = { libro: number; rango: string }; export type Resultado = { llavePrivada: string; direccion: string }; // La lista oficial de palabras en español (BIP-39) del nivel Máxima. export const PALABRAS: readonly string[] = spanishWordlist; function materialDe(nivel: "standard" | "advanced", tipo: IdentifierType, valor: string, avatar: number, secretos: Secreto[]): CulturalDerivationMaterial { const valorNormalizado = normalizeIdentifierValue(tipo, valor); if (!valorNormalizado) throw new Error("Falta tu identificador"); const cantidad = nivel === "standard" ? 1 : 2; if (secretos.length !== cantidad) throw new Error(nivel === "standard" ? "Estándar usa un secreto" : "Avanzada usa dos secretos"); return { v: "SERO_FLASH_DERIVATION_V1", network: "BSC", curve: "secp256k1", security: nivel, identifier: { type: tipo, value: valorNormalizado }, avatar: { id: avatar }, secrets: secretos.map((s, i) => secretMaterialFromManual({ book: s.libro, range: s.rango }, (i + 1) as 1 | 2)), }; } // Estándar y Avanzada: libro y rango, identificador y avatar. export async function llaveDeNivel(nivel: "standard" | "advanced", tipo: IdentifierType, valor: string, avatar: number, secretos: Secreto[]): Promise { const llave = await derivePrivateKeyFromMaterialV4(materialDe(nivel, tipo, valor, avatar, secretos)); const resultado: Resultado = { llavePrivada: `0x${bytesToHex(llave)}`, direccion: privateKeyToAddress(llave) }; llave.fill(0); return resultado; } // Máxima: las 12 palabras en español. export function llaveDePalabras(texto: string): Resultado { const palabras = texto.trim().toLocaleLowerCase("es").split(/\s+/).filter(Boolean); if (!validateMnemonic(palabras.join(" "), spanishWordlist)) throw new Error("Esas 12 palabras no forman una frase válida. Revisa el orden y cada palabra."); const llave = derivePrivateKeyFromWords(palabras); const resultado: Resultado = { llavePrivada: `0x${bytesToHex(llave)}`, direccion: privateKeyToAddress(llave) }; llave.fill(0); return resultado; }