Skip to content

net.http.signature #

net.http.signature — HTTP Message Signatures (RFC 9421)

Sign and verify HTTP requests and responses per [RFC 9421][rfc9421] — the standard that replaces the long-running Signature / Signature-Input drafts and underpins production deployments at major CDNs, mTLS proxies, mutual API authentication, and the upcoming [Web Bot Auth][web-bot-auth] work.

[rfc9421]: https://www.rfc-editor.org/rfc/rfc9421.html [web-bot-auth]: https://datatracker.ietf.org/doc/draft-meunier-web-bot-auth-architecture/

Quick start

import time
import net.http
import net.http.signature

// Sign an outbound request. `created` defaults to time.now().unix().
mut req := http.new_request(.post, 'https://example.com/items', '{}')
req.header.add_custom('Date', 'Tue, 20 Apr 2021 02:07:55 GMT')!
req.header.add_custom('Content-Type', 'application/json')!
// RFC 9530 digest of the request content, here the two bytes `{}`.
req.header.add_custom('Content-Digest', 'sha-256=:RBNvo1WzZ4oRRq0W9+hknpT7T8If536DEMBg9hyq/4o=:')!

priv := signature.Key.from_pem(alice_private_pem)!.with_keyid('alice')
signature.sign_request(mut req, priv,
    components: ['@method', '@target-uri', '@authority', 'date', 'content-type',
        'content-digest']
)!
// req now carries Signature-Input and Signature header fields.

// On the receiving side, verify with the public key resolved from `keyid`,
// then check the signed digest against the bytes you actually received.
pub_key := signature.Key.from_pem(alice_public_pem)!
signature.verify_request(req, pub_key, now_unix: time.now().unix())!

Key.from_pem accepts the canonical PKCS#8 / SPKI / SEC1 PEM blocks that openssl genpkey and friends produce. For ECDSA keys it goes through crypto.ecdsa, whose PEM/DER entry points currently need -d use_openssl; Ed25519 PEM parsing and every raw-coordinate constructor (Key.ed25519_private(seed), Key.ecdsa_p256_public(x, y), …) work on the default build and are the way in when you have JWK-style key material.

The now_unix option rejects signatures created after the supplied time and enforces the optional expires parameter; pass 0 (the default) to skip time validation.

verify_request requires @method, @target-uri, @authority and content-digest by default; for responses the default is @status and content-digest. Default sign_request and sign_response calls require an existing Content-Digest field and cover it.

That digest requirement does not depend on the message actually carrying content: a bodyless message needs the RFC 9530 digest of empty content (sha-256=:47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=:). Deriving the policy from the received body instead would let an attacker drop the requirement by dropping the body, since nothing authenticates its absence.

Applications must also validate the digest against the received bytes — this module verifies that the field is signed, not that it matches the content. A different authorization profile can be selected explicitly with components when signing and required_components when verifying. The lower-level verify API performs cryptographic verification only unless its required_components option is set.

Algorithms

IANA name Status Backed by
hmac-sha256 ✅ crypto.hmac + crypto.sha256
ecdsa-p256-sha256 ✅ crypto.ecdsa (P-256)
ecdsa-p384-sha384 ✅ crypto.ecdsa (P-384)
ed25519 ✅ crypto.ed25519

rsa-pss-sha512 and rsa-v1_5-sha256 are intentionally out of scope — vlib/crypto does not yet ship an RSA implementation. Adding them is mechanical once it does.

Covered components

All derived components from RFC 9421 §2.2 are implemented:

@method, @target-uri, @authority, @scheme, @request-target, @path, @query, @status.

Plain HTTP fields are matched by lowercased field name, with multi-value fields joined by ", " and OWS trimmed at the boundaries (RFC 9421 §2.1).

@query-param (RFC 9421 §2.2.8), structured-field re-serialisation (sf, key, bs parameters from §2.1.x), and binary-wrapped fields are deferred to a follow-up PR.

When a response covers content-length without carrying the field, sign_response inserts the body length so the peer can reconstruct the signature base. It refuses to do so on 1xx and 204, where RFC 9110 §8.6 forbids the field outright, and on 304, where the value describes the content the matching 200 would carry and so cannot be inferred from the body.

Two cases the module cannot detect, because an http.Response does not say which request it answers: a 2xx response to CONNECT must not carry the field at all, so do not cover content-length there; a response to HEAD may carry it, but the value is the length a GET would have returned, so set the field yourself before signing.

Two API layers

// Components-level - works on any HTTP-shaped data, no http.Request
// dependency. Use this offline (signing fixtures, building tests).
base := signature.signature_base_string(components, params)!
out  := signature.sign(components, params, key, 'sig1')!
signature.verify(components, sig_input, sig_value, 'sig1', key)!

// http.Request / http.Response wrappers - sugar over the above.
signature.sign_request(mut req, key, components: [...], created: now)!
signature.verify_request(req, key, now_unix: now)!

Design notes

  • No silent algorithm fallbacks. If you set the alg parameter and it doesn't match the key's algorithm, sign errors out with MalformedMessage. verify does the same on the inbound side. RFC 9421 §3.1 step 3 makes this a correctness requirement.

  • Empty keyid is allowed. RFC 9421 doesn't make keyid mandatory; some out-of-band channel (mTLS cert, JWT bearer) identifies the signer instead. sign still emits a usable signature; the verifier picks the key by other means.

  • Multiple signatures coexist. Calling sign_request twice with different labels merges the labelled entries into a single Signature-Input / Signature field per RFC 8941 §3.2 (comma- separated dictionary) — TLS-terminating proxies and federated signing scenarios both rely on this layout.

  • Extension parameters stay covered. Unknown signature parameters with token, decimal, or byte-sequence values are preserved as ExtensionParamValue entries while verification replays their exact wire representation in the signature base.

  • No clock dependency. Both created and the expiry check are driven by the caller (opts.created, opts.now_unix). Signing in bulk over historical data, deterministic test runs, and replay protection are all the caller's concern.

Test vectors

RFC 9421 Appendix B vectors are vendored under tests/rfc9421/ and exercised by rfc9421_test.v:

Section Algorithm Mode
B.2.5 hmac-sha256 bytes-exact
B.2.6 ed25519 bytes-exact
B.2.4 ecdsa-p256-sha256 verify (ECDSA non-deterministic)

http_message_test.v covers sign/verify roundtrips across all four supported algorithms (including a freshly-generated P-384 key), tampered URL rejection, missing-header rejection, expiry enforcement, multi-signature coexistence, and alg / label validation. structured_field_test.v pins the Inner List + parameter serialisation, multi-value field joining, OWS trimming, and the @query empty-vs-present semantics.

fn algorithm_from_name #

fn algorithm_from_name(s string) ?Algorithm

algorithm_from_name parses the IANA token. Returns none for algorithms outside this module's supported set so the caller can surface an UnsupportedAlgorithm error with the original token kept.

fn encode_byte_sequence #

fn encode_byte_sequence(bytes []u8) string

encode_byte_sequence produces the wire form :base64: used inside the Signature header.

fn parse_signature #

fn parse_signature(input string) !map[string][]u8

parse_signature parses a Signature header value into a label → signature-bytes map. Each value in the source is a :base64: byte sequence per RFC 8941 §3.3.5.

fn parse_signature_input #

fn parse_signature_input(input string) ![]SignatureEntry

parse_signature_input parses one Signature-Input header value into a list of entries (one per labelled signature). The grammar follows RFC 9421 §4.1 / §4.2 and RFC 8941 §3.2.

fn serialize_inner_list #

fn serialize_inner_list(items []string) !string

serialize_inner_list returns the canonical Inner List form (parens around space-separated quoted-string items) for the covered components list. Each component name is emitted as a quoted string; invalid HTTP field names or Structured Field string bytes return MalformedMessage.

fn serialize_params #

fn serialize_params(pairs []ParamPair) !string

serialize_params formats the named parameters in RFC 8941 §3.1.2 form, in the order they were inserted into the map. RFC 9421 does not constrain the order of parameters, but our output mirrors pairs so callers control the wire layout. It returns MalformedMessage when a parameter name or string value violates the RFC 8941 grammar.

fn serialize_signature_params #

fn serialize_signature_params(p SignatureParams) !string

serialize_signature_params formats the inner-list-with-parameters segment that goes both into the @signature-params line and into the Signature-Input header value. Parameter order is fixed (created, expires, nonce, alg, keyid, tag) for diff-stability; RFC 9421 doesn't constrain order and verifiers reparse anyway. Invalid Structured Field string bytes return MalformedMessage.

fn sign #

fn sign(c Components, p SignatureParams, key Key, label string) !SignedHeaders

sign computes the signature base, signs it with key, and returns the two header values to attach to the message under label.

Required parameters in p:* components - the ordered covered-components list

  • either keyid (commonly required for routing) or none (rare)

key.algorithm selects the signing routine; if p.alg is also set it MUST match the algorithm of key (RFC 9421 §3.1 step 3).

fn sign_request #

fn sign_request(mut req http.Request, key Key, opts SignRequestOptions) !

sign_request signs an HTTP request in place by appending the Signature-Input and Signature header fields. Existing values of these headers are preserved (RFC 9421 §4.3 - multiple signatures can coexist), so calling this twice with different labels yields two co-existing signatures. Automatic redirects are disabled because the signature only covers this request target and method.

created defaults to time.now().unix() when omitted, since RFC 9421 §7.2.1 RECOMMENDS the parameter for replay protection. Pass an explicit created: 0 only if you know you don't want it.

The default component profile always requires and covers Content-Digest, including on bodyless messages, because the matching verify_request policy cannot depend on the received body (see its doc comment). Supply the field - RFC 9530 defines the digest of empty content - or pass an explicit components profile to sign without it.

fn sign_response #

fn sign_response(mut resp http.Response, key Key, opts SignResponseOptions) !

sign_response signs an HTTP response in place. Like sign_request it preserves any pre-existing Signature-Input / Signature values and defaults created to the current time. The default component profile always requires and covers Content-Digest, for the same reason as sign_request. Nonzero status codes outside the HTTP 100...599 range are rejected.

fn signature_base_string #

fn signature_base_string(c Components, p SignatureParams) !string

signature_base_string returns the bytes that go into the signing primitive. RFC 9421 §2.5 step 7 forbids duplicate covered components (the verifier rejects them), so we enforce that here too.

fn signature_header_value #

fn signature_header_value(label string, sig []u8) !string

signature_header_value returns the full Signature value for label, ready to be put into the header Signature: <…>. Invalid labels return MalformedMessage.

fn signature_input_value #

fn signature_input_value(label string, p SignatureParams) !string

signature_input_value returns the full Signature-Input value for label, ready to be put into the header Signature-Input: <…>. Invalid labels or Structured Field string bytes return MalformedMessage.

fn verify #

fn verify(c Components, sig_input_header string, signature_header string, label string, key Key, opts VerifyOptions) !

verify checks the signature for label against c using key. Both header values are taken as-they-appear-on-the-wire (i.e. the raw Signature-Input and Signature field values).

label selects which signature to check when several are present. Pass an empty string to verify the only signature - the call fails with MalformedMessage if zero or more than one is found. When opts.now_unix > 0, created and the optional expires parameter are also enforced. The low-level API requires callers to set required_components when the result is used for authorization; the HTTP wrappers provide safe defaults.

fn verify_request #

fn verify_request(req http.Request, key Key, opts VerifyRequestOptions) !

verify_request verifies a labelled signature on an HTTP request. If opts.label is empty and exactly one signature is present, that one is checked. If opts.now_unix > 0, the signature time bounds are enforced. By default, coverage of @method, @target-uri, @authority and content-digest is required; pass an explicit application profile through required_components to override that policy.

The digest requirement is deliberately unconditional: selecting it from the received body would let an attacker drop the requirement together with the body. This module does not compare Content-Digest against the received bytes - the application still has to do that.

fn verify_response #

fn verify_response(resp http.Response, key Key, opts VerifyResponseOptions) !

verify_response verifies a labelled signature on an HTTP response and requires @status and content-digest coverage, whatever the received body, unless required_components is set explicitly. See verify_request for why that policy is a constant.

fn Key.ecdsa_p256_private #

fn Key.ecdsa_p256_private(x []u8, y []u8, d []u8) !Key

Key.ecdsa_p256_private wraps an ECDSA P-256 private key as raw (x, y, d) coordinates. Each coordinate is zero-padded to 32 bytes (the curve byte size). Oversized coordinates return MalformedMessage.

fn Key.ecdsa_p256_public #

fn Key.ecdsa_p256_public(x []u8, y []u8) !Key

Key.ecdsa_p256_public wraps an ECDSA P-256 public key as raw (x, y), zero-padding each coordinate to 32 bytes. Oversized coordinates return MalformedMessage.

fn Key.ecdsa_p384_private #

fn Key.ecdsa_p384_private(x []u8, y []u8, d []u8) !Key

Key.ecdsa_p384_private wraps an ECDSA P-384 private key as raw (x, y, d) coordinates. Each coordinate is zero-padded to 48 bytes. Oversized coordinates return MalformedMessage.

fn Key.ecdsa_p384_public #

fn Key.ecdsa_p384_public(x []u8, y []u8) !Key

Key.ecdsa_p384_public wraps an ECDSA P-384 public key as raw (x, y), zero-padding each coordinate to 48 bytes. Oversized coordinates return MalformedMessage.

fn Key.ed25519_private #

fn Key.ed25519_private(seed []u8) Key

Key.ed25519_private wraps a 32-byte Ed25519 seed (RFC 8032 §5.1.5). The corresponding public key can be derived on demand by the signer.

fn Key.ed25519_public #

fn Key.ed25519_public(x []u8) Key

Key.ed25519_public wraps the 32-byte Ed25519 public x-coordinate.

fn Key.from_pem #

fn Key.from_pem(pem_text string) !Key

Key.from_pem decodes a PEM-encoded key and returns a Key tagged with the algorithm inferred from the embedded OID. Supports the four PEM shapes commonly emitted by openssl genpkey / openssl ec:

  • -----BEGIN PRIVATE KEY----- (PKCS#8 — Ed25519 or ECDSA)
  • -----BEGIN EC PRIVATE KEY----- (SEC1 — ECDSA)
  • -----BEGIN PUBLIC KEY----- (SPKI — Ed25519 or ECDSA)

HMAC keys never come as PEM (they're raw shared secrets) — call Key.hmac_sha256 directly with the bytes for those.

fn Key.hmac_sha256 #

fn Key.hmac_sha256(secret []u8) !Key

Key.hmac_sha256 builds a symmetric key for the hmac-sha256 algorithm. secret must not be empty. RFC 9421 §3.3.3 recommends at least 256 bits of entropy.

type ParamValue #

type ParamValue = ExtensionParamValue | bool | i64 | string

ParamValue is the typed value of a signature parameter. RFC 9421 §2.3 defines the registered parameters as either Integer (created, expires) or String (keyid, nonce, tag, alg). Boolean and valid extension bare items are retained for application-defined parameters.

enum Algorithm #

enum Algorithm {
	hmac_sha256       // hmac-sha256       — RFC 9421 §3.3.3
	ecdsa_p256_sha256 // ecdsa-p256-sha256 — RFC 9421 §3.3.4
	ecdsa_p384_sha384 // ecdsa-p384-sha384 — RFC 9421 §3.3.5
	ed25519           // ed25519           — RFC 9421 §3.3.6
}

Algorithm names the signing or verification routine selected for a signature. The string form returned by name() is the exact token emitted on the wire as the value of the alg signature parameter.

fn (Algorithm) name #

fn (a Algorithm) name() string

name returns the IANA token for the algorithm.

fn (Algorithm) is_mac #

fn (a Algorithm) is_mac() bool

is_mac reports whether the algorithm is symmetric (MAC) rather than asymmetric (signature). MACs are signed and verified with the same key.

struct Components #

struct Components {
pub mut:
	// Derived components covered by RFC 9421 §2.2.
	method         ?string
	target_uri     ?string // full request URI, used by @target-uri
	authority      ?string // host[:port] of the request
	scheme         ?string // "http" | "https"
	request_target ?string // method-line target as-on-the-wire
	path           ?string // path component of the URI
	query          ?string // query component INCLUDING the leading "?"
	// status applies to response signing/verification (@status, §2.2.9).
	status ?int
	// fields holds HTTP header field values keyed by *lowercased*
	// field name, with the slice preserving the order multiple values
	// arrive in. Values are joined with ", " on emission per
	// RFC 9421 §2.1.
	fields map[string][]string
}

Components is the side-channel input for signature_base_string. Empty / none fields mean "not available" - the signer / verifier returns MalformedMessage if the covered-components list mentions a derived component whose source is none here.

fn (Components) add_field #

fn (mut c Components) add_field(name string, value string)

add_field is sugar for accumulating header values without juggling the underlying map manually. Consecutive calls preserve insertion order, which matters for fields that appear more than once.

struct ExtensionParamValue #

struct ExtensionParamValue {
pub:
	raw string
}

ExtensionParamValue preserves a valid RFC 8941 bare item whose type is not used by a registered RFC 9421 signature parameter.

struct Key #

struct Key {
pub:
	algorithm  Algorithm
	is_private bool
	// bytes layout per algorithm:
	//   .hmac_sha256       → the symmetric secret
	//   .ed25519, private  → 32-byte seed (RFC 8032 §5.1.5)
	//   .ed25519, public   → 32-byte x coordinate
	//   .ecdsa_*, private  → x || y || d (each at curve byte size)
	//   .ecdsa_*, public   → x || y       (each at curve byte size)
	bytes []u8
	// keyid, if set, is what the high-level helpers will emit as the
	// `keyid` signature parameter when no explicit keyid is passed.
	// Not part of the cryptographic identity - just routing metadata.
	keyid ?string
}

Key is a tagged blob of key material. Public/private distinction is in is_private; the on-the-wire layout of bytes depends on algorithm and is documented per constructor below.

fn (Key) with_keyid #

fn (k Key) with_keyid(keyid string) Key

with_keyid returns a copy of the Key with keyid set. Convenience for fluent construction at call sites.

struct MalformedMessage #

struct MalformedMessage {
	Error
pub:
	reason string
}

MalformedMessage covers all syntactic and structural problems with the inputs (missing covered components, malformed Signature-Input, unknown derived component, etc.). The reason field carries the short human-readable detail.

fn (MalformedMessage) msg #

fn (e &MalformedMessage) msg() string

msg formats a MalformedMessage for IError.msg().

struct ParamPair #

struct ParamPair {
pub:
	name  string
	value ParamValue
}

ParamPair is one parameter for serialize_params. We use a slice of these instead of a map because parameter order matters on the wire (it doesn't change verification - the verifier reparses - but it makes generated headers deterministic and diff-friendly).

struct SignRequestOptions #

@[params]
struct SignRequestOptions {
pub:
	components []string
	label      string = 'sig1'
	keyid      ?string
	created    ?i64
	expires    ?i64
	nonce      ?string
	tag        ?string
	// include_alg, when true, emits the `alg` signature parameter on
	// the wire. Most signers leave it off (the verifier looks the alg
	// up by `keyid`); set to true for explicit signalling.
	include_alg bool
	// scheme is used to reconstruct `@target-uri` and `@scheme` when
	// `req.url` is origin-form (e.g. `/foo`, as produced by
	// `http.parse_request*`). The default matches the common signing
	// scenario (TLS-protected APIs). Ignored when `req.url` already
	// carries a scheme.
	scheme string = 'https'
}

SignRequestOptions parametrises sign_request. components is optional - when omitted we sign the conservative default (@method, @target-uri, @authority, content-digest, plus the Date header if present). RFC 9421 doesn't mandate a default; this one mirrors what most production deployments use.

struct SignResponseOptions #

@[params]
struct SignResponseOptions {
pub:
	components  []string
	label       string = 'sig1'
	keyid       ?string
	created     ?i64
	expires     ?i64
	nonce       ?string
	tag         ?string
	include_alg bool
}

SignResponseOptions / VerifyResponseOptions mirror their request counterparts. Defaults assume a status-code-and-content scenario.

struct SignatureEntry #

struct SignatureEntry {
pub:
	label                  string
	components             []string
	params                 map[string]ParamValue
	signature_params_value string
}

SignatureEntry is one (label, covered-components, parameters) tuple parsed out of a Signature-Input header. The raw signature_params_value is preserved verbatim - the verifier re-emits these exact bytes into the signature base, which is what the signer signed. Re-serialising from the parsed params would introduce ordering ambiguity (RFC 8941 doesn't pin a canonical map order) and break verification of signatures produced by other stacks.

struct SignatureExpired #

struct SignatureExpired {
	Error
pub:
	expires i64
	now     i64
}

SignatureExpired is returned by verification helpers when the signature's expires parameter is at or before the verification time. Callers that don't want this check can leave now_unix at zero.

fn (SignatureExpired) msg #

fn (e &SignatureExpired) msg() string

msg formats a SignatureExpired for IError.msg().

struct SignatureNotYetValid #

struct SignatureNotYetValid {
	Error
pub:
	created i64
	now     i64
}

SignatureNotYetValid is returned by verification helpers when the signature's created parameter is later than the verification time.

fn (SignatureNotYetValid) msg #

fn (e &SignatureNotYetValid) msg() string

msg formats a SignatureNotYetValid for IError.msg().

struct SignatureParams #

@[params]
struct SignatureParams {
pub mut:
	components []string
	keyid      ?string
	alg        ?string
	created    ?i64
	expires    ?i64
	nonce      ?string
	tag        ?string
}

SignatureParams holds the parameters that go after the inner-list in the @signature-params line and on the Signature-Input header. components is the ordered list the verifier walks; mutating the order changes the wire bytes and breaks verification.

struct SignedHeaders #

struct SignedHeaders {
pub:
	signature_input string
	signature       string
}

SignedHeaders bundles the two HTTP header values produced by sign. The signer attaches these as Signature-Input and Signature respectively.

struct UnsupportedAlgorithm #

struct UnsupportedAlgorithm {
	Error
pub:
	name string
}

UnsupportedAlgorithm is returned when the alg parameter (or the implied algorithm of the supplied key) is not one this module implements. Carrying the offending token lets callers report it clearly rather than echoing a generic "not supported".

fn (UnsupportedAlgorithm) msg #

fn (e &UnsupportedAlgorithm) msg() string

msg formats an UnsupportedAlgorithm for IError.msg().

struct VerificationFailed #

struct VerificationFailed {
	Error
pub:
	label string
}

VerificationFailed is returned when a signature does not match the recomputed signature base. Distinct from MalformedMessage so that callers can tell "wire bytes are fine but signature is bad" apart from "wire bytes are unparseable".

fn (VerificationFailed) msg #

fn (e &VerificationFailed) msg() string

msg formats a VerificationFailed for IError.msg().

struct VerifyOptions #

@[params]
struct VerifyOptions {
pub:
	now_unix            i64
	required_components []string
}

VerifyOptions tweaks verify. now_unix enables created and optional expires time checks (any value > 0 turns them on), and required_components enforces an application coverage policy.

struct VerifyRequestOptions #

@[params]
struct VerifyRequestOptions {
pub:
	label    string
	now_unix i64
	// required_components defaults to @method, @target-uri, @authority and
	// content-digest, whatever the received body.
	required_components []string
	// scheme — see SignRequestOptions.scheme. Both ends of the
	// signature must agree on the scheme used to reconstruct the
	// target URI, otherwise the signature bases differ.
	scheme string = 'https'
}

VerifyRequestOptions parametrises verify_request. label selects which labelled signature to verify when several are present.

struct VerifyResponseOptions #

@[params]
struct VerifyResponseOptions {
pub:
	label    string
	now_unix i64
	// required_components defaults to @status and content-digest, whatever
	// the received body.
	required_components []string
}