// 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
}