Module: ZeroClick::Sellers::Stateful::Verify
- Defined in:
- lib/zeroclick/sellers/stateful/verify.rb
Overview
Stateful request signatures.
Deliberately distinct from Sellers::Verify: the canonical string carries a trailing PURPOSE segment, selected from the matched route rather than from anything the caller sends. That segment is a domain separator — it makes an ordinary proxy signature invalid on the stateful routes, and a stateful signature invalid on ordinary ones, so a signature captured from one cannot be replayed against the other.
Defined Under Namespace
Constant Summary collapse
- ACCESS_SKEW_SECONDS =
300- PURPOSES =
%w[access.write access.mint].freeze
- FAILURE_CODES =
Verification failures collapse to exactly these four codes, which are also the 401 bodies the dispatcher returns. Fewer codes than the proxy rail on purpose: a caller learning why its signature failed learns something about the secret.
%w[ missing_or_malformed_signature stale_timestamp unknown_kid invalid_signature ].freeze
Class Method Summary collapse
-
.canonical_access_string(timestamp:, method:, path_and_query:, body:, request_id:, agent_id:, purpose:) ⇒ Object
Seven newline-joined fields — one more than the proxy rail's six.
-
.sign_access_request(secret:, kid:, method:, path_and_query:, request_id:, agent_id:, purpose:, body: "", timestamp: nil) ⇒ Object
Build a protocol signature.
- .verify_access_signature(signature_header:, method:, path_and_query:, body:, request_id:, agent_id:, purpose:, signing_secrets: nil, resolve_signing_secret: nil, max_skew_seconds: ACCESS_SKEW_SECONDS, clock: -> { Time.now.to_i }) ⇒ Object
Class Method Details
.canonical_access_string(timestamp:, method:, path_and_query:, body:, request_id:, agent_id:, purpose:) ⇒ Object
Seven newline-joined fields — one more than the proxy rail's six.
61 62 63 64 65 66 67 68 69 70 71 |
# File 'lib/zeroclick/sellers/stateful/verify.rb', line 61 def canonical_access_string(timestamp:, method:, path_and_query:, body:, request_id:, agent_id:, purpose:) [ , method.upcase, path_and_query, OpenSSL::Digest::SHA256.hexdigest(body), request_id || "", agent_id || "", purpose ].join("\n") end |
.sign_access_request(secret:, kid:, method:, path_and_query:, request_id:, agent_id:, purpose:, body: "", timestamp: nil) ⇒ Object
Build a protocol signature. For integration tests and for sellers that need to replay a request; the proxy signs real traffic.
122 123 124 125 126 127 128 129 130 131 132 133 |
# File 'lib/zeroclick/sellers/stateful/verify.rb', line 122 def sign_access_request(secret:, kid:, method:, path_and_query:, request_id:, agent_id:, purpose:, body: "", timestamp: nil) stamp = || Time.now.to_i v1 = OpenSSL::HMAC.hexdigest( "SHA256", secret, canonical_access_string( timestamp: stamp.to_s, method: method, path_and_query: path_and_query, body: body, request_id: request_id, agent_id: agent_id, purpose: purpose ) ) "t=#{stamp},kid=#{kid},v1=#{v1}" end |
.verify_access_signature(signature_header:, method:, path_and_query:, body:, request_id:, agent_id:, purpose:, signing_secrets: nil, resolve_signing_secret: nil, max_skew_seconds: ACCESS_SKEW_SECONDS, clock: -> { Time.now.to_i }) ⇒ Object
73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 |
# File 'lib/zeroclick/sellers/stateful/verify.rb', line 73 def verify_access_signature(signature_header:, method:, path_and_query:, body:, request_id:, agent_id:, purpose:, signing_secrets: nil, resolve_signing_secret: nil, max_skew_seconds: ACCESS_SKEW_SECONDS, clock: -> { Time.now.to_i }) if signing_secrets.nil? == resolve_signing_secret.nil? raise Error.new("malformed_input", operation: "verify_access_signature", message: "Provide exactly one of signing_secrets or resolve_signing_secret") end unless body.is_a?(String) raise Error.new("malformed_input", operation: "verify_access_signature", message: "body must be a String of raw bytes; decode nothing before verifying") end return Failure.new("missing_or_malformed_signature") if signature_header.nil? # Same header grammar as the proxy rail, including ignoring unknown # members — sandbox traffic appends sb=1 here too. signature = Sellers::Verify.parse_signature_header(signature_header) return Failure.new("missing_or_malformed_signature") if signature.nil? = Integer(signature["t"], 10) return Failure.new("stale_timestamp") if (clock.call.to_i - ).abs > max_skew_seconds secret = if resolve_signing_secret resolve_signing_secret.call(signature["kid"]) else (signing_secrets || {})[signature["kid"]] end # An empty secret is treated as unknown, not as a usable key: a blank # value in a secrets map is a configuration slip, and signing with it # would accept anything that made the same slip. return Failure.new("unknown_kid") unless secret.is_a?(String) && !secret.empty? expected = OpenSSL::HMAC.hexdigest( "SHA256", secret, canonical_access_string( timestamp: signature["t"], method: method, path_and_query: path_and_query, body: body, request_id: request_id, agent_id: agent_id, purpose: purpose ) ) return Failure.new("invalid_signature") unless OpenSSL.secure_compare(expected, signature["v1"]) Ok.new(kid: signature["kid"], timestamp: ) end |