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
67 changes: 67 additions & 0 deletions httpsig/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,73 @@ We support the two most widely recommended algorithms from the [IANA HTTP Messag
- Widely supported
- Perfect interoperability

## Structured Fields (RFC 8941)

Every header this package reads or writes is an RFC 8941 Structured Field, and
the parser and serializer that handle them are exported. Use them for
neighbouring fields rather than writing another parser — `AAuth-Requirement` is
a Dictionary, `AAuth-Capabilities` a List of Tokens, and hand-rolled 8941 fails
on the same three things every time: quoting, escaping, and byte sequences.

```ts
import {
parseDictionary,
serializeDictionary,
Token,
bareItemToString,
} from '@hellocoop/httpsig'

// A `;` inside a quoted string does not end the parameter list.
const dict = parseDictionary(
'requirement=interaction; url="https://resource.example/i?a=1;b=2"; code="A1B2-C3D4"',
)

const [value, params] = dict.get('requirement') as Item
value instanceof Token // true — `interaction` is a Token, not a String
params.get('url') // 'https://resource.example/i?a=1;b=2'
params.get('code') // 'A1B2-C3D4'

serializeDictionary(
new Map([
[
'requirement',
[new Token('auth-token'), new Map([['resource-token', jwt]])],
],
]),
)
```

**Exported**

| | |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| Parsing | `parseDictionary`, `parseList`, `parseItem`, `ParseError` |
| Serializing | `serializeDictionary`, `serializeList`, `serializeItem`, `serializeInnerList`, `serializeBareItem`, `serializeParameters`, `SerializeError` |
| Values | `Token`, `ByteSequence` |
| Guards | `isInnerList`, `isByteSequence`, `isValidTokenStr`, `isValidKeyStr` |
| Helper | `bareItemToString` — reads a String or a Token, refuses anything else |
| Types | `Dictionary`, `List`, `Item`, `InnerList`, `Parameters`, `BareItem` |

Shapes:

```
Dictionary Map<string, Item | InnerList>
List (Item | InnerList)[]
Item [BareItem, Parameters]
InnerList [Item[], Parameters]
Parameters Map<string, BareItem>
BareItem number | string | Token | ByteSequence | boolean
```

A `Token` is a bare word (`hwk`, `Ed25519`); a `string` is a quoted sf-string.
The distinction is load-bearing — `@signature-params` is covered by the
signature, so a parameter that arrives as a Token must go back out as a Token.

The implementation is vendored from
[`structured-headers`](https://github.com/evert/structured-header) v1.0.1 (MIT)
rather than taken as a dependency, because this package has zero runtime
dependencies by design. See `src/vendor/structured-headers/README.md`.

## Security Considerations

### Timestamp Validation
Expand Down
14 changes: 11 additions & 3 deletions httpsig/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@hellocoop/httpsig",
"version": "2.0.1",
"version": "2.1.0",
"description": "HTTP Message Signatures (RFC 9421) with Signature-Key header support",
"repository": {
"type": "git",
Expand All @@ -10,10 +10,18 @@
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": "./dist/index.js"
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./structured-fields": {
"types": "./dist/structured-fields.d.ts",
"default": "./dist/structured-fields.js"
}
},
"files": [
"dist/"
"dist/",
"src/vendor/"
],
"keywords": [
"http",
Expand Down
12 changes: 8 additions & 4 deletions httpsig/src/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
import {
generateSignatureBase,
generateSignatureInputHeader,
generateSignatureParams,
generateSignatureKeyHeader,
generateSignatureHeader,
generateContentDigest,
Expand Down Expand Up @@ -266,10 +267,13 @@ export async function fetch(
)
headers.set('signature-input', signatureInputHeader)

// Add signature params to component values
const componentList = components.map((c) => `"${c}"`).join(' ')
const signatureParams = `(${componentList});created=${created}`
componentValues.set('@signature-params', signatureParams)
// Add signature params to component values. Serialized from the same
// structure the Signature-Input header was built from, so the header and
// the signature base cannot disagree.
componentValues.set(
'@signature-params',
generateSignatureParams(components, created),
)
components.push('@signature-params')

// Generate signature base
Expand Down
40 changes: 40 additions & 0 deletions httpsig/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,46 @@ export {
parseAcceptSignatureAlg,
} from './utils/signature.js'

/**
* RFC 8941 Structured Field Values.
*
* Exported so that consumers parsing neighbouring structured fields --
* AAuth-Requirement is a Dictionary, AAuth-Capabilities a List of Tokens --
* use the implementation this package already carries instead of hand-rolling
* one. Hand-rolled 8941 fails on quoting, escaping and byte sequences every
* time; a `;` inside a quoted `url` is enough to break a naive parameter
* split.
*/
export {
parseDictionary,
parseList,
parseItem,
serializeDictionary,
serializeList,
serializeItem,
serializeInnerList,
serializeBareItem,
serializeParameters,
bareItemToString,
isInnerList,
isByteSequence,
isValidTokenStr,
isValidKeyStr,
Token,
ByteSequence,
ParseError,
SerializeError,
} from './structured-fields.js'

export type {
Dictionary,
List,
Item,
InnerList,
Parameters,
BareItem,
} from './structured-fields.js'

export {
generateKeyPair,
determineAlgorithm,
Expand Down
90 changes: 90 additions & 0 deletions httpsig/src/structured-fields.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* RFC 8941 Structured Field Values for HTTP
*
* This is the package's single structured-field implementation. Every header
* it reads or writes -- Signature, Signature-Input, Signature-Key,
* Signature-Error, Accept-Signature, Accept-Signature-Scheme,
* Accept-Signature-Alg -- goes through it, and it is exported so that
* consumers parsing neighbouring fields (AAuth-Requirement, a Dictionary;
* AAuth-Capabilities, a List of Tokens) do not write a fourth one.
*
* The implementation is vendored, not depended on: see
* `src/vendor/structured-headers/README.md` for what was taken, from which
* version, and why. This module is the seam -- nothing outside it should
* import from `vendor/` directly.
*
* Shapes, briefly:
*
* Dictionary Map<string, Item | InnerList>
* List (Item | InnerList)[]
* Item [BareItem, Parameters]
* InnerList [Item[], Parameters]
* Parameters Map<string, BareItem>
* BareItem number | string | Token | ByteSequence | boolean
*
* A `Token` is a bare word (`hwk`, `Ed25519`); a `string` is a quoted
* sf-string. They are distinct types, and the distinction is load-bearing:
* `requirement=interaction` and `requirement="interaction"` are different
* values, and a parser that erases the difference cannot round-trip.
*/

export {
parseDictionary,
parseList,
parseItem,
ParseError,
} from './vendor/structured-headers/parser.js'

export {
serializeDictionary,
serializeList,
serializeItem,
serializeInnerList,
serializeBareItem,
serializeParameters,
SerializeError,
} from './vendor/structured-headers/serializer.js'

export { Token } from './vendor/structured-headers/token.js'
export { ByteSequence } from './vendor/structured-headers/types.js'
export {
isInnerList,
isByteSequence,
isValidTokenStr,
isValidKeyStr,
} from './vendor/structured-headers/util.js'

export type {
Dictionary,
List,
Item,
InnerList,
Parameters,
BareItem,
} from './vendor/structured-headers/types.js'

import { BareItem } from './vendor/structured-headers/types.js'
import { Token } from './vendor/structured-headers/token.js'

/**
* Read a Bare Item that is expected to carry text.
*
* Returns the value of a String or a Token, and throws for anything else. The
* Token case is deliberate leniency: several fields in this family are
* specified to carry Strings, but a sender that omits the quotes produces a
* Token that means the same thing to a human and parses unambiguously. Values
* that cannot be text -- Integers, Decimals, Booleans, Byte Sequences -- are
* rejected rather than stringified, so a type confusion surfaces as an error
* instead of a plausible-looking string.
*/
export function bareItemToString(value: BareItem): string {
if (typeof value === 'string') {
return value
}
if (value instanceof Token) {
return value.toString()
}
throw new TypeError(
`Expected a Structured Field String or Token, got ${typeof value}`,
)
}
15 changes: 15 additions & 0 deletions httpsig/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
* Type definitions for @hellocoop/httpsig
*/

import type { InnerList } from './structured-fields.js'

/**
* Valid derived components from RFC 9421 Section 2.2
*/
Expand Down Expand Up @@ -176,12 +178,25 @@ export interface VerificationResult {
}

export interface ParsedSignatureInput {
/** The Dictionary key this signature is under. */
label: string
/** The covered component identifiers, unquoted. */
components: string[]
/**
* The Inner List parameters, as Structured Field values: `created` is an
* Integer, a quoted `keyid` is a string, a bare one is a `Token`.
*/
params: {
created: number
[key: string]: any
}
/**
* The parsed Inner List exactly as the signer sent it. Serializing this is
* how `@signature-params` is reproduced for the signature base -- it is
* covered by the signature, so every parameter must survive the round
* trip, including ones this implementation does not act on.
*/
signatureParams: InnerList
}

export interface ParsedSignatureKey {
Expand Down
Loading