Class: Browserctl::State::Bundle
- Inherits:
-
Object
- Object
- Browserctl::State::Bundle
- Defined in:
- lib/browserctl/state/bundle.rb
Overview
Single-file portable codec for browserctl persisted state — the .bctl bundle. Wraps a plaintext manifest (origins, flow binding, timestamps) alongside a payload of cookies + storage. The manifest is always readable without a passphrase (so ‘state info` can show origins and expiry); the payload is optionally encrypted.
Wire format (big-endian):
magic: "BCTL\x00" 5 bytes
version: 0x01 1 byte
flags: bit 0 = encrypted 1 byte
reserved: 0x00 1 byte
manifest_len: 4 bytes
manifest: JSON manifest_len bytes (always plaintext)
payload_len: 4 bytes
payload: see below payload_len bytes
footer: 32 bytes
When flags & 0x01 is unset:
payload = JSON bytes (plaintext)
footer = SHA-256 over magic..payload (corruption detection)
When flags & 0x01 is set:
payload = salt(16) || nonce(12) || ciphertext || tag(16)
footer = HMAC-SHA-256(hmac_key, magic..payload)
salt drives PBKDF2(passphrase, salt, 200_000, SHA-256, 64-byte output);
first 32 bytes are the AES-256-GCM encryption key, last 32 bytes are
the HMAC-SHA-256 key.
AES-256-GCM cipher setup and PBKDF2 key derivation are delegated to ‘Browserctl::EncryptionService` so this class stays focused on the bundle wire format. The service translates `OpenSSL::Cipher` errors into `EncryptionService::DecryptionError`, which we map to `PassphraseError` for the public API.
Defined Under Namespace
Classes: BundleError, PassphraseError, TamperError
Constant Summary collapse
- MAGIC =
"BCTL\x00".b.freeze
- VERSION =
1- BUNDLE_FORMAT_VERSION =
Manifest-level format version, written as ‘format_version` and validated on decode. Distinct from the wire-format byte `VERSION` above (which gates the binary envelope shape) — this gates the manifest schema. See docs/reference/format-versions.md.
1- SUPPORTED_FORMAT_VERSIONS =
[BUNDLE_FORMAT_VERSION].freeze
- FLAG_ENCRYPTED =
0x01- HEADER_SIZE =
version + flags + reserved
MAGIC.bytesize + 3
- LEN_SIZE =
4- FOOTER_SIZE =
32- SALT_SIZE =
Cryptographic primitive sizes are sourced from EncryptionService so there is exactly one source of truth for cipher parameters.
Browserctl::EncryptionService::SALT_SIZE
- NONCE_SIZE =
Browserctl::EncryptionService::NONCE_SIZE
- TAG_SIZE =
Browserctl::EncryptionService::TAG_SIZE
- PBKDF2_ITERS =
Browserctl::EncryptionService::PBKDF2_ITERS
Class Method Summary collapse
-
.decode(blob, passphrase: nil) ⇒ Object
Decodes a blob, verifying the footer and decrypting payload when encrypted.
-
.encode(manifest:, payload:, passphrase: nil) ⇒ Object
Encodes manifest + payload into a single binary blob.
-
.peek_manifest(blob) ⇒ Object
Reads the manifest without verifying the footer or decrypting the payload.
Class Method Details
.decode(blob, passphrase: nil) ⇒ Object
Decodes a blob, verifying the footer and decrypting payload when encrypted. Raises TamperError on digest/HMAC mismatch and PassphraseError when an encrypted bundle is decoded without a passphrase or with the wrong one.
100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 |
# File 'lib/browserctl/state/bundle.rb', line 100 def self.decode(blob, passphrase: nil) magic, version, flags = read_header!(blob) raise BundleError, "unsupported bundle version #{version}" unless version == VERSION manifest_bytes, payload_bytes, = read_sections!(blob) body = blob.byteslice(0, blob.bytesize - FOOTER_SIZE) encrypted = flags.anybits?(FLAG_ENCRYPTED) (body, , encrypted: encrypted, passphrase: passphrase) manifest = JSON.parse(manifest_bytes, symbolize_names: true) verify_format_version!(manifest) payload = decode_payload(payload_bytes, encrypted: encrypted, passphrase: passphrase) { manifest: manifest, payload: payload, magic: magic, version: version, encrypted: encrypted } end |
.encode(manifest:, payload:, passphrase: nil) ⇒ Object
Encodes manifest + payload into a single binary blob.
76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 |
# File 'lib/browserctl/state/bundle.rb', line 76 def self.encode(manifest:, payload:, passphrase: nil) manifest = stamp_format_version(manifest) manifest_bytes = JSON.generate(manifest).b payload_json = JSON.generate(payload).b flags = 0 hmac_key = nil if passphrase salt = EncryptionService.random_salt enc_key, hmac_key = EncryptionService.derive_keys(passphrase, salt) payload_bytes = salt + EncryptionService.encrypt(payload_json, enc_key) flags |= FLAG_ENCRYPTED else payload_bytes = payload_json end body = build_body(flags, manifest_bytes, payload_bytes) body + (body, hmac_key) end |
.peek_manifest(blob) ⇒ Object
Reads the manifest without verifying the footer or decrypting the payload. Use for ‘state info` and similar read-only queries.
119 120 121 122 123 124 125 126 127 |
# File 'lib/browserctl/state/bundle.rb', line 119 def self.peek_manifest(blob) _, version, = read_header!(blob) raise BundleError, "unsupported bundle version #{version}" unless version == VERSION manifest_bytes, = read_sections!(blob) manifest = JSON.parse(manifest_bytes, symbolize_names: true) verify_format_version!(manifest) manifest end |