ruby_everywhere 0.1.7 → 0.1.9
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 +119 -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 +26 -0
- data/support/shell/src-tauri/src/updater.rs +745 -0
- data/support/shell/src-tauri/tauri.conf.json +1 -1
- metadata +23 -2
|
@@ -10,10 +10,22 @@
|
|
|
10
10
|
// Everywhere.platform // "desktop" | "mobile" | "browser"
|
|
11
11
|
// Everywhere.os // "macos" | "windows" | "linux" | "ios" | "android" | "chromeos" | "unknown"
|
|
12
12
|
// Everywhere.native // true inside a RubyEverywhere app
|
|
13
|
+
// Everywhere.version // app version (everywhere.yml app.version); null in a plain browser tab
|
|
13
14
|
// Everywhere.notify({ title, body }) // native / web Notification / console
|
|
14
15
|
// Everywhere.confirm("Sure?") // Promise<boolean>, native dialog when possible
|
|
15
16
|
// Everywhere.on("menu", handler) // shell events; returns unsubscribe fn
|
|
16
17
|
// Everywhere.visit("/settings") // Turbo.visit with location fallback
|
|
18
|
+
//
|
|
19
|
+
// Everywhere.updates.supported // true when the shell has an update feed
|
|
20
|
+
// Everywhere.updates.channel // effective channel ("stable", "beta", …)
|
|
21
|
+
// Everywhere.updates.check() // Promise<{available, version?, notes?, notesHtml?}>
|
|
22
|
+
// Everywhere.updates.install() // download/verify/swap/relaunch
|
|
23
|
+
// Everywhere.updates.setChannel("beta")// Promise<{channel}>, persisted by the shell
|
|
24
|
+
// Everywhere.updates.on("available" | "none" | "progress" | "ready" | "error" | "channel", handler)
|
|
25
|
+
//
|
|
26
|
+
// notes is the markdown source; notesHtml is the same notes pre-rendered to
|
|
27
|
+
// HTML (from the signed update feed — your own content), ready for a
|
|
28
|
+
// changelog modal: el.innerHTML = notesHtml.
|
|
17
29
|
|
|
18
30
|
const config =
|
|
19
31
|
(typeof window !== "undefined" && window.__EVERYWHERE_CONFIG__) || {}
|
|
@@ -74,6 +86,51 @@ const desktop = {
|
|
|
74
86
|
|
|
75
87
|
clipboardRead() {
|
|
76
88
|
return window.__TAURI__.clipboardManager.readText()
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
// Ask the shell to check its update feed; resolve off whichever update
|
|
92
|
+
// event answers first. The shell always replies to an explicit check
|
|
93
|
+
// (available / none / error), the timeout is just a safety net.
|
|
94
|
+
updatesCheck() {
|
|
95
|
+
return new Promise((resolve, reject) => {
|
|
96
|
+
const offs = []
|
|
97
|
+
let timer = null
|
|
98
|
+
const settle = (fn) => (payload) => {
|
|
99
|
+
offs.forEach((off) => off())
|
|
100
|
+
clearTimeout(timer)
|
|
101
|
+
fn(payload)
|
|
102
|
+
}
|
|
103
|
+
offs.push(this.on("update-available", settle((p) =>
|
|
104
|
+
resolve({ available: true, version: p.version,
|
|
105
|
+
notes: p.notes ?? null, notesHtml: p.notes_html ?? null }))))
|
|
106
|
+
offs.push(this.on("update-none", settle(() => resolve({ available: false }))))
|
|
107
|
+
offs.push(this.on("update-error", settle((p) => reject(new Error(p.message)))))
|
|
108
|
+
timer = setTimeout(settle(() => resolve({ available: false, timedOut: true })), 30000)
|
|
109
|
+
window.__TAURI__.event.emit("everywhere:update-check", {})
|
|
110
|
+
})
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
updatesInstall() {
|
|
114
|
+
return window.__TAURI__.event.emit("everywhere:update-install", {})
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
// Ask the shell to switch update channels. The shell validates, persists the
|
|
118
|
+
// choice (it survives relaunches and overrides everywhere.yml), and always
|
|
119
|
+
// answers: update-channel on success, update-error on rejection.
|
|
120
|
+
updatesSetChannel(channel) {
|
|
121
|
+
return new Promise((resolve, reject) => {
|
|
122
|
+
const offs = []
|
|
123
|
+
let timer = null
|
|
124
|
+
const settle = (fn) => (payload) => {
|
|
125
|
+
offs.forEach((off) => off())
|
|
126
|
+
clearTimeout(timer)
|
|
127
|
+
fn(payload)
|
|
128
|
+
}
|
|
129
|
+
offs.push(this.on("update-channel", settle((p) => resolve({ channel: p.channel }))))
|
|
130
|
+
offs.push(this.on("update-error", settle((p) => reject(new Error(p.message)))))
|
|
131
|
+
timer = setTimeout(settle(() => reject(new Error("channel change timed out"))), 10000)
|
|
132
|
+
window.__TAURI__.event.emit("everywhere:update-set-channel", { channel })
|
|
133
|
+
})
|
|
77
134
|
}
|
|
78
135
|
}
|
|
79
136
|
|
|
@@ -114,6 +171,21 @@ const browser = {
|
|
|
114
171
|
try { return await navigator.clipboard.readText() } catch (_) { return null }
|
|
115
172
|
}
|
|
116
173
|
return null
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
// Auto-updates only exist inside the shell; the browser tab is always "current".
|
|
177
|
+
updatesCheck() {
|
|
178
|
+
return Promise.resolve({ available: false, unsupported: true })
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
updatesInstall() {
|
|
182
|
+
console.log("[everywhere] updates unavailable in the browser")
|
|
183
|
+
return Promise.resolve()
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
updatesSetChannel() {
|
|
187
|
+
console.log("[everywhere] update channel unavailable in the browser")
|
|
188
|
+
return Promise.resolve({ channel: null, unsupported: true })
|
|
117
189
|
}
|
|
118
190
|
}
|
|
119
191
|
|
|
@@ -136,10 +208,18 @@ const platform = detectPlatform()
|
|
|
136
208
|
const os = detectOS()
|
|
137
209
|
const adapter = adapters[platform]
|
|
138
210
|
|
|
211
|
+
// Mutable so setChannel can keep updates.channel truthful without a reload.
|
|
212
|
+
let updatesChannel = (config.updates && config.updates.channel) || null
|
|
213
|
+
|
|
139
214
|
export const Everywhere = {
|
|
140
215
|
platform,
|
|
141
216
|
os,
|
|
142
217
|
|
|
218
|
+
// The running app's version (everywhere.yml app.version, injected by the
|
|
219
|
+
// shell) — e.g. an About page or footer badge. null in a plain browser tab,
|
|
220
|
+
// where the page came from a server, not a versioned bundle.
|
|
221
|
+
version: config.version || null,
|
|
222
|
+
|
|
143
223
|
get native() {
|
|
144
224
|
return platform !== "browser"
|
|
145
225
|
},
|
|
@@ -168,6 +248,45 @@ export const Everywhere = {
|
|
|
168
248
|
read() {
|
|
169
249
|
return adapter.clipboardRead()
|
|
170
250
|
}
|
|
251
|
+
},
|
|
252
|
+
|
|
253
|
+
updates: {
|
|
254
|
+
// True only when the shell actually has a feed to check (everywhere.yml
|
|
255
|
+
// updates: url + public_key made it into the shipped config).
|
|
256
|
+
get supported() {
|
|
257
|
+
return platform === "desktop" && !!config.updates
|
|
258
|
+
},
|
|
259
|
+
version: config.version || null,
|
|
260
|
+
|
|
261
|
+
// The channel the shell is actually checking. The shell injects the
|
|
262
|
+
// effective value (a persisted user choice overrides everywhere.yml), and
|
|
263
|
+
// setChannel keeps it current without a reload.
|
|
264
|
+
get channel() {
|
|
265
|
+
return updatesChannel
|
|
266
|
+
},
|
|
267
|
+
|
|
268
|
+
check() {
|
|
269
|
+
return adapter.updatesCheck()
|
|
270
|
+
},
|
|
271
|
+
|
|
272
|
+
// Fire-and-forget: progress arrives via on("progress"), completion as
|
|
273
|
+
// "ready", failures as "error"; on success the shell restarts the app.
|
|
274
|
+
install() {
|
|
275
|
+
return adapter.updatesInstall()
|
|
276
|
+
},
|
|
277
|
+
|
|
278
|
+
// Switch the update feed channel (e.g. "stable" -> "beta"). Resolves once
|
|
279
|
+
// the shell has persisted it; follow with check() to scan the new channel.
|
|
280
|
+
setChannel(channel) {
|
|
281
|
+
return adapter.updatesSetChannel(channel).then((result) => {
|
|
282
|
+
if (result && result.channel) updatesChannel = result.channel
|
|
283
|
+
return result
|
|
284
|
+
})
|
|
285
|
+
},
|
|
286
|
+
|
|
287
|
+
on(event, handler) {
|
|
288
|
+
return adapter.on(`update-${event}`, handler)
|
|
289
|
+
}
|
|
171
290
|
}
|
|
172
291
|
}
|
|
173
292
|
|
|
@@ -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
|