1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
// Verifying atproto service auth on a Node server (Hono or any other; rust/host/src/serviceauth.rs
// is the Rust host's): an `Authorization: Bearer <JWT>` a caller's PDS minted with
// com.atproto.server.getServiceAuth, signed (ES256K or ES256, low S) by the caller's #atproto key.
import { createHash, createPublicKey, verify } from "node:crypto";
import { unbase58 } from "../encoding/index.js";
/** Longest a token may still be valid for, and the clock skew allowed on its iat. */
export const MAX_TTL_S = 3600;
export const SKEW_S = 60;
/** Tokens a replay cache remembers at once, and how long and how many DID documents are kept. */
const REPLAY_MAX = 10_000;
const DOC_TTL_MS = 300_000;
const DOC_MAX = 1000;
/**
* The tokens verifyServiceAuth accepted, each until its exp: a token is good for one request (a
* replay of it is refused). Keyed by iss + jti, or the token's sha256 when it has no jti.
* Past `max` live tokens new ones are refused ("busy") rather than a live one dropped,
* which would let it be replayed. ponytail: one cap for every issuer; cap per iss if one floods it.
*/
export function replayCache(max = REPLAY_MAX) {
const seen = new Map(); // key -> exp
return {
seen,
admit(key, exp, now) {
if ((seen.get(key) ?? 0) > now) throw new Error("the token was already used");
if (seen.size >= max) for (const [k, e] of seen) if (e <= now) seen.delete(k);
if (seen.size >= max) throw new Error("busy: too many tokens in use; try again later");
seen.set(key, exp);
},
};
}
/**
* `resolve` with its documents kept for ttlMs, at most `max` of them (expired ones go first, then
* the oldest). Failures are not kept.
* ponytail: a key rotated within ttlMs fails the issuer's new tokens until the entry expires.
*/
export function cachedResolve(resolve, { ttlMs = DOC_TTL_MS, max = DOC_MAX, clock = Date.now } = {}) {
const docs = new Map(); // did -> [at, doc], oldest first
return async (did) => {
const hit = docs.get(did);
if (hit && clock() - hit[0] < ttlMs) return hit[1];
const doc = await resolve(did);
docs.delete(did);
if (docs.size >= max) for (const [k, [at]] of docs) if (clock() - at >= ttlMs) docs.delete(k);
if (docs.size >= max) docs.delete(docs.keys().next().value);
docs.set(did, [clock(), doc]);
return doc;
};
}
// multicodec prefix (secp256k1-pub 0xe7 0x01, p256-pub 0x80 0x24) -> [alg, curve OID, group order]
const CURVES = {
"231,1": ["ES256K", [0x2b, 0x81, 0x04, 0x00, 0x0a], 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n],
"128,36": ["ES256", [0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07], 0xffffffff00000000ffffffffffffffffbce6faada7179e84f3b9cac2fc632551n],
};
// The #atproto key of a DID document: (multicodec prefix, compressed key).
function atprotoKey(doc) {
const multibase = doc?.verificationMethod?.find?.((m) => typeof m?.id === "string" && m.id.endsWith("#atproto"))?.publicKeyMultibase;
if (typeof multibase !== "string") throw new Error("the issuer's DID document has no #atproto key");
if (!multibase.startsWith("z")) throw new Error("publicKeyMultibase is not base58btc");
const bytes = unbase58(multibase.slice(1));
if (bytes.length < 3) throw new Error("publicKeyMultibase is too short");
return [`${bytes[0]},${bytes[1]}`, bytes.subarray(2)];
}
const der = (tag, bytes) => [tag, bytes.length, ...bytes];
function checkSignature(alg, prefix, key, message, signature) {
const curve = CURVES[prefix];
if (!curve || curve[0] !== alg) throw new Error(`alg ${alg} does not match the issuer's key type`);
const [, oid, n] = curve;
if (signature.length !== 64) throw new Error("bad signature: not compact r||s");
if (BigInt(`0x${Buffer.from(signature.subarray(32)).toString("hex")}`) > n / 2n) throw new Error("bad signature: high S");
// SubjectPublicKeyInfo { { ecPublicKey, curve }, BIT STRING compressed point }
const spki = der(0x30, [...der(0x30, [...der(0x06, [0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01]), ...der(0x06, oid)]), ...der(0x03, [0, ...key])]);
let publicKey;
try {
publicKey = createPublicKey({ key: Buffer.from(spki), format: "der", type: "spki" });
} catch (e) {
throw new Error(`bad signature: ${e.message}`);
}
if (!verify("sha256", message, { key: publicKey, dsaEncoding: "ieee-p1363" }, signature)) throw new Error("bad signature: signature error");
}
/**
* did:plc via plc.directory, did:web via /.well-known/did.json (uncached; verifyServiceAuth's
* default resolve is this through cachedResolve).
* ponytail: unguarded fetch of the issuer's document; hardening is the open SSRF item (one hardened DID resolver).
*/
export async function resolveDid(did, fetch = globalThis.fetch) {
const url = did.startsWith("did:web:") ? `https://${did.slice(8)}/.well-known/did.json` : did.startsWith("did:plc:") ? `https://plc.directory/${did}` : null;
if (!url) throw new Error(`cannot resolve ${did}`);
const response = await fetch(url);
if (!response.ok) throw new Error(`resolving ${did}: ${response.status}`);
return response.json();
}
const defaultResolve = cachedResolve((did) => resolveDid(did));
const defaultReplay = replayCache();
/**
* The issuer DID of a valid `Authorization: Bearer <service-auth JWT>` for method `lxm`: aud one of
* `audiences` (or that DID with a #fragment), iat no later than now + SKEW_S, exp in the future but
* at most MAX_TTL_S away, iss in `issuers` (empty: anyone), signed by the issuer's #atproto key, and
* not seen by `replay` before (a caller mints one token per request). Throws an Error with the
* reason otherwise (answer 401). Defaults: DID documents cached (cachedResolve over resolveDid), one
* replay cache for the process.
* @param {{ audiences: string[], issuers?: string[] }} auth
* @param {string | undefined | null} authorization the Authorization header
* @param {string} lxm
* @param {{ now?: number, resolve?: (did: string) => Promise<object>, replay?: ReturnType<typeof replayCache> }} [options] now: unix seconds
*/
export async function verifyServiceAuth({ audiences, issuers = [] }, authorization, lxm, { now = Math.floor(Date.now() / 1000), resolve = defaultResolve, replay = defaultReplay } = {}) {
if (typeof authorization !== "string" || !authorization.startsWith("Bearer ")) throw new Error("Bearer service-auth token required");
const parts = authorization.slice(7).trim().split(".");
if (parts.length !== 3) throw new Error("the token is not a JWT");
const [h, p, s] = parts;
const part = (x) => {
if (!/^[A-Za-z0-9_-]*$/.test(x)) throw new Error("the token is not base64url");
try {
return JSON.parse(Buffer.from(x, "base64url").toString());
} catch {
throw new Error("the token is not JSON");
}
};
const [header, claims] = [part(h), part(p)];
const claim = (k) => (typeof claims?.[k] === "string" ? claims[k] : "");
const iss = claim("iss").split("#")[0];
if (claim("lxm") !== lxm) throw new Error(`the token is for ${JSON.stringify(claim("lxm"))}, not ${lxm}`);
const aud = claim("aud");
if (!audiences.some((a) => a === aud || a === aud.split("#")[0])) throw new Error(`the token's aud ${JSON.stringify(aud)} is not this service`);
const exp = Number.isSafeInteger(claims.exp) && claims.exp > 0 ? claims.exp : 0;
if (exp <= now) throw new Error("the token has expired");
if (exp > now + MAX_TTL_S) throw new Error(`the token's exp is more than ${MAX_TTL_S} s away`);
if (!Number.isSafeInteger(claims.iat) || claims.iat < 0 || claims.iat > now + SKEW_S) throw new Error("the token's iat is missing or in the future");
if (issuers.length && !issuers.includes(iss)) throw new Error(`issuer ${iss} is not allowed`);
const [prefix, key] = atprotoKey(await resolve(iss));
// Canonical only: Node decodes spare trailing bits away, so "AQID-g" and "AQID-h" would be one
// signature under two replay keys.
if (!/^[A-Za-z0-9_-]*$/.test(s) || Buffer.from(s, "base64url").toString("base64url") !== s) throw new Error("the signature is not base64url");
checkSignature(typeof header?.alg === "string" ? header.alg : "", prefix, key, Buffer.from(`${h}.${p}`), new Uint8Array(Buffer.from(s, "base64url")));
const jti = claim("jti");
replay.admit(jti ? `${iss} ${jti}` : `sha256 ${createHash("sha256").update(`${h}.${p}.${s}`).digest("hex")}`, exp, now);
return iss;
}