Skip to content

[local-keys] Add signHttpRequest() for hardware-backed jwks_uri HTTP message signatures #8

@dickhardt

Description

@dickhardt

Context

@aauth/local-keys today exposes signAgentToken() / createAgentToken(), which produce signatureKey: { type: 'jwt', jwt } — the agent-token / JWT keyType flow. There's no equivalent for the jwks_uri keyType, which is what server-side identities (a producer's domain, an operator's dickhardt.github.io, etc.) use to sign HTTP requests where the receiver fetches the signer's published JWKS to verify.

The underlying primitive is already in place: driver.signHash(keyId, hash) is exactly what HTTP signatures need. It's used by signAgentToken() for the hardware path. We just don't expose the equivalent for HTTP signatures.

Proposal

Add a high-level helper that mirrors signAgentToken() but for HTTP requests:

export interface SignHttpRequestOptions {
  /** Agent URL to use as the signature-key `id` (e.g. \"https://you.github.io\"). */
  agentUrl: string

  /** The request to sign. */
  request: {
    method: string
    url: string | URL                  // path + optional query; authority overridden below
    authority: string                  // canonical authority the verifier will check
    headers?: Record<string, string | string[]>
    body?: string | Buffer | Uint8Array // required if content-digest is in covered components
  }

  /** Defaults to AAuth profile body components. */
  components?: string[]

  /** dwk value for the signature-key header (defaults to 'aauth-agent.json'). */
  dwk?: string

  /** Override key resolution — useful for testing. */
  kid?: string

  /** Signature label, defaults to 'sig'. */
  label?: string
}

export interface SignedRequest {
  headers: {
    'Signature-Input': string
    'Signature': string
    'Signature-Key': string
    'Content-Digest'?: string
  }
  /** Which key was actually used. Useful for logging. */
  resolved: ResolvedKey
}

export declare function signHttpRequest(
  options: SignHttpRequestOptions,
): Promise<SignedRequest>

Implementation sketch

async function signHttpRequest(opts: SignHttpRequestOptions): Promise<SignedRequest> {
  const resolved = await resolveKey(opts.agentUrl)
  const driver = getBackend(resolved.backend)

  // Software backend: extract private JWK from keychain, use httpsig directly
  if (resolved.backend === 'software') {
    const data = readKeychain(opts.agentUrl)!
    const privateJwk = data.keys[resolved.kid]
    return signRequestWithKey(opts, resolved, privateJwk)
  }

  // Hardware: hash-and-sign via driver
  return signRequest(opts, { algorithm: resolved.algorithm, hash: 'SHA-256' }, async (data) => {
    const hash = createHash('sha256').update(data).digest()
    const { signature } = await driver.signHash(resolved.keyId, hash)
    return signature
  })
}

(Where signRequest is the proposed primitive in companion issue hellocoop/packages-js#63.)

Why this is the right shape

  • Mirrors signAgentToken(). Same resolveKey-then-sign flow, same backend abstraction, same hardware-vs-software dispatch. Just produces HTTP signature headers instead of a JWT.
  • No new backend surface. Reuses the existing driver.signHash() primitive that already exists for the JWT path.
  • Symmetry with the producer story. Cloudflare Workers and other software-only producers sign via @hellocoop/httpsig fetch() today. Hardware-backed identities should have the same ergonomics.

Edge cases

  • ES256 hardware drivers (PIV, SE) return signatures in different encodings. The secure-enclave driver returns base64url-decoded raw bytes (per the code); YubiKey PIV typically returns DER. The helper needs to normalize to raw r||s for the HTTP signature Signature header.
  • content-digest: when included in covered components, the helper computes it from the body and adds the Content-Digest header to the returned set.
  • Key resolution: defer to resolveKey() as-is — hardware preferred, software fallback, kid override for tests.

Companion issue

@hellocoop/httpsig issue for the underlying signer-agnostic primitive: hellocoop/packages-js#63

Motivation

Came up while building the AAuth ingest endpoint for Hellō Freezer. Operator-side smoke testing against real production identities (e.g. dickhardt.github.io with hardware keys configured in ~/.aauth/config.json) needs this helper to produce signed test requests without each caller having to reproduce HTTP signature canonicalization by hand.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions