/*
RFC-1188: Web Bot Auth outbound identity — HTTP Message Signatures
(draft-ietf-httpbis-message-signatures) signer, OKP JWK derivation for the
published /.well-known/http-message-signatures-directory document, and the
single OutboundAgentClient fetch wrapper every platform-originated
agent-directed call goes through. Runtime-agnostic by construction:
@noble/ed25519 + WebCrypto SHA-256 + btoa — no node:* imports, no Buffer,
Workers-safe.
- Does not verify inbound signatures — inbound WBA stays delegated to the Cloudflare edge (ADR-0098); the only in-origin signature check lives inside RFC-1187's registration key-proof.
- Does not resolve key material or key versions — callers supply privateKeyHex / public key + kid (resolution conventions live with the callers).
- Does not implement retry, timeout, or redirect policy — the wrapped fetch owns transport semantics.
- RFC-1188: initial WBA signer, OKP JWK derivation, and OutboundAgentClient wrapper.
- RFC-1188: Web Bot Auth outbound identity: wba-sign primitives, directory emit+sweep, AGS-WBA-01, leitstand signed fetches
- RFC-1188: fo-review fix — direct ed.signAsync (no transitive node:crypto through signBytes), clone() scoped inside the body check, abort-signal regression + encoder PBT coverage
*/
// noble is the only dependency — signBytes lives in ../signing/sign.ts, which
// transitively imports canonical-json → primitives → node:crypto; importing it
// would break this module's Workers-safe contract at module-eval time.
import * as ed from "@noble/ed25519";
/** Well-known path the published key directory is served at (IETF draft convention). */
export const WBA_DIRECTORY_PATH = "/.well-known/http-message-signatures-directory";
const SIGNATURE_LABEL = "sig1";
const SIGNATURE_TTL_SECONDS = 60;
const COVERED_BASE = ["@method", "@authority", "@path", "signature-agent"] as const;
// ---------------------------------------------------------------------------
// Local encoders — Buffer-free so the module stays Workers-safe (the canonical
// encoders in ../signing/key.ts import node:crypto and must not leak here).
// ---------------------------------------------------------------------------
function hexToBytes(hex: string): Uint8Array {
const clean = hex.trim().toLowerCase();
if (!/^[0-9a-f]*$/.test(clean) || clean.length % 2 !== 0) {
throw new Error("invalid hex input");
}
const out = new Uint8Array(clean.length / 2);
for (let i = 0; i < out.length; i++) out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
return out;
}
const B58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
/** Decode a base58btc multibase string ("z"-prefixed) to bytes. */
function multibaseToBytes(multibase: string): Uint8Array {
if (!multibase.startsWith("z")) {
throw new Error(`unsupported multibase prefix "${multibase[0]}" — only "z" (base58btc)`);
}
const src = multibase.slice(1);
if (src.length === 0) return new Uint8Array(0);
let zeros = 0;
while (zeros < src.length && src[zeros] === "1") zeros++;
// Big-endian base-58 accumulation.
const bytes: number[] = [0];
for (let i = zeros; i < src.length; i++) {
const value = B58_ALPHABET.indexOf(src[i]);
if (value < 0) throw new Error(`invalid base58btc character "${src[i]}"`);
let carry = value;
for (let j = 0; j < bytes.length; j++) {
carry += bytes[j] * 58;
bytes[j] = carry & 0xff;
carry >>= 8;
}
while (carry > 0) {
bytes.push(carry & 0xff);
carry >>= 8;
}
}
return new Uint8Array([...new Array(zeros).fill(0), ...bytes.reverse()]);
}
function b64uEncodeBytes(bytes: Uint8Array): string {
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
}
function b64EncodeBytes(bytes: Uint8Array): string {
let bin = "";
for (const b of bytes) bin += String.fromCharCode(b);
return btoa(bin);
}
function b64Decode(input: string): Uint8Array {
const bin = atob(input);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
// ---------------------------------------------------------------------------
// JWK derivation — RFC-1188: the published directory exposes the passport key.
// ---------------------------------------------------------------------------
/** OKP JWK shape published in the WBA directory — DOM JsonWebKey lacks `kid` here. */
export interface WbaJwk {
kty: "OKP";
crv: "Ed25519";
/** Bare passport key version ("v1", "v2", …). */
kid: string;
/** base64url-encoded 32-byte Ed25519 public key. */
x: string;
alg: "EdDSA";
use: "sig";
}
/**
* Derive the OKP JWK published in the WBA directory from the canonical stored
* key form — raw bytes, hex, or base58btc multibase (`publicKeyMultibase`
* from cosmic-passport-key.json). `kid` is the bare passport key version.
*/
export function passportKeyToJwk(publicKey: Uint8Array | string, kid: string): WbaJwk {
let bytes: Uint8Array;
if (publicKey instanceof Uint8Array) {
bytes = publicKey;
} else if (publicKey.startsWith("z")) {
bytes = multibaseToBytes(publicKey);
} else {
bytes = hexToBytes(publicKey);
}
if (bytes.length !== 32) {
throw new Error(`Ed25519 public key must be 32 bytes, got ${bytes.length}`);
}
return { kty: "OKP", crv: "Ed25519", kid, x: b64uEncodeBytes(bytes), alg: "EdDSA", use: "sig" };
}
// ---------------------------------------------------------------------------
// Signer — HTTP Message Signatures over Ed25519.
// ---------------------------------------------------------------------------
export interface WbaSigner {
/** Active passport key version — also the JWK `kid` and signature `keyid`. */
readonly keyId: string;
/** Signs an outbound Request, returning a NEW Request with WBA headers set. */
sign(request: Request, opts?: { created?: number }): Promise;
}
export function createWbaSigner(opts: {
privateKeyHex: string;
keyId: string;
directoryUrl: string;
userAgent: string;
}): WbaSigner {
const privateKey = hexToBytes(opts.privateKeyHex);
if (privateKey.length !== 32) {
throw new Error(`Ed25519 private key must be 32 bytes, got ${privateKey.length}`);
}
return {
keyId: opts.keyId,
async sign(request, signOpts) {
const created = signOpts?.created ?? Math.floor(Date.now() / 1000);
const expires = created + SIGNATURE_TTL_SECONDS;
const url = new URL(request.url);
const covered: string[] = [...COVERED_BASE];
const lines: string[] = [
`"@method": ${request.method.toUpperCase()}`,
`"@authority": ${url.host}`,
`"@path": ${url.pathname}`,
`"signature-agent": ${opts.directoryUrl}`,
];
const headers = new Headers(request.headers);
headers.set("Signature-Agent", opts.directoryUrl);
headers.set("User-Agent", opts.userAgent);
// content-digest only when a body exists — clone before reading so the
// caller's request keeps its single-read body stream intact.
if (request.body !== null) {
const bodyRequest = request.clone();
const digest = await crypto.subtle.digest("SHA-256", await bodyRequest.arrayBuffer());
const contentDigest = `sha-256=:${b64EncodeBytes(new Uint8Array(digest))}:`;
headers.set("Content-Digest", contentDigest);
covered.push("content-digest");
lines.push(`"content-digest": ${contentDigest}`);
}
const coveredList = covered.map((c) => `"${c}"`).join(" ");
const params = `(${coveredList});keyid="${opts.keyId}";alg=ed25519;created=${created};expires=${expires}`;
lines.push(`"@signature-params": ${params}`);
const signatureBase = new TextEncoder().encode(lines.join("\n"));
const signature = await ed.signAsync(signatureBase, privateKey);
headers.set("Signature-Input", `${SIGNATURE_LABEL}=${params}`);
headers.set("Signature", `${SIGNATURE_LABEL}=:${b64EncodeBytes(signature)}:`);
return new Request(request, { headers });
},
};
}
// ---------------------------------------------------------------------------
// Outbound client — the single choke point for agent-directed calls.
// ---------------------------------------------------------------------------
export interface OutboundAgentClient {
readonly fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise;
}
/**
* Wraps fetch for platform-originated agent-directed requests. With a signer
* the request carries the full WBA header set; without one it still carries
* the identifying User-Agent and passes through otherwise unchanged — degraded,
* never broken. A signing failure logs a warning and falls back to unsigned:
* identity must never take outbound calls down.
*/
export function createOutboundAgentClient(opts: {
signer?: WbaSigner;
userAgent: string;
fetchImpl?: typeof fetch;
onSigningFailure?: (error: unknown) => void;
}): OutboundAgentClient {
const transport = opts.fetchImpl ?? fetch;
return {
async fetch(input, init) {
if (opts.signer) {
try {
return await transport(await opts.signer.sign(new Request(input, init)));
} catch (error) {
opts.onSigningFailure?.(error);
}
}
// Unsigned path: preserve the caller's fetch shape (url, init) — only
// the identifying User-Agent is injected, nothing is re-wrapped. When
// the caller passed a plain header record, keep the record shape so
// downstream code (and test doubles) sees the same type it supplied.
const source = input instanceof Request ? input.headers : init?.headers;
if (
source &&
typeof source === "object" &&
!(source instanceof Headers) &&
!Array.isArray(source)
) {
return transport(input, {
...init,
headers: {
...(source as Record),
"User-Agent": opts.userAgent,
},
});
}
const headers = new Headers(source);
headers.set("User-Agent", opts.userAgent);
return transport(input, { ...init, headers });
},
};
}
// ---------------------------------------------------------------------------
// Independent verification helper — used by tests + manifest.verify to prove
// a Signature header verifies against a published JWK. RFC 9421 base rebuild.
// ---------------------------------------------------------------------------
/** Decode the published directory JWK `x` back to raw public-key bytes. */
export function jwkToPublicKeyBytes(jwk: Pick): Uint8Array {
if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519" || typeof jwk.x !== "string") {
throw new Error("not an Ed25519 OKP JWK");
}
// base64url → base64
const padded = jwk.x.replaceAll("-", "+").replaceAll("_", "/");
return b64Decode(padded + "=".repeat((4 - (padded.length % 4)) % 4));
}