browserctl 0.9.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 +45 -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 +36 -0
- data/lib/browserctl/flow.rb +215 -0
- 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 +32 -2
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "browserctl/snapshot/ref"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
module Snapshot
|
|
7
|
+
# Builds a per-element fingerprint that survives small DOM changes.
|
|
8
|
+
#
|
|
9
|
+
# The fingerprint is later used by the replay layer to rematch an
|
|
10
|
+
# element when its recorded selector no longer resolves: score the
|
|
11
|
+
# candidate elements in the new DOM against the recorded fingerprint
|
|
12
|
+
# and pick the best match above a threshold.
|
|
13
|
+
#
|
|
14
|
+
# Shape:
|
|
15
|
+
# {
|
|
16
|
+
# text: <accessible name>,
|
|
17
|
+
# role: <ARIA role, explicit or implicit>,
|
|
18
|
+
# neighbors: [<short text of nearby siblings>, ...],
|
|
19
|
+
# position: { index: <int>, depth: <int> }
|
|
20
|
+
# }
|
|
21
|
+
class Fingerprint
|
|
22
|
+
NEIGHBOR_RADIUS = 2 # siblings to capture on each side
|
|
23
|
+
NEIGHBOR_TEXT_LEN = 40
|
|
24
|
+
|
|
25
|
+
def initialize(ref_deriver: RefDeriver.new)
|
|
26
|
+
@ref_deriver = ref_deriver
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def build(node)
|
|
30
|
+
{
|
|
31
|
+
text: accessible_name(node),
|
|
32
|
+
role: role(node),
|
|
33
|
+
neighbors: neighbors(node),
|
|
34
|
+
position: position(node)
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def role(node)
|
|
41
|
+
explicit = node["role"]
|
|
42
|
+
return explicit if explicit && !explicit.empty?
|
|
43
|
+
|
|
44
|
+
RefDeriver::IMPLICIT_ROLE[node.name] || node.name
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def accessible_name(node)
|
|
48
|
+
%w[aria-label placeholder alt title].each do |attr|
|
|
49
|
+
v = node[attr]
|
|
50
|
+
return v.strip if v && !v.strip.empty?
|
|
51
|
+
end
|
|
52
|
+
node.text.to_s.strip.slice(0, 80)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def neighbors(node)
|
|
56
|
+
parent = node.parent
|
|
57
|
+
return [] unless parent.respond_to?(:children)
|
|
58
|
+
|
|
59
|
+
idx = parent.children.to_a.index(node) || 0
|
|
60
|
+
window = parent.children.to_a[[idx - NEIGHBOR_RADIUS, 0].max...(idx + NEIGHBOR_RADIUS + 1)] || []
|
|
61
|
+
window
|
|
62
|
+
.reject { |c| c == node || !c.respond_to?(:name) }
|
|
63
|
+
.map { |c| neighbor_signal(c) }
|
|
64
|
+
.reject(&:empty?)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def neighbor_signal(node)
|
|
68
|
+
text = node.text.to_s.strip.gsub(/\s+/, " ").slice(0, NEIGHBOR_TEXT_LEN)
|
|
69
|
+
text.empty? ? "" : "#{node.name}:#{text}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def position(node)
|
|
73
|
+
idx = node.parent.respond_to?(:children) ? (node.parent.children.to_a.index(node) || 0) : 0
|
|
74
|
+
{ index: idx, depth: depth(node) }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def depth(node)
|
|
78
|
+
d = 0
|
|
79
|
+
cur = node.parent
|
|
80
|
+
while cur.respond_to?(:name) && cur.name != "document"
|
|
81
|
+
d += 1
|
|
82
|
+
cur = cur.parent
|
|
83
|
+
end
|
|
84
|
+
d
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
module Snapshot
|
|
7
|
+
# Derives a stable element ref from semantic + structural signals.
|
|
8
|
+
#
|
|
9
|
+
# The same DOM element should produce the same ref across two snapshots
|
|
10
|
+
# of the same page. Inputs to the hash are:
|
|
11
|
+
# - role (explicit @role, else implicit ARIA role from tag)
|
|
12
|
+
# - accessible name (aria-label || text || placeholder || alt)
|
|
13
|
+
# - tag
|
|
14
|
+
# - parent path (chain of ancestor tag names up to <html>)
|
|
15
|
+
#
|
|
16
|
+
# Collisions within a single snapshot are disambiguated by the caller via
|
|
17
|
+
# `disambiguate(ref, taken)` — the deriver itself is pure.
|
|
18
|
+
class RefDeriver
|
|
19
|
+
IMPLICIT_ROLE = {
|
|
20
|
+
"a" => "link", "button" => "button", "input" => "textbox",
|
|
21
|
+
"select" => "combobox", "textarea" => "textbox"
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
HASH_LEN = 7
|
|
25
|
+
|
|
26
|
+
def derive(node)
|
|
27
|
+
signal = [role(node), accessible_name(node), node.name, parent_path(node)].join("|")
|
|
28
|
+
"e#{Digest::SHA256.hexdigest(signal)[0, HASH_LEN]}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Given a candidate ref and a set of already-taken refs in the current
|
|
32
|
+
# snapshot, return a unique ref. Adds `-2`, `-3`, ... as needed.
|
|
33
|
+
def disambiguate(ref, taken)
|
|
34
|
+
return ref unless taken.include?(ref)
|
|
35
|
+
|
|
36
|
+
n = 2
|
|
37
|
+
n += 1 while taken.include?("#{ref}-#{n}")
|
|
38
|
+
"#{ref}-#{n}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def role(node)
|
|
44
|
+
explicit = node["role"]
|
|
45
|
+
return explicit if explicit && !explicit.empty?
|
|
46
|
+
|
|
47
|
+
IMPLICIT_ROLE[node.name] || node.name
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def accessible_name(node)
|
|
51
|
+
%w[aria-label placeholder alt title].each do |attr|
|
|
52
|
+
v = node[attr]
|
|
53
|
+
return v.strip if v && !v.strip.empty?
|
|
54
|
+
end
|
|
55
|
+
text = node.text.to_s.strip
|
|
56
|
+
text.empty? ? "" : text.slice(0, 80)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def parent_path(node)
|
|
60
|
+
parts = []
|
|
61
|
+
cur = node.parent
|
|
62
|
+
while cur.respond_to?(:name) && cur.name != "html" && cur.name != "document"
|
|
63
|
+
parts.unshift(cur.name)
|
|
64
|
+
cur = cur.parent
|
|
65
|
+
end
|
|
66
|
+
parts.join(">")
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Browserctl
|
|
4
|
+
module Snapshot
|
|
5
|
+
# Stage 3 of the snapshot pipeline.
|
|
6
|
+
#
|
|
7
|
+
# Right now this is the identity function — annotated entries are
|
|
8
|
+
# already in the wire shape clients expect. It exists as a seam so
|
|
9
|
+
# later milestones can canonicalize, redact, or compress without
|
|
10
|
+
# touching extraction or annotation.
|
|
11
|
+
class Serializer
|
|
12
|
+
def call(entries)
|
|
13
|
+
entries
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -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)
|