Skip to content

Vault Mode

Added (2026-02-09): Defines the Blind Replica architecture — a relay operating in vault mode for long-term encrypted backup. See docs/research/blind-replica-architecture.md for full research context.

Vault mode is a relay configuration that provides long-term encrypted storage for Cloud Backup. It reuses the existing relay binary, protocol, and BlobStorage trait — the only differences are retention policy and storage backend.

PropertyTransit Mode (current)Vault Mode (new)
PurposeEphemeral message passingLong-term encrypted backup
RetentionTTL (default 7 days)Unlimited
StorageLocal SQLiteObject storage (R2/B2/S3)
Content blobsNot storedStored via iroh-blobs
Cleanupcleanup_expired() runs hourlyNo expiry cleanup
Cursor trackingPer-groupPer-group (same mechanism)
Zero-knowledgeYesYes
DeploymentGlobal, latency-sensitiveCentralized, near storage region
PrincipleMeaning
No new crateVault mode is a relay config variant, not a separate binary
No new protocolSame envelopes, same content refs, same push/pull
No TTLStored blobs do not expire; deleted only on explicit request
Object storageBlobs stored in R2/B2, not local SQLite
Thin proxyVault relay holds cursor metadata locally; blob data is remote
Separate deploymentTransit and vault relays run independently for failure isolation

The relay’s StorageConfig gains a mode field:

[storage]
mode = "transit" # Current behavior: SQLite, TTL cleanup
# or
mode = "vault" # New: object storage, no TTL

Transit mode (default): Unchanged from Section 7. SQLite storage, TTL-based cleanup, per-group quotas. All existing behavior is preserved.

Vault mode: The BlobStorage trait (already defined in sync-relay/src/storage/mod.rs) gains a new implementation backed by object storage. The trait contract does not change — store_blob(), get_blobs_after(), cleanup_expired(), etc. all have the same signatures. The vault implementation simply:

  • Stores blob payloads in object storage (R2/B2) keyed by {group_id}/{cursor}
  • Keeps cursor metadata in a local SQLite (lightweight, <100MB for 100K users)
  • Returns a no-op from cleanup_expired() (nothing expires)

The new ObjectStorage struct implements the existing BlobStorage trait:

BlobStorage trait hierarchy: SqliteStorage (transit mode) and ObjectStorage (vault mode) with local metadata + R2/B2/S3

Object key scheme:

{bucket}/{group_id_hex}/{cursor_padded}

Example: vk-vault/a1b2c3d4.../000000000042

Cursor is zero-padded to 15 digits for lexicographic ordering, enabling prefix scans for pull operations.

Operations mapping:

BlobStorage methodObject storage behavior
store_blob()PUT object + INSERT cursor metadata locally
get_blobs_after()SELECT cursors locally → GET objects by key
get_max_cursor()SELECT from local metadata
mark_delivered()UPDATE local metadata (same as transit)
cleanup_expired()No-op (returns 0 deleted)
get_group_storage()SUM from local metadata (payload sizes tracked locally)
get_blob()GET single object by blob_id

API compatibility:

ProviderAPINotes
Cloudflare R2S3-compatibleFree egress (recommended for restore-heavy workload)
Backblaze B2S3-compatibleCheaper storage, paid egress
AWS S3NativeHigher cost, global availability
MinIOS3-compatibleSelf-hosted option

All providers use the S3-compatible API via the rust-s3 or aws-sdk-s3 crate. The implementation is provider-agnostic.

[storage]
mode = "vault"
# Object storage backend
backend = "r2" # "r2", "b2", "s3", "minio"
bucket = "vk-vault"
endpoint = "https://xxx.r2.cloudflarestorage.com"
access_key_id = "${VAULT_ACCESS_KEY}"
secret_access_key = "${VAULT_SECRET_KEY}"
region = "auto" # R2 uses "auto"
# Local metadata database (cursor tracking, delivery status)
metadata_database = "/data/vault-meta.db"
# Quotas
max_group_storage = 53687091200 # 50 GB fair use cap per group
max_blob_size = 10485760 # 10 MB per blob (larger than transit's 1MB)
[storage.content]
enabled = true # Accept iroh-blobs for content storage
max_content_per_group = 53687091200 # 50 GB content cap per group

Environment variable overrides (for container deployment):

VariablePurpose
VAULT_ACCESS_KEYObject storage access key
VAULT_SECRET_KEYObject storage secret key
VAULT_BUCKETBucket name
VAULT_ENDPOINTS3-compatible endpoint URL

Transit relays do not store content blobs — large files transfer device-to-device via iroh-blobs (Section 17). Vault relays additionally participate as an iroh-blobs peer to capture content for backup.

When Cloud Backup is ON:

Vault content flow: Device A sends sync messages to transit relay and vault relay, content blobs go to vault relay then to object storage

The vault relay runs an iroh-blobs server that accepts content blob transfers. Each blob is stored as a separate object in the same bucket, keyed by content hash:

{bucket}/{group_id_hex}/content/{content_hash_hex}

Content blobs use the same encrypt-then-hash pipeline defined in Section 17 — the vault relay receives already-encrypted ciphertext and its BLAKE3 hash. It stores the ciphertext without decryption.

The sync-client gains a CloudBackup configuration:

enum CloudBackup {
Off, // Default. Push to transit relay(s) only.
On {
vault_relay_address: String, // Vault relay endpoint
},
}

Behavior when ON:

  • push() sends to transit relay(s) AND vault relay
  • Content transfers also target the vault relay’s iroh-blobs endpoint
  • Vault push is fire-and-forget (does not block the primary push acknowledgement)

Behavior when OFF:

  • Same as current behavior. No vault relay interaction.

The toggle is a local user preference. It does not affect the protocol, the encryption, or any relay behavior. The vault relay does not know whether a user has the toggle ON or OFF — it simply receives pushes like any other relay.

Restore uses the existing pull protocol. No new messages or endpoints required.

Full restore (all devices lost):

  1. Application (VardKista) provides group_id and vault relay address (from Recovery Kit — see docs/research/blind-replica-architecture.md)
  2. sync-client connects to vault relay
  3. sync-client.pull(group_id, after_cursor: 0) — pulls all stored blobs from the beginning
  4. Content blobs retrieved via iroh-blobs from vault relay
  5. Application decrypts and rebuilds local state

Partial restore (existing device available):

  1. New device pairs with existing device (existing pairing flow)
  2. New device syncs from transit relay AND vault relay
  3. CRDTs merge state from all sources — no conflict resolution needed

The vault relay does not distinguish between a “restore” pull and a normal pull. Both are the same operation: get_blobs_after(group_id, cursor).

LimitValueEnforcement
Max storage per group50 GBget_group_storage() check before store_blob()
Max blob size10 MBSame as transit (checked in handle_push())
Max content per group50 GBSeparate tracking for iroh-blobs content
Total per-group cap100 GBSync messages + content combined

When a group exceeds its quota, store_blob() returns QuotaExceeded. The client handles this gracefully — the application can prompt the user to manage storage.

EventAction
Cloud Backup ONVault relay starts receiving pushes. Full sync from transit relay history (if available within TTL).
Cloud Backup OFFVault relay stops receiving new pushes. Existing data preserved for 30 days.
30 days after OFFAll group data purged from object storage. Cursor metadata deleted.
Re-enable within 30 daysResume from last cursor. No re-sync needed.
Re-enable after 30 daysFull re-sync from device.
User requests deletionImmediate: all blobs for group deleted from object storage. Metadata purged.

Deletion is simple: The vault relay controls the R2 bucket. Deletion = DELETE all objects with the group’s prefix + DELETE local metadata rows. No scattered copies in analytics, CDNs, or backup systems.

All security properties from Section 4 are preserved. Additionally:

PropertyHow
Zero-knowledgeVault relay stores encrypted blobs. Same ciphertext as transit. No keys.
No new attack surfaceSame protocol, same message types, same authentication.
Object storage isolationEach group’s data under a unique prefix. No cross-group access.
Deletion completenessObject storage has no hidden caches or replicas beyond what the bucket provides.
Metadata minimalityVault metadata is cursor positions and payload sizes. No content, no filenames, no timestamps beyond ordering.

What the vault relay can observe (unchanged from transit):

  • Blob sizes
  • Group IDs (opaque)
  • Device public keys (pseudonymous)
  • Timing of push/pull operations
  • IP addresses (at connection level, not logged)

What the vault relay cannot observe:

  • Blob contents (encrypted)
  • File names, types, or structure
  • User identity or account information
  • Relationships between groups

Vault relays and transit relays are separate deployments with different scaling profiles:

PropertyTransit RelayVault Relay
Container sizeCX22 (4GB RAM, 40GB disk)CX11 (2GB RAM, 20GB disk)
Scaling factorConcurrent connectionsBandwidth (restore throughput)
Data locationLocal SQLiteObject storage (remote)
Local disk usage5-20 GB (active messages)<1 GB (cursor metadata only)
Global distributionYes (latency-sensitive)No (colocate near object storage)
Failure impactReal-time sync degradesBackup/restore unavailable, sync unaffected

See docs/research/blind-replica-architecture.md for detailed cost analysis at scale.


[dependencies]
serde = { version = "1", features = ["derive"] }
rmp-serde = "1"
uuid = { version = "1", features = ["v4", "serde"] }
[dependencies]
sync-types = { path = "../sync-types" }
[dependencies]
sync-types = { path = "../sync-types" }
sync-core = { path = "../sync-core" }
sync-content = { path = "../sync-content" } # Large content transfer
tokio = { version = "1", features = ["rt", "sync", "time"] }
clatter = "2.2" # Hybrid Noise protocol (ML-KEM-768 + X25519)
iroh = "0.96" # Endpoint, connections, discovery (all tiers) - requires cargo patch
argon2 = "0.5"
chacha20poly1305 = "0.10" # Supports XChaCha20
rand = "0.8"
thiserror = "1"
tracing = "0.1"
[dependencies]
sync-types = { path = "../sync-types" }
iroh-blobs = "0.98" # Content-addressed storage with BLAKE3/Bao
iroh = "0.96" # Endpoint for transfers - requires cargo patch
chacha20poly1305 = "0.10" # XChaCha20-Poly1305 for content encryption
blake3 = "1" # Hashing ciphertext for content address
hkdf = "0.12" # Content key derivation from GroupSecret
sha2 = "0.10" # HKDF-SHA256
tokio = { version = "1", features = ["rt", "sync", "fs"] }
thiserror = "1"
tracing = "0.1"
[dependencies]
sync-types = { path = "../sync-types" }
tokio = { version = "1", features = ["full"] }
iroh = "0.96" # Endpoint for accepting client connections (QUIC) - requires cargo patch
clatter = "2.2" # Hybrid Noise protocol (ML-KEM-768 + X25519)
sqlx = { version = "0.8", default-features = false, features = ["sqlite", "runtime-tokio", "derive"] }
axum = "0.7" # Health/metrics HTTP endpoints only
tower = "0.4"
tracing = "0.1"
tracing-subscriber = "0.3"
config = "0.14"
[package]
name = "zerok-sync-bridge"
[lib]
crate-type = ["rlib"]
[dependencies]
sync-client = { path = "../sync-client" }
sync-types = { path = "../sync-types" }
tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] }
thiserror = "1"
tracing = "0.1"
[package]
name = "zerok-sync-node"
[lib]
crate-type = ["cdylib"]
[dependencies]
sync-bridge = { path = "../sync-bridge" }
napi = { version = "3", features = ["async", "tokio_rt"] }
napi-derive = "3"
[build-dependencies]
napi-build = "2"
[package]
name = "zerok-sync-python"
[lib]
name = "zerok_sync"
crate-type = ["cdylib"]
[dependencies]
sync-bridge = { path = "../sync-bridge" }
pyo3 = { version = "0.28", features = ["extension-module", "abi3-py310"] }
pyo3-async-runtimes = { version = "0.28", features = ["tokio-runtime"] }
tokio = { version = "1", features = ["rt-multi-thread"] }

Framework Integration: Tauri Plugin (updated)

Section titled “Framework Integration: Tauri Plugin (updated)”
# tauri-plugin-sync — now depends on sync-bridge, not sync-client directly
[dependencies]
sync-bridge = { path = "../sync-bridge" }
tauri = "2"
tauri-plugin = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"