Skip to content

[httpsig] Add hardware-friendly signing primitive (signFn callback) for jwks_uri flow #63

@dickhardt

Description

@dickhardt

Context

Today the public signing API in @hellocoop/httpsig is fetch(), which requires either:

  • signingKey: JsonWebKey (with private d), or
  • signingCryptoKey: CryptoKey (extractable)

Both paths reach WebCrypto.subtle.sign() with key material that lives in process memory. Hardware-backed keys can't satisfy either:

  • YubiKey PIV — private key never leaves the device; the only operation is "hash → signature" via PIV apdu
  • Apple Secure Enclave — same model; signing via a system helper, no extractable private bits
  • Cloud KMS / HSM — same model; sign-this-hash is the only API

@aauth/local-keys already exposes driver.signHash(keyId, hash) for exactly this purpose (it's how signAgentToken() works for hardware keys today). But that's the JWT keyType path. There's no equivalent for the jwks_uri HTTP-message-signature keyType.

Proposal

Add a signer-agnostic primitive that exposes the canonicalization without owning the signing operation:

export interface SignRequestOptions {
  method: string
  authority: string             // canonical, NOT from request URL
  path: string
  query?: string
  headers: Record<string, string | string[]>
  body?: string | Buffer | Uint8Array  // for content-digest if covered
  signatureKey: SignatureKeyType
  components?: string[]         // defaults per AAuth profile
  label?: string                // defaults to 'sig'
  created?: number              // defaults to now
}

export interface SignerInfo {
  /** Algorithm of the underlying key — drives the JWA \"alg\" param */
  algorithm: 'ES256' | 'Ed25519' | 'ES384' | ...
  /** Hash algorithm to apply before calling signFn */
  hash: 'SHA-256' | 'SHA-384' | 'SHA-512'
}

export type SignFn = (hashedSignatureBase: Uint8Array) => Promise<Uint8Array>

export interface SignedHeaders {
  'Signature-Input': string
  'Signature': string
  'Signature-Key': string
  'Content-Digest'?: string
}

/**
 * Build an HTTP message signature given a hash-and-sign callback.
 *
 * The caller supplies the actual signing operation, which lets hardware
 * backends (YubiKey, Secure Enclave, KMS) participate without exposing
 * extractable key material.
 */
export declare function signRequest(
  options: SignRequestOptions,
  signer: SignerInfo,
  signFn: SignFn,
): Promise<SignedHeaders>

The existing fetch() would be reimplemented in terms of signRequest():

async function fetch(url, opts) {
  const cryptoKey = opts.signingCryptoKey ?? await importPrivateKey(opts.signingKey)
  const headers = await signRequest({...}, {algorithm, hash}, async (data) => {
    return new Uint8Array(await crypto.subtle.sign(algorithm, cryptoKey, data))
  })
  // attach headers and do the actual fetch
}

Why this shape

  • Signer-agnostic. Works for software keys (WebCrypto), hardware keys (driver.signHash), cloud KMS, future signers.
  • No new deps. The package learns nothing about AAuth hardware backends; it just exposes the canonicalization it already does internally.
  • Preserves existing API. fetch() continues to work; this is purely additive.
  • Symmetry with verify. verify() is already "give me the bytes and the public key, tell me if it matches" — this gives signing the same shape.

Edge cases worth specifying in the issue

  • Hash algorithm: ES256 → SHA-256, EdDSA → identity (Ed25519 signs raw bytes); the helper should document what signFn receives.
  • Signature format: ES256 hardware backends often return DER-encoded signatures; HTTP signatures want raw r||s concatenation. Caller's responsibility, or helper conversion?
  • content-digest handling: if the caller includes it in components, the body must be passed; otherwise body is optional.

Companion issue

@aauth/local-keys issue to add signHttpRequest() that wraps resolveKey + driver.signHash through this primitive: aauth-dev/packages-js#8 (will be linked once filed)

Motivation

Came up while building the AAuth ingest endpoint for Hellō Freezer (hellocoop/Freezer). Cloudflare Worker producers can sign with software keys today — that works through fetch(). But operator-side smoke testing against a real production-shaped identity (e.g. dickhardt.github.io with YubiKey/Secure Enclave keys) has no path. Same gap blocks any future producer that wants to root signing in hardware.

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