cc-me 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: c43c58197d013e5817c6a26baf753f0c5d0dda0fbf66e726af86e7ebb41a51d5
4
+ data.tar.gz: d8e0d8a7704c9fb6aa2d08889af73aaf6c12048c4ebee7ee2e21337d8ea8e16d
5
+ SHA512:
6
+ metadata.gz: 530091e4c2c6eba8d607fa434fd995397a3a417f8e6afa9e1d5ccf0d53490b2259adbf7dd591f03b1db9278eee3c7e032011b593cf1a4f5b3a7c418696a1d192
7
+ data.tar.gz: 06cf1c33bd91fe29c47278b2cae620a98ed85d7bbaf32b357cf51ef7e1db75de2395273455e9005b89101c16741a43ae368560441c4c24aa665d902a24f95e5d
data/README.md ADDED
@@ -0,0 +1,103 @@
1
+ # cc-me
2
+
3
+ Ruby client for [cc.me](https://cc.me/). The library builds trampoline and
4
+ inbox URLs and decrypts deliveries; the CLI forwards inbox deliveries to a local
5
+ endpoint. Mirrors the canonical JavaScript client and follows the wire protocol
6
+ in [`../PROTOCOL.md`](../PROTOCOL.md).
7
+
8
+ Requires Ruby 3.0+ and [RbNaCl](https://github.com/RubyCrypto/rbnacl) (which
9
+ needs libsodium installed).
10
+
11
+ ```sh
12
+ gem install cc-me
13
+ ```
14
+
15
+ Forward an inbox to a local endpoint:
16
+
17
+ ```sh
18
+ cc-me http://example.local:8080/webhook
19
+ ```
20
+
21
+ The CLI prints the inbox URL to register with the provider. It uses
22
+ `~/.cc-me.key` by default, creating it if needed and reusing it later. The key
23
+ is an Ed25519 seed; the URL shows the derived Ed25519 public key. Use `--key` to
24
+ choose a specific path:
25
+
26
+ ```sh
27
+ cc-me --key ~/hooks.key http://example.local:8080/webhook
28
+ ```
29
+
30
+ You can also set `CC_ME_KEY`, `CC_ME_URL`, and `CC_ME_LIMIT`.
31
+
32
+ ```ruby
33
+ require "cc_me"
34
+
35
+ alias_url = CcMe.create_alias("http://example.local/auth/callback")
36
+ puts "OAuth callback URL: #{alias_url.url}"
37
+
38
+ key = CcMe.private_key(File.join(Dir.home, ".cc-me.key"))
39
+ cc = CcMe::Client.new(private_key: key)
40
+
41
+ puts "Webhook URL: #{cc.inbox_url}"
42
+ puts "Webmention URL: #{cc.webmention_url}"
43
+ puts "WebSub URL: #{cc.websub_url}"
44
+ puts "Slack URL: #{cc.slack_url}"
45
+ puts "Pingback URL: #{cc.pingback_url}"
46
+ puts "Meta URL: #{cc.meta_url('shared-verify-token')}"
47
+ puts "CloudEvents URL: #{cc.cloudevents_url}"
48
+ puts "Discord URL: #{cc.discord_url('discord-app-public-key')}"
49
+
50
+ result = cc.claim(limit: 10, poll: true)
51
+
52
+ handled = []
53
+ result.requests.each do |request|
54
+ puts [request.method, request.path, request.text].join(" ")
55
+ handled << request.id
56
+ end
57
+ cc.ack(handled)
58
+ ```
59
+
60
+ `create_alias` is idempotent: calling it again with the same target returns the
61
+ same URL.
62
+
63
+ Protocol URL helpers return provider-ready receiver URLs. Webmention, WebSub,
64
+ Slack Events API, Pingback, Meta-style webhooks, CloudEvents, and Discord
65
+ Interactions deliveries arrive in the same inbox and are read with `peek` or
66
+ `claim`.
67
+
68
+ `meta_url(token)` adds an optional verify token for Meta-style handshakes.
69
+ `cloudevents_url` accepts binary, structured, and batched JSON CloudEvents.
70
+ `discord_url(app_public_key)` verifies Discord signatures and answers
71
+ interaction PINGs before storing non-PING interactions.
72
+
73
+ `limit` is optional. Omit it to use the service default:
74
+
75
+ ```ruby
76
+ result = cc.claim(poll: true)
77
+ ```
78
+
79
+ `peek` returns a cursor for live inspectors and dashboards:
80
+
81
+ ```ruby
82
+ page = cc.peek(poll: true)
83
+ nxt = cc.peek(cursor: page.cursor, poll: true)
84
+ ```
85
+
86
+ Call `CcMe.private_key` with no argument to create an in-memory key, or pass your
87
+ own stored base64url seed string to `CcMe::Client.new(private_key: ...)`.
88
+ `CcMe.private_key(path)` creates and reuses a key file, keeping it private to the
89
+ user (mode 0600) on Unix-like systems.
90
+
91
+ Each decrypted request exposes `id`, `received_at_unix_ms`, `method`, `path`,
92
+ `query`, `headers` (each with `name`, `value`, `value_bytes`), `body_bytes`, and
93
+ `text` / `json` helpers. The decrypted `id` is verified against the envelope
94
+ `id`.
95
+
96
+ The `inspect` subcommand from the JS CLI is intentionally not ported.
97
+
98
+ ## Build & test
99
+
100
+ ```sh
101
+ bundle install
102
+ bundle exec rake test
103
+ ```
data/exe/cc-me ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "cc_me/forward"
5
+
6
+ CcMe::Forward.main(ARGV)
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ # cc-me forward CLI.
4
+ #
5
+ # Ports the <tt><forward-url></tt> command from +client/js/forward.js+. The
6
+ # +inspect+ subcommand is intentionally not ported.
7
+
8
+ require "cc_me"
9
+
10
+ module CcMe
11
+ module Forward
12
+ DEFAULT_LIMIT = 10
13
+
14
+ HOP_BY_HOP = %w[
15
+ connection
16
+ content-length
17
+ host
18
+ keep-alive
19
+ proxy-authenticate
20
+ proxy-authorization
21
+ te
22
+ trailer
23
+ transfer-encoding
24
+ upgrade
25
+ ].freeze
26
+
27
+ module_function
28
+
29
+ def default_key_file
30
+ File.join(Dir.home, ".cc-me.key")
31
+ end
32
+
33
+ def usage
34
+ warn "usage:\n cc-me [--key <path>] <forward-url>"
35
+ end
36
+
37
+ def parse_args(args)
38
+ env_key = ENV["CC_ME_KEY"]
39
+ key_file = env_key && !env_key.empty? ? env_key : default_key_file
40
+ positionals = []
41
+
42
+ i = 0
43
+ while i < args.length
44
+ arg = args[i]
45
+ if arg == "--help" || arg == "-h"
46
+ usage
47
+ exit 0
48
+ elsif arg == "--key"
49
+ i += 1
50
+ raise Error, "--key needs a value" if i >= args.length || args[i].empty?
51
+
52
+ key_file = args[i]
53
+ elsif arg.start_with?("--key=")
54
+ value = arg.split("=", 2)[1]
55
+ raise Error, "--key needs a value" if value.nil? || value.empty?
56
+
57
+ key_file = value
58
+ elsif arg.start_with?("-")
59
+ raise Error, "unknown option: #{arg}"
60
+ else
61
+ positionals << arg
62
+ end
63
+ i += 1
64
+ end
65
+
66
+ raise Error, "only one forward URL is supported" if positionals.length > 1
67
+
68
+ [key_file, positionals.first]
69
+ end
70
+
71
+ # Build the forward target URL by merging the delivery query into the base.
72
+ def forward_url(base, query)
73
+ return base if query.nil? || query.empty?
74
+
75
+ if base.include?("?")
76
+ path, existing = base.split("?", 2)
77
+ existing.empty? ? "#{path}?#{query}" : "#{path}?#{existing}&#{query}"
78
+ else
79
+ "#{base}?#{query}"
80
+ end
81
+ end
82
+
83
+ # Replay a single delivery to the target. Raises on transport failure or a
84
+ # non-2xx response.
85
+ def forward_request(target, request)
86
+ uri = URI(forward_url(target, request.query))
87
+ has_body = request.method != "GET" && request.method != "HEAD" && !request.body_bytes.empty?
88
+
89
+ req = Net::HTTPGenericRequest.new(request.method, has_body, request.method != "HEAD", uri)
90
+ request.headers.each do |header|
91
+ next if HOP_BY_HOP.include?(header.name.downcase)
92
+
93
+ req[header.name] = header.value
94
+ end
95
+ req.body = request.body_bytes if has_body
96
+
97
+ http = Net::HTTP.new(uri.hostname, uri.port)
98
+ http.use_ssl = uri.scheme == "https"
99
+ response = http.request(req)
100
+ code = response.code.to_i
101
+ raise Error, "forward failed with #{code}" unless (200..299).cover?(code)
102
+ rescue SocketError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout => e
103
+ raise Error, "forward transport error: #{e.message}"
104
+ end
105
+
106
+ # Process one claimed batch: replay each delivery in order, acking on
107
+ # success. On a forward failure, ack the ids already handled, release the
108
+ # current and remaining ids, and re-raise. On full success, ack every
109
+ # handled id.
110
+ #
111
+ # The optional block replays a single delivery (defaults to
112
+ # +forward_request+); factored out so it is testable against a mock server.
113
+ def process_batch(client, requests, target = nil, &block)
114
+ forward = block || ->(request) { forward_request(target, request) }
115
+ acked = []
116
+
117
+ requests.each_with_index do |request, index|
118
+ begin
119
+ forward.call(request)
120
+ rescue StandardError
121
+ release_ids = requests[index..].map(&:id)
122
+ begin
123
+ client.ack(acked) unless acked.empty?
124
+ rescue StandardError
125
+ # already-handled ids are best-effort acked
126
+ end
127
+ begin
128
+ client.release(release_ids) unless release_ids.empty?
129
+ rescue StandardError
130
+ # remaining ids are best-effort released
131
+ end
132
+ raise
133
+ end
134
+
135
+ acked << request.id
136
+ suffix = request.query && !request.query.empty? ? "?#{request.query}" : ""
137
+ warn "#{request.method} #{request.path}#{suffix}"
138
+ end
139
+
140
+ client.ack(acked) unless acked.empty?
141
+ end
142
+
143
+ def run(args)
144
+ key_file, target = parse_args(args)
145
+ if target.nil?
146
+ usage
147
+ exit 64
148
+ end
149
+
150
+ key = CcMe.private_key(key_file)
151
+ client = CcMe::Client.new(private_key: key, base_url: ENV["CC_ME_URL"])
152
+
153
+ warn "cc.me inbox: #{client.inbox_url}"
154
+ warn "forwarding to: #{target}"
155
+
156
+ env_limit = ENV["CC_ME_LIMIT"]
157
+ limit = env_limit && !env_limit.empty? ? env_limit.to_i : DEFAULT_LIMIT
158
+
159
+ loop do
160
+ result = client.claim(limit: limit, poll: true)
161
+ process_batch(client, result.requests, target)
162
+ end
163
+ end
164
+
165
+ def main(args = ARGV)
166
+ run(args)
167
+ rescue SystemExit
168
+ raise
169
+ rescue StandardError => e
170
+ warn e.message
171
+ exit 1
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CcMe
4
+ VERSION = "0.1.0"
5
+ end
data/lib/cc_me.rb ADDED
@@ -0,0 +1,404 @@
1
+ # frozen_string_literal: true
2
+
3
+ # cc.me client library.
4
+ #
5
+ # Mirrors the canonical JavaScript implementation in +client/js/index.js+ and
6
+ # follows the wire protocol described in +client/PROTOCOL.md+. The Rust server
7
+ # in +src/main.rs+ is the source of truth for the wire format.
8
+
9
+ require "base64"
10
+ require "digest"
11
+ require "json"
12
+ require "net/http"
13
+ require "uri"
14
+
15
+ require "rbnacl"
16
+
17
+ require_relative "cc_me/version"
18
+
19
+ module CcMe
20
+ DEFAULT_BASE_URL = "https://cc.me/"
21
+ AUTH_VERSION = "cc-me-v1"
22
+ AUTH_TIMESTAMP_HEADER = "x-cc-me-timestamp"
23
+ AUTH_SIGNATURE_HEADER = "x-cc-me-signature"
24
+ SEED_BYTES = 32
25
+ SEALED_BOX_PUBLIC_KEY_BYTES = 32
26
+ SEALED_BOX_NONCE_BYTES = 24
27
+
28
+ # Raised for invalid keys, transport/non-2xx responses, and decode failures.
29
+ class Error < StandardError; end
30
+
31
+ # --- base64url helpers (no padding) -------------------------------------
32
+
33
+ def self.b64u_encode(bytes)
34
+ Base64.urlsafe_encode64(bytes, padding: false)
35
+ end
36
+
37
+ def self.b64u_decode(value)
38
+ str = value.to_s.strip
39
+ padding = "=" * ((4 - (str.length % 4)) % 4)
40
+ Base64.urlsafe_decode64(str + padding)
41
+ rescue ArgumentError => e
42
+ raise Error, "invalid base64url: #{e.message}"
43
+ end
44
+
45
+ def self.sha256_b64u(bytes)
46
+ b64u_encode(Digest::SHA256.digest(bytes))
47
+ end
48
+
49
+ # --- key handling --------------------------------------------------------
50
+
51
+ # Decode a base64url private key into its 32 seed bytes, validating length.
52
+ def self.seed_bytes(value)
53
+ seed = b64u_decode(value)
54
+ unless seed.bytesize == SEED_BYTES
55
+ raise Error, "private_key must be 32 bytes of base64url"
56
+ end
57
+
58
+ seed
59
+ end
60
+
61
+ def self.signing_key(value)
62
+ RbNaCl::SigningKey.new(seed_bytes(value))
63
+ end
64
+
65
+ def self.generate_private_key
66
+ b64u_encode(RbNaCl::Random.random_bytes(SEED_BYTES))
67
+ end
68
+
69
+ # Load or create a base64url Ed25519 seed.
70
+ #
71
+ # With +nil+ a fresh in-memory key is generated and returned (not persisted).
72
+ # With a +path+ the file is reused if present (and re-secured to mode 0600 on
73
+ # Unix), otherwise created with mode 0600 containing the base64url seed
74
+ # followed by a newline.
75
+ def self.private_key(path = nil)
76
+ return generate_private_key if path.nil?
77
+
78
+ if File.exist?(path)
79
+ key = File.read(path).strip
80
+ seed_bytes(key) # validate
81
+ secure_key_file(path)
82
+ return key
83
+ end
84
+
85
+ key = generate_private_key
86
+ File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
87
+ file.write("#{key}\n")
88
+ end
89
+ secure_key_file(path)
90
+ key
91
+ end
92
+
93
+ def self.secure_key_file(path)
94
+ return if Gem.win_platform?
95
+
96
+ File.chmod(0o600, path)
97
+ end
98
+
99
+ # --- URL helpers ---------------------------------------------------------
100
+
101
+ def self.normalize_base(base_url)
102
+ base = base_url.nil? || base_url.empty? ? DEFAULT_BASE_URL : base_url
103
+ base.end_with?("/") ? base : "#{base}/"
104
+ end
105
+
106
+ def self.trim_trailing_slash(value)
107
+ value.end_with?("/") ? value[0...-1] : value
108
+ end
109
+
110
+ # Percent-encode a single path segment, matching JS +encodeURIComponent+
111
+ # (everything but the RFC 3986 unreserved set).
112
+ def self.percent_encode(value)
113
+ value.to_s.b.each_byte.map do |byte|
114
+ if (0x41..0x5A).cover?(byte) || (0x61..0x7A).cover?(byte) ||
115
+ (0x30..0x39).cover?(byte) || [0x2D, 0x5F, 0x2E, 0x7E].include?(byte)
116
+ byte.chr
117
+ else
118
+ format("%%%02X", byte)
119
+ end
120
+ end.join
121
+ end
122
+
123
+ # Encode a query parameter value, matching JS +URLSearchParams+ / Python
124
+ # +urlencode+ (space becomes +).
125
+ def self.encode_query_value(value)
126
+ URI.encode_www_form_component(value.to_s)
127
+ end
128
+
129
+ # Build a trampoline URL: <tt>{base}/?at={target}</tt> plus any extra params.
130
+ def self.trampoline_url(target, base_url: nil, params: nil)
131
+ query = +"at=#{encode_query_value(target)}"
132
+ (params || {}).each do |key, value|
133
+ next if value.nil?
134
+
135
+ query << "&#{encode_query_value(key)}=#{encode_query_value(value)}"
136
+ end
137
+ "#{normalize_base(base_url)}?#{query}"
138
+ end
139
+
140
+ # Alias response wrapper: exposes +url+.
141
+ AliasResponse = Struct.new(:url)
142
+
143
+ # POST <tt>{base}/c</tt> with <tt>{"at": target}</tt> -> alias URL. Idempotent,
144
+ # no auth.
145
+ def self.create_alias(target, base_url: nil)
146
+ url = "#{normalize_base(base_url)}c"
147
+ body = JSON.generate("at" => target.to_s)
148
+ response = http_request("POST", url, body, "content-type" => "application/json")
149
+ AliasResponse.new(response["url"])
150
+ end
151
+
152
+ # --- HTTP helpers --------------------------------------------------------
153
+
154
+ def self.http_request(method, url, body, headers)
155
+ uri = URI(url)
156
+ request =
157
+ case method
158
+ when "GET" then Net::HTTP::Get.new(uri)
159
+ when "POST" then Net::HTTP::Post.new(uri)
160
+ else raise Error, "unsupported method #{method}"
161
+ end
162
+ headers.each { |name, value| request[name] = value }
163
+ request.body = body if body
164
+
165
+ http = Net::HTTP.new(uri.hostname, uri.port)
166
+ http.use_ssl = uri.scheme == "https"
167
+ parse_json_response(http.request(request))
168
+ rescue SocketError, SystemCallError, Net::OpenTimeout, Net::ReadTimeout => e
169
+ raise Error, "cc.me request failed: #{e.message}"
170
+ end
171
+
172
+ def self.parse_json_response(response)
173
+ raw = response.body
174
+ parsed =
175
+ if raw && !raw.empty?
176
+ begin
177
+ JSON.parse(raw)
178
+ rescue JSON::ParserError
179
+ {}
180
+ end
181
+ else
182
+ {}
183
+ end
184
+
185
+ code = response.code.to_i
186
+ unless (200..299).cover?(code)
187
+ message = (parsed.is_a?(Hash) && parsed["error"]) || "cc.me request failed with #{code}"
188
+ raise Error, message
189
+ end
190
+ parsed
191
+ end
192
+
193
+ # --- captured requests ---------------------------------------------------
194
+
195
+ CapturedHeader = Struct.new(:name, :value, :value_bytes)
196
+
197
+ # A decrypted delivery (the captured HTTP request).
198
+ class CapturedRequest
199
+ attr_reader :id, :received_at_unix_ms, :method, :path, :query, :headers, :body_bytes
200
+
201
+ def initialize(id:, received_at_unix_ms:, method:, path:, query:, headers:, body_bytes:)
202
+ @id = id
203
+ @received_at_unix_ms = received_at_unix_ms
204
+ @method = method
205
+ @path = path
206
+ @query = query
207
+ @headers = headers
208
+ @body_bytes = body_bytes
209
+ end
210
+
211
+ # Body decoded as UTF-8.
212
+ def text
213
+ @body_bytes.dup.force_encoding(Encoding::UTF_8)
214
+ end
215
+
216
+ # Body parsed as JSON.
217
+ def json
218
+ JSON.parse(text)
219
+ end
220
+ end
221
+
222
+ def self.decode_captured_request(plaintext)
223
+ parsed = JSON.parse(plaintext)
224
+ body_bytes = b64u_decode(parsed["body_b64u"])
225
+ headers = (parsed["headers"] || []).map do |header|
226
+ value_bytes = b64u_decode(header["value_b64u"])
227
+ value = value_bytes.dup.force_encoding(Encoding::UTF_8)
228
+ value = value.scrub unless value.valid_encoding?
229
+ CapturedHeader.new(header["name"], value, value_bytes)
230
+ end
231
+ CapturedRequest.new(
232
+ id: parsed["id"],
233
+ received_at_unix_ms: parsed["received_at_unix_ms"],
234
+ method: parsed["method"],
235
+ path: parsed["path"],
236
+ query: parsed["query"],
237
+ headers: headers,
238
+ body_bytes: body_bytes
239
+ )
240
+ end
241
+
242
+ # Response from peek/claim: the count, raw items, cursor, and decrypted
243
+ # requests.
244
+ DeliveryResponse = Struct.new(:count, :items, :cursor, :requests, keyword_init: true)
245
+
246
+ # A client bound to a single private key and base URL.
247
+ class Client
248
+ def initialize(private_key:, base_url: nil)
249
+ raise Error, "private_key is required" if private_key.nil? || private_key.empty?
250
+
251
+ @private_key = private_key
252
+ @base_url = CcMe.normalize_base(base_url)
253
+ @signing_key = CcMe.signing_key(private_key)
254
+ @public_key = CcMe.b64u_encode(@signing_key.verify_key.to_bytes)
255
+
256
+ # Recipient X25519 secret key, derived from the Ed25519 seed the same way
257
+ # libsodium's crypto_sign_ed25519_sk_to_curve25519 does: the first 32
258
+ # bytes of SHA512(seed). The X25519 public key is then scalarmult_base of
259
+ # that secret, which equals the Montgomery form of the Ed25519 public key.
260
+ seed = CcMe.seed_bytes(private_key)
261
+ @x_secret = RbNaCl::PrivateKey.new(Digest::SHA512.digest(seed)[0, 32])
262
+ @x_public = @x_secret.public_key
263
+ end
264
+
265
+ # -- URL helpers --
266
+
267
+ def inbox_url(limit: nil, cursor: nil, poll: false)
268
+ "#{CcMe.trim_trailing_slash(@base_url)}#{inbox_query(limit: limit, cursor: cursor, poll: poll)}"
269
+ end
270
+
271
+ def webmention_url
272
+ protocol_url("webmention")
273
+ end
274
+
275
+ def websub_url
276
+ protocol_url("websub")
277
+ end
278
+
279
+ def slack_url
280
+ protocol_url("slack")
281
+ end
282
+
283
+ def pingback_url
284
+ protocol_url("pingback")
285
+ end
286
+
287
+ def meta_url(verify_token = nil)
288
+ base = protocol_url("meta")
289
+ return base if verify_token.nil?
290
+
291
+ "#{base}?v=#{CcMe.encode_query_value(verify_token)}"
292
+ end
293
+
294
+ def cloudevents_url
295
+ protocol_url("cloudevents")
296
+ end
297
+
298
+ def discord_url(app_public_key)
299
+ if app_public_key.nil? || app_public_key.to_s.empty?
300
+ raise Error, "app_public_key is required"
301
+ end
302
+
303
+ "#{CcMe.trim_trailing_slash(@base_url)}#{inbox_path}/discord/#{CcMe.percent_encode(app_public_key)}"
304
+ end
305
+
306
+ # -- requests --
307
+
308
+ def peek(limit: nil, cursor: nil, poll: false, decrypt: true)
309
+ path_and_query = inbox_query(limit: limit, cursor: cursor, poll: poll)
310
+ url = "#{CcMe.trim_trailing_slash(@base_url)}#{path_and_query}"
311
+ headers = sign("GET", path_and_query, "")
312
+ decrypt_response(CcMe.http_request("GET", url, nil, headers), decrypt)
313
+ end
314
+
315
+ def claim(limit: nil, poll: false, decrypt: true)
316
+ payload = { "poll" => poll }
317
+ payload["limit"] = limit unless limit.nil?
318
+ body = JSON.generate(payload)
319
+ decrypt_response(signed_post("claim", body), decrypt)
320
+ end
321
+
322
+ def ack(ids)
323
+ signed_post("ack", JSON.generate("ids" => Array(ids)))
324
+ end
325
+
326
+ def release(ids)
327
+ signed_post("release", JSON.generate("ids" => Array(ids)))
328
+ end
329
+
330
+ private
331
+
332
+ def inbox_path
333
+ "/i/#{@public_key}"
334
+ end
335
+
336
+ # Build the inbox path+query string used both for signing and the wire.
337
+ def inbox_query(limit: nil, cursor: nil, poll: false)
338
+ path = inbox_path.dup
339
+ params = []
340
+ params << "l=#{limit}" unless limit.nil?
341
+ params << "c=#{CcMe.encode_query_value(cursor)}" unless cursor.nil?
342
+ params << "p=" if poll
343
+ path << "?#{params.join('&')}" unless params.empty?
344
+ path
345
+ end
346
+
347
+ def protocol_url(protocol)
348
+ "#{CcMe.trim_trailing_slash(@base_url)}#{inbox_path}/#{protocol}"
349
+ end
350
+
351
+ def signed_post(action, body)
352
+ path_and_query = "#{inbox_path}/#{action}"
353
+ url = "#{CcMe.trim_trailing_slash(@base_url)}#{path_and_query}"
354
+ headers = { "content-type" => "application/json" }.merge(sign("POST", path_and_query, body))
355
+ CcMe.http_request("POST", url, body, headers)
356
+ end
357
+
358
+ # Build the two owner-auth headers for a request. The +path_and_query+ bytes
359
+ # signed here MUST equal the bytes sent on the wire.
360
+ def sign(method, path_and_query, body)
361
+ timestamp = Time.now.to_i
362
+ message = "#{AUTH_VERSION}\n#{method}\n#{path_and_query}\n#{timestamp}\n#{CcMe.sha256_b64u(body)}"
363
+ {
364
+ AUTH_TIMESTAMP_HEADER => timestamp.to_s,
365
+ AUTH_SIGNATURE_HEADER => CcMe.b64u_encode(@signing_key.sign(message))
366
+ }
367
+ end
368
+
369
+ def decrypt_response(body, decrypt)
370
+ items = body["items"] || []
371
+ requests = decrypt ? items.map { |item| decrypt_envelope(item) } : []
372
+ DeliveryResponse.new(
373
+ count: body.fetch("count", items.length),
374
+ items: items,
375
+ cursor: body["cursor"],
376
+ requests: requests
377
+ )
378
+ end
379
+
380
+ def decrypt_envelope(envelope)
381
+ sealed = CcMe.b64u_decode(envelope["sealed"])
382
+ if sealed.bytesize <= SEALED_BOX_PUBLIC_KEY_BYTES
383
+ raise Error, "encrypted delivery is too short"
384
+ end
385
+
386
+ ephemeral_public = sealed[0, SEALED_BOX_PUBLIC_KEY_BYTES]
387
+ box = sealed[SEALED_BOX_PUBLIC_KEY_BYTES..]
388
+ nonce = RbNaCl::Hash.blake2b(
389
+ ephemeral_public + @x_public.to_bytes,
390
+ digest_size: SEALED_BOX_NONCE_BYTES
391
+ )
392
+ begin
393
+ plaintext = RbNaCl::Box.new(RbNaCl::PublicKey.new(ephemeral_public), @x_secret).open(nonce, box)
394
+ rescue RbNaCl::CryptoError
395
+ raise Error, "failed to decrypt delivery"
396
+ end
397
+
398
+ request = CcMe.decode_captured_request(plaintext)
399
+ raise Error, "delivery id mismatch" unless request.id == envelope["id"]
400
+
401
+ request
402
+ end
403
+ end
404
+ end
metadata ADDED
@@ -0,0 +1,78 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: cc-me
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - xmit dev team
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-01 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: base64
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.2'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rbnacl
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '7.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '7.1'
40
+ description: |-
41
+ Ruby client for cc.me. Builds trampoline and inbox URLs and decrypts
42
+ deliveries; the cc-me CLI forwards inbox deliveries to a local endpoint.
43
+ Mirrors the canonical JavaScript client.
44
+ executables:
45
+ - cc-me
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - README.md
50
+ - exe/cc-me
51
+ - lib/cc_me.rb
52
+ - lib/cc_me/forward.rb
53
+ - lib/cc_me/version.rb
54
+ homepage: https://cc.me/
55
+ licenses:
56
+ - MIT
57
+ metadata:
58
+ homepage_uri: https://cc.me/
59
+ source_code_uri: https://github.com/xmit-co/cc.me
60
+ documentation_uri: https://github.com/xmit-co/cc.me/blob/main/client/PROTOCOL.md
61
+ rdoc_options: []
62
+ require_paths:
63
+ - lib
64
+ required_ruby_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '3.0'
69
+ required_rubygems_version: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: '0'
74
+ requirements: []
75
+ rubygems_version: 3.7.2
76
+ specification_version: 4
77
+ summary: cc.me trampoline and encrypted webhook queue client + CLI
78
+ test_files: []