Class: Portage::Ucp::Security::Signature
- Inherits:
-
Object
- Object
- Portage::Ucp::Security::Signature
- 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
Constant Summary collapse
- CURVES =
ECDSA only, per the spec: P-256 mandatory, P-384 optional.
coordis 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
-
.verify!(method:, authority:, path:, headers:, body:, trusted_keys:, query: nil, required_components: %w[@method @authority @path idempotency-key],, max_age: 300) ⇒ 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 }).
Instance Method Summary collapse
-
#initialize(method:, authority:, path:, headers:, body:, trusted_keys:, required_components:, max_age:, query: nil) ⇒ Signature
constructor
A new instance of Signature.
- #verify! ⇒ Object
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 = @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 }).
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: , 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
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 |