OverviewFilesCommitsIssuesDocsReleasesPeers

p2pbuilders / web / js / crypto.js

106 lines · javascript ref:
// crypto.js — real Ed25519, the authenticity backbone of the gossip network.
//
// A record is trusted ONLY because it carries a valid Ed25519 signature from its
// author's key — never because of which outbox relayed it. This module provides
// that signing/verification over whatever platform crypto is available:
//
//   1. WebCrypto SubtleCrypto Ed25519 (PearBrowser / modern browsers, and Node 20+)
//   2. node:crypto Ed25519 (test runner / older Node)
//   3. none  -> cooperative dev fallback (clearly marked insecure; see gossip.js)
//
// Keys are handled as a 32-byte seed (hex) so DevIdentity can persist them.
// All ops are async (Subtle is async); callers await.

const PKCS8_PREFIX = '302e020100300506032b657004220420' // Ed25519 PKCS8 header for a 32B seed
const SPKI_PREFIX = '302a300506032b6570032100'           // Ed25519 SPKI header for a 32B pubkey

const toHex = (buf) => { const u = new Uint8Array(buf); let s = ''; for (let i = 0; i < u.length; i++) s += u[i].toString(16).padStart(2, '0'); return s }
const fromHex = (h) => { const a = new Uint8Array(h.length / 2); for (let i = 0; i < a.length; i++) a[i] = parseInt(h.substr(i * 2, 2), 16); return a }
const concat = (a, b) => { const u = new Uint8Array(a.length + b.length); u.set(a, 0); u.set(b, a.length); return u }
const utf8 = (s) => new TextEncoder().encode(s)

let backend = null // 'subtle' | 'node' | 'none'
let nodeCrypto = null
let _ready = null

async function detect () {
  // Prefer SubtleCrypto Ed25519.
  try {
    const subtle = globalThis.crypto && globalThis.crypto.subtle
    if (subtle) {
      const kp = await subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])
      const msg = utf8('peerit-selftest')
      const sig = await subtle.sign({ name: 'Ed25519' }, kp.privateKey, msg)
      const okk = await subtle.verify({ name: 'Ed25519' }, kp.publicKey, sig, msg)
      if (okk) { backend = 'subtle'; return }
    }
  } catch {}
  // Fall back to node:crypto — never in a browser, where the import can't
  // succeed and only produces a scary CORS/404 console error.
  try {
    if (typeof window !== 'undefined') { backend = 'none'; return }
    const mod = await import('node:crypto')
    nodeCrypto = mod.default || mod
    const { privateKey, publicKey } = nodeCrypto.generateKeyPairSync('ed25519')
    const sig = nodeCrypto.sign(null, utf8('peerit-selftest'), privateKey)
    if (nodeCrypto.verify(null, utf8('peerit-selftest'), publicKey, sig)) { backend = 'node'; return }
  } catch {}
  backend = 'none'
}

export function ready () { if (!_ready) _ready = detect(); return _ready }
export function isSecure () { return backend === 'subtle' || backend === 'node' }
export function backendName () { return backend }

// --- node helpers ---
function nodePriv (seedHex) { return nodeCrypto.createPrivateKey({ key: Buffer.from(PKCS8_PREFIX + seedHex, 'hex'), format: 'der', type: 'pkcs8' }) }
function nodePub (pubHex) { return nodeCrypto.createPublicKey({ key: Buffer.from(SPKI_PREFIX + pubHex, 'hex'), format: 'der', type: 'spki' }) }

// --- subtle helpers ---
async function subtlePriv (seedHex) {
  return globalThis.crypto.subtle.importKey('pkcs8', fromHex(PKCS8_PREFIX + seedHex), { name: 'Ed25519' }, false, ['sign'])
}
async function subtlePub (pubHex) {
  return globalThis.crypto.subtle.importKey('raw', fromHex(pubHex), { name: 'Ed25519' }, false, ['verify'])
}

// Generate a keypair. Returns { seedHex (private, 32B), pubHex (public, 32B) }.
export async function genKeyPair () {
  await ready()
  if (backend === 'subtle') {
    const kp = await globalThis.crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])
    const pkcs8 = new Uint8Array(await globalThis.crypto.subtle.exportKey('pkcs8', kp.privateKey))
    const raw = new Uint8Array(await globalThis.crypto.subtle.exportKey('raw', kp.publicKey))
    return { seedHex: toHex(pkcs8.slice(-32)), pubHex: toHex(raw) }
  }
  if (backend === 'node') {
    const { privateKey } = nodeCrypto.generateKeyPairSync('ed25519')
    const pkcs8 = privateKey.export({ type: 'pkcs8', format: 'der' })
    const seedHex = toHex(new Uint8Array(pkcs8).slice(-32))
    const spki = nodeCrypto.createPublicKey(privateKey).export({ type: 'spki', format: 'der' })
    return { seedHex, pubHex: toHex(new Uint8Array(spki).slice(-32)) }
  }
  // insecure cooperative fallback — random ids, no real keys
  const rnd = () => { let s = ''; for (let i = 0; i < 64; i++) s += Math.floor(Math.random() * 16).toString(16); return s }
  return { seedHex: rnd(), pubHex: rnd() }
}

// Sign a string message with a seed. Returns hex signature ('' in fallback).
export async function sign (seedHex, message) {
  await ready()
  if (backend === 'subtle') return toHex(await globalThis.crypto.subtle.sign({ name: 'Ed25519' }, await subtlePriv(seedHex), utf8(message)))
  if (backend === 'node') return toHex(nodeCrypto.sign(null, utf8(message), nodePriv(seedHex)))
  return '' // fallback: no signature
}

// Verify a hex signature of a string message against a public key. async -> bool.
export async function verify (pubHex, message, sigHex) {
  await ready()
  if (!sigHex || !pubHex) return false
  try {
    if (backend === 'subtle') return await globalThis.crypto.subtle.verify({ name: 'Ed25519' }, await subtlePub(pubHex), fromHex(sigHex), utf8(message))
    if (backend === 'node') return nodeCrypto.verify(null, utf8(message), nodePub(pubHex), Buffer.from(sigHex, 'hex'))
  } catch { return false }
  return false
}