Skip to content

Encrypted opaque storage: a general primitive for private off-chain account data #307

Description

@BrianSeong99

Motivation

Guardian already coordinates per-account state and authenticates the account's cosigners. What it doesn't offer is a place to durably store arbitrary private data that has no on-chain representation and can't be rebuilt from chain — and that the operator must not be able to read.

Many integrations need this. A wallet needs to persist contacts, trusted dApps, settings, and portfolio history. A dApp or bridge integration may need to stash per-account preferences, session metadata, or app-specific state. Today each of these has to stand up its own backend, even though Guardian is already the per-account, authenticated service they talk to.

Rather than solve this once per integration, add a general storage primitive to Guardian: encrypted, opaque, per-account. Wallet metadata is the first concrete consumer, not the scope.

Proposal

A small opaque, namespaced blob store scoped per account. Clients store named, versioned blobs; Guardian only ever sees ciphertext. The client encrypts before PUT and decrypts after GET with a key it derives and holds locally — Guardian never receives it and never interprets the bytes.

This is the same architectural move as #266: after that change Guardian treats proposals as opaque blobs with no server-side parsing. Storage is opaque by design — Guardian never indexes, validates, or type-checks contents. It's a dumb, encrypted, per-account key-value store. (Related: #255 META - Privacy.)

Example use case — Bread Wallet private metadata. The wallet writes namespaces like contacts, trusted_apps, settings, portfolio_history, encrypted under a key derived from the user's recovery phrase, so a new device restores and decrypts them during recovery. Guardian sees only opaque blobs.

Proposed API

Reuses the existing auth headers (x-pubkey / x-signature / x-timestamp), validated against the account's authorized cosigner list — no new auth.

GET    /storage            -> [{ key, version }]       # cheap sync check
GET    /storage/:key       -> { version, value }       # value = opaque ciphertext
PUT    /storage/:key       -> body { value }, header If-Match: <version>
                             # 201 on write, 409 on version mismatch (CAS)
DELETE /storage/:key       -> tombstone

Data model

One additive table, opaque to the server (mirrors states / deltas):

CREATE TABLE account_storage (
    account_id  VARCHAR(64) NOT NULL,
    key         VARCHAR(64) NOT NULL,   -- caller-defined namespace
    version     BIGINT      NOT NULL,   -- monotonic per (account_id, key)
    value       BYTEA       NOT NULL,   -- opaque bytes; never read server-side
    updated_at  TIMESTAMPTZ NOT NULL,
    PRIMARY KEY (account_id, key)
);
  • Conflicts: optimistic concurrency via If-Match: <version> — consistent with the nonce "higher wins" rule already used for deltas.
  • Encryption is the client's responsibility and out of scope for the server; Guardian stores whatever bytes it's handed. Documenting only that the server must remain content-agnostic.

What this needs from Guardian

  1. The account_storage table + the endpoints above, kept fully opaque (no parsing/validation of contents). Small extension to the existing storage backend.
  2. SDK client methods to get / put / list / delete entries (analogous to the client-API work already discussed for delta application).

Deliberately low-lift: no policy engine, no server-side crypto — opaque storage plus CRUD.

Open questions

  1. Per-account quota / value-size cap, and whether storage shares the delta rate-limit / body-size budgets?
  2. On switch_guardian, is copying entries to the new Guardian sufficient, or should the SDK orchestrate migration?
  3. Latest-only, or keep a short version history for multi-device conflict forensics?
  4. Naming: is account_storage / /storage the right primitive name, or something more specific (e.g. /kv)?

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions