Class: Browserctl::State::Bundle

Inherits:
Object
  • Object
show all
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
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

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.

Raises:



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, footer = read_sections!(blob)
  body = blob.byteslice(0, blob.bytesize - FOOTER_SIZE)

  encrypted = flags.anybits?(FLAG_ENCRYPTED)
  verify_footer!(body, footer, 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.

Parameters:

  • manifest (Hash)

    plaintext manifest (always readable)

  • payload (Hash)

    cookies/storage; encrypted when passphrase given

  • passphrase (String, nil) (defaults to: nil)

    when given, payload is encrypted and the footer is an HMAC. When nil, payload is plaintext and the footer is a SHA-256 digest.



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 + footer_for(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.

Raises:



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