OverviewFilesCommitsIssuesDocsReleasesPeers

P2P-Hiverelay / packages / services / identity / crypto.js

250 lines · javascript ref:
/**
 * Identity Crypto — secp256k1 primitives for the Identity Protocol Layer
 *
 * Provides Schnorr signing/verification (BIP-340) compatible with:
 *   - Lightning Network node keys
 *   - Nostr protocol (NIP-01, NIP-19)
 *   - Bitcoin key infrastructure
 *
 * Uses @noble/secp256k1 for the secp256k1 curve operations
 * and @noble/hashes for SHA-256 used in Schnorr signatures.
 *
 * Key formats:
 *   - secp256k1 private key: 32 bytes (64 hex chars)
 *   - secp256k1 public key (x-only / Schnorr): 32 bytes (64 hex chars)
 *   - secp256k1 public key (compressed): 33 bytes (66 hex chars)
 *   - Ed25519 public key: 32 bytes (64 hex chars) — used by Hyperswarm
 *   - Schnorr signature: 64 bytes (128 hex chars)
 */

import * as secp from '@noble/secp256k1'
import { sha256 } from '@noble/hashes/sha2.js'
import { hmac } from '@noble/hashes/hmac.js'
import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js'
import { randomBytes } from 'crypto'

// Noble secp256k1 v2+ requires explicit hash wiring
secp.hashes.sha256 = (...msgs) => {
  const filtered = msgs.filter(m => m != null)
  return sha256(secp.etc.concatBytes(...filtered))
}
secp.hashes.hmacSha256 = (key, ...msgs) => {
  const filtered = msgs.filter(m => m != null)
  return hmac(sha256, key, secp.etc.concatBytes(...filtered))
}

const { schnorr, getPublicKey } = secp

/**
 * Generate a new secp256k1 keypair.
 * Returns x-only public key (32 bytes) for Schnorr/Nostr compatibility.
 */
export function generateKeypair () {
  const privateKey = randomBytes(32)
  const publicKey = getPublicKey(privateKey, true) // compressed (33 bytes)
  const xOnlyPubkey = publicKey.slice(1) // strip prefix byte → 32 bytes (x-only)

  return {
    privateKey: Buffer.from(privateKey),
    publicKey: Buffer.from(xOnlyPubkey),
    publicKeyHex: bytesToHex(xOnlyPubkey),
    compressedPubkey: Buffer.from(publicKey)
  }
}

/**
 * Get x-only public key from private key.
 */
export function pubkeyFromPrivate (privateKeyHex) {
  const compressed = getPublicKey(hexToBytes(privateKeyHex), true)
  return bytesToHex(compressed.slice(1))
}

/**
 * Sign a message using Schnorr (BIP-340).
 * Compatible with Nostr event signing.
 *
 * @param {string} messageHex - Message to sign (hex encoded)
 * @param {string} privateKeyHex - 32-byte private key (hex)
 * @returns {string} 64-byte Schnorr signature (hex)
 */
export async function schnorrSign (messageHex, privateKeyHex) {
  const msgBytes = hexToBytes(messageHex)
  const privBytes = hexToBytes(privateKeyHex)
  const sig = await schnorr.sign(msgBytes, privBytes)
  return bytesToHex(sig)
}

/**
 * Verify a Schnorr signature (BIP-340).
 * Compatible with Nostr event verification.
 *
 * @param {string} signatureHex - 64-byte signature (hex)
 * @param {string} messageHex - Signed message (hex)
 * @param {string} pubkeyHex - 32-byte x-only public key (hex)
 * @returns {boolean}
 */
export async function schnorrVerify (signatureHex, messageHex, pubkeyHex) {
  try {
    const sig = hexToBytes(signatureHex)
    const msg = hexToBytes(messageHex)
    const pub = hexToBytes(pubkeyHex)
    return await schnorr.verify(sig, msg, pub)
  } catch {
    return false
  }
}

/**
 * Hash a UTF-8 string with SHA-256, return hex.
 * Used to create message digests for signing.
 */
export function sha256Hex (input) {
  const bytes = typeof input === 'string'
    ? new TextEncoder().encode(input)
    : input
  return bytesToHex(sha256(bytes))
}

/**
 * Create a tagged hash per BIP-340 convention.
 * tag_hash = SHA256(SHA256(tag) || SHA256(tag) || message)
 */
export function taggedHash (tag, messageHex) {
  const tagBytes = new TextEncoder().encode(tag)
  const tagHash = sha256(tagBytes)
  const msgBytes = hexToBytes(messageHex)

  const buf = new Uint8Array(tagHash.length * 2 + msgBytes.length)
  buf.set(tagHash, 0)
  buf.set(tagHash, tagHash.length)
  buf.set(msgBytes, tagHash.length * 2)

  return bytesToHex(sha256(buf))
}

/**
 * Create the attestation message that links an Ed25519 app key
 * to a secp256k1 developer identity.
 *
 * Format: SHA256("hiverelay:attestation" || ed25519_pubkey || secp256k1_pubkey || timestamp)
 *
 * @param {string} ed25519PubkeyHex - App's Ed25519 public key (64 hex chars)
 * @param {string} secp256k1PubkeyHex - Developer's secp256k1 public key (64 hex chars)
 * @param {number} timestamp - Unix timestamp (seconds)
 * @returns {string} Message hash to sign (hex)
 */
export function createAttestationMessage (ed25519PubkeyHex, secp256k1PubkeyHex, timestamp) {
  const payload = `hiverelay:attestation:${ed25519PubkeyHex}:${secp256k1PubkeyHex}:${timestamp}`
  return sha256Hex(payload)
}

/**
 * Create an LNURL-auth challenge (k1 parameter).
 * Returns 32 random bytes as hex — the challenge the wallet signs.
 */
export function createAuthChallenge () {
  return bytesToHex(randomBytes(32))
}

/**
 * Convert a Nostr npub (bech32) to hex pubkey.
 * Simplified implementation — handles npub1... format.
 */
export function npubToHex (npub) {
  if (!npub.startsWith('npub1')) {
    throw new Error('Invalid npub format')
  }
  // Bech32 decode (simplified — 5-bit to 8-bit conversion)
  const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'
  const data5bit = []
  for (let i = 5; i < npub.length - 6; i++) { // skip prefix and checksum
    const idx = CHARSET.indexOf(npub[i])
    if (idx === -1) throw new Error('Invalid bech32 character')
    data5bit.push(idx)
  }

  // Convert 5-bit groups to 8-bit bytes
  let acc = 0
  let bits = 0
  const bytes = []
  for (const val of data5bit) {
    acc = (acc << 5) | val
    bits += 5
    if (bits >= 8) {
      bits -= 8
      bytes.push((acc >> bits) & 0xff)
    }
  }

  if (bytes.length !== 32) throw new Error('Invalid npub length')
  return bytesToHex(new Uint8Array(bytes))
}

/**
 * Convert a hex pubkey to Nostr npub (bech32).
 */
export function hexToNpub (hex) {
  const bytes = hexToBytes(hex)
  if (bytes.length !== 32) throw new Error('Invalid pubkey length')

  const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'

  // Convert 8-bit bytes to 5-bit groups
  let acc = 0
  let bits = 0
  const data5bit = []
  for (const byte of bytes) {
    acc = (acc << 8) | byte
    bits += 8
    while (bits >= 5) {
      bits -= 5
      data5bit.push((acc >> bits) & 0x1f)
    }
  }
  if (bits > 0) {
    data5bit.push((acc << (5 - bits)) & 0x1f)
  }

  // Bech32 encoding with checksum
  const hrp = 'npub'
  const values = [0, ...data5bit] // witness version 0 for bech32m... actually npub uses bech32
  const checksum = bech32Checksum(hrp, values)
  const all = [...data5bit, ...checksum]

  return hrp + '1' + all.map(v => CHARSET[v]).join('')
}

function bech32Polymod (values) {
  const GEN = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3]
  let chk = 1
  for (const v of values) {
    const b = chk >> 25
    chk = ((chk & 0x1ffffff) << 5) ^ v
    for (let i = 0; i < 5; i++) {
      if ((b >> i) & 1) chk ^= GEN[i]
    }
  }
  return chk
}

function bech32HrpExpand (hrp) {
  const ret = []
  for (let i = 0; i < hrp.length; i++) ret.push(hrp.charCodeAt(i) >> 5)
  ret.push(0)
  for (let i = 0; i < hrp.length; i++) ret.push(hrp.charCodeAt(i) & 31)
  return ret
}

function bech32Checksum (hrp, data) {
  const values = [...bech32HrpExpand(hrp), ...data, 0, 0, 0, 0, 0, 0]
  const polymod = bech32Polymod(values) ^ 1
  const ret = []
  for (let i = 0; i < 6; i++) {
    ret.push((polymod >> (5 * (5 - i))) & 31)
  }
  return ret
}

export { bytesToHex, hexToBytes }