| … | … | @@ -0,0 +1,141 @@ |
| 1 | // PDS auth primitives — password hashing + HS256 session tokens. Pure and bare-safe: |
| 2 | // scrypt/hmac/sha256 are @noble/hashes (pure JS, no crypto global), randomness comes from |
| 3 | // hypercore-crypto (bare-native via sodium). No I/O — the caller owns storage + clock. |
| 4 | // |
| 5 | // This is the ONLY auth the bsky-facing HTTP membrane needs: a client calls createSession |
| 6 | // with {identifier,password}, gets an opaque accessJwt, and presents it as `Authorization: |
| 7 | // Bearer` on writes. The PDS verifies the HMAC + expiry and reads `sub` (the repo DID). |
| 8 | // Standard atproto shape, so any @atproto/api client works unchanged. |
| 9 | |
| 10 | import { scrypt } from '@noble/hashes/scrypt' |
| 11 | import { hmac } from '@noble/hashes/hmac' |
| 12 | import { sha256 } from '@noble/hashes/sha2' |
| 13 | import { secp256k1 } from '@noble/curves/secp256k1' |
| 14 | import { randomBytes } from 'hypercore-crypto' |
| 15 | |
| 16 | const enc = new TextEncoder() |
| 17 | const dec = new TextDecoder() |
| 18 | |
| 19 | // scrypt work factor — ~16 MiB, interactive-login appropriate. Embedded in the stored |
| 20 | // string so the params can evolve without breaking existing hashes. |
| 21 | const SCRYPT = { N: 16384, r: 8, p: 1, dkLen: 32 } |
| 22 | |
| 23 | // ---- base64url (JWT segments) — base64 is universally supported (node + bare-buffer); |
| 24 | // the +/ -> -_ transform makes it url-safe without needing 'base64url' encoder support. ---- |
| 25 | export function b64url (bytes) { |
| 26 | return Buffer.from(bytes).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') |
| 27 | } |
| 28 | export function b64urlToBytes (str) { |
| 29 | let s = str.replace(/-/g, '+').replace(/_/g, '/') |
| 30 | while (s.length % 4) s += '=' |
| 31 | return new Uint8Array(Buffer.from(s, 'base64')) |
| 32 | } |
| 33 | |
| 34 | function hex (bytes) { return Buffer.from(bytes).toString('hex') } |
| 35 | function fromHex (str) { return new Uint8Array(Buffer.from(str, 'hex')) } |
| 36 | |
| 37 | // Constant-time equality — never leak match progress via early return. |
| 38 | export function timingSafeEqual (a, b) { |
| 39 | if (a.length !== b.length) return false |
| 40 | let d = 0 |
| 41 | for (let i = 0; i < a.length; i++) d |= a[i] ^ b[i] |
| 42 | return d === 0 |
| 43 | } |
| 44 | |
| 45 | // ---- passwords ---- |
| 46 | // Stored form: `scrypt$<N>$<r>$<p>$<saltHex>$<hashHex>`. |
| 47 | export function hashPassword (password, { salt } = {}) { |
| 48 | const s = salt || randomBytes(16) |
| 49 | const h = scrypt(enc.encode(password), s, SCRYPT) |
| 50 | return `scrypt$${SCRYPT.N}$${SCRYPT.r}$${SCRYPT.p}$${hex(s)}$${hex(h)}` |
| 51 | } |
| 52 | |
| 53 | export function verifyPassword (password, stored) { |
| 54 | if (typeof stored !== 'string' || !stored.startsWith('scrypt$')) return false |
| 55 | const parts = stored.split('$') |
| 56 | if (parts.length !== 6) return false |
| 57 | const N = parseInt(parts[1], 10) |
| 58 | const r = parseInt(parts[2], 10) |
| 59 | const p = parseInt(parts[3], 10) |
| 60 | const salt = fromHex(parts[4]) |
| 61 | const expect = fromHex(parts[5]) |
| 62 | const got = scrypt(enc.encode(password), salt, { N, r, p, dkLen: expect.length }) |
| 63 | return timingSafeEqual(got, expect) |
| 64 | } |
| 65 | |
| 66 | // ---- HS256 JWTs ---- |
| 67 | function segment (obj) { return b64url(enc.encode(JSON.stringify(obj))) } |
| 68 | |
| 69 | export function signJwt (payload, secret) { |
| 70 | const head = segment({ alg: 'HS256', typ: 'JWT' }) |
| 71 | const body = segment(payload) |
| 72 | const signingInput = `${head}.${body}` |
| 73 | const sig = hmac(sha256, secret, enc.encode(signingInput)) |
| 74 | return `${signingInput}.${b64url(sig)}` |
| 75 | } |
| 76 | |
| 77 | // Verify signature + expiry (nowSec = current time in epoch seconds). Throws a typed |
| 78 | // Error ({ status, error }) on any failure so the membrane can map it to an XRPC error. |
| 79 | export function verifyJwt (token, secret, { nowSec } = {}) { |
| 80 | const parts = typeof token === 'string' ? token.split('.') : [] |
| 81 | if (parts.length !== 3) throw authError(401, 'AuthMissing', 'malformed token') |
| 82 | const signingInput = `${parts[0]}.${parts[1]}` |
| 83 | const expected = hmac(sha256, secret, enc.encode(signingInput)) |
| 84 | const got = b64urlToBytes(parts[2]) |
| 85 | if (!timingSafeEqual(got, expected)) throw authError(401, 'InvalidToken', 'bad signature') |
| 86 | let payload |
| 87 | try { payload = JSON.parse(dec.decode(b64urlToBytes(parts[1]))) } catch { throw authError(401, 'InvalidToken', 'bad payload') } |
| 88 | const now = nowSec != null ? nowSec : Math.floor(Date.now() / 1000) |
| 89 | if (payload.exp != null && now >= payload.exp) throw authError(401, 'ExpiredToken', 'token expired') |
| 90 | return payload |
| 91 | } |
| 92 | |
| 93 | // ---- ES256K signed assertions — the key-native login (also atproto's service-auth |
| 94 | // shape). A caller proves control of an account's atproto signing key by signing a |
| 95 | // short-lived assertion; the PDS verifies against the key already published in the DID, |
| 96 | // so NO secret is stored. This is the P2P trust root (key possession) brought to the |
| 97 | // HTTP edge — the same key that signs the repo's commits. ---- |
| 98 | export function signAssertion (payload, privKey) { |
| 99 | const head = segment({ alg: 'ES256K', typ: 'JWT' }) |
| 100 | const body = segment(payload) |
| 101 | const signingInput = `${head}.${body}` |
| 102 | const sig = secp256k1.sign(sha256(enc.encode(signingInput)), privKey, { lowS: true }).toCompactRawBytes() |
| 103 | return `${signingInput}.${b64url(sig)}` |
| 104 | } |
| 105 | |
| 106 | // Verify signature + expiry against a compressed secp256k1 public key. Throws typed. |
| 107 | export function verifyAssertion (token, pubKeyCompressed, { nowSec } = {}) { |
| 108 | const parts = typeof token === 'string' ? token.split('.') : [] |
| 109 | if (parts.length !== 3) throw authError(401, 'AuthMissing', 'malformed assertion') |
| 110 | const signingInput = `${parts[0]}.${parts[1]}` |
| 111 | let okSig = false |
| 112 | try { okSig = secp256k1.verify(b64urlToBytes(parts[2]), sha256(enc.encode(signingInput)), pubKeyCompressed, { lowS: true }) } catch { okSig = false } |
| 113 | if (!okSig) throw authError(401, 'InvalidToken', 'bad assertion signature') |
| 114 | let payload |
| 115 | try { payload = JSON.parse(dec.decode(b64urlToBytes(parts[1]))) } catch { throw authError(401, 'InvalidToken', 'bad assertion payload') } |
| 116 | const now = nowSec != null ? nowSec : Math.floor(Date.now() / 1000) |
| 117 | if (payload.exp != null && now >= payload.exp) throw authError(401, 'ExpiredToken', 'assertion expired') |
| 118 | return payload |
| 119 | } |
| 120 | |
| 121 | // Decode a JWT/assertion segment (header or claims) WITHOUT verifying — used to read the |
| 122 | // issuer before we know which key to verify against. null on malformed input. |
| 123 | export function decodeSegment (seg) { |
| 124 | try { return JSON.parse(dec.decode(b64urlToBytes(seg))) } catch { return null } |
| 125 | } |
| 126 | |
| 127 | export function parseBearer (authHeader) { |
| 128 | if (typeof authHeader !== 'string') return null |
| 129 | const m = authHeader.match(/^Bearer\s+(.+)$/i) |
| 130 | return m ? m[1].trim() : null |
| 131 | } |
| 132 | |
| 133 | export function authError (status, error, message) { |
| 134 | const e = new Error(message || error) |
| 135 | e.status = status |
| 136 | e.error = error |
| 137 | return e |
| 138 | } |
| 139 | |
| 140 | export const ACCESS_SCOPE = 'com.atproto.access' |
| 141 | export const REFRESH_SCOPE = 'com.atproto.refresh' |