Module: ZeroClick::Sellers::Verify
- Defined in:
- lib/zeroclick/sellers/verify.rb
Overview
zc-signature verification. The algorithm is pinned by test/vectors/signing-vectors.json, which every SDK executes.
Constant Summary collapse
- TIMESTAMP_RE =
Bounded on purpose. Ruby's Integer() would happily convert an unbounded digit run, and a multi-megabyte one turns an unauthenticated request into a denial-of-service. 20 digits is far past any real epoch second and matches the other SDKs, which reject anything longer as malformed.
/\A\d{1,20}\z/- HEX_64_RE =
/\A[0-9a-f]{64}\z/
Class Method Summary collapse
-
.call(request, signing_secrets: nil, resolve_signing_secret: nil, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS, clock: -> { Time.now.to_i }) ⇒ Object
Verify a ZeroClick-signed request.
-
.canonical_string(timestamp:, method:, path_and_query:, body:, zc_request_id:, zc_agent_id:) ⇒ Object
The exact bytes that get signed: six newline-joined fields.
-
.parse_signature_header(value) ⇒ Object
Parse
t=<unix>,kid=<id>,v1=<64 lowercase hex>.
Class Method Details
.call(request, signing_secrets: nil, resolve_signing_secret: nil, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS, clock: -> { Time.now.to_i }) ⇒ Object
Verify a ZeroClick-signed request.
Returns a value rather than raising: an unsigned or badly signed request is an expected event. VerifyFailure#response is ready to return as-is.
A verified request whose zc_agent_id is nil is a signed anonymous probe, which is valid — not a failure.
Provide exactly one of signing_secrets (a Hash of kid => secret) or
resolve_signing_secret (a callable taking a kid, returning the secret
or nil).
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 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 |
# File 'lib/zeroclick/sellers/verify.rb', line 92 def call(request, signing_secrets: nil, resolve_signing_secret: nil, tolerance_seconds: DEFAULT_TOLERANCE_SECONDS, clock: -> { Time.now.to_i }) if signing_secrets.nil? == resolve_signing_secret.nil? raise Error.new("malformed_input", operation: "verify_request", message: "Provide exactly one of signing_secrets or resolve_signing_secret") end unless request.is_a?(Request) raise Error.new("malformed_input", operation: "verify_request", message: "request must be a ZeroClick::Sellers::Request") end resolve = resolve_signing_secret || ->(kid) { (signing_secrets || {})[kid] } signature_header = request.header("zc-signature") return VerifyFailure.new("missing_signature", Responses.invalid_zeroclick_signature) if signature_header.nil? signature = parse_signature_header(signature_header) return VerifyFailure.new("malformed_signature", Responses.invalid_zeroclick_signature) if signature.nil? = Integer(signature["t"], 10) now_seconds = clock.call.to_i if (now_seconds - ).abs > tolerance_seconds return VerifyFailure.new("stale_timestamp", Responses.invalid_zeroclick_signature) end zc_request_id = request.header("zc-request-id") if zc_request_id.nil? || zc_request_id.empty? return VerifyFailure.new("missing_request_id", Responses.invalid_zeroclick_signature) end zc_agent_id = request.header("zc-agent-id") begin secret = resolve.call(signature["kid"]) rescue StandardError => e raise Error.new("signing_secret_resolution_failed", operation: "verify_request", kid: signature["kid"], cause: e.) end return VerifyFailure.new("unknown_kid", Responses.invalid_zeroclick_signature) if secret.nil? unless secret.is_a?(String) && !secret.empty? raise Error.new("signing_secret_resolution_failed", operation: "verify_request", kid: signature["kid"]) end expected = OpenSSL::HMAC.hexdigest( "SHA256", secret, canonical_string( timestamp: signature["t"], method: request.method, path_and_query: request.path_and_query, body: request.body, zc_request_id: zc_request_id, zc_agent_id: zc_agent_id ) ) # Constant time: a byte-at-a-time compare leaks the signature prefix. unless OpenSSL.secure_compare(expected, signature["v1"]) return VerifyFailure.new("invalid_signature", Responses.invalid_zeroclick_signature) end VerifyOk.new( ZeroClickContext.new( zc_request_id: zc_request_id, zc_agent_id: zc_agent_id, timestamp: , kid: signature["kid"], zc_anonymous_id: request.header("zc-anonymous-id"), zc_buyer_id: request.header("zc-buyer-id") ) ) end |
.canonical_string(timestamp:, method:, path_and_query:, body:, zc_request_id:, zc_agent_id:) ⇒ Object
The exact bytes that get signed: six newline-joined fields.
70 71 72 73 74 75 76 77 78 79 |
# File 'lib/zeroclick/sellers/verify.rb', line 70 def canonical_string(timestamp:, method:, path_and_query:, body:, zc_request_id:, zc_agent_id:) [ , method.upcase, path_and_query, OpenSSL::Digest::SHA256.hexdigest(body), zc_request_id, zc_agent_id || "" ].join("\n") end |
.parse_signature_header(value) ⇒ Object
Parse t=<unix>,kid=<id>,v1=<64 lowercase hex>.
Unknown members are IGNORED, deliberately. The proxy extends this header
additively — sb=1 marks sandbox traffic today — while signing a
canonical string that is byte-identical either way. Demanding exactly
three members failed every sandbox request as malformed_signature, so a
seller on this SDK could never finish sandbox testing.
What prevents an attacker smuggling a second v1 is DUPLICATE rejection, not the member count. Each check below defends one property:
- the
=and empty-key checks reject a structurally malformed member; - the duplicate check means a key can only ever be bound once, so a later member cannot shadow an earlier one;
- the three-key presence check is what the discarded member count only ever implied;
- only those three are returned, so an unknown member never reaches the caller.
41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 |
# File 'lib/zeroclick/sellers/verify.rb', line 41 def parse_signature_header(value) # -1 keeps trailing empty fields, so "t=1,kid=k,v1=x," stays malformed # rather than silently losing its empty member. entries = value.split(",", -1) return nil if entries.length < 3 parsed = {} entries.each do |entry| separator = entry.index("=") return nil if separator.nil? || separator.zero? key = entry[0...separator].strip return nil if key.empty? return nil if parsed.key?(key) parsed[key] = entry[(separator + 1)..].strip end return nil unless parsed.key?("t") && parsed.key?("kid") && parsed.key?("v1") return nil unless TIMESTAMP_RE.match?(parsed["t"]) return nil unless HEX_64_RE.match?(parsed["v1"]) kid = parsed["kid"] return nil if kid.empty? || kid != kid.strip { "t" => parsed["t"], "kid" => kid, "v1" => parsed["v1"] } end |