// The same cases as rust/host/src/serviceauth.rs. import assert from "node:assert/strict"; import { test } from "node:test"; import { base58 } from "../encoding/index.js"; import { createDidKey } from "../didkey/index.js"; import { secp256k1 } from "../server/index.js"; import { cachedResolve, MAX_TTL_S, replayCache, SKEW_S, verifyServiceAuth } from "./index.js"; const ISS = "did:plc:caller0000000000000000000"; const AUD = "did:key:zDnaeService"; const LXM = "com.example.launch"; const NOW = 1_800_000_000; const auth = { audiences: [AUD, "did:web:svc.example"], issuers: [ISS] }; const KEY = new Uint8Array(32).fill(7); const enc = (value) => Buffer.from(JSON.stringify(value)).toString("base64url"); const jwt = (alg, claims, sign) => { const signing = `${enc({ typ: "JWT", alg })}.${enc(claims)}`; return `${signing}.${Buffer.from(sign(Buffer.from(signing))).toString("base64url")}`; }; const claims = (extra = {}) => ({ iss: ISS, aud: AUD, lxm: LXM, iat: NOW, exp: NOW + 60, jti: "x", ...extra }); const es256k = (c, key = KEY) => jwt("ES256K", c, (m) => secp256k1.sign(m, key)); const docFor = (key) => ({ verificationMethod: [{ id: "#atproto", publicKeyMultibase: `z${base58([0xe7, 0x01, ...secp256k1.getPublicKey(key, true)])}` }] }); const resolve = async () => docFor(KEY); const check = (header, extra = {}) => verifyServiceAuth(auth, header, LXM, { now: NOW, resolve, replay: replayCache(), ...extra }); test("an ES256K token from an allowed issuer passes; aud may carry a #fragment", async () => { assert.equal(await check(`Bearer ${es256k(claims())}`), ISS); assert.equal(await check(`Bearer ${es256k(claims({ aud: "did:web:svc.example#svc" }))}`), ISS); assert.equal(await check(`Bearer ${es256k(claims({ iat: NOW + SKEW_S }))}`), ISS, "iat within the skew"); }); test("an ES256 token (a did:key's service auth) passes", async () => { const key = await createDidKey(); const token = await key.serviceAuth({ aud: AUD, lxm: LXM }); const doc = { verificationMethod: [{ id: "did:key:x#atproto", publicKeyMultibase: key.did.slice("did:key:".length) }] }; assert.equal(await verifyServiceAuth({ audiences: [AUD], issuers: [key.did] }, `Bearer ${token}`, LXM, { resolve: async () => doc }), key.did); }); test("bad tokens are refused", async () => { const refused = async (header, pattern) => assert.rejects(check(header), pattern); await refused(undefined, /Bearer/); await refused("Basic x", /Bearer/); await refused("Bearer a.b", /not a JWT/); await refused(`Bearer ${es256k(claims({ lxm: "other.method" }))}`, /not com\.example\.launch/); await refused(`Bearer ${es256k(claims({ aud: "did:web:someone.else" }))}`, /is not this service/); await refused(`Bearer ${es256k(claims({ exp: NOW }))}`, /expired/); await refused(`Bearer ${es256k(claims({ exp: NOW + MAX_TTL_S + 1 }))}`, /more than 3600 s away/); await refused(`Bearer ${es256k(claims({ iat: undefined }))}`, /iat/); await refused(`Bearer ${es256k(claims({ iat: NOW + SKEW_S + 1 }))}`, /iat/); await refused(`Bearer ${es256k(claims({ iss: "did:plc:stranger" }))}`, /not allowed/); await refused(`Bearer ${es256k(claims(), new Uint8Array(32).fill(9))}`, /bad signature/); // a valid signature with S flipped high (atproto requires low S) const N = 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n; const high = jwt("ES256K", claims(), (m) => { const rs = secp256k1.sign(m, KEY); const s = BigInt(`0x${Buffer.from(rs.subarray(32)).toString("hex")}`); rs.set(Buffer.from((N - s).toString(16).padStart(64, "0"), "hex"), 32); return rs; }); await refused(`Bearer ${high}`, /high S/); const wrong = es256k(claims()).replace(enc({ typ: "JWT", alg: "ES256K" }), enc({ typ: "JWT", alg: "ES256" })); await refused(`Bearer ${wrong}`, /does not match the issuer/); }); test("a token is good once until it expires; a token without jti is keyed by its hash", async () => { const replay = replayCache(); const header = `Bearer ${es256k(claims())}`; assert.equal(await check(header, { replay }), ISS); await assert.rejects(check(header, { replay, now: NOW + 1 }), /the token was already used/); await assert.rejects(check(`Bearer ${es256k(claims({ exp: NOW + 30 }))}`, { replay }), /already used/, "same iss + jti"); assert.equal(await check(`Bearer ${es256k(claims({ jti: "y" }))}`, { replay }), ISS); const bare = `Bearer ${es256k(claims({ jti: undefined }))}`; assert.equal(await check(bare, { replay }), ISS); await assert.rejects(check(bare, { replay }), /already used/); assert.equal(replay.seen.size, 3); // The signature's spare trailing bits: the next base64url symbol decodes to the same 64 bytes, a // second replay key for one signature unless only the canonical form is taken. const B64U = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; const flipped = bare.slice(0, -1) + B64U[B64U.indexOf(bare.at(-1)) + 1]; assert.deepEqual(Buffer.from(flipped.split(".")[2], "base64url"), Buffer.from(bare.split(".")[2], "base64url")); await assert.rejects(check(flipped, { replay }), /signature is not base64url/); }); test("the replay cache is bounded: expired entries go first; full of live tokens it refuses, never evicts", () => { const replay = replayCache(2); replay.admit("a", 60, 0); replay.admit("x", 3600, 0); assert.throws(() => replay.admit("y", 3600, 0), /busy: too many tokens in use/); assert.throws(() => replay.admit("a", 60, 1), /already used/, "a live token stays remembered"); replay.admit("d", 3600, 61); // a has expired: its slot is free assert.deepEqual([...replay.seen.keys()], ["x", "d"]); replay.admit("a", 3700, 3601); // past its exp a key may come back }); test("DID documents are cached for a while and bounded; failures are not kept", async () => { let t = 0; const fetched = []; const resolve = cachedResolve(async (did) => { fetched.push(did); if (did === "did:plc:bad") throw new Error("nope"); return { id: did }; }, { ttlMs: 100, max: 2, clock: () => t }); for (const did of ["did:plc:a", "did:plc:a", "did:plc:b", "did:plc:a"]) assert.equal((await resolve(did)).id, did); assert.deepEqual(fetched, ["did:plc:a", "did:plc:b"]); await assert.rejects(resolve("did:plc:bad")); await assert.rejects(resolve("did:plc:bad")); await resolve("did:plc:c"); await resolve("did:plc:a"); assert.deepEqual(fetched.slice(2), ["did:plc:bad", "did:plc:bad", "did:plc:c", "did:plc:a"]); t = 150; await resolve("did:plc:c"); assert.equal(fetched.length, 7); });