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

Classes: Failure, Ok

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

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:)
  [
    timestamp,
    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 = timestamp || 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?

  timestamp = Integer(signature["t"], 10)
  return Failure.new("stale_timestamp") if (clock.call.to_i - timestamp).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: timestamp)
end