Class: Portage::Ucp::Security::Signature

Inherits:
Object
  • Object
show all
Defined in:
lib/portage/ucp/security/signature.rb

Overview

Verifies that an inbound agent request carries a valid RFC 9421 HTTP Message Signature, per UCP's own signature spec (ucp.dev/2026-04-08/specification/signatures/, pinned 2026-09-15 — this is the piece §22 of the design log flagged as unresearched before writing code, now checked against the live spec rather than guessed). This is a distinct concern from three other signing stories already in this gem:

- Manifest (manifest.rb) signs the *outbound* /.well-known/ucp
document with a business-held key.
- Rack::WebhookEndpoint verifies *inbound backend* webhooks by a
shared-secret HMAC.
- Authenticator proves *who* is calling a tools/call, not that a
human consented to the specific purchase.

This class verifies that the calling platform's own signature over the HTTP request itself is valid and covers the fields UCP requires — the AP2/UCP authorization story: cryptographic proof bound to this exact method/path/body, not just an API key.

Verify-before-parse (matches WebhookEndpoint's posture exactly): the caller passes the raw, unparsed body; nothing here (or in the Rack middleware that wraps it, see Rack::SignatureVerification) touches the body as JSON. An unverified body is untrusted input.

Key set shape is reused from Manifest's signing_keys (§9): a flat array of JWK hashes, multiple entries = current+next during rotation. This is deliberately a different config value from Manifest's own signing_keys (that's the business's own key, advertised for others to verify the business's manifest with; this is the calling platform's key, trusted by the business to verify inbound requests with) — same shape, not the same data, per §22's instruction not to invent a second differently-shaped key config.

trusted_keys may be a plain Array (JWK) or anything responding to #call(kid) => JWK hash or nil, so a consumer trusting more than one platform can look keys up however it needs to (its own store, a cached fetch of the platform's own manifest) — the gem doesn't assume single-platform trust, without inventing multi-platform discovery logic that isn't pinned anywhere yet.

Constant Summary collapse

CURVES =

ECDSA only, per the spec: P-256 mandatory, P-384 optional. coord is the byte length of each of r/s in the required raw r||s signature encoding (never ASN.1/DER on the wire) and of the JWK's x/y coordinates.

{
  "P-256" => { openssl_name: "prime256v1", oid: "1.2.840.10045.3.1.7", coord: 32, digest: "SHA256" },
  "P-384" => { openssl_name: "secp384r1", oid: "1.3.132.0.34", coord: 48, digest: "SHA384" }
}.freeze
EC_PUBLIC_KEY_OID =
"1.2.840.10045.2.1".freeze
SIGNATURE_INPUT_FORMAT =
/\A(?<label>[!\w-]+)=\((?<components>[^)]*)\)(?<params>.*)\z/
SIGNATURE_FORMAT =
%r{\A[!\w-]+=:(?<value>[A-Za-z0-9+/=]+):\z}

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(method:, authority:, path:, headers:, body:, trusted_keys:, required_components:, max_age:, query: nil) ⇒ Signature

Returns a new instance of Signature.



93
94
95
96
97
98
99
100
101
102
103
104
# File 'lib/portage/ucp/security/signature.rb', line 93

def initialize(method:, authority:, path:, headers:, body:, trusted_keys:, required_components:, max_age:,
               query: nil)
  @method = method
  @authority = authority
  @path = path
  @query = query
  @headers = headers.transform_keys { |k| k.to_s.downcase }
  @body = body || ""
  @trusted_keys = trusted_keys
  @required_components = required_components
  @max_age = max_age
end

Class Method Details

.verify!(method:, authority:, path:, headers:, body:, trusted_keys:, query: nil, required_components: %w[@method @authority @path idempotency-key],, max_age: 300) ⇒ Hash

Returns { verified: true, keyid: } — never a falsy result, raises a Security::SignatureError subclass on any failure instead so callers can't accidentally ignore one (same convention as PolicyGuard.check!'s { allowed: true }).

Parameters:

  • method (String) —

    HTTP verb, e.g. "POST"

  • authority (String) —

    request Host (and port if non-default)

  • path (String) —

    request path, no query string

  • query (String, nil) (defaults to: nil) —

    raw query string including leading "?", or nil/"" when the request has none

  • headers (Hash<String, String>) —

    lower-cased header name => value, at minimum whatever the sender listed as covered components (signature-input, signature, content-digest, content-type, idempotency-key, ucp-agent)

  • body (String) —

    raw request body bytes, unparsed

  • trusted_keys (Array<Hash>, #call) —

    JWK hash set (or resolver), see class doc

  • required_components (Array<String>) (defaults to: %w[@method @authority @path idempotency-key],) —

    the minimum the signer must have covered for this to count as proof of anything — a signature that covers only a harmless header is not a signature over the request that matters. Defaults to the request-line plus idempotency-key, since that's what makes replaying a signed request against a different mutation impossible; content-digest is required additionally whenever body is non-empty.

  • max_age (Integer, nil) (defaults to: 300) —

    seconds a signature's created may lag behind now before it's treated as stale (bounds replay of an otherwise-valid captured request — RFC 9421 leaves freshness enforcement to the verifier). nil disables the check.

Returns:

  • (Hash) —

    { verified: true, keyid: } — never a falsy result, raises a Security::SignatureError subclass on any failure instead so callers can't accidentally ignore one (same convention as PolicyGuard.check!'s { allowed: true }).



86
87
88
89
90
91
# File 'lib/portage/ucp/security/signature.rb', line 86

def self.verify!(method:, authority:, path:, headers:, body:, trusted_keys:, query: nil,
                 required_components: %w[@method @authority @path idempotency-key], max_age: 300)
  new(method: method, authority: authority, path: path, query: query, headers: headers, body: body,
      trusted_keys: trusted_keys, required_components: required_components,
      max_age: max_age).verify!
end

Instance Method Details

#verify! ⇒ Object

Raises:



106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
# File 'lib/portage/ucp/security/signature.rb', line 106

def verify!
  signature_input = header!("signature-input")
  signature = header!("signature")

  label, components, params = parse_signature_input(signature_input)
  ensure_required_coverage!(components)
  ensure_digest_covered_if_body!(components)
  ensure_fresh!(params)

  key = resolve_key(params[:keyid])
  raise UnknownKeyError, "no trusted key for keyid #{params[:keyid].inspect}" unless key

  verify_content_digest!(components) unless @body.empty?

  base = signature_base(components, label, signature_input)
  raw_signature = decode_signature(signature)
  verify_ecdsa!(key, base, raw_signature)

  { verified: true, keyid: params[:keyid] }
end