browserctl 0.12.0 → 0.13.0

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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -0
  3. data/README.md +3 -3
  4. data/bin/browserctl +39 -32
  5. data/lib/browserctl/callable_definition.rb +114 -0
  6. data/lib/browserctl/client.rb +0 -27
  7. data/lib/browserctl/commands/cli_output.rb +17 -3
  8. data/lib/browserctl/commands/daemon.rb +10 -6
  9. data/lib/browserctl/commands/flow.rb +7 -5
  10. data/lib/browserctl/commands/init.rb +20 -7
  11. data/lib/browserctl/commands/migrate.rb +56 -8
  12. data/lib/browserctl/commands/output_format.rb +144 -0
  13. data/lib/browserctl/commands/page.rb +9 -5
  14. data/lib/browserctl/commands/{record.rb → recording.rb} +14 -13
  15. data/lib/browserctl/commands/resume.rb +1 -1
  16. data/lib/browserctl/commands/screenshot.rb +2 -2
  17. data/lib/browserctl/commands/snapshot.rb +8 -3
  18. data/lib/browserctl/commands/state.rb +3 -2
  19. data/lib/browserctl/commands/trace.rb +40 -11
  20. data/lib/browserctl/commands/workflow.rb +9 -7
  21. data/lib/browserctl/contextual_persistence.rb +58 -0
  22. data/lib/browserctl/driver/cdp.rb +2 -3
  23. data/lib/browserctl/encryption_service.rb +84 -0
  24. data/lib/browserctl/flow.rb +35 -59
  25. data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +4 -4
  26. data/lib/browserctl/recording/log_writer.rb +82 -0
  27. data/lib/browserctl/recording/redactor.rb +58 -0
  28. data/lib/browserctl/recording/state.rb +44 -0
  29. data/lib/browserctl/recording/workflow_renderer.rb +214 -0
  30. data/lib/browserctl/recording.rb +33 -294
  31. data/lib/browserctl/server/command_dispatcher.rb +25 -16
  32. data/lib/browserctl/server/handlers/state.rb +7 -5
  33. data/lib/browserctl/server.rb +2 -1
  34. data/lib/browserctl/state/bundle.rb +20 -47
  35. data/lib/browserctl/state.rb +46 -9
  36. data/lib/browserctl/version.rb +1 -1
  37. data/lib/browserctl/workflow/recovery_manager.rb +87 -0
  38. data/lib/browserctl/workflow.rb +61 -237
  39. metadata +11 -8
  40. data/examples/session_reuse.rb +0 -75
  41. data/lib/browserctl/commands/session.rb +0 -243
  42. data/lib/browserctl/driver/base.rb +0 -13
  43. data/lib/browserctl/driver.rb +0 -5
  44. data/lib/browserctl/server/handlers/session.rb +0 -94
  45. data/lib/browserctl/session.rb +0 -206
@@ -1,75 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Demonstrates the authenticate-once, reuse-forever pattern.
4
- #
5
- # The first run (no saved session) invokes `login_once` to authenticate,
6
- # which saves the session. Every subsequent run loads the saved session
7
- # directly — no re-authentication needed.
8
- #
9
- # `expired_if:` detects when the saved session exists but server-side auth
10
- # has lapsed (rotated cookie, token TTL), and automatically re-authenticates.
11
- #
12
- # Run:
13
- # browserctl workflow run examples/session_reuse.rb \
14
- # --app_url https://the-internet.herokuapp.com \
15
- # --username tomsmith \
16
- # --password "SuperSecretPassword!"
17
- #
18
- # On the first run: authenticates and saves the session.
19
- # On subsequent runs: loads the session and skips the login page entirely.
20
-
21
- # --- Step 1: define the login workflow (run once, triggered automatically on missing/expired session) ---
22
-
23
- Browserctl.workflow "session_reuse/login_once" do
24
- desc "Authenticate and save session — called automatically by session_reuse when needed"
25
-
26
- param :app_url, required: true
27
- param :username, required: true
28
- param :password, required: true, secret: true
29
-
30
- step "open login page" do
31
- open_page(:main, url: "#{app_url}/login")
32
- end
33
-
34
- step "fill and submit credentials" do
35
- page(:main).fill("input#username", username)
36
- page(:main).fill("input#password", password)
37
- page(:main).click("button[type=submit]")
38
- end
39
-
40
- step "verify login succeeded" do
41
- assert page(:main).url.include?("/secure"), "login failed — still on login page"
42
- end
43
-
44
- step "save authenticated session" do
45
- save_session("session_reuse_demo")
46
- puts " ✓ Session saved — future runs will skip this step"
47
- end
48
- end
49
-
50
- # --- Step 2: the main workflow that reuses the saved session ---
51
-
52
- Browserctl.workflow "session_reuse" do
53
- desc "Authenticate once, reuse forever — demonstrates load_session with fallback and expired_if"
54
-
55
- param :app_url, default: "https://the-internet.herokuapp.com"
56
- param :username, default: "tomsmith"
57
- param :password, default: "SuperSecretPassword!", secret: true
58
-
59
- step "restore session or log in" do
60
- load_session("session_reuse_demo",
61
- fallback: "session_reuse/login_once",
62
- expired_if: lambda {
63
- page(:main).navigate("#{app_url}/secure")
64
- !page(:main).url.include?("/secure")
65
- })
66
- puts " ✓ Session ready — authenticated as #{username}"
67
- end
68
-
69
- step "do authenticated work" do
70
- page(:main).navigate("#{app_url}/secure")
71
- heading = page(:main).evaluate("document.querySelector('h2')?.textContent?.trim()")
72
- assert heading&.include?("Secure Area"), "expected to be in secure area, got: #{heading.inspect}"
73
- puts " ✓ Landed in secure area without re-authenticating"
74
- end
75
- end
@@ -1,243 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "fileutils"
4
- require "io/console"
5
- require "json"
6
- require "open3"
7
- require "openssl"
8
- require "securerandom"
9
- require "tmpdir"
10
- require_relative "cli_output"
11
-
12
- module Browserctl
13
- module Commands
14
- module Session
15
- extend CliOutput
16
-
17
- USAGE = "Usage: browserctl session <save|load|list|delete|export|import> [args]"
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
-
26
- def self.run(client, args)
27
- sub = args.shift or abort USAGE
28
- case sub
29
- when "save" then run_save(client, args)
30
- when "load" then run_load(client, args)
31
- when "list" then run_list(client)
32
- when "delete" then run_delete(client, args)
33
- when "export" then run_export(args)
34
- when "import" then run_import(args)
35
- else abort "unknown session subcommand '#{sub}'\n#{USAGE}"
36
- end
37
- end
38
-
39
- def self.run_save(client, args)
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))
43
- end
44
-
45
- def self.run_load(client, args)
46
- name = args.shift or abort "usage: browserctl session load <name>"
47
- print_result(client.session_load(name))
48
- end
49
-
50
- def self.run_list(client)
51
- print_result(client.session_list)
52
- end
53
-
54
- def self.run_delete(client, args)
55
- name = args.shift or abort "usage: browserctl session delete <name>"
56
- print_result(client.session_delete(name))
57
- end
58
-
59
- def self.run_export(args)
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
-
64
- Browserctl::Session.validate_name!(name)
65
- session_dir = File.join(Browserctl::BROWSERCTL_DIR, "sessions", name)
66
- abort "session '#{name}' not found" unless Dir.exist?(session_dir)
67
-
68
- dest = File.expand_path(dest)
69
-
70
- if encrypt
71
- passphrase = prompt_passphrase(confirm: true)
72
- encrypt_export(session_dir, name, dest, passphrase)
73
- else
74
- pid = Process.spawn("zip", "-r", dest, name, chdir: File.join(Browserctl::BROWSERCTL_DIR, "sessions"))
75
- Process.wait(pid)
76
- end
77
-
78
- puts({ ok: true, path: dest }.to_json)
79
- end
80
-
81
- def self.run_import(args)
82
- zip_path = args.shift or abort "usage: browserctl session import <path>"
83
- zip_path = File.expand_path(zip_path)
84
- abort "zip file not found: #{zip_path}" unless File.exist?(zip_path)
85
-
86
- sessions_dir = File.join(Browserctl::BROWSERCTL_DIR, "sessions")
87
- FileUtils.mkdir_p(sessions_dir)
88
-
89
- before = Dir[File.join(sessions_dir, "*/")].map { |p| File.basename(p) }
90
-
91
- if encrypted_zip?(zip_path)
92
- passphrase = prompt_passphrase
93
- decrypt_import(zip_path, sessions_dir, passphrase)
94
- else
95
- pid = Process.spawn("unzip", "-o", zip_path, "-d", sessions_dir)
96
- Process.wait(pid)
97
- end
98
-
99
- after = Dir[File.join(sessions_dir, "*/")].map { |p| File.basename(p) }
100
- name = (after - before).first
101
-
102
- puts({ ok: true, name: name }.to_json)
103
- end
104
-
105
- # --- private helpers ---
106
-
107
- def self.prompt_passphrase(confirm: false)
108
- return ENV["BROWSERCTL_EXPORT_PASSPHRASE"] if ENV["BROWSERCTL_EXPORT_PASSPHRASE"]
109
-
110
- $stderr.print "Passphrase: "
111
- pass = $stdin.noecho(&:gets).to_s.chomp
112
- $stderr.puts
113
-
114
- if confirm
115
- $stderr.print "Confirm passphrase: "
116
- confirm_pass = $stdin.noecho(&:gets).to_s.chomp
117
- $stderr.puts
118
- abort "Passphrases do not match." unless pass == confirm_pass
119
- end
120
-
121
- pass
122
- end
123
- private_class_method :prompt_passphrase
124
-
125
- def self.derive_key(passphrase, salt)
126
- OpenSSL::PKCS5.pbkdf2_hmac(
127
- passphrase,
128
- salt,
129
- PBKDF2_ITERATIONS,
130
- PBKDF2_KEY_LEN,
131
- OpenSSL::Digest.new("SHA256")
132
- )
133
- end
134
- private_class_method :derive_key
135
-
136
- def self.encrypt_blob(plaintext, key)
137
- nonce = SecureRandom.bytes(NONCE_LEN)
138
- cipher = OpenSSL::Cipher.new("aes-256-gcm")
139
- cipher.encrypt
140
- cipher.key = key
141
- cipher.iv = nonce
142
- ct = cipher.update(plaintext) + cipher.final
143
- tag = cipher.auth_tag
144
- nonce + ct + tag
145
- end
146
- private_class_method :encrypt_blob
147
-
148
- def self.decrypt_blob(blob, key)
149
- nonce = blob.byteslice(0, NONCE_LEN)
150
- tag = blob.byteslice(-16, 16)
151
- ciphertext = blob.byteslice(NONCE_LEN, blob.bytesize - NONCE_LEN - 16)
152
- cipher = OpenSSL::Cipher.new("aes-256-gcm")
153
- cipher.decrypt
154
- cipher.key = key
155
- cipher.iv = nonce
156
- cipher.auth_tag = tag
157
- cipher.update(ciphertext) + cipher.final
158
- rescue OpenSSL::Cipher::CipherError => e
159
- raise Browserctl::Error, "export decryption failed: #{e.message}"
160
- end
161
- private_class_method :decrypt_blob
162
-
163
- # Build an encrypted zip using the system zip command.
164
- # Plaintext files are staged in a tmpdir; sensitive ones are encrypted
165
- # before staging. An _encryption_manifest.json is added at the zip root.
166
- def self.encrypt_export(session_dir, session_name, dest, passphrase)
167
- salt = SecureRandom.bytes(SALT_LEN)
168
- key = derive_key(passphrase, salt)
169
- manifest = JSON.generate({
170
- encrypted: true, kdf: "pbkdf2-sha256",
171
- iterations: PBKDF2_ITERATIONS, salt: salt.unpack1("H*")
172
- })
173
- Dir.mktmpdir do |tmpdir|
174
- File.write(File.join(tmpdir, "_encryption_manifest.json"), manifest)
175
- stage_session_files(session_dir, session_name, tmpdir, key)
176
- pid = Process.spawn("zip", "-r", dest, "_encryption_manifest.json", session_name, chdir: tmpdir)
177
- Process.wait(pid)
178
- end
179
- end
180
- private_class_method :encrypt_export
181
-
182
- def self.stage_session_files(session_dir, session_name, tmpdir, key)
183
- staged_session = File.join(tmpdir, session_name)
184
- FileUtils.mkdir_p(staged_session)
185
- Dir[File.join(session_dir, "**", "*")].each do |file|
186
- next if File.directory?(file)
187
-
188
- relative = file.delete_prefix("#{session_dir}/")
189
- staged_path = File.join(staged_session, relative)
190
- FileUtils.mkdir_p(File.dirname(staged_path))
191
- raw = File.binread(file)
192
- if SENSITIVE_BASENAMES.include?(File.basename(file))
193
- File.binwrite("#{staged_path}.enc", encrypt_blob(raw, key))
194
- else
195
- File.binwrite(staged_path, raw)
196
- end
197
- end
198
- end
199
- private_class_method :stage_session_files
200
-
201
- # Returns true if the zip contains _encryption_manifest.json.
202
- def self.encrypted_zip?(zip_path)
203
- out, status = Open3.capture2("unzip", "-l", zip_path)
204
- status.success? && out.include?("_encryption_manifest.json")
205
- end
206
- private_class_method :encrypted_zip?
207
-
208
- # Extract zip to tmpdir, read manifest, decrypt .enc files, copy to sessions_dir.
209
- def self.decrypt_import(zip_path, sessions_dir, passphrase)
210
- Dir.mktmpdir do |tmpdir|
211
- pid = Process.spawn("unzip", "-o", zip_path, "-d", tmpdir)
212
- Process.wait(pid)
213
- manifest_path = File.join(tmpdir, "_encryption_manifest.json")
214
- raise Browserctl::Error, "missing encryption manifest in zip" unless File.exist?(manifest_path)
215
-
216
- manifest = JSON.parse(File.read(manifest_path), symbolize_names: true)
217
- key = derive_key(passphrase, [manifest[:salt]].pack("H*"))
218
- copy_decrypted_files(tmpdir, sessions_dir, key)
219
- end
220
- end
221
- private_class_method :decrypt_import
222
-
223
- def self.copy_decrypted_files(tmpdir, sessions_dir, key)
224
- Dir[File.join(tmpdir, "**", "*")].each do |file|
225
- next if File.directory?(file)
226
- next if File.basename(file) == "_encryption_manifest.json"
227
-
228
- relative = file.delete_prefix("#{tmpdir}/")
229
- dest_path = File.join(sessions_dir, relative)
230
- FileUtils.mkdir_p(File.dirname(dest_path))
231
- if file.end_with?(".enc")
232
- File.open(dest_path.delete_suffix(".enc"), "wb", 0o600) do |f|
233
- f.write(decrypt_blob(File.binread(file), key))
234
- end
235
- else
236
- FileUtils.cp(file, dest_path)
237
- end
238
- end
239
- end
240
- private_class_method :copy_decrypted_files
241
- end
242
- end
243
- end
@@ -1,13 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Browserctl
4
- module Driver
5
- class Base
6
- def create_page = raise NotImplementedError, "#{self.class.name}#create_page not implemented"
7
- def quit = raise NotImplementedError, "#{self.class.name}#quit not implemented"
8
- def headed? = raise NotImplementedError, "#{self.class.name}#headed? not implemented"
9
- def supports?(_) = false
10
- def devtools_info(_page) = raise NotImplementedError, "#{self.class.name}#devtools_info not implemented"
11
- end
12
- end
13
- end
@@ -1,5 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "driver/base"
4
- require_relative "driver/cdp_page"
5
- require_relative "driver/cdp"
@@ -1,94 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "../../session"
4
-
5
- module Browserctl
6
- class CommandDispatcher
7
- module Handlers
8
- module Session
9
- private
10
-
11
- def cmd_session_save(req)
12
- first_session = @global_mutex.synchronize { @pages.values.first }
13
- return { error: "no open pages — open a page before saving a session" } unless first_session
14
-
15
- cookies = first_session.page.cookies.all.values.map(&:to_h)
16
-
17
- pages_meta = {}
18
- local_storage = {}
19
- @global_mutex.synchronize { @pages.dup }.each do |page_name, session|
20
- session.mutex.synchronize do
21
- origin = session.page.evaluate("location.origin")
22
- local_str = session.page.evaluate("JSON.stringify({...localStorage})")
23
- pages_meta[page_name] = { url: session.page.current_url, title: session.page.title }
24
- local_storage[origin] = JSON.parse(local_str)
25
- end
26
- end
27
-
28
- now = Time.now.iso8601
29
- Browserctl::Session.save(
30
- req[:session_name],
31
- metadata: { version: 1, name: req[:session_name],
32
- created_at: now, updated_at: now, pages: pages_meta },
33
- cookies: cookies,
34
- local_storage: local_storage,
35
- session_storage: {},
36
- encrypt: req[:encrypt] || false
37
- )
38
- { ok: true, path: Browserctl::Session.path(req[:session_name]),
39
- pages: pages_meta.length, cookies: cookies.length }
40
- end
41
-
42
- def cmd_session_load(req)
43
- data = Browserctl::Session.load(req[:session_name])
44
-
45
- data[:metadata][:pages].each do |page_name, page_data|
46
- existing = @global_mutex.synchronize { @pages[page_name.to_s] }
47
- if existing
48
- existing.page.go_to(page_data[:url])
49
- else
50
- new_page = @driver.create_page
51
- new_page.go_to(page_data[:url])
52
- @global_mutex.synchronize { @pages[page_name.to_s] = PageSession.new(new_page) }
53
- end
54
- end
55
-
56
- seed_session = @global_mutex.synchronize { @pages.values.first }
57
- cookie_count = data[:cookies].length
58
- data[:cookies].each { |c| seed_session.page.cookies.set(**c.slice(:name, :value, :domain, :path)) }
59
-
60
- ls_key_count = 0
61
- data[:local_storage].each do |origin, keys|
62
- next if keys.empty?
63
-
64
- tmp_page = @driver.create_page
65
- begin
66
- tmp_page.go_to(origin)
67
- keys.each do |k, v|
68
- tmp_page.evaluate("localStorage.setItem(#{k.to_json}, #{v.to_json})")
69
- ls_key_count += 1
70
- end
71
- ensure
72
- tmp_page.close
73
- end
74
- end
75
-
76
- { ok: true, cookies: cookie_count, pages: data[:metadata][:pages].length,
77
- local_storage_keys: ls_key_count }
78
- rescue Browserctl::Error, ArgumentError, JSON::ParserError, RuntimeError => e
79
- { error: e.message }
80
- end
81
-
82
- def cmd_session_list(_req)
83
- sessions = Browserctl::Session.all
84
- { ok: true, sessions: sessions }
85
- end
86
-
87
- def cmd_session_delete(req)
88
- Browserctl::Session.delete(req[:session_name])
89
- { ok: true }
90
- end
91
- end
92
- end
93
- end
94
- end
@@ -1,206 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "fileutils"
4
- require "json"
5
- require "openssl"
6
- require "securerandom"
7
- require "open3"
8
- require_relative "constants"
9
-
10
- module Browserctl
11
- class Session
12
- BASE_DIR = File.join(BROWSERCTL_DIR, "sessions")
13
-
14
- SAFE_NAME = /\A[a-zA-Z0-9_-]{1,64}\z/
15
-
16
- SENSITIVE_FILES = %w[cookies.json local_storage.json session_storage.json].freeze
17
-
18
- def self.path(name) = File.join(BASE_DIR, name)
19
- def self.exist?(name) = Dir.exist?(path(name))
20
-
21
- def self.delete(name)
22
- validate_name!(name)
23
- FileUtils.rm_rf(path(name))
24
- end
25
-
26
- def self.all
27
- Dir[File.join(BASE_DIR, "*/metadata.json")].filter_map { |f| load_meta(f) }
28
- end
29
-
30
- def self.save(session_name, metadata:, cookies:, local_storage:, session_storage:, encrypt: false) # rubocop:disable Metrics/ParameterLists
31
- validate_name!(session_name)
32
- key, metadata = prepare_encryption(session_name, metadata, encrypt)
33
- dir = path(session_name)
34
- FileUtils.mkdir_p(dir)
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
41
-
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
68
- end
69
- private_class_method :write_session_files
70
-
71
- def self.load(session_name)
72
- validate_name!(session_name)
73
- dir = path(session_name)
74
- raise Browserctl::Error, "session '#{session_name}' not found" unless Dir.exist?(dir)
75
-
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
94
- end
95
-
96
- def self.load_meta(path)
97
- JSON.parse(File.read(path), symbolize_names: true)
98
- rescue JSON::ParserError
99
- nil
100
- end
101
-
102
- def self.validate_name!(name)
103
- return if SAFE_NAME.match?(name.to_s)
104
-
105
- raise ArgumentError, "invalid session name #{name.inspect} — use letters, digits, _ or - (max 64 chars)"
106
- end
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", out: File::NULL, err: File::NULL)
166
- end
167
- private_class_method :keychain_available?
168
-
169
- def self.load_session_storage(dir)
170
- ss_path = File.join(dir, "session_storage.json")
171
- File.exist?(ss_path) ? JSON.parse(File.read(ss_path), symbolize_names: false) : {}
172
- end
173
- private_class_method :load_session_storage
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
-
189
- def self.write_json(path, data)
190
- File.open(path, "w", 0o600) { |f| f.write(JSON.generate(data)) }
191
- end
192
- private_class_method :write_json
193
-
194
- # Cookies and storage contain secrets — restrict to owner read/write only.
195
- def self.write_secret(path, data)
196
- File.open(path, "w", 0o600) { |f| f.write(JSON.generate(data)) }
197
- end
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
205
- end
206
- end