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.
Context
Today the public signing API in
@hellocoop/httpsigisfetch(), which requires either:signingKey: JsonWebKey(with privated), orsigningCryptoKey: CryptoKey(extractable)Both paths reach
WebCrypto.subtle.sign()with key material that lives in process memory. Hardware-backed keys can't satisfy either:@aauth/local-keysalready exposesdriver.signHash(keyId, hash)for exactly this purpose (it's howsignAgentToken()works for hardware keys today). But that's the JWT keyType path. There's no equivalent for thejwks_uriHTTP-message-signature keyType.Proposal
Add a signer-agnostic primitive that exposes the canonicalization without owning the signing operation:
The existing
fetch()would be reimplemented in terms ofsignRequest():Why this shape
fetch()continues to work; this is purely additive.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
signFnreceives.content-digesthandling: if the caller includes it incomponents, the body must be passed; otherwise body is optional.Companion issue
@aauth/local-keysissue to addsignHttpRequest()that wrapsresolveKey+driver.signHashthrough 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.iowith YubiKey/Secure Enclave keys) has no path. Same gap blocks any future producer that wants to root signing in hardware.