// atproto identity utilities: handle/DID resolution, Multikey decoding, and // service-auth JWT mint/verify. Adapted from matey's SDK-free client modules // (~/Code/robin.berjon.com/matey — same author, same conventions), trimmed to // what ziran needs and made fetch-injectable for offline tests. // // The service-auth JWT is atproto's inter-service credential: a ~60s compact // JWS signed by the *user's* repo signing key (the `#atproto` verification // method in their DID document). Anyone can verify one by resolving the DID // document — no shared secret, no OAuth infrastructure at the verifier. import { ed25519 } from '@noble/curves/ed25519.js'; import { p256 } from '@noble/curves/nist.js'; import { secp256k1 } from '@noble/curves/secp256k1.js'; import { base58 } from '@scure/base'; export const DEFAULT_HANDLE_RESOLVER = 'https://public.api.bsky.app'; export const DEFAULT_PLC_DIRECTORY = 'https://plc.directory'; export interface DidService { id: string; type: string; serviceEndpoint: unknown; } export interface DidVerificationMethod { id: string; type: string; controller?: string; publicKeyMultibase?: string; } export interface DidDocument { id: string; alsoKnownAs?: string[]; verificationMethod?: DidVerificationMethod[]; service?: DidService[]; } export interface ResolverOptions { fetch?: typeof fetch; handleResolver?: string; plcDirectory?: string; } export interface AtIdentity { did: string; document: DidDocument; handle: string | null; pds: string | null; } const strip = (s: string) => s.replace(/\/+$/, ''); /** Normalize user input: `@handle`, `at://handle`, `handle`, or a DID. */ export function normalizeIdentifier(input: string): { kind: 'did' | 'handle'; value: string } { let s = input.trim().toLowerCase(); if (s.startsWith('at://')) s = s.slice(5); if (s.startsWith('@')) s = s.slice(1); if (s.startsWith('did:')) { if (!/^did:(plc|web):[a-z0-9._:%-]+$/.test(s)) throw new Error(`unsupported DID: ${s}`); return { kind: 'did', value: s }; } if (!/^([a-z0-9]([a-z0-9-]*[a-z0-9])?\.)+[a-z]([a-z0-9-]*[a-z0-9])?$/.test(s)) { throw new Error(`“${input}” is not a handle or DID`); } return { kind: 'handle', value: s }; } async function fetchJson(f: typeof fetch, url: string): Promise<{ status: number; json: unknown }> { const res = await f(url, { headers: { accept: 'application/json' } }); let json: unknown = null; try { json = await res.json(); } catch { // non-JSON body } return { status: res.status, json }; } export async function resolveHandle(handle: string, opts: ResolverOptions = {}): Promise { const f = opts.fetch ?? fetch; const base = strip(opts.handleResolver ?? DEFAULT_HANDLE_RESOLVER); const { status, json } = await fetchJson( f, `${base}/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}`, ); const did = (json as Record | null)?.did; if (status !== 200 || typeof did !== 'string') { throw new Error(`could not resolve handle ${handle} (HTTP ${status})`); } return did; } export async function resolveDidDocument(did: string, opts: ResolverOptions = {}): Promise { const f = opts.fetch ?? fetch; let url: string; if (did.startsWith('did:plc:')) { url = `${strip(opts.plcDirectory ?? DEFAULT_PLC_DIRECTORY)}/${did}`; } else if (did.startsWith('did:web:')) { const host = decodeURIComponent(did.slice('did:web:'.length)); if (host.includes('/') || host.includes('?') || host.includes('#')) { throw new Error('atproto did:web identifiers are hostname-only'); } url = `https://${host}/.well-known/did.json`; } else { throw new Error(`unsupported DID method: ${did}`); } const { status, json } = await fetchJson(f, url); if (status !== 200 || json === null || typeof json !== 'object') { throw new Error(`could not resolve ${did} (HTTP ${status})`); } const doc = json as DidDocument; if (doc.id !== did) throw new Error(`DID document id ${doc.id} does not match ${did}`); return doc; } export function handleFromDocument(doc: DidDocument): string | null { for (const aka of doc.alsoKnownAs ?? []) { if (aka.startsWith('at://')) { const rest = aka.slice(5); if (rest.length > 0 && !rest.includes('/')) return rest; } } return null; } export function pdsFromDocument(doc: DidDocument): string | null { for (const svc of doc.service ?? []) { const id = svc.id.startsWith('#') ? svc.id : `#${svc.id.split('#')[1] ?? ''}`; if (id === '#atproto_pds' && svc.type === 'AtprotoPersonalDataServer') { return typeof svc.serviceEndpoint === 'string' ? svc.serviceEndpoint : null; } } return null; } /** Resolve a handle or DID to a full identity, with backlink verification when starting from a handle. */ export async function resolveIdentity(identifier: string, opts: ResolverOptions = {}): Promise { const norm = normalizeIdentifier(identifier); const did = norm.kind === 'did' ? norm.value : await resolveHandle(norm.value, opts); const document = await resolveDidDocument(did, opts); const declared = handleFromDocument(document); if (norm.kind === 'handle' && declared !== norm.value) { throw new Error( `handle verification failed: ${norm.value} resolved to ${did}, whose document declares ${declared ?? 'no handle'}`, ); } return { did, document, handle: declared, pds: pdsFromDocument(document) }; } /* ——— Multikey (did:key material in DID documents) ——— */ export type AtCurve = 'ed25519' | 'p256' | 'secp256k1'; const PREFIXES: Record = { ed25519: new Uint8Array([0xed, 0x01]), p256: new Uint8Array([0x80, 0x24]), secp256k1: new Uint8Array([0xe7, 0x01]), }; const KEY_LENGTHS: Record = { ed25519: 32, p256: 33, secp256k1: 33 }; export function decodeMultikey(multibase: string): { curve: AtCurve; keyBytes: Uint8Array } { if (!multibase.startsWith('z')) throw new Error('expected base58btc multibase (z…)'); const bytes = base58.decode(multibase.slice(1)); for (const curve of Object.keys(PREFIXES) as AtCurve[]) { const prefix = PREFIXES[curve]; if (bytes.length === prefix.length + KEY_LENGTHS[curve] && prefix.every((b, i) => bytes[i] === b)) { return { curve, keyBytes: bytes.slice(prefix.length) }; } } throw new Error('unsupported or malformed Multikey'); } export function encodeMultikey(curve: AtCurve, keyBytes: Uint8Array): string { const prefix = PREFIXES[curve]; if (keyBytes.length !== KEY_LENGTHS[curve]) { throw new Error(`bad ${curve} key length ${keyBytes.length}`); } const joined = new Uint8Array(prefix.length + keyBytes.length); joined.set(prefix, 0); joined.set(keyBytes, prefix.length); return `z${base58.encode(joined)}`; } /** The repo signing key (`#atproto` verification method) from a DID doc. */ export function atprotoSigningKey(doc: DidDocument): { curve: AtCurve; keyBytes: Uint8Array } { for (const vm of doc.verificationMethod ?? []) { const id = vm.id.startsWith('#') ? vm.id : `#${vm.id.split('#')[1] ?? ''}`; if (id === '#atproto' && vm.publicKeyMultibase) return decodeMultikey(vm.publicKeyMultibase); } throw new Error('DID document has no #atproto signing key'); } /* ——— service-auth JWTs ——— */ const te = new TextEncoder(); const td = new TextDecoder(); function b64urlEncode(bytes: Uint8Array): string { let bin = ''; for (const b of bytes) bin += String.fromCharCode(b); return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } function b64urlDecode(s: string): Uint8Array { const pad = s.replace(/-/g, '+').replace(/_/g, '/'); const bin = atob(pad + '='.repeat((4 - (pad.length % 4)) % 4)); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; } const b64urlJson = (obj: unknown) => b64urlEncode(te.encode(JSON.stringify(obj))); export interface ServiceAuthClaims { iss: string; aud: string; exp: number; lxm?: string; jti?: string; } const ALG_FOR_CURVE: Record = { ed25519: 'EdDSA', p256: 'ES256', secp256k1: 'ES256K' }; /** Verify a compact-JWS signature against a raw public key. ECDSA signatures are the JOSE 64-byte r||s form; `lowS: false` because JWA does not require low-S canonicalization and other stacks emit high-S. */ export function verifyJwsSignature( curve: AtCurve, publicKey: Uint8Array, signingInput: Uint8Array, signature: Uint8Array, ): boolean { switch (curve) { case 'ed25519': return ed25519.verify(signature, signingInput, publicKey); case 'p256': return p256.verify(signature, signingInput, publicKey, { prehash: true, lowS: false, format: 'compact' }); case 'secp256k1': return secp256k1.verify(signature, signingInput, publicKey, { prehash: true, lowS: false, format: 'compact' }); } } /** Mint a service-auth JWT with a raw signing key. Production tokens come from the user's PDS (`com.atproto.server.getServiceAuth`); this local minting exists for tests and for dev identities that own their keys. */ export function mintServiceAuthJwt( claims: ServiceAuthClaims, curve: AtCurve, secretKey: Uint8Array, ): string { const h = b64urlJson({ typ: 'JWT', alg: ALG_FOR_CURVE[curve] }); const p = b64urlJson({ jti: crypto.randomUUID().replace(/-/g, ''), ...claims }); const input = te.encode(`${h}.${p}`); let sig: Uint8Array; switch (curve) { case 'ed25519': sig = ed25519.sign(input, secretKey); break; case 'p256': sig = p256.sign(input, secretKey, { prehash: true, lowS: true, format: 'compact' }); break; case 'secp256k1': sig = secp256k1.sign(input, secretKey, { prehash: true, lowS: true, format: 'compact' }); break; } return `${h}.${p}.${b64urlEncode(sig)}`; } export interface VerifyServiceAuthOptions { /** The service DID this token must be addressed to. */ aud: string; /** Required lxm (method binding), if the service enforces one. */ lxm?: string; /** DID document lookup; defaults to network resolution. */ resolveDid?: (did: string) => Promise; /** Clock skew allowance in seconds. */ skew?: number; } /** Verify a service-auth JWT end to end: shape, expiry, audience, optional lxm, and the signature against the issuer's resolved `#atproto` key. Returns the issuer DID. */ export async function verifyServiceAuthJwt(token: string, opts: VerifyServiceAuthOptions): Promise { const parts = token.split('.'); if (parts.length !== 3) throw new Error('not a compact JWS'); const [h, p, s] = parts as [string, string, string]; const header = JSON.parse(td.decode(b64urlDecode(h))) as Record; const payload = JSON.parse(td.decode(b64urlDecode(p))) as Record; const iss = payload.iss; if (typeof iss !== 'string' || !iss.startsWith('did:')) throw new Error('missing iss'); if (payload.aud !== opts.aud) throw new Error('token is for a different service'); const exp = payload.exp; if (typeof exp !== 'number' || exp * 1000 < Date.now() - (opts.skew ?? 30) * 1000) { throw new Error('token expired'); } if (opts.lxm !== undefined && payload.lxm !== opts.lxm) throw new Error('token bound to a different method'); const doc = await (opts.resolveDid ?? ((did: string) => resolveDidDocument(did)))(iss); const { curve, keyBytes } = atprotoSigningKey(doc); if (header.alg !== ALG_FOR_CURVE[curve]) throw new Error(`alg ${header.alg} does not match the ${curve} key`); const ok = verifyJwsSignature(curve, keyBytes, te.encode(`${h}.${p}`), b64urlDecode(s)); if (!ok) throw new Error('bad signature'); return iss; }