ruby_everywhere 0.1.7 → 0.1.8
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 +4 -4
- data/lib/everywhere/blake2b.rb +116 -0
- data/lib/everywhere/cli.rb +4 -0
- data/lib/everywhere/commands/publish.rb +267 -0
- data/lib/everywhere/commands/updates_keygen.rb +69 -0
- data/lib/everywhere/config.rb +38 -1
- data/lib/everywhere/javascript/bridge.js +75 -0
- data/lib/everywhere/minisign.rb +205 -0
- data/lib/everywhere/s3.rb +232 -0
- data/lib/everywhere/update_manifest.rb +115 -0
- data/lib/everywhere/version.rb +2 -2
- data/support/shell/src-tauri/Cargo.lock +123 -1
- data/support/shell/src-tauri/Cargo.toml +7 -1
- data/support/shell/src-tauri/src/main.rs +16 -0
- data/support/shell/src-tauri/src/updater.rs +650 -0
- data/support/shell/src-tauri/tauri.conf.json +1 -1
- metadata +23 -2
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "base64"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require_relative "blake2b"
|
|
7
|
+
|
|
8
|
+
module Everywhere
|
|
9
|
+
# Minisign-compatible Ed25519 signing, stdlib-only (OpenSSL + our Blake2b).
|
|
10
|
+
#
|
|
11
|
+
# This is the update-artifact signing scheme: `every updates keygen` mints a
|
|
12
|
+
# keypair, `every publish` signs each artifact, and the shell verifies with
|
|
13
|
+
# the `minisign-verify` Rust crate before installing an update. Files are
|
|
14
|
+
# byte-compatible with the reference minisign tool — keys made here sign with
|
|
15
|
+
# `minisign -S`, keys made there publish here, and every `.minisig` verifies
|
|
16
|
+
# with `minisign -V`.
|
|
17
|
+
#
|
|
18
|
+
# Formats (https://jedisct1.github.io/minisign/):
|
|
19
|
+
# public key "Ed" || key_id[8] || ed25519_pk[32], base64, comment line above
|
|
20
|
+
# secret key "Ed" || "Sc" || "B2" || salt[32] || opslimit[8 LE] ||
|
|
21
|
+
# memlimit[8 LE] || (key_id[8] || sk[64] || checksum[32]) XOR
|
|
22
|
+
# scrypt(passphrase); checksum = BLAKE2b-256("Ed"||key_id||sk)
|
|
23
|
+
# signature "ED" || key_id[8] || sig[64] over BLAKE2b-512(file) —
|
|
24
|
+
# prehashed mode, so signing never loads the artifact whole —
|
|
25
|
+
# plus a global sig over sig||trusted_comment
|
|
26
|
+
module Minisign
|
|
27
|
+
Error = Class.new(StandardError)
|
|
28
|
+
VerifyError = Class.new(Error)
|
|
29
|
+
PassphraseError = Class.new(Error)
|
|
30
|
+
|
|
31
|
+
UNTRUSTED = "signature from ruby_everywhere secret key"
|
|
32
|
+
|
|
33
|
+
# libsodium scryptsalsa208sha256 "interactive" cost (N=2^14, r=8, p=1 —
|
|
34
|
+
# 16 MB, milliseconds to derive). The reference tool defaults to
|
|
35
|
+
# "sensitive" (1 GB); both read whatever the key file declares, so the
|
|
36
|
+
# cheaper default costs no compatibility.
|
|
37
|
+
KDF_OPSLIMIT = 524_288
|
|
38
|
+
KDF_MEMLIMIT = 16_777_216
|
|
39
|
+
|
|
40
|
+
KeyPair = Struct.new(:public_key, :secret_key, :key_id_hex, keyword_init: true)
|
|
41
|
+
|
|
42
|
+
module_function
|
|
43
|
+
|
|
44
|
+
# ---- keygen --------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
def keygen(passphrase: "")
|
|
47
|
+
pkey = OpenSSL::PKey.generate_key("ED25519")
|
|
48
|
+
key_id = SecureRandom.bytes(8)
|
|
49
|
+
sk = pkey.raw_private_key + pkey.raw_public_key # 32-byte seed + 32-byte pk
|
|
50
|
+
|
|
51
|
+
checksum = Blake2b.digest("Ed#{key_id}#{sk}", 32)
|
|
52
|
+
salt = SecureRandom.bytes(32)
|
|
53
|
+
stream = kdf_stream(passphrase, salt, KDF_OPSLIMIT, KDF_MEMLIMIT)
|
|
54
|
+
|
|
55
|
+
secret_blob = "EdScB2#{salt}#{[KDF_OPSLIMIT, KDF_MEMLIMIT].pack("Q<Q<")}" \
|
|
56
|
+
"#{xor_bytes("#{key_id}#{sk}#{checksum}", stream)}"
|
|
57
|
+
public_blob = "Ed#{key_id}#{pkey.raw_public_key}"
|
|
58
|
+
id_hex = key_id_hex(key_id)
|
|
59
|
+
|
|
60
|
+
KeyPair.new(
|
|
61
|
+
public_key: "untrusted comment: minisign public key #{id_hex}\n#{Base64.strict_encode64(public_blob)}\n",
|
|
62
|
+
secret_key: "untrusted comment: minisign encrypted secret key\n#{Base64.strict_encode64(secret_blob)}\n",
|
|
63
|
+
key_id_hex: id_hex
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# ---- signing -------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
# Returns the full .minisig content for `path`.
|
|
70
|
+
def sign_file(path, secret_key:, passphrase: "", trusted_comment: nil)
|
|
71
|
+
key_id, seed = decrypt_secret_key(secret_key, passphrase)
|
|
72
|
+
pkey = OpenSSL::PKey.new_raw_private_key("ED25519", seed)
|
|
73
|
+
|
|
74
|
+
signature = pkey.sign(nil, Blake2b.digest64_file(path))
|
|
75
|
+
trusted_comment ||= "timestamp:#{Time.now.to_i}\tfile:#{File.basename(path)}\thashed"
|
|
76
|
+
global = pkey.sign(nil, signature + trusted_comment.b)
|
|
77
|
+
|
|
78
|
+
"untrusted comment: #{UNTRUSTED}\n" \
|
|
79
|
+
"#{Base64.strict_encode64("ED#{key_id}#{signature}")}\n" \
|
|
80
|
+
"trusted comment: #{trusted_comment}\n" \
|
|
81
|
+
"#{Base64.strict_encode64(global)}\n"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# ---- verification --------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
# `public_key` may be the two-line key file or just its base64 line.
|
|
87
|
+
# Raises VerifyError unless everything checks out.
|
|
88
|
+
def verify_file(path, signature:, public_key:)
|
|
89
|
+
alg, key_id, sig, trusted_comment, global = parse_signature(signature)
|
|
90
|
+
pk_key_id, pk = parse_public_key(public_key)
|
|
91
|
+
|
|
92
|
+
raise VerifyError, "signature key id #{key_id_hex(key_id)} doesn't match public key #{key_id_hex(pk_key_id)}" unless key_id == pk_key_id
|
|
93
|
+
|
|
94
|
+
pkey = OpenSSL::PKey.new_raw_public_key("ED25519", pk)
|
|
95
|
+
message = alg == "ED" ? Blake2b.digest64_file(path) : File.binread(path)
|
|
96
|
+
raise VerifyError, "file signature invalid for #{File.basename(path)}" unless pkey.verify(nil, sig, message)
|
|
97
|
+
raise VerifyError, "trusted comment signature invalid" unless pkey.verify(nil, global, sig + trusted_comment.b)
|
|
98
|
+
|
|
99
|
+
true
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The bare base64 line of a public key — what everywhere.yml stores and the
|
|
103
|
+
# shell's minisign-verify consumes.
|
|
104
|
+
def public_key_base64(content)
|
|
105
|
+
line = content.lines.map(&:strip).reject { |l| l.empty? || l.start_with?("untrusted comment:") }.first
|
|
106
|
+
raise Error, "no public key data found" unless line
|
|
107
|
+
|
|
108
|
+
blob = decode64(line, "public key")
|
|
109
|
+
raise Error, "unsupported public key format" unless blob.bytesize == 42 && blob.start_with?("Ed")
|
|
110
|
+
|
|
111
|
+
line
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# ---- internals -----------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def parse_public_key(content)
|
|
117
|
+
blob = decode64(public_key_base64(content), "public key")
|
|
118
|
+
[blob.byteslice(2, 8), blob.byteslice(10, 32)]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def parse_signature(content)
|
|
122
|
+
lines = content.lines.map(&:chomp)
|
|
123
|
+
sig_b64 = lines.find { |l| !l.start_with?("untrusted comment:") && !l.strip.empty? }
|
|
124
|
+
trusted = lines.find { |l| l.start_with?("trusted comment: ") }&.delete_prefix("trusted comment: ")
|
|
125
|
+
global_b64 = lines[lines.index { |l| l.start_with?("trusted comment:") }.to_i + 1] if trusted
|
|
126
|
+
raise VerifyError, "malformed signature file" unless sig_b64 && trusted && global_b64
|
|
127
|
+
|
|
128
|
+
blob = decode64(sig_b64, "signature")
|
|
129
|
+
alg = blob.byteslice(0, 2)
|
|
130
|
+
raise VerifyError, "unsupported signature algorithm #{alg.inspect}" unless %w[ED Ed].include?(alg)
|
|
131
|
+
raise VerifyError, "malformed signature" unless blob.bytesize == 74
|
|
132
|
+
|
|
133
|
+
[alg, blob.byteslice(2, 8), blob.byteslice(10, 64), trusted, decode64(global_b64, "global signature")]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# -> [key_id, ed25519_seed]
|
|
137
|
+
def decrypt_secret_key(content, passphrase)
|
|
138
|
+
raise Error, "no secret key material given" if content.to_s.strip.empty?
|
|
139
|
+
|
|
140
|
+
line = content.lines.map(&:strip).reject { |l| l.empty? || l.start_with?("untrusted comment:") }.first
|
|
141
|
+
blob = decode64(line.to_s, "secret key")
|
|
142
|
+
kdf_alg = blob.byteslice(2, 2)
|
|
143
|
+
unless blob.bytesize == 158 && blob.start_with?("Ed") && ["Sc", "\x00\x00"].include?(kdf_alg) &&
|
|
144
|
+
blob.byteslice(4, 2) == "B2"
|
|
145
|
+
raise Error, "unsupported secret key format"
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
keynum = if kdf_alg == "Sc"
|
|
149
|
+
salt = blob.byteslice(6, 32)
|
|
150
|
+
opslimit, memlimit = blob.byteslice(38, 16).unpack("Q<Q<")
|
|
151
|
+
xor_bytes(blob.byteslice(54, 104), kdf_stream(passphrase, salt, opslimit, memlimit))
|
|
152
|
+
else
|
|
153
|
+
blob.byteslice(54, 104) # null KDF (minisign -W): stored in the clear
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
key_id = keynum.byteslice(0, 8)
|
|
157
|
+
sk = keynum.byteslice(8, 64)
|
|
158
|
+
checksum = keynum.byteslice(72, 32)
|
|
159
|
+
# Reference minisign writes an all-zero checksum for unencrypted (-W)
|
|
160
|
+
# keys and skips the check; a wrong checksum on an encrypted key means a
|
|
161
|
+
# wrong passphrase (scrypt stream decrypted to garbage).
|
|
162
|
+
unless (kdf_alg != "Sc" && checksum == ("\0" * 32).b) ||
|
|
163
|
+
OpenSSL.secure_compare(checksum, Blake2b.digest("Ed#{key_id}#{sk}", 32))
|
|
164
|
+
raise PassphraseError, "wrong passphrase (or corrupt secret key)"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
[key_id, sk.byteslice(0, 32)]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def kdf_stream(passphrase, salt, opslimit, memlimit)
|
|
171
|
+
n, r, p = scrypt_params(opslimit, memlimit)
|
|
172
|
+
OpenSSL::KDF.scrypt(passphrase.to_s, salt: salt, N: n, r: r, p: p, length: 104)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Port of libsodium's pickparams: how opslimit/memlimit (what the key file
|
|
176
|
+
# stores) map to scrypt's N/r/p.
|
|
177
|
+
def scrypt_params(opslimit, memlimit)
|
|
178
|
+
opslimit = 32_768 if opslimit < 32_768
|
|
179
|
+
r = 8
|
|
180
|
+
if opslimit < memlimit / 32
|
|
181
|
+
max_n = opslimit / (r * 4)
|
|
182
|
+
[1 << n_log2(max_n), r, 1]
|
|
183
|
+
else
|
|
184
|
+
max_n = memlimit / (r * 128)
|
|
185
|
+
n = 1 << n_log2(max_n)
|
|
186
|
+
max_rp = [(opslimit / 4) / n, 0x3fffffff].min
|
|
187
|
+
[n, r, [max_rp / r, 1].max]
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def n_log2(max_n) = (1..62).find { |i| (1 << i) > max_n / 2 } || 63
|
|
192
|
+
|
|
193
|
+
def key_id_hex(key_id) = key_id.unpack1("Q<").to_s(16).upcase
|
|
194
|
+
|
|
195
|
+
def xor_bytes(a, b)
|
|
196
|
+
a.unpack("C*").zip(b.unpack("C*")).map { |x, y| x ^ y }.pack("C*")
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def decode64(str, what)
|
|
200
|
+
Base64.strict_decode64(str.strip)
|
|
201
|
+
rescue ArgumentError
|
|
202
|
+
raise Error, "invalid base64 in #{what}"
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "time"
|
|
7
|
+
|
|
8
|
+
module Everywhere
|
|
9
|
+
# Minimal S3 client — just the four operations `every publish` needs
|
|
10
|
+
# (PUT / HEAD / GET / CopyObject), signed with AWS Signature V4, stdlib only.
|
|
11
|
+
#
|
|
12
|
+
# Deliberately not aws-sdk-s3: the gem keeps a near-zero runtime dependency
|
|
13
|
+
# footprint, and SigV4 is a stable, testable ~150 lines. Works against any
|
|
14
|
+
# S3-compatible endpoint — AWS, Cloudflare R2, DigitalOcean Spaces, MinIO.
|
|
15
|
+
#
|
|
16
|
+
# Addressing: virtual-host style for AWS proper, path style for custom
|
|
17
|
+
# endpoints (R2/Spaces/MinIO all accept it; MinIO requires it). Override
|
|
18
|
+
# with force_path_style.
|
|
19
|
+
class S3
|
|
20
|
+
Error = Class.new(StandardError)
|
|
21
|
+
|
|
22
|
+
ALGORITHM = "AWS4-HMAC-SHA256"
|
|
23
|
+
UNSIGNED = "UNSIGNED-PAYLOAD"
|
|
24
|
+
|
|
25
|
+
attr_reader :bucket
|
|
26
|
+
|
|
27
|
+
def initialize(bucket:, region: "auto", endpoint: nil, force_path_style: nil,
|
|
28
|
+
access_key_id:, secret_access_key:, session_token: nil)
|
|
29
|
+
@bucket = bucket
|
|
30
|
+
@region = region.to_s.empty? ? "auto" : region
|
|
31
|
+
@access_key_id = access_key_id
|
|
32
|
+
@secret_access_key = secret_access_key
|
|
33
|
+
@session_token = session_token
|
|
34
|
+
|
|
35
|
+
if endpoint && !endpoint.empty?
|
|
36
|
+
@endpoint = URI(endpoint)
|
|
37
|
+
@path_style = force_path_style.nil? ? true : force_path_style
|
|
38
|
+
else
|
|
39
|
+
@endpoint = URI("https://s3.#{aws_region}.amazonaws.com")
|
|
40
|
+
@path_style = force_path_style || false
|
|
41
|
+
end
|
|
42
|
+
raise Error, "endpoint must be http(s)" unless %w[http https].include?(@endpoint.scheme)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# body: String or IO (IO must respond to #size or be paired with size:).
|
|
46
|
+
# sha256: precomputed hex payload digest; IOs default to UNSIGNED-PAYLOAD.
|
|
47
|
+
def put_object(key, body, content_type: nil, cache_control: nil, sha256: nil, size: nil)
|
|
48
|
+
headers = {}
|
|
49
|
+
headers["content-type"] = content_type if content_type
|
|
50
|
+
headers["cache-control"] = cache_control if cache_control
|
|
51
|
+
if body.is_a?(String)
|
|
52
|
+
sha256 ||= OpenSSL::Digest::SHA256.hexdigest(body)
|
|
53
|
+
headers["content-length"] = body.bytesize.to_s
|
|
54
|
+
else
|
|
55
|
+
sha256 ||= UNSIGNED
|
|
56
|
+
headers["content-length"] = (size || body.size).to_s
|
|
57
|
+
end
|
|
58
|
+
request(:put, key, headers: headers, body: body, payload_sha256: sha256)
|
|
59
|
+
true
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# -> { size:, etag: } or nil when the key doesn't exist.
|
|
63
|
+
def head_object(key)
|
|
64
|
+
res = request(:head, key, allow_404: true)
|
|
65
|
+
return nil if res.code == "404"
|
|
66
|
+
|
|
67
|
+
{ size: res["content-length"]&.to_i, etag: res["etag"]&.delete('"') }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# -> body String, or nil when the key doesn't exist.
|
|
71
|
+
def get_object(key)
|
|
72
|
+
res = request(:get, key, allow_404: true)
|
|
73
|
+
res.code == "404" ? nil : res.body
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Server-side copy within the bucket (how the -latest alias is refreshed
|
|
77
|
+
# without re-uploading the artifact).
|
|
78
|
+
def copy_object(source_key, dest_key, content_type: nil, cache_control: nil)
|
|
79
|
+
headers = { "x-amz-copy-source" => "/#{@bucket}/#{uri_encode_path(source_key)}" }
|
|
80
|
+
if content_type || cache_control
|
|
81
|
+
headers["x-amz-metadata-directive"] = "REPLACE"
|
|
82
|
+
headers["content-type"] = content_type if content_type
|
|
83
|
+
headers["cache-control"] = cache_control if cache_control
|
|
84
|
+
end
|
|
85
|
+
res = request(:put, dest_key, headers: headers)
|
|
86
|
+
# CopyObject reports some failures inside a 200 body.
|
|
87
|
+
raise Error, "copy #{source_key} -> #{dest_key} failed: #{res.body}" if res.body.to_s.include?("<Error>")
|
|
88
|
+
|
|
89
|
+
true
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# ---- request plumbing ----------------------------------------------------
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def request(method, key, headers: {}, body: nil, payload_sha256: nil, allow_404: false, attempt: 1)
|
|
97
|
+
uri = object_uri(key)
|
|
98
|
+
payload_sha256 ||= OpenSSL::Digest::SHA256.hexdigest("")
|
|
99
|
+
all = headers.merge(Signer.headers(
|
|
100
|
+
method: method, uri: uri, headers: headers, payload_sha256: payload_sha256,
|
|
101
|
+
access_key_id: @access_key_id, secret_access_key: @secret_access_key,
|
|
102
|
+
session_token: @session_token, region: aws_region
|
|
103
|
+
))
|
|
104
|
+
|
|
105
|
+
req = Net::HTTP.const_get(method.capitalize).new(uri.request_uri)
|
|
106
|
+
all.each { |k, v| req[k] = v }
|
|
107
|
+
if body.is_a?(String)
|
|
108
|
+
req.body = body
|
|
109
|
+
elsif body
|
|
110
|
+
req.body_stream = body
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", read_timeout: 300) do |http|
|
|
114
|
+
http.request(req)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
return res if res.is_a?(Net::HTTPSuccess) || (allow_404 && res.code == "404")
|
|
118
|
+
|
|
119
|
+
if retryable?(res) && attempt < 3 && body_rewindable?(body)
|
|
120
|
+
sleep(attempt)
|
|
121
|
+
body.rewind if body.respond_to?(:rewind) && !body.is_a?(String)
|
|
122
|
+
return request(method, key, headers: headers, body: body, payload_sha256: payload_sha256,
|
|
123
|
+
allow_404: allow_404, attempt: attempt + 1)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
raise Error, "S3 #{method.upcase} #{key} failed (HTTP #{res.code}): #{error_detail(res)}"
|
|
127
|
+
rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout, SocketError => e
|
|
128
|
+
raise Error, "S3 #{method.upcase} #{key} failed: #{e.message}" unless attempt < 3 && body_rewindable?(body)
|
|
129
|
+
|
|
130
|
+
sleep(attempt)
|
|
131
|
+
body.rewind if body.respond_to?(:rewind) && !body.is_a?(String)
|
|
132
|
+
request(method, key, headers: headers, body: body, payload_sha256: payload_sha256,
|
|
133
|
+
allow_404: allow_404, attempt: attempt + 1)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def retryable?(res) = res.code.to_i >= 500 || res.code == "429"
|
|
137
|
+
|
|
138
|
+
def body_rewindable?(body) = body.nil? || body.is_a?(String) || body.respond_to?(:rewind)
|
|
139
|
+
|
|
140
|
+
def error_detail(res)
|
|
141
|
+
code = res.body.to_s[%r{<Code>([^<]+)</Code>}, 1]
|
|
142
|
+
msg = res.body.to_s[%r{<Message>([^<]+)</Message>}, 1]
|
|
143
|
+
[code, msg].compact.join(": ").then { |s| s.empty? ? res.message : s }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def object_uri(key)
|
|
147
|
+
encoded = uri_encode_path(key)
|
|
148
|
+
if @path_style
|
|
149
|
+
URI("#{@endpoint.scheme}://#{@endpoint.host}:#{@endpoint.port}/#{@bucket}/#{encoded}")
|
|
150
|
+
else
|
|
151
|
+
host = @endpoint.host.start_with?("#{@bucket}.") ? @endpoint.host : "#{@bucket}.#{@endpoint.host}"
|
|
152
|
+
URI("#{@endpoint.scheme}://#{host}:#{@endpoint.port}/#{encoded}")
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# RFC 3986 encoding per path segment ('/' preserved) — SigV4's canonical form.
|
|
157
|
+
def uri_encode_path(key)
|
|
158
|
+
key.split("/", -1).map { |seg| Signer.uri_encode(seg) }.join("/")
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def aws_region = @region == "auto" ? "us-east-1" : @region
|
|
162
|
+
|
|
163
|
+
# Pure SigV4 header computation, separated for direct testing against the
|
|
164
|
+
# published AWS test vectors.
|
|
165
|
+
module Signer
|
|
166
|
+
module_function
|
|
167
|
+
|
|
168
|
+
def headers(method:, uri:, headers:, payload_sha256:, access_key_id:, secret_access_key:,
|
|
169
|
+
session_token: nil, region:, time: Time.now.utc)
|
|
170
|
+
amz_date = time.strftime("%Y%m%dT%H%M%SZ")
|
|
171
|
+
date = time.strftime("%Y%m%d")
|
|
172
|
+
|
|
173
|
+
signed = {
|
|
174
|
+
"host" => host_header(uri),
|
|
175
|
+
"x-amz-content-sha256" => payload_sha256,
|
|
176
|
+
"x-amz-date" => amz_date
|
|
177
|
+
}
|
|
178
|
+
signed["x-amz-security-token"] = session_token if session_token
|
|
179
|
+
# Sign every x-amz-* header we send (S3 requires it) plus the core set.
|
|
180
|
+
canonical_headers = headers.select { |k, _| k.downcase.start_with?("x-amz-") || %w[range content-type content-md5 cache-control date expires].include?(k.downcase) }
|
|
181
|
+
.transform_keys(&:downcase).merge(signed)
|
|
182
|
+
|
|
183
|
+
sorted = canonical_headers.sort.to_h
|
|
184
|
+
header_list = sorted.keys.join(";")
|
|
185
|
+
canonical_request = [
|
|
186
|
+
method.to_s.upcase,
|
|
187
|
+
uri.path.empty? ? "/" : uri.path,
|
|
188
|
+
canonical_query(uri.query),
|
|
189
|
+
sorted.map { |k, v| "#{k}:#{v.to_s.strip}\n" }.join,
|
|
190
|
+
header_list,
|
|
191
|
+
payload_sha256
|
|
192
|
+
].join("\n")
|
|
193
|
+
|
|
194
|
+
scope = "#{date}/#{region}/s3/aws4_request"
|
|
195
|
+
string_to_sign = [
|
|
196
|
+
ALGORITHM, amz_date, scope,
|
|
197
|
+
OpenSSL::Digest::SHA256.hexdigest(canonical_request)
|
|
198
|
+
].join("\n")
|
|
199
|
+
|
|
200
|
+
k = %W[AWS4#{secret_access_key} #{date} #{region} s3 aws4_request]
|
|
201
|
+
.reduce { |key, data| OpenSSL::HMAC.digest("SHA256", key, data) }
|
|
202
|
+
signature = OpenSSL::HMAC.hexdigest("SHA256", k, string_to_sign)
|
|
203
|
+
|
|
204
|
+
signed.merge(
|
|
205
|
+
"authorization" => "#{ALGORITHM} Credential=#{access_key_id}/#{scope}, " \
|
|
206
|
+
"SignedHeaders=#{header_list}, Signature=#{signature}"
|
|
207
|
+
)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Canonical form: every param as key=value (empty value keeps the "="),
|
|
211
|
+
# sorted. Callers pass unencoded simple queries; we never need decoding.
|
|
212
|
+
def canonical_query(query)
|
|
213
|
+
return "" if query.nil? || query.empty?
|
|
214
|
+
|
|
215
|
+
query.split("&").map { |pair|
|
|
216
|
+
k, v = pair.split("=", 2)
|
|
217
|
+
"#{uri_encode(k)}=#{uri_encode(v || "")}"
|
|
218
|
+
}.sort.join("&")
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def host_header(uri)
|
|
222
|
+
default_port = uri.scheme == "https" ? 443 : 80
|
|
223
|
+
uri.port == default_port ? uri.host : "#{uri.host}:#{uri.port}"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# RFC 3986 unreserved-only encoding (AWS canonical form).
|
|
227
|
+
def uri_encode(str)
|
|
228
|
+
str.to_s.gsub(/[^A-Za-z0-9\-._~]/) { |c| c.bytes.map { |b| format("%%%02X", b) }.join }
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module Everywhere
|
|
7
|
+
# The update-feed contract: bucket key layout + latest.json / index.json
|
|
8
|
+
# bodies. This is THE interface between `every publish`, the shell's Rust
|
|
9
|
+
# updater, and the Platform's release publishing — all three speak exactly
|
|
10
|
+
# these shapes, so keep changes here schema-versioned and additive.
|
|
11
|
+
#
|
|
12
|
+
# Layout (channel is a *release channel* — stable/beta — never the receipt's
|
|
13
|
+
# distribution_channel):
|
|
14
|
+
#
|
|
15
|
+
# {prefix?}/{channel}/{os}/{arch}/
|
|
16
|
+
# latest.json mutable pointer, written last, Cache-Control: no-cache
|
|
17
|
+
# index.json append-only publish history (rollback + stats source)
|
|
18
|
+
# My-App-1.2.0.zip immutable + .minisig sibling
|
|
19
|
+
# My-App-latest.zip convenience alias (CopyObject) for download links
|
|
20
|
+
#
|
|
21
|
+
# The shell only ever does:
|
|
22
|
+
# GET {updates.url}/{channel}/{os}/{arch}/latest.json?current={version}
|
|
23
|
+
# — a raw bucket/CDN or a dynamic Platform endpoint serve the identical JSON.
|
|
24
|
+
module UpdateManifest
|
|
25
|
+
SCHEMA = 1
|
|
26
|
+
|
|
27
|
+
IMMUTABLE_CACHE = "public, max-age=31536000, immutable"
|
|
28
|
+
MUTABLE_CACHE = "no-cache"
|
|
29
|
+
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
def dir(channel:, os:, arch:, prefix: nil)
|
|
33
|
+
[prefix, channel, os, arch].compact.reject(&:empty?).join("/")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def artifact_key(filename, **loc) = "#{dir(**loc)}/#{filename}"
|
|
37
|
+
def latest_key(**loc) = "#{dir(**loc)}/latest.json"
|
|
38
|
+
def index_key(**loc) = "#{dir(**loc)}/index.json"
|
|
39
|
+
|
|
40
|
+
# Where images embedded in release notes live: channel-scoped (shared by
|
|
41
|
+
# every os/arch target of the release), versioned so they're immutable and
|
|
42
|
+
# rollback-safe. notes_html rewrites embedded images to these URLs at
|
|
43
|
+
# publish time — signed/expiring storage URLs must never leak into a feed.
|
|
44
|
+
def notes_asset_key(filename, channel:, version:, prefix: nil)
|
|
45
|
+
"#{[ prefix, channel ].compact.reject(&:empty?).join("/")}/notes/#{version}/#{filename}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# "My-App-1.2.0.zip" -> "My-App-latest.zip" (keeps the extension).
|
|
49
|
+
def latest_alias(filename, version)
|
|
50
|
+
aliased = filename.sub("-#{version}", "-latest")
|
|
51
|
+
aliased == filename ? nil : aliased
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# One latest.json body (also an index.json entry), built from a build
|
|
55
|
+
# receipt (dist/release.json) + publish-time facts.
|
|
56
|
+
#
|
|
57
|
+
# Release notes ship in two forms: `notes` is the human-authored source
|
|
58
|
+
# (markdown; plain text is valid markdown), `notes_html` is pre-rendered
|
|
59
|
+
# sanitized-by-construction HTML the app can inject straight into a
|
|
60
|
+
# changelog modal via the bridge. Pass notes_html explicitly to override
|
|
61
|
+
# the default markdown rendering (the Platform does — its notes are
|
|
62
|
+
# authored rich).
|
|
63
|
+
def entry(receipt:, url:, signature:, channel:, notes: nil, notes_html: nil, published_at: Time.now.utc)
|
|
64
|
+
artifact = receipt.fetch("artifact")
|
|
65
|
+
sha256 = artifact.fetch("checksum").to_s.delete_prefix("sha256:")
|
|
66
|
+
notes_html ||= render_notes_html(notes)
|
|
67
|
+
|
|
68
|
+
{
|
|
69
|
+
"schema" => SCHEMA,
|
|
70
|
+
"app" => {
|
|
71
|
+
"name" => receipt.dig("app", "name"),
|
|
72
|
+
"bundle_id" => receipt.dig("app", "bundle_id")
|
|
73
|
+
},
|
|
74
|
+
"channel" => channel,
|
|
75
|
+
"os" => receipt.dig("target", "os"),
|
|
76
|
+
"arch" => receipt.dig("target", "arch"),
|
|
77
|
+
"version" => receipt.dig("app", "version"),
|
|
78
|
+
"published_at" => published_at.iso8601,
|
|
79
|
+
"notes" => notes,
|
|
80
|
+
"notes_html" => notes_html,
|
|
81
|
+
"url" => url,
|
|
82
|
+
"sha256" => sha256,
|
|
83
|
+
"size_bytes" => artifact.fetch("size_bytes"),
|
|
84
|
+
"signature" => signature,
|
|
85
|
+
"manifest_checksum" => receipt["manifest_checksum"]
|
|
86
|
+
}.compact
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# index.json: append-only history, newest first. Re-publishing the same
|
|
90
|
+
# version replaces its entry (idempotent re-runs don't duplicate).
|
|
91
|
+
def index_append(existing_json, entry)
|
|
92
|
+
entries = existing_json ? JSON.parse(existing_json).fetch("entries", []) : []
|
|
93
|
+
entries = entries.reject { |e| e["version"] == entry["version"] }
|
|
94
|
+
JSON.pretty_generate({ "schema" => SCHEMA, "entries" => [entry] + entries }) << "\n"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def index_find(index_json, version)
|
|
98
|
+
return nil unless index_json
|
|
99
|
+
|
|
100
|
+
JSON.parse(index_json).fetch("entries", []).find { |e| e["version"] == version }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def to_json(entry) = JSON.pretty_generate(entry) << "\n"
|
|
104
|
+
|
|
105
|
+
# Markdown -> HTML for notes_html. Kramdown with GFM-ish niceties off the
|
|
106
|
+
# shelf; raw HTML in the source passes through (the feed is the app
|
|
107
|
+
# developer's own signed content — same trust as the artifact itself).
|
|
108
|
+
def render_notes_html(notes)
|
|
109
|
+
return nil if notes.nil? || notes.strip.empty?
|
|
110
|
+
|
|
111
|
+
require "kramdown"
|
|
112
|
+
Kramdown::Document.new(notes, auto_ids: false, entity_output: :as_char).to_html.strip
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
data/lib/everywhere/version.rb
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Everywhere
|
|
4
|
-
VERSION = "0.1.
|
|
4
|
+
VERSION = "0.1.8"
|
|
5
5
|
|
|
6
6
|
# Version of the vendored @rubyeverywhere/bridge JS this gem ships. Tracks
|
|
7
7
|
# bridge/package.json — bump it whenever lib/everywhere/javascript/bridge.js is
|
|
8
8
|
# refreshed. `every install` stamps it into the vendored file so the build
|
|
9
9
|
# receipt can record which bridge shipped; it versions independently of the CLI.
|
|
10
|
-
BRIDGE_VERSION = "0.
|
|
10
|
+
BRIDGE_VERSION = "0.2.0"
|
|
11
11
|
end
|