// user-keys.js — a keypair per registered user.
//
// ⛔ WHY. `ctx.publicKeyPem` is process-wide, so a registered user advertised the PRIMARY's key
// material, and `buildActor` advertised the primary's key ID with it. Mastodon indexes actors by
// `publicKey.id`, so it resolved a registered user to the actor it already knew for that key:
// @ashatron@ap.7rnx.net came back as tabertron — a duplicate, not a new account.
//
// Distinct key IDs fix the resolution. Distinct key MATERIAL is what makes the separation real: a
// signature is what proves who acted, and two actors sharing a keypair are one identity wearing two
// names. Anything ashatron signed would have been signed by tabertron's key.
//
// ⛔ A KEY IS STABLE FOR THE LIFE OF THE NAME. Every remote server caches an actor's key; rotating
// it silently breaks signature verification everywhere at once, and the symptom is deliveries
// rejected for reasons that look like our crypto is broken. So this mints once and reads thereafter.
//
// ⚠ RSA-2048, because that is what interop needs today — the SWICG HTTP Signatures spec recommends
// it and says Ed25519 "is not yet widely supported", and Mastodon's FEP-521a support is RSA-only
// (DESIGN.md §4). Not a preference; a constraint with a shelf life.
import { spawn } from 'bare-subprocess'
import fs from 'bare-fs'
import { join } from 'bare-path'
const NAME = /^[a-z0-9][a-z0-9_-]{0,31}$/
export class UserKeys {
constructor ({ dir }) {
if (!dir) throw new Error('UserKeys: a directory is required')
this.dir = dir
this._cache = new Map()
}
_paths (username) {
return { priv: join(this.dir, username + '.key'), pub: join(this.dir, username + '.pub') }
}
/**
* This user's keypair — minted on first ask, read from disk thereafter.
*
* ⚠ Returns the PRIVATE half too. The caller needs it to sign as this user; nothing else should
* hold it, and it is written 0600 for that reason.
*/
async forUser (username) {
if (!NAME.test(String(username || ''))) throw new Error('UserKeys: invalid username')
if (this._cache.has(username)) return this._cache.get(username)
const { priv, pub } = this._paths(username)
try {
const pair = {
privateKeyPem: fs.readFileSync(priv, 'utf8'),
publicKeyPem: fs.readFileSync(pub, 'utf8')
}
this._cache.set(username, pair)
return pair
} catch {
// Not minted yet. Fall through — an unreadable half is treated as absent rather than
// half-repaired, because a mismatched pair verifies against nothing and says nothing.
}
const pair = await mint()
fs.mkdirSync(this.dir, { recursive: true })
// ⛔ The private half first and 0600. Writing the public one first would leave a window where an
// actor advertises a key whose private half does not exist yet.
fs.writeFileSync(priv, pair.privateKeyPem, { mode: 0o600 })
fs.writeFileSync(pub, pair.publicKeyPem)
this._cache.set(username, pair)
return pair
}
/** Drop a released user's keys. A burned name must not keep signing. */
async forget (username) {
if (!NAME.test(String(username || ''))) return false
const { priv, pub } = this._paths(username)
this._cache.delete(username)
let gone = false
for (const p of [priv, pub]) { try { fs.unlinkSync(p); gone = true } catch {} }
return gone
}
}
/**
* ⛔ openssl, not bare-crypto. bare-crypto's generateKeyPair supports ed25519 ONLY —
* UNKNOWN_KEY_TYPE for rsa — and AP interop needs RSA-2048 today: Mastodon's FEP-521a support is
* RSA-only and the SWICG spec says Ed25519 "is not yet widely supported" (DESIGN.md §4). So the
* constraint is external and the tool is the one the README already used to mint the primary's key.
*
* ⚠ Which means a host with no openssl cannot register users. That is stated rather than hidden:
* the error names the binary instead of surfacing as an actor with no key.
*/
function mint () {
return new Promise((resolve, reject) => {
const gen = spawn('openssl', ['genpkey', '-algorithm', 'RSA', '-pkeyopt', 'rsa_keygen_bits:2048'])
let priv = ''
let err = ''
gen.stdout.on('data', (d) => { priv += d })
gen.stderr.on('data', (d) => { err += d })
gen.on('error', () => reject(new Error('UserKeys: openssl is not available — a registered user cannot be given a key without it')))
gen.on('exit', (code) => {
if (code !== 0 || !priv.includes('PRIVATE KEY')) {
return reject(new Error('UserKeys: openssl genpkey failed: ' + (err.trim() || 'exit ' + code)))
}
const pub = spawn('openssl', ['rsa', '-pubout'])
let out = ''
let perr = ''
pub.stdout.on('data', (d) => { out += d })
pub.stderr.on('data', (d) => { perr += d })
pub.on('error', reject)
pub.on('exit', (c) => {
if (c !== 0 || !out.includes('PUBLIC KEY')) {
return reject(new Error('UserKeys: openssl rsa -pubout failed: ' + (perr.trim() || 'exit ' + c)))
}
resolve({ privateKeyPem: priv, publicKeyPem: out })
})
pub.stdin.end(priv)
})
})
}
export default { UserKeys }