OverviewFilesCommitsIssuesDocsReleasesPeers

ap-bridge / lib / rsa.js

212 lines · javascript ref:
// rsa.js — RSA PKCS#1 v1.5 signing/verification over SHA-256, in pure JS.
//
// WHY THIS EXISTS: the Bare runtime has NO RSA. `bare-crypto`'s entire WebCrypto surface is
// pbkdf2/sha/hmac/ed25519 (lib/web/algorithm/), `generateKeyPair`/`sign`/`verify` have an ED25519
// branch and nothing else, and sodium-native/hypercore-crypto/@noble are all curve-only. ActivityPub
// HTTP Signatures are still RSA-2048 `rsa-sha256` across the fediverse, and FEP-521a's inbound
// support shipped RSA-only (mastodon#39497), so RSA is forced — and writing the HTTP layer by hand
// on bare-http1 routes around nothing. See memory/reference_bare-crypto-no-rsa.
//
// SCOPE: sign + verify only. KEY GENERATION IS DELIBERATELY ABSENT — prime generation is the hard,
// dangerous part, and we never need it at runtime: an actor's keypair is minted once with `openssl`
// and stored. Keeping keygen out keeps this file small enough to audit.
//
// The modulus arithmetic is BigInt (verified present in Bare 1.26.0). SHA-256 comes from bare-crypto,
// which does have it natively.

import crypto from 'bare-crypto'
import b4a from 'b4a'

// RFC 8017 §9.2, DigestInfo for id-sha256 — the fixed ASN.1 prefix prepended to the raw digest.
const SHA256_DIGEST_INFO = b4a.from('3031300d060960864801650304020105000420', 'hex')

// --- minimal ASN.1 DER reader -------------------------------------------------------------
// Only what a key file needs: SEQUENCE, INTEGER, BIT STRING, OCTET STRING. Not a general parser.

class Der {
  constructor (buf) { this.buf = buf; this.pos = 0 }

  _tag (expected) {
    const tag = this.buf[this.pos++]
    if (expected !== undefined && tag !== expected) {
      throw new Error(`DER: expected tag 0x${expected.toString(16)}, got 0x${tag.toString(16)}`)
    }
    return tag
  }

  _len () {
    let len = this.buf[this.pos++]
    if (len < 0x80) return len // short form
    const n = len & 0x7f // long form: next n bytes are the length, big-endian
    if (n === 0 || n > 4) throw new Error('DER: unsupported length encoding')
    len = 0
    for (let i = 0; i < n; i++) len = (len << 8) | this.buf[this.pos++]
    return len
  }

  seq () { this._tag(0x30); const len = this._len(); return new Der(this.buf.subarray(this.pos, (this.pos += len))) }

  // DER encodes INTEGERs as signed, so a positive value whose top bit is set carries a leading 0x00.
  // Strip it or the BigInt comes out wrong.
  int () {
    this._tag(0x02)
    const len = this._len()
    let bytes = this.buf.subarray(this.pos, (this.pos += len))
    while (bytes.length > 1 && bytes[0] === 0x00) bytes = bytes.subarray(1)
    return bytesToBigInt(bytes)
  }

  // NOTE: `this.pos += this._len()` would be WRONG — the compound assignment reads this.pos
  // before _len() runs, clobbering _len()'s own advance. Bind the length first.
  skipInt () { this._tag(0x02); const len = this._len(); this.pos += len }

  bitString () {
    this._tag(0x03)
    const len = this._len()
    const unused = this.buf[this.pos++] // count of unused trailing bits; always 0 for our keys
    if (unused !== 0) throw new Error('DER: unexpected unused bits in BIT STRING')
    return this.buf.subarray(this.pos, (this.pos += len - 1))
  }

  octetString () { this._tag(0x04); const len = this._len(); return this.buf.subarray(this.pos, (this.pos += len)) }

  peek () { return this.buf[this.pos] }
  get remaining () { return this.buf.length - this.pos }
}

// --- conversions --------------------------------------------------------------------------

function bytesToBigInt (bytes) {
  let hex = ''
  for (const b of bytes) hex += b.toString(16).padStart(2, '0')
  return hex === '' ? 0n : BigInt('0x' + hex)
}

// Fixed-width big-endian, left-padded to `len` — RFC 8017's I2OSP.
function bigIntToBytes (n, len) {
  let hex = n.toString(16)
  if (hex.length & 1) hex = '0' + hex
  const raw = b4a.from(hex, 'hex')
  if (raw.length > len) throw new Error('RSA: integer too large for modulus')
  const out = b4a.alloc(len)
  out.set(raw, len - raw.length)
  return out
}

function pemToDer (pem, label) {
  const re = new RegExp(`-----BEGIN ${label}-----([\\s\\S]*?)-----END ${label}-----`)
  const m = re.exec(pem)
  if (!m) throw new Error(`PEM: no ${label} block found`)
  return b4a.from(m[1].replace(/\s+/g, ''), 'base64')
}

// --- key parsing --------------------------------------------------------------------------

// PKCS#1 (`BEGIN RSA PRIVATE KEY`) and PKCS#8 (`BEGIN PRIVATE KEY`) both land here. PKCS#8 wraps
// the PKCS#1 structure in an extra SEQUENCE + AlgorithmIdentifier + OCTET STRING, so we unwrap and
// re-enter. openssl 3 emits PKCS#8 by default and PKCS#1 with `-traditional`; accept either rather
// than making the caller care.
export function parsePrivateKey (pem) {
  if (pem.includes('BEGIN RSA PRIVATE KEY')) return parsePkcs1Private(pemToDer(pem, 'RSA PRIVATE KEY'))
  if (pem.includes('BEGIN PRIVATE KEY')) {
    const outer = new Der(pemToDer(pem, 'PRIVATE KEY')).seq()
    outer.skipInt() // version
    outer.seq() // AlgorithmIdentifier — assumed rsaEncryption; a wrong OID fails later at parse
    return parsePkcs1Private(outer.octetString())
  }
  throw new Error('PEM: not an RSA private key')
}

function parsePkcs1Private (der) {
  const s = new Der(der).seq()
  s.skipInt() // version
  const n = s.int()
  const e = s.int()
  const d = s.int()
  return { n, e, d, size: byteLength(n) }
}

// SPKI (`BEGIN PUBLIC KEY`) is what ActivityPub actors publish as `publicKeyPem`.
export function parsePublicKey (pem) {
  if (pem.includes('BEGIN RSA PUBLIC KEY')) return parsePkcs1Public(pemToDer(pem, 'RSA PUBLIC KEY'))
  if (pem.includes('BEGIN PUBLIC KEY')) {
    const outer = new Der(pemToDer(pem, 'PUBLIC KEY')).seq()
    outer.seq() // AlgorithmIdentifier
    return parsePkcs1Public(outer.bitString())
  }
  throw new Error('PEM: not an RSA public key')
}

function parsePkcs1Public (der) {
  const s = new Der(der).seq()
  const n = s.int()
  const e = s.int()
  return { n, e, size: byteLength(n) }
}

function byteLength (n) {
  let hex = n.toString(16)
  if (hex.length & 1) hex = '0' + hex
  return hex.length / 2
}

// --- modular exponentiation ---------------------------------------------------------------
// Square-and-multiply. Not constant-time: this is fine for SIGNING with our own key on our own
// box (no attacker-controlled timing channel across the network for a single signature per
// delivery), and verification uses only public values. Do NOT reuse this for key exchange or
// anything where a remote party can time many operations against a secret exponent.

function modPow (base, exp, mod) {
  let result = 1n
  base %= mod
  while (exp > 0n) {
    if (exp & 1n) result = (result * base) % mod
    exp >>= 1n
    base = (base * base) % mod
  }
  return result
}

// --- EMSA-PKCS1-v1_5 (RFC 8017 §9.2) ------------------------------------------------------
// EM = 0x00 || 0x01 || PS || 0x00 || T   where PS is 0xFF padding and T is DigestInfo || H.

function emsaEncode (message, emLen) {
  const hash = crypto.createHash('sha256').update(message).digest()
  const t = b4a.concat([SHA256_DIGEST_INFO, b4a.from(hash)])
  if (emLen < t.length + 11) throw new Error('RSA: modulus too small for SHA-256 PKCS#1 v1.5')
  const ps = b4a.alloc(emLen - t.length - 3, 0xff)
  return b4a.concat([b4a.from([0x00, 0x01]), ps, b4a.from([0x00]), t])
}

// --- public API ---------------------------------------------------------------------------

// Returns the raw signature bytes (caller base64s them for the Signature header).
export function sign (message, privateKey) {
  const key = typeof privateKey === 'string' ? parsePrivateKey(privateKey) : privateKey
  const em = emsaEncode(message, key.size)
  const s = modPow(bytesToBigInt(em), key.d, key.n)
  return bigIntToBytes(s, key.size)
}

// Recompute the expected encoding and compare — equivalent to RFC 8017's RSASSA-PKCS1-V1_5-VERIFY.
export function verify (message, signature, publicKey) {
  const key = typeof publicKey === 'string' ? parsePublicKey(publicKey) : publicKey
  if (signature.length !== key.size) return false
  let m
  try {
    m = modPow(bytesToBigInt(signature), key.e, key.n)
  } catch {
    return false
  }
  const em = bigIntToBytes(m, key.size)
  const expected = emsaEncode(message, key.size)
  return timingSafeEqual(em, expected)
}

function timingSafeEqual (a, b) {
  if (a.length !== b.length) return false
  let diff = 0
  for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]
  return diff === 0
}