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 +7 -0
- data/README.md +103 -0
- data/exe/cc-me +6 -0
- data/lib/cc_me/forward.rb +174 -0
- data/lib/cc_me/version.rb +5 -0
- data/lib/cc_me.rb +404 -0
- metadata +78 -0
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,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
|
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: []
|