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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e63d2425e4bbd57beefebf31e3f616a3183823f467b08c89ba6a1e2bb32cca0b
4
- data.tar.gz: 8f12b646d805237a46b2bba2c194fb91c21834dc78648db97a4f74b0cc672de6
3
+ metadata.gz: e85e284d37a585d84857db9f02a93bc7d9aacbf5c026da8ab06559fdff121f16
4
+ data.tar.gz: 76dbe3a44dee51d1c520dc0bd4d282790309efab1ecc5567fcee585b064660f1
5
5
  SHA512:
6
- metadata.gz: 50521d3c938c009818d85c93b793c55f5ec2b6d7ae64ec0daae5bf337d793f795189f870d1451d5dca18b285c428a2dd48ec1c0b6af210e6e7c3b41a49c14989
7
- data.tar.gz: fe973906dbfe35134aaa32c2e3080196febaa99dc4c8858683ceb9a9e41579f9a40b7ca7865af67ba946a6b2acc68dd7bafa31036daafeebe96e23edbd3f53c6
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
- name = args.shift or abort "usage: browserctl session save <name>"
27
- print_result(client.session_save(name))
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
- name = args.shift or abort "usage: browserctl session export <name> <path>"
46
- dest = args.shift or abort "usage: browserctl session export <name> <path>"
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
- pid = Process.spawn("zip", "-r", dest, name, chdir: File.join(Browserctl::BROWSERCTL_DIR, "sessions"))
52
- Process.wait(pid)
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
- pid = Process.spawn("unzip", "-o", zip_path, "-d", sessions_dir)
64
- Process.wait(pid)
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
@@ -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"), metadata)
30
- write_secret(File.join(dir, "cookies.json"), cookies)
31
- write_secret(File.join(dir, "local_storage.json"), local_storage)
32
- return if session_storage.empty?
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
- write_secret(File.join(dir, "session_storage.json"), session_storage)
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
- metadata: JSON.parse(File.read(File.join(dir, "metadata.json")), symbolize_names: true),
44
- cookies: JSON.parse(File.read(File.join(dir, "cookies.json")), symbolize_names: true),
45
- local_storage: JSON.parse(File.read(File.join(dir, "local_storage.json")), symbolize_names: false),
46
- session_storage: load_session_storage(dir)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Browserctl
4
- VERSION = "0.8.0"
4
+ VERSION = "0.8.1"
5
5
  end
@@ -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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: browserctl
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick