OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-identity commits / 0a26c0c

atproto-identity: absorb auth.js (atproto session/auth substrate)
Move auth.js from atproto-bridge/pds/lib — pure atproto auth primitives (scrypt password hash/verify, HMAC session JWT sign/verify, secp256k1 service-auth assertions). Belongs with the crypto/identity substrate (@noble deps already present; added hypercore-crypto for randomBytes). Both the bridge and hyper-peer consume it — the peer needs signJwt/verifyPassword to mint its OWN sessions (peer-signs-over-RPC login; the bridge relays, never mints). Standalone JWT roundtrip + password verify confirmed. atproto-identity 13/13. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
0a26c0c13d8b9121e8f4c9c473e504799e310896
by plankatron <fleeky@gmail.com> · 2026-07-03T22:01:24.000Z

Changed files (3) +154 -0

lib/auth.js +141 -0
……@@ -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
10import { scrypt } from '@noble/hashes/scrypt'
11import { hmac } from '@noble/hashes/hmac'
12import { sha256 } from '@noble/hashes/sha2'
13import { secp256k1 } from '@noble/curves/secp256k1'
14import { randomBytes } from 'hypercore-crypto'
15
16const enc = new TextEncoder()
17const 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.
21const 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. ----
25export function b64url (bytes) {
26 return Buffer.from(bytes).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
27}
28export 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
34function hex (bytes) { return Buffer.from(bytes).toString('hex') }
35function fromHex (str) { return new Uint8Array(Buffer.from(str, 'hex')) }
36
37// Constant-time equality — never leak match progress via early return.
38export 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>`.
47export 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
53export 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 ----
67function segment (obj) { return b64url(enc.encode(JSON.stringify(obj))) }
68
69export 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.
79export 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. ----
98export 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.
107export 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.
123export function decodeSegment (seg) {
124 try { return JSON.parse(dec.decode(b64urlToBytes(seg))) } catch { return null }
125}
126
127export 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
133export 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
140export const ACCESS_SCOPE = 'com.atproto.access'
141export const REFRESH_SCOPE = 'com.atproto.refresh'
package-lock.json +12 -0
……@@ -14,6 +14,7 @@
1414 "@noble/hashes": "^1.5.0",
1515 "@scure/bip32": "^1.5.0",
1616 "@scure/bip39": "^1.4.0",
17 "hypercore-crypto": "^3.0.0",
1718 "keet-identity-key": "^3.2.0",
1819 "multiformats": "^13.3.0"
1920 },
……@@ -957,6 +958,17 @@
957958 "picomatch": "^4.0.2"
958959 }
959960 },
961 "node_modules/hypercore-crypto": {
962 "version": "3.7.0",
963 "resolved": "https://registry.npmjs.org/hypercore-crypto/-/hypercore-crypto-3.7.0.tgz",
964 "integrity": "sha512-SWxobptQf2V/+ZLCCDRTXqFzGfTPIkNIuDyLKXpEMfcpJ1/ec94N9aWgylHcWOHmRt14ng/KR7z0BugebWHK9Q==",
965 "license": "MIT",
966 "dependencies": {
967 "b4a": "^1.6.6",
968 "compact-encoding": "^3.0.0",
969 "sodium-universal": "^5.0.0"
970 }
971 },
960972 "node_modules/keet-identity-key": {
961973 "version": "3.2.0",
962974 "resolved": "https://registry.npmjs.org/keet-identity-key/-/keet-identity-key-3.2.0.tgz",
package.json +1 -0
……@@ -14,6 +14,7 @@
1414 "@noble/hashes": "^1.5.0",
1515 "@scure/bip32": "^1.5.0",
1616 "@scure/bip39": "^1.4.0",
17 "hypercore-crypto": "^3.0.0",
1718 "keet-identity-key": "^3.2.0",
1819 "multiformats": "^13.3.0"
1920 },