X402.Extensions.OfferReceipt.JWS (X402 v0.6.0)

Copy Markdown View Source

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-byte R || S JOSE 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 kid header (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/1 supports 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 :crypto to 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

compact()

@type compact() :: String.t()

A JWS Compact Serialization string (header.payload.signature).

header()

@type header() :: %{optional(String.t()) => term()}

A decoded protected header with at least alg and kid.

sign_error()

@type sign_error() ::
  {:unsupported_algorithm, String.t()}
  | {:invalid_key, String.t()}
  | {:unsupported_json_value, term()}

verify_error()

@type verify_error() ::
  :invalid_jws
  | :signature_mismatch
  | {:unsupported_algorithm, term()}
  | {:invalid_key, String.t()}
  | {:missing_header, String.t()}

Functions

canonicalize(value)

(since 0.6.0)
@spec canonicalize(term()) ::
  {:ok, String.t()} | {:error, {:unsupported_json_value, term()}}

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

peek_header(compact)

(since 0.6.0)
@spec peek_header(compact()) :: {:ok, header()} | {:error, :invalid_jws}

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}

peek_payload(compact)

(since 0.6.0)
@spec peek_payload(compact()) :: {:ok, term()} | {: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}}

sign(payload, opts)

(since 0.6.0)
@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: ES256K or EdDSA.

  • :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
  )

verify(compact, public_key, opts \\ [])

(since 0.6.0)
@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"].