browserctl 0.10.0 → 0.11.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 +38 -0
- data/README.md +1 -1
- data/bin/browserctl +45 -4
- data/lib/browserctl/client.rb +47 -3
- data/lib/browserctl/commands/cli_output.rb +16 -3
- data/lib/browserctl/commands/flow.rb +123 -0
- data/lib/browserctl/commands/state.rb +193 -0
- data/lib/browserctl/commands/workflow.rb +62 -4
- data/lib/browserctl/constants.rb +1 -1
- data/lib/browserctl/detectors/auth_required.rb +128 -0
- data/lib/browserctl/detectors.rb +2 -0
- data/lib/browserctl/errors.rb +30 -0
- data/lib/browserctl/flow.rb +22 -1
- data/lib/browserctl/flow_registry.rb +66 -0
- data/lib/browserctl/flows/stdlib/basic_auth.rb +30 -0
- data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +59 -0
- data/lib/browserctl/flows/stdlib/magic_link_email.rb +28 -0
- data/lib/browserctl/flows/stdlib/oauth_github.rb +28 -0
- data/lib/browserctl/flows/stdlib/oauth_google.rb +30 -0
- data/lib/browserctl/flows/stdlib/totp_2fa.rb +61 -0
- data/lib/browserctl/recording.rb +212 -26
- data/lib/browserctl/replay/context.rb +40 -0
- data/lib/browserctl/replay/fingerprint_matcher.rb +86 -0
- data/lib/browserctl/replay/snapshot_diff.rb +51 -0
- data/lib/browserctl/replay/telemetry.rb +60 -0
- data/lib/browserctl/runner.rb +38 -4
- data/lib/browserctl/server/command_dispatcher.rb +10 -1
- data/lib/browserctl/server/handlers/interaction.rb +3 -3
- data/lib/browserctl/server/handlers/navigation.rb +33 -4
- data/lib/browserctl/server/handlers/observation.rb +43 -2
- data/lib/browserctl/server/handlers/state.rb +149 -0
- data/lib/browserctl/server/page_session.rb +9 -7
- data/lib/browserctl/server/snapshot_builder.rb +21 -45
- data/lib/browserctl/snapshot/annotator.rb +75 -0
- data/lib/browserctl/snapshot/extractor.rb +21 -0
- data/lib/browserctl/snapshot/fingerprint.rb +88 -0
- data/lib/browserctl/snapshot/ref.rb +70 -0
- data/lib/browserctl/snapshot/serializer.rb +17 -0
- data/lib/browserctl/state/bundle.rb +242 -0
- data/lib/browserctl/state/transport.rb +64 -0
- data/lib/browserctl/state/transports/file.rb +35 -0
- data/lib/browserctl/state/transports/one_password.rb +67 -0
- data/lib/browserctl/state/transports/s3.rb +42 -0
- data/lib/browserctl/state.rb +208 -0
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow/flow_wrapper.rb +81 -0
- data/lib/browserctl/workflow/promoter.rb +96 -0
- data/lib/browserctl/workflow/promotion_ledger.rb +72 -0
- data/lib/browserctl/workflow.rb +180 -16
- metadata +31 -2
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require_relative "../errors"
|
|
7
|
+
|
|
8
|
+
module Browserctl
|
|
9
|
+
module State
|
|
10
|
+
# Single-file portable codec for browserctl session state — the .bctl
|
|
11
|
+
# bundle. Wraps a plaintext manifest (origins, flow binding, timestamps)
|
|
12
|
+
# alongside a payload of cookies + storage. The manifest is always
|
|
13
|
+
# readable without a passphrase (so `state info` can show origins and
|
|
14
|
+
# expiry); the payload is optionally encrypted.
|
|
15
|
+
#
|
|
16
|
+
# Wire format (big-endian):
|
|
17
|
+
#
|
|
18
|
+
# magic: "BCTL\x00" 5 bytes
|
|
19
|
+
# version: 0x01 1 byte
|
|
20
|
+
# flags: bit 0 = encrypted 1 byte
|
|
21
|
+
# reserved: 0x00 1 byte
|
|
22
|
+
# manifest_len: 4 bytes
|
|
23
|
+
# manifest: JSON manifest_len bytes (always plaintext)
|
|
24
|
+
# payload_len: 4 bytes
|
|
25
|
+
# payload: see below payload_len bytes
|
|
26
|
+
# footer: 32 bytes
|
|
27
|
+
#
|
|
28
|
+
# When flags & 0x01 is unset:
|
|
29
|
+
# payload = JSON bytes (plaintext)
|
|
30
|
+
# footer = SHA-256 over magic..payload (corruption detection)
|
|
31
|
+
#
|
|
32
|
+
# When flags & 0x01 is set:
|
|
33
|
+
# payload = salt(16) || nonce(12) || ciphertext || tag(16)
|
|
34
|
+
# footer = HMAC-SHA-256(hmac_key, magic..payload)
|
|
35
|
+
# salt drives PBKDF2(passphrase, salt, 200_000, SHA-256, 64-byte output);
|
|
36
|
+
# first 32 bytes are the AES-256-GCM encryption key, last 32 bytes are
|
|
37
|
+
# the HMAC-SHA-256 key.
|
|
38
|
+
#
|
|
39
|
+
# Reuses the same AES-256-GCM primitive as v0.8 session encryption
|
|
40
|
+
# (lib/browserctl/session.rb). The two will share a Crypto module in a
|
|
41
|
+
# follow-up; duplicated here to keep this PR focused.
|
|
42
|
+
class Bundle
|
|
43
|
+
MAGIC = "BCTL\x00".b.freeze
|
|
44
|
+
VERSION = 1
|
|
45
|
+
FLAG_ENCRYPTED = 0x01
|
|
46
|
+
HEADER_SIZE = MAGIC.bytesize + 3 # version + flags + reserved
|
|
47
|
+
LEN_SIZE = 4
|
|
48
|
+
FOOTER_SIZE = 32
|
|
49
|
+
SALT_SIZE = 16
|
|
50
|
+
NONCE_SIZE = 12
|
|
51
|
+
TAG_SIZE = 16
|
|
52
|
+
PBKDF2_ITERS = 200_000
|
|
53
|
+
|
|
54
|
+
class BundleError < Browserctl::Error; def self.default_code = "bundle_error" end
|
|
55
|
+
class TamperError < BundleError; def self.default_code = "bundle_tampered" end
|
|
56
|
+
class PassphraseError < BundleError; def self.default_code = "bundle_passphrase" end
|
|
57
|
+
|
|
58
|
+
# Encodes manifest + payload into a single binary blob.
|
|
59
|
+
#
|
|
60
|
+
# @param manifest [Hash] plaintext manifest (always readable)
|
|
61
|
+
# @param payload [Hash] cookies/storage; encrypted when passphrase given
|
|
62
|
+
# @param passphrase [String, nil] when given, payload is encrypted and
|
|
63
|
+
# the footer is an HMAC. When nil, payload is plaintext and the
|
|
64
|
+
# footer is a SHA-256 digest.
|
|
65
|
+
def self.encode(manifest:, payload:, passphrase: nil)
|
|
66
|
+
manifest_bytes = JSON.generate(manifest).b
|
|
67
|
+
payload_json = JSON.generate(payload).b
|
|
68
|
+
flags = 0
|
|
69
|
+
hmac_key = nil
|
|
70
|
+
|
|
71
|
+
if passphrase
|
|
72
|
+
salt = SecureRandom.bytes(SALT_SIZE)
|
|
73
|
+
enc_key, hmac_key = derive_keys(passphrase, salt)
|
|
74
|
+
payload_bytes = salt + aes_gcm_encrypt(payload_json, enc_key)
|
|
75
|
+
flags |= FLAG_ENCRYPTED
|
|
76
|
+
else
|
|
77
|
+
payload_bytes = payload_json
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
body = build_body(flags, manifest_bytes, payload_bytes)
|
|
81
|
+
body + footer_for(body, hmac_key)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Decodes a blob, verifying the footer and decrypting payload when
|
|
85
|
+
# encrypted. Raises TamperError on digest/HMAC mismatch and
|
|
86
|
+
# PassphraseError when an encrypted bundle is decoded without a
|
|
87
|
+
# passphrase or with the wrong one.
|
|
88
|
+
def self.decode(blob, passphrase: nil)
|
|
89
|
+
magic, version, flags = read_header!(blob)
|
|
90
|
+
raise BundleError, "unsupported bundle version #{version}" unless version == VERSION
|
|
91
|
+
|
|
92
|
+
manifest_bytes, payload_bytes, footer = read_sections!(blob)
|
|
93
|
+
body = blob.byteslice(0, blob.bytesize - FOOTER_SIZE)
|
|
94
|
+
|
|
95
|
+
encrypted = flags.anybits?(FLAG_ENCRYPTED)
|
|
96
|
+
verify_footer!(body, footer, encrypted: encrypted, passphrase: passphrase)
|
|
97
|
+
|
|
98
|
+
manifest = JSON.parse(manifest_bytes, symbolize_names: true)
|
|
99
|
+
payload = decode_payload(payload_bytes, encrypted: encrypted, passphrase: passphrase)
|
|
100
|
+
|
|
101
|
+
{ manifest: manifest, payload: payload, magic: magic, version: version, encrypted: encrypted }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Reads the manifest without verifying the footer or decrypting the
|
|
105
|
+
# payload. Use for `state info` and similar read-only queries.
|
|
106
|
+
def self.peek_manifest(blob)
|
|
107
|
+
_, version, = read_header!(blob)
|
|
108
|
+
raise BundleError, "unsupported bundle version #{version}" unless version == VERSION
|
|
109
|
+
|
|
110
|
+
manifest_bytes, = read_sections!(blob)
|
|
111
|
+
JSON.parse(manifest_bytes, symbolize_names: true)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def self.build_body(flags, manifest_bytes, payload_bytes)
|
|
115
|
+
header = MAGIC + [VERSION, flags, 0].pack("CCC")
|
|
116
|
+
header +
|
|
117
|
+
[manifest_bytes.bytesize].pack("N") + manifest_bytes +
|
|
118
|
+
[payload_bytes.bytesize].pack("N") + payload_bytes
|
|
119
|
+
end
|
|
120
|
+
private_class_method :build_body
|
|
121
|
+
|
|
122
|
+
def self.footer_for(body, hmac_key)
|
|
123
|
+
if hmac_key
|
|
124
|
+
OpenSSL::HMAC.digest("SHA256", hmac_key, body)
|
|
125
|
+
else
|
|
126
|
+
OpenSSL::Digest.digest("SHA256", body)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
private_class_method :footer_for
|
|
130
|
+
|
|
131
|
+
def self.read_header!(blob)
|
|
132
|
+
raise BundleError, "blob too small for header" if blob.bytesize < HEADER_SIZE + (2 * LEN_SIZE) + FOOTER_SIZE
|
|
133
|
+
|
|
134
|
+
magic = blob.byteslice(0, MAGIC.bytesize)
|
|
135
|
+
raise BundleError, "bad magic — not a .bctl bundle" unless magic == MAGIC
|
|
136
|
+
|
|
137
|
+
version, flags, _reserved = blob.byteslice(MAGIC.bytesize, 3).unpack("CCC")
|
|
138
|
+
[magic, version, flags]
|
|
139
|
+
end
|
|
140
|
+
private_class_method :read_header!
|
|
141
|
+
|
|
142
|
+
def self.read_sections!(blob)
|
|
143
|
+
cursor = HEADER_SIZE
|
|
144
|
+
manifest_len = blob.byteslice(cursor, LEN_SIZE).unpack1("N")
|
|
145
|
+
cursor += LEN_SIZE
|
|
146
|
+
manifest_bytes = blob.byteslice(cursor, manifest_len)
|
|
147
|
+
cursor += manifest_len
|
|
148
|
+
payload_len = blob.byteslice(cursor, LEN_SIZE).unpack1("N")
|
|
149
|
+
cursor += LEN_SIZE
|
|
150
|
+
payload_bytes = blob.byteslice(cursor, payload_len)
|
|
151
|
+
cursor += payload_len
|
|
152
|
+
footer = blob.byteslice(cursor, FOOTER_SIZE)
|
|
153
|
+
|
|
154
|
+
unless manifest_bytes && payload_bytes && footer && footer.bytesize == FOOTER_SIZE
|
|
155
|
+
raise BundleError, "truncated bundle"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
[manifest_bytes, payload_bytes, footer]
|
|
159
|
+
end
|
|
160
|
+
private_class_method :read_sections!
|
|
161
|
+
|
|
162
|
+
def self.verify_footer!(body, footer, encrypted:, passphrase:)
|
|
163
|
+
if encrypted
|
|
164
|
+
raise PassphraseError, "encrypted bundle requires a passphrase" unless passphrase
|
|
165
|
+
|
|
166
|
+
# We need the HMAC key, which depends on the salt embedded in the
|
|
167
|
+
# payload. Pull the salt from the payload bytes inside `body`.
|
|
168
|
+
salt = extract_salt!(body)
|
|
169
|
+
_, hmac_key = derive_keys(passphrase, salt)
|
|
170
|
+
expected = OpenSSL::HMAC.digest("SHA256", hmac_key, body)
|
|
171
|
+
raise PassphraseError, "wrong passphrase or tampered bundle" unless secure_eq?(footer, expected)
|
|
172
|
+
else
|
|
173
|
+
expected = OpenSSL::Digest.digest("SHA256", body)
|
|
174
|
+
raise TamperError, "bundle digest mismatch — file is corrupted or modified" unless secure_eq?(footer,
|
|
175
|
+
expected)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
private_class_method :verify_footer!
|
|
179
|
+
|
|
180
|
+
def self.extract_salt!(body)
|
|
181
|
+
cursor = HEADER_SIZE
|
|
182
|
+
manifest_len = body.byteslice(cursor, LEN_SIZE).unpack1("N")
|
|
183
|
+
cursor += LEN_SIZE + manifest_len + LEN_SIZE
|
|
184
|
+
body.byteslice(cursor, SALT_SIZE) or raise BundleError, "encrypted payload missing salt"
|
|
185
|
+
end
|
|
186
|
+
private_class_method :extract_salt!
|
|
187
|
+
|
|
188
|
+
def self.decode_payload(bytes, encrypted:, passphrase:)
|
|
189
|
+
if encrypted
|
|
190
|
+
salt = bytes.byteslice(0, SALT_SIZE)
|
|
191
|
+
ciphertext = bytes.byteslice(SALT_SIZE, bytes.bytesize - SALT_SIZE)
|
|
192
|
+
enc_key, = derive_keys(passphrase, salt)
|
|
193
|
+
plaintext = aes_gcm_decrypt(ciphertext, enc_key)
|
|
194
|
+
JSON.parse(plaintext)
|
|
195
|
+
else
|
|
196
|
+
JSON.parse(bytes)
|
|
197
|
+
end
|
|
198
|
+
rescue OpenSSL::Cipher::CipherError
|
|
199
|
+
raise PassphraseError, "wrong passphrase — payload could not be decrypted"
|
|
200
|
+
end
|
|
201
|
+
private_class_method :decode_payload
|
|
202
|
+
|
|
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
|
+
def self.secure_eq?(actual, expected)
|
|
235
|
+
return false if actual.bytesize != expected.bytesize
|
|
236
|
+
|
|
237
|
+
OpenSSL.fixed_length_secure_compare(actual, expected)
|
|
238
|
+
end
|
|
239
|
+
private_class_method :secure_eq?
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require_relative "../errors"
|
|
5
|
+
|
|
6
|
+
module Browserctl
|
|
7
|
+
module State
|
|
8
|
+
# Pluggable transport for moving .bctl bundles in and out of remote
|
|
9
|
+
# systems. Each transport responds to:
|
|
10
|
+
#
|
|
11
|
+
# .scheme String — URI scheme it handles ("file", "s3", "op", ...)
|
|
12
|
+
# #handles?(uri) -> Boolean
|
|
13
|
+
# #available? -> Boolean (e.g. CLI tool present, network reachable)
|
|
14
|
+
# #read(uri) -> binary String (the bundle bytes)
|
|
15
|
+
# #write(uri, blob) -> nil; raises on failure
|
|
16
|
+
#
|
|
17
|
+
# Transports are matched by URI scheme. A bare path with no scheme falls
|
|
18
|
+
# through to the FileTransport.
|
|
19
|
+
module Transport
|
|
20
|
+
class TransportError < Browserctl::Error; def self.default_code = "transport_error" end
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
def registry
|
|
24
|
+
@registry ||= []
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def register(transport)
|
|
28
|
+
registry << transport
|
|
29
|
+
transport
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def for(uri)
|
|
33
|
+
parsed = parse(uri)
|
|
34
|
+
match = registry.find { |t| t.handles?(parsed) }
|
|
35
|
+
raise TransportError, "no transport for #{uri.inspect}" unless match
|
|
36
|
+
|
|
37
|
+
unless match.available?
|
|
38
|
+
scheme = parsed.scheme || "file"
|
|
39
|
+
raise TransportError, "transport for '#{scheme}://' is not available — install the underlying CLI"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
[match, parsed]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def parse(uri)
|
|
46
|
+
uri.is_a?(URI) ? uri : URI.parse(uri.to_s)
|
|
47
|
+
rescue URI::InvalidURIError
|
|
48
|
+
# bare path with characters URI rejects — treat as file
|
|
49
|
+
URI.parse("file://#{uri}")
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class Base
|
|
54
|
+
def self.scheme = raise NotImplementedError
|
|
55
|
+
|
|
56
|
+
def handles?(parsed)
|
|
57
|
+
parsed.scheme.nil? || parsed.scheme == self.class.scheme
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def available? = true
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "../transport"
|
|
5
|
+
|
|
6
|
+
module Browserctl
|
|
7
|
+
module State
|
|
8
|
+
module Transports
|
|
9
|
+
# Default transport — reads/writes plain files. Handles bare paths and
|
|
10
|
+
# `file://` URIs. Always available.
|
|
11
|
+
class File < Transport::Base
|
|
12
|
+
def self.scheme = "file"
|
|
13
|
+
|
|
14
|
+
def read(parsed)
|
|
15
|
+
path = local_path(parsed)
|
|
16
|
+
raise Transport::TransportError, "file not found: #{path}" unless ::File.exist?(path)
|
|
17
|
+
|
|
18
|
+
::File.binread(path)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def write(parsed, blob)
|
|
22
|
+
path = local_path(parsed)
|
|
23
|
+
FileUtils.mkdir_p(::File.dirname(path))
|
|
24
|
+
::File.open(path, "wb", 0o600) { |f| f.write(blob) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def local_path(parsed)
|
|
28
|
+
::File.expand_path(parsed.path.to_s.empty? ? parsed.opaque.to_s : parsed.path)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
Browserctl::State::Transport.register(Browserctl::State::Transports::File.new)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require_relative "../transport"
|
|
6
|
+
|
|
7
|
+
module Browserctl
|
|
8
|
+
module State
|
|
9
|
+
module Transports
|
|
10
|
+
# 1Password transport — stores bundles as Documents.
|
|
11
|
+
# URIs look like `op://Vault/ItemName`.
|
|
12
|
+
#
|
|
13
|
+
# The op CLI has no streaming primitive for documents, so we stage
|
|
14
|
+
# the blob to a tmpfile (chmod 0600) and use `op document create` /
|
|
15
|
+
# `op document get`.
|
|
16
|
+
class OnePassword < Transport::Base
|
|
17
|
+
SAFE_REF = %r{\Aop://[^/]+/[^/]+\z}
|
|
18
|
+
|
|
19
|
+
def self.scheme = "op"
|
|
20
|
+
|
|
21
|
+
def available?
|
|
22
|
+
system("which", "op", out: ::File::NULL, err: ::File::NULL)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def read(parsed)
|
|
26
|
+
uri = parsed.to_s
|
|
27
|
+
validate!(uri)
|
|
28
|
+
out, err, status = Open3.capture3("op", "read", uri, binmode: true)
|
|
29
|
+
return out if status.success?
|
|
30
|
+
|
|
31
|
+
raise Transport::TransportError, "op read failed: #{err.strip.empty? ? out : err}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def write(parsed, blob)
|
|
35
|
+
uri = parsed.to_s
|
|
36
|
+
validate!(uri)
|
|
37
|
+
vault, title = parse_ref(uri)
|
|
38
|
+
|
|
39
|
+
Dir.mktmpdir do |tmp|
|
|
40
|
+
path = ::File.join(tmp, "#{title}.bctl")
|
|
41
|
+
::File.open(path, "wb", 0o600) { |f| f.write(blob) }
|
|
42
|
+
|
|
43
|
+
args = ["op", "document", "create", path, "--title", title, "--vault", vault]
|
|
44
|
+
out, err, status = Open3.capture3(*args)
|
|
45
|
+
unless status.success?
|
|
46
|
+
raise Transport::TransportError, "op document create failed: #{err.strip.empty? ? out : err}"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def validate!(uri)
|
|
52
|
+
return if SAFE_REF.match?(uri)
|
|
53
|
+
|
|
54
|
+
raise Transport::TransportError, "invalid 1Password reference: #{uri.inspect} (expected op://Vault/Item)"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def parse_ref(uri)
|
|
58
|
+
# op://Vault/Item — exactly two segments after the scheme
|
|
59
|
+
rest = uri.delete_prefix("op://")
|
|
60
|
+
rest.split("/", 2)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
Browserctl::State::Transport.register(Browserctl::State::Transports::OnePassword.new)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require_relative "../transport"
|
|
5
|
+
|
|
6
|
+
module Browserctl
|
|
7
|
+
module State
|
|
8
|
+
module Transports
|
|
9
|
+
# S3 transport — shells out to the `aws` CLI so we don't drag the
|
|
10
|
+
# aws-sdk gem into core. URIs look like `s3://bucket/path/key.bctl`.
|
|
11
|
+
# Credentials/region come from the user's normal AWS environment
|
|
12
|
+
# (env vars, `~/.aws/credentials`, IAM, etc.).
|
|
13
|
+
class S3 < Transport::Base
|
|
14
|
+
def self.scheme = "s3"
|
|
15
|
+
|
|
16
|
+
def available?
|
|
17
|
+
system("which", "aws", out: ::File::NULL, err: ::File::NULL)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def read(parsed)
|
|
21
|
+
run!("aws", "s3", "cp", parsed.to_s, "-", binmode: true)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def write(parsed, blob)
|
|
25
|
+
out, err, status = Open3.capture3("aws", "s3", "cp", "-", parsed.to_s, stdin_data: blob, binmode: true)
|
|
26
|
+
return if status.success?
|
|
27
|
+
|
|
28
|
+
raise Transport::TransportError, "aws s3 cp failed: #{err.strip.empty? ? out : err}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def run!(*cmd, binmode: false)
|
|
32
|
+
out, err, status = Open3.capture3(*cmd, binmode: binmode)
|
|
33
|
+
return out if status.success?
|
|
34
|
+
|
|
35
|
+
raise Transport::TransportError, "#{cmd.first} failed: #{err.strip.empty? ? out : err}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
Browserctl::State::Transport.register(Browserctl::State::Transports::S3.new)
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
require_relative "constants"
|
|
7
|
+
require_relative "errors"
|
|
8
|
+
require_relative "version"
|
|
9
|
+
require_relative "state/bundle"
|
|
10
|
+
require_relative "state/transport"
|
|
11
|
+
require_relative "state/transports/file"
|
|
12
|
+
require_relative "state/transports/s3"
|
|
13
|
+
require_relative "state/transports/one_password"
|
|
14
|
+
|
|
15
|
+
module Browserctl
|
|
16
|
+
# Top-level state store: a single .bctl bundle per name under
|
|
17
|
+
# ~/.browserctl/state/<name>.bctl. Wraps the Bundle codec with on-disk
|
|
18
|
+
# naming, validation, and a small inventory API used by `state list/info`.
|
|
19
|
+
#
|
|
20
|
+
# Data shape inside a bundle:
|
|
21
|
+
#
|
|
22
|
+
# manifest = {
|
|
23
|
+
# name: String,
|
|
24
|
+
# version: 1, # bundle schema version
|
|
25
|
+
# producer: "browserctl/<gem-ver>",
|
|
26
|
+
# created_at: ISO-8601,
|
|
27
|
+
# origins: [String, ...],
|
|
28
|
+
# flow: String | nil, # bound flow name, for `state rotate`
|
|
29
|
+
# flow_version: String | nil,
|
|
30
|
+
# expires_at: ISO-8601 | nil, # earliest cookie expiry
|
|
31
|
+
# encrypted: Boolean
|
|
32
|
+
# }
|
|
33
|
+
#
|
|
34
|
+
# payload = {
|
|
35
|
+
# cookies: [Hash, ...],
|
|
36
|
+
# local_storage: { origin => { key => value } },
|
|
37
|
+
# session_storage: { origin => { key => value } }
|
|
38
|
+
# }
|
|
39
|
+
module State
|
|
40
|
+
BASE_DIR = File.join(BROWSERCTL_DIR, "state")
|
|
41
|
+
SAFE_NAME = /\A[a-zA-Z0-9_-]{1,64}\z/
|
|
42
|
+
EXTENSION = ".bctl"
|
|
43
|
+
MANIFEST_VERSION = 1
|
|
44
|
+
|
|
45
|
+
def self.path(name) = File.join(BASE_DIR, "#{name}#{EXTENSION}")
|
|
46
|
+
def self.exist?(name) = File.exist?(path(name))
|
|
47
|
+
|
|
48
|
+
def self.validate_name!(name)
|
|
49
|
+
return if SAFE_NAME.match?(name.to_s)
|
|
50
|
+
|
|
51
|
+
raise ArgumentError, "invalid state name #{name.inspect} — use letters, digits, _ or - (max 64 chars)"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Persist a bundle. `payload` is { cookies:, local_storage:, session_storage: }.
|
|
55
|
+
# `manifest_extras` may carry origins (override), flow, flow_version.
|
|
56
|
+
def self.save(name, payload:, origins: nil, flow: nil, flow_version: nil, passphrase: nil) # rubocop:disable Metrics/ParameterLists
|
|
57
|
+
validate_name!(name)
|
|
58
|
+
FileUtils.mkdir_p(BASE_DIR)
|
|
59
|
+
|
|
60
|
+
manifest = build_manifest(
|
|
61
|
+
name: name,
|
|
62
|
+
origins: origins || derive_origins(payload),
|
|
63
|
+
flow: flow,
|
|
64
|
+
flow_version: flow_version,
|
|
65
|
+
cookies: payload[:cookies] || payload["cookies"] || [],
|
|
66
|
+
encrypted: !passphrase.nil?
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
blob = Bundle.encode(manifest: manifest, payload: payload, passphrase: passphrase)
|
|
70
|
+
File.open(path(name), "wb", 0o600) { |f| f.write(blob) }
|
|
71
|
+
manifest
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Load and decode a bundle. Returns { manifest:, payload:, encrypted: }.
|
|
75
|
+
def self.load(name, passphrase: nil)
|
|
76
|
+
validate_name!(name)
|
|
77
|
+
raise Browserctl::Error, "state '#{name}' not found" unless exist?(name)
|
|
78
|
+
|
|
79
|
+
Bundle.decode(File.binread(path(name)), passphrase: passphrase)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.delete(name)
|
|
83
|
+
validate_name!(name)
|
|
84
|
+
FileUtils.rm_f(path(name))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Copies the on-disk .bctl bundle to a transport-addressable destination
|
|
88
|
+
# (file path, s3://bucket/key, op://Vault/Item, or any registered scheme).
|
|
89
|
+
# Bundle bytes are written verbatim — no re-encoding — so the receiving
|
|
90
|
+
# side can verify the manifest/payload exactly as produced.
|
|
91
|
+
def self.export(name, destination)
|
|
92
|
+
validate_name!(name)
|
|
93
|
+
raise Browserctl::Error, "state '#{name}' not found" unless exist?(name)
|
|
94
|
+
|
|
95
|
+
transport, parsed = Transport.for(destination)
|
|
96
|
+
blob = ::File.binread(path(name))
|
|
97
|
+
transport.write(parsed, blob)
|
|
98
|
+
{ name: name, destination: destination, bytes: blob.bytesize }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Pulls a bundle from a transport-addressable source and stores it as a
|
|
102
|
+
# local state. Validates the magic header before persisting so we never
|
|
103
|
+
# leave a corrupt bundle in the state directory. `name` defaults to the
|
|
104
|
+
# source's basename without `.bctl`.
|
|
105
|
+
def self.import(source, name: nil)
|
|
106
|
+
transport, parsed = Transport.for(source)
|
|
107
|
+
blob = transport.read(parsed)
|
|
108
|
+
raise Bundle::BundleError, "imported blob is not a .bctl bundle" unless blob.start_with?(Bundle::MAGIC)
|
|
109
|
+
|
|
110
|
+
manifest = Bundle.peek_manifest(blob)
|
|
111
|
+
target_name = name || derive_name(source) || manifest[:name]
|
|
112
|
+
validate_name!(target_name)
|
|
113
|
+
|
|
114
|
+
FileUtils.mkdir_p(BASE_DIR)
|
|
115
|
+
::File.open(path(target_name), "wb", 0o600) { |f| f.write(blob) }
|
|
116
|
+
{ name: target_name, source: source, bytes: blob.bytesize, encrypted: manifest[:encrypted] }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def self.derive_name(uri)
|
|
120
|
+
base = ::File.basename(uri.to_s.split("?").first.to_s, EXTENSION)
|
|
121
|
+
return nil if base.empty?
|
|
122
|
+
|
|
123
|
+
base
|
|
124
|
+
end
|
|
125
|
+
private_class_method :derive_name
|
|
126
|
+
|
|
127
|
+
# Read manifests for all stored bundles. Errors on a single file are
|
|
128
|
+
# surfaced via { error: "...", path: "..." } rather than aborting the list.
|
|
129
|
+
def self.all
|
|
130
|
+
return [] unless Dir.exist?(BASE_DIR)
|
|
131
|
+
|
|
132
|
+
Dir[File.join(BASE_DIR, "*#{EXTENSION}")].map do |file|
|
|
133
|
+
info_for(file)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Inspect a single bundle without decrypting the payload.
|
|
138
|
+
def self.info(name)
|
|
139
|
+
validate_name!(name)
|
|
140
|
+
raise Browserctl::Error, "state '#{name}' not found" unless exist?(name)
|
|
141
|
+
|
|
142
|
+
info_for(path(name))
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def self.info_for(file)
|
|
146
|
+
blob = File.binread(file)
|
|
147
|
+
manifest = Bundle.peek_manifest(blob)
|
|
148
|
+
manifest.merge(
|
|
149
|
+
path: file,
|
|
150
|
+
size: blob.bytesize
|
|
151
|
+
)
|
|
152
|
+
rescue Bundle::BundleError => e
|
|
153
|
+
{ name: File.basename(file, EXTENSION), path: file, error: e.message }
|
|
154
|
+
end
|
|
155
|
+
private_class_method :info_for
|
|
156
|
+
|
|
157
|
+
def self.build_manifest(name:, origins:, flow:, flow_version:, cookies:, encrypted:) # rubocop:disable Metrics/ParameterLists
|
|
158
|
+
{
|
|
159
|
+
name: name,
|
|
160
|
+
version: MANIFEST_VERSION,
|
|
161
|
+
producer: "browserctl/#{Browserctl::VERSION}",
|
|
162
|
+
created_at: Time.now.utc.iso8601,
|
|
163
|
+
origins: Array(origins).compact.uniq,
|
|
164
|
+
flow: flow,
|
|
165
|
+
flow_version: flow_version,
|
|
166
|
+
expires_at: earliest_expiry(cookies),
|
|
167
|
+
encrypted: encrypted
|
|
168
|
+
}
|
|
169
|
+
end
|
|
170
|
+
private_class_method :build_manifest
|
|
171
|
+
|
|
172
|
+
# Origins captured from the payload itself when not overridden. Pulls from
|
|
173
|
+
# cookie domains and storage keys to cover both navigation-tracked and
|
|
174
|
+
# cookie-only auth.
|
|
175
|
+
def self.derive_origins(payload)
|
|
176
|
+
cookies = fetch_either(payload, :cookies, "cookies", default: [])
|
|
177
|
+
ls = fetch_either(payload, :local_storage, "local_storage", default: {})
|
|
178
|
+
ss = fetch_either(payload, :session_storage, "session_storage", default: {})
|
|
179
|
+
|
|
180
|
+
(origins_from_cookies(cookies) + ls.keys.map(&:to_s) + ss.keys.map(&:to_s)).uniq
|
|
181
|
+
end
|
|
182
|
+
private_class_method :derive_origins
|
|
183
|
+
|
|
184
|
+
def self.origins_from_cookies(cookies)
|
|
185
|
+
cookies.filter_map do |c|
|
|
186
|
+
domain = (c[:domain] || c["domain"]).to_s
|
|
187
|
+
next if domain.empty?
|
|
188
|
+
|
|
189
|
+
domain.start_with?(".") ? "https://#{domain[1..]}" : "https://#{domain}"
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
private_class_method :origins_from_cookies
|
|
193
|
+
|
|
194
|
+
def self.fetch_either(hash, sym_key, str_key, default:)
|
|
195
|
+
hash[sym_key] || hash[str_key] || default
|
|
196
|
+
end
|
|
197
|
+
private_class_method :fetch_either
|
|
198
|
+
|
|
199
|
+
def self.earliest_expiry(cookies)
|
|
200
|
+
times = cookies.filter_map do |c|
|
|
201
|
+
v = c[:expires] || c["expires"] || c[:expiresAt] || c["expiresAt"]
|
|
202
|
+
v&.to_f&.positive? ? Time.at(v.to_f).utc.iso8601 : nil
|
|
203
|
+
end
|
|
204
|
+
times.min
|
|
205
|
+
end
|
|
206
|
+
private_class_method :earliest_expiry
|
|
207
|
+
end
|
|
208
|
+
end
|
data/lib/browserctl/version.rb
CHANGED