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
- .b64u_decode(value) ⇒ Object
-
.b64u_encode(bytes) ⇒ Object
— base64url helpers (no padding) ————————————-.
-
.create_alias(target, base_url: nil) ⇒ Object
POST
{base}/cwith{"at": target}-> alias URL. - .decode_captured_request(plaintext) ⇒ Object
-
.encode_query_value(value) ⇒ Object
Encode a query parameter value, matching JS
URLSearchParams/ Pythonurlencode(space becomes +). - .generate_private_key ⇒ Object
-
.http_request(method, url, body, headers) ⇒ Object
— HTTP helpers ——————————————————–.
-
.normalize_base(base_url) ⇒ Object
— URL helpers ———————————————————.
- .parse_json_response(response) ⇒ Object
-
.percent_encode(value) ⇒ Object
Percent-encode a single path segment, matching JS
encodeURIComponent(everything but the RFC 3986 unreserved set). -
.private_key(path = nil) ⇒ Object
Load or create a base64url Ed25519 seed.
- .secure_key_file(path) ⇒ Object
-
.seed_bytes(value) ⇒ Object
Decode a base64url private key into its 32 seed bytes, validating length.
- .sha256_b64u(bytes) ⇒ Object
- .signing_key(value) ⇒ Object
-
.trampoline_url(target, base_url: nil, params: nil) ⇒ Object
Build a trampoline URL:
{base}/?at={target}plus any extra params. - .trim_trailing_slash(value) ⇒ Object
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.}" 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_key ⇒ Object
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.}" 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) = (parsed.is_a?(Hash) && parsed["error"]) || "cc.me request failed with #{code}" raise Error, 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 |