diff --git a/.changeset/bright-clouds-dance.md b/.changeset/bright-clouds-dance.md new file mode 100644 index 00000000..129a0f81 --- /dev/null +++ b/.changeset/bright-clouds-dance.md @@ -0,0 +1,5 @@ +--- +'ox': minor +--- + +Added ERC-8128 Signed HTTP Requests module at `ox/erc8128` with `HttpSignature` and `ContentDigest` utilities. diff --git a/src/erc8128/ContentDigest.ts b/src/erc8128/ContentDigest.ts new file mode 100644 index 00000000..1d53fd2d --- /dev/null +++ b/src/erc8128/ContentDigest.ts @@ -0,0 +1,139 @@ +import * as Base64 from '../core/Base64.js' +import * as Bytes from '../core/Bytes.js' +import * as Errors from '../core/Errors.js' +import * as Hash from '../core/Hash.js' + +/** A parsed Content-Digest value. */ +export type ContentDigest = { + /** Hash algorithm (e.g. `"sha-256"`). */ + algorithm: string + /** Base64-encoded digest. */ + digest: string +} + +/** + * Computes an RFC 9530 `Content-Digest` header value from a request body. + * + * @example + * ```ts twoslash + * import { ContentDigest } from 'ox/erc8128' + * + * const header = ContentDigest.compute('hello') + * // @log: 'sha-256=:LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=:' + * ``` + * + * @param body - The request body. + * @returns The `Content-Digest` header value. + */ +export function compute(body: string | Uint8Array): string { + const bytes = typeof body === 'string' ? Bytes.fromString(body) : body + const hash = Hash.sha256(bytes, { as: 'Bytes' }) + const encoded = Base64.fromBytes(hash) + return `sha-256=:${encoded}:` +} + +export declare namespace compute { + type ErrorType = + | Hash.sha256.ErrorType + | Base64.fromBytes.ErrorType + | Errors.GlobalErrorType +} + +/** + * Serializes a {@link ContentDigest} into an RFC 9530 header value. + * + * @example + * ```ts twoslash + * import { ContentDigest } from 'ox/erc8128' + * + * const header = ContentDigest.serialize({ + * algorithm: 'sha-256', + * digest: 'LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=', + * }) + * // @log: 'sha-256=:LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=:' + * ``` + * + * @param value - The content digest to serialize. + * @returns The header value string. + */ +export function serialize(value: ContentDigest): string { + return `${value.algorithm}=:${value.digest}:` +} + +export declare namespace serialize { + type ErrorType = Errors.GlobalErrorType +} + +/** + * Deserializes an RFC 9530 `Content-Digest` header value. + * + * @example + * ```ts twoslash + * import { ContentDigest } from 'ox/erc8128' + * + * const { algorithm, digest } = ContentDigest.deserialize( + * 'sha-256=:LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=:', + * ) + * // @log: { algorithm: 'sha-256', digest: 'LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=' } + * ``` + * + * @param header - The `Content-Digest` header value. + * @returns The parsed content digest. + */ +export function deserialize(header: string): ContentDigest { + const match = header.match(/^([A-Za-z0-9_-]+)=:([A-Za-z0-9+/]+={0,2}):$/) + if (!match) throw new InvalidContentDigestError(header) + return { algorithm: match[1]!.toLowerCase(), digest: match[2]! } +} + +export declare namespace deserialize { + type ErrorType = InvalidContentDigestError | Errors.GlobalErrorType +} + +/** + * Verifies that a `Content-Digest` header matches the actual body content. + * + * @example + * ```ts twoslash + * import { ContentDigest } from 'ox/erc8128' + * + * const valid = ContentDigest.verify({ + * body: new TextEncoder().encode('hello'), + * header: 'sha-256=:LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=:', + * }) + * // @log: true + * ``` + * + * @param options - Body and header to verify. + * @returns `true` if the digest matches, `false` otherwise. + */ +export function verify(options: verify.Options): boolean { + const { body, header } = options + const parsed = deserialize(header) + if (parsed.algorithm !== 'sha-256') return false + const expected = compute(body) + const expectedParsed = deserialize(expected) + return parsed.digest === expectedParsed.digest +} + +export declare namespace verify { + type Options = { + /** The request body. */ + body: string | Uint8Array + /** The `Content-Digest` header value. */ + header: string + } + + type ErrorType = + | compute.ErrorType + | deserialize.ErrorType + | Errors.GlobalErrorType +} + +/** Thrown when a `Content-Digest` header value is malformed. */ +export class InvalidContentDigestError extends Errors.BaseError { + override readonly name = 'ContentDigest.InvalidContentDigestError' + constructor(header: string) { + super(`Invalid Content-Digest header value: "${header}".`) + } +} diff --git a/src/erc8128/HttpSignature.ts b/src/erc8128/HttpSignature.ts new file mode 100644 index 00000000..13b63d0c --- /dev/null +++ b/src/erc8128/HttpSignature.ts @@ -0,0 +1,451 @@ +import type * as Address from '../core/Address.js' +import * as Base64 from '../core/Base64.js' +import * as Bytes from '../core/Bytes.js' +import * as Errors from '../core/Errors.js' +import * as Hex from '../core/Hex.js' +import * as PersonalMessage from '../core/PersonalMessage.js' +import * as ContentDigest from './ContentDigest.js' +import * as HttpSignatureInput from './internal/HttpSignatureInput.js' +import * as KeyId from './internal/KeyId.js' + +/** An HTTP request to sign. */ +export type Request = { + /** Host and optional port (e.g. `"api.example.com"`). */ + authority: string + /** Raw request body. */ + body?: string | Uint8Array | undefined + /** HTTP headers (lowercase keys). */ + headers?: Record | undefined + /** HTTP method (e.g. `"GET"`, `"POST"`). */ + method: string + /** URL path (e.g. `"/foo"`). */ + path: string + /** Query string including leading `"?"` (e.g. `"?a=1&b=two"`). */ + query?: string | undefined +} + +/** Known RFC 9421 derived components and ERC-8128 header fields. */ +export type Component = + | '@authority' + | '@method' + | '@path' + | '@query' + | '@scheme' + | '@target-uri' + | 'content-digest' + | (string & {}) + +/** Signature parameters for an ERC-8128 HTTP signature. */ +export type SignatureParams = { + /** Ethereum address of the signer. */ + address: Address.Address + /** Chain ID for the signing account. */ + chainId: number + /** Covered components to include in the signature. @default `['@authority', '@method', '@path']` + `@query` and `content-digest` when applicable */ + components?: readonly Component[] | undefined + /** Unix timestamp (seconds) when the signature was created. @default `Math.floor(Date.now() / 1000)` */ + created?: number | undefined + /** Unix timestamp (seconds) when the signature expires. @default `created + 60` */ + expires?: number | undefined + /** Signature label. @default `"eth"` */ + label?: string | undefined + /** Unique nonce for replay protection. @default random 32 bytes (base64url-encoded) */ + nonce?: string | undefined +} + +/** + * Computes the sign payload for an [ERC-8128](https://github.com/slice-so/ERCs/blob/9f6cf39b52b94d405dcc4e521916a0ab00f03e2a/ERCS/erc-8128.md) HTTP signature. + * + * Constructs the RFC 9421 signature base from the request and parameters, + * then returns the ERC-191 hash ready for signing with `Secp256k1.sign`. + * + * Accepts either a plain {@link Request} object or a Fetch API + * [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request). + * + * @example + * ### Plain Request + * + * ```ts twoslash + * import { Secp256k1 } from 'ox' + * import { HttpSignature } from 'ox/erc8128' + * + * const { payload, signatureInput } = await HttpSignature.getSignPayload({ + * request: { + * method: 'POST', + * authority: 'api.example.com', + * path: '/foo', + * query: '?a=1&b=two', + * body: new TextEncoder().encode('{"hello":"world"}'), + * }, + * chainId: 1, + * address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + * }) + * + * const signature = Secp256k1.sign({ payload, privateKey: '0x...' }) + * ``` + * + * @example + * ### Fetch API Request + * + * ```ts twoslash + * import { Secp256k1 } from 'ox' + * import { HttpSignature } from 'ox/erc8128' + * + * const request = new Request('https://api.example.com/foo?a=1', { + * method: 'POST', + * body: JSON.stringify({ hello: 'world' }), + * }) + * + * const { payload, signatureInput } = await HttpSignature.getSignPayload({ + * request, + * chainId: 1, + * address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + * }) + * + * const signature = Secp256k1.sign({ payload, privateKey: '0x...' }) + * ``` + * + * @param options - Request and signature parameters. + * @returns The ERC-191 sign payload and serialized `Signature-Input` header value. + */ +export async function getSignPayload( + options: getSignPayload.Options, +): Promise { + const { chainId, address, label = 'eth' } = options + + const created = options.created ?? Math.floor(Date.now() / 1000) + const expires = options.expires ?? created + 60 + const nonce = + options.nonce ?? + Base64.fromBytes(Bytes.random(32), { pad: false, url: true }) + + const request = isFetchRequest(options.request) + ? await fromFetchRequest(options.request) + : options.request + + const keyid = KeyId.serialize({ chainId, address }) + + // Compute covered components. + const coveredComponents = options.components ?? components(request) + + // Ensure content-digest header exists if body is present. + const headers = { ...request.headers } + if (request.body && request.body.length > 0 && !headers['content-digest']) { + headers['content-digest'] = ContentDigest.compute(request.body) + } + + // Serialize Signature-Input value. + const params = { + created, + expires, + nonce, + keyid, + } satisfies HttpSignatureInput.Params + + const signatureInput = HttpSignatureInput.serialize( + label, + coveredComponents, + params, + ) + + // Build signature base per RFC 9421. + const signatureBase = createSignatureBase( + { ...request, headers }, + coveredComponents, + params, + ) + + // ERC-191 hash of signature base. + const payload = PersonalMessage.getSignPayload(Hex.fromString(signatureBase)) + + return { payload, signatureInput } +} + +export declare namespace getSignPayload { + type Options = SignatureParams & { + /** The HTTP request to sign. Accepts a plain {@link Request} or a Fetch API `Request`. */ + request: Request | globalThis.Request + } + + type ReturnType = { + /** The ERC-191 payload to sign with `Secp256k1.sign`. */ + payload: Hex.Hex + /** The serialized `Signature-Input` header value to attach to the request. */ + signatureInput: string + } + + type ErrorType = + | PersonalMessage.getSignPayload.ErrorType + | Hex.fromString.ErrorType + | Errors.GlobalErrorType +} + +/** + * Serializes a signature into the `Signature` HTTP header value. + * + * @example + * ```ts twoslash + * import { Secp256k1 } from 'ox' + * import { HttpSignature } from 'ox/erc8128' + * + * const signature = Secp256k1.sign({ payload: '0x...', privateKey: '0x...' }) + * const header = HttpSignature.serialize({ signature }) + * // @log: 'eth=:base64bytes:' + * ``` + * + * @param options - Signature and optional label. + * @returns The `Signature` header value. + */ +export function serialize(options: serialize.Options): string { + const { label = 'eth', signature } = options + const sigBytes = Hex.toBytes(signature) + const sigB64 = Base64.fromBytes(sigBytes) + return `${label}=:${sigB64}:` +} + +export declare namespace serialize { + type Options = { + /** Signature label. @default `"eth"` */ + label?: string | undefined + /** The signature as hex. */ + signature: Hex.Hex + } + + type ErrorType = + | Hex.toBytes.ErrorType + | Base64.fromBytes.ErrorType + | Errors.GlobalErrorType +} + +/** + * Attaches `Signature-Input` and `Signature` headers to a Fetch API `Request`. + * + * @example + * ```ts twoslash + * import { Secp256k1 } from 'ox' + * import { HttpSignature } from 'ox/erc8128' + * + * const request = new Request('https://api.example.com/foo', { method: 'GET' }) + * + * const { payload, signatureInput } = await HttpSignature.getSignPayload({ + * request, + * chainId: 1, + * address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + * }) + * + * const signature = Secp256k1.sign({ payload, privateKey: '0x...' }) + * + * const signedRequest = HttpSignature.toRequest({ + * request, + * signature, + * signatureInput, + * }) + * ``` + * + * @param options - Request, signature, and signature input. + * @returns A new `Request` with signature headers attached. + */ +export function toRequest(options: toRequest.Options): globalThis.Request { + const { request, signatureInput, label } = options + const signatureHeader = serialize({ label, signature: options.signature }) + const signed = new globalThis.Request(request, { + headers: new Headers(request.headers), + }) + signed.headers.set('signature-input', signatureInput) + signed.headers.set('signature', signatureHeader) + return signed +} + +export declare namespace toRequest { + type Options = { + /** Signature label. @default `"eth"` */ + label?: string | undefined + /** The Fetch API `Request` to attach headers to. */ + request: globalThis.Request + /** The signature as hex. */ + signature: Hex.Hex + /** The serialized `Signature-Input` header value from `getSignPayload`. */ + signatureInput: string + } + + type ErrorType = serialize.ErrorType | Errors.GlobalErrorType +} + +/** + * Extracts the signature and signature input from a signed Fetch API `Request`. + * + * @example + * ```ts twoslash + * import { HttpSignature } from 'ox/erc8128' + * + * const request = new Request('https://api.example.com/foo', { method: 'GET' }) + * // ... attach signature headers ... + * + * const { signature, signatureInput } = HttpSignature.fromRequest(request) + * ``` + * + * @param request - The signed Fetch API `Request`. + * @param options - Options. + * @returns The parsed signature and raw signature input header value. + */ +export function fromRequest( + request: globalThis.Request, + options: fromRequest.Options = {}, +): fromRequest.ReturnType { + const { label = 'eth' } = options + + const signatureInput = request.headers.get('signature-input') + if (!signatureInput) throw new MissingHeaderError('Signature-Input') + + const signatureHeader = request.headers.get('signature') + if (!signatureHeader) throw new MissingHeaderError('Signature') + + // Parse `label=:base64:` from the Signature header. + const prefix = `${label}=:` + if (!signatureHeader.startsWith(prefix) || !signatureHeader.endsWith(':')) + throw new InvalidSignatureHeaderError(signatureHeader) + + const sigB64 = signatureHeader.slice(prefix.length, -1) + const sigBytes = Base64.toBytes(sigB64) + const signature = Hex.fromBytes(sigBytes) + + return { signature, signatureInput } +} + +export declare namespace fromRequest { + type Options = { + /** Signature label to look for. @default `"eth"` */ + label?: string | undefined + } + + type ReturnType = { + /** The signature as hex. */ + signature: Hex.Hex + /** The raw `Signature-Input` header value. */ + signatureInput: string + } + + type ErrorType = + | MissingHeaderError + | InvalidSignatureHeaderError + | Hex.fromBytes.ErrorType + | Errors.GlobalErrorType +} + +/** Thrown when a required HTTP signature header is missing. */ +export class MissingHeaderError extends Errors.BaseError { + override readonly name = 'HttpSignature.MissingHeaderError' + constructor(header: string) { + super(`Missing required header: "${header}".`) + } +} + +/** Thrown when the `Signature` header value is malformed. */ +export class InvalidSignatureHeaderError extends Errors.BaseError { + override readonly name = 'HttpSignature.InvalidSignatureHeaderError' + constructor(value: string) { + super(`Invalid Signature header value: "${value}".`) + } +} + +/** @internal Checks whether a value is a Fetch API `Request`. */ +function isFetchRequest( + value: Request | globalThis.Request, +): value is globalThis.Request { + return ( + typeof globalThis.Request !== 'undefined' && + value instanceof globalThis.Request + ) +} + +/** @internal Converts a Fetch API `Request` to a plain {@link Request}. */ +async function fromFetchRequest(request: globalThis.Request): Promise { + const url = new URL(request.url) + + // Normalize authority: omit default ports (80 for http, 443 for https). + const isDefaultPort = + (url.protocol === 'http:' && url.port === '80') || + (url.protocol === 'https:' && url.port === '443') + const authority = isDefaultPort ? url.hostname : url.host + + const headers: Record = {} + request.headers.forEach((value, key) => { + headers[key] = value + }) + + let body: Uint8Array | undefined + if (request.body !== null) { + body = new Uint8Array(await request.clone().arrayBuffer()) + } + + return { + authority, + method: request.method, + path: url.pathname, + ...(url.search ? { query: url.search } : {}), + ...(body ? { body } : {}), + ...(Object.keys(headers).length > 0 ? { headers } : {}), + } +} + +/** @internal Returns the list of covered components for a request per ERC-8128 §3.1.1. */ +function components(request: Request): string[] { + const c = ['@authority', '@method', '@path'] + if (request.query) c.push('@query') + if (request.body && request.body.length > 0) c.push('content-digest') + return c +} + +/** + * @internal + * Constructs the RFC 9421 signature base from request components. + * + * Each covered component is serialized as `"": \n`, + * followed by `"@signature-params": `. + */ +function createSignatureBase( + request: Request & { headers?: Record }, + components: readonly string[], + params: HttpSignatureInput.Params, +): string { + const lines: string[] = [] + + for (const component of components) { + let value: string + switch (component) { + case '@method': + value = request.method.toUpperCase() + break + case '@authority': + value = request.authority + break + case '@path': + value = request.path + break + case '@query': + value = request.query ?? '?' + break + default: + // HTTP header field (e.g. "content-digest"). + // Canonicalize per RFC 9421: trim and collapse whitespace. + value = (request.headers?.[component] ?? '') + .trim() + .replace(/[ \t]+/g, ' ') + break + } + lines.push(`${HttpSignatureInput.quoteSfString(component)}: ${value}`) + } + + // Build @signature-params line. + const inner = components + .map((c) => HttpSignatureInput.quoteSfString(c)) + .join(' ') + const paramParts: string[] = [] + for (const [key, value] of Object.entries(params)) { + if (typeof value === 'number') paramParts.push(`${key}=${value}`) + else paramParts.push(`${key}=${HttpSignatureInput.quoteSfString(value)}`) + } + lines.push(`"@signature-params": (${inner});${paramParts.join(';')}`) + + return lines.join('\n') +} diff --git a/src/erc8128/_test/ContentDigest.test.ts b/src/erc8128/_test/ContentDigest.test.ts new file mode 100644 index 00000000..0c9959fd --- /dev/null +++ b/src/erc8128/_test/ContentDigest.test.ts @@ -0,0 +1,77 @@ +import { ContentDigest } from 'ox/erc8128' +import { describe, expect, test } from 'vitest' + +describe('compute', () => { + test('default', () => { + const header = ContentDigest.compute(new TextEncoder().encode('hello')) + expect(header).toMatchInlineSnapshot( + `"sha-256=:LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=:"`, + ) + }) + + test('behavior: string body', () => { + const header = ContentDigest.compute('hello') + expect(header).toMatchInlineSnapshot( + `"sha-256=:LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=:"`, + ) + }) +}) + +describe('serialize', () => { + test('default', () => { + const header = ContentDigest.serialize({ + algorithm: 'sha-256', + digest: 'LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=', + }) + expect(header).toBe( + 'sha-256=:LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=:', + ) + }) +}) + +describe('deserialize', () => { + test('default', () => { + const result = ContentDigest.deserialize( + 'sha-256=:LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=:', + ) + expect(result).toEqual({ + algorithm: 'sha-256', + digest: 'LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ=', + }) + }) + + test('behavior: invalid header', () => { + expect(() => + ContentDigest.deserialize('invalid'), + ).toThrowErrorMatchingInlineSnapshot( + '[ContentDigest.InvalidContentDigestError: Invalid Content-Digest header value: "invalid".]', + ) + }) +}) + +describe('verify', () => { + test('default', () => { + const body = new TextEncoder().encode('hello') + const header = ContentDigest.compute(body) + expect(ContentDigest.verify({ body, header })).toBe(true) + }) + + test('behavior: string body', () => { + const header = ContentDigest.compute('hello') + expect(ContentDigest.verify({ body: 'hello', header })).toBe(true) + }) + + test('behavior: mismatch', () => { + const header = ContentDigest.compute('hello') + expect(ContentDigest.verify({ body: 'world', header })).toBe(false) + }) + + test('behavior: unsupported algorithm', () => { + expect( + ContentDigest.verify({ + body: 'hello', + header: 'sha-512=:abc123=:', + }), + ).toBe(false) + }) +}) diff --git a/src/erc8128/_test/HttpSignature.test.ts b/src/erc8128/_test/HttpSignature.test.ts new file mode 100644 index 00000000..6222cc57 --- /dev/null +++ b/src/erc8128/_test/HttpSignature.test.ts @@ -0,0 +1,386 @@ +import { Secp256k1, Signature } from 'ox' +import { HttpSignature } from 'ox/erc8128' +import { describe, expect, test } from 'vitest' +import { accounts } from '../../../test/constants/accounts.js' + +describe('getSignPayload', () => { + test('default', async () => { + const { payload, signatureInput } = await HttpSignature.getSignPayload({ + request: { + method: 'POST', + authority: 'api.example.com', + path: '/foo', + query: '?a=1&b=two', + body: new TextEncoder().encode('hello'), + }, + chainId: 1, + address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + created: 1736940000, + expires: 1736940060, + nonce: 'b64url_r4Nd0mN0nCEK0YQY4d4r7A', + }) + + expect(payload).toMatchInlineSnapshot( + `"0x1a25bd6bf96f0d3ca81984063bc046d8c679449c740f56d747cff4a6ac8a7be0"`, + ) + expect(signatureInput).toMatchInlineSnapshot( + `"eth=("@authority" "@method" "@path" "@query" "content-digest");created=1736940000;expires=1736940060;nonce="b64url_r4Nd0mN0nCEK0YQY4d4r7A";keyid="erc8128:1:0xd8da6bf26964af9d7eed9e03e53415d37aa96045""`, + ) + }) + + test('behavior: GET request (no body, no query)', async () => { + const { payload, signatureInput } = await HttpSignature.getSignPayload({ + request: { + method: 'GET', + authority: 'api.example.com', + path: '/hello', + }, + chainId: 1, + address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + created: 1700000000, + expires: 1700000060, + nonce: 'testnonce123', + }) + + expect(payload).toMatchInlineSnapshot( + `"0x01d46f7275ea0beec4431dcc699d3e155df7fdcfeba215207dd5c62555e90bcc"`, + ) + expect(signatureInput).toMatchInlineSnapshot( + `"eth=("@authority" "@method" "@path");created=1700000000;expires=1700000060;nonce="testnonce123";keyid="erc8128:1:0xd8da6bf26964af9d7eed9e03e53415d37aa96045""`, + ) + }) + + test('behavior: GET request with query', async () => { + const { signatureInput } = await HttpSignature.getSignPayload({ + request: { + method: 'GET', + authority: 'api.example.com', + path: '/search', + query: '?q=test', + }, + chainId: 1, + address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + created: 1700000000, + expires: 1700000060, + nonce: 'testnonce123', + }) + + expect(signatureInput).toContain('"@query"') + expect(signatureInput).not.toContain('content-digest') + }) + + test('behavior: custom label', async () => { + const { signatureInput } = await HttpSignature.getSignPayload({ + request: { + method: 'GET', + authority: 'api.example.com', + path: '/hello', + }, + chainId: 1, + address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + created: 1700000000, + expires: 1700000060, + nonce: 'testnonce123', + label: 'sig1', + }) + + expect(signatureInput).toMatch(/^sig1=/) + }) + + test('behavior: defaults (created, expires, nonce)', async () => { + const before = Math.floor(Date.now() / 1000) + const { signatureInput } = await HttpSignature.getSignPayload({ + request: { + method: 'GET', + authority: 'api.example.com', + path: '/hello', + }, + chainId: 1, + address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + }) + const after = Math.floor(Date.now() / 1000) + + const createdMatch = signatureInput.match(/created=(\d+)/) + const expiresMatch = signatureInput.match(/expires=(\d+)/) + const nonceMatch = signatureInput.match(/nonce="([^"]+)"/) + + expect(createdMatch).not.toBeNull() + expect(expiresMatch).not.toBeNull() + expect(nonceMatch).not.toBeNull() + + const created = Number(createdMatch![1]) + const expires = Number(expiresMatch![1]) + + expect(created).toBeGreaterThanOrEqual(before) + expect(created).toBeLessThanOrEqual(after) + expect(expires).toBe(created + 60) + expect(nonceMatch![1]!.length).toBeGreaterThan(0) + }) + + test('behavior: existing content-digest header preserved', async () => { + const customDigest = 'sha-256=:customdigest:' + const { signatureInput } = await HttpSignature.getSignPayload({ + request: { + method: 'POST', + authority: 'api.example.com', + path: '/foo', + body: new TextEncoder().encode('hello'), + headers: { 'content-digest': customDigest }, + }, + chainId: 1, + address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + created: 1700000000, + expires: 1700000060, + nonce: 'testnonce123', + }) + + expect(signatureInput).toContain('content-digest') + }) + + test('behavior: roundtrip sign + recover', async () => { + const { payload } = await HttpSignature.getSignPayload({ + request: { + method: 'POST', + authority: 'api.example.com', + path: '/foo', + body: new TextEncoder().encode('test body'), + }, + chainId: 1, + address: accounts[0].address, + created: 1700000000, + expires: 1700000060, + nonce: 'roundtripnonce', + }) + + const signature = Secp256k1.sign({ + payload, + privateKey: accounts[0].privateKey, + }) + + const recoveredAddress = Secp256k1.recoverAddress({ + payload, + signature, + }) + + expect(recoveredAddress).toBe(accounts[0].address) + }) + + test('behavior: different chain id', async () => { + const { signatureInput } = await HttpSignature.getSignPayload({ + request: { + method: 'GET', + authority: 'api.example.com', + path: '/hello', + }, + chainId: 10, + address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + created: 1700000000, + expires: 1700000060, + nonce: 'testnonce123', + }) + + expect(signatureInput).toContain('erc8128:10:') + }) + + test('behavior: Fetch API Request', async () => { + const fetchRequest = new Request('https://api.example.com/foo?a=1&b=two', { + method: 'POST', + body: new TextEncoder().encode('hello'), + }) + + const { payload, signatureInput } = await HttpSignature.getSignPayload({ + request: fetchRequest, + chainId: 1, + address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + created: 1736940000, + expires: 1736940060, + nonce: 'b64url_r4Nd0mN0nCEK0YQY4d4r7A', + }) + + // Should produce the same payload as the equivalent plain request. + const { payload: plainPayload } = await HttpSignature.getSignPayload({ + request: { + method: 'POST', + authority: 'api.example.com', + path: '/foo', + query: '?a=1&b=two', + body: new TextEncoder().encode('hello'), + }, + chainId: 1, + address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + created: 1736940000, + expires: 1736940060, + nonce: 'b64url_r4Nd0mN0nCEK0YQY4d4r7A', + }) + + expect(payload).toBe(plainPayload) + expect(signatureInput).toContain('"@query"') + expect(signatureInput).toContain('content-digest') + }) + + test('behavior: Fetch API Request (GET, no body)', async () => { + const fetchRequest = new Request('https://api.example.com/hello') + + const { signatureInput } = await HttpSignature.getSignPayload({ + request: fetchRequest, + chainId: 1, + address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + created: 1700000000, + expires: 1700000060, + nonce: 'testnonce123', + }) + + expect(signatureInput).not.toContain('"@query"') + expect(signatureInput).not.toContain('content-digest') + }) + + test('behavior: custom components', async () => { + const { signatureInput } = await HttpSignature.getSignPayload({ + request: { + method: 'POST', + authority: 'api.example.com', + path: '/foo', + query: '?a=1', + body: new TextEncoder().encode('hello'), + }, + chainId: 1, + address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + created: 1700000000, + expires: 1700000060, + nonce: 'testnonce123', + components: ['@authority', '@method'], + }) + + expect(signatureInput).toMatchInlineSnapshot( + `"eth=("@authority" "@method");created=1700000000;expires=1700000060;nonce="testnonce123";keyid="erc8128:1:0xd8da6bf26964af9d7eed9e03e53415d37aa96045""`, + ) + }) + + test('behavior: custom components override does not include auto-detected', async () => { + const { signatureInput } = await HttpSignature.getSignPayload({ + request: { + method: 'GET', + authority: 'api.example.com', + path: '/search', + query: '?q=test', + }, + chainId: 1, + address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + created: 1700000000, + expires: 1700000060, + nonce: 'testnonce123', + components: ['@authority'], + }) + + // Only @authority should be in components, not @method/@path/@query. + expect(signatureInput).toMatch(/^eth=\("@authority"\)/) + expect(signatureInput).not.toContain('"@method"') + expect(signatureInput).not.toContain('"@path"') + expect(signatureInput).not.toContain('"@query"') + }) +}) + +describe('serialize', () => { + test('default', () => { + const signature = Secp256k1.sign({ + payload: '0x0000000000000000000000000000000000000000000000000000000000000001', + privateKey: accounts[0].privateKey, + }) + + const header = HttpSignature.serialize({ signature: Signature.toHex(signature) }) + + expect(header).toMatch(/^eth=:/) + expect(header).toMatch(/:$/) + }) + + test('behavior: custom label', () => { + const signature = Secp256k1.sign({ + payload: '0x0000000000000000000000000000000000000000000000000000000000000001', + privateKey: accounts[0].privateKey, + }) + + const header = HttpSignature.serialize({ signature: Signature.toHex(signature), label: 'sig1' }) + + expect(header).toMatch(/^sig1=:/) + }) +}) + +describe('toRequest', () => { + test('default', async () => { + const request = new Request('https://api.example.com/foo', { + method: 'GET', + }) + + const { payload, signatureInput } = await HttpSignature.getSignPayload({ + request, + chainId: 1, + address: accounts[0].address, + created: 1700000000, + expires: 1700000060, + nonce: 'testnonce123', + }) + + const signature = Secp256k1.sign({ + payload, + privateKey: accounts[0].privateKey, + }) + + const signatureHex = Signature.toHex(signature) + + const signed = HttpSignature.toRequest({ + request, + signature: signatureHex, + signatureInput, + }) + + expect(signed.headers.get('signature-input')).toBe(signatureInput) + expect(signed.headers.get('signature')).toMatch(/^eth=:/) + + // Verify roundtrip: recover address from the signed request. + const extracted = HttpSignature.fromRequest(signed) + + const recovered = Secp256k1.recoverAddress({ + payload, + signature: Signature.fromHex(extracted.signature), + }) + + expect(recovered).toBe(accounts[0].address) + expect(extracted.signatureInput).toBe(signatureInput) + }) +}) + +describe('fromRequest', () => { + test('behavior: missing Signature-Input header', () => { + const request = new Request('https://api.example.com/foo') + expect(() => + HttpSignature.fromRequest(request), + ).toThrowErrorMatchingInlineSnapshot( + '[HttpSignature.MissingHeaderError: Missing required header: "Signature-Input".]', + ) + }) + + test('behavior: missing Signature header', () => { + const request = new Request('https://api.example.com/foo', { + headers: { 'signature-input': 'eth=("@method");created=1' }, + }) + expect(() => + HttpSignature.fromRequest(request), + ).toThrowErrorMatchingInlineSnapshot( + '[HttpSignature.MissingHeaderError: Missing required header: "Signature".]', + ) + }) + + test('behavior: invalid Signature header', () => { + const request = new Request('https://api.example.com/foo', { + headers: { + 'signature-input': 'eth=("@method");created=1', + signature: 'invalid', + }, + }) + expect(() => + HttpSignature.fromRequest(request), + ).toThrowErrorMatchingInlineSnapshot( + '[HttpSignature.InvalidSignatureHeaderError: Invalid Signature header value: "invalid".]', + ) + }) +}) diff --git a/src/erc8128/_test/index.test.ts b/src/erc8128/_test/index.test.ts new file mode 100644 index 00000000..846c329f --- /dev/null +++ b/src/erc8128/_test/index.test.ts @@ -0,0 +1,11 @@ +import { expect, test } from 'vitest' +import * as exports from '../index.js' + +test('exports', () => { + expect(Object.keys(exports)).toMatchInlineSnapshot(` + [ + "ContentDigest", + "HttpSignature", + ] + `) +}) diff --git a/src/erc8128/index.ts b/src/erc8128/index.ts new file mode 100644 index 00000000..ae5ae9bd --- /dev/null +++ b/src/erc8128/index.ts @@ -0,0 +1,43 @@ +/** @entrypointCategory ERCs */ +// biome-ignore lint/complexity/noUselessEmptyExport: tsdoc +export type {} + +/** + * Utility functions for working with [RFC 9530 Content-Digest](https://www.rfc-editor.org/rfc/rfc9530) headers. + * + * @example + * ```ts twoslash + * import { ContentDigest } from 'ox/erc8128' + * + * const header = ContentDigest.compute(new TextEncoder().encode('hello')) + * const valid = ContentDigest.verify({ body: new TextEncoder().encode('hello'), header }) + * ``` + * + * @category ERC-8128 + */ +export * as ContentDigest from './ContentDigest.js' + +/** + * Utility functions for working with [ERC-8128 Signed HTTP Requests](https://github.com/slice-so/ERCs/blob/9f6cf39b52b94d405dcc4e521916a0ab00f03e2a/ERCS/erc-8128.md). + * + * @example + * ```ts twoslash + * import { Secp256k1 } from 'ox' + * import { HttpSignature } from 'ox/erc8128' + * + * const { payload, signatureInput } = await HttpSignature.getSignPayload({ + * request: { + * method: 'GET', + * authority: 'api.example.com', + * path: '/hello', + * }, + * chainId: 1, + * address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', + * }) + * + * const signature = Secp256k1.sign({ payload, privateKey: '0x...' }) + * ``` + * + * @category ERC-8128 + */ +export * as HttpSignature from './HttpSignature.js' diff --git a/src/erc8128/internal/HttpSignatureInput.ts b/src/erc8128/internal/HttpSignatureInput.ts new file mode 100644 index 00000000..e1bf3649 --- /dev/null +++ b/src/erc8128/internal/HttpSignatureInput.ts @@ -0,0 +1,108 @@ +/** Signature parameters for serialization. */ +export type Params = Record + +/** Deserialized signature input. */ +export type Deserialized = { + components: string[] + label: string + params: { + created?: number | undefined + expires?: number | undefined + keyid?: string | undefined + nonce?: string | undefined + [key: string]: string | number | undefined + } +} + +/** + * Serializes the `Signature-Input` header value per RFC 9421 Structured Fields. + * + * @example + * ```ts + * import * as HttpSignatureInput from './HttpSignatureInput.js' + * HttpSignatureInput.serialize('eth', ['@method', '@authority'], { created: 1700000000 }) + * // 'eth=("@method" "@authority");created=1700000000' + * ``` + */ +export function serialize( + label: string, + components: readonly string[], + params: Params, +): string { + const inner = components.map((c) => quoteSfString(c)).join(' ') + const paramParts: string[] = [] + for (const [key, value] of Object.entries(params)) { + if (typeof value === 'number') paramParts.push(`${key}=${value}`) + else paramParts.push(`${key}=${quoteSfString(value)}`) + } + return `${label}=(${inner});${paramParts.join(';')}` +} + +/** + * Deserializes a `Signature-Input` header value. + * + * Parses `label=("@method" "@authority" ...);created=123;keyid="erc8128:1:0x..."`. + * + * @example + * ```ts + * import * as HttpSignatureInput from './HttpSignatureInput.js' + * HttpSignatureInput.deserialize('eth=("@method" "@authority");created=1700000000;keyid="erc8128:1:0xabc"') + * ``` + */ +export function deserialize(input: string): Deserialized { + // Split label from the rest: `eth=(...);...` + const eqIdx = input.indexOf('=') + if (eqIdx === -1) throw new InvalidSignatureInputError(input) + + const label = input.slice(0, eqIdx) + const rest = input.slice(eqIdx + 1) + + // Parse components from `(...)`. + const openParen = rest.indexOf('(') + const closeParen = rest.indexOf(')') + if (openParen === -1 || closeParen === -1) + throw new InvalidSignatureInputError(input) + + const componentsStr = rest.slice(openParen + 1, closeParen) + const components = componentsStr + .split(' ') + .map((c) => c.replace(/"/g, '')) + .filter(Boolean) + + // Parse params after `)`. + const paramsStr = rest.slice(closeParen + 1) + const params: Deserialized['params'] = {} + + if (paramsStr.startsWith(';')) { + // Split on `;` but respect quoted values. + const entries = paramsStr.slice(1).split(';') + for (const entry of entries) { + const kvIdx = entry.indexOf('=') + if (kvIdx === -1) continue + const key = entry.slice(0, kvIdx) + let value: string | number = entry.slice(kvIdx + 1) + // Remove surrounding quotes for string values. + if (value.startsWith('"') && value.endsWith('"')) + value = value.slice(1, -1) + else if (/^\d+$/.test(value)) value = Number(value) + params[key] = value + } + } + + return { label, components, params } +} + +/** + * Quotes a string per RFC 8941 (Structured Fields) sf-string rules. + * Escapes `\` → `\\` and `"` → `\"`, wraps in double quotes. + */ +export function quoteSfString(value: string): string { + return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` +} + +export class InvalidSignatureInputError extends Error { + override readonly name = 'HttpSignature.InvalidSignatureInputError' + constructor(input: string) { + super(`Invalid Signature-Input header value: "${input}".`) + } +} diff --git a/src/erc8128/internal/KeyId.ts b/src/erc8128/internal/KeyId.ts new file mode 100644 index 00000000..0dc34506 --- /dev/null +++ b/src/erc8128/internal/KeyId.ts @@ -0,0 +1,51 @@ +import * as Address from '../../core/Address.js' + +export type KeyId = { + address: Address.Address + chainId: number +} + +/** + * Serializes a keyid in `erc8128::
` format. + * + * @example + * ```ts + * import * as KeyId from './KeyId.js' + * KeyId.serialize({ address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045', chainId: 1 }) + * // 'erc8128:1:0xd8da6bf26964af9d7eed9e03e53415d37aa96045' + * ``` + */ +export function serialize(options: KeyId): string { + return `erc8128:${options.chainId}:${options.address.toLowerCase()}` +} + +/** + * Parses a keyid from `erc8128::
` format. + * + * @example + * ```ts + * import * as KeyId from './KeyId.js' + * KeyId.parse('erc8128:1:0xd8da6bf26964af9d7eed9e03e53415d37aa96045') + * // { address: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045', chainId: 1 } + * ``` + */ +export function parse(keyid: string): KeyId { + const parts = keyid.split(':') + if (parts.length !== 3 || parts[0] !== 'erc8128') + throw new InvalidKeyIdError(keyid) + const chainId = Number(parts[1]) + if (!Number.isFinite(chainId) || chainId < 0) + throw new InvalidKeyIdError(keyid) + const address = parts[2]! + Address.assert(address) + return { chainId, address } +} + +export class InvalidKeyIdError extends Error { + override readonly name = 'HttpSignature.InvalidKeyIdError' + constructor(keyid: string) { + super( + `Invalid ERC-8128 keyid: "${keyid}". Expected format: "erc8128::
".`, + ) + } +} diff --git a/src/index.docs.ts b/src/index.docs.ts index 51f90d28..170889f2 100644 --- a/src/index.docs.ts +++ b/src/index.docs.ts @@ -7,5 +7,6 @@ export * from './erc6492/index.js' export * from './erc7821/index.js' export * from './erc8010/index.js' export * from './erc8021/index.js' +export * from './erc8128/index.js' export * from './webauthn/index.js' export * from './tempo/index.js' diff --git a/tsconfig.json b/tsconfig.json index 69707244..d1fe2ca0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,7 @@ "ox/erc7821": ["src/erc7821/index.ts"], "ox/erc8010": ["src/erc8010/index.ts"], "ox/erc8021": ["src/erc8021/index.ts"], + "ox/erc8128": ["src/erc8128/index.ts"], "ox/tempo": ["src/tempo/index.ts"], "ox/trusted-setups": ["src/trusted-setups/index.ts"], "ox/webauthn": ["src/webauthn/index.ts"],