Compact JWS signing and verification for the offer-receipt extension.
Implements the JWS Compact Serialization (header.payload.signature,
RFC 7515) used by the x402 offer-and-receipt extension with the two
algorithms the extension names (§3.3):
"ES256K"— ECDSA over secp256k1 with SHA-256 (RFC 8812), with the 64-byteR || SJOSE signature encoding"EdDSA"— Ed25519 (RFC 8037)
Both are implemented with OTP's :crypto application — no additional
dependencies. Payloads are canonicalized with the JSON Canonicalization
Scheme (JCS, RFC 8785) before signing, as required by the extension's
security considerations (§10).
Keys
Keys are raw binaries, not JWKs:
"ES256K"— a 32-byte secp256k1 private key; the public key is a SEC1 point (65-byte uncompressed, 33-byte compressed, or the bare 64-byte X || Y coordinates)"EdDSA"— a 32-byte Ed25519 seed; the public key is the 32-byte Ed25519 public key
Boundaries
- Key discovery is out of scope: the
kidheader (a DID URL per the extension spec) is carried and returned verbatim, but this module never resolves it — callers supply the public key for verification and are responsible for checking the key is authorized for the resource (spec §4.5.1). canonicalize/1supports the JSON values that appear in offer and receipt payloads (objects, arrays, strings, integers, booleans, null). Floats are rejected with{:error, {:unsupported_json_value, value}}rather than risking a non-canonical number serialization.- ECDSA over secp256k1 requires OTP's
:cryptoto be linked against an OpenSSL with secp256k1 support (the common case); otherwise"ES256K"operations return{:error, {:unsupported_algorithm, "ES256K"}}.
Summary
Types
A JWS Compact Serialization string (header.payload.signature).
A decoded protected header with at least alg and kid.
Functions
Serializes a JSON-representable term with the JSON Canonicalization Scheme.
Decodes the protected header of a JWS without verifying the signature.
Decodes the payload of a JWS without verifying the signature.
Signs a JSON payload into a JWS Compact Serialization string.
Verifies a JWS Compact Serialization string against a public key.
Types
@type compact() :: String.t()
A JWS Compact Serialization string (header.payload.signature).
A decoded protected header with at least alg and kid.
Functions
Serializes a JSON-representable term with the JSON Canonicalization Scheme.
Implements RFC 8785 for the value domain used by offer and receipt
payloads: object keys are sorted by UTF-16 code units, no insignificant
whitespace is emitted, and strings use ECMAScript's minimal escaping.
Atom keys and values are serialized as their string form (as Jason
does); floats are rejected as unsupported.
Examples
iex> X402.Extensions.OfferReceipt.JWS.canonicalize(%{"b" => 1, "a" => [true, nil, "x"]})
{:ok, ~s({"a":[true,null,"x"],"b":1})}
iex> X402.Extensions.OfferReceipt.JWS.canonicalize(%{"bad" => 1.5})
{:error, {:unsupported_json_value, 1.5}}
Decodes the protected header of a JWS without verifying the signature.
Useful for extracting the kid in order to resolve the verification key.
Examples
iex> X402.Extensions.OfferReceipt.JWS.peek_header(
...> "eyJhbGciOiJFUzI1NksiLCJraWQiOiJkaWQ6d2ViOmFwaS5leGFtcGxlLmNvbSNrZXktMSJ9.e30.c2ln"
...> )
{:ok, %{"alg" => "ES256K", "kid" => "did:web:api.example.com#key-1"}}
iex> X402.Extensions.OfferReceipt.JWS.peek_header("not a jws")
{:error, :invalid_jws}
Decodes the payload of a JWS without verifying the signature.
Only use the result for display or matching; verified reads must go
through verify/3.
Examples
iex> X402.Extensions.OfferReceipt.JWS.peek_payload("eyJhbGciOiJFUzI1NksiLCJraWQiOiJrIn0.eyJ2ZXJzaW9uIjoxfQ.c2ln")
{:ok, %{"version" => 1}}
@spec sign( map(), keyword() ) :: {:ok, compact()} | {:error, sign_error()}
Signs a JSON payload into a JWS Compact Serialization string.
The protected header is {"alg": alg, "kid": kid}; the payload is
JCS-canonicalized before base64url encoding, so signing the same payload
twice produces the same JWS (for a deterministic algorithm like EdDSA).
Options
:alg- Required. JWS algorithm:ES256KorEdDSA.:kid(String.t/0) - Required. Key identifier placed in the protected header (a DID URL).:key(String.t/0) - Required. Raw private key: a 32-byte secp256k1 private key for"ES256K", a 32-byte Ed25519 seed for"EdDSA".
Examples
{:ok, jws} =
X402.Extensions.OfferReceipt.JWS.sign(
%{"version" => 1, "resourceUrl" => "https://api.example.com/data"},
alg: "EdDSA",
kid: "did:web:api.example.com#key-1",
key: ed25519_seed
)
@spec verify(compact(), binary(), keyword()) :: {:ok, %{header: header(), payload: term()}} | {:error, verify_error()}
Verifies a JWS Compact Serialization string against a public key.
Returns the decoded header and payload on success. The header must carry
"alg" (one of the allowed :algs) and "kid" (required by the
extension, §3.3). Key authorization for the signed resource is the
caller's responsibility (spec §4.5.1).
Options
:algs- Algorithms accepted during verification (allowlist). The default value is["ES256K", "EdDSA"].