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

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?

  timestamp = Integer(signature["t"], 10)
  now_seconds = clock.call.to_i
  if (now_seconds - timestamp).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.message)
  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: 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:)
  [
    timestamp,
    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