browserctl 0.8.0 → 0.8.2
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/CHANGELOG.md +15 -0
- data/lib/browserctl/commands/session.rb +176 -8
- data/lib/browserctl/runner.rb +5 -1
- data/lib/browserctl/secret_resolver_registry.rb +6 -2
- data/lib/browserctl/session.rb +139 -12
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow.rb +8 -4
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c5145a9c65fc1dc92bd238aecd15c672286dfa41005333dbaa1e6a613834c9fc
|
|
4
|
+
data.tar.gz: 0f1c56d87a58b41fbdea2e3ec1abf211bcf08a3677348d34565301f15bb76e96
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 162679d64a18bfb241ef1312a7ecfac12cc312407ccd598bc0741c2f985d1754dbdcbe00112ee2cc522a3987b88d8cd34dd4799b48651238efb548348123d58f
|
|
7
|
+
data.tar.gz: 710809a3eb707ad0f1f7300c77cb4b6cfdc4f631dc7f1e02b4b426262164b061d7008ba1901e501483e85a09ecb7be63fd60afa08dba9039b79670e711532fa4
|
data/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,21 @@ All notable changes to this project will be documented in this file.
|
|
|
10
10
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
11
11
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
12
12
|
|
|
13
|
+
## [0.8.2](https://github.com/patrick204nqh/browserctl/compare/v0.8.1...v0.8.2) (2026-04-29)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Bug Fixes
|
|
17
|
+
|
|
18
|
+
* v0.8.2 UX polish — describe secret_ref, fallback hint, cross-platform encrypt guidance ([#65](https://github.com/patrick204nqh/browserctl/issues/65)) ([2fe5ea4](https://github.com/patrick204nqh/browserctl/commit/2fe5ea4da0997dc2ab2c3a0696fc819e3fffca40))
|
|
19
|
+
|
|
20
|
+
## [0.8.1](https://github.com/patrick204nqh/browserctl/compare/v0.8.0...v0.8.1) (2026-04-29)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
### Bug Fixes
|
|
24
|
+
|
|
25
|
+
* fill test gaps and tighten DX for v0.8.1 encryption ([#59](https://github.com/patrick204nqh/browserctl/issues/59)) ([80ae8b3](https://github.com/patrick204nqh/browserctl/commit/80ae8b38620f182db02a971516cfb5295cfb4945))
|
|
26
|
+
* session encryption at rest and passphrase-protected export ([#57](https://github.com/patrick204nqh/browserctl/issues/57)) ([e3185d9](https://github.com/patrick204nqh/browserctl/commit/e3185d95dad3dd373659ce3c1e5781d9a06ed7f1))
|
|
27
|
+
|
|
13
28
|
## [0.8.0](https://github.com/patrick204nqh/browserctl/compare/v0.7.0...v0.8.0) (2026-04-29)
|
|
14
29
|
|
|
15
30
|
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "io/console"
|
|
5
|
+
require "json"
|
|
6
|
+
require "open3"
|
|
7
|
+
require "openssl"
|
|
8
|
+
require "securerandom"
|
|
9
|
+
require "tmpdir"
|
|
3
10
|
require_relative "cli_output"
|
|
4
11
|
|
|
5
12
|
module Browserctl
|
|
@@ -9,6 +16,13 @@ module Browserctl
|
|
|
9
16
|
|
|
10
17
|
USAGE = "Usage: browserctl session <save|load|list|delete|export|import> [args]"
|
|
11
18
|
|
|
19
|
+
PBKDF2_ITERATIONS = 100_000
|
|
20
|
+
PBKDF2_KEY_LEN = 32
|
|
21
|
+
SALT_LEN = 16
|
|
22
|
+
NONCE_LEN = 12
|
|
23
|
+
|
|
24
|
+
SENSITIVE_BASENAMES = %w[cookies.json local_storage.json session_storage.json].freeze
|
|
25
|
+
|
|
12
26
|
def self.run(client, args)
|
|
13
27
|
sub = args.shift or abort USAGE
|
|
14
28
|
case sub
|
|
@@ -23,8 +37,9 @@ module Browserctl
|
|
|
23
37
|
end
|
|
24
38
|
|
|
25
39
|
def self.run_save(client, args)
|
|
26
|
-
|
|
27
|
-
|
|
40
|
+
encrypt = args.delete("--encrypt")
|
|
41
|
+
name = args.shift or abort "usage: browserctl session save <name> [--encrypt]"
|
|
42
|
+
print_result(client.session_save(name, encrypt: !!encrypt))
|
|
28
43
|
end
|
|
29
44
|
|
|
30
45
|
def self.run_load(client, args)
|
|
@@ -42,14 +57,23 @@ module Browserctl
|
|
|
42
57
|
end
|
|
43
58
|
|
|
44
59
|
def self.run_export(args)
|
|
45
|
-
|
|
46
|
-
|
|
60
|
+
encrypt = args.delete("--encrypt")
|
|
61
|
+
name = args.shift or abort "usage: browserctl session export <name> <path> [--encrypt]"
|
|
62
|
+
dest = args.shift or abort "usage: browserctl session export <name> <path> [--encrypt]"
|
|
63
|
+
|
|
47
64
|
session_dir = File.join(Browserctl::BROWSERCTL_DIR, "sessions", name)
|
|
48
65
|
abort "session '#{name}' not found" unless Dir.exist?(session_dir)
|
|
49
66
|
|
|
50
67
|
dest = File.expand_path(dest)
|
|
51
|
-
|
|
52
|
-
|
|
68
|
+
|
|
69
|
+
if encrypt
|
|
70
|
+
passphrase = prompt_passphrase(confirm: true)
|
|
71
|
+
encrypt_export(session_dir, name, dest, passphrase)
|
|
72
|
+
else
|
|
73
|
+
pid = Process.spawn("zip", "-r", dest, name, chdir: File.join(Browserctl::BROWSERCTL_DIR, "sessions"))
|
|
74
|
+
Process.wait(pid)
|
|
75
|
+
end
|
|
76
|
+
|
|
53
77
|
puts({ ok: true, path: dest }.to_json)
|
|
54
78
|
end
|
|
55
79
|
|
|
@@ -60,10 +84,154 @@ module Browserctl
|
|
|
60
84
|
|
|
61
85
|
sessions_dir = File.join(Browserctl::BROWSERCTL_DIR, "sessions")
|
|
62
86
|
FileUtils.mkdir_p(sessions_dir)
|
|
63
|
-
|
|
64
|
-
|
|
87
|
+
|
|
88
|
+
if encrypted_zip?(zip_path)
|
|
89
|
+
passphrase = prompt_passphrase
|
|
90
|
+
decrypt_import(zip_path, sessions_dir, passphrase)
|
|
91
|
+
else
|
|
92
|
+
pid = Process.spawn("unzip", "-o", zip_path, "-d", sessions_dir)
|
|
93
|
+
Process.wait(pid)
|
|
94
|
+
end
|
|
95
|
+
|
|
65
96
|
puts({ ok: true }.to_json)
|
|
66
97
|
end
|
|
98
|
+
|
|
99
|
+
# --- private helpers ---
|
|
100
|
+
|
|
101
|
+
def self.prompt_passphrase(confirm: false)
|
|
102
|
+
return ENV["BROWSERCTL_EXPORT_PASSPHRASE"] if ENV["BROWSERCTL_EXPORT_PASSPHRASE"]
|
|
103
|
+
|
|
104
|
+
$stderr.print "Passphrase: "
|
|
105
|
+
pass = $stdin.noecho(&:gets).to_s.chomp
|
|
106
|
+
$stderr.puts
|
|
107
|
+
|
|
108
|
+
if confirm
|
|
109
|
+
$stderr.print "Confirm passphrase: "
|
|
110
|
+
confirm_pass = $stdin.noecho(&:gets).to_s.chomp
|
|
111
|
+
$stderr.puts
|
|
112
|
+
abort "Passphrases do not match." unless pass == confirm_pass
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
pass
|
|
116
|
+
end
|
|
117
|
+
private_class_method :prompt_passphrase
|
|
118
|
+
|
|
119
|
+
def self.derive_key(passphrase, salt)
|
|
120
|
+
OpenSSL::PKCS5.pbkdf2_hmac(
|
|
121
|
+
passphrase,
|
|
122
|
+
salt,
|
|
123
|
+
PBKDF2_ITERATIONS,
|
|
124
|
+
PBKDF2_KEY_LEN,
|
|
125
|
+
OpenSSL::Digest.new("SHA256")
|
|
126
|
+
)
|
|
127
|
+
end
|
|
128
|
+
private_class_method :derive_key
|
|
129
|
+
|
|
130
|
+
def self.encrypt_blob(plaintext, key)
|
|
131
|
+
nonce = SecureRandom.bytes(NONCE_LEN)
|
|
132
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
133
|
+
cipher.encrypt
|
|
134
|
+
cipher.key = key
|
|
135
|
+
cipher.iv = nonce
|
|
136
|
+
ct = cipher.update(plaintext) + cipher.final
|
|
137
|
+
tag = cipher.auth_tag
|
|
138
|
+
nonce + ct + tag
|
|
139
|
+
end
|
|
140
|
+
private_class_method :encrypt_blob
|
|
141
|
+
|
|
142
|
+
def self.decrypt_blob(blob, key)
|
|
143
|
+
nonce = blob.byteslice(0, NONCE_LEN)
|
|
144
|
+
tag = blob.byteslice(-16, 16)
|
|
145
|
+
ciphertext = blob.byteslice(NONCE_LEN, blob.bytesize - NONCE_LEN - 16)
|
|
146
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
147
|
+
cipher.decrypt
|
|
148
|
+
cipher.key = key
|
|
149
|
+
cipher.iv = nonce
|
|
150
|
+
cipher.auth_tag = tag
|
|
151
|
+
cipher.update(ciphertext) + cipher.final
|
|
152
|
+
rescue OpenSSL::Cipher::CipherError => e
|
|
153
|
+
raise Browserctl::Error, "export decryption failed: #{e.message}"
|
|
154
|
+
end
|
|
155
|
+
private_class_method :decrypt_blob
|
|
156
|
+
|
|
157
|
+
# Build an encrypted zip using the system zip command.
|
|
158
|
+
# Plaintext files are staged in a tmpdir; sensitive ones are encrypted
|
|
159
|
+
# before staging. An _encryption_manifest.json is added at the zip root.
|
|
160
|
+
def self.encrypt_export(session_dir, session_name, dest, passphrase)
|
|
161
|
+
salt = SecureRandom.bytes(SALT_LEN)
|
|
162
|
+
key = derive_key(passphrase, salt)
|
|
163
|
+
manifest = JSON.generate({
|
|
164
|
+
encrypted: true, kdf: "pbkdf2-sha256",
|
|
165
|
+
iterations: PBKDF2_ITERATIONS, salt: salt.unpack1("H*")
|
|
166
|
+
})
|
|
167
|
+
Dir.mktmpdir do |tmpdir|
|
|
168
|
+
File.write(File.join(tmpdir, "_encryption_manifest.json"), manifest)
|
|
169
|
+
stage_session_files(session_dir, session_name, tmpdir, key)
|
|
170
|
+
pid = Process.spawn("zip", "-r", dest, "_encryption_manifest.json", session_name, chdir: tmpdir)
|
|
171
|
+
Process.wait(pid)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
private_class_method :encrypt_export
|
|
175
|
+
|
|
176
|
+
def self.stage_session_files(session_dir, session_name, tmpdir, key)
|
|
177
|
+
staged_session = File.join(tmpdir, session_name)
|
|
178
|
+
FileUtils.mkdir_p(staged_session)
|
|
179
|
+
Dir[File.join(session_dir, "**", "*")].each do |file|
|
|
180
|
+
next if File.directory?(file)
|
|
181
|
+
|
|
182
|
+
relative = file.delete_prefix("#{session_dir}/")
|
|
183
|
+
staged_path = File.join(staged_session, relative)
|
|
184
|
+
FileUtils.mkdir_p(File.dirname(staged_path))
|
|
185
|
+
raw = File.binread(file)
|
|
186
|
+
if SENSITIVE_BASENAMES.include?(File.basename(file))
|
|
187
|
+
File.binwrite("#{staged_path}.enc", encrypt_blob(raw, key))
|
|
188
|
+
else
|
|
189
|
+
File.binwrite(staged_path, raw)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
private_class_method :stage_session_files
|
|
194
|
+
|
|
195
|
+
# Returns true if the zip contains _encryption_manifest.json.
|
|
196
|
+
def self.encrypted_zip?(zip_path)
|
|
197
|
+
out, status = Open3.capture2("unzip", "-l", zip_path)
|
|
198
|
+
status.success? && out.include?("_encryption_manifest.json")
|
|
199
|
+
end
|
|
200
|
+
private_class_method :encrypted_zip?
|
|
201
|
+
|
|
202
|
+
# Extract zip to tmpdir, read manifest, decrypt .enc files, copy to sessions_dir.
|
|
203
|
+
def self.decrypt_import(zip_path, sessions_dir, passphrase)
|
|
204
|
+
Dir.mktmpdir do |tmpdir|
|
|
205
|
+
pid = Process.spawn("unzip", "-o", zip_path, "-d", tmpdir)
|
|
206
|
+
Process.wait(pid)
|
|
207
|
+
manifest_path = File.join(tmpdir, "_encryption_manifest.json")
|
|
208
|
+
raise Browserctl::Error, "missing encryption manifest in zip" unless File.exist?(manifest_path)
|
|
209
|
+
|
|
210
|
+
manifest = JSON.parse(File.read(manifest_path), symbolize_names: true)
|
|
211
|
+
key = derive_key(passphrase, [manifest[:salt]].pack("H*"))
|
|
212
|
+
copy_decrypted_files(tmpdir, sessions_dir, key)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
private_class_method :decrypt_import
|
|
216
|
+
|
|
217
|
+
def self.copy_decrypted_files(tmpdir, sessions_dir, key)
|
|
218
|
+
Dir[File.join(tmpdir, "**", "*")].each do |file|
|
|
219
|
+
next if File.directory?(file)
|
|
220
|
+
next if File.basename(file) == "_encryption_manifest.json"
|
|
221
|
+
|
|
222
|
+
relative = file.delete_prefix("#{tmpdir}/")
|
|
223
|
+
dest_path = File.join(sessions_dir, relative)
|
|
224
|
+
FileUtils.mkdir_p(File.dirname(dest_path))
|
|
225
|
+
if file.end_with?(".enc")
|
|
226
|
+
File.open(dest_path.delete_suffix(".enc"), "wb", 0o600) do |f|
|
|
227
|
+
f.write(decrypt_blob(File.binread(file), key))
|
|
228
|
+
end
|
|
229
|
+
else
|
|
230
|
+
FileUtils.cp(file, dest_path)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
private_class_method :copy_decrypted_files
|
|
67
235
|
end
|
|
68
236
|
end
|
|
69
237
|
end
|
data/lib/browserctl/runner.rb
CHANGED
|
@@ -109,7 +109,11 @@ module Browserctl
|
|
|
109
109
|
end
|
|
110
110
|
|
|
111
111
|
def format_params(defn)
|
|
112
|
-
defn.param_defs.transform_values
|
|
112
|
+
defn.param_defs.transform_values do |p|
|
|
113
|
+
entry = { required: p.required, secret: p.secret, default: p.default }
|
|
114
|
+
entry[:secret_ref] = p.secret_ref if p.secret_ref
|
|
115
|
+
entry
|
|
116
|
+
end
|
|
113
117
|
end
|
|
114
118
|
end
|
|
115
119
|
end
|
|
@@ -16,9 +16,13 @@ module Browserctl
|
|
|
16
16
|
scheme, reference = secret_ref.split("://", 2)
|
|
17
17
|
resolver = @mutex.synchronize { @registry[scheme] }
|
|
18
18
|
raise SecretResolverError, "unknown secret resolver scheme '#{scheme}'" unless resolver
|
|
19
|
+
|
|
19
20
|
unless resolver.available?
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
msg = "'#{scheme}://' resolver is not available in this environment"
|
|
22
|
+
if scheme == "keychain"
|
|
23
|
+
msg += "\n Use env://YOUR_VAR_NAME to source secrets from environment variables instead."
|
|
24
|
+
end
|
|
25
|
+
raise SecretResolverError, msg
|
|
22
26
|
end
|
|
23
27
|
|
|
24
28
|
resolver.resolve(reference)
|
data/lib/browserctl/session.rb
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
require "json"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "open3"
|
|
5
8
|
require_relative "constants"
|
|
6
9
|
|
|
7
10
|
module Browserctl
|
|
@@ -10,6 +13,8 @@ module Browserctl
|
|
|
10
13
|
|
|
11
14
|
SAFE_NAME = /\A[a-zA-Z0-9_-]{1,64}\z/
|
|
12
15
|
|
|
16
|
+
SENSITIVE_FILES = %w[cookies.json local_storage.json session_storage.json].freeze
|
|
17
|
+
|
|
13
18
|
def self.path(name) = File.join(BASE_DIR, name)
|
|
14
19
|
def self.exist?(name) = Dir.exist?(path(name))
|
|
15
20
|
|
|
@@ -22,29 +27,70 @@ module Browserctl
|
|
|
22
27
|
Dir[File.join(BASE_DIR, "*/metadata.json")].filter_map { |f| load_meta(f) }
|
|
23
28
|
end
|
|
24
29
|
|
|
25
|
-
def self.save(session_name, metadata:, cookies:, local_storage:, session_storage:)
|
|
30
|
+
def self.save(session_name, metadata:, cookies:, local_storage:, session_storage:, encrypt: false) # rubocop:disable Metrics/ParameterLists
|
|
26
31
|
validate_name!(session_name)
|
|
32
|
+
key, metadata = prepare_encryption(session_name, metadata, encrypt)
|
|
27
33
|
dir = path(session_name)
|
|
28
34
|
FileUtils.mkdir_p(dir)
|
|
29
|
-
write_json(File.join(dir, "metadata.json"),
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
35
|
+
write_json(File.join(dir, "metadata.json"), metadata)
|
|
36
|
+
write_session_files(dir, cookies, local_storage, session_storage, key)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.prepare_encryption(session_name, metadata, encrypt)
|
|
40
|
+
return [nil, metadata] unless encrypt
|
|
33
41
|
|
|
34
|
-
|
|
42
|
+
unless keychain_available?
|
|
43
|
+
raise Browserctl::Error,
|
|
44
|
+
"session encryption requires macOS Keychain (darwin only)\n " \
|
|
45
|
+
"For Linux/CI, omit --encrypt and rely on 0o600 file permissions,\n " \
|
|
46
|
+
"or use BROWSERCTL_EXPORT_PASSPHRASE with session export --encrypt for portable archives."
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
key = SecureRandom.bytes(32)
|
|
50
|
+
keychain_store(session_name, key)
|
|
51
|
+
[key, metadata.merge(encrypted: true)]
|
|
52
|
+
end
|
|
53
|
+
private_class_method :prepare_encryption
|
|
54
|
+
|
|
55
|
+
def self.write_session_files(dir, cookies, local_storage, session_storage, key)
|
|
56
|
+
if key
|
|
57
|
+
write_encrypted_secret(File.join(dir, "cookies.json.enc"), cookies, key)
|
|
58
|
+
write_encrypted_secret(File.join(dir, "local_storage.json.enc"), local_storage, key)
|
|
59
|
+
unless session_storage.empty?
|
|
60
|
+
write_encrypted_secret(File.join(dir, "session_storage.json.enc"), session_storage,
|
|
61
|
+
key)
|
|
62
|
+
end
|
|
63
|
+
else
|
|
64
|
+
write_secret(File.join(dir, "cookies.json"), cookies)
|
|
65
|
+
write_secret(File.join(dir, "local_storage.json"), local_storage)
|
|
66
|
+
write_secret(File.join(dir, "session_storage.json"), session_storage) unless session_storage.empty?
|
|
67
|
+
end
|
|
35
68
|
end
|
|
69
|
+
private_class_method :write_session_files
|
|
36
70
|
|
|
37
71
|
def self.load(session_name)
|
|
38
72
|
validate_name!(session_name)
|
|
39
73
|
dir = path(session_name)
|
|
40
74
|
raise "session '#{session_name}' not found" unless Dir.exist?(dir)
|
|
41
75
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
76
|
+
meta = JSON.parse(File.read(File.join(dir, "metadata.json")), symbolize_names: true)
|
|
77
|
+
|
|
78
|
+
if meta[:encrypted]
|
|
79
|
+
key = keychain_fetch(session_name)
|
|
80
|
+
{
|
|
81
|
+
metadata: meta,
|
|
82
|
+
cookies: decrypt_json(File.join(dir, "cookies.json.enc"), key, symbolize_names: true),
|
|
83
|
+
local_storage: decrypt_json(File.join(dir, "local_storage.json.enc"), key, symbolize_names: false),
|
|
84
|
+
session_storage: load_session_storage_encrypted(dir, key)
|
|
85
|
+
}
|
|
86
|
+
else
|
|
87
|
+
{
|
|
88
|
+
metadata: meta,
|
|
89
|
+
cookies: JSON.parse(File.read(File.join(dir, "cookies.json")), symbolize_names: true),
|
|
90
|
+
local_storage: JSON.parse(File.read(File.join(dir, "local_storage.json")), symbolize_names: false),
|
|
91
|
+
session_storage: load_session_storage(dir)
|
|
92
|
+
}
|
|
93
|
+
end
|
|
48
94
|
end
|
|
49
95
|
|
|
50
96
|
def self.load_meta(path)
|
|
@@ -59,12 +105,87 @@ module Browserctl
|
|
|
59
105
|
raise ArgumentError, "invalid session name #{name.inspect} — use letters, digits, _ or - (max 64 chars)"
|
|
60
106
|
end
|
|
61
107
|
|
|
108
|
+
# Encrypt data (JSON) and return binary blob: [12-byte nonce][ciphertext+16-byte tag]
|
|
109
|
+
def self.encrypt_file(data, key)
|
|
110
|
+
nonce = SecureRandom.bytes(12)
|
|
111
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
112
|
+
cipher.encrypt
|
|
113
|
+
cipher.key = key
|
|
114
|
+
cipher.iv = nonce
|
|
115
|
+
ciphertext = cipher.update(data) + cipher.final
|
|
116
|
+
tag = cipher.auth_tag
|
|
117
|
+
nonce + ciphertext + tag
|
|
118
|
+
end
|
|
119
|
+
private_class_method :encrypt_file
|
|
120
|
+
|
|
121
|
+
# Decrypt binary blob produced by encrypt_file; returns original plaintext string.
|
|
122
|
+
def self.decrypt_file(blob, key)
|
|
123
|
+
nonce = blob.byteslice(0, 12)
|
|
124
|
+
tag = blob.byteslice(-16, 16)
|
|
125
|
+
ciphertext = blob.byteslice(12, blob.bytesize - 28)
|
|
126
|
+
|
|
127
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
128
|
+
cipher.decrypt
|
|
129
|
+
cipher.key = key
|
|
130
|
+
cipher.iv = nonce
|
|
131
|
+
cipher.auth_tag = tag
|
|
132
|
+
cipher.update(ciphertext) + cipher.final
|
|
133
|
+
rescue OpenSSL::Cipher::CipherError => e
|
|
134
|
+
raise Browserctl::Error, "decryption failed: #{e.message}"
|
|
135
|
+
end
|
|
136
|
+
private_class_method :decrypt_file
|
|
137
|
+
|
|
138
|
+
def self.keychain_store(name, key)
|
|
139
|
+
hex_key = key.unpack1("H*")
|
|
140
|
+
out, status = Open3.capture2(
|
|
141
|
+
"security", "add-generic-password",
|
|
142
|
+
"-a", name,
|
|
143
|
+
"-s", "browserctl",
|
|
144
|
+
"-w", hex_key,
|
|
145
|
+
"-U"
|
|
146
|
+
)
|
|
147
|
+
raise Browserctl::Error, "keychain store failed: #{out}" unless status.success?
|
|
148
|
+
end
|
|
149
|
+
private_class_method :keychain_store
|
|
150
|
+
|
|
151
|
+
def self.keychain_fetch(name)
|
|
152
|
+
hex_key, status = Open3.capture2(
|
|
153
|
+
"security", "find-generic-password",
|
|
154
|
+
"-a", name,
|
|
155
|
+
"-s", "browserctl",
|
|
156
|
+
"-w"
|
|
157
|
+
)
|
|
158
|
+
raise Browserctl::Error, "keychain fetch failed for session '#{name}'" unless status.success?
|
|
159
|
+
|
|
160
|
+
[hex_key.strip].pack("H*")
|
|
161
|
+
end
|
|
162
|
+
private_class_method :keychain_fetch
|
|
163
|
+
|
|
164
|
+
def self.keychain_available?
|
|
165
|
+
RUBY_PLATFORM.include?("darwin") && system("which security > /dev/null 2>&1")
|
|
166
|
+
end
|
|
167
|
+
private_class_method :keychain_available?
|
|
168
|
+
|
|
62
169
|
def self.load_session_storage(dir)
|
|
63
170
|
ss_path = File.join(dir, "session_storage.json")
|
|
64
171
|
File.exist?(ss_path) ? JSON.parse(File.read(ss_path), symbolize_names: false) : {}
|
|
65
172
|
end
|
|
66
173
|
private_class_method :load_session_storage
|
|
67
174
|
|
|
175
|
+
def self.load_session_storage_encrypted(dir, key)
|
|
176
|
+
ss_path = File.join(dir, "session_storage.json.enc")
|
|
177
|
+
return {} unless File.exist?(ss_path)
|
|
178
|
+
|
|
179
|
+
decrypt_json(ss_path, key, symbolize_names: false)
|
|
180
|
+
end
|
|
181
|
+
private_class_method :load_session_storage_encrypted
|
|
182
|
+
|
|
183
|
+
def self.decrypt_json(path, key, symbolize_names:)
|
|
184
|
+
blob = File.binread(path)
|
|
185
|
+
JSON.parse(decrypt_file(blob, key), symbolize_names: symbolize_names)
|
|
186
|
+
end
|
|
187
|
+
private_class_method :decrypt_json
|
|
188
|
+
|
|
68
189
|
def self.write_json(path, data)
|
|
69
190
|
File.write(path, JSON.generate(data))
|
|
70
191
|
end
|
|
@@ -75,5 +196,11 @@ module Browserctl
|
|
|
75
196
|
File.open(path, "w", 0o600) { |f| f.write(JSON.generate(data)) }
|
|
76
197
|
end
|
|
77
198
|
private_class_method :write_secret
|
|
199
|
+
|
|
200
|
+
def self.write_encrypted_secret(path, data, key)
|
|
201
|
+
blob = encrypt_file(JSON.generate(data), key)
|
|
202
|
+
File.open(path, "wb", 0o600) { |f| f.write(blob) }
|
|
203
|
+
end
|
|
204
|
+
private_class_method :write_encrypted_secret
|
|
78
205
|
end
|
|
79
206
|
end
|
data/lib/browserctl/version.rb
CHANGED
data/lib/browserctl/workflow.rb
CHANGED
|
@@ -4,6 +4,7 @@ require "timeout"
|
|
|
4
4
|
require_relative "client"
|
|
5
5
|
require_relative "errors"
|
|
6
6
|
require_relative "secret_resolvers"
|
|
7
|
+
require_relative "session"
|
|
7
8
|
|
|
8
9
|
module Browserctl
|
|
9
10
|
ParamDef = Struct.new(:name, :required, :secret, :default, :secret_ref, keyword_init: true)
|
|
@@ -60,8 +61,8 @@ module Browserctl
|
|
|
60
61
|
res
|
|
61
62
|
end
|
|
62
63
|
|
|
63
|
-
def save_session(session_name)
|
|
64
|
-
res = @client.session_save(session_name)
|
|
64
|
+
def save_session(session_name, encrypt: false)
|
|
65
|
+
res = @client.session_save(session_name, encrypt: encrypt)
|
|
65
66
|
raise WorkflowError, res[:error] if res[:error]
|
|
66
67
|
|
|
67
68
|
res
|
|
@@ -76,8 +77,11 @@ module Browserctl
|
|
|
76
77
|
invoke(fallback.to_s)
|
|
77
78
|
res2 = @client.session_load(session_name)
|
|
78
79
|
if res2[:error]
|
|
79
|
-
|
|
80
|
-
|
|
80
|
+
msg = "session '#{session_name}' still unavailable after running fallback '#{fallback}'"
|
|
81
|
+
unless Session.exist?(session_name)
|
|
82
|
+
msg += "\n Hint: '#{fallback}' did not call save_session(\"#{session_name}\") — add it as the last step."
|
|
83
|
+
end
|
|
84
|
+
raise WorkflowError, msg
|
|
81
85
|
end
|
|
82
86
|
|
|
83
87
|
res2
|