Module: CcMe

Defined in:
lib/cc_me.rb,
lib/cc_me/forward.rb,
lib/cc_me/version.rb

Defined Under Namespace

Modules: Forward Classes: AliasResponse, CapturedHeader, CapturedRequest, Client, DeliveryResponse, Error

Constant Summary collapse

DEFAULT_BASE_URL =
"https://cc.me/"
AUTH_VERSION =
"cc-me-v1"
AUTH_TIMESTAMP_HEADER =
"x-cc-me-timestamp"
AUTH_SIGNATURE_HEADER =
"x-cc-me-signature"
SEED_BYTES =
32
SEALED_BOX_PUBLIC_KEY_BYTES =
32
SEALED_BOX_NONCE_BYTES =
24
VERSION =
"0.1.0"

Class Method Summary collapse

Class Method Details

.b64u_decode(value) ⇒ Object



37
38
39
40
41
42
43
# File 'lib/cc_me.rb', line 37

def self.b64u_decode(value)
  str = value.to_s.strip
  padding = "=" * ((4 - (str.length % 4)) % 4)
  Base64.urlsafe_decode64(str + padding)
rescue ArgumentError => e
  raise Error, "invalid base64url: #{e.message}"
end

.b64u_encode(bytes) ⇒ Object

— base64url helpers (no padding) ————————————-



33
34
35
# File 'lib/cc_me.rb', line 33

def self.b64u_encode(bytes)
  Base64.urlsafe_encode64(bytes, padding: false)
end

.create_alias(target, base_url: nil) ⇒ Object

POST {base}/c with {"at": target} -> alias URL. Idempotent, no auth.



145
146
147
148
149
150
# File 'lib/cc_me.rb', line 145

def self.create_alias(target, base_url: nil)
  url = "#{normalize_base(base_url)}c"
  body = JSON.generate("at" => target.to_s)
  response = http_request("POST", url, body, "content-type" => "application/json")
  AliasResponse.new(response["url"])
end

.decode_captured_request(plaintext) ⇒ Object



222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
# File 'lib/cc_me.rb', line 222

def self.decode_captured_request(plaintext)
  parsed = JSON.parse(plaintext)
  body_bytes = b64u_decode(parsed["body_b64u"])
  headers = (parsed["headers"] || []).map do |header|
    value_bytes = b64u_decode(header["value_b64u"])
    value = value_bytes.dup.force_encoding(Encoding::UTF_8)
    value = value.scrub unless value.valid_encoding?
    CapturedHeader.new(header["name"], value, value_bytes)
  end
  CapturedRequest.new(
    id: parsed["id"],
    received_at_unix_ms: parsed["received_at_unix_ms"],
    method: parsed["method"],
    path: parsed["path"],
    query: parsed["query"],
    headers: headers,
    body_bytes: body_bytes
  )
end

.encode_query_value(value) ⇒ Object

Encode a query parameter value, matching JS URLSearchParams / Python urlencode (space becomes +).



125
126
127
# File 'lib/cc_me.rb', line 125

def self.encode_query_value(value)
  URI.encode_www_form_component(value.to_s)
end

.generate_private_keyObject



65
66
67
# File 'lib/cc_me.rb', line 65

def self.generate_private_key
  b64u_encode(RbNaCl::Random.random_bytes(SEED_BYTES))
end

.http_request(method, url, body, headers) ⇒ Object

— HTTP helpers ——————————————————–



154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
# File 'lib/cc_me.rb', line 154

def self.http_request(method, url, body, headers)
  uri = URI(url)
  request =
    case method
    when "GET" then Net::HTTP::Get.new(uri)
    when "POST" then Net::HTTP::Post.new(uri)
    else raise Error, "unsupported method #{method}"
    end
  headers.each { |name, value| request[name] = value }
  request.body = body if body

  http = Net::HTTP.new(uri.hostname, uri.port)
  http.use_ssl = uri.scheme == "https"
  parse_json_response(http.request(request))
rescue SocketError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout => e
  raise Error, "cc.me request failed: #{e.message}"
end

.normalize_base(base_url) ⇒ Object

— URL helpers ———————————————————



101
102
103
104
# File 'lib/cc_me.rb', line 101

def self.normalize_base(base_url)
  base = base_url.nil? || base_url.empty? ? DEFAULT_BASE_URL : base_url
  base.end_with?("/") ? base : "#{base}/"
end

.parse_json_response(response) ⇒ Object



172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
# File 'lib/cc_me.rb', line 172

def self.parse_json_response(response)
  raw = response.body
  parsed =
    if raw && !raw.empty?
      begin
        JSON.parse(raw)
      rescue JSON::ParserError
        {}
      end
    else
      {}
    end

  code = response.code.to_i
  unless (200..299).cover?(code)
    message = (parsed.is_a?(Hash) && parsed["error"]) || "cc.me request failed with #{code}"
    raise Error, message
  end
  parsed
end

.percent_encode(value) ⇒ Object

Percent-encode a single path segment, matching JS encodeURIComponent (everything but the RFC 3986 unreserved set).



112
113
114
115
116
117
118
119
120
121
# File 'lib/cc_me.rb', line 112

def self.percent_encode(value)
  value.to_s.b.each_byte.map do |byte|
    if (0x41..0x5A).cover?(byte) || (0x61..0x7A).cover?(byte) ||
       (0x30..0x39).cover?(byte) || [0x2D, 0x5F, 0x2E, 0x7E].include?(byte)
      byte.chr
    else
      format("%%%02X", byte)
    end
  end.join
end

.private_key(path = nil) ⇒ Object

Load or create a base64url Ed25519 seed.

With nil a fresh in-memory key is generated and returned (not persisted). With a path the file is reused if present (and re-secured to mode 0600 on Unix), otherwise created with mode 0600 containing the base64url seed followed by a newline.



75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
# File 'lib/cc_me.rb', line 75

def self.private_key(path = nil)
  return generate_private_key if path.nil?

  if File.exist?(path)
    key = File.read(path).strip
    seed_bytes(key) # validate
    secure_key_file(path)
    return key
  end

  key = generate_private_key
  File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
    file.write("#{key}\n")
  end
  secure_key_file(path)
  key
end

.secure_key_file(path) ⇒ Object



93
94
95
96
97
# File 'lib/cc_me.rb', line 93

def self.secure_key_file(path)
  return if Gem.win_platform?

  File.chmod(0o600, path)
end

.seed_bytes(value) ⇒ Object

Decode a base64url private key into its 32 seed bytes, validating length.



52
53
54
55
56
57
58
59
# File 'lib/cc_me.rb', line 52

def self.seed_bytes(value)
  seed = b64u_decode(value)
  unless seed.bytesize == SEED_BYTES
    raise Error, "private_key must be 32 bytes of base64url"
  end

  seed
end

.sha256_b64u(bytes) ⇒ Object



45
46
47
# File 'lib/cc_me.rb', line 45

def self.sha256_b64u(bytes)
  b64u_encode(Digest::SHA256.digest(bytes))
end

.signing_key(value) ⇒ Object



61
62
63
# File 'lib/cc_me.rb', line 61

def self.signing_key(value)
  RbNaCl::SigningKey.new(seed_bytes(value))
end

.trampoline_url(target, base_url: nil, params: nil) ⇒ Object

Build a trampoline URL: {base}/?at={target} plus any extra params.



130
131
132
133
134
135
136
137
138
# File 'lib/cc_me.rb', line 130

def self.trampoline_url(target, base_url: nil, params: nil)
  query = +"at=#{encode_query_value(target)}"
  (params || {}).each do |key, value|
    next if value.nil?

    query << "&#{encode_query_value(key)}=#{encode_query_value(value)}"
  end
  "#{normalize_base(base_url)}?#{query}"
end

.trim_trailing_slash(value) ⇒ Object



106
107
108
# File 'lib/cc_me.rb', line 106

def self.trim_trailing_slash(value)
  value.end_with?("/") ? value[0...-1] : value
end