OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-identity / lib / auth.js

142 lines · javascript ref:
// PDS auth primitives — password hashing + HS256 session tokens. Pure and bare-safe:
// scrypt/hmac/sha256 are @noble/hashes (pure JS, no crypto global), randomness comes from
// hypercore-crypto (bare-native via sodium). No I/O — the caller owns storage + clock.
//
// This is the ONLY auth the bsky-facing HTTP membrane needs: a client calls createSession
// with {identifier,password}, gets an opaque accessJwt, and presents it as `Authorization:
// Bearer` on writes. The PDS verifies the HMAC + expiry and reads `sub` (the repo DID).
// Standard atproto shape, so any @atproto/api client works unchanged.

import { scrypt } from '@noble/hashes/scrypt'
import { hmac } from '@noble/hashes/hmac'
import { sha256 } from '@noble/hashes/sha2'
import { secp256k1 } from '@noble/curves/secp256k1'
import { randomBytes } from 'hypercore-crypto'

const enc = new TextEncoder()
const dec = new TextDecoder()

// scrypt work factor — ~16 MiB, interactive-login appropriate. Embedded in the stored
// string so the params can evolve without breaking existing hashes.
const SCRYPT = { N: 16384, r: 8, p: 1, dkLen: 32 }

// ---- base64url (JWT segments) — base64 is universally supported (node + bare-buffer);
// the +/ -> -_ transform makes it url-safe without needing 'base64url' encoder support. ----
export function b64url (bytes) {
  return Buffer.from(bytes).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
export function b64urlToBytes (str) {
  let s = str.replace(/-/g, '+').replace(/_/g, '/')
  while (s.length % 4) s += '='
  return new Uint8Array(Buffer.from(s, 'base64'))
}

function hex (bytes) { return Buffer.from(bytes).toString('hex') }
function fromHex (str) { return new Uint8Array(Buffer.from(str, 'hex')) }

// Constant-time equality — never leak match progress via early return.
export function timingSafeEqual (a, b) {
  if (a.length !== b.length) return false
  let d = 0
  for (let i = 0; i < a.length; i++) d |= a[i] ^ b[i]
  return d === 0
}

// ---- passwords ----
// Stored form: `scrypt$<N>$<r>$<p>$<saltHex>$<hashHex>`.
export function hashPassword (password, { salt } = {}) {
  const s = salt || randomBytes(16)
  const h = scrypt(enc.encode(password), s, SCRYPT)
  return `scrypt$${SCRYPT.N}$${SCRYPT.r}$${SCRYPT.p}$${hex(s)}$${hex(h)}`
}

export function verifyPassword (password, stored) {
  if (typeof stored !== 'string' || !stored.startsWith('scrypt$')) return false
  const parts = stored.split('$')
  if (parts.length !== 6) return false
  const N = parseInt(parts[1], 10)
  const r = parseInt(parts[2], 10)
  const p = parseInt(parts[3], 10)
  const salt = fromHex(parts[4])
  const expect = fromHex(parts[5])
  const got = scrypt(enc.encode(password), salt, { N, r, p, dkLen: expect.length })
  return timingSafeEqual(got, expect)
}

// ---- HS256 JWTs ----
function segment (obj) { return b64url(enc.encode(JSON.stringify(obj))) }

export function signJwt (payload, secret) {
  const head = segment({ alg: 'HS256', typ: 'JWT' })
  const body = segment(payload)
  const signingInput = `${head}.${body}`
  const sig = hmac(sha256, secret, enc.encode(signingInput))
  return `${signingInput}.${b64url(sig)}`
}

// Verify signature + expiry (nowSec = current time in epoch seconds). Throws a typed
// Error ({ status, error }) on any failure so the membrane can map it to an XRPC error.
export function verifyJwt (token, secret, { nowSec } = {}) {
  const parts = typeof token === 'string' ? token.split('.') : []
  if (parts.length !== 3) throw authError(401, 'AuthMissing', 'malformed token')
  const signingInput = `${parts[0]}.${parts[1]}`
  const expected = hmac(sha256, secret, enc.encode(signingInput))
  const got = b64urlToBytes(parts[2])
  if (!timingSafeEqual(got, expected)) throw authError(401, 'InvalidToken', 'bad signature')
  let payload
  try { payload = JSON.parse(dec.decode(b64urlToBytes(parts[1]))) } catch { throw authError(401, 'InvalidToken', 'bad payload') }
  const now = nowSec != null ? nowSec : Math.floor(Date.now() / 1000)
  if (payload.exp != null && now >= payload.exp) throw authError(401, 'ExpiredToken', 'token expired')
  return payload
}

// ---- ES256K signed assertions — the key-native login (also atproto's service-auth
// shape). A caller proves control of an account's atproto signing key by signing a
// short-lived assertion; the PDS verifies against the key already published in the DID,
// so NO secret is stored. This is the P2P trust root (key possession) brought to the
// HTTP edge — the same key that signs the repo's commits. ----
export function signAssertion (payload, privKey) {
  const head = segment({ alg: 'ES256K', typ: 'JWT' })
  const body = segment(payload)
  const signingInput = `${head}.${body}`
  const sig = secp256k1.sign(sha256(enc.encode(signingInput)), privKey, { lowS: true }).toCompactRawBytes()
  return `${signingInput}.${b64url(sig)}`
}

// Verify signature + expiry against a compressed secp256k1 public key. Throws typed.
export function verifyAssertion (token, pubKeyCompressed, { nowSec } = {}) {
  const parts = typeof token === 'string' ? token.split('.') : []
  if (parts.length !== 3) throw authError(401, 'AuthMissing', 'malformed assertion')
  const signingInput = `${parts[0]}.${parts[1]}`
  let okSig = false
  try { okSig = secp256k1.verify(b64urlToBytes(parts[2]), sha256(enc.encode(signingInput)), pubKeyCompressed, { lowS: true }) } catch { okSig = false }
  if (!okSig) throw authError(401, 'InvalidToken', 'bad assertion signature')
  let payload
  try { payload = JSON.parse(dec.decode(b64urlToBytes(parts[1]))) } catch { throw authError(401, 'InvalidToken', 'bad assertion payload') }
  const now = nowSec != null ? nowSec : Math.floor(Date.now() / 1000)
  if (payload.exp != null && now >= payload.exp) throw authError(401, 'ExpiredToken', 'assertion expired')
  return payload
}

// Decode a JWT/assertion segment (header or claims) WITHOUT verifying — used to read the
// issuer before we know which key to verify against. null on malformed input.
export function decodeSegment (seg) {
  try { return JSON.parse(dec.decode(b64urlToBytes(seg))) } catch { return null }
}

export function parseBearer (authHeader) {
  if (typeof authHeader !== 'string') return null
  const m = authHeader.match(/^Bearer\s+(.+)$/i)
  return m ? m[1].trim() : null
}

export function authError (status, error, message) {
  const e = new Error(message || error)
  e.status = status
  e.error = error
  return e
}

export const ACCESS_SCOPE = 'com.atproto.access'
export const REFRESH_SCOPE = 'com.atproto.refresh'