@@ -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+}
@@ -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.
@@ -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,0 +1,181 @@
1+// Components captures the inputs the signing/verifying side reads
2+// when it builds the signature base. It is deliberately decoupled
3+// from `http.Request` / `http.Response` so the same primitives work
4+// for messages produced by any HTTP stack (or for offline signing).
5+//
6+// The `request_*` and `response_*` slots overlap on purpose - a
7+// Components value typically describes either a request or a
8+// response, and unused fields stay `none`.
9+module signature
10+
11+// Components is the side-channel input for `signature_base_string`.
12+// Empty / `none` fields mean "not available" - the signer / verifier
13+// returns `MalformedMessage` if the covered-components list mentions
14+// a derived component whose source is `none` here.
15+pub struct Components {
16+pub mut:
17+ // Derived components covered by RFC 9421 §2.2.
18+ method ?string
19+ target_uri ?string // full request URI, used by @target-uri
20+ authority ?string // host[:port] of the request
21+ scheme ?string // "http" | "https"
22+ request_target ?string // method-line target as-on-the-wire
23+ path ?string // path component of the URI
24+ query ?string // query component INCLUDING the leading "?"
25+ // status applies to response signing/verification (@status, §2.2.9).
26+ status ?int
27+ // fields holds HTTP header field values keyed by *lowercased*
28+ // field name, with the slice preserving the order multiple values
29+ // arrive in. Values are joined with ", " on emission per
30+ // RFC 9421 §2.1.
31+ fields map[string][]string
32+}
33+
34+// add_field is sugar for accumulating header values without juggling
35+// the underlying map manually. Consecutive calls preserve insertion
36+// order, which matters for fields that appear more than once.
37+pub fn (mut c Components) add_field(name string, value string) {
38+ lname := name.to_lower()
39+ if lname in c.fields {
40+ mut existing := c.fields[lname]
41+ existing << value
42+ c.fields[lname] = existing
43+ } else {
44+ c.fields[lname] = [value]
45+ }
46+}
47+
48+// component_value returns the canonical string the component
49+// contributes to the signature base. Derived components start with
50+// '@'; everything else is treated as an HTTP field name (lowercased
51+// for lookup, RFC 9421 §2.1.3).
52+fn (c Components) component_value(name string) !string {
53+ if name != '' && name[0] == `@` {
54+ return c.derived_value(name)!
55+ }
56+ return c.field_value(name)!
57+}
58+
59+fn (c Components) derived_value(name string) !string {
60+ return match name {
61+ '@method' {
62+ c.method or { return missing(name) }
63+ }
64+ '@target-uri' {
65+ c.target_uri or { return missing(name) }
66+ }
67+ '@authority' {
68+ if a := c.authority {
69+ normalize_authority(a, c.scheme)
70+ } else {
71+ return missing(name)
72+ }
73+ }
74+ '@scheme' {
75+ if s := c.scheme {
76+ s.to_lower()
77+ } else {
78+ return missing(name)
79+ }
80+ }
81+ '@request-target' {
82+ c.request_target or { return missing(name) }
83+ }
84+ '@path' {
85+ c.path or { return missing(name) }
86+ }
87+ '@query' {
88+ // RFC 9421 §2.2.7: the value MUST include the leading "?".
89+ // If query is empty, the value is the single character "?".
90+ if q := c.query {
91+ if q.len == 0 || q[0] != `?` {
92+ '?' + q
93+ } else {
94+ q
95+ }
96+ } else {
97+ return missing(name)
98+ }
99+ }
100+ '@status' {
101+ if s := c.status {
102+ s.str()
103+ } else {
104+ return missing(name)
105+ }
106+ }
107+ else {
108+ return MalformedMessage{
109+ reason: 'unsupported derived component "${name}"'
110+ }
111+ }
112+ }
113+}
114+
115+fn (c Components) field_value(name string) !string {
116+ lname := name.to_lower()
117+ values := c.fields[lname] or { return missing(name) }
118+ if values.len == 0 {
119+ return missing(name)
120+ }
121+ if values.len == 1 {
122+ return trim_ows(values[0])
123+ }
124+ mut trimmed := []string{cap: values.len}
125+ for v in values {
126+ trimmed << trim_ows(v)
127+ }
128+ return trimmed.join(', ')
129+}
130+
131+fn missing(name string) IError {
132+ return MalformedMessage{
133+ reason: 'covered component "${name}" is not present in the message'
134+ }
135+}
136+
137+// normalize_authority lowercases the authority and strips the port when
138+// it equals the URI scheme's default (RFC 9421 §2.2.3 + RFC 9110 §4.2.3).
139+// Without this, peers that emit `example.com` and peers that emit
140+// `example.com:443` produce different signature bases for the same
141+// resource and fail to interoperate.
142+fn normalize_authority(authority string, scheme ?string) string {
143+ lower := authority.to_lower()
144+ port_colon := find_port_colon(lower) or { return lower }
145+ port := lower[port_colon + 1..]
146+ scheme_lower := if s := scheme { s.to_lower() } else { '' }
147+ if (scheme_lower == 'https' && port == '443') || (scheme_lower == 'http' && port == '80') {
148+ return lower[..port_colon]
149+ }
150+ return lower
151+}
152+
153+// find_port_colon returns the index of the ':' that separates the port
154+// in an authority, or `none` if no port is present. IPv6 literals embed
155+// colons inside `[...]`; the port colon (if any) is the one immediately
156+// following the closing bracket.
157+fn find_port_colon(authority string) ?int {
158+ if authority.starts_with('[') {
159+ bracket := authority.index(']') or { return none }
160+ if bracket + 1 < authority.len && authority[bracket + 1] == `:` {
161+ return bracket + 1
162+ }
163+ return none
164+ }
165+ colon := authority.last_index(':') or { return none }
166+ return colon
167+}
168+
169+// trim_ows removes leading/trailing OWS (RFC 7230 §3.2.3 - SP and
170+// HTAB). RFC 9421 §2.1 step 3 mandates this trim before signing.
171+fn trim_ows(s string) string {
172+ mut start := 0
173+ mut end := s.len
174+ for start < end && (s[start] == ` ` || s[start] == `\t`) {
175+ start++
176+ }
177+ for end > start && (s[end - 1] == ` ` || s[end - 1] == `\t`) {
178+ end--
179+ }
180+ return s[start..end]
181+}
@@ -0,0 +1,76 @@
1+// Tests for derived component canonicalization rules from RFC 9421 §2.2.
2+module signature
3+
4+fn test_authority_strips_default_https_port() {
5+ c := Components{
6+ authority: 'Example.com:443'
7+ scheme: 'https'
8+ }
9+ assert c.derived_value('@authority')! == 'example.com'
10+}
11+
12+fn test_authority_strips_default_http_port() {
13+ c := Components{
14+ authority: 'example.com:80'
15+ scheme: 'http'
16+ }
17+ assert c.derived_value('@authority')! == 'example.com'
18+}
19+
20+fn test_authority_keeps_non_default_port() {
21+ c := Components{
22+ authority: 'example.com:8443'
23+ scheme: 'https'
24+ }
25+ assert c.derived_value('@authority')! == 'example.com:8443'
26+}
27+
28+fn test_authority_does_not_strip_when_scheme_mismatched() {
29+ // :80 with https scheme is *not* the default, so it must stay.
30+ c := Components{
31+ authority: 'example.com:80'
32+ scheme: 'https'
33+ }
34+ assert c.derived_value('@authority')! == 'example.com:80'
35+}
36+
37+fn test_authority_no_port_unchanged() {
38+ c := Components{
39+ authority: 'Example.COM'
40+ scheme: 'https'
41+ }
42+ assert c.derived_value('@authority')! == 'example.com'
43+}
44+
45+fn test_authority_ipv6_strips_default_port() {
46+ c := Components{
47+ authority: '[2001:db8::1]:443'
48+ scheme: 'https'
49+ }
50+ assert c.derived_value('@authority')! == '[2001:db8::1]'
51+}
52+
53+fn test_authority_ipv6_keeps_non_default_port() {
54+ c := Components{
55+ authority: '[2001:db8::1]:8443'
56+ scheme: 'https'
57+ }
58+ assert c.derived_value('@authority')! == '[2001:db8::1]:8443'
59+}
60+
61+fn test_authority_ipv6_no_port_unchanged() {
62+ c := Components{
63+ authority: '[2001:db8::1]'
64+ scheme: 'https'
65+ }
66+ assert c.derived_value('@authority')! == '[2001:db8::1]'
67+}
68+
69+fn test_authority_no_scheme_keeps_port() {
70+ // Without a scheme we cannot know which port is the default, so
71+ // the port is preserved.
72+ c := Components{
73+ authority: 'example.com:443'
74+ }
75+ assert c.derived_value('@authority')! == 'example.com:443'
76+}
@@ -0,0 +1,68 @@
1+// Typed errors surfaced by the signature module. All concrete error
2+// types embed `Error` so callers can pattern-match with `err is X` (V's
3+// idiomatic typed-error matching).
4+module signature
5+
6+// VerificationFailed is returned when a signature does not match the
7+// recomputed signature base. Distinct from `MalformedMessage` so that
8+// callers can tell "wire bytes are fine but signature is bad" apart
9+// from "wire bytes are unparseable".
10+pub struct VerificationFailed {
11+ Error
12+pub:
13+ label string
14+}
15+
16+// msg formats a VerificationFailed for `IError.msg()`.
17+pub fn (e &VerificationFailed) msg() string {
18+ if e.label != '' {
19+ return 'http.signature: signature ${e.label} did not verify'
20+ }
21+ return 'http.signature: signature did not verify'
22+}
23+
24+// MalformedMessage covers all syntactic and structural problems with
25+// the inputs (missing covered components, malformed Signature-Input,
26+// unknown derived component, etc.). The `reason` field carries the
27+// short human-readable detail.
28+pub struct MalformedMessage {
29+ Error
30+pub:
31+ reason string
32+}
33+
34+// msg formats a MalformedMessage for `IError.msg()`.
35+pub fn (e &MalformedMessage) msg() string {
36+ return 'http.signature: ${e.reason}'
37+}
38+
39+// UnsupportedAlgorithm is returned when the `alg` parameter (or the
40+// implied algorithm of the supplied key) is not one this module
41+// implements. Carrying the offending token lets callers report it
42+// clearly rather than echoing a generic "not supported".
43+pub struct UnsupportedAlgorithm {
44+ Error
45+pub:
46+ name string
47+}
48+
49+// msg formats an UnsupportedAlgorithm for `IError.msg()`.
50+pub fn (e &UnsupportedAlgorithm) msg() string {
51+ return 'http.signature: algorithm ${e.name} is not supported'
52+}
53+
54+// SignatureExpired is returned by verification helpers when the
55+// signature's `expires` parameter is at or before the verification
56+// time. Callers that don't want this check can pass their own time
57+// reference (or use the lower-level `verify` that does no time check).
58+pub struct SignatureExpired {
59+ Error
60+pub:
61+ expires i64
62+ now i64
63+}
64+
65+// msg formats a SignatureExpired for `IError.msg()`.
66+pub fn (e &SignatureExpired) msg() string {
67+ return 'http.signature: signature expired (expires=${e.expires}, now=${e.now})'
68+}
@@ -0,0 +1,267 @@
1+// http.Request / http.Response integration. These helpers are thin
2+// wrappers - they translate the message into a `Components` value and
3+// delegate to `sign` / `verify`. Keeping the conversion isolated here
4+// means the components-level API stays the canonical surface.
5+module signature
6+
7+import net.http
8+import net.urllib
9+import time
10+
11+// SignRequestOptions parametrises `sign_request`. `components` is
12+// optional - when omitted we sign the conservative default
13+// (`@method`, `@target-uri`, `@authority`, plus the `Date` header if
14+// present). RFC 9421 doesn't mandate a default; this one mirrors what
15+// most production deployments use.
16+@[params]
17+pub struct SignRequestOptions {
18+pub:
19+ components []string
20+ label string = 'sig1'
21+ keyid ?string
22+ created ?i64
23+ expires ?i64
24+ nonce ?string
25+ tag ?string
26+ // include_alg, when true, emits the `alg` signature parameter on
27+ // the wire. Most signers leave it off (the verifier looks the alg
28+ // up by `keyid`); set to true for explicit signalling.
29+ include_alg bool
30+ // scheme is used to reconstruct `@target-uri` and `@scheme` when
31+ // `req.url` is origin-form (e.g. `/foo`, as produced by
32+ // `http.parse_request*`). The default matches the common signing
33+ // scenario (TLS-protected APIs). Ignored when `req.url` already
34+ // carries a scheme.
35+ scheme string = 'https'
36+}
37+
38+// sign_request signs an HTTP request in place by appending the
39+// `Signature-Input` and `Signature` header fields. Existing values of
40+// these headers are preserved (RFC 9421 §4.3 - multiple signatures
41+// can coexist), so calling this twice with different labels yields
42+// two co-existing signatures.
43+//
44+// `created` defaults to `time.now().unix()` when omitted, since
45+// RFC 9421 §7.2.1 RECOMMENDS the parameter for replay protection.
46+// Pass an explicit `created: 0` only if you know you don't want it.
47+pub fn sign_request(mut req http.Request, key Key, opts SignRequestOptions) ! {
48+ c := request_components(req, opts.scheme)!
49+ mut comps := opts.components.clone()
50+ if comps.len == 0 {
51+ comps = default_request_components(req)
52+ }
53+ mut alg := ?string(none)
54+ if opts.include_alg {
55+ alg = key.algorithm.name()
56+ }
57+ p := SignatureParams{
58+ components: comps
59+ keyid: opts.keyid
60+ alg: alg
61+ created: opts.created or { time.now().unix() }
62+ expires: opts.expires
63+ nonce: opts.nonce
64+ tag: opts.tag
65+ }
66+ out := sign(c, p, key, opts.label)!
67+ append_dict_header(mut req.header, 'Signature-Input', out.signature_input)!
68+ append_dict_header(mut req.header, 'Signature', out.signature)!
69+}
70+
71+// VerifyRequestOptions parametrises `verify_request`. `label` selects
72+// which labelled signature to verify when several are present.
73+@[params]
74+pub struct VerifyRequestOptions {
75+pub:
76+ label string
77+ now_unix i64
78+ // scheme — see SignRequestOptions.scheme. Both ends of the
79+ // signature must agree on the scheme used to reconstruct the
80+ // target URI, otherwise the signature bases differ.
81+ scheme string = 'https'
82+}
83+
84+// verify_request verifies a labelled signature on an HTTP request. If
85+// `opts.label` is empty and exactly one signature is present, that
86+// one is checked. If `opts.now_unix > 0`, the `expires` parameter is
87+// also enforced.
88+pub fn verify_request(req http.Request, key Key, opts VerifyRequestOptions) ! {
89+ c := request_components(req, opts.scheme)!
90+ sig_input := merged_dict_field(req.header, 'Signature-Input') or {
91+ return MalformedMessage{
92+ reason: 'request has no Signature-Input header'
93+ }
94+ }
95+ sig_value := merged_dict_field(req.header, 'Signature') or {
96+ return MalformedMessage{
97+ reason: 'request has no Signature header'
98+ }
99+ }
100+ verify(c, sig_input, sig_value, opts.label, key, now_unix: opts.now_unix)!
101+}
102+
103+// SignResponseOptions / VerifyResponseOptions mirror their request
104+// counterparts. Defaults assume a status-code-and-content scenario.
105+@[params]
106+pub struct SignResponseOptions {
107+pub:
108+ components []string
109+ label string = 'sig1'
110+ keyid ?string
111+ created ?i64
112+ expires ?i64
113+ nonce ?string
114+ tag ?string
115+ include_alg bool
116+}
117+
118+// sign_response signs an HTTP response in place. Like `sign_request`
119+// it preserves any pre-existing Signature-Input / Signature values
120+// and defaults `created` to the current time.
121+pub fn sign_response(mut resp http.Response, key Key, opts SignResponseOptions) ! {
122+ c := response_components(resp)
123+ mut comps := opts.components.clone()
124+ if comps.len == 0 {
125+ comps = ['@status']
126+ }
127+ mut alg := ?string(none)
128+ if opts.include_alg {
129+ alg = key.algorithm.name()
130+ }
131+ p := SignatureParams{
132+ components: comps
133+ keyid: opts.keyid
134+ alg: alg
135+ created: opts.created or { time.now().unix() }
136+ expires: opts.expires
137+ nonce: opts.nonce
138+ tag: opts.tag
139+ }
140+ out := sign(c, p, key, opts.label)!
141+ append_dict_header(mut resp.header, 'Signature-Input', out.signature_input)!
142+ append_dict_header(mut resp.header, 'Signature', out.signature)!
143+}
144+
145+@[params]
146+pub struct VerifyResponseOptions {
147+pub:
148+ label string
149+ now_unix i64
150+}
151+
152+// verify_response verifies a labelled signature on an HTTP response.
153+pub fn verify_response(resp http.Response, key Key, opts VerifyResponseOptions) ! {
154+ c := response_components(resp)
155+ sig_input := merged_dict_field(resp.header, 'Signature-Input') or {
156+ return MalformedMessage{
157+ reason: 'response has no Signature-Input header'
158+ }
159+ }
160+ sig_value := merged_dict_field(resp.header, 'Signature') or {
161+ return MalformedMessage{
162+ reason: 'response has no Signature header'
163+ }
164+ }
165+ verify(c, sig_input, sig_value, opts.label, key, now_unix: opts.now_unix)!
166+}
167+
168+// append_dict_header merges `addition` into the existing dictionary
169+// field `name`, separating with ", " per RFC 8941 §3.2 - this keeps
170+// multiple labelled signatures in a single `Signature-Input` /
171+// `Signature` field as the spec recommends, even when `add_custom`
172+// would otherwise create separate field instances.
173+fn append_dict_header(mut h http.Header, name string, addition string) ! {
174+ existing := h.get_custom(name) or {
175+ h.add_custom(name, addition)!
176+ return
177+ }
178+ h.set_custom(name, existing + ', ' + addition)!
179+}
180+
181+// merged_dict_field returns the concatenation of all values of `name`
182+// joined with ", ". HTTP/1.1 §3.2.2 lets a Structured Field appear on
183+// multiple field-lines; verifiers MUST reassemble them before parsing.
184+fn merged_dict_field(h http.Header, name string) ?string {
185+ values := h.custom_values(name)
186+ if values.len == 0 {
187+ return none
188+ }
189+ return values.join(', ')
190+}
191+
192+// request_components extracts the derived-component values from an
193+// http.Request. `default_scheme` is used when `req.url` is in
194+// origin-form (e.g. `/foo?bar=1`, as produced by `http.parse_request*`
195+// for inbound HTTP/1.1 messages); in that case `@target-uri` is
196+// reconstructed as `<scheme>://<authority><url>` per RFC 9110 §7.1.
197+// If `req.url` is not a valid URL we surface a typed error rather
198+// than silently dropping components that depend on it.
199+fn request_components(req http.Request, default_scheme string) !Components {
200+ parsed := urllib.parse(req.url) or {
201+ return MalformedMessage{
202+ reason: 'request url "${req.url}" is not a valid URL: ${err.msg()}'
203+ }
204+ }
205+ authority := if parsed.host != '' {
206+ parsed.host
207+ } else {
208+ req.host
209+ }
210+ scheme := if parsed.scheme != '' { parsed.scheme } else { default_scheme }
211+ mut c := Components{
212+ method: req.method.str()
213+ }
214+ is_origin_form := req.url.starts_with('/')
215+ c.target_uri = if is_origin_form && authority != '' {
216+ '${scheme}://${authority}${req.url}'
217+ } else {
218+ req.url
219+ }
220+ if authority != '' {
221+ c.authority = authority
222+ }
223+ if scheme != '' {
224+ c.scheme = scheme
225+ }
226+ if parsed.path != '' {
227+ c.path = parsed.path
228+ } else {
229+ c.path = '/'
230+ }
231+ c.query = if parsed.raw_query != '' { '?' + parsed.raw_query } else { '?' }
232+ mut request_target := c.path or { '/' }
233+ if rq := c.query {
234+ if rq != '?' {
235+ request_target += rq
236+ }
237+ }
238+ c.request_target = request_target
239+ for k in req.header.keys() {
240+ values := req.header.custom_values(k)
241+ if values.len > 0 {
242+ c.fields[k.to_lower()] = values
243+ }
244+ }
245+ return c
246+}
247+
248+fn response_components(resp http.Response) Components {
249+ mut c := Components{
250+ status: resp.status_code
251+ }
252+ for k in resp.header.keys() {
253+ values := resp.header.custom_values(k)
254+ if values.len > 0 {
255+ c.fields[k.to_lower()] = values
256+ }
257+ }
258+ return c
259+}
260+
261+fn default_request_components(req http.Request) []string {
262+ mut comps := ['@method', '@target-uri', '@authority']
263+ if req.header.contains_custom('Date') {
264+ comps << 'date'
265+ }
266+ return comps
267+}
@@ -0,0 +1,288 @@
1+// Tests for the http.Request / http.Response wrappers. The
2+// roundtrip cases exercise the full sign-then-verify pipeline through
3+// the public API; the negative paths cover the rejection branches
4+// callers depend on for security.
5+module signature
6+
7+import crypto.ecdsa
8+import crypto.ed25519
9+import encoding.base64
10+import net.http
11+
12+const test_secret = 'shh-this-is-a-secret-shared-with-the-server'
13+
14+fn build_request(url string) http.Request {
15+ mut req := http.Request{
16+ method: .post
17+ url: url
18+ }
19+ req.header.add_custom('Date', 'Tue, 20 Apr 2021 02:07:55 GMT') or {}
20+ req.header.add_custom('Content-Type', 'application/json') or {}
21+ req.header.add_custom('Host', 'example.com') or {}
22+ return req
23+}
24+
25+fn test_sign_and_verify_request_hmac_roundtrip() {
26+ mut req := build_request('https://example.com/foo?bar=1')
27+ key := Key.hmac_sha256(test_secret.bytes()).with_keyid('shared-key')
28+ sign_request(mut req, key,
29+ components: ['@method', '@target-uri', '@authority', 'date', 'content-type']
30+ created: 1618884473
31+ )!
32+ verify_request(req, key)!
33+}
34+
35+fn test_sign_and_verify_request_ed25519_roundtrip() {
36+ seed := []u8{len: 32, init: u8(index)}
37+ priv_obj := ed25519.new_key_from_seed(seed)
38+ pub_bytes := []u8(priv_obj.public_key())
39+
40+ priv_key := Key.ed25519_private(seed).with_keyid('alice-ed25519')
41+ pub_key := Key.ed25519_public(pub_bytes)
42+
43+ mut req := build_request('https://example.com/foo')
44+ sign_request(mut req, priv_key,
45+ components: ['@method', '@target-uri', 'date']
46+ created: 1618884473
47+ )!
48+ verify_request(req, pub_key)!
49+}
50+
51+fn test_sign_and_verify_request_ecdsa_p256_roundtrip() {
52+ x, y, d := p256_test_key()
53+ priv_key := Key.ecdsa_p256_private(x, y, d).with_keyid('p256-key')
54+ pub_key := Key.ecdsa_p256_public(x, y)
55+ mut req := build_request('https://example.com/api?id=42')
56+ sign_request(mut req, priv_key,
57+ components: ['@method', '@target-uri', '@path', '@query', 'content-type']
58+ created: 1618884473
59+ )!
60+ verify_request(req, pub_key)!
61+}
62+
63+fn test_sign_and_verify_request_ecdsa_p384_roundtrip() {
64+ // P-384 has no RFC 9421 vector; generate a fresh keypair via the
65+ // V ecdsa module so the test is self-contained.
66+ pub_obj, priv_obj := ecdsa.generate_key(nid: .secp384r1)!
67+ defer {
68+ priv_obj.free()
69+ pub_obj.free()
70+ }
71+ pub_bytes := pub_obj.bytes()!
72+ // pub_bytes is the SEC1 uncompressed point: 0x04 || x || y. The
73+ // public-key constructor wants raw (x, y) — strip the prefix.
74+ x := pub_bytes[1..49]
75+ y := pub_bytes[49..97]
76+ d := priv_obj.bytes()!
77+ priv_key := Key.ecdsa_p384_private(x, y, d).with_keyid('p384-key')
78+ pub_key := Key.ecdsa_p384_public(x, y)
79+ mut req := build_request('https://example.com/foo')
80+ sign_request(mut req, priv_key,
81+ components: ['@method', '@target-uri']
82+ created: 1618884473
83+ )!
84+ verify_request(req, pub_key)!
85+}
86+
87+fn test_verify_request_rejects_wrong_key() {
88+ mut req := build_request('https://example.com/foo')
89+ good := Key.hmac_sha256('secret-A'.bytes())
90+ bad := Key.hmac_sha256('secret-B'.bytes())
91+ sign_request(mut req, good, components: ['@method', '@target-uri'], created: 1)!
92+ if _ := verify_request(req, bad) {
93+ assert false, 'wrong key must not verify'
94+ } else {
95+ assert err is VerificationFailed
96+ }
97+}
98+
99+fn test_verify_request_rejects_tampered_target_uri() {
100+ mut req := build_request('https://example.com/foo')
101+ key := Key.hmac_sha256(test_secret.bytes())
102+ sign_request(mut req, key, components: ['@method', '@target-uri'], created: 1)!
103+ // Mutate the URL after signing - the verifier rebuilds the
104+ // signature base from the (now-tampered) request and must fail.
105+ req.url = 'https://example.com/bar'
106+ if _ := verify_request(req, key) {
107+ assert false, 'tampered @target-uri must not verify'
108+ } else {
109+ assert err is VerificationFailed
110+ }
111+}
112+
113+fn test_verify_request_rejects_missing_signature_header() {
114+ mut req := build_request('https://example.com/foo')
115+ key := Key.hmac_sha256(test_secret.bytes())
116+ sign_request(mut req, key, components: ['@method'], created: 1)!
117+ req.header.delete_custom('Signature')
118+ if _ := verify_request(req, key) {
119+ assert false, 'missing Signature header must error'
120+ } else {
121+ assert err is MalformedMessage
122+ assert err.msg().contains('no Signature header')
123+ }
124+}
125+
126+fn test_verify_request_rejects_expired_signature() {
127+ mut req := build_request('https://example.com/foo')
128+ key := Key.hmac_sha256(test_secret.bytes())
129+ sign_request(mut req, key,
130+ components: ['@method']
131+ created: 1000
132+ expires: 2000
133+ )!
134+ if _ := verify_request(req, key, now_unix: 5000) {
135+ assert false, 'expired signature must be rejected when now_unix > expires'
136+ } else {
137+ assert err is SignatureExpired
138+ }
139+ // Unchecked when now_unix is left at the default zero.
140+ verify_request(req, key)!
141+}
142+
143+fn test_origin_form_request_target_uri_reconstructed() {
144+ // Inbound HTTP/1.1 requests parsed by `http.parse_request*` carry
145+ // the request target verbatim (`/foo?bar=1`), not an absolute URI.
146+ // `request_components` must rebuild `<scheme>://<authority><url>`
147+ // so the verifier sees the same `@target-uri` as a peer that signs
148+ // with an absolute URL.
149+ mut signing_req := http.Request{
150+ method: .post
151+ url: 'https://example.com/foo?bar=1'
152+ }
153+ signing_req.header.add_custom('Host', 'example.com')!
154+ key := Key.hmac_sha256(test_secret.bytes())
155+ sign_request(mut signing_req, key,
156+ components: ['@method', '@target-uri']
157+ created: 1
158+ )!
159+
160+ // Receiver side: same request as it would land after parsing.
161+ mut received := http.Request{
162+ method: .post
163+ url: '/foo?bar=1'
164+ host: 'example.com'
165+ }
166+ for k in signing_req.header.keys() {
167+ for v in signing_req.header.custom_values(k) {
168+ received.header.add_custom(k, v)!
169+ }
170+ }
171+ verify_request(received, key)!
172+}
173+
174+fn test_origin_form_uses_explicit_scheme() {
175+ // HTTP-only deployment: caller must pass `scheme: 'http'` so both
176+ // sides agree on the reconstructed target URI.
177+ mut signing_req := http.Request{
178+ method: .get
179+ url: 'http://api.example.com/v1/items'
180+ }
181+ signing_req.header.add_custom('Host', 'api.example.com')!
182+ key := Key.hmac_sha256(test_secret.bytes())
183+ sign_request(mut signing_req, key,
184+ components: ['@method', '@target-uri']
185+ created: 1
186+ scheme: 'http'
187+ )!
188+
189+ mut received := http.Request{
190+ method: .get
191+ url: '/v1/items'
192+ host: 'api.example.com'
193+ }
194+ for k in signing_req.header.keys() {
195+ for v in signing_req.header.custom_values(k) {
196+ received.header.add_custom(k, v)!
197+ }
198+ }
199+ verify_request(received, key, scheme: 'http')!
200+}
201+
202+fn test_sign_two_signatures_coexist() {
203+ mut req := build_request('https://example.com/foo')
204+ k1 := Key.hmac_sha256('one'.bytes())
205+ k2 := Key.hmac_sha256('two'.bytes())
206+ sign_request(mut req, k1, components: ['@method'], label: 'sig-a', created: 1)!
207+ sign_request(mut req, k2, components: ['@target-uri'], label: 'sig-b', created: 2)!
208+ verify_request(req, k1, label: 'sig-a')!
209+ verify_request(req, k2, label: 'sig-b')!
210+ if _ := verify_request(req, k1, label: 'sig-b') {
211+ assert false, 'sig-b must not verify under k1'
212+ } else {
213+ assert err is VerificationFailed
214+ }
215+}
216+
217+fn test_sign_response_and_verify() {
218+ mut resp := http.Response{
219+ status_code: 200
220+ }
221+ resp.header.add_custom('Content-Type', 'application/json')!
222+ resp.header.add_custom('Content-Length', '23')!
223+ key := Key.hmac_sha256(test_secret.bytes())
224+ sign_response(mut resp, key,
225+ components: ['@status', 'content-type', 'content-length']
226+ created: 1
227+ )!
228+ verify_response(resp, key)!
229+}
230+
231+fn test_alg_param_must_match_key_algorithm() {
232+ x, y, d := p256_test_key()
233+ priv := Key.ecdsa_p256_private(x, y, d)
234+ c := Components{
235+ method: 'GET'
236+ target_uri: 'https://example.com/'
237+ }
238+ p := SignatureParams{
239+ components: ['@method']
240+ alg: 'ed25519'
241+ }
242+ if _ := sign(c, p, priv, 'sig1') {
243+ assert false, 'alg mismatch must fail'
244+ } else {
245+ assert err is MalformedMessage
246+ }
247+}
248+
249+fn test_label_validation_rejects_empty_and_uppercase() {
250+ p := SignatureParams{
251+ components: ['@method']
252+ }
253+ key := Key.hmac_sha256('k'.bytes())
254+ c := Components{
255+ method: 'GET'
256+ }
257+ if _ := sign(c, p, key, '') {
258+ assert false, 'empty label must fail'
259+ } else {
260+ assert err is MalformedMessage
261+ }
262+ if _ := sign(c, p, key, 'Sig1') {
263+ assert false, 'uppercase label must fail (Structured Field key grammar)'
264+ } else {
265+ assert err is MalformedMessage
266+ }
267+}
268+
269+// p256_test_key returns the (x, y, d) coordinates from RFC 9421
270+// Appendix B.1.3.
271+fn p256_test_key() ([]u8, []u8, []u8) {
272+ x := pad_b64u('qIVYZVLCrPZHGHjP17CTW0_-D9Lfw0EkjqF7xB4FivA', 32)
273+ y := pad_b64u('Mc4nN9LTDOBhfoUeg8Ye9WedFRhnZXZJA12Qp0zZ6F0', 32)
274+ d := pad_b64u('UpuF81l-kOxbjf7T4mNSv0r5tN67Gim7rnf6EFpcYDs', 32)
275+ return x, y, d
276+}
277+
278+fn pad_b64u(s string, want int) []u8 {
279+ mut padded := s
280+ for padded.len % 4 != 0 {
281+ padded += '='
282+ }
283+ mut b := base64.url_decode(padded)
284+ for b.len < want {
285+ b.prepend(u8(0))
286+ }
287+ return b
288+}
@@ -0,0 +1,305 @@
1+// Key material used by `sign` and `verify`. The struct is intentionally
2+// algorithm-tagged - one Key value carries both its algorithm and the
3+// raw octets, so the high-level helpers can pick the right primitive
4+// without the caller having to repeat themselves.
5+//
6+// Use the typed constructors (Key.hmac_sha256, Key.ed25519_private,
7+// Key.ecdsa_p256_public, etc.) - never build a Key by struct literal.
8+module signature
9+
10+import crypto.ecdsa
11+import crypto.pem
12+
13+// Key is a tagged blob of key material. Public/private distinction is
14+// in `is_private`; the on-the-wire layout of `bytes` depends on
15+// `algorithm` and is documented per constructor below.
16+pub struct Key {
17+pub:
18+ algorithm Algorithm
19+ is_private bool
20+ // bytes layout per algorithm:
21+ // .hmac_sha256 → the symmetric secret
22+ // .ed25519, private → 32-byte seed (RFC 8032 §5.1.5)
23+ // .ed25519, public → 32-byte x coordinate
24+ // .ecdsa_*, private → x || y || d (each at curve byte size)
25+ // .ecdsa_*, public → x || y (each at curve byte size)
26+ bytes []u8
27+ // keyid, if set, is what the high-level helpers will emit as the
28+ // `keyid` signature parameter when no explicit keyid is passed.
29+ // Not part of the cryptographic identity - just routing metadata.
30+ keyid ?string
31+}
32+
33+// Key.hmac_sha256 builds a symmetric key for the hmac-sha256 algorithm.
34+// `secret` must not be empty. RFC 9421 §3.3.3 recommends at least
35+// 256 bits of entropy.
36+pub fn Key.hmac_sha256(secret []u8) Key {
37+ return Key{
38+ algorithm: .hmac_sha256
39+ bytes: secret
40+ }
41+}
42+
43+// Key.ed25519_private wraps a 32-byte Ed25519 seed (RFC 8032 §5.1.5).
44+// The corresponding public key can be derived on demand by the signer.
45+pub fn Key.ed25519_private(seed []u8) Key {
46+ return Key{
47+ algorithm: .ed25519
48+ is_private: true
49+ bytes: seed
50+ }
51+}
52+
53+// Key.ed25519_public wraps the 32-byte Ed25519 public x-coordinate.
54+pub fn Key.ed25519_public(x []u8) Key {
55+ return Key{
56+ algorithm: .ed25519
57+ bytes: x
58+ }
59+}
60+
61+// Key.ecdsa_p256_private wraps an ECDSA P-256 private key as raw
62+// (x, y, d) coordinates. Each coordinate is zero-padded to 32 bytes
63+// (the curve byte size).
64+pub fn Key.ecdsa_p256_private(x []u8, y []u8, d []u8) Key {
65+ return Key{
66+ algorithm: .ecdsa_p256_sha256
67+ is_private: true
68+ bytes: concat3(x, y, d)
69+ }
70+}
71+
72+// Key.ecdsa_p256_public wraps an ECDSA P-256 public key as raw (x, y).
73+pub fn Key.ecdsa_p256_public(x []u8, y []u8) Key {
74+ return Key{
75+ algorithm: .ecdsa_p256_sha256
76+ bytes: concat3(x, y, []u8{})
77+ }
78+}
79+
80+// Key.ecdsa_p384_private wraps an ECDSA P-384 private key as raw
81+// (x, y, d) coordinates. Each coordinate is zero-padded to 48 bytes.
82+pub fn Key.ecdsa_p384_private(x []u8, y []u8, d []u8) Key {
83+ return Key{
84+ algorithm: .ecdsa_p384_sha384
85+ is_private: true
86+ bytes: concat3(x, y, d)
87+ }
88+}
89+
90+// Key.ecdsa_p384_public wraps an ECDSA P-384 public key as raw (x, y).
91+pub fn Key.ecdsa_p384_public(x []u8, y []u8) Key {
92+ return Key{
93+ algorithm: .ecdsa_p384_sha384
94+ bytes: concat3(x, y, []u8{})
95+ }
96+}
97+
98+// with_keyid returns a copy of the Key with `keyid` set. Convenience
99+// for fluent construction at call sites.
100+pub fn (k Key) with_keyid(keyid string) Key {
101+ return Key{
102+ algorithm: k.algorithm
103+ is_private: k.is_private
104+ bytes: k.bytes
105+ keyid: keyid
106+ }
107+}
108+
109+fn concat3(a []u8, b []u8, c []u8) []u8 {
110+ mut out := []u8{cap: a.len + b.len + c.len}
111+ out << a
112+ out << b
113+ out << c
114+ return out
115+}
116+
117+// Key.from_pem decodes a PEM-encoded key and returns a Key tagged with
118+// the algorithm inferred from the embedded OID. Supports the four
119+// PEM shapes commonly emitted by `openssl genpkey` / `openssl ec`:
120+//
121+// * `-----BEGIN PRIVATE KEY-----` (PKCS#8 — Ed25519 or ECDSA)
122+// * `-----BEGIN EC PRIVATE KEY-----` (SEC1 — ECDSA)
123+// * `-----BEGIN PUBLIC KEY-----` (SPKI — Ed25519 or ECDSA)
124+//
125+// HMAC keys never come as PEM (they're raw shared secrets) — call
126+// `Key.hmac_sha256` directly with the bytes for those.
127+pub fn Key.from_pem(pem_text string) !Key {
128+ block := pem.decode_only(pem_text) or {
129+ return MalformedMessage{
130+ reason: 'PEM decode failed (missing or malformed BEGIN/END markers)'
131+ }
132+ }
133+ der := block.data
134+ if contains_oid_ed25519(der) {
135+ return match block.block_type {
136+ 'PRIVATE KEY' {
137+ parse_ed25519_pkcs8(der)!
138+ }
139+ 'PUBLIC KEY' {
140+ parse_ed25519_spki(der)!
141+ }
142+ else {
143+ return MalformedMessage{
144+ reason: 'unexpected PEM block "${block.block_type}" for an Ed25519 key'
145+ }
146+ }
147+ }
148+ }
149+ // ECDSA path - delegate parsing to vlib/crypto/ecdsa, then extract
150+ // raw coordinates so the Key struct stays algorithm-tagged.
151+ return match block.block_type {
152+ 'PUBLIC KEY' {
153+ ecdsa_key_from_pem_public(pem_text)!
154+ }
155+ 'EC PRIVATE KEY', 'PRIVATE KEY' {
156+ ecdsa_key_from_pem_private(pem_text)!
157+ }
158+ else {
159+ return MalformedMessage{
160+ reason: 'unsupported PEM block "${block.block_type}" - expected PRIVATE KEY, EC PRIVATE KEY, or PUBLIC KEY'
161+ }
162+ }
163+ }
164+}
165+
166+// OID 1.3.101.112 (id-Ed25519, RFC 8410 §3) - the byte sequence
167+// `06 03 2B 65 70` is unambiguous in any PKCS#8 / SPKI Ed25519 blob.
168+fn contains_oid_ed25519(b []u8) bool {
169+ needle := [u8(0x06), 0x03, 0x2B, 0x65, 0x70]
170+ if b.len < needle.len {
171+ return false
172+ }
173+ for i in 0 .. b.len - needle.len + 1 {
174+ mut ok := true
175+ for j in 0 .. needle.len {
176+ if b[i + j] != needle[j] {
177+ ok = false
178+ break
179+ }
180+ }
181+ if ok {
182+ return true
183+ }
184+ }
185+ return false
186+}
187+
188+// parse_ed25519_pkcs8 extracts the 32-byte seed from the canonical
189+// RFC 8410 §7 PrivateKeyInfo encoding. The DER is fully constrained
190+// for Ed25519 keys (no optional fields), so we recognise the byte
191+// pattern rather than running a general ASN.1 parser.
192+fn parse_ed25519_pkcs8(der []u8) !Key {
193+ prefix := [u8(0x30), 0x2E, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2B, 0x65, 0x70, 0x04,
194+ 0x22, 0x04, 0x20]
195+ if der.len != 48 || !has_prefix(der, prefix) {
196+ return MalformedMessage{
197+ reason: 'Ed25519 PKCS#8 private key must follow the canonical RFC 8410 §7 encoding'
198+ }
199+ }
200+ return Key.ed25519_private(der[16..48].clone())
201+}
202+
203+// parse_ed25519_spki extracts the 32-byte x coordinate from the
204+// canonical SubjectPublicKeyInfo encoding (RFC 8410 §4).
205+fn parse_ed25519_spki(der []u8) !Key {
206+ prefix := [u8(0x30), 0x2A, 0x30, 0x05, 0x06, 0x03, 0x2B, 0x65, 0x70, 0x03, 0x21, 0x00]
207+ if der.len != 44 || !has_prefix(der, prefix) {
208+ return MalformedMessage{
209+ reason: 'Ed25519 SPKI public key must follow the canonical RFC 8410 §4 encoding'
210+ }
211+ }
212+ return Key.ed25519_public(der[12..44].clone())
213+}
214+
215+fn has_prefix(b []u8, prefix []u8) bool {
216+ if b.len < prefix.len {
217+ return false
218+ }
219+ for i in 0 .. prefix.len {
220+ if b[i] != prefix[i] {
221+ return false
222+ }
223+ }
224+ return true
225+}
226+
227+fn ecdsa_key_from_pem_private(pem_text string) !Key {
228+ priv_obj := ecdsa.privkey_from_string(pem_text) or {
229+ return MalformedMessage{
230+ reason: 'ECDSA PEM parse failed: ${err.msg()}'
231+ }
232+ }
233+ d := priv_obj.bytes()!
234+ pub_obj := priv_obj.public_key()!
235+ priv_obj.free()
236+ xy := pub_obj.bytes()!
237+ pub_obj.free()
238+ return ecdsa_key_from_xy_d(xy, d, true)!
239+}
240+
241+fn ecdsa_key_from_pem_public(pem_text string) !Key {
242+ pub_obj := ecdsa.pubkey_from_string(pem_text) or {
243+ return MalformedMessage{
244+ reason: 'ECDSA PEM parse failed: ${err.msg()}'
245+ }
246+ }
247+ xy := pub_obj.bytes()!
248+ pub_obj.free()
249+ return ecdsa_key_from_xy_d(xy, []u8{}, false)!
250+}
251+
252+// ecdsa_key_from_xy_d takes the SEC1 uncompressed point (`xy` =
253+// `0x04 || x || y`) and an optional `d` and selects the matching
254+// `Key.ecdsa_p256_*` / `Key.ecdsa_p384_*` constructor by point size.
255+fn ecdsa_key_from_xy_d(xy []u8, d []u8, is_priv bool) !Key {
256+ if xy.len < 1 || xy[0] != 0x04 {
257+ return MalformedMessage{
258+ reason: 'ECDSA public key must be SEC1 uncompressed (0x04 || x || y)'
259+ }
260+ }
261+ coord := (xy.len - 1) / 2
262+ x := xy[1..1 + coord]
263+ y := xy[1 + coord..1 + coord * 2]
264+ // `ecdsa.PrivateKey.bytes()` returns the scalar in minimal-length form
265+ // (no leading zeros), so we pad it to the curve byte size; the wire
266+ // layout that `ecdsa_sign` expects is fixed-width.
267+ priv_d := if is_priv { pad_left(d, coord)! } else { d }
268+ return match coord {
269+ 32 {
270+ if is_priv {
271+ Key.ecdsa_p256_private(x, y, priv_d)
272+ } else {
273+ Key.ecdsa_p256_public(x, y)
274+ }
275+ }
276+ 48 {
277+ if is_priv {
278+ Key.ecdsa_p384_private(x, y, priv_d)
279+ } else {
280+ Key.ecdsa_p384_public(x, y)
281+ }
282+ }
283+ else {
284+ return UnsupportedAlgorithm{
285+ name: 'ECDSA curve with ${coord * 8}-bit coordinates'
286+ }
287+ }
288+ }
289+}
290+
291+fn pad_left(b []u8, width int) ![]u8 {
292+ if b.len == width {
293+ return b
294+ }
295+ if b.len > width {
296+ return MalformedMessage{
297+ reason: 'ECDSA scalar wider (${b.len}) than curve coordinate size (${width})'
298+ }
299+ }
300+ mut out := []u8{len: width}
301+ for i, v in b {
302+ out[width - b.len + i] = v
303+ }
304+ return out
305+}
@@ -0,0 +1,159 @@
1+// Tests for Key.from_pem - the PEM blocks come from RFC 9421
2+// Appendix B.1.3 (ECDSA P-256) and B.1.4 (Ed25519). The verification
3+// roundtrip checks that the parsed key behaves identically to one
4+// built from raw coordinates.
5+module signature
6+
7+const rfc_ed25519_public_pem = '-----BEGIN PUBLIC KEY-----
8+MCowBQYDK2VwAyEAJrQLj5P/89iXES9+vFgrIy29clF9CC/oPPsw3c5D0bs=
9+-----END PUBLIC KEY-----'
10+
11+const rfc_ed25519_private_pem = '-----BEGIN PRIVATE KEY-----
12+MC4CAQAwBQYDK2VwBCIEIJ+DYvh6SEqVTm50DFtMDoQikTmiCqirVv9mWG9qfSnF
13+-----END PRIVATE KEY-----'
14+
15+const rfc_ec_p256_public_pem = '-----BEGIN PUBLIC KEY-----
16+MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEqIVYZVLCrPZHGHjP17CTW0/+D9Lf
17+w0EkjqF7xB4FivAxzic30tMM4GF+hR6Dxh71Z50VGGdldkkDXZCnTNnoXQ==
18+-----END PUBLIC KEY-----'
19+
20+const rfc_ec_p256_private_pem = '-----BEGIN EC PRIVATE KEY-----
21+MHcCAQEEIFKbhfNZfpDsW43+0+JjUr9K+bTeuxopu653+hBaXGA7oAoGCCqGSM49
22+AwEHoUQDQgAEqIVYZVLCrPZHGHjP17CTW0/+D9Lfw0EkjqF7xB4FivAxzic30tMM
23+4GF+hR6Dxh71Z50VGGdldkkDXZCnTNnoXQ==
24+-----END EC PRIVATE KEY-----'
25+
26+// P-256 key whose private scalar starts with 0x00, so OpenSSL's
27+// `BN_bn2binpad(num_bytes)` returns 31 bytes instead of 32. This
28+// exercises the leading-zero padding in `ecdsa_key_from_xy_d`.
29+const short_d_p256_private_pem = '-----BEGIN EC PRIVATE KEY-----
30+MHcCAQEEIACZmEw0q8iipb0amaNiobX/wwn6PoIKUatErMY2Dd4+oAoGCCqGSM49
31+AwEHoUQDQgAE/z/OBheMT6mCKDapfETr56tkYLOrnQh+ZL293+IqXsJ+iMZgYe0/
32+WHaZhZfCu1OKUWayaVEkvb7j0o3uUfw+OQ==
33+-----END EC PRIVATE KEY-----'
34+
35+fn key_test_b26_components() Components {
36+ return Components{
37+ method: 'POST'
38+ path: '/foo'
39+ authority: 'example.com'
40+ fields: {
41+ 'date': ['Tue, 20 Apr 2021 02:07:55 GMT']
42+ 'content-type': ['application/json']
43+ 'content-length': ['18']
44+ }
45+ }
46+}
47+
48+fn key_test_b26_params() SignatureParams {
49+ return SignatureParams{
50+ components: ['date', '@method', '@path', '@authority', 'content-type', 'content-length']
51+ created: 1618884473
52+ keyid: 'test-key-ed25519'
53+ }
54+}
55+
56+fn key_test_b24_components() Components {
57+ return Components{
58+ status: 200
59+ fields: {
60+ 'content-type': ['application/json']
61+ 'content-digest': [
62+ 'sha-512=:mEWXIS7MaLRuGgxOBdODa3xqM1XdEvxoYhvlCFJ41QJgJc4GTsPp29l5oGX69wWdXymyU0rjJuahq4l5aGgfLQ==:',
63+ ]
64+ 'content-length': ['23']
65+ }
66+ }
67+}
68+
69+fn test_from_pem_ed25519_private_reproduces_rfc_signature() {
70+ priv := Key.from_pem(rfc_ed25519_private_pem)!
71+ assert priv.algorithm == .ed25519
72+ assert priv.is_private
73+ c := key_test_b26_components()
74+ out := sign(c, key_test_b26_params(), priv, 'sig-b26')!
75+ // RFC 9421 §B.2.6 reference value, byte-exact.
76+ assert out.signature == 'sig-b26=:wqcAqbmYJ2ji2glfAMaRy4gruYYnx2nEFN2HN6jrnDnQCK1u02Gb04v9EDgwUPiu4A0w6vuQv5lIp5WPpBKRCw==:'
77+}
78+
79+fn test_from_pem_ed25519_public_verifies_rfc_signature() {
80+ pub_key := Key.from_pem(rfc_ed25519_public_pem)!
81+ assert pub_key.algorithm == .ed25519
82+ assert !pub_key.is_private
83+ verify(key_test_b26_components(),
84+ 'sig-b26=("date" "@method" "@path" "@authority" "content-type" "content-length");created=1618884473;keyid="test-key-ed25519"',
85+ 'sig-b26=:wqcAqbmYJ2ji2glfAMaRy4gruYYnx2nEFN2HN6jrnDnQCK1u02Gb04v9EDgwUPiu4A0w6vuQv5lIp5WPpBKRCw==:',
86+ 'sig-b26', pub_key)!
87+}
88+
89+fn test_from_pem_ecdsa_p256_private_signs_and_verifies() {
90+ priv := Key.from_pem(rfc_ec_p256_private_pem)!
91+ pub_key := Key.from_pem(rfc_ec_p256_public_pem)!
92+ assert priv.algorithm == .ecdsa_p256_sha256
93+ assert pub_key.algorithm == .ecdsa_p256_sha256
94+ assert priv.is_private
95+ c := Components{
96+ method: 'POST'
97+ target_uri: 'https://example.com/'
98+ }
99+ p := SignatureParams{
100+ components: ['@method', '@target-uri']
101+ created: 1
102+ }
103+ out := sign(c, p, priv, 'sig1')!
104+ verify(c, out.signature_input, out.signature, 'sig1', pub_key)!
105+}
106+
107+fn test_from_pem_ecdsa_p256_public_verifies_rfc_b24_reference() {
108+ pub_key := Key.from_pem(rfc_ec_p256_public_pem)!
109+ verify(key_test_b24_components(),
110+ 'sig-b24=("@status" "content-type" "content-digest" "content-length");created=1618884473;keyid="test-key-ecc-p256"',
111+ 'sig-b24=:wNmSUAhwb5LxtOtOpNa6W5xj067m5hFrj0XQ4fvpaCLx0NKocgPquLgyahnzDnDAUy5eCdlYUEkLIj+32oiasw==:',
112+ 'sig-b24', pub_key)!
113+}
114+
115+fn test_from_pem_ecdsa_p256_pads_short_private_scalar() {
116+ // Regression: a P-256 PEM whose `d` has a leading zero byte must
117+ // still produce a 96-byte (x||y||d) key and sign successfully.
118+ priv := Key.from_pem(short_d_p256_private_pem)!
119+ assert priv.algorithm == .ecdsa_p256_sha256
120+ assert priv.is_private
121+ assert priv.bytes.len == 96
122+ c := Components{
123+ method: 'POST'
124+ target_uri: 'https://example.com/'
125+ }
126+ p := SignatureParams{
127+ components: ['@method', '@target-uri']
128+ created: 1
129+ }
130+ out := sign(c, p, priv, 'sig1')!
131+ verify(c, out.signature_input, out.signature, 'sig1', priv)!
132+}
133+
134+fn test_pad_left_pads_to_width_and_rejects_overflow() {
135+ assert pad_left([u8(0x01)], 4)! == [u8(0x00), 0x00, 0x00, 0x01]
136+ assert pad_left([u8(0x01), 0x02, 0x03, 0x04], 4)! == [u8(0x01), 0x02, 0x03, 0x04]
137+ if _ := pad_left([u8(0x01), 0x02, 0x03, 0x04, 0x05], 4) {
138+ assert false, 'must reject scalars wider than the curve'
139+ } else {
140+ assert err is MalformedMessage
141+ }
142+}
143+
144+fn test_from_pem_rejects_garbage() {
145+ if _ := Key.from_pem('not a PEM block') {
146+ assert false, 'must reject non-PEM input'
147+ } else {
148+ assert err is MalformedMessage
149+ }
150+}
151+
152+fn test_from_pem_rejects_unsupported_block_type() {
153+ src := '-----BEGIN RSA PRIVATE KEY-----\nMIIEvg==\n-----END RSA PRIVATE KEY-----'
154+ if _ := Key.from_pem(src) {
155+ assert false, 'must reject RSA PEM (no RSA support in this module)'
156+ } else {
157+ assert err is MalformedMessage
158+ }
159+}
@@ -0,0 +1,164 @@
1+// Byte-exact test cases from RFC 9421 Appendix B. Deterministic
2+// algorithms (Ed25519, HMAC) reproduce the reference signature
3+// exactly; ECDSA can only verify the reference (RFC 9421 §B.2.4
4+// notes its non-determinism).
5+module signature
6+
7+import encoding.base64
8+import encoding.hex
9+
10+const sample_request_date = 'Tue, 20 Apr 2021 02:07:55 GMT'
11+
12+// b64u_decode is base64.url_decode with manual padding restoration —
13+// JWK encodes raw key coordinates without trailing '=' padding.
14+fn b64u_decode(s string) []u8 {
15+ mut padded := s
16+ for padded.len % 4 != 0 {
17+ padded += '='
18+ }
19+ return base64.url_decode(padded)
20+}
21+
22+// RFC 9421 §B.2.6: Ed25519 over the standard test request, byte-exact
23+// reproduction of the reference signature.
24+fn test_b26_ed25519_signature_base_matches_rfc() {
25+ c := Components{
26+ method: 'POST'
27+ path: '/foo'
28+ authority: 'example.com'
29+ fields: {
30+ 'date': ['Tue, 20 Apr 2021 02:07:55 GMT']
31+ 'content-type': ['application/json']
32+ 'content-length': ['18']
33+ }
34+ }
35+ p := SignatureParams{
36+ components: ['date', '@method', '@path', '@authority', 'content-type', 'content-length']
37+ created: 1618884473
38+ keyid: 'test-key-ed25519'
39+ }
40+ got := signature_base_string(c, p)!
41+ want := '"date": Tue, 20 Apr 2021 02:07:55 GMT\n' + '"@method": POST\n' + '"@path": /foo\n' +
42+ '"@authority": example.com\n' + '"content-type": application/json\n' +
43+ '"content-length": 18\n' +
44+ '"@signature-params": ("date" "@method" "@path" "@authority" "content-type" "content-length");created=1618884473;keyid="test-key-ed25519"'
45+ assert got == want
46+}
47+
48+fn test_b26_ed25519_signature_matches_rfc() {
49+ seed := b64u_decode('n4Ni-HpISpVObnQMW0wOhCKROaIKqKtW_2ZYb2p9KcU')
50+ priv := Key.ed25519_private(seed)
51+ c := b26_components()
52+ p := b26_params()
53+ out := sign(c, p, priv, 'sig-b26')!
54+ want_input := 'sig-b26=("date" "@method" "@path" "@authority" "content-type" "content-length");created=1618884473;keyid="test-key-ed25519"'
55+ want_signature := 'sig-b26=:wqcAqbmYJ2ji2glfAMaRy4gruYYnx2nEFN2HN6jrnDnQCK1u02Gb04v9EDgwUPiu4A0w6vuQv5lIp5WPpBKRCw==:'
56+ assert out.signature_input == want_input
57+ assert out.signature == want_signature
58+}
59+
60+fn test_b26_ed25519_verify_roundtrip() {
61+ x := b64u_decode('JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs')
62+ pub_key := Key.ed25519_public(x)
63+ verify(b26_components(),
64+ 'sig-b26=("date" "@method" "@path" "@authority" "content-type" "content-length");created=1618884473;keyid="test-key-ed25519"',
65+ 'sig-b26=:wqcAqbmYJ2ji2glfAMaRy4gruYYnx2nEFN2HN6jrnDnQCK1u02Gb04v9EDgwUPiu4A0w6vuQv5lIp5WPpBKRCw==:',
66+ 'sig-b26', pub_key)!
67+}
68+
69+fn b26_components() Components {
70+ return Components{
71+ method: 'POST'
72+ path: '/foo'
73+ authority: 'example.com'
74+ fields: {
75+ 'date': [sample_request_date]
76+ 'content-type': ['application/json']
77+ 'content-length': ['18']
78+ }
79+ }
80+}
81+
82+fn b26_params() SignatureParams {
83+ return SignatureParams{
84+ components: ['date', '@method', '@path', '@authority', 'content-type', 'content-length']
85+ created: 1618884473
86+ keyid: 'test-key-ed25519'
87+ }
88+}
89+
90+// RFC 9421 §B.2.5: HMAC-SHA256 — deterministic, byte-exact.
91+fn test_b25_hmac_sha256_matches_rfc() {
92+ secret :=
93+ base64.decode('uzvJfB4u3N0Jy4T7NZ75MDVcr8zSTInedJtkgcu46YW4XByzNJjxBdtjUkdJPBtbmHhIDi6pcl8jsasjlTMtDQ==')
94+ key := Key.hmac_sha256(secret)
95+ c := Components{
96+ authority: 'example.com'
97+ fields: {
98+ 'date': [sample_request_date]
99+ 'content-type': ['application/json']
100+ }
101+ }
102+ p := SignatureParams{
103+ components: ['date', '@authority', 'content-type']
104+ created: 1618884473
105+ keyid: 'test-shared-secret'
106+ }
107+ out := sign(c, p, key, 'sig-b25')!
108+ assert out.signature_input == 'sig-b25=("date" "@authority" "content-type");created=1618884473;keyid="test-shared-secret"'
109+ assert out.signature == 'sig-b25=:pxcQw6G3AjtMBQjwo8XzkZf/bws5LelbaMk5rGIGtE8=:'
110+ verify(c, out.signature_input, out.signature, 'sig-b25', key)!
111+}
112+
113+// RFC 9421 §B.2.4: ECDSA P-256 / SHA-256 over the test response.
114+// ECDSA is non-deterministic — only verification of the reference
115+// signature is checked here. A separate test does sign+verify roundtrip.
116+fn test_b24_ecdsa_p256_verify_rfc_signature() {
117+ x := b64u_decode('qIVYZVLCrPZHGHjP17CTW0_-D9Lfw0EkjqF7xB4FivA')
118+ y := b64u_decode('Mc4nN9LTDOBhfoUeg8Ye9WedFRhnZXZJA12Qp0zZ6F0')
119+ pub_key := Key.ecdsa_p256_public(x, y)
120+ c := b24_components()
121+ verify(c,
122+ 'sig-b24=("@status" "content-type" "content-digest" "content-length");created=1618884473;keyid="test-key-ecc-p256"',
123+ 'sig-b24=:wNmSUAhwb5LxtOtOpNa6W5xj067m5hFrj0XQ4fvpaCLx0NKocgPquLgyahnzDnDAUy5eCdlYUEkLIj+32oiasw==:',
124+ 'sig-b24', pub_key)!
125+}
126+
127+fn test_b24_ecdsa_p256_sign_verify_roundtrip() {
128+ x := b64u_decode('qIVYZVLCrPZHGHjP17CTW0_-D9Lfw0EkjqF7xB4FivA')
129+ y := b64u_decode('Mc4nN9LTDOBhfoUeg8Ye9WedFRhnZXZJA12Qp0zZ6F0')
130+ d := b64u_decode('UpuF81l-kOxbjf7T4mNSv0r5tN67Gim7rnf6EFpcYDs')
131+ priv := Key.ecdsa_p256_private(x, y, d)
132+ pub_key := Key.ecdsa_p256_public(x, y)
133+ c := b24_components()
134+ p := SignatureParams{
135+ components: ['@status', 'content-type', 'content-digest', 'content-length']
136+ created: 1618884473
137+ keyid: 'test-key-ecc-p256'
138+ }
139+ out := sign(c, p, priv, 'sig-b24')!
140+ verify(c, out.signature_input, out.signature, 'sig-b24', pub_key)!
141+}
142+
143+fn b24_components() Components {
144+ return Components{
145+ status: 200
146+ fields: {
147+ 'content-type': ['application/json']
148+ 'content-digest': [
149+ 'sha-512=:mEWXIS7MaLRuGgxOBdODa3xqM1XdEvxoYhvlCFJ41QJgJc4GTsPp29l5oGX69wWdXymyU0rjJuahq4l5aGgfLQ==:',
150+ ]
151+ 'content-length': ['23']
152+ }
153+ }
154+}
155+
156+// Sanity check: the raw Ed25519 signature is exactly 64 bytes (RFC 8032).
157+fn test_ed25519_signature_is_64_bytes() {
158+ seed := b64u_decode('n4Ni-HpISpVObnQMW0wOhCKROaIKqKtW_2ZYb2p9KcU')
159+ priv := Key.ed25519_private(seed)
160+ base := signature_base_string(b26_components(), b26_params())!
161+ raw := sign_base(base.bytes(), priv)!
162+ assert raw.len == 64
163+ _ = hex.encode(raw)
164+}
@@ -0,0 +1,190 @@
1+// HTTP Message Signatures (RFC 9421) - module entry point.
2+//
3+// Two API layers:
4+//
5+// * **Components-based** - `signature_base_string`, `sign`, `verify`
6+// work over a generic `Components` value. Use this when you have
7+// parsed the HTTP message yourself or when signing offline.
8+//
9+// * **`http.Request` / `http.Response` integration** - the
10+// `sign_request`, `verify_request`, `sign_response`,
11+// `verify_response` helpers wrap the components layer for code
12+// that already speaks `vlib/net/http`.
13+//
14+// The output of `sign` is the literal pair of header values you set
15+// on the message - one for `Signature-Input`, one for `Signature` -
16+// not a higher-level "signed message" object. Symmetry is the goal -
17+// the verifier reparses the same headers and recomputes the base.
18+module signature
19+
20+// SignedHeaders bundles the two HTTP header values produced by
21+// `sign`. The signer attaches these as `Signature-Input` and
22+// `Signature` respectively.
23+pub struct SignedHeaders {
24+pub:
25+ signature_input string
26+ signature string
27+}
28+
29+// sign computes the signature base, signs it with `key`, and returns
30+// the two header values to attach to the message under `label`.
31+//
32+// Required parameters in `p`:
33+// * `components` - the ordered covered-components list
34+// * either `keyid` (commonly required for routing) or none (rare)
35+//
36+// `key.algorithm` selects the signing routine; if `p.alg` is also set
37+// it MUST match the algorithm of `key` (RFC 9421 §3.1 step 3).
38+pub fn sign(c Components, p SignatureParams, key Key, label string) !SignedHeaders {
39+ check_label(label)!
40+ check_alg_consistency(p, key)!
41+ mut p2 := SignatureParams{
42+ components: p.components.clone()
43+ keyid: p.keyid
44+ alg: p.alg
45+ created: p.created
46+ expires: p.expires
47+ nonce: p.nonce
48+ tag: p.tag
49+ }
50+ if p2.keyid == none {
51+ if kid := key.keyid {
52+ p2.keyid = kid
53+ }
54+ }
55+ base := signature_base_string(c, p2)!
56+ sig := sign_base(base.bytes(), key)!
57+ return SignedHeaders{
58+ signature_input: signature_input_value(label, p2)
59+ signature: signature_header_value(label, sig)
60+ }
61+}
62+
63+// VerifyOptions tweaks `verify`. `now_unix` enables the optional
64+// `expires` parameter check (any value > 0 turns it on); leave it at
65+// the default to skip the expiry check entirely. Kept as a single
66+// option struct so future toggles (clock skew tolerance, allowed
67+// algorithm list…) land here without breaking signatures.
68+@[params]
69+pub struct VerifyOptions {
70+pub:
71+ now_unix i64
72+}
73+
74+// verify checks the signature for `label` against `c` using `key`.
75+// Both header values are taken as-they-appear-on-the-wire (i.e. the
76+// raw `Signature-Input` and `Signature` field values).
77+//
78+// `label` selects which signature to check when several are present.
79+// Pass an empty string to verify the only signature - the call fails
80+// with `MalformedMessage` if zero or more than one is found. When
81+// `opts.now_unix > 0` the `expires` parameter is also enforced.
82+pub fn verify(c Components, sig_input_header string, signature_header string, label string, key Key, opts VerifyOptions) ! {
83+ entries := parse_signature_input(sig_input_header)!
84+ signatures := parse_signature(signature_header)!
85+ wanted := pick_label(entries, signatures, label)!
86+ entry := find_entry(entries, wanted) or {
87+ return MalformedMessage{
88+ reason: 'Signature-Input has no entry for label "${wanted}"'
89+ }
90+ }
91+ sig := signatures[wanted] or {
92+ return MalformedMessage{
93+ reason: 'Signature header has no entry for label "${wanted}"'
94+ }
95+ }
96+ check_alg_param(entry, key)!
97+ base := signature_base_from_entry(c, entry)!
98+ verify_base(base.bytes(), sig, key, wanted)!
99+ if opts.now_unix > 0 {
100+ if exp_v := entry.params['expires'] {
101+ if exp_v is i64 {
102+ if opts.now_unix >= exp_v {
103+ return SignatureExpired{
104+ expires: exp_v
105+ now: opts.now_unix
106+ }
107+ }
108+ }
109+ }
110+ }
111+}
112+
113+fn pick_label(entries []SignatureEntry, signatures map[string][]u8, requested string) !string {
114+ if requested != '' {
115+ return requested
116+ }
117+ if entries.len == 1 && signatures.len == 1 {
118+ return entries[0].label
119+ }
120+ if entries.len == 0 {
121+ return MalformedMessage{
122+ reason: 'Signature-Input is empty'
123+ }
124+ }
125+ return MalformedMessage{
126+ reason: 'multiple signatures present; pass a label to choose one'
127+ }
128+}
129+
130+fn find_entry(entries []SignatureEntry, label string) ?SignatureEntry {
131+ for e in entries {
132+ if e.label == label {
133+ return e
134+ }
135+ }
136+ return none
137+}
138+
139+fn check_label(label string) ! {
140+ if label == '' {
141+ return MalformedMessage{
142+ reason: 'signature label cannot be empty'
143+ }
144+ }
145+ for c in label {
146+ if !((c >= `a` && c <= `z`) || (c >= `0` && c <= `9`) || c == `-` || c == `_` || c == `*`) {
147+ return MalformedMessage{
148+ reason: 'signature label "${label}" must match the Structured Field key grammar (lowercase + digits + - _ *)'
149+ }
150+ }
151+ }
152+ first := label[0]
153+ if !((first >= `a` && first <= `z`) || first == `*`) {
154+ return MalformedMessage{
155+ reason: 'signature label "${label}" must start with a lowercase letter or "*"'
156+ }
157+ }
158+}
159+
160+fn check_alg_consistency(p SignatureParams, key Key) ! {
161+ if alg_str := p.alg {
162+ want := algorithm_from_name(alg_str) or {
163+ return UnsupportedAlgorithm{
164+ name: alg_str
165+ }
166+ }
167+ if want != key.algorithm {
168+ return MalformedMessage{
169+ reason: 'alg parameter "${alg_str}" does not match key algorithm "${key.algorithm.name()}"'
170+ }
171+ }
172+ }
173+}
174+
175+fn check_alg_param(entry SignatureEntry, key Key) ! {
176+ if alg_v := entry.params['alg'] {
177+ if alg_v is string {
178+ want := algorithm_from_name(alg_v) or {
179+ return UnsupportedAlgorithm{
180+ name: alg_v
181+ }
182+ }
183+ if want != key.algorithm {
184+ return MalformedMessage{
185+ reason: 'signature alg "${alg_v}" does not match key algorithm "${key.algorithm.name()}"'
186+ }
187+ }
188+ }
189+ }
190+}
@@ -0,0 +1,124 @@
1+// Construction of the canonical signature base string per
2+// RFC 9421 §2.5. The signature base is the data passed to the
3+// signing primitive; both signer and verifier MUST produce identical
4+// bytes here for verification to succeed.
5+//
6+// Format (one line per covered component, separated by LF):
7+//
8+// "<component>": <component-value>
9+// …
10+// "@signature-params": <inner-list>;<params>
11+//
12+// The final line is the only one without a trailing newline.
13+module signature
14+
15+// SignatureParams holds the parameters that go after the inner-list
16+// in the @signature-params line and on the Signature-Input header.
17+// `components` is the *ordered* list the verifier walks; mutating
18+// the order changes the wire bytes and breaks verification.
19+@[params]
20+pub struct SignatureParams {
21+pub mut:
22+ components []string
23+ keyid ?string
24+ alg ?string
25+ created ?i64
26+ expires ?i64
27+ nonce ?string
28+ tag ?string
29+}
30+
31+// signature_base_string returns the bytes that go into the signing
32+// primitive. RFC 9421 §2.5 step 7 forbids duplicate covered components
33+// (the verifier rejects them), so we enforce that here too.
34+pub fn signature_base_string(c Components, p SignatureParams) !string {
35+ return build_signature_base(c, p.components, serialize_signature_params(p))!
36+}
37+
38+// build_signature_base is the shared core. Both `signature_base_string`
39+// (signer side - canonical params from the struct) and the verify
40+// path (verifier side - params replayed verbatim from the wire) drive
41+// it; only the way they obtain `signature_params_value` differs.
42+fn build_signature_base(c Components, components []string, signature_params_value string) !string {
43+ mut seen := map[string]bool{}
44+ mut lines := []string{cap: components.len + 1}
45+ for name in components {
46+ if name in seen {
47+ return MalformedMessage{
48+ reason: 'covered components list contains duplicate "${name}"'
49+ }
50+ }
51+ seen[name] = true
52+ value := c.component_value(name)!
53+ lines << '"' + name + '": ' + value
54+ }
55+ lines << '"@signature-params": ' + signature_params_value
56+ return lines.join('\n')
57+}
58+
59+// serialize_signature_params formats the inner-list-with-parameters
60+// segment that goes both into the @signature-params line and into
61+// the Signature-Input header value. Parameter order is fixed
62+// (created, expires, nonce, alg, keyid, tag) for diff-stability;
63+// RFC 9421 doesn't constrain order and verifiers reparse anyway.
64+pub fn serialize_signature_params(p SignatureParams) string {
65+ mut pairs := []ParamPair{cap: 6}
66+ if v := p.created {
67+ pairs << ParamPair{
68+ name: 'created'
69+ value: v
70+ }
71+ }
72+ if v := p.expires {
73+ pairs << ParamPair{
74+ name: 'expires'
75+ value: v
76+ }
77+ }
78+ if v := p.nonce {
79+ pairs << ParamPair{
80+ name: 'nonce'
81+ value: v
82+ }
83+ }
84+ if v := p.alg {
85+ pairs << ParamPair{
86+ name: 'alg'
87+ value: v
88+ }
89+ }
90+ if v := p.keyid {
91+ pairs << ParamPair{
92+ name: 'keyid'
93+ value: v
94+ }
95+ }
96+ if v := p.tag {
97+ pairs << ParamPair{
98+ name: 'tag'
99+ value: v
100+ }
101+ }
102+ return serialize_inner_list(p.components) + serialize_params(pairs)
103+}
104+
105+// signature_input_value returns the full Signature-Input value for
106+// `label`, ready to be put into the header `Signature-Input: <…>`.
107+pub fn signature_input_value(label string, p SignatureParams) string {
108+ return label + '=' + serialize_signature_params(p)
109+}
110+
111+// signature_header_value returns the full Signature value for `label`,
112+// ready to be put into the header `Signature: <…>`.
113+pub fn signature_header_value(label string, sig []u8) string {
114+ return label + '=' + encode_byte_sequence(sig)
115+}
116+
117+// signature_base_from_entry replays the signature base for a parsed
118+// SignatureEntry. The `@signature-params` line uses the entry's raw
119+// wire bytes verbatim so verification matches exactly what the signer
120+// signed, regardless of the order in which the signer emitted its
121+// parameters.
122+fn signature_base_from_entry(c Components, entry SignatureEntry) !string {
123+ return build_signature_base(c, entry.components, entry.signature_params_value)!
124+}
@@ -0,0 +1,331 @@
1+// Sign / verify primitives - the part that actually consumes the
2+// signature base. Each branch wraps the corresponding `vlib/crypto`
3+// routine (ed25519, ecdsa, hmac) and converts between RFC 9421's
4+// preferred formats and what the V crypto modules expose:
5+//
6+// * Ed25519: raw 32-byte seed in / out (matches RFC 8032).
7+// * ECDSA: raw R‖S concatenation (RFC 9421 §3.3.4); V's `ecdsa`
8+// module talks DER, so we convert at the boundary.
9+// * HMAC: raw bytes out (RFC 9421 §3.3.3).
10+module signature
11+
12+import crypto.ecdsa
13+import crypto.ed25519
14+import crypto.hmac
15+import crypto.sha256
16+
17+const ec_p256_size = 32
18+const ec_p384_size = 48
19+
20+// sign_base computes the signature of `base` using `key`.
21+// Returns the raw signature bytes (not yet base64-encoded).
22+fn sign_base(base []u8, key Key) ![]u8 {
23+ if !key.is_private && !key.algorithm.is_mac() {
24+ return MalformedMessage{
25+ reason: 'sign requires a private key for ${key.algorithm.name()}'
26+ }
27+ }
28+ return match key.algorithm {
29+ .hmac_sha256 { hmac.new(key.bytes, base, sha256.sum, sha256.block_size) }
30+ .ed25519 { ed25519_sign(base, key.bytes)! }
31+ .ecdsa_p256_sha256 { ecdsa_sign(base, key, ec_p256_size, .prime256v1)! }
32+ .ecdsa_p384_sha384 { ecdsa_sign(base, key, ec_p384_size, .secp384r1)! }
33+ }
34+}
35+
36+// verify_base checks that `signature` is valid over `base` under `key`.
37+// Returns a typed `VerificationFailed` rather than `false`+nil so
38+// callers can distinguish "signature invalid" from "verification
39+// machinery itself failed".
40+fn verify_base(base []u8, signature []u8, key Key, label string) ! {
41+ ok := match key.algorithm {
42+ .hmac_sha256 {
43+ expected := hmac.new(key.bytes, base, sha256.sum, sha256.block_size)
44+ hmac.equal(signature, expected)
45+ }
46+ .ed25519 {
47+ ed25519_verify(base, signature, key)!
48+ }
49+ .ecdsa_p256_sha256 {
50+ ecdsa_verify(base, signature, key, ec_p256_size, .prime256v1)!
51+ }
52+ .ecdsa_p384_sha384 {
53+ ecdsa_verify(base, signature, key, ec_p384_size, .secp384r1)!
54+ }
55+ }
56+
57+ if !ok {
58+ return VerificationFailed{
59+ label: label
60+ }
61+ }
62+}
63+
64+fn ed25519_sign(base []u8, seed []u8) ![]u8 {
65+ if seed.len != ed25519.seed_size {
66+ return MalformedMessage{
67+ reason: 'Ed25519 seed must be ${ed25519.seed_size} bytes, got ${seed.len}'
68+ }
69+ }
70+ priv := ed25519.new_key_from_seed(seed)
71+ return priv.sign(base)
72+}
73+
74+fn ed25519_verify(base []u8, sig []u8, key Key) !bool {
75+ pub_key := if key.is_private {
76+ ed25519.new_key_from_seed(key.bytes).public_key()
77+ } else {
78+ ed25519.PublicKey(key.bytes.clone())
79+ }
80+ return ed25519.verify(pub_key, base, sig)
81+}
82+
83+fn ecdsa_sign(base []u8, key Key, coord_size int, curve ecdsa.Nid) ![]u8 {
84+ // Raw layout for an ECDSA private Key is x || y || d (each
85+ // `coord_size` bytes). V's ecdsa module derives x and y from d
86+ // alone, so we only need d to build the EVP key.
87+ if key.bytes.len != coord_size * 3 {
88+ return MalformedMessage{
89+ reason: 'ECDSA private key must be ${coord_size * 3} bytes (x||y||d), got ${key.bytes.len}'
90+ }
91+ }
92+ d := key.bytes[coord_size * 2..coord_size * 3]
93+ priv := ecdsa.new_key_from_seed(d, nid: curve, fixed_size: true) or {
94+ return MalformedMessage{
95+ reason: 'ECDSA new_key_from_seed failed: ${err.msg()}'
96+ }
97+ }
98+ defer {
99+ priv.free()
100+ }
101+ der := priv.sign(base) or {
102+ return MalformedMessage{
103+ reason: 'ECDSA sign failed: ${err.msg()}'
104+ }
105+ }
106+ return der_to_raw(der, coord_size)
107+}
108+
109+fn ecdsa_verify(base []u8, sig []u8, key Key, coord_size int, curve ecdsa.Nid) !bool {
110+ if sig.len != coord_size * 2 {
111+ return VerificationFailed{}
112+ }
113+ pub_key := build_ecdsa_public(key, coord_size, curve)!
114+ defer {
115+ pub_key.free()
116+ }
117+ der := raw_to_der(sig, coord_size)
118+ return pub_key.verify(base, der) or {
119+ // V's ecdsa.verify returns an error for malformed DER but
120+ // success/false for "signature does not match". We mapped
121+ // both to VerificationFailed at the call site, so swallow
122+ // the error here and return false.
123+ false
124+ }
125+}
126+
127+fn build_ecdsa_public(key Key, coord_size int, curve ecdsa.Nid) !ecdsa.PublicKey {
128+ // For a private Key we build via the seed path (V's ecdsa derives
129+ // x, y from d). For a public Key we hand-craft a SubjectPublicKeyInfo
130+ // DER from the supplied (x, y) - this is the only place where this
131+ // module talks raw ASN.1 DER.
132+ if key.is_private {
133+ if key.bytes.len != coord_size * 3 {
134+ return MalformedMessage{
135+ reason: 'ECDSA private key must be ${coord_size * 3} bytes'
136+ }
137+ }
138+ d := key.bytes[coord_size * 2..coord_size * 3]
139+ priv := ecdsa.new_key_from_seed(d, nid: curve, fixed_size: true)!
140+ pub_key := priv.public_key()!
141+ priv.free()
142+ return pub_key
143+ }
144+ if key.bytes.len != coord_size * 2 {
145+ return MalformedMessage{
146+ reason: 'ECDSA public key must be ${coord_size * 2} bytes (x||y)'
147+ }
148+ }
149+ x := key.bytes[..coord_size]
150+ y := key.bytes[coord_size..coord_size * 2]
151+ spki := build_ec_spki(x, y, curve)
152+ return ecdsa.pubkey_from_bytes(spki)!
153+}
154+
155+// der_to_raw extracts (R, S) from a DER-encoded ECDSA signature and
156+// returns R‖S left-padded to `coord_size` bytes each. RFC 3279 §2.2.3
157+// shape is: SEQUENCE { INTEGER R, INTEGER S }.
158+fn der_to_raw(der []u8, coord_size int) ![]u8 {
159+ if der.len < 8 || der[0] != 0x30 {
160+ return MalformedMessage{
161+ reason: 'ECDSA signature: malformed DER (no SEQUENCE)'
162+ }
163+ }
164+ mut idx, _ := read_der_length(der, 1)!
165+ r, idx2 := read_der_integer(der, idx)!
166+ s, _ := read_der_integer(der, idx2)!
167+ mut out := []u8{len: coord_size * 2}
168+ pad_into(mut out, 0, r, coord_size)
169+ pad_into(mut out, coord_size, s, coord_size)
170+ return out
171+}
172+
173+// raw_to_der packs R‖S back into the SEQUENCE form `ecdsa.verify`
174+// expects. Both halves are sign-extended (a 0x00 prefix is added when
175+// the high bit is set) so they remain non-negative INTEGERs.
176+fn raw_to_der(raw []u8, coord_size int) []u8 {
177+ r := strip_zeros(raw[..coord_size])
178+ s := strip_zeros(raw[coord_size..coord_size * 2])
179+ r_int := encode_der_integer(r)
180+ s_int := encode_der_integer(s)
181+ body_len := r_int.len + s_int.len
182+ mut out := []u8{cap: body_len + 8}
183+ out << 0x30
184+ out << encode_der_length(body_len)
185+ out << r_int
186+ out << s_int
187+ return out
188+}
189+
190+fn read_der_length(buf []u8, start int) !(int, int) {
191+ if start >= buf.len {
192+ return MalformedMessage{
193+ reason: 'truncated DER length'
194+ }
195+ }
196+ first := buf[start]
197+ if first < 0x80 {
198+ return start + 1, int(first)
199+ }
200+ n := int(first & 0x7f)
201+ if n == 0 || n > 4 || start + 1 + n > buf.len {
202+ return MalformedMessage{
203+ reason: 'unsupported DER long-form length (${n} bytes)'
204+ }
205+ }
206+ mut len := u32(0)
207+ for i in 0 .. n {
208+ len = (len << 8) | u32(buf[start + 1 + i])
209+ }
210+ return start + 1 + n, int(len)
211+}
212+
213+fn read_der_integer(buf []u8, start int) !([]u8, int) {
214+ if start + 2 > buf.len || buf[start] != 0x02 {
215+ return MalformedMessage{
216+ reason: 'expected INTEGER tag 0x02 at offset ${start}'
217+ }
218+ }
219+ idx, len := read_der_length(buf, start + 1)!
220+ if idx + len > buf.len {
221+ return MalformedMessage{
222+ reason: 'INTEGER content runs past buffer'
223+ }
224+ }
225+ mut content_start := idx
226+ end := idx + len
227+ // DER INTEGERs are sign-extended; strip the 0x00 padding byte.
228+ for end > content_start + 1 && buf[content_start] == 0x00 {
229+ content_start++
230+ }
231+ return buf[content_start..end].clone(), idx + len
232+}
233+
234+fn encode_der_length(n int) []u8 {
235+ if n < 0x80 {
236+ return [u8(n)]
237+ }
238+ mut bytes := []u8{cap: 4}
239+ mut x := n
240+ for x > 0 {
241+ bytes.prepend(u8(x & 0xff))
242+ x >>= 8
243+ }
244+ mut out := []u8{cap: bytes.len + 1}
245+ out << u8(0x80 | bytes.len)
246+ out << bytes
247+ return out
248+}
249+
250+fn encode_der_integer(content []u8) []u8 {
251+ // Pad with 0x00 if the high bit is set, otherwise the value would
252+ // be interpreted as a negative integer.
253+ mut body := content.clone()
254+ if body.len == 0 {
255+ body = [u8(0x00)]
256+ } else if body[0] & 0x80 != 0 {
257+ body.prepend(u8(0x00))
258+ }
259+ mut out := []u8{cap: body.len + 4}
260+ out << 0x02
261+ out << encode_der_length(body.len)
262+ out << body
263+ return out
264+}
265+
266+fn strip_zeros(b []u8) []u8 {
267+ mut i := 0
268+ for i < b.len - 1 && b[i] == 0 {
269+ i++
270+ }
271+ return b[i..]
272+}
273+
274+fn pad_into(mut dst []u8, dst_off int, src []u8, target int) {
275+ if src.len >= target {
276+ // Source is already at or above the curve byte size; copy the
277+ // rightmost `target` bytes (drops the sign-extension 0x00).
278+ off := src.len - target
279+ for i in 0 .. target {
280+ dst[dst_off + i] = src[off + i]
281+ }
282+ return
283+ }
284+ pad := target - src.len
285+ for i in 0 .. pad {
286+ dst[dst_off + i] = 0
287+ }
288+ for i in 0 .. src.len {
289+ dst[dst_off + pad + i] = src[i]
290+ }
291+}
292+
293+// build_ec_spki constructs a SubjectPublicKeyInfo DER for an EC public
294+// key with the given (x, y) coordinates. RFC 5280 §4.1.2.7 + RFC 5480
295+// shape:
296+//
297+// SEQUENCE {
298+// SEQUENCE { OID ecPublicKey, OID <curve> },
299+// BIT STRING (0x00 || 0x04 || x || y)
300+// }
301+fn build_ec_spki(x []u8, y []u8, curve ecdsa.Nid) []u8 {
302+ curve_oid := match curve {
303+ .prime256v1 { [u8(0x06), 0x08, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x03, 0x01, 0x07] } // 1.2.840.10045.3.1.7
304+ .secp384r1 { [u8(0x06), 0x05, 0x2B, 0x81, 0x04, 0x00, 0x22] } // 1.3.132.0.34
305+ else { []u8{} }
306+ }
307+
308+ ec_pub_oid := [u8(0x06), 0x07, 0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x02, 0x01] // 1.2.840.10045.2.1
309+ mut alg := []u8{}
310+ alg << ec_pub_oid
311+ alg << curve_oid
312+ mut alg_seq := []u8{cap: alg.len + 4}
313+ alg_seq << 0x30
314+ alg_seq << encode_der_length(alg.len)
315+ alg_seq << alg
316+ mut point := []u8{cap: 1 + x.len + y.len}
317+ point << 0x04 // uncompressed point header (RFC 5480 §2.2)
318+ point << x
319+ point << y
320+ mut bit_string := []u8{cap: point.len + 4}
321+ bit_string << 0x03
322+ bit_string << encode_der_length(point.len + 1)
323+ bit_string << 0x00 // unused-bits indicator
324+ bit_string << point
325+ mut spki := []u8{cap: alg_seq.len + bit_string.len + 4}
326+ spki << 0x30
327+ spki << encode_der_length(alg_seq.len + bit_string.len)
328+ spki << alg_seq
329+ spki << bit_string
330+ return spki
331+}
@@ -0,0 +1,396 @@
1+// Minimal Structured Field Values (RFC 8941) helpers, scoped to what
2+// the Signature-Input and Signature header fields actually use:
3+//
4+// Signature-Input: <label>=(<inner-list>);<params>, <label2>=...
5+// Signature: <label>=:<base64-bytes>:, <label2>=...
6+//
7+// We deliberately avoid a full RFC 8941 implementation — every
8+// production-quality SF parser is a few hundred lines of dispatch and
9+// edge-case handling, and 95% of that surface is unused here. If we
10+// later need general SF support, factor this into a sibling module.
11+module signature
12+
13+import encoding.base64
14+
15+// ParamValue is the typed value of a signature parameter. RFC 9421 §2.3
16+// defines the registered parameters as either Integer (`created`,
17+// `expires`) or String (`keyid`, `nonce`, `tag`, `alg`). Boolean is
18+// included for future-proofing per RFC 8941 §3.1.2.
19+pub type ParamValue = i64 | string | bool
20+
21+// SignatureEntry is one (label, covered-components, parameters) tuple
22+// parsed out of a Signature-Input header. The raw
23+// `signature_params_value` is preserved verbatim - the verifier
24+// re-emits these exact bytes into the signature base, which is what
25+// the signer signed. Re-serialising from the parsed params would
26+// introduce ordering ambiguity (RFC 8941 doesn't pin a canonical map
27+// order) and break verification of signatures produced by other
28+// stacks.
29+pub struct SignatureEntry {
30+pub:
31+ label string
32+ components []string
33+ params map[string]ParamValue
34+ signature_params_value string
35+}
36+
37+// serialize_params formats the named parameters in RFC 8941 §3.1.2 form,
38+// in the order they were inserted into the map. RFC 9421 does not
39+// constrain the order of parameters, but our output mirrors `pairs` so
40+// callers control the wire layout.
41+pub fn serialize_params(pairs []ParamPair) string {
42+ mut sb := []string{cap: pairs.len * 2}
43+ for pair in pairs {
44+ sb << ';'
45+ sb << pair.name
46+ sb << '='
47+ match pair.value {
48+ i64 { sb << pair.value.str() }
49+ string { sb << '"' + escape_string(pair.value) + '"' }
50+ bool { sb << if pair.value { '?1' } else { '?0' } }
51+ }
52+ }
53+ return sb.join('')
54+}
55+
56+// ParamPair is one parameter for `serialize_params`. We use a slice of
57+// these instead of a map because parameter order matters on the wire
58+// (it doesn't change verification - the verifier reparses - but it
59+// makes generated headers deterministic and diff-friendly).
60+pub struct ParamPair {
61+pub:
62+ name string
63+ value ParamValue
64+}
65+
66+// serialize_inner_list returns the canonical Inner List form (parens
67+// around space-separated quoted-string items) for the covered
68+// components list. Each component name is emitted as a quoted string.
69+pub fn serialize_inner_list(items []string) string {
70+ mut parts := []string{cap: items.len}
71+ for it in items {
72+ parts << '"' + escape_string(it) + '"'
73+ }
74+ return '(' + parts.join(' ') + ')'
75+}
76+
77+// escape_string applies the RFC 8941 §3.3.3 quoted-string escape rules
78+// (backslash and double-quote are the only escape-required characters).
79+fn escape_string(s string) string {
80+ if !s.contains('\\') && !s.contains('"') {
81+ return s
82+ }
83+ mut out := []u8{cap: s.len + 4}
84+ for c in s {
85+ if c == `\\` || c == `"` {
86+ out << `\\`
87+ }
88+ out << c
89+ }
90+ return out.bytestr()
91+}
92+
93+// parse_signature_input parses one Signature-Input header value into a
94+// list of entries (one per labelled signature). The grammar follows
95+// RFC 9421 §4.1 / §4.2 and RFC 8941 §3.2.
96+pub fn parse_signature_input(input string) ![]SignatureEntry {
97+ mut p := SfParser{
98+ src: input
99+ pos: 0
100+ }
101+ mut entries := []SignatureEntry{}
102+ for {
103+ p.skip_sp()
104+ if p.done() {
105+ break
106+ }
107+ label := p.parse_key()!
108+ p.expect(`=`) or {
109+ return MalformedMessage{
110+ reason: 'Signature-Input: expected "=" after label "${label}"'
111+ }
112+ }
113+ value_start := p.pos
114+ components := p.parse_inner_list()!
115+ params := p.parse_params()!
116+ value_end := p.pos
117+ entries << SignatureEntry{
118+ label: label
119+ components: components
120+ params: params
121+ signature_params_value: p.src[value_start..value_end]
122+ }
123+ p.skip_sp()
124+ if p.done() {
125+ break
126+ }
127+ p.expect(`,`) or {
128+ return MalformedMessage{
129+ reason: 'Signature-Input: expected "," between entries near offset ${p.pos}'
130+ }
131+ }
132+ }
133+ return entries
134+}
135+
136+// parse_signature parses a Signature header value into a label →
137+// signature-bytes map. Each value in the source is a `:base64:` byte
138+// sequence per RFC 8941 §3.3.5.
139+pub fn parse_signature(input string) !map[string][]u8 {
140+ mut p := SfParser{
141+ src: input
142+ pos: 0
143+ }
144+ mut out := map[string][]u8{}
145+ for {
146+ p.skip_sp()
147+ if p.done() {
148+ break
149+ }
150+ label := p.parse_key()!
151+ p.expect(`=`) or {
152+ return MalformedMessage{
153+ reason: 'Signature: expected "=" after label "${label}"'
154+ }
155+ }
156+ bytes := p.parse_byte_sequence()!
157+ out[label] = bytes
158+ p.skip_sp()
159+ if p.done() {
160+ break
161+ }
162+ p.expect(`,`) or {
163+ return MalformedMessage{
164+ reason: 'Signature: expected "," between entries near offset ${p.pos}'
165+ }
166+ }
167+ }
168+ return out
169+}
170+
171+// SfParser is a tiny hand-written cursor over a Structured Field byte
172+// string. Dedicated to the small subset of RFC 8941 we use - keeping
173+// the state on the struct lets us return precise positions in errors.
174+struct SfParser {
175+ src string
176+mut:
177+ pos int
178+}
179+
180+fn (mut p SfParser) done() bool {
181+ return p.pos >= p.src.len
182+}
183+
184+fn (mut p SfParser) peek() u8 {
185+ return if p.pos < p.src.len { p.src[p.pos] } else { 0 }
186+}
187+
188+fn (mut p SfParser) skip_sp() {
189+ for p.pos < p.src.len && (p.src[p.pos] == ` ` || p.src[p.pos] == `\t`) {
190+ p.pos++
191+ }
192+}
193+
194+fn (mut p SfParser) expect(c u8) ! {
195+ if p.pos >= p.src.len || p.src[p.pos] != c {
196+ return error('expected "${rune(c)}"')
197+ }
198+ p.pos++
199+}
200+
201+// parse_key parses a Dictionary key (RFC 8941 §3.2 + §3.1.2). Per
202+// RFC 9421 §2.3, signature labels are the same lexical form.
203+fn (mut p SfParser) parse_key() !string {
204+ if p.done() {
205+ return MalformedMessage{
206+ reason: 'unexpected end of input expecting key'
207+ }
208+ }
209+ c := p.peek()
210+ if !(c == `*` || (c >= `a` && c <= `z`)) {
211+ return MalformedMessage{
212+ reason: 'invalid key start byte 0x${c.hex()} at offset ${p.pos}'
213+ }
214+ }
215+ start := p.pos
216+ for p.pos < p.src.len {
217+ ch := p.src[p.pos]
218+ if (ch >= `a` && ch <= `z`) || (ch >= `0` && ch <= `9`) || ch == `_`
219+ || ch == `-` || ch == `.` || ch == `*` {
220+ p.pos++
221+ } else {
222+ break
223+ }
224+ }
225+ return p.src[start..p.pos]
226+}
227+
228+fn (mut p SfParser) parse_inner_list() ![]string {
229+ p.expect(`(`) or {
230+ return MalformedMessage{
231+ reason: 'expected "(" starting inner list at offset ${p.pos}'
232+ }
233+ }
234+ mut items := []string{}
235+ for {
236+ p.skip_sp()
237+ if p.done() {
238+ return MalformedMessage{
239+ reason: 'unterminated inner list'
240+ }
241+ }
242+ if p.peek() == `)` {
243+ p.pos++
244+ return items
245+ }
246+ items << p.parse_string()!
247+ // A list item may itself carry parameters (`;k=v`); RFC 9421's
248+ // covered-components list never sets these, but RFC 8941 allows
249+ // them so we silently skip them to stay forward-compatible.
250+ for p.pos < p.src.len && p.src[p.pos] == `;` {
251+ p.pos++
252+ p.parse_key()!
253+ if p.pos < p.src.len && p.src[p.pos] == `=` {
254+ p.pos++
255+ p.parse_bare_item()!
256+ }
257+ }
258+ }
259+ return items
260+}
261+
262+// parse_params consumes `;name=value` pairs starting at the current
263+// position and returns them as a name-keyed map. Callers that need
264+// the original wire-ordered serialisation must work from the raw
265+// substring rather than this map (RFC 8941 doesn't pin a canonical
266+// re-emission order).
267+fn (mut p SfParser) parse_params() !map[string]ParamValue {
268+ mut m := map[string]ParamValue{}
269+ for p.pos < p.src.len && p.src[p.pos] == `;` {
270+ p.pos++
271+ p.skip_sp()
272+ name := p.parse_key()!
273+ mut value := ParamValue(true)
274+ if p.pos < p.src.len && p.src[p.pos] == `=` {
275+ p.pos++
276+ value = p.parse_bare_item()!
277+ }
278+ m[name] = value
279+ }
280+ return m
281+}
282+
283+fn (mut p SfParser) parse_bare_item() !ParamValue {
284+ if p.done() {
285+ return MalformedMessage{
286+ reason: 'unexpected end of input'
287+ }
288+ }
289+ c := p.peek()
290+ if c == `"` {
291+ return ParamValue(p.parse_string()!)
292+ }
293+ if c == `?` {
294+ p.pos++
295+ if p.pos >= p.src.len {
296+ return MalformedMessage{
297+ reason: 'truncated boolean literal'
298+ }
299+ }
300+ b := p.src[p.pos]
301+ if b != `0` && b != `1` {
302+ return MalformedMessage{
303+ reason: 'invalid boolean literal "?${rune(b)}"'
304+ }
305+ }
306+ p.pos++
307+ return ParamValue(b == `1`)
308+ }
309+ if c == `-` || (c >= `0` && c <= `9`) {
310+ return ParamValue(p.parse_integer()!)
311+ }
312+ return MalformedMessage{
313+ reason: 'unsupported bare item byte 0x${c.hex()} at offset ${p.pos} (this module models only string/integer/boolean parameter values)'
314+ }
315+}
316+
317+fn (mut p SfParser) parse_string() !string {
318+ p.expect(`"`) or {
319+ return MalformedMessage{
320+ reason: 'expected quoted string at offset ${p.pos}'
321+ }
322+ }
323+ mut out := []u8{}
324+ for p.pos < p.src.len {
325+ c := p.src[p.pos]
326+ if c == `"` {
327+ p.pos++
328+ return out.bytestr()
329+ }
330+ if c == `\\` {
331+ p.pos++
332+ if p.pos >= p.src.len {
333+ return MalformedMessage{
334+ reason: 'truncated escape sequence in string'
335+ }
336+ }
337+ n := p.src[p.pos]
338+ if n != `\\` && n != `"` {
339+ return MalformedMessage{
340+ reason: 'invalid escape "\\${rune(n)}" in string'
341+ }
342+ }
343+ out << n
344+ p.pos++
345+ continue
346+ }
347+ out << c
348+ p.pos++
349+ }
350+ return MalformedMessage{
351+ reason: 'unterminated string'
352+ }
353+}
354+
355+fn (mut p SfParser) parse_integer() !i64 {
356+ start := p.pos
357+ if p.pos < p.src.len && p.src[p.pos] == `-` {
358+ p.pos++
359+ }
360+ digits_start := p.pos
361+ for p.pos < p.src.len && p.src[p.pos] >= `0` && p.src[p.pos] <= `9` {
362+ p.pos++
363+ }
364+ if p.pos == digits_start {
365+ return MalformedMessage{
366+ reason: 'integer literal with no digits at offset ${start}'
367+ }
368+ }
369+ return p.src[start..p.pos].i64()
370+}
371+
372+fn (mut p SfParser) parse_byte_sequence() ![]u8 {
373+ p.expect(`:`) or {
374+ return MalformedMessage{
375+ reason: 'expected ":" starting byte sequence at offset ${p.pos}'
376+ }
377+ }
378+ start := p.pos
379+ for p.pos < p.src.len && p.src[p.pos] != `:` {
380+ p.pos++
381+ }
382+ if p.pos >= p.src.len {
383+ return MalformedMessage{
384+ reason: 'unterminated byte sequence'
385+ }
386+ }
387+ encoded := p.src[start..p.pos]
388+ p.pos++ // closing ':'
389+ return base64.decode(encoded)
390+}
391+
392+// encode_byte_sequence produces the wire form `:base64:` used inside
393+// the Signature header.
394+pub fn encode_byte_sequence(bytes []u8) string {
395+ return ':' + base64.encode(bytes) + ':'
396+}
@@ -0,0 +1,219 @@
1+// Tests for the small RFC 8941 subset implemented in
2+// `structured_field.v`. We don't test general SF parsing - only what
3+// HTTP signatures actually use - and pin the output bytes that
4+// matter for interop.
5+module signature
6+
7+fn test_serialize_inner_list_quotes_each_item() {
8+ got := serialize_inner_list(['@method', 'date', '@path'])
9+ assert got == '("@method" "date" "@path")'
10+}
11+
12+fn test_serialize_inner_list_handles_empty() {
13+ assert serialize_inner_list([]string{}) == '()'
14+}
15+
16+fn test_serialize_params_keeps_input_order() {
17+ pairs := [
18+ ParamPair{
19+ name: 'created'
20+ value: i64(1)
21+ },
22+ ParamPair{
23+ name: 'keyid'
24+ value: 'k1'
25+ },
26+ ]
27+ got := serialize_params(pairs)
28+ assert got == ';created=1;keyid="k1"'
29+}
30+
31+fn test_serialize_params_escapes_quotes_in_strings() {
32+ pairs := [
33+ ParamPair{
34+ name: 'tag'
35+ value: 'has"quote'
36+ },
37+ ]
38+ assert serialize_params(pairs) == ';tag="has\\"quote"'
39+}
40+
41+fn test_serialize_params_emits_boolean_short_form() {
42+ pairs := [
43+ ParamPair{
44+ name: 'flag'
45+ value: true
46+ },
47+ ParamPair{
48+ name: 'other'
49+ value: false
50+ },
51+ ]
52+ assert serialize_params(pairs) == ';flag=?1;other=?0'
53+}
54+
55+fn test_parse_signature_input_single_entry() {
56+ src := 'sig1=("@method" "host");created=1618884473;keyid="my-key"'
57+ entries := parse_signature_input(src)!
58+ assert entries.len == 1
59+ e := entries[0]
60+ assert e.label == 'sig1'
61+ assert e.components == ['@method', 'host']
62+ assert e.params['created'] or { ParamValue(i64(0)) } == ParamValue(i64(1618884473))
63+ assert e.params['keyid'] or { ParamValue('') } == ParamValue('my-key')
64+}
65+
66+fn test_parse_signature_input_multiple_entries() {
67+ src := 'sig-a=("@method");created=1, sig-b=("date" "@authority");keyid="k"'
68+ entries := parse_signature_input(src)!
69+ assert entries.len == 2
70+ assert entries[0].label == 'sig-a'
71+ assert entries[0].components == ['@method']
72+ assert entries[1].label == 'sig-b'
73+ assert entries[1].components == ['date', '@authority']
74+}
75+
76+fn test_parse_signature_returns_decoded_bytes() {
77+ src := 'sig1=:cGF5bG9hZA==:'
78+ parsed := parse_signature(src)!
79+ assert parsed.len == 1
80+ bytes := parsed['sig1'] or { []u8{} }
81+ assert bytes.bytestr() == 'payload'
82+}
83+
84+fn test_parse_signature_input_rejects_uppercase_label() {
85+ if _ := parse_signature_input('Sig1=()') {
86+ assert false, 'uppercase label must be rejected by SF parser'
87+ } else {
88+ assert err is MalformedMessage
89+ }
90+}
91+
92+fn test_parse_signature_input_preserves_signature_params_value() {
93+ src := 'sig1=("@method");created=1618884473;keyid="my-key"'
94+ entries := parse_signature_input(src)!
95+ // The verifier replays this verbatim into the signature base so
96+ // it MUST equal the input substring (including the inner list).
97+ assert entries[0].signature_params_value == '("@method");created=1618884473;keyid="my-key"'
98+}
99+
100+fn test_verify_accepts_non_canonical_param_order() {
101+ // External signers might emit `;keyid=...;created=...` (keyid
102+ // before created). Our re-canonicalised order is the opposite, so
103+ // without the verbatim replay the bases would differ. Use HMAC so
104+ // we can synthesise a wire signature ourselves.
105+ c := Components{
106+ method: 'POST'
107+ }
108+ key := Key.hmac_sha256('shared-secret'.bytes())
109+ // Hand-build the base with keyid first, sign it, then verify
110+ // using the same wire order.
111+ base := '"@method": POST\n"@signature-params": ("@method");keyid="k1";created=42'
112+ sig := sign_base(base.bytes(), key)!
113+ sig_input := 'sig1=("@method");keyid="k1";created=42'
114+ sig_header := signature_header_value('sig1', sig)
115+ verify(c, sig_input, sig_header, 'sig1', key)!
116+}
117+
118+fn test_signature_base_string_rejects_duplicate_components() {
119+ c := Components{
120+ method: 'GET'
121+ fields: {
122+ 'host': ['example.com']
123+ }
124+ }
125+ p := SignatureParams{
126+ components: ['@method', '@method']
127+ }
128+ if _ := signature_base_string(c, p) {
129+ assert false, 'duplicate components must error'
130+ } else {
131+ assert err is MalformedMessage
132+ assert err.msg().contains('duplicate')
133+ }
134+}
135+
136+fn test_signature_base_string_errors_on_missing_field() {
137+ c := Components{
138+ method: 'GET'
139+ }
140+ p := SignatureParams{
141+ components: ['@method', 'date']
142+ }
143+ if _ := signature_base_string(c, p) {
144+ assert false, 'missing component must error'
145+ } else {
146+ assert err is MalformedMessage
147+ assert err.msg().contains('"date"')
148+ }
149+}
150+
151+fn test_components_lowercases_field_names_and_adds_in_order() {
152+ mut c := Components{}
153+ c.add_field('Accept', 'text/html')
154+ c.add_field('accept', 'application/json')
155+ values := c.fields['accept']
156+ assert values.len == 2
157+ assert values[0] == 'text/html'
158+ assert values[1] == 'application/json'
159+}
160+
161+fn test_field_value_joins_multi_value_with_comma_space() {
162+ c := Components{
163+ fields: {
164+ 'accept': ['text/html', 'application/json']
165+ }
166+ }
167+ p := SignatureParams{
168+ components: ['accept']
169+ }
170+ got := signature_base_string(c, p)!
171+ assert got.starts_with('"accept": text/html, application/json\n')
172+}
173+
174+fn test_field_value_trims_ows() {
175+ c := Components{
176+ fields: {
177+ 'foo': [' hello ', '\tworld\t']
178+ }
179+ }
180+ p := SignatureParams{
181+ components: ['foo']
182+ }
183+ got := signature_base_string(c, p)!
184+ assert got.starts_with('"foo": hello, world\n')
185+}
186+
187+fn test_query_with_leading_question_mark_is_preserved() {
188+ c := Components{
189+ query: '?a=1'
190+ }
191+ p := SignatureParams{
192+ components: ['@query']
193+ }
194+ got := signature_base_string(c, p)!
195+ assert got.starts_with('"@query": ?a=1\n')
196+}
197+
198+fn test_empty_query_emits_single_question_mark() {
199+ c := Components{
200+ query: ''
201+ }
202+ p := SignatureParams{
203+ components: ['@query']
204+ }
205+ got := signature_base_string(c, p)!
206+ // RFC 9421 §2.2.7: empty query is the single character "?".
207+ assert got.starts_with('"@query": ?\n')
208+}
209+
210+fn test_authority_is_lowercased() {
211+ c := Components{
212+ authority: 'EXAMPLE.com'
213+ }
214+ p := SignatureParams{
215+ components: ['@authority']
216+ }
217+ got := signature_base_string(c, p)!
218+ assert got.starts_with('"@authority": example.com\n')
219+}
@@ -0,0 +1,31 @@
1+{
2+ "title": "RFC 9421 §B.2.4 — Signing a Response Using ecdsa-p256-sha256",
3+ "input": {
4+ "status": 200,
5+ "fields": {
6+ "content-type": "application/json",
7+ "content-digest": "sha-512=:mEWXIS7MaLRuGgxOBdODa3xqM1XdEvxoYhvlCFJ41QJgJc4GTsPp29l5oGX69wWdXymyU0rjJuahq4l5aGgfLQ==:",
8+ "content-length": "23"
9+ },
10+ "components": ["@status", "content-type", "content-digest", "content-length"],
11+ "params": {
12+ "created": 1618884473,
13+ "keyid": "test-key-ecc-p256"
14+ },
15+ "label": "sig-b24",
16+ "key": {
17+ "alg": "ecdsa-p256-sha256",
18+ "x_b64u": "qIVYZVLCrPZHGHjP17CTW0_-D9Lfw0EkjqF7xB4FivA",
19+ "y_b64u": "Mc4nN9LTDOBhfoUeg8Ye9WedFRhnZXZJA12Qp0zZ6F0",
20+ "d_b64u": "UpuF81l-kOxbjf7T4mNSv0r5tN67Gim7rnf6EFpcYDs"
21+ }
22+ },
23+ "intermediates": {
24+ "signature_base": "\"@status\": 200\n\"content-type\": application/json\n\"content-digest\": sha-512=:mEWXIS7MaLRuGgxOBdODa3xqM1XdEvxoYhvlCFJ41QJgJc4GTsPp29l5oGX69wWdXymyU0rjJuahq4l5aGgfLQ==:\n\"content-length\": 23\n\"@signature-params\": (\"@status\" \"content-type\" \"content-digest\" \"content-length\");created=1618884473;keyid=\"test-key-ecc-p256\""
25+ },
26+ "output": {
27+ "signature_input": "sig-b24=(\"@status\" \"content-type\" \"content-digest\" \"content-length\");created=1618884473;keyid=\"test-key-ecc-p256\"",
28+ "signature": "sig-b24=:wNmSUAhwb5LxtOtOpNa6W5xj067m5hFrj0XQ4fvpaCLx0NKocgPquLgyahnzDnDAUy5eCdlYUEkLIj+32oiasw==:",
29+ "note": "ECDSA is non-deterministic — the signature value above is verifiable but cannot be reproduced byte-for-byte."
30+ }
31+}
@@ -0,0 +1,28 @@
1+{
2+ "title": "RFC 9421 §B.2.5 — Signing a Request Using hmac-sha256",
3+ "input": {
4+ "method": "POST",
5+ "authority": "example.com",
6+ "fields": {
7+ "date": "Tue, 20 Apr 2021 02:07:55 GMT",
8+ "content-type": "application/json"
9+ },
10+ "components": ["date", "@authority", "content-type"],
11+ "params": {
12+ "created": 1618884473,
13+ "keyid": "test-shared-secret"
14+ },
15+ "label": "sig-b25",
16+ "key": {
17+ "alg": "hmac-sha256",
18+ "k_b64": "uzvJfB4u3N0Jy4T7NZ75MDVcr8zSTInedJtkgcu46YW4XByzNJjxBdtjUkdJPBtbmHhIDi6pcl8jsasjlTMtDQ=="
19+ }
20+ },
21+ "intermediates": {
22+ "signature_base": "\"date\": Tue, 20 Apr 2021 02:07:55 GMT\n\"@authority\": example.com\n\"content-type\": application/json\n\"@signature-params\": (\"date\" \"@authority\" \"content-type\");created=1618884473;keyid=\"test-shared-secret\""
23+ },
24+ "output": {
25+ "signature_input": "sig-b25=(\"date\" \"@authority\" \"content-type\");created=1618884473;keyid=\"test-shared-secret\"",
26+ "signature": "sig-b25=:pxcQw6G3AjtMBQjwo8XzkZf/bws5LelbaMk5rGIGtE8=:"
27+ }
28+}
@@ -0,0 +1,31 @@
1+{
2+ "title": "RFC 9421 §B.2.6 — Signing a Request Using ed25519",
3+ "input": {
4+ "method": "POST",
5+ "path": "/foo",
6+ "authority": "example.com",
7+ "fields": {
8+ "date": "Tue, 20 Apr 2021 02:07:55 GMT",
9+ "content-type": "application/json",
10+ "content-length": "18"
11+ },
12+ "components": ["date", "@method", "@path", "@authority", "content-type", "content-length"],
13+ "params": {
14+ "created": 1618884473,
15+ "keyid": "test-key-ed25519"
16+ },
17+ "label": "sig-b26",
18+ "key": {
19+ "alg": "ed25519",
20+ "d_b64u": "n4Ni-HpISpVObnQMW0wOhCKROaIKqKtW_2ZYb2p9KcU",
21+ "x_b64u": "JrQLj5P_89iXES9-vFgrIy29clF9CC_oPPsw3c5D0bs"
22+ }
23+ },
24+ "intermediates": {
25+ "signature_base": "\"date\": Tue, 20 Apr 2021 02:07:55 GMT\n\"@method\": POST\n\"@path\": /foo\n\"@authority\": example.com\n\"content-type\": application/json\n\"content-length\": 18\n\"@signature-params\": (\"date\" \"@method\" \"@path\" \"@authority\" \"content-type\" \"content-length\");created=1618884473;keyid=\"test-key-ed25519\""
26+ },
27+ "output": {
28+ "signature_input": "sig-b26=(\"date\" \"@method\" \"@path\" \"@authority\" \"content-type\" \"content-length\");created=1618884473;keyid=\"test-key-ed25519\"",
29+ "signature": "sig-b26=:wqcAqbmYJ2ji2glfAMaRy4gruYYnx2nEFN2HN6jrnDnQCK1u02Gb04v9EDgwUPiu4A0w6vuQv5lIp5WPpBKRCw==:"
30+ }
31+}