Module: LocalVault::SyncBundle

Defined in:
lib/localvault/sync_bundle.rb

Overview

Packs and unpacks vault data for cloud sync via InventList + R2.

Bundle versions:

  • v1: personal sync — { version, meta, secrets }

  • v2: legacy team — { version, meta, secrets, key_slots }

  • v3: team with ownership — { version, owner, meta, secrets, key_slots }

The server never sees plaintext — the bundle is opaque ciphertext.

Examples:

Personal sync (v1)

blob = SyncBundle.pack(store)

Team sync (v3)

blob = SyncBundle.pack_v3(store, owner: "alice", key_slots: slots)
data = SyncBundle.unpack(blob)
data[:owner]     # => "alice"
data[:key_slots] # => {"alice" => {...}, "bob" => {...}}

Defined Under Namespace

Classes: UnpackError

Constant Summary collapse

SUPPORTED_VERSIONS =
[1, 2, 3].freeze

Class Method Summary collapse

Class Method Details

.pack(store) ⇒ String

Pack a personal vault — v1 format, no key_slots, no owner.

Parameters:

  • store (Store)

    the vault store to pack

Returns:

  • (String)

    JSON string ready for upload



33
34
35
36
37
38
39
40
41
# File 'lib/localvault/sync_bundle.rb', line 33

def self.pack(store)
  meta_content    = File.read(store.meta_path)
  secrets_content = store.read_encrypted || ""
  JSON.generate(
    "version" => 1,
    "meta"    => Base64.strict_encode64(meta_content),
    "secrets" => Base64.strict_encode64(secrets_content)
  )
end

.pack_v3(store, owner:, key_slots: {}) ⇒ String

Pack a team vault — v3 format with owner, key_slots, and per-member blobs.

Reads store.meta_path and store.read_encrypted off disk. For flows that need to build a bundle from in-memory bytes without touching disk first (e.g. transactional rotate), use pack_v3_bytes.

Parameters:

  • store (Store)

    the vault store to pack

  • owner (String)

    the owner’s InventList handle

  • key_slots (Hash) (defaults to: {})

    per-user key slot data

Returns:

  • (String)

    JSON string ready for upload



53
54
55
56
57
58
59
60
# File 'lib/localvault/sync_bundle.rb', line 53

def self.pack_v3(store, owner:, key_slots: {})
  pack_v3_bytes(
    meta_bytes:    File.read(store.meta_path),
    secrets_bytes: store.read_encrypted || "",
    owner:         owner,
    key_slots:     key_slots
  )
end

.pack_v3_bytes(meta_bytes:, secrets_bytes:, owner:, key_slots: {}) ⇒ String

Pack a v3 team bundle from raw in-memory bytes without touching disk.

Used by transactional rotate flows that need to push the new bundle to the server BEFORE committing the rotated secrets + meta to local disk —if the push fails, nothing on disk changes, so the user can retry without being left with a locally-rotated but remotely-stale vault.

Parameters:

  • meta_bytes (String)

    raw meta YAML bytes (same shape as store.meta_path contents)

  • secrets_bytes (String)

    raw encrypted secrets bytes

  • owner (String)

    the owner’s InventList handle

  • key_slots (Hash) (defaults to: {})

    per-user key slot data

Returns:

  • (String)

    JSON string ready for upload



74
75
76
77
78
79
80
81
82
# File 'lib/localvault/sync_bundle.rb', line 74

def self.pack_v3_bytes(meta_bytes:, secrets_bytes:, owner:, key_slots: {})
  JSON.generate(
    "version"   => 3,
    "owner"     => owner,
    "meta"      => Base64.strict_encode64(meta_bytes),
    "secrets"   => Base64.strict_encode64(secrets_bytes),
    "key_slots" => key_slots
  )
end

.unpack(blob, expected_name: nil) ⇒ Hash

Unpack any version bundle into its component parts.

Parameters:

  • blob (String)

    JSON string from ApiClient#pull_vault

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

    if set, validates the meta.yml vault name matches

Returns:

  • (Hash)

    {meta:, secrets:, key_slots:, owner:}

Raises:

  • (UnpackError)

    on invalid format, unsupported version, or name mismatch



90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/localvault/sync_bundle.rb', line 90

def self.unpack(blob, expected_name: nil)
  data = JSON.parse(blob)
  version = data["version"]
  raise UnpackError, "Unsupported bundle version: #{version.inspect}" unless SUPPORTED_VERSIONS.include?(version)

  meta_raw    = Base64.strict_decode64(data.fetch("meta"))
  secrets_raw = Base64.strict_decode64(data.fetch("secrets"))
  key_slots   = data["key_slots"].is_a?(Hash) ? data["key_slots"] : {}
  owner       = data["owner"]

  if expected_name
    meta_parsed = YAML.safe_load(meta_raw)
    actual_name = meta_parsed&.dig("name")
    if actual_name && actual_name != expected_name
      raise UnpackError, "Bundle meta name '#{actual_name}' does not match expected vault '#{expected_name}'"
    end
  end

  { meta: meta_raw, secrets: secrets_raw, key_slots: key_slots, owner: owner }
rescue JSON::ParserError => e
  raise UnpackError, "Invalid sync bundle format: #{e.message}"
rescue KeyError => e
  raise UnpackError, "Sync bundle missing required field: #{e.message}"
rescue ArgumentError => e
  raise UnpackError, "Sync bundle has invalid encoding: #{e.message}"
end