// Crypto primitives. Mirrors client-app/lib/crypto.ts function-for- // function (same signatures, same hex/base64 formats on the wire) so // the two clients decrypt each other's envelopes and sign txs the node // accepts interchangeably. // // The only real difference from mobile: we don't need expo-crypto — the // Electron renderer is a Chromium browser, so window.crypto.getRandomValues // is always available and we just let tweetnacl pick it up on its own // (tweetnacl auto-detects window.crypto when present). import nacl from 'tweetnacl'; import { decodeUTF8, encodeUTF8 } from 'tweetnacl-util'; import type { KeyFile } from './types'; // ─── Hex / base64 ──────────────────────────────────────────────────────── export function hexToBytes(hex: string): Uint8Array { if (hex.length % 2 !== 0) throw new Error('odd hex length'); const b = new Uint8Array(hex.length / 2); for (let i = 0; i < b.length; i++) b[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); return b; } export function bytesToHex(b: Uint8Array): string { return Array.from(b).map(x => x.toString(16).padStart(2, '0')).join(''); } export function bytesToBase64(b: Uint8Array): string { let s = ''; for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]); return btoa(s); } export function base64ToBytes(b64: string): Uint8Array { const bin = atob(b64.replace(/-/g, '+').replace(/_/g, '/')); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; } // ─── Key generation ────────────────────────────────────────────────────── export function generateKeyFile(): KeyFile { const sign = nacl.sign.keyPair(); const box = nacl.box.keyPair(); return { pub_key: bytesToHex(sign.publicKey), priv_key: bytesToHex(sign.secretKey), x25519_pub: bytesToHex(box.publicKey), x25519_priv: bytesToHex(box.secretKey), }; } // ─── NaCl box (E2E messaging) ──────────────────────────────────────────── export function encryptMessage( plaintext: string, senderSecretHex: string, recipientPubHex: string, ): { nonce: string; ciphertext: string } { const nonce = nacl.randomBytes(nacl.box.nonceLength); const msg = decodeUTF8(plaintext); const box = nacl.box(msg, nonce, hexToBytes(recipientPubHex), hexToBytes(senderSecretHex)); return { nonce: bytesToHex(nonce), ciphertext: bytesToHex(box) }; } export function decryptMessage( ciphertextHex: string, nonceHex: string, senderPubHex: string, recipientSecHex: string, ): string | null { try { const plain = nacl.box.open( hexToBytes(ciphertextHex), hexToBytes(nonceHex), hexToBytes(senderPubHex), hexToBytes(recipientSecHex), ); return plain ? encodeUTF8(plain) : null; } catch { return null; } } // ─── Ed25519 signing ───────────────────────────────────────────────────── export function signBase64(data: Uint8Array, privKeyHex: string): string { const sig = nacl.sign.detached(data, hexToBytes(privKeyHex)); return bytesToBase64(sig); } // ─── Helpers ───────────────────────────────────────────────────────────── export function shortAddr(hex: string, chars = 8): string { if (hex.length <= chars * 2 + 3) return hex; return `${hex.slice(0, chars)}…${hex.slice(-chars)}`; }