Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 30 additions & 8 deletions httpsig/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,15 @@ interface HttpSigFetchOptions extends RequestInit {
label?: string // Signature label (default: 'sig')
components?: string[] // Override default components

// Content-Digest coverage for requests with a body (default: 'auto')
// 'auto' - cover content-digest when the body's exact bytes are
// available to hash (string, Uint8Array, ArrayBuffer, Buffer);
// streaming bodies (ReadableStream, FormData, Blob) are
// signed without it
// 'require' - like 'auto', but throw on a body that cannot be digested
// 'omit' - never auto-append content-digest
contentDigest?: 'auto' | 'require' | 'omit'

// Testing mode
dryRun?: boolean // Return headers without fetching (still returns Promise)
}
Expand Down Expand Up @@ -258,9 +267,13 @@ interface VerifyOptions {
// JWKS caching
jwksCacheTtl?: number // JWKS cache TTL in ms (default: 3600000)

// AAuth profile enforcement
strictAAuth?: boolean // Enforce AAuth profile requirements (default: true)
// When true, requires signature-key in covered components
// Algorithms this verifier accepts (default: SUPPORTED_ALGORITHMS)
supportedAlgorithms?: SignatureAlgorithm[]

// AAuth HTTPSig profile (Section 10.3) enforcement: when true, a request
// with a body fails verification unless the signature covers
// content-digest and the digest validates against the body
requireContentDigest?: boolean
}
```

Expand Down Expand Up @@ -512,14 +525,23 @@ Signature-Input: sig=("@method" "@authority" "@path" "signature-key");created=17
Signature-Input: sig=("@method" "@authority" "@path" "content-type" "signature-key");created=1730217600
```

**Optional: Content-Digest**
**Content-Digest (automatic since 2.2.0)**

If you want body integrity verification, you can add `content-digest` to your components list. When included, the `content-digest` header is computed as:
Per the AAuth HTTPSig profile (Section 10.3), a request carrying a body to a
PS or AS endpoint MUST also cover `content-digest` (RFC 9530). `fetch()`
appends `content-digest` to the covered components automatically whenever the
body's exact bytes are available to hash — a string, Uint8Array, ArrayBuffer,
or Buffer. A body serialized by the fetch implementation (ReadableStream,
FormData, Blob) is signed without it; pass `contentDigest: 'require'` to
throw instead, or `contentDigest: 'omit'` to never auto-append. The header is
computed as:

```
Content-Digest: sha-256=:BASE64(SHA256(body)):
```

A verifier enforces coverage with `requireContentDigest: true`.

### Overriding Default Components

You can override the default components using the `components` parameter. The library exports helpful constants:
Expand All @@ -544,10 +566,10 @@ import {
// ['@method', '@authority', '@path', 'content-type', 'signature-key']
```

**Example - Adding content-digest for body integrity:**
**Example - Custom components:**

```typescript
// Add content-digest if you need body integrity verification
// Add the date header to the covered components
await fetch('https://api.example.com/data', {
method: 'POST',
headers: {
Expand All @@ -563,8 +585,8 @@ await fetch('https://api.example.com/data', {
'@path',
'date', // Include date header
'content-type',
'content-digest', // Add for body integrity
'signature-key',
// content-digest is appended automatically for a digestible body
],
})
```
Expand Down
2 changes: 1 addition & 1 deletion httpsig/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@hellocoop/httpsig",
"version": "2.1.0",
"version": "2.2.0",
"description": "HTTP Message Signatures (RFC 9421) with Signature-Key header support",
"repository": {
"type": "git",
Expand Down
35 changes: 35 additions & 0 deletions httpsig/src/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,21 @@ function getContentTypeFromBody(body: any): string | null {
return 'application/octet-stream'
}

/**
* Whether generateContentDigest can hash this body: the digest must be
* computed over the exact bytes that go on the wire, so only bodies whose
* bytes are available here qualify. A ReadableStream, FormData, or Blob is
* serialized by the fetch implementation, not by us.
*/
function isDigestibleBody(body: any): boolean {
return (
typeof body === 'string' ||
body instanceof Uint8Array ||
body instanceof ArrayBuffer ||
Buffer.isBuffer(body)
)
}

/**
* Validate component names
*/
Expand Down Expand Up @@ -122,6 +137,7 @@ export async function fetch(
signatureKey,
label = 'sig',
components: customComponents,
contentDigest = 'auto',
dryRun = false,
returnSent = false,
method = 'GET',
Expand Down Expand Up @@ -175,6 +191,25 @@ export async function fetch(
: [...DEFAULT_COMPONENTS_GET]
}

// Per AAuth Section 10.3, a request carrying a body MUST cover
// content-digest. Cover it whenever the body's exact bytes are available
// to hash. 'require' refuses to sign a body that cannot be digested,
// rather than sending a request the server must reject; 'omit' restores
// the pre-2.2 behavior for callers that opt out.
if (body !== undefined && body !== null && contentDigest !== 'omit') {
const digestible = isDigestibleBody(body)
if (!digestible && contentDigest === 'require') {
throw new Error(
'contentDigest is "require" but the body cannot be digested: ' +
'only string, Uint8Array, ArrayBuffer, and Buffer bodies ' +
'have their exact bytes available to hash',
)
}
if (digestible && !components.includes('content-digest')) {
components.push('content-digest')
}
}

const componentValues = new Map<string, string>()

// Handle body-related headers if body exists
Expand Down
29 changes: 29 additions & 0 deletions httpsig/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,24 @@ export interface HttpSigFetchOptions extends RequestInit {
label?: string // Signature label (default: 'sig')
components?: string[] // Override default components

/**
* Content-Digest coverage for requests with a body, per the AAuth HTTPSig
* profile (Section 10.3): a request carrying a body to a PS or AS
* endpoint MUST cover `content-digest` (RFC 9530).
*
* - `'auto'` (default): cover `content-digest` when the body's exact
* bytes are available to hash here -- a string, Uint8Array,
* ArrayBuffer, or Buffer. A body whose bytes are produced by the fetch
* implementation (ReadableStream, FormData, Blob) is signed without it.
* - `'require'`: like `'auto'`, but throw on a body that cannot be
* digested instead of silently dropping the component. PS and AS
* callers use this: refusing to sign beats sending a request the
* server must reject.
* - `'omit'`: never auto-append `content-digest` (the pre-2.2 behavior).
* It is still covered when listed explicitly in `components`.
*/
contentDigest?: 'auto' | 'require' | 'omit'

// Testing mode
dryRun?: boolean // Return headers without fetching (still returns Promise)

Expand Down Expand Up @@ -122,6 +140,17 @@ export interface VerifyOptions {
* for example to accept Ed25519 only, or to refuse RSASSA-PKCS1-v1_5.
*/
supportedAlgorithms?: SignatureAlgorithm[]

/**
* When true, a request with a body fails verification unless the
* signature covers `content-digest` (and the digest validates against
* the body). This is how a PS or AS enforces the AAuth HTTPSig profile
* (Section 10.3) -- without it the digest is validated only when the
* signer chose to cover it, which enforces nothing. Resources are exempt
* from the profile rule and declare their needs via
* `additional_signature_components` in resource metadata.
*/
requireContentDigest?: boolean
}

// Note: the strictAAuth option was removed in 2.0. Covering `signature-key` is
Expand Down
11 changes: 9 additions & 2 deletions httpsig/src/utils/signature.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,8 +221,15 @@ export async function generateContentDigest(body: BodyInit): Promise<string> {
} else if (Buffer.isBuffer(body)) {
bytes = new Uint8Array(body)
} else {
// For other types (ReadableStream, etc.), convert to string
bytes = new TextEncoder().encode(String(body))
// Refuse other types (ReadableStream, FormData, Blob, ...). Falling
// through to String(body) here produced a valid signature over the
// SHA-256 of literal text like "[object ReadableStream]" -- bytes
// that never go on the wire and that no verifier can reproduce.
throw new Error(
`Cannot generate content-digest for body type: ${
(body as any)?.constructor?.name ?? typeof body
}`,
)
}

const hash = await sha256(bytes)
Expand Down
24 changes: 24 additions & 0 deletions httpsig/src/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,7 @@ export async function verify(
maxClockSkew = 60,
jwksCacheTtl = 3600000, // 1 hour
supportedAlgorithms,
requireContentDigest = false,
} = options

// The set this verifier accepts. Reported in Accept-Signature-Alg on an
Expand Down Expand Up @@ -601,6 +602,29 @@ export async function verify(
componentValues.set('@path', request.path)
componentValues.set('@query', request.query || '')

// Enforce the AAuth HTTPSig profile (Section 10.3): a request with a
// body must cover content-digest. Opt-in, because only a PS or AS is
// bound by the profile rule -- and without this check the digest
// below is validated only when the signer chose to cover it, which
// enforces nothing.
if (
requireContentDigest &&
request.body !== undefined &&
!components.includes('content-digest')
) {
throw invalidInput(
'content-digest must be a covered component on a request with a body',
[
'@method',
'@authority',
'@path',
'content-digest',
'content-type',
'signature-key',
],
)
}

// Validate content-digest if body is present
if (
request.body !== undefined &&
Expand Down
Loading
Loading