medvednikov

/

vqPublic
0 commits21 issue12 pull requests0 contributorsDiscussionsProjectsCI

net.http.signature: add HTTP Message Signatures (RFC 9421) module #17

Opendavlgdwants to mergepr/27113intomaster· last May 8
19 files changed+3114-0
examples/http_signature.vnew file+88-0

@@ -0,0 +1,88 @@

1+// HTTP Message Signatures example: signs an outbound request with

2+// Ed25519 (using the RFC 9421 §B.1.4 test key in PEM form) and then

3+// verifies the result with the matching public key.

4+//

5+// Run with: v run examples/http_signature.v

6+import net.http

7+import net.http.signature

8+

9+const ed25519_private_pem = '-----BEGIN PRIVATE KEY-----

10+MC4CAQAwBQYDK2VwBCIEIJ+DYvh6SEqVTm50DFtMDoQikTmiCqirVv9mWG9qfSnF

11+-----END PRIVATE KEY-----'

12+

13+const ed25519_public_pem = '-----BEGIN PUBLIC KEY-----

14+MCowBQYDK2VwAyEAJrQLj5P/89iXES9+vFgrIy29clF9CC/oPPsw3c5D0bs=

15+-----END PUBLIC KEY-----'

16+

17+fn main() {

18+ demo_sign_and_verify_request()!

19+ demo_two_signatures()!

20+}

21+

22+// demo_sign_and_verify_request walks through the common path: the

23+// client signs a request before sending; the server verifies before

24+// processing.

25+fn demo_sign_and_verify_request() ! {

26+ priv := signature.Key.from_pem(ed25519_private_pem)!.with_keyid('test-key-ed25519')

27+ pub_key := signature.Key.from_pem(ed25519_public_pem)!

28+

29+ mut req := http.Request{

30+ method: .post

31+ url: 'https://example.com/foo'

32+ }

33+ req.header.add_custom('Host', 'example.com')!

34+ req.header.add_custom('Date', 'Tue, 20 Apr 2021 02:07:55 GMT')!

35+ req.header.add_custom('Content-Type', 'application/json')!

36+ req.header.add_custom('Content-Length', '18')!

37+

38+ signature.sign_request(mut req, priv,

39+ components: ['date', '@method', '@path', '@authority', 'content-type', 'content-length']

40+ created: 1618884473

41+ label: 'sig-b26'

42+ )!

43+

44+ si := req.header.get_custom('Signature-Input') or { '' }

45+ sig := req.header.get_custom('Signature') or { '' }

46+ println('Signature-Input: ${si}')

47+ println('Signature: ${sig}')

48+

49+ signature.verify_request(req, pub_key)!

50+ println(' ✓ verified with the matching public key')

51+}

52+

53+// demo_two_signatures shows a TLS-terminating proxy scenario: the

54+// client signs the original request, the proxy adds its own signature

55+// over the same message under a different label, and the backend

56+// verifies both independently.

57+fn demo_two_signatures() ! {

58+ client_key := signature.Key.hmac_sha256('client-shared-secret'.bytes()).with_keyid('client')

59+ proxy_key := signature.Key.hmac_sha256('proxy-shared-secret'.bytes()).with_keyid('proxy')

60+

61+ mut req := http.Request{

62+ method: .get

63+ url: 'https://api.example.com/orders/42'

64+ }

65+ req.header.add_custom('Host', 'api.example.com')!

66+ req.header.add_custom('Date', 'Tue, 20 Apr 2021 02:07:55 GMT')!

67+

68+ // `created` defaults to `time.now().unix()` when not set —

69+ // fine for a real client. Pinned here so the example output

70+ // is reproducible across runs.

71+ signature.sign_request(mut req, client_key,

72+ components: ['@method', '@target-uri', 'date']

73+ label: 'client-sig'

74+ created: 1618884473

75+ )!

76+ signature.sign_request(mut req, proxy_key,

77+ components: ['@method', '@authority', 'date']

78+ label: 'proxy-sig'

79+ created: 1618884480

80+ )!

81+

82+ si := req.header.get_custom('Signature-Input') or { '' }

83+ println('\nMerged Signature-Input: ${si}')

84+

85+ signature.verify_request(req, client_key, label: 'client-sig')!

86+ signature.verify_request(req, proxy_key, label: 'proxy-sig')!

87+ println(' ✓ both labelled signatures verified')

88+}

vlib/net/http/signature/README.mdnew file+124-0

@@ -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.

vlib/net/http/signature/algorithms.vnew file+44-0

@@ -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+}

vlib/net/http/signature/components.vnew file+181-0

@@ -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+}

vlib/net/http/signature/components_test.vnew file+76-0

@@ -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+}

vlib/net/http/signature/errors.vnew file+68-0

@@ -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+}

vlib/net/http/signature/http_message.vnew file+267-0

@@ -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+}

vlib/net/http/signature/http_message_test.vnew file+288-0

@@ -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+}

vlib/net/http/signature/key.vnew file+305-0

@@ -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+}

vlib/net/http/signature/key_test.vnew file+159-0

@@ -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+}

vlib/net/http/signature/rfc9421_test.vnew file+164-0

@@ -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+}

vlib/net/http/signature/signature.vnew file+190-0

@@ -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+}

vlib/net/http/signature/signature_base.vnew file+124-0

@@ -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+}

vlib/net/http/signature/signing.vnew file+331-0

@@ -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+}

vlib/net/http/signature/structured_field.vnew file+396-0

@@ -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+}

vlib/net/http/signature/structured_field_test.vnew file+219-0

@@ -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+}

vlib/net/http/signature/tests/rfc9421/B_2_4_ecdsa.jsonnew file+31-0

@@ -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+}

vlib/net/http/signature/tests/rfc9421/B_2_5_hmac.jsonnew file+28-0

@@ -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+}

vlib/net/http/signature/tests/rfc9421/B_2_6_ed25519.jsonnew file+31-0

@@ -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+}