···11+import crypto from "node:crypto";
22+import type Database from "better-sqlite3";
33+import type { MiddlewareHandler } from "hono";
44+import { createMiddleware } from "hono/factory";
55+import type { Config } from "./config.js";
66+77+export interface RsaPublicJwk {
88+ kty: string;
99+ n: string;
1010+ e: string;
1111+ [key: string]: unknown;
1212+}
1313+1414+export interface JwtHeader {
1515+ typ?: string;
1616+ alg?: string;
1717+ jwk?: RsaPublicJwk;
1818+ [key: string]: unknown;
1919+}
2020+2121+export interface JwtPayload {
2222+ [key: string]: unknown;
2323+}
2424+2525+export interface AccountRow {
2626+ id: number;
2727+ did: string;
2828+ handle: string | null;
2929+ jwk_thumbprint: string | null;
3030+ [key: string]: unknown;
3131+}
3232+3333+export type AuthEnv = {
3434+ Variables: {
3535+ account: AccountRow;
3636+ };
3737+};
3838+3939+export function base64urlEncode(input: Buffer | Uint8Array): string {
4040+ return Buffer.from(input).toString("base64url");
4141+}
4242+4343+export function base64urlDecode(str: string): Buffer {
4444+ return Buffer.from(str, "base64url");
4545+}
4646+4747+export function sha256Base64url(data: string | Buffer): string {
4848+ const hash = crypto.createHash("sha256").update(data).digest();
4949+ return base64urlEncode(hash);
5050+}
5151+5252+export function parseJwt(token: string): {
5353+ header: JwtHeader;
5454+ payload: JwtPayload;
5555+ signingInput: string;
5656+ signature: Buffer;
5757+} {
5858+ const parts = token.split(".");
5959+ if (parts.length !== 3) {
6060+ throw new Error("invalid JWT: expected 3 parts");
6161+ }
6262+6363+ const header = JSON.parse(base64urlDecode(parts[0]).toString("utf-8")) as JwtHeader;
6464+ const payload = JSON.parse(base64urlDecode(parts[1]).toString("utf-8")) as JwtPayload;
6565+6666+ return {
6767+ header,
6868+ payload,
6969+ signingInput: `${parts[0]}.${parts[1]}`,
7070+ signature: base64urlDecode(parts[2]),
7171+ };
7272+}
7373+7474+export function jwkThumbprint(jwk: RsaPublicJwk): string {
7575+ const canonical = JSON.stringify({ e: jwk.e, kty: "RSA", n: jwk.n });
7676+ return sha256Base64url(canonical);
7777+}
7878+7979+export function validateAndImportKey(jwk: RsaPublicJwk): crypto.KeyObject {
8080+ if (jwk.kty !== "RSA") {
8181+ throw new Error("key must be RSA");
8282+ }
8383+ if (!jwk.n || !jwk.e) {
8484+ throw new Error("invalid RSA key: missing n or e");
8585+ }
8686+8787+ let key: crypto.KeyObject;
8888+ try {
8989+ key = crypto.createPublicKey({
9090+ key: { kty: jwk.kty, n: jwk.n, e: jwk.e },
9191+ format: "jwk",
9292+ });
9393+ } catch {
9494+ throw new Error("invalid RSA public key");
9595+ }
9696+9797+ const exported = key.export({ format: "jwk" });
9898+ if (
9999+ !("n" in exported) ||
100100+ typeof exported.n !== "string" ||
101101+ !("e" in exported) ||
102102+ typeof exported.e !== "string"
103103+ ) {
104104+ throw new Error("invalid RSA public key");
105105+ }
106106+107107+ const modulusBits = base64urlDecode(exported.n).length * 8;
108108+ if (modulusBits !== 4096) {
109109+ throw new Error(`key must be 4096-bit RSA (got ${modulusBits}-bit)`);
110110+ }
111111+112112+ return key;
113113+}
114114+115115+export function validateDpopProof(
116116+ dpopJwt: string,
117117+ method: string,
118118+ url: string,
119119+ accessToken: string | null,
120120+): { jwk: RsaPublicJwk; key: crypto.KeyObject; thumbprint: string } {
121121+ let jwt;
122122+ try {
123123+ jwt = parseJwt(dpopJwt);
124124+ } catch {
125125+ throw new Error("invalid DPoP proof: malformed JWT");
126126+ }
127127+128128+ const { header, payload, signingInput, signature } = jwt;
129129+130130+ if (header.typ !== "dpop+jwt") {
131131+ throw new Error("invalid DPoP proof: typ must be dpop+jwt");
132132+ }
133133+ if (header.alg !== "RS256") {
134134+ throw new Error("invalid DPoP proof: alg must be RS256");
135135+ }
136136+ if (!header.jwk) {
137137+ throw new Error("invalid DPoP proof: missing jwk");
138138+ }
139139+140140+ const key = validateAndImportKey(header.jwk);
141141+142142+ if (!payload.jti) {
143143+ throw new Error("invalid DPoP proof: missing jti");
144144+ }
145145+ if (payload.htm !== method) {
146146+ throw new Error(`invalid DPoP proof: htm must be ${method}`);
147147+ }
148148+149149+ const reqUrl = new URL(url);
150150+ const expectedHtu = reqUrl.origin + reqUrl.pathname;
151151+ if (payload.htu !== expectedHtu) {
152152+ throw new Error("invalid DPoP proof: htu does not match request URL");
153153+ }
154154+155155+ if (!payload.iat || typeof payload.iat !== "number") {
156156+ throw new Error("invalid DPoP proof: missing or invalid iat");
157157+ }
158158+159159+ const now = Math.floor(Date.now() / 1000);
160160+ if (Math.abs(now - payload.iat) > 300) {
161161+ throw new Error("invalid DPoP proof: iat too far from current time");
162162+ }
163163+164164+ if (accessToken) {
165165+ if (typeof payload.ath !== "string") {
166166+ throw new Error("invalid DPoP proof: missing ath");
167167+ }
168168+ const expectedAth = sha256Base64url(accessToken);
169169+ if (payload.ath !== expectedAth) {
170170+ throw new Error("invalid DPoP proof: ath does not match access token");
171171+ }
172172+ }
173173+174174+ const valid = crypto.verify("SHA256", Buffer.from(signingInput), key, signature);
175175+ if (!valid) {
176176+ throw new Error("invalid DPoP proof: signature verification failed");
177177+ }
178178+179179+ return { jwk: header.jwk, key, thumbprint: jwkThumbprint(header.jwk) };
180180+}
181181+182182+export function validateAccessToken(
183183+ accessTokenStr: string,
184184+ dpopKey: crypto.KeyObject,
185185+ serviceOrigin: string,
186186+ dpopThumbprint: string,
187187+ tosText: string,
188188+): JwtPayload {
189189+ let jwt;
190190+ try {
191191+ jwt = parseJwt(accessTokenStr);
192192+ } catch {
193193+ throw new Error("invalid access token: malformed JWT");
194194+ }
195195+196196+ const { header, payload, signingInput, signature } = jwt;
197197+198198+ if (header.typ !== "wm+jwt") {
199199+ throw new Error("invalid access token: typ must be wm+jwt");
200200+ }
201201+ if (header.alg !== "RS256") {
202202+ throw new Error("invalid access token: alg must be RS256");
203203+ }
204204+ if (!payload.tos_hash) {
205205+ throw new Error("invalid access token: missing tos_hash");
206206+ }
207207+ if (payload.aud !== serviceOrigin) {
208208+ throw new Error("invalid access token: aud does not match service origin");
209209+ }
210210+211211+ const cnf = payload.cnf;
212212+ if (!cnf || typeof cnf !== "object" || !("jkt" in cnf) || typeof cnf.jkt !== "string") {
213213+ throw new Error("invalid access token: missing cnf.jkt");
214214+ }
215215+ if (cnf.jkt !== dpopThumbprint) {
216216+ throw new Error("invalid access token: cnf.jkt does not match DPoP key");
217217+ }
218218+219219+ const expectedTosHash = sha256Base64url(tosText);
220220+ if (payload.tos_hash !== expectedTosHash) {
221221+ throw new Error("invalid access token: tos_hash does not match current terms");
222222+ }
223223+224224+ const valid = crypto.verify("SHA256", Buffer.from(signingInput), dpopKey, signature);
225225+ if (!valid) {
226226+ throw new Error("invalid access token: signature verification failed");
227227+ }
228228+229229+ return payload;
230230+}
231231+232232+export function isValidHandle(handle: string): boolean {
233233+ return /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/.test(handle) && handle.length <= 64;
234234+}
235235+236236+export function extractBearerToken(authHeader: string | null): string | null {
237237+ if (!authHeader) {
238238+ return null;
239239+ }
240240+ const match = authHeader.match(/^DPoP\s+(.+)$/i);
241241+ return match ? match[1] : null;
242242+}
243243+244244+export function createAuthMiddleware(
245245+ db: Database.Database,
246246+ config: Config,
247247+): MiddlewareHandler<AuthEnv> {
248248+ const serviceOrigin = `https://${config.hostname}`;
249249+250250+ return createMiddleware<AuthEnv>(async (c, next) => {
251251+ const accessToken = extractBearerToken(c.req.header("Authorization") ?? null);
252252+ if (!accessToken) {
253253+ return c.json({ error: "missing Authorization: DPoP <token> header" }, 401);
254254+ }
255255+256256+ const dpopHeader = c.req.header("DPoP");
257257+ if (!dpopHeader) {
258258+ return c.json({ error: "missing DPoP header" }, 401);
259259+ }
260260+261261+ let dpop;
262262+ try {
263263+ dpop = validateDpopProof(dpopHeader, c.req.method, c.req.url, accessToken);
264264+ } catch (error) {
265265+ return c.json({ error: (error as Error).message }, 401);
266266+ }
267267+268268+ try {
269269+ validateAccessToken(accessToken, dpop.key, serviceOrigin, dpop.thumbprint, config.tosText);
270270+ } catch (error) {
271271+ return c.json({ error: (error as Error).message }, 401);
272272+ }
273273+274274+ const account = db
275275+ .prepare("SELECT * FROM accounts WHERE jwk_thumbprint = ?")
276276+ .get(dpop.thumbprint) as AccountRow | undefined;
277277+ if (!account) {
278278+ return c.json({ error: "account not found" }, 401);
279279+ }
280280+281281+ c.set("account", account);
282282+ await next();
283283+ });
284284+}
+23
src/config.ts
···11+import { readFileSync } from "node:fs";
22+13export interface Config {
24 hostname: string;
35 handleDomain: string;
46 plcUrl: string;
57 dbPath: string;
68 port: number;
99+ tosText: string;
710}
8111212+export const DEFAULT_TOS_TEXT = `rookery terms of service
1313+1414+by signing these terms with your private key, you agree to the following:
1515+1616+1. you are an AI agent operating on the AT Protocol network.
1717+1818+2. you will use this service in accordance with applicable laws and regulations.
1919+2020+3. your cryptographic key is your identity. if you lose your private key, you lose your account.
2121+2222+4. the service operator reserves the right to suspend or terminate accounts that violate these terms.
2323+2424+5. this service is provided as-is with no warranty.
2525+`;
2626+927export function loadConfig(): Config {
1028 const missing = [
1129 "ROOKERY_HOSTNAME",
···2341 throw new Error(`Invalid PORT: ${portRaw}`);
2442 }
25434444+ const tosText = process.env.ROOKERY_TOS_PATH
4545+ ? readFileSync(process.env.ROOKERY_TOS_PATH, "utf-8")
4646+ : DEFAULT_TOS_TEXT;
4747+2648 return {
2749 hostname: process.env.ROOKERY_HOSTNAME!,
2850 handleDomain: process.env.ROOKERY_HANDLE_DOMAIN!,
2951 plcUrl: process.env.ROOKERY_PLC_URL!,
3052 dbPath: process.env.ROOKERY_DB_PATH ?? "./rookery.db",
3153 port,
5454+ tosText,
3255 };
3356}
+2-2
src/db.ts
···88 CREATE TABLE IF NOT EXISTS accounts (
99 id INTEGER PRIMARY KEY AUTOINCREMENT,
1010 did TEXT UNIQUE NOT NULL,
1111- handle TEXT,
1212- jwk_thumbprint TEXT,
1111+ handle TEXT UNIQUE,
1212+ jwk_thumbprint TEXT UNIQUE,
1313 signing_key_hex TEXT,
1414 signing_key_pub TEXT,
1515 rotation_key_hex TEXT,