browserctl 0.8.0 → 0.8.1
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 +8 -0
- data/lib/browserctl/commands/session.rb +176 -8
- data/lib/browserctl/session.rb +134 -12
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow.rb +2 -2
- 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: e85e284d37a585d84857db9f02a93bc7d9aacbf5c026da8ab06559fdff121f16
|
|
4
|
+
data.tar.gz: 76dbe3a44dee51d1c520dc0bd4d282790309efab1ecc5567fcee585b064660f1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e66d36468423be0212176d8d40df94c5e10ba3d5f23663041b502c0d481580f94f12f308deaffac18b25fef1cc0bd644dac110e4d98ce365574ef6aa53dc4784
|
|
7
|
+
data.tar.gz: b147ca301c15e728b9bf6e3682bcf975f7f105adadd77d62db9a6a68fa8a05cd73533562f198b0825abc99f45ee73c04cf50aa2205d840d0181f3b599e9f7093
|
data/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,14 @@ 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.1](https://github.com/patrick204nqh/browserctl/compare/v0.8.0...v0.8.1) (2026-04-29)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Bug Fixes
|
|
17
|
+
|
|
18
|
+
* 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))
|
|
19
|
+
* session encryption at rest and passphrase-protected export ([#57](https://github.com/patrick204nqh/browserctl/issues/57)) ([e3185d9](https://github.com/patrick204nqh/browserctl/commit/e3185d95dad3dd373659ce3c1e5781d9a06ed7f1))
|
|
20
|
+
|
|
13
21
|
## [0.8.0](https://github.com/patrick204nqh/browserctl/compare/v0.7.0...v0.8.0) (2026-04-29)
|
|
14
22
|
|
|
15
23
|
|
|
@@ -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/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,65 @@ 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
|
+
raise Browserctl::Error, "session encryption requires macOS Keychain (darwin only)" unless keychain_available?
|
|
43
|
+
|
|
44
|
+
key = SecureRandom.bytes(32)
|
|
45
|
+
keychain_store(session_name, key)
|
|
46
|
+
[key, metadata.merge(encrypted: true)]
|
|
47
|
+
end
|
|
48
|
+
private_class_method :prepare_encryption
|
|
49
|
+
|
|
50
|
+
def self.write_session_files(dir, cookies, local_storage, session_storage, key)
|
|
51
|
+
if key
|
|
52
|
+
write_encrypted_secret(File.join(dir, "cookies.json.enc"), cookies, key)
|
|
53
|
+
write_encrypted_secret(File.join(dir, "local_storage.json.enc"), local_storage, key)
|
|
54
|
+
unless session_storage.empty?
|
|
55
|
+
write_encrypted_secret(File.join(dir, "session_storage.json.enc"), session_storage,
|
|
56
|
+
key)
|
|
57
|
+
end
|
|
58
|
+
else
|
|
59
|
+
write_secret(File.join(dir, "cookies.json"), cookies)
|
|
60
|
+
write_secret(File.join(dir, "local_storage.json"), local_storage)
|
|
61
|
+
write_secret(File.join(dir, "session_storage.json"), session_storage) unless session_storage.empty?
|
|
62
|
+
end
|
|
35
63
|
end
|
|
64
|
+
private_class_method :write_session_files
|
|
36
65
|
|
|
37
66
|
def self.load(session_name)
|
|
38
67
|
validate_name!(session_name)
|
|
39
68
|
dir = path(session_name)
|
|
40
69
|
raise "session '#{session_name}' not found" unless Dir.exist?(dir)
|
|
41
70
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
71
|
+
meta = JSON.parse(File.read(File.join(dir, "metadata.json")), symbolize_names: true)
|
|
72
|
+
|
|
73
|
+
if meta[:encrypted]
|
|
74
|
+
key = keychain_fetch(session_name)
|
|
75
|
+
{
|
|
76
|
+
metadata: meta,
|
|
77
|
+
cookies: decrypt_json(File.join(dir, "cookies.json.enc"), key, symbolize_names: true),
|
|
78
|
+
local_storage: decrypt_json(File.join(dir, "local_storage.json.enc"), key, symbolize_names: false),
|
|
79
|
+
session_storage: load_session_storage_encrypted(dir, key)
|
|
80
|
+
}
|
|
81
|
+
else
|
|
82
|
+
{
|
|
83
|
+
metadata: meta,
|
|
84
|
+
cookies: JSON.parse(File.read(File.join(dir, "cookies.json")), symbolize_names: true),
|
|
85
|
+
local_storage: JSON.parse(File.read(File.join(dir, "local_storage.json")), symbolize_names: false),
|
|
86
|
+
session_storage: load_session_storage(dir)
|
|
87
|
+
}
|
|
88
|
+
end
|
|
48
89
|
end
|
|
49
90
|
|
|
50
91
|
def self.load_meta(path)
|
|
@@ -59,12 +100,87 @@ module Browserctl
|
|
|
59
100
|
raise ArgumentError, "invalid session name #{name.inspect} — use letters, digits, _ or - (max 64 chars)"
|
|
60
101
|
end
|
|
61
102
|
|
|
103
|
+
# Encrypt data (JSON) and return binary blob: [12-byte nonce][ciphertext+16-byte tag]
|
|
104
|
+
def self.encrypt_file(data, key)
|
|
105
|
+
nonce = SecureRandom.bytes(12)
|
|
106
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
107
|
+
cipher.encrypt
|
|
108
|
+
cipher.key = key
|
|
109
|
+
cipher.iv = nonce
|
|
110
|
+
ciphertext = cipher.update(data) + cipher.final
|
|
111
|
+
tag = cipher.auth_tag
|
|
112
|
+
nonce + ciphertext + tag
|
|
113
|
+
end
|
|
114
|
+
private_class_method :encrypt_file
|
|
115
|
+
|
|
116
|
+
# Decrypt binary blob produced by encrypt_file; returns original plaintext string.
|
|
117
|
+
def self.decrypt_file(blob, key)
|
|
118
|
+
nonce = blob.byteslice(0, 12)
|
|
119
|
+
tag = blob.byteslice(-16, 16)
|
|
120
|
+
ciphertext = blob.byteslice(12, blob.bytesize - 28)
|
|
121
|
+
|
|
122
|
+
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
123
|
+
cipher.decrypt
|
|
124
|
+
cipher.key = key
|
|
125
|
+
cipher.iv = nonce
|
|
126
|
+
cipher.auth_tag = tag
|
|
127
|
+
cipher.update(ciphertext) + cipher.final
|
|
128
|
+
rescue OpenSSL::Cipher::CipherError => e
|
|
129
|
+
raise Browserctl::Error, "decryption failed: #{e.message}"
|
|
130
|
+
end
|
|
131
|
+
private_class_method :decrypt_file
|
|
132
|
+
|
|
133
|
+
def self.keychain_store(name, key)
|
|
134
|
+
hex_key = key.unpack1("H*")
|
|
135
|
+
out, status = Open3.capture2(
|
|
136
|
+
"security", "add-generic-password",
|
|
137
|
+
"-a", name,
|
|
138
|
+
"-s", "browserctl",
|
|
139
|
+
"-w", hex_key,
|
|
140
|
+
"-U"
|
|
141
|
+
)
|
|
142
|
+
raise Browserctl::Error, "keychain store failed: #{out}" unless status.success?
|
|
143
|
+
end
|
|
144
|
+
private_class_method :keychain_store
|
|
145
|
+
|
|
146
|
+
def self.keychain_fetch(name)
|
|
147
|
+
hex_key, status = Open3.capture2(
|
|
148
|
+
"security", "find-generic-password",
|
|
149
|
+
"-a", name,
|
|
150
|
+
"-s", "browserctl",
|
|
151
|
+
"-w"
|
|
152
|
+
)
|
|
153
|
+
raise Browserctl::Error, "keychain fetch failed for session '#{name}'" unless status.success?
|
|
154
|
+
|
|
155
|
+
[hex_key.strip].pack("H*")
|
|
156
|
+
end
|
|
157
|
+
private_class_method :keychain_fetch
|
|
158
|
+
|
|
159
|
+
def self.keychain_available?
|
|
160
|
+
RUBY_PLATFORM.include?("darwin") && system("which security > /dev/null 2>&1")
|
|
161
|
+
end
|
|
162
|
+
private_class_method :keychain_available?
|
|
163
|
+
|
|
62
164
|
def self.load_session_storage(dir)
|
|
63
165
|
ss_path = File.join(dir, "session_storage.json")
|
|
64
166
|
File.exist?(ss_path) ? JSON.parse(File.read(ss_path), symbolize_names: false) : {}
|
|
65
167
|
end
|
|
66
168
|
private_class_method :load_session_storage
|
|
67
169
|
|
|
170
|
+
def self.load_session_storage_encrypted(dir, key)
|
|
171
|
+
ss_path = File.join(dir, "session_storage.json.enc")
|
|
172
|
+
return {} unless File.exist?(ss_path)
|
|
173
|
+
|
|
174
|
+
decrypt_json(ss_path, key, symbolize_names: false)
|
|
175
|
+
end
|
|
176
|
+
private_class_method :load_session_storage_encrypted
|
|
177
|
+
|
|
178
|
+
def self.decrypt_json(path, key, symbolize_names:)
|
|
179
|
+
blob = File.binread(path)
|
|
180
|
+
JSON.parse(decrypt_file(blob, key), symbolize_names: symbolize_names)
|
|
181
|
+
end
|
|
182
|
+
private_class_method :decrypt_json
|
|
183
|
+
|
|
68
184
|
def self.write_json(path, data)
|
|
69
185
|
File.write(path, JSON.generate(data))
|
|
70
186
|
end
|
|
@@ -75,5 +191,11 @@ module Browserctl
|
|
|
75
191
|
File.open(path, "w", 0o600) { |f| f.write(JSON.generate(data)) }
|
|
76
192
|
end
|
|
77
193
|
private_class_method :write_secret
|
|
194
|
+
|
|
195
|
+
def self.write_encrypted_secret(path, data, key)
|
|
196
|
+
blob = encrypt_file(JSON.generate(data), key)
|
|
197
|
+
File.open(path, "wb", 0o600) { |f| f.write(blob) }
|
|
198
|
+
end
|
|
199
|
+
private_class_method :write_encrypted_secret
|
|
78
200
|
end
|
|
79
201
|
end
|
data/lib/browserctl/version.rb
CHANGED
data/lib/browserctl/workflow.rb
CHANGED
|
@@ -60,8 +60,8 @@ module Browserctl
|
|
|
60
60
|
res
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
-
def save_session(session_name)
|
|
64
|
-
res = @client.session_save(session_name)
|
|
63
|
+
def save_session(session_name, encrypt: false)
|
|
64
|
+
res = @client.session_save(session_name, encrypt: encrypt)
|
|
65
65
|
raise WorkflowError, res[:error] if res[:error]
|
|
66
66
|
|
|
67
67
|
res
|