browserctl 0.11.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -0
- data/README.md +4 -3
- data/bin/browserctl +171 -115
- data/bin/browserd +8 -1
- data/lib/browserctl/callable_definition.rb +114 -0
- data/lib/browserctl/client.rb +3 -30
- data/lib/browserctl/commands/cli_output.rb +38 -4
- data/lib/browserctl/commands/daemon.rb +10 -6
- data/lib/browserctl/commands/flow.rb +7 -5
- data/lib/browserctl/commands/init.rb +20 -7
- data/lib/browserctl/commands/migrate.rb +142 -0
- data/lib/browserctl/commands/output_format.rb +144 -0
- data/lib/browserctl/commands/page.rb +9 -5
- data/lib/browserctl/commands/{record.rb → recording.rb} +14 -13
- data/lib/browserctl/commands/resume.rb +1 -1
- data/lib/browserctl/commands/screenshot.rb +2 -2
- data/lib/browserctl/commands/snapshot.rb +8 -3
- data/lib/browserctl/commands/state.rb +3 -2
- data/lib/browserctl/commands/trace.rb +216 -0
- data/lib/browserctl/commands/workflow.rb +9 -7
- data/lib/browserctl/constants.rb +3 -1
- data/lib/browserctl/contextual_persistence.rb +58 -0
- data/lib/browserctl/crash_report.rb +96 -0
- data/lib/browserctl/driver/cdp.rb +2 -3
- data/lib/browserctl/encryption_service.rb +84 -0
- data/lib/browserctl/error/codes.rb +44 -0
- data/lib/browserctl/error/exit_codes.rb +54 -0
- data/lib/browserctl/error/suggested_actions.rb +41 -0
- data/lib/browserctl/errors.rb +44 -14
- data/lib/browserctl/flow.rb +35 -59
- data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +4 -4
- data/lib/browserctl/format_version.rb +37 -0
- data/lib/browserctl/logger.rb +102 -9
- data/lib/browserctl/migrations.rb +216 -0
- data/lib/browserctl/recording/log_writer.rb +82 -0
- data/lib/browserctl/recording/redactor.rb +58 -0
- data/lib/browserctl/recording/state.rb +44 -0
- data/lib/browserctl/recording/workflow_renderer.rb +214 -0
- data/lib/browserctl/recording.rb +39 -268
- data/lib/browserctl/redactor.rb +58 -0
- data/lib/browserctl/rubocop/cops/typed_error.rb +69 -0
- data/lib/browserctl/runner.rb +12 -6
- data/lib/browserctl/secret_resolver_registry.rb +23 -4
- data/lib/browserctl/server/command_dispatcher.rb +28 -16
- data/lib/browserctl/server/handlers/daemon_control.rb +5 -1
- data/lib/browserctl/server/handlers/error_payload.rb +27 -0
- data/lib/browserctl/server/handlers/interaction.rb +21 -3
- data/lib/browserctl/server/handlers/navigation.rb +19 -3
- data/lib/browserctl/server/handlers/state.rb +7 -5
- data/lib/browserctl/server.rb +2 -1
- data/lib/browserctl/state/bundle.rb +63 -49
- data/lib/browserctl/state.rb +46 -9
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow/flow_wrapper.rb +1 -1
- data/lib/browserctl/workflow/recovery_manager.rb +87 -0
- data/lib/browserctl/workflow.rb +117 -238
- metadata +25 -14
- data/examples/session_reuse.rb +0 -75
- data/lib/browserctl/commands/session.rb +0 -243
- data/lib/browserctl/driver/base.rb +0 -13
- data/lib/browserctl/driver.rb +0 -5
- data/lib/browserctl/server/handlers/session.rb +0 -94
- data/lib/browserctl/session.rb +0 -206
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "snapshot_builder"
|
|
4
4
|
require_relative "page_session"
|
|
5
|
+
require_relative "handlers/error_payload"
|
|
5
6
|
require_relative "handlers/page_lifecycle"
|
|
6
7
|
require_relative "handlers/navigation"
|
|
7
8
|
require_relative "handlers/observation"
|
|
@@ -10,15 +11,16 @@ require_relative "handlers/hitl"
|
|
|
10
11
|
require_relative "handlers/devtools"
|
|
11
12
|
require_relative "handlers/daemon_control"
|
|
12
13
|
require_relative "handlers/storage"
|
|
13
|
-
require_relative "handlers/session"
|
|
14
14
|
require_relative "handlers/state"
|
|
15
15
|
require_relative "handlers/interaction"
|
|
16
16
|
require_relative "../detectors"
|
|
17
17
|
require_relative "../policy"
|
|
18
|
+
require_relative "../errors"
|
|
18
19
|
require_relative "../replay/snapshot_diff"
|
|
19
20
|
|
|
20
21
|
module Browserctl
|
|
21
22
|
class CommandDispatcher
|
|
23
|
+
include Handlers::ErrorPayload
|
|
22
24
|
include Handlers::PageLifecycle
|
|
23
25
|
include Handlers::Navigation
|
|
24
26
|
include Handlers::Observation
|
|
@@ -27,7 +29,6 @@ module Browserctl
|
|
|
27
29
|
include Handlers::DevTools
|
|
28
30
|
include Handlers::DaemonControl
|
|
29
31
|
include Handlers::Storage
|
|
30
|
-
include Handlers::Session
|
|
31
32
|
include Handlers::State
|
|
32
33
|
include Handlers::Interaction
|
|
33
34
|
|
|
@@ -67,10 +68,6 @@ module Browserctl
|
|
|
67
68
|
"select" => :cmd_select,
|
|
68
69
|
"dialog_accept" => :cmd_dialog_accept,
|
|
69
70
|
"dialog_dismiss" => :cmd_dialog_dismiss,
|
|
70
|
-
"session_save" => :cmd_session_save,
|
|
71
|
-
"session_load" => :cmd_session_load,
|
|
72
|
-
"session_list" => :cmd_session_list,
|
|
73
|
-
"session_delete" => :cmd_session_delete,
|
|
74
71
|
"state_save" => :cmd_state_save,
|
|
75
72
|
"state_load" => :cmd_state_load,
|
|
76
73
|
"state_list" => :cmd_state_list,
|
|
@@ -96,23 +93,38 @@ module Browserctl
|
|
|
96
93
|
# @param req [Hash{Symbol => Object}] parsed request; must include `:cmd`
|
|
97
94
|
# @return [Hash{Symbol => Object}] response; always includes `:ok` or `:error`
|
|
98
95
|
def dispatch(req)
|
|
99
|
-
|
|
100
|
-
if
|
|
101
|
-
Browserctl.logger.debug("#{req[:cmd]} #{req[:name]}")
|
|
102
|
-
return send(handler, req)
|
|
103
|
-
end
|
|
96
|
+
builtin = dispatch_builtin(req)
|
|
97
|
+
return builtin if builtin
|
|
104
98
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
session = req[:name] ? @global_mutex.synchronize { @pages[req[:name]] } : nil
|
|
108
|
-
return plugin.call(session, req)
|
|
109
|
-
end
|
|
99
|
+
plugin = dispatch_plugin(req)
|
|
100
|
+
return plugin if plugin
|
|
110
101
|
|
|
111
102
|
{ error: "unknown command: #{req[:cmd]}" }
|
|
112
103
|
end
|
|
113
104
|
|
|
114
105
|
private
|
|
115
106
|
|
|
107
|
+
# Routes the request to a builtin handler if `req[:cmd]` is in COMMAND_MAP.
|
|
108
|
+
# Returns the handler response, or `nil` if the command isn't a builtin.
|
|
109
|
+
def dispatch_builtin(req)
|
|
110
|
+
handler = COMMAND_MAP[req[:cmd]]
|
|
111
|
+
return nil unless handler
|
|
112
|
+
|
|
113
|
+
Browserctl.logger.debug("#{req[:cmd]} #{req[:name]}")
|
|
114
|
+
send(handler, req)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Routes the request to a registered plugin command if one matches.
|
|
118
|
+
# Returns the plugin response, or `nil` if no plugin handles `req[:cmd]`.
|
|
119
|
+
def dispatch_plugin(req)
|
|
120
|
+
plugin = Browserctl.lookup_plugin_command(req[:cmd])
|
|
121
|
+
return nil unless plugin
|
|
122
|
+
|
|
123
|
+
Browserctl.logger.debug("plugin:#{req[:cmd]} #{req[:name]}")
|
|
124
|
+
session = req[:name] ? @global_mutex.synchronize { @pages[req[:name]] } : nil
|
|
125
|
+
plugin.call(session, req)
|
|
126
|
+
end
|
|
127
|
+
|
|
116
128
|
def with_page(name)
|
|
117
129
|
session = @global_mutex.synchronize { @pages[name] }
|
|
118
130
|
return { error: "no page named '#{name}'" } unless session
|
|
@@ -21,7 +21,11 @@ module Browserctl
|
|
|
21
21
|
def cmd_fetch(req)
|
|
22
22
|
key = req[:key].to_s
|
|
23
23
|
found = @kv_mutex.synchronize { @kv_store.key?(key) ? { ok: true, value: @kv_store[key] } : nil }
|
|
24
|
-
found ||
|
|
24
|
+
found || error_payload(
|
|
25
|
+
code: Browserctl::Error::Codes::KEY_NOT_FOUND,
|
|
26
|
+
message: "key '#{key}' not found",
|
|
27
|
+
context: { key: key }
|
|
28
|
+
)
|
|
25
29
|
end
|
|
26
30
|
end
|
|
27
31
|
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../errors"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
class CommandDispatcher
|
|
7
|
+
module Handlers
|
|
8
|
+
# Centralised structured-error builder for daemon JSON-RPC responses.
|
|
9
|
+
# Each handler returns `{ error:, code:, context:, suggested_action: }`
|
|
10
|
+
# for any failure carrying a stable {Browserctl::Error::Codes} code.
|
|
11
|
+
module ErrorPayload
|
|
12
|
+
# @param code [String] a SCREAMING_SNAKE code from {Codes}
|
|
13
|
+
# @param message [String] human-readable error
|
|
14
|
+
# @param context [Hash] free-form structured fields (selector, path, ...)
|
|
15
|
+
# @return [Hash{Symbol => Object}]
|
|
16
|
+
def error_payload(code:, message:, context: {})
|
|
17
|
+
{
|
|
18
|
+
error: message,
|
|
19
|
+
code: code,
|
|
20
|
+
context: context,
|
|
21
|
+
suggested_action: Browserctl::Error::SuggestedActions.for(code)
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -27,7 +27,13 @@ module Browserctl
|
|
|
27
27
|
"return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; " \
|
|
28
28
|
"})(#{sel.to_json})"
|
|
29
29
|
)
|
|
30
|
-
|
|
30
|
+
unless coords
|
|
31
|
+
return error_payload(
|
|
32
|
+
code: Browserctl::Error::Codes::SELECTOR_NOT_FOUND,
|
|
33
|
+
message: "selector not found: #{sel}",
|
|
34
|
+
context: { selector: sel }
|
|
35
|
+
)
|
|
36
|
+
end
|
|
31
37
|
|
|
32
38
|
session.page.mouse.move(x: coords["x"], y: coords["y"])
|
|
33
39
|
{ ok: true }
|
|
@@ -43,7 +49,13 @@ module Browserctl
|
|
|
43
49
|
return sel if sel.is_a?(Hash)
|
|
44
50
|
|
|
45
51
|
el = session.page.at_css(sel)
|
|
46
|
-
|
|
52
|
+
unless el
|
|
53
|
+
return error_payload(
|
|
54
|
+
code: Browserctl::Error::Codes::SELECTOR_NOT_FOUND,
|
|
55
|
+
message: "selector not found: #{sel}",
|
|
56
|
+
context: { selector: sel }
|
|
57
|
+
)
|
|
58
|
+
end
|
|
47
59
|
|
|
48
60
|
el.select_file(path)
|
|
49
61
|
{ ok: true }
|
|
@@ -56,7 +68,13 @@ module Browserctl
|
|
|
56
68
|
return sel if sel.is_a?(Hash)
|
|
57
69
|
|
|
58
70
|
el = session.page.at_css(sel)
|
|
59
|
-
|
|
71
|
+
unless el
|
|
72
|
+
return error_payload(
|
|
73
|
+
code: Browserctl::Error::Codes::SELECTOR_NOT_FOUND,
|
|
74
|
+
message: "selector not found: #{sel}",
|
|
75
|
+
context: { selector: sel }
|
|
76
|
+
)
|
|
77
|
+
end
|
|
60
78
|
|
|
61
79
|
el.evaluate(
|
|
62
80
|
"this.value = #{req[:value].to_json}; " \
|
|
@@ -8,7 +8,11 @@ module Browserctl
|
|
|
8
8
|
|
|
9
9
|
def cmd_navigate(req)
|
|
10
10
|
unless Policy.allowed_navigation?(req[:url].to_s)
|
|
11
|
-
return
|
|
11
|
+
return error_payload(
|
|
12
|
+
code: Browserctl::Error::Codes::DOMAIN_NOT_ALLOWED,
|
|
13
|
+
message: "navigation to '#{req[:url]}' blocked by domain policy",
|
|
14
|
+
context: { url: req[:url].to_s }
|
|
15
|
+
)
|
|
12
16
|
end
|
|
13
17
|
|
|
14
18
|
with_page(req[:name]) do |session|
|
|
@@ -81,7 +85,13 @@ module Browserctl
|
|
|
81
85
|
|
|
82
86
|
def type_into(page, selector, value)
|
|
83
87
|
el = page.at_css(selector)
|
|
84
|
-
|
|
88
|
+
unless el
|
|
89
|
+
return error_payload(
|
|
90
|
+
code: Browserctl::Error::Codes::SELECTOR_NOT_FOUND,
|
|
91
|
+
message: "selector not found: #{selector}",
|
|
92
|
+
context: { selector: selector }
|
|
93
|
+
)
|
|
94
|
+
end
|
|
85
95
|
|
|
86
96
|
el.focus
|
|
87
97
|
el.evaluate("this.select()")
|
|
@@ -91,7 +101,13 @@ module Browserctl
|
|
|
91
101
|
|
|
92
102
|
def click_element(page, selector)
|
|
93
103
|
el = page.at_css(selector)
|
|
94
|
-
|
|
104
|
+
unless el
|
|
105
|
+
return error_payload(
|
|
106
|
+
code: Browserctl::Error::Codes::SELECTOR_NOT_FOUND,
|
|
107
|
+
message: "selector not found: #{selector}",
|
|
108
|
+
context: { selector: selector }
|
|
109
|
+
)
|
|
110
|
+
end
|
|
95
111
|
|
|
96
112
|
# Use the DOM native click() so JS-only event listeners fire.
|
|
97
113
|
# CDP mouse simulation (el.click) dispatches events at screen coordinates
|
|
@@ -15,21 +15,23 @@ module Browserctl
|
|
|
15
15
|
first_session = @global_mutex.synchronize { @pages.values.first }
|
|
16
16
|
return { error: "no open pages — open a page before saving state" } unless first_session
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
captured, captured_origins = capture_state_payload
|
|
19
|
+
payload = Browserctl::State::Payload.build(
|
|
20
|
+
cookies: captured[:cookies],
|
|
21
|
+
local_storage: captured[:local_storage],
|
|
22
|
+
session_storage: captured[:session_storage],
|
|
22
23
|
origins: req[:origins] || captured_origins,
|
|
23
24
|
flow: req[:flow],
|
|
24
25
|
flow_version: req[:flow_version],
|
|
25
26
|
passphrase: req[:passphrase]
|
|
26
27
|
)
|
|
28
|
+
manifest = Browserctl::State.save(req[:name], payload)
|
|
27
29
|
|
|
28
30
|
{
|
|
29
31
|
ok: true,
|
|
30
32
|
path: Browserctl::State.path(req[:name]),
|
|
31
33
|
origins: manifest[:origins],
|
|
32
|
-
cookies: payload
|
|
34
|
+
cookies: payload.cookies.length,
|
|
33
35
|
encrypted: manifest[:encrypted]
|
|
34
36
|
}
|
|
35
37
|
rescue Browserctl::Error, ArgumentError => e
|
data/lib/browserctl/server.rb
CHANGED
|
@@ -6,7 +6,8 @@ require "fileutils"
|
|
|
6
6
|
require "timeout"
|
|
7
7
|
require_relative "constants"
|
|
8
8
|
require_relative "logger"
|
|
9
|
-
require_relative "driver"
|
|
9
|
+
require_relative "driver/cdp_page"
|
|
10
|
+
require_relative "driver/cdp"
|
|
10
11
|
require_relative "server/command_dispatcher"
|
|
11
12
|
require_relative "server/idle_watcher"
|
|
12
13
|
require_relative "server/page_session"
|
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "openssl"
|
|
5
|
-
require "securerandom"
|
|
6
5
|
require_relative "../errors"
|
|
6
|
+
require_relative "../error/codes"
|
|
7
|
+
require_relative "../encryption_service"
|
|
7
8
|
|
|
8
9
|
module Browserctl
|
|
9
10
|
module State
|
|
10
|
-
# Single-file portable codec for browserctl
|
|
11
|
+
# Single-file portable codec for browserctl persisted state — the .bctl
|
|
11
12
|
# bundle. Wraps a plaintext manifest (origins, flow binding, timestamps)
|
|
12
13
|
# alongside a payload of cookies + storage. The manifest is always
|
|
13
14
|
# readable without a passphrase (so `state info` can show origins and
|
|
@@ -36,20 +37,30 @@ module Browserctl
|
|
|
36
37
|
# first 32 bytes are the AES-256-GCM encryption key, last 32 bytes are
|
|
37
38
|
# the HMAC-SHA-256 key.
|
|
38
39
|
#
|
|
39
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
40
|
+
# AES-256-GCM cipher setup and PBKDF2 key derivation are delegated to
|
|
41
|
+
# `Browserctl::EncryptionService` so this class stays focused on the
|
|
42
|
+
# bundle wire format. The service translates `OpenSSL::Cipher` errors
|
|
43
|
+
# into `EncryptionService::DecryptionError`, which we map to
|
|
44
|
+
# `PassphraseError` for the public API.
|
|
42
45
|
class Bundle
|
|
43
46
|
MAGIC = "BCTL\x00".b.freeze
|
|
44
47
|
VERSION = 1
|
|
48
|
+
# Manifest-level format version, written as `format_version` and
|
|
49
|
+
# validated on decode. Distinct from the wire-format byte `VERSION`
|
|
50
|
+
# above (which gates the binary envelope shape) — this gates the
|
|
51
|
+
# manifest schema. See docs/reference/format-versions.md.
|
|
52
|
+
BUNDLE_FORMAT_VERSION = 1
|
|
53
|
+
SUPPORTED_FORMAT_VERSIONS = [BUNDLE_FORMAT_VERSION].freeze
|
|
45
54
|
FLAG_ENCRYPTED = 0x01
|
|
46
55
|
HEADER_SIZE = MAGIC.bytesize + 3 # version + flags + reserved
|
|
47
56
|
LEN_SIZE = 4
|
|
48
57
|
FOOTER_SIZE = 32
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
58
|
+
# Cryptographic primitive sizes are sourced from EncryptionService so
|
|
59
|
+
# there is exactly one source of truth for cipher parameters.
|
|
60
|
+
SALT_SIZE = Browserctl::EncryptionService::SALT_SIZE
|
|
61
|
+
NONCE_SIZE = Browserctl::EncryptionService::NONCE_SIZE
|
|
62
|
+
TAG_SIZE = Browserctl::EncryptionService::TAG_SIZE
|
|
63
|
+
PBKDF2_ITERS = Browserctl::EncryptionService::PBKDF2_ITERS
|
|
53
64
|
|
|
54
65
|
class BundleError < Browserctl::Error; def self.default_code = "bundle_error" end
|
|
55
66
|
class TamperError < BundleError; def self.default_code = "bundle_tampered" end
|
|
@@ -63,15 +74,16 @@ module Browserctl
|
|
|
63
74
|
# the footer is an HMAC. When nil, payload is plaintext and the
|
|
64
75
|
# footer is a SHA-256 digest.
|
|
65
76
|
def self.encode(manifest:, payload:, passphrase: nil)
|
|
77
|
+
manifest = stamp_format_version(manifest)
|
|
66
78
|
manifest_bytes = JSON.generate(manifest).b
|
|
67
79
|
payload_json = JSON.generate(payload).b
|
|
68
80
|
flags = 0
|
|
69
81
|
hmac_key = nil
|
|
70
82
|
|
|
71
83
|
if passphrase
|
|
72
|
-
salt =
|
|
73
|
-
enc_key, hmac_key = derive_keys(passphrase, salt)
|
|
74
|
-
payload_bytes = salt +
|
|
84
|
+
salt = EncryptionService.random_salt
|
|
85
|
+
enc_key, hmac_key = EncryptionService.derive_keys(passphrase, salt)
|
|
86
|
+
payload_bytes = salt + EncryptionService.encrypt(payload_json, enc_key)
|
|
75
87
|
flags |= FLAG_ENCRYPTED
|
|
76
88
|
else
|
|
77
89
|
payload_bytes = payload_json
|
|
@@ -96,7 +108,8 @@ module Browserctl
|
|
|
96
108
|
verify_footer!(body, footer, encrypted: encrypted, passphrase: passphrase)
|
|
97
109
|
|
|
98
110
|
manifest = JSON.parse(manifest_bytes, symbolize_names: true)
|
|
99
|
-
|
|
111
|
+
verify_format_version!(manifest)
|
|
112
|
+
payload = decode_payload(payload_bytes, encrypted: encrypted, passphrase: passphrase)
|
|
100
113
|
|
|
101
114
|
{ manifest: manifest, payload: payload, magic: magic, version: version, encrypted: encrypted }
|
|
102
115
|
end
|
|
@@ -108,8 +121,40 @@ module Browserctl
|
|
|
108
121
|
raise BundleError, "unsupported bundle version #{version}" unless version == VERSION
|
|
109
122
|
|
|
110
123
|
manifest_bytes, = read_sections!(blob)
|
|
111
|
-
JSON.parse(manifest_bytes, symbolize_names: true)
|
|
124
|
+
manifest = JSON.parse(manifest_bytes, symbolize_names: true)
|
|
125
|
+
verify_format_version!(manifest)
|
|
126
|
+
manifest
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Returns the manifest with `format_version` set as the first key. When
|
|
130
|
+
# the caller already provided a value we keep it (so encoders can stamp
|
|
131
|
+
# a future version explicitly); otherwise we stamp the current one.
|
|
132
|
+
def self.stamp_format_version(manifest)
|
|
133
|
+
existing = manifest[:format_version] || manifest["format_version"]
|
|
134
|
+
version = existing || BUNDLE_FORMAT_VERSION
|
|
135
|
+
rest = manifest.except(:format_version, "format_version")
|
|
136
|
+
{ format_version: version }.merge(rest)
|
|
137
|
+
end
|
|
138
|
+
private_class_method :stamp_format_version
|
|
139
|
+
|
|
140
|
+
# Raises Browserctl::ProtocolMismatch when the manifest declares no
|
|
141
|
+
# format_version or one this build does not support. The error carries
|
|
142
|
+
# the canonical PROTOCOL_MISMATCH code from the v0.12 error taxonomy.
|
|
143
|
+
def self.verify_format_version!(manifest, path: nil)
|
|
144
|
+
version = manifest[:format_version] || manifest["format_version"]
|
|
145
|
+
return if version && SUPPORTED_FORMAT_VERSIONS.include?(version)
|
|
146
|
+
|
|
147
|
+
where = path ? " at #{path}" : ""
|
|
148
|
+
msg = if version.nil?
|
|
149
|
+
"bundle manifest#{where} is missing format_version " \
|
|
150
|
+
"(supported: #{SUPPORTED_FORMAT_VERSIONS.inspect})"
|
|
151
|
+
else
|
|
152
|
+
"bundle manifest#{where} declares format_version=#{version.inspect}, " \
|
|
153
|
+
"this build supports #{SUPPORTED_FORMAT_VERSIONS.inspect}"
|
|
154
|
+
end
|
|
155
|
+
raise Browserctl::ProtocolMismatch.new(msg, code: Browserctl::Error::Codes::PROTOCOL_MISMATCH)
|
|
112
156
|
end
|
|
157
|
+
private_class_method :verify_format_version!
|
|
113
158
|
|
|
114
159
|
def self.build_body(flags, manifest_bytes, payload_bytes)
|
|
115
160
|
header = MAGIC + [VERSION, flags, 0].pack("CCC")
|
|
@@ -166,7 +211,7 @@ module Browserctl
|
|
|
166
211
|
# We need the HMAC key, which depends on the salt embedded in the
|
|
167
212
|
# payload. Pull the salt from the payload bytes inside `body`.
|
|
168
213
|
salt = extract_salt!(body)
|
|
169
|
-
_, hmac_key = derive_keys(passphrase, salt)
|
|
214
|
+
_, hmac_key = EncryptionService.derive_keys(passphrase, salt)
|
|
170
215
|
expected = OpenSSL::HMAC.digest("SHA256", hmac_key, body)
|
|
171
216
|
raise PassphraseError, "wrong passphrase or tampered bundle" unless secure_eq?(footer, expected)
|
|
172
217
|
else
|
|
@@ -189,48 +234,17 @@ module Browserctl
|
|
|
189
234
|
if encrypted
|
|
190
235
|
salt = bytes.byteslice(0, SALT_SIZE)
|
|
191
236
|
ciphertext = bytes.byteslice(SALT_SIZE, bytes.bytesize - SALT_SIZE)
|
|
192
|
-
enc_key, = derive_keys(passphrase, salt)
|
|
193
|
-
plaintext =
|
|
237
|
+
enc_key, = EncryptionService.derive_keys(passphrase, salt)
|
|
238
|
+
plaintext = EncryptionService.decrypt(ciphertext, enc_key)
|
|
194
239
|
JSON.parse(plaintext)
|
|
195
240
|
else
|
|
196
241
|
JSON.parse(bytes)
|
|
197
242
|
end
|
|
198
|
-
rescue
|
|
243
|
+
rescue EncryptionService::DecryptionError
|
|
199
244
|
raise PassphraseError, "wrong passphrase — payload could not be decrypted"
|
|
200
245
|
end
|
|
201
246
|
private_class_method :decode_payload
|
|
202
247
|
|
|
203
|
-
def self.derive_keys(passphrase, salt)
|
|
204
|
-
material = OpenSSL::PKCS5.pbkdf2_hmac(passphrase.to_s, salt, PBKDF2_ITERS, 64, "SHA256")
|
|
205
|
-
[material.byteslice(0, 32), material.byteslice(32, 32)]
|
|
206
|
-
end
|
|
207
|
-
private_class_method :derive_keys
|
|
208
|
-
|
|
209
|
-
def self.aes_gcm_encrypt(plaintext, key)
|
|
210
|
-
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
211
|
-
cipher.encrypt
|
|
212
|
-
cipher.key = key
|
|
213
|
-
nonce = SecureRandom.bytes(NONCE_SIZE)
|
|
214
|
-
cipher.iv = nonce
|
|
215
|
-
ct = cipher.update(plaintext) + cipher.final
|
|
216
|
-
nonce + ct + cipher.auth_tag
|
|
217
|
-
end
|
|
218
|
-
private_class_method :aes_gcm_encrypt
|
|
219
|
-
|
|
220
|
-
def self.aes_gcm_decrypt(blob, key)
|
|
221
|
-
nonce = blob.byteslice(0, NONCE_SIZE)
|
|
222
|
-
tag = blob.byteslice(-TAG_SIZE, TAG_SIZE)
|
|
223
|
-
ciphertext = blob.byteslice(NONCE_SIZE, blob.bytesize - NONCE_SIZE - TAG_SIZE)
|
|
224
|
-
|
|
225
|
-
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
|
226
|
-
cipher.decrypt
|
|
227
|
-
cipher.key = key
|
|
228
|
-
cipher.iv = nonce
|
|
229
|
-
cipher.auth_tag = tag
|
|
230
|
-
cipher.update(ciphertext) + cipher.final
|
|
231
|
-
end
|
|
232
|
-
private_class_method :aes_gcm_decrypt
|
|
233
|
-
|
|
234
248
|
def self.secure_eq?(actual, expected)
|
|
235
249
|
return false if actual.bytesize != expected.bytesize
|
|
236
250
|
|
data/lib/browserctl/state.rb
CHANGED
|
@@ -42,6 +42,41 @@ module Browserctl
|
|
|
42
42
|
EXTENSION = ".bctl"
|
|
43
43
|
MANIFEST_VERSION = 1
|
|
44
44
|
|
|
45
|
+
# Value object bundling everything needed to persist a state bundle. The
|
|
46
|
+
# browser-side data lives in `cookies`, `local_storage`, and
|
|
47
|
+
# `session_storage`; the manifest extras live in `origins`, `flow`, and
|
|
48
|
+
# `flow_version`; `passphrase` flips the bundle into an encrypted variant.
|
|
49
|
+
Payload = Data.define(
|
|
50
|
+
:cookies,
|
|
51
|
+
:local_storage,
|
|
52
|
+
:session_storage,
|
|
53
|
+
:origins,
|
|
54
|
+
:flow,
|
|
55
|
+
:flow_version,
|
|
56
|
+
:passphrase
|
|
57
|
+
) do
|
|
58
|
+
def self.build(cookies: [], local_storage: {}, session_storage: {}, # rubocop:disable Metrics/ParameterLists
|
|
59
|
+
origins: nil, flow: nil, flow_version: nil, passphrase: nil)
|
|
60
|
+
new(
|
|
61
|
+
cookies: cookies,
|
|
62
|
+
local_storage: local_storage,
|
|
63
|
+
session_storage: session_storage,
|
|
64
|
+
origins: origins,
|
|
65
|
+
flow: flow,
|
|
66
|
+
flow_version: flow_version,
|
|
67
|
+
passphrase: passphrase
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def to_bundle_payload
|
|
72
|
+
{
|
|
73
|
+
cookies: cookies,
|
|
74
|
+
local_storage: local_storage,
|
|
75
|
+
session_storage: session_storage
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
45
80
|
def self.path(name) = File.join(BASE_DIR, "#{name}#{EXTENSION}")
|
|
46
81
|
def self.exist?(name) = File.exist?(path(name))
|
|
47
82
|
|
|
@@ -51,22 +86,24 @@ module Browserctl
|
|
|
51
86
|
raise ArgumentError, "invalid state name #{name.inspect} — use letters, digits, _ or - (max 64 chars)"
|
|
52
87
|
end
|
|
53
88
|
|
|
54
|
-
# Persist a bundle. `payload` is
|
|
55
|
-
#
|
|
56
|
-
|
|
89
|
+
# Persist a bundle. `payload` is a `State::Payload` value object carrying
|
|
90
|
+
# cookies, local/session storage, and the manifest extras (origins, flow,
|
|
91
|
+
# flow_version, passphrase).
|
|
92
|
+
def self.save(name, payload)
|
|
57
93
|
validate_name!(name)
|
|
58
94
|
FileUtils.mkdir_p(BASE_DIR)
|
|
59
95
|
|
|
96
|
+
bundle_payload = payload.to_bundle_payload
|
|
60
97
|
manifest = build_manifest(
|
|
61
98
|
name: name,
|
|
62
|
-
origins: origins || derive_origins(
|
|
63
|
-
flow: flow,
|
|
64
|
-
flow_version: flow_version,
|
|
65
|
-
cookies: payload
|
|
66
|
-
encrypted: !passphrase.nil?
|
|
99
|
+
origins: payload.origins || derive_origins(bundle_payload),
|
|
100
|
+
flow: payload.flow,
|
|
101
|
+
flow_version: payload.flow_version,
|
|
102
|
+
cookies: payload.cookies || [],
|
|
103
|
+
encrypted: !payload.passphrase.nil?
|
|
67
104
|
)
|
|
68
105
|
|
|
69
|
-
blob = Bundle.encode(manifest: manifest, payload:
|
|
106
|
+
blob = Bundle.encode(manifest: manifest, payload: bundle_payload, passphrase: payload.passphrase)
|
|
70
107
|
File.open(path(name), "wb", 0o600) { |f| f.write(blob) }
|
|
71
108
|
manifest
|
|
72
109
|
end
|
data/lib/browserctl/version.rb
CHANGED
|
@@ -59,7 +59,7 @@ module Browserctl
|
|
|
59
59
|
def write(defn, overwrite: true, dir: nil)
|
|
60
60
|
path = dir ? File.join(dir, "#{defn.name}.rb") : target_path(defn.name)
|
|
61
61
|
if File.exist?(path) && !overwrite
|
|
62
|
-
raise "flow wrapper already exists at #{path} (pass overwrite: true to replace)"
|
|
62
|
+
raise Browserctl::WorkflowError, "flow wrapper already exists at #{path} (pass overwrite: true to replace)"
|
|
63
63
|
end
|
|
64
64
|
|
|
65
65
|
FileUtils.mkdir_p(File.dirname(path))
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browserctl
|
|
4
|
+
module Workflow
|
|
5
|
+
# Owns the AUTH_REQUIRED recovery state machine for `load_state`.
|
|
6
|
+
#
|
|
7
|
+
# When the daemon reports AUTH_REQUIRED on a `state_load` (e.g. expired
|
|
8
|
+
# cookies in the bundle), the manager either runs the bound flow or
|
|
9
|
+
# the caller-provided override, re-saves the bundle, and reloads it
|
|
10
|
+
# with `skip_auth_check: true`.
|
|
11
|
+
#
|
|
12
|
+
# Decoupled from {ContextualPersistence} so the multi-step recovery
|
|
13
|
+
# logic has a dedicated home and a dedicated spec. The host context
|
|
14
|
+
# only needs to expose `client` (for daemon RPCs) and `invoke` (for
|
|
15
|
+
# running the bound flow); see {ContextualPersistence#load_state}.
|
|
16
|
+
class RecoveryManager
|
|
17
|
+
AUTH_REQUIRED_CODE = "AUTH_REQUIRED"
|
|
18
|
+
|
|
19
|
+
# @param context [#client, #invoke] a workflow context exposing
|
|
20
|
+
# the daemon client and an `invoke(flow_name, page:)` entry point.
|
|
21
|
+
def initialize(context)
|
|
22
|
+
@context = context
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# True when `res` is the daemon's AUTH_REQUIRED preflight signal.
|
|
26
|
+
def self.auth_required?(res)
|
|
27
|
+
(res[:code] || res["code"]) == AUTH_REQUIRED_CODE
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Run recovery for `state_name` given the daemon's initial AUTH_REQUIRED
|
|
31
|
+
# response. Returns the merged retry result (with `rotated: true`) or
|
|
32
|
+
# raises {WorkflowError} when no flow is bound and no override is
|
|
33
|
+
# supplied, or when the post-rotation reload still fails.
|
|
34
|
+
#
|
|
35
|
+
# @param state_name [String] the bundle name being loaded
|
|
36
|
+
# @param initial_res [Hash] the original AUTH_REQUIRED response
|
|
37
|
+
# @param on_auth_required [Proc, nil] optional override; when given,
|
|
38
|
+
# it runs in lieu of invoking the suggested flow.
|
|
39
|
+
def recover(state_name, initial_res, on_auth_required: nil)
|
|
40
|
+
if on_auth_required
|
|
41
|
+
on_auth_required.call
|
|
42
|
+
else
|
|
43
|
+
invoke_bound_flow(state_name, initial_res)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
rotate_and_reload(state_name)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
attr_reader :context
|
|
52
|
+
|
|
53
|
+
def invoke_bound_flow(state_name, initial_res)
|
|
54
|
+
flow_name = initial_res[:suggested_flow] || initial_res["suggested_flow"]
|
|
55
|
+
if flow_name.nil? || flow_name.to_s.empty?
|
|
56
|
+
raise WorkflowError,
|
|
57
|
+
"state '#{state_name}' needs auth but bundle has no bound flow — " \
|
|
58
|
+
"save with `save_state('#{state_name}', flow: :NAME)` or pass on_auth_required:"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Match the daemon's `state load` preflight: it auth-checks the first
|
|
62
|
+
# open page (insertion order). Passing that same name to the flow
|
|
63
|
+
# gives stdlib flows a `page` proxy to drive (oauth_github reads
|
|
64
|
+
# `page.url`, totp_2fa calls `page.fill`, etc.). Falls back to no
|
|
65
|
+
# page only when nothing is open — `state_save` would have errored
|
|
66
|
+
# earlier in that case, so this is a defence-in-depth nil.
|
|
67
|
+
context.invoke(flow_name, page: first_open_page)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def rotate_and_reload(state_name)
|
|
71
|
+
after_save = context.client.state_save(state_name)
|
|
72
|
+
raise WorkflowError, after_save[:error] if after_save[:error]
|
|
73
|
+
|
|
74
|
+
retry_res = context.client.state_load(state_name, skip_auth_check: true)
|
|
75
|
+
raise WorkflowError, retry_res[:error] if retry_res[:error]
|
|
76
|
+
|
|
77
|
+
retry_res.merge(rotated: true)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def first_open_page
|
|
81
|
+
res = context.client.page_list
|
|
82
|
+
pages = res[:pages] || res["pages"] || []
|
|
83
|
+
pages.first
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|