Skip to content

Commit b79a162

Browse files
committed
net.http.signature: add HTTP Message Signatures (RFC 9421) module
1 parent 9f76fd0 commit b79a162

18 files changed

Lines changed: 2870 additions & 0 deletions

examples/http_signature.v

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
// HTTP Message Signatures example: signs an outbound request with
2+
// Ed25519 (using the RFC 9421 §B.1.4 test key in PEM form) and then
3+
// verifies the result with the matching public key.
4+
//
5+
// Run with: v run examples/http_signature.v
6+
import net.http
7+
import net.http.signature
8+
9+
const ed25519_private_pem = '-----BEGIN PRIVATE KEY-----
10+
MC4CAQAwBQYDK2VwBCIEIJ+DYvh6SEqVTm50DFtMDoQikTmiCqirVv9mWG9qfSnF
11+
-----END PRIVATE KEY-----'
12+
13+
const ed25519_public_pem = '-----BEGIN PUBLIC KEY-----
14+
MCowBQYDK2VwAyEAJrQLj5P/89iXES9+vFgrIy29clF9CC/oPPsw3c5D0bs=
15+
-----END PUBLIC KEY-----'
16+
17+
fn main() {
18+
demo_sign_and_verify_request()!
19+
demo_two_signatures()!
20+
}
21+
22+
// demo_sign_and_verify_request walks through the common path: the
23+
// client signs a request before sending; the server verifies before
24+
// processing.
25+
fn demo_sign_and_verify_request() ! {
26+
priv := signature.Key.from_pem(ed25519_private_pem)!.with_keyid('test-key-ed25519')
27+
pub_key := signature.Key.from_pem(ed25519_public_pem)!
28+
29+
mut req := http.Request{
30+
method: .post
31+
url: 'https://example.com/foo'
32+
}
33+
req.header.add_custom('Host', 'example.com')!
34+
req.header.add_custom('Date', 'Tue, 20 Apr 2021 02:07:55 GMT')!
35+
req.header.add_custom('Content-Type', 'application/json')!
36+
req.header.add_custom('Content-Length', '18')!
37+
38+
signature.sign_request(mut req, priv,
39+
components: ['date', '@method', '@path', '@authority', 'content-type', 'content-length']
40+
created: 1618884473
41+
label: 'sig-b26'
42+
)!
43+
44+
si := req.header.get_custom('Signature-Input') or { '' }
45+
sig := req.header.get_custom('Signature') or { '' }
46+
println('Signature-Input: ${si}')
47+
println('Signature: ${sig}')
48+
49+
signature.verify_request(req, pub_key)!
50+
println(' ✓ verified with the matching public key')
51+
}
52+
53+
// demo_two_signatures shows a TLS-terminating proxy scenario: the
54+
// client signs the original request, the proxy adds its own signature
55+
// over the same message under a different label, and the backend
56+
// verifies both independently.
57+
fn demo_two_signatures() ! {
58+
client_key := signature.Key.hmac_sha256('client-shared-secret'.bytes()).with_keyid('client')
59+
proxy_key := signature.Key.hmac_sha256('proxy-shared-secret'.bytes()).with_keyid('proxy')
60+
61+
mut req := http.Request{
62+
method: .get
63+
url: 'https://api.example.com/orders/42'
64+
}
65+
req.header.add_custom('Host', 'api.example.com')!
66+
req.header.add_custom('Date', 'Tue, 20 Apr 2021 02:07:55 GMT')!
67+
68+
// `created` defaults to `time.now().unix()` when not set —
69+
// fine for a real client. Pinned here so the example output
70+
// is reproducible across runs.
71+
signature.sign_request(mut req, client_key,
72+
components: ['@method', '@target-uri', 'date']
73+
label: 'client-sig'
74+
created: 1618884473
75+
)!
76+
signature.sign_request(mut req, proxy_key,
77+
components: ['@method', '@authority', 'date']
78+
label: 'proxy-sig'
79+
created: 1618884480
80+
)!
81+
82+
si := req.header.get_custom('Signature-Input') or { '' }
83+
println('\nMerged Signature-Input: ${si}')
84+
85+
signature.verify_request(req, client_key, label: 'client-sig')!
86+
signature.verify_request(req, proxy_key, label: 'proxy-sig')!
87+
println(' ✓ both labelled signatures verified')
88+
}

vlib/net/http/signature/README.md

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
# `net.http.signature` — HTTP Message Signatures (RFC 9421)
2+
3+
Sign and verify HTTP requests and responses per [RFC 9421][rfc9421]
4+
the standard that replaces the long-running `Signature` /
5+
`Signature-Input` drafts and underpins production deployments at major
6+
CDNs, mTLS proxies, mutual API authentication, and the upcoming
7+
[Web Bot Auth][web-bot-auth] work.
8+
9+
[rfc9421]: https://www.rfc-editor.org/rfc/rfc9421.html
10+
[web-bot-auth]: https://datatracker.ietf.org/doc/draft-meunier-web-bot-auth-architecture/
11+
12+
## Quick start
13+
14+
```v ignore
15+
import net.http
16+
import net.http.signature
17+
18+
// Sign an outbound request. `created` defaults to time.now().unix().
19+
mut req := http.new_request(.post, 'https://example.com/items', '{}')!
20+
req.header.add_custom('Date', 'Tue, 20 Apr 2021 02:07:55 GMT')!
21+
req.header.add_custom('Content-Type', 'application/json')!
22+
23+
priv := signature.Key.from_pem(alice_private_pem)!.with_keyid('alice')
24+
signature.sign_request(mut req, priv,
25+
components: ['@method', '@target-uri', '@authority', 'date', 'content-type']
26+
)!
27+
// req now carries Signature-Input and Signature header fields.
28+
29+
// On the receiving side, verify with the public key resolved from `keyid`:
30+
pub_key := signature.Key.from_pem(alice_public_pem)!
31+
signature.verify_request(req, pub_key, now_unix: time.now().unix())!
32+
```
33+
34+
`Key.from_pem` accepts the canonical PKCS#8 / SPKI / SEC1 PEM blocks
35+
that `openssl genpkey` and friends produce. The raw-coordinate
36+
constructors (`Key.ed25519_private(seed)`, `Key.ecdsa_p256_public(x,
37+
y)`, …) are still available when you have JWK-style key material.
38+
39+
The `now_unix` option enforces the optional `expires` parameter; pass
40+
`0` (the default) to skip the expiry check.
41+
42+
## Algorithms
43+
44+
| IANA name | Status | Backed by |
45+
| ------------------- | ------ | --------- |
46+
| `hmac-sha256` || `crypto.hmac` + `crypto.sha256` |
47+
| `ecdsa-p256-sha256` || `crypto.ecdsa` (P-256) |
48+
| `ecdsa-p384-sha384` || `crypto.ecdsa` (P-384) |
49+
| `ed25519` || `crypto.ed25519` |
50+
51+
`rsa-pss-sha512` and `rsa-v1_5-sha256` are intentionally out of scope —
52+
`vlib/crypto` does not yet ship an RSA implementation. Adding them is
53+
mechanical once it does.
54+
55+
## Covered components
56+
57+
All derived components from RFC 9421 §2.2 are implemented:
58+
59+
`@method`, `@target-uri`, `@authority`, `@scheme`, `@request-target`,
60+
`@path`, `@query`, `@status`.
61+
62+
Plain HTTP fields are matched by *lowercased* field name, with
63+
multi-value fields joined by `", "` and OWS trimmed at the boundaries
64+
(RFC 9421 §2.1).
65+
66+
`@query-param` (RFC 9421 §2.2.8), structured-field re-serialisation
67+
(`sf`, `key`, `bs` parameters from §2.1.x), and binary-wrapped fields
68+
are deferred to a follow-up PR.
69+
70+
## Two API layers
71+
72+
```v ignore
73+
// Components-level - works on any HTTP-shaped data, no http.Request
74+
// dependency. Use this offline (signing fixtures, building tests).
75+
base := signature.signature_base_string(components, params)!
76+
out := signature.sign(components, params, key, 'sig1')!
77+
signature.verify(components, sig_input, sig_value, 'sig1', key)!
78+
79+
// http.Request / http.Response wrappers - sugar over the above.
80+
signature.sign_request(mut req, key, components: [...], created: now)!
81+
signature.verify_request(req, key, now_unix: now)!
82+
```
83+
84+
## Design notes
85+
86+
* **No silent algorithm fallbacks.** If you set the `alg` parameter
87+
and it doesn't match the key's algorithm, `sign` errors out with
88+
`MalformedMessage`. `verify` does the same on the inbound side.
89+
RFC 9421 §3.1 step 3 makes this a correctness requirement.
90+
91+
* **Empty `keyid` is allowed.** RFC 9421 doesn't make `keyid`
92+
mandatory; some out-of-band channel (mTLS cert, JWT bearer)
93+
identifies the signer instead. `sign` still emits a usable
94+
signature; the verifier picks the key by other means.
95+
96+
* **Multiple signatures coexist.** Calling `sign_request` twice with
97+
different labels merges the labelled entries into a single
98+
`Signature-Input` / `Signature` field per RFC 8941 §3.2 (comma-
99+
separated dictionary) — TLS-terminating proxies and federated
100+
signing scenarios both rely on this layout.
101+
102+
* **No clock dependency.** Both `created` and the expiry check are
103+
driven by the caller (`opts.created`, `opts.now_unix`). Signing in
104+
bulk over historical data, deterministic test runs, and replay
105+
protection are all the caller's concern.
106+
107+
## Test vectors
108+
109+
RFC 9421 Appendix B vectors are vendored under
110+
`tests/rfc9421/` and exercised by `rfc9421_test.v`:
111+
112+
| Section | Algorithm | Mode |
113+
| --- | --- | --- |
114+
| B.2.5 | `hmac-sha256` | **bytes-exact** |
115+
| B.2.6 | `ed25519` | **bytes-exact** |
116+
| B.2.4 | `ecdsa-p256-sha256` | verify (ECDSA non-deterministic) |
117+
118+
`http_message_test.v` covers sign/verify roundtrips across all four
119+
supported algorithms (including a freshly-generated P-384 key),
120+
tampered URL rejection, missing-header rejection, expiry enforcement,
121+
multi-signature coexistence, and `alg` / label validation.
122+
`structured_field_test.v` pins the Inner List + parameter
123+
serialisation, multi-value field joining, OWS trimming, and the
124+
`@query` empty-vs-present semantics.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Algorithm identifiers from the IANA "HTTP Signature Algorithms" registry
2+
// (RFC 9421 §6.2.2). Only those backed by `vlib/crypto` primitives are
3+
// modelled here; RSA-based algorithms are deliberately omitted because
4+
// `vlib/crypto` does not yet ship an RSA implementation.
5+
module signature
6+
7+
// Algorithm names the signing or verification routine selected for a
8+
// signature. The string form returned by `name()` is the exact token
9+
// emitted on the wire as the value of the `alg` signature parameter.
10+
pub enum Algorithm {
11+
hmac_sha256 // hmac-sha256 — RFC 9421 §3.3.3
12+
ecdsa_p256_sha256 // ecdsa-p256-sha256 — RFC 9421 §3.3.4
13+
ecdsa_p384_sha384 // ecdsa-p384-sha384 — RFC 9421 §3.3.5
14+
ed25519 // ed25519 — RFC 9421 §3.3.6
15+
}
16+
17+
// name returns the IANA token for the algorithm.
18+
pub fn (a Algorithm) name() string {
19+
return match a {
20+
.hmac_sha256 { 'hmac-sha256' }
21+
.ecdsa_p256_sha256 { 'ecdsa-p256-sha256' }
22+
.ecdsa_p384_sha384 { 'ecdsa-p384-sha384' }
23+
.ed25519 { 'ed25519' }
24+
}
25+
}
26+
27+
// algorithm_from_name parses the IANA token. Returns `none` for
28+
// algorithms outside this module's supported set so the caller can
29+
// surface an `UnsupportedAlgorithm` error with the original token kept.
30+
pub fn algorithm_from_name(s string) ?Algorithm {
31+
return match s {
32+
'hmac-sha256' { Algorithm.hmac_sha256 }
33+
'ecdsa-p256-sha256' { Algorithm.ecdsa_p256_sha256 }
34+
'ecdsa-p384-sha384' { Algorithm.ecdsa_p384_sha384 }
35+
'ed25519' { Algorithm.ed25519 }
36+
else { none }
37+
}
38+
}
39+
40+
// is_mac reports whether the algorithm is symmetric (MAC) rather than
41+
// asymmetric (signature). MACs are signed and verified with the same key.
42+
pub fn (a Algorithm) is_mac() bool {
43+
return a == .hmac_sha256
44+
}

0 commit comments

Comments
 (0)