galata-vault
End-to-end-encrypted secrets and config documents for software, with no accounts. A project is a key you generate. Everything is encrypted and signed on your machine, and the server (or an in-process backend) stores only ciphertext it cannot read, under signatures it cannot forge.
galata-vault is a Rust library first: the galata-vault SDK. Built on it are
the gv command line, a Python package, a server that cannot decrypt, and a
metadata-only MCP server for AI agents.
It is a general-purpose tool, and it is also the configuration and secret store of Galata, a low-latency algorithmic trading framework in Rust.
galata-vault has not been independently audited. Its formats may change before 1.0. Use it at your own risk.
Stability. The crates are 0.x: a 0.y release may break the API, a
0.y.z release does not, and CHANGELOG.md lists every
change. The wire and file formats are specified separately, in
docs/spec/, and carry their own version markers: data
written under a marker stays readable by every later release that reads it,
and a format that changes gets a new marker. The minimum supported Rust
version is 1.98, and raising it is a minor release. Security fixes cover the
latest 0.y release (SECURITY.md).
Install
cargo add galata-vault # the Rust SDK
pip install galata-vault # the Python package, with gv
curl --proto '=https' --tlsv1.2 -LsSf \
https://github.com/sercanatalik/galata-vault/releases/latest/download/gv-installer.sh | sh
gv is a binary, not a published crate, so it comes from that installer, the
Python package, or cargo install --git https://github.com/sercanatalik/galata-vault gv.
Each GitHub release
also carries a PowerShell installer for gv, and shell installers for
gv-server and gv-mcp. Every binary has a GitHub artifact attestation:
gh attestation verify <file> --repo sercanatalik/galata-vault.
The SDK
#![allow(unused)]
fn main() {
let vault = galata_vault::Vault::from_env()?; // GV_SERVER, and GV_TOKEN or GV_TOKEN_FILE
let url = vault.secret("DATABASE_URL")?; // .expose() gives the exact bytes written
let app: toml::Table = vault.config("app")?.deserialize()?;
Ok::<(), Box<dyn std::error::Error>>(())
}
Vault, the token client. Read, list, write and delete secrets and configs as the token’s scope allows; fetch history and status; verify the audit chain;refresh()after a rotation.owner, the owner API. Everythinggvdoes as a project’s owner: projects, environments, rediscovery from a key, recovery and delegation kits, minting and revoking tokens, rotation, rekey (with resume and abort), and audit verification against a stored head. It prompts for nothing and prints nothing.- Seams. Requests go through a
Transport(ClientBuilderbuilds the HTTP one), keys through aKeyStoreyou supply, and local bookkeeping through aStateStore. Nothing is process-global, and the SDK leaves its host alone: no keychain, no process limits, nothing on stdout or stderr. embedded, no server. With theembeddedfeature,galata_vault::embedded::open(dir)runs the server’s rules in-process over a data directory: no socket, no HTTP, no async runtime.
Errors are one #[non_exhaustive] enum with stable codes, and integrity
failures are their own variant. See the SDK’s README
and its examples.
The command line
gv-server local # or use a server you trust
gv init acme --server http://127.0.0.1:8750 # writes acme-recovery.gvkit; keep it safe
gv env add acme/prod
printf %s "$DATABASE_URL" | gv set DATABASE_URL --env acme/prod
gv run --env acme/prod -- ./deploy # secrets arrive as environment variables
gv token mint --scope read --env acme/prod # for CI: GV_TOKEN + GV_SERVER
gv config set app --format toml --env acme/prod < app.toml
gv ui # the vault in your browser, on loopback
gv ui
gv ui serves the vault to a browser tab from the gv process itself, on
loopback, behind a single-use link. No key or token ever reaches the page: it
gets names and metadata, and a value only for the one reveal that asked for
it. Minting, revoking, rotating and deleting wait for y in the terminal
that started it, with a four-digit code shown on both sides. What it does and
what it refuses to do is docs/local-ui.md.
The concepts behind it (the key tree, delegation and rekey, tokens and what
revocation means, config documents, durability, the local UI, the MCP
server) are in docs/guide.md. The cli module is also a
library: another binary can carry the gv command tree under its own name
(galata_vault::cli).
Python
import galata_vault
vault = galata_vault.Vault.from_env() # GV_SERVER, and GV_TOKEN or GV_TOKEN_FILE
url = vault.get("DATABASE_URL")
app = vault.get_config("app").parse() # a dict
The wheel runs the same Rust code as gv, and bundles gv itself. See
crates/gv-py/README.md.
The server
The server stores ciphertext (age v1), signed records, HMAC name indexes with encrypted names, public keys, owner-signed descriptors and bundles, and an audit hash chain. It never receives a plaintext name or value, a project or environment path, or any key that decrypts; the server binary links no decryption code, and a CI guard fails the build if it ever does. Every client pins a vault id before trusting anything, so a server cannot substitute a key, forge or move a record, or rewrite the tree unnoticed. docs/threat-model.md says what each actor can and cannot do.
gv-server local needs no configuration and listens on loopback. For other
machines you trust, run a configured gv-server behind a TLS-terminating
proxy: deploy/README.md.
One crate, and what is in it
galata-vault is the only published crate. What used to be ten crates are
its modules, each behind the feature that needs it, so a build gets the code
it asked for and no more.
| Module | Feature | Role |
|---|---|---|
proto | always | formats, API types, descriptors, audit chain, signature verification |
keys | sdk | key tree, owner, token and writer keys, bundles, name key |
seal | sdk | age envelopes, signed records, rotation; the only value decryption |
client | sdk | the client transport and request signing; no value decryption |
| the root | sdk | the token client, the owner API, embedded |
backend | storage | SQLite storage and journal records |
server_core | server-core | the server’s rules, with no HTTP, async or decryption code |
server | server | the HTTP API, an axum shell over the core |
cli | cli, ui | gv, and a library another binary can brand and extend |
mcp | mcp | the metadata-only MCP server |
The split is load-bearing, not cosmetic: server does not enable sdk, so a
server build links no value or name crypto at all, and the SDK’s default
build links no database, HTTP server or async runtime.
scripts/check-linkage.sh proves both from the
dependency graph, and checks that the serving modules name no client-side
crypto.
The three binaries live in unpublished crates that wrap those modules
(crates/gv, crates/gv-server, crates/gv-mcp), which is how each keeps
its own release target list. gv-py is the Python package (PyPI:
galata-vault); gv-adversary and gv-conformance are the malicious-server
harness and the conformance suite. ARCHITECTURE.md maps
it all.
Documentation
- docs/spec/: the protocol, with test vectors in
testdata/vectors/v1/and a conformance suite - docs/threat-model.md and ARCHITECTURE.md
- docs/guide.md: concepts and the command line
- SECURITY.md, CHANGELOG.md, RELEASING.md
All of it is also on the documentation site,
https://sercanatalik.github.io/galata-vault/, built from this repository by
.github/workflows/pages.yml.
Part of Galata
Galata is a Rust framework for low-latency algorithmic trading. It covers multi-venue market data capture, signal generation, deterministic portfolio risk controls, and agentic strategy execution driven by a fine-tuned decision model that turns market signals into calibrated probabilities. galata-vault is its single source of configuration and secrets.
flowchart LR
VAULT["gv-server local<br/>loopback :8750<br/>ciphertext only"]
CAP["galata-datawatch<br/>capture, one per venue"]
NATS{{"NATS"}}
TOWER["galata-tower"]
VAULT -- "config document<br/>+ its venue's credentials" --> CAP
VAULT -- "broker passwords" --> NATS
VAULT -- "reader password" --> TOWER
- One source, never two. Capture fetches its configuration document from the vault once, at boot, and refuses to start if the vault is unreachable. There is no cached last-known-good copy to disagree with it.
- Each service holds a token for its own secrets only. A capture process can read its venue’s credentials and nothing else. Credentials that must be cryptographically isolated from each other, such as two venues’ keys, go in child vaults, since an allow-list is server policy rather than a second encryption boundary.
- Local-first. The reference deployment runs
gv-server localon loopback, so the vault’s availability is the machine’s, not a network’s.
Galata consumes this repository only as the published galata-vault crate,
never as a path or git dependency. How it is wired is described in
galata-datawatch.
Roadmap
| Item | Status |
|---|---|
| One published crate in place of ten, with the server/SDK split enforced by features | done in 0.4.0 |
| Refuse at startup a database whose tables do not match its schema version | done, in the next release |
Deprecate the nine crates that 0.4.0 folded into galata-vault (still on crates.io at 0.2.0) | next |
Homebrew tap for gv, gv-server and gv-mcp | planned; see RELEASING.md |
1.0: an external security review published under audit/ with every high-severity finding fixed, the wire and file formats declared frozen in docs/spec/, and OpenSSF Best Practices “passing” | before 1.0 |
For Galata, the vault next holds the Robinhood venue credentials (a provider RPC key for Robinhood Chain and an Ed25519 key for the Crypto Trading API), each in its own child vault.
Contributing and licence
scripts/check-all.sh runs everything CI runs. Contributions are accepted
under the Developer Certificate of Origin: see
CONTRIBUTING.md and the Code of Conduct.
Licensed under the MIT licence.
Using galata-vault
A guide to the concepts behind gv and the SDK: the key tree, delegation,
tokens, config documents, durability, expiry, the local UI and the MCP
server. The README is the short version;
docs/spec/ is the normative one, and
threat-model.md says what each actor can and cannot do.
galata-vault has not been independently audited. Its formats may change before 1.0. Use it at your own risk.
Projects and environments are a key tree
A project is one random 256-bit key: the recovery kit holds it (gvk1_…).
Every environment key is derived from its parent’s key with a one-way HKDF,
so acme/prod and acme/dev come from acme, and acme/prod/eu from
acme/prod. Paths are at most four lowercase segments.
- Each node is one vault, identified by a hash of its owner key. Nobody else can claim that id, even after the vault expires.
- Rediscovery: every vault holds an owner-only children record, sealed to
the owner and signed by the owner; no token can read or write it. Any node
key alone rediscovers its whole subtree:
gv recover acme-recovery.gvkiton a new machine brings back every environment. - Typos are caught locally:
--env acme/prdois refused with “did you mean acme/prod?”, before any request is made. - Selection:
--env, thenGV_ENV, then a.gv.tomlcontaining onlyenv = "…".- A repository file cannot point
gvat another server. The server URL is pinned per project when you runinit.
Delegation, and ownership transfer
gv key export acme/dev writes a kit that makes its holder the owner of
acme/dev and everything below it. HKDF is one-way, so the holder has no
path to acme/prod or acme.
gv rekey acme/dev re-roots the subtree: acme/dev gets a fresh random key,
and it and every environment below it are re-created at new vault ids with
fresh keys and their data carried over. The old vaults are deleted last, and
every token for them dies with them; gv lists the tokens to re-mint. If you
hold the parent’s key, the parent’s children record is relinked to the new
key; if you don’t, the subtree leaves your tree. Afterwards, nothing held
before (the old key, an ancestor’s key, old tokens) can derive or read
anything in the subtree. This is how ownership moves without accounts.
The new kit is written before anything changes on the server, and the
re-root is resumable: gv rekey --resume finishes an interrupted run, and
gv rekey --abort removes new vaults that were never linked.
No recovery, by design
There is no account, email or operator reset. If you lose every copy of a
project key, its secrets are gone. gv init will not finish until you
confirm the recovery kit is stored. Exported environment kits give partial
recovery of their own subtrees.
Config documents
Besides secrets, a vault holds config documents. These are whole toml,
json, yaml or text files, versioned like secrets.
gv config set app --format toml --env acme/prod < app.toml
gv config get app --env acme/prod # exactly the bytes written
gv token mint --scope config --env acme/prod # reads configs, never a secret
- Their own keys. Each generation has a config keypair and a config
writer key. A
configtoken holds the config key and the name key, never the vault key, so it cannot decrypt a secret whatever it is given. Aconfig-writetoken also holds the config writer key, and neither the vault key nor the secret writer key. - Checked before they are sent.
- Every body must be UTF-8, and a
tomlorjsonbody must parse. - A body is refused if it holds a credential literal: a valid
gvt1_token orgvk1_key, a PEM private key, or0xfollowed by 64 hex digits. The error names the line, since every config reader could read the literal.--allow-literalsoverrides this for one write.
- Every body must be UTF-8, and a
- Edits don’t cross. A write can name the version it read. A stale edit then fails, naming both versions, and overwrites nothing.
Tokens, and what revocation means
| Scope | Can | Holds |
|---|---|---|
meta | list names, history, audit, status | the name key |
append | list, create and update secrets and configs; never read | the name key and both writer keys |
read | list and read secrets and configs (optionally only listed secret names) | the vault key and the config key; no writer key |
admin | read and write secrets and configs, list tokens, revoke without rotating | every key of the generation |
config | list, history, audit, status, and read configs | the config key, never the vault key |
config-write | as config, and create, update and delete configs; never a secret | the config key and the config writer key |
Minting, rotation, rekey and deleting an environment need the owner key. Bundles and descriptors are owner-signed, so no token, whatever its scope, can produce them.
Revocation is forward-only. Revoking a token stops it at the server. A
holder may already have unwrapped the keys its scope holds, and with a copied
key can decrypt any ciphertext it already copied, or sign a write the server
would refuse only by policy. gv token revoke <id> --rotate (owner-only)
moves the whole vault to a new generation in one atomic step: fresh keys,
every retained version re-encrypted and re-signed, a new descriptor linked to
the old one. After that, the old keys open and sign nothing that is stored,
including values written later. Change any secret you believe was actually
read.
Tokens are checksummed (gvt1_…, 92 characters after the prefix, carrying
the vault id), so a mistyped token is rejected before it reaches the server.
Whoever holds a leaked token can revoke it without ever sending it:
gv token report reads it from a file or stdin and sends a signature made
with the token’s own request-signing key.
Durability and the loss window
- The database is SQLite in WAL mode, in the data directory.
- Revocations, rotations and vault deletions are journaled (a rekey is
durable through the deletions of the vaults it retires). Each is written to
the journal before it is acknowledged. If the journal cannot be written,
these operations return 503 (
unavailable) and change nothing; ordinary writes carry on. Before a server serves anything, and before an embedded application’s first request, the journal is replayed, so an older copy of the database cannot bring back a revoked token, undo a rotation, or revive a deleted vault. - On one machine (local mode, a self-hosted server, the embedded backend) the journal is a directory beside the database, on the same disk. It protects against an older copy of the database, not against losing the disk: the data directory is the only copy, so back it up (deploy/README.md).
- Ordinary writes (setting a secret) are acknowledged once committed. A write that was lost is simply absent: the version history shows what survived.
Expiry
No vault expires for inactivity. No server in this repository deletes a
vault because nothing touched it: responses carry no X-GV-Expires-At,
status shows no expiry, the capabilities report idle_expiry_days: null, and
gv sends no keep-alive requests.
The protocol still allows a server to expire idle vaults
(spec/hosted.md), so gv keeps handling one that does.
When a server announces expiry, in its capabilities or its responses:
- Every response carries
X-GV-Expires-At, andgvwarns you, naming the environment, when that is less than 14 days away. - While you use any environment,
gvtouches its ancestors at most once a day, so a project root outlives environments you use daily. gv env repair <path>re-creates an expired vault at its same id and relists its children. The expired vault’s secrets are not recoverable.
Residual metadata
End-to-end encryption hides contents, not everything. The server, or anyone with its data, can see:
- how many vaults exist, and each one’s size, number of names and versions, and write and read times;
- how many config documents each vault holds, with each one’s ciphertext size and number of versions (configs and secrets are stored apart, so they can be told apart);
- token ids, scopes and expiry times, and which token touched which record index and version (the audit chain);
- secret- and config-name indexes, which are stable within a generation, so repeated access to “the same secret” is visible, though not which secret it is.
It can also infer some things:
- Timing links a project’s vaults. Environments created seconds apart from one address are probably one project. Nothing on the server records the tree, but timing can suggest it.
- Client IP addresses are not written to the database, logs or metrics; a test enforces this. Your TLS terminator’s logs are your own to manage.
Abuse controls
Quotas keep every vault small, in every build:
- secrets: 200 names, 16 KiB per value, 20 versions per name;
- configs: 64 documents, 256 KiB each, 20 versions each;
- 4 MiB per vault across secrets and configs, and 128 tokens.
Every quota is configurable. Nothing the server returns is renderable in a browser. There is no proof of work, there are no rate limits and nothing expires: a server that strangers can reach belongs behind a proxy that limits requests. A configuration asking for any of those is refused at startup, naming the setting, rather than started without it.
A local UI: gv ui
gv ui serves the vault to your browser from the gv process itself, on
127.0.0.1 at a port the OS picks. It prints a one-time link and opens it.
The vault server does not change: it still serves no HTML and cannot
decrypt.
- Keys stay in
gv.- The page gets names and metadata, and a value only when you reveal it.
- A revealed value hides again after 10 seconds.
- The page never gets a key, a bundle or a token.
- Copying doesn’t show the value. Copy sends the value from
gvstraight to the clipboard (pbcopy, orwl-copy,xcliporxsel). It is cleared after 30 seconds, unless you have copied something else since. - The terminal decides.
- Minting a token, revoking one, rotating a vault and deleting an
environment all wait for
yin the terminal runninggv ui. The terminal shows the same 4-digit code as the page. - Revoking a token that holds a key rotates the vault in the same step, as the owner.
- The terminal lists every reveal, copy and write as it happens.
- Minting a token, revoking one, rotating a vault and deleting an
environment all wait for
- Sessions are short.
- A link works once, and lapses after 60 seconds if unused.
- A session ends after 15 minutes idle, when you press
lock, or when Enter prints a new link. - Ctrl-C locks and quits.
- Opening the browser passes the link as an argument to
openorxdg-open;--no-openavoids that.
- What it cannot protect:
- A browser extension with access to local pages can read whatever the page shows. Use a browser profile without extensions if that matters to you.
- Clipboard managers may keep a history of what you copy.
- Key custody stays in the CLI.
gv init,gv recover,gv keyandgv rekeyare not available in the UI.
The UI’s threat model and manual checklist are in docs/local-ui.md.
For AI agents: metadata only
gv mcp setup acme mints one meta token per environment. gv mcp then
runs a local stdio MCP server with four tools: list_secrets, diff_envs,
audit and status.
- It cannot return or write a value. Its tokens carry no vault key and no writer key, and the binary links no value decryption; a CI guard checks both.
- It verifies what it reports: the descriptor from the pinned vault id, the record signatures behind every listed name, and the audit chain.
diff_envscompares environments on your machine, from independent requests, so the server never learns they are related.
galata-vault threat model
This document states, for each party that could attack a galata-vault user, what that party can do, what it cannot, and what enforces each limit: cryptography (a key the party does not hold, a signature it cannot make, a binding it cannot change) or server policy (a rule an honest server applies, which a dishonest one may not). Phase 5’s external review packet builds on it. Every security claim in the README must trace to a row here (see the last section).
The protocol it describes is specified in docs/spec/. Where this document and the specification disagree, the specification wins.
Assumptions
- A client is not compromised while it is unlocked: the machine running
gv, the SDK or the Python package, and the process itself, are the user’s. - The primitives hold: Ed25519, X25519, HKDF-SHA256, HMAC-SHA256, SHA-256, BLAKE3, ChaCha20-Poly1305, XChaCha20-Poly1305, XSalsa20-Poly1305 (sealed boxes) and age v1.
- Honest users keep their node keys and token strings to themselves.
- Clients keep the state the protocol asks them to keep (descriptor and version pins, audit heads) where the protection says it depends on it.
Actors
An active malicious server
Includes a dishonest operator, anyone who controls the server’s machine, and
another process answering on the port a client uses (for gv-server local,
the local port).
| What | Enforced by | |
|---|---|---|
| can | withhold or delete any vault, record, row or the whole service | nothing (availability is out of scope) |
| can | see the residual metadata below | nothing |
| can | show a client that keeps no state an old but correctly signed record, generation or audit history | nothing without client state (see “Rollback and fork”) |
| can | show different clients different, individually consistent audit histories | nothing without an external witness |
| can | ignore every server-policy limit: the read allow-list, TTLs, expiry, revocation before rotation, quotas, rate limits | these are server policy |
| can | refuse to serve, answer errors, or claim capabilities it does not have (claiming not to speak protocol version 1 makes clients refuse it) | nothing |
| cannot | read a value, a config body or a record name | cryptography: values are age ciphertext to keys in owner-signed bundles; names are XChaCha20-Poly1305 under the name key (records.md#3, keys.md#8) |
| cannot | substitute a vault or config public key, or a writer key | cryptography: every key comes from a descriptor signed by the owner key, which hashes to the vault id the client pinned (records.md#1, protocol.md#4) |
| cannot | forge a bundle, or move a token’s bundle to another token, scope or generation | cryptography: the owner signs vault, token, scope and generation, and the plaintext repeats them (records.md#2) |
| cannot | forge a record or tombstone, or move one between vaults, generations, names, versions or kinds | cryptography: the writer signature binds all of them, and the decrypted envelope repeats them (records.md#3, records.md#4) |
| cannot | forge the children record, or redirect an environment | cryptography: only the owner signs it (records.md#5) |
| cannot | roll back a record, generation or audit history past what a client has pinned | client state: pins and heads (protocol.md#12, audit.md#5) |
| cannot | derive any key from what clients send | cryptography: requests are signed, and no request carries a key or the token secret (signatures.md#2, keys.md#6) |
| cannot | impersonate the owner, mint a token, rotate, or claim a vault id | cryptography: all need the owner signing key; the vault id is its hash |
A TLS terminator or a log reader
Anyone who sees requests and responses in the clear: a reverse proxy, a CDN, a request log, a packet capture after TLS.
| What | Enforced by | |
|---|---|---|
| can | see everything the server sees in transit: ciphertext, signatures, public keys, name indexes, metadata | nothing |
| can | delay or drop a request, or deliver a withheld request itself, once, within the ±300 s skew window | nothing beyond the window |
| cannot | decrypt anything, or derive a key | cryptography: no credential that decrypts crosses the wire (signatures.md#2) |
| cannot | replay a request | server policy: each signature’s nonce is spent once, within the skew window (signatures.md#2.3) |
| cannot | alter a request’s method, path, query, preconditions or body | cryptography: the signature covers them |
| cannot | find a credential in a URL | server policy: credentials in a query string are refused unprocessed (http-api.md#4.1) |
A stolen database dump or backup
Includes the SQLite database, the journal, and any backup of the data directory.
| What | Enforced by | |
|---|---|---|
| can | see ciphertext, sealed bundles, public keys, descriptors, name indexes, audit rows, spent nonces and the residual metadata | nothing |
| cannot | decrypt a value, a name or a bundle | cryptography: no private key is stored |
| cannot | authenticate as a token or an owner | cryptography: the server stores only auth_pub and owner_sign_pub; no token secret or secret hash |
| cannot | derive a token’s box key | cryptography: it derives only from the token secret (keys.md#6) |
| cannot | undo an acknowledged revocation, rotation or deletion by restoring an older copy on the same server | server policy: the journal is replayed before anything is served |
An append token holder
Holds the name key and both writer keys of the current generation.
| What | Enforced by | |
|---|---|---|
| can | create, update and delete secrets and configs, with valid writer signatures | cryptography: it holds the secret and config writer keys |
| can | list names and decrypt them, and read history metadata, audit and status | cryptography: it holds the name key |
| cannot | read a value or a config body | cryptography: its bundle has no vault or config private key (records.md#2.2) |
| cannot | write the children record, a descriptor or a bundle; mint, rotate, rekey or delete | cryptography: all need the owner signing key |
| cannot | read records through the API | server policy: the server refuses its reads (it could not decrypt them anyway) |
| cannot | be attributed a write cryptographically, as against another writer-key holder | writer keys are per generation per kind, not per token; the audit row names the authenticated token (server policy) |
| after revocation without rotation | keeps its writer keys, whose signatures still verify in the current generation | revocation is forward-only; the server refuses its requests (policy); rotation cuts it off (cryptography) |
A config token holder
Holds the name key and the config private key.
| What | Enforced by | |
|---|---|---|
| can | read configs, list names, history, audit and status | cryptography: config key and name key |
| cannot | decrypt a secret | cryptography: its bundle type has no field for the vault key |
| cannot | write anything | cryptography: it holds no writer key |
A config-write token holder
Holds the name key, the config private key and the config writer key.
| What | Enforced by | |
|---|---|---|
| can | read, create, update and delete configs | cryptography: config key and config writer key |
| cannot | read or write a secret | cryptography: no vault key, and secret records verify only against the secret writer key (records.md#4) |
| cannot | write the children record, descriptors or bundles | cryptography: owner key only |
A meta token holder, including the MCP server
Holds the name key only. gv-mcp holds only meta tokens.
| What | Enforced by | |
|---|---|---|
| can | list and decrypt names, and read history metadata, audit and status; verify record signatures from their hashes | cryptography: name key; public descriptor |
| cannot | read or write a value or a config | cryptography: its bundle holds the name key only; gv-mcp refuses a bundle that holds more, and links no value decryption (scripts/check-mcp-linkage.sh) |
| cannot | learn names of environments it holds no token for | cryptography: each vault has its own name key |
| cannot | tell the server that two environments are related by comparing them | protocol: diff_envs compares on the client, from independent requests |
read and admin token holders
| What | Enforced by | |
|---|---|---|
read can | read every secret and config of the generation | cryptography: vault and config private keys |
read cannot | read a secret outside its allow-list through the API | server policy only: it holds the vault key |
read cannot | write anything | cryptography: no writer key |
admin can | read and write secrets and configs, list tokens, revoke without rotating | cryptography for the keys (every key of the generation); server policy for token listing and revocation |
admin cannot | mint, rotate, rekey, write the children record or delete the vault | cryptography: owner signing key only |
Another process running as the same local user
| What | Enforced by | |
|---|---|---|
| can | read and change gv-server local’s or an embedded application’s data directory, open vault.db, and so bypass scopes, quotas, the allow-list and audit | nothing: file permissions stop other users, not this one |
| can | usually read the user’s node keys and tokens: from the OS keychain where the platform lets any process of the user read it, from token files, kits and gv’s configuration, or by attaching to a client process | the operating system; typically nothing |
| can | then act as the owner of every project whose key it reads | nothing: holding the key is ownership |
| can | answer on the local port in place of gv-server local | nothing: it is then a malicious server (above) |
| cannot | forge anything a client verifies without a key it can read | cryptography: pinned vault ids and signatures bind it as they bind a malicious server |
This actor is close to “a compromised unlocked client”, which is out of scope: the protections that remain are those against a malicious server, and they last only while the process cannot read the user’s keys.
A network attacker
| What | Enforced by | |
|---|---|---|
| can | observe traffic volume and timing to the server | nothing |
| can | block or delay traffic | nothing |
| cannot | read or change requests | TLS, which clients require for any non-loopback server (http:// only to loopback) |
| cannot | redirect a client to another server | clients follow no redirect, and a project’s server URL is pinned in its kit and local state |
| cannot | forge or replay anything, even with a server it controls | cryptography, as against a malicious server |
Out of scope
- A compromised client while it is unlocked, including malware running as the user (see the same-user row).
- Side channels on the client host: timing, cache, power, swap and core dumps beyond what the CLI already limits.
- Availability against a malicious server or network.
- Post-quantum attackers: X25519 and Ed25519 throughout.
- The local UI’s browser-side risks, which have their own review
(
docs/local-ui.md).
Residual metadata
End-to-end encryption hides contents, not everything. The server, or anyone with its data, sees:
- how many vaults exist, and each one’s size, number of names and versions, and write and read times;
- how many config documents each vault holds, with each one’s ciphertext size and number of versions (configs and secrets are stored apart, so they can be told apart);
- token ids, scopes and expiry times, and which token touched which record index and version (the audit chain);
- name indexes, stable within a generation, so repeated access to the same record is visible, though not which record it is.
It can infer:
- which vaults belong to one project, from creation timing and addresses;
- client addresses, which the server writes to no database, log or metric. A TLS terminator’s logs are the operator’s own.
Rollback and fork attacks that remain without an external witness
The audit chain is computed by the server with no key, and the protocol has no external witness. So:
- A server can show a client that keeps no state an old, internally consistent history: an older generation, an older version of a record, a shorter audit chain. Every signature on it verifies.
- A server can fork: show different clients different, individually consistent histories. Neither detects it unless they compare heads.
- A server can withhold rows or records: not serve the latest write, or any write, to some clients.
- Detection is only relative to state a client kept: its descriptor pin
(generation and hash), its per-name version pins, and its audit head.
gvkeeps them instate.toml; the SDKs detect rollback only when the caller persists and passes them back. A client that loses that state loses the protection. - An old but correctly signed record is still an authentic record of that version: a server cannot make up a value, only choose which authentic one to show.
Limits that are server policy only
- the
readtoken allow-list; - token lifetimes, and vault idle expiry;
- revocation before the next rotation (a revoked holder may keep keys it already unwrapped, and writer signatures it can make still verify until the vault rotates);
- quotas and rate limits;
- the uniform 401, which hides whether a vault or token exists;
- scope checks on reads by holders that could not decrypt the result anyway.
README claims and the rows behind them
| README claim | Supported by |
|---|---|
| “The server stores only ciphertext, and everything you rely on from it is signed by keys it does not hold.” | malicious server: cannot read, substitute, forge or move; stolen dump: cannot decrypt |
| The server cannot decrypt, substitute a key, forge a record or rewrite the tree unnoticed. | malicious server rows; tree: the children record row |
| The server build cannot link decryption code. | stolen dump and malicious server rows; ARCHITECTURE.md invariants (scripts/check-server-linkage.sh) |
| The MCP server cannot read or write values. | meta and MCP rows |
| Names are hidden too. | malicious server: cannot read a record name; residual metadata (indexes are visible) |
| The audit chain is verified by the client against the last head it saw. | malicious server: cannot roll back past a pinned head; “Rollback and fork” for what remains |
| Environments are a one-way key tree; a subtree’s key gives nothing above it. | not an actor limit: the key schedule (docs/spec/keys.md#4); a delegation-kit holder is the owner of its subtree, and of nothing above it |
| A request log or the TLS terminator holds nothing that decrypts. | TLS terminator or log reader rows |
A meta, read or config token cannot produce a valid write, a config-write token cannot produce a valid secret, and an append token cannot redirect an environment: by cryptography. | meta, read, config, config-write and append rows |
| Revocation is forward-only; rotation is the cut-off. | append row “after revocation”; “Limits that are server policy only” |
| A leaked token can be revoked without sending it. | TLS terminator: nothing on the wire derives a key; docs/spec/signatures.md#7 |
| An older copy of the database cannot bring back a revoked token, undo a rotation or revive a deleted vault. | stolen dump: journal replay (server policy) |
| Client IP addresses are held only in memory, and only by a public instance’s rate limiter. | residual metadata |
| Threat table: disk, backups or journal leak; request logs or TLS terminator leak; malicious operator or port squatter; token leak; project key leak; compromised unlocked laptop out of scope. | stolen dump; TLS terminator; malicious server and same-user rows; token-holder rows; same-user row (holding the key is ownership); out of scope |
| What the protocol does not do: prevent a fork or withholding; enforce by cryptography the allow-list, TTLs and expiry, revocation before rotation, or quotas; hide metadata. | “Rollback and fork”; “Limits that are server policy only”; residual metadata |
Architecture
galata-vault is an end-to-end-encrypted secrets and config service with no accounts: a project is a key, environments are a one-way key tree, and each node of the tree is one vault on a server. Clients encrypt, sign and verify everything; the server stores ciphertext and public keys and checks signatures, and cannot decrypt because no code in its build can. The same server rules also run in-process, over a data directory, for an application that needs no server.
This is a map, not a manual. The protocol is specified in docs/spec/; what each actor can and cannot do is in docs/threat-model.md.
Codemap
One published crate, galata-vault, whose modules are what used to be ten
crates. Each is behind the feature named beside it in
README.md; paths below are relative
to crates/galata-vault/src/.
Client side, bottom to top:
proto: the shared vocabulary of the protocol. String formats (gvk1_,gvt1_, paths), the framing convention and the label registry, every request and response body, descriptors, record contexts, the audit chain, proof of work, and Ed25519 verification. Holds no private key and cannot sign or decrypt. Depends on nothing in the workspace. Start atsrc/api.rsandsrc/frame.rs.keys: key material that is not a secret value. The key tree, owner, token and writer keys, owner-signed bundles (sealed boxes), the name key and the children record. Cannot decrypt a value. Depends onproto. Start at the diagram insrc/lib.rs, thensrc/bundle.rs.seal: the only crate that can read a secret value. age v1 envelopes, signed records, and the rotation batch. Depends onprotoandkeys. Start atsrc/envelope.rsandsrc/record.rs.client: the client transport. TheTransportseam, the typedApithat builds and signs every canonical request, and the HTTP transport (featurehttp). Links no value decryption. Depends onprotoandkeys. Start atsrc/transport.rs, thensrc/api.rs.- the crate root: the Rust SDK.
Vault(the token client),owner(projects, environments, kits, tokens, rotation, rekey, audit), pins against rollback, and, with featureembedded, an in-process backend overserver_core. Depends onclient,proto,keys,seal(andserver_core,backendwithembedded). Start atsrc/vault.rs(Handle), thensrc/token.rsandsrc/owner/. cli: thegvcommand and thegv uilocal UI, as a library another binary can brand. Built on the SDK. Start atsrc/cli.rsandsrc/context.rs.crates/gv-py: the Python package: a binding of the SDK, the bundledgv, and the private_vectorsrunner. Start atsrc/lib.rsandpython/galata_vault/__init__.py.mcp: the metadata-only MCP server, overclient. Holds onlymetatokens. Start atsrc/tools.rsandsrc/env.rs.
Server side, bottom to top:
backend: theStoretrait and its SQLite implementation. Ciphertext, hashes and public keys only; audit rows are appended in the same transaction as the action. Depends onproto. Start atsrc/lib.rs.server_core: every rule the server enforces, as a synchronous library: authentication on the canonical request, the scope matrix, quotas, preconditions, critical operations through the journal, replay, the capabilities document, and the route table. Depends onprotoandbackendonly. Start atsrc/lib.rs(Core::call) andsrc/route.rs(ROUTES).server: the HTTP server, an axum shell over the core: response hygiene, the loopback and TLS-proxy rule, andlocalmode. One build, with no feature that changes what it enforces. Start atsrc/lib.rs(router) andsrc/main.rs.
Tests and tools:
crates/gv-adversary: the malicious-server harness: a real server behind a scriptable proxy that forges, replays and rolls back. The SDK,gv,gv-mcpand the Python package are run against it.crates/gv-conformance: the black-box conformance suite for servers (unpublished): numbered cases that cite the spec, run against any server URL or the embedded backend.scripts/vectors/: an independent Python implementation of the protocol that generatestestdata/vectors/v1/. It shares no code with the crates.
The layer rule
Modules depend only on what is below them in their column, and the features are what make that checkable:
client: proto → keys → seal → client → the crate root → cli, gv-py
(mcp uses client, never seal)
server: proto → backend → server_core → server
sdk is the feature that turns on the client column; server turns on the
server column and does not enable sdk. So a server build has no value
or name crypto compiled into it at all, and cannot grow any by accident: the
code is not there to call.
embedded is the one place the columns meet: the SDK links server_core and
backend to serve itself in-process, and still no HTTP server, runtime or
S3 client.
Inside one crate the compiler cannot enforce the columns the way ten crates
did – a #[cfg] could widen them. scripts/check-linkage.sh
is what replaces it: it reads the dependency graph per feature set, and it
reads the source for a serving module that names crate::keys, crate::seal
or crate::client. Both halves fail the build.
gv ui: the local UI’s threat model
gv ui adds a local HTTP surface to gv. This file covers only that
surface. The client cryptography it relies on is covered by
threat-model.md and is unchanged.
What it looks like
Names and metadata are decrypted in gv and sent to the page. A value is
not: the dots stay dots until a reveal asks for one, and that reveal is
audited like any other read. Copying puts the value on the clipboard from the
gv process, never through the page, and clears it after 30 seconds.
The audit chain is verified in gv against the head this machine stored, so
a server that dropped or reordered a row is caught here rather than
believed. The server holds only the hashes of the names in those rows.
Shape
browser tab gv ui (the gv process) gv-server (unchanged)
┌───────────────────────┐ HTTP ┌──────────────────────────────┐ /v1 ┌──────────────────┐
│ HTML, CSS, a little JS │ ────▶ │ keychain · session · crypto │ ────▶ │ ciphertext only │
│ session cookie │ ◀──── │ terminal: the trusted input │ ◀──── │ serves no HTML │
│ what is on screen │ └──────────────────────────────┘ └──────────────────┘
└───────────────────────┘ 127.0.0.1:<OS-assigned port>
- One thread owns all state: the owner session and its credential store, the browser session, the pending confirmation, a minted token awaiting hand-over, and the clipboard timers.
- Code:
crates/galata_vault::cli/src/ui/. The HTTP plumbing and checks are inweb.rs, the loop and handlers inapp.rs, templates inpages.rs, and the clipboard inclip.rs.
What is at stake
| Asset | Where it lives | Reaches the page? |
|---|---|---|
| Node keys, vault keys, bundles | the gv process, the keychain | never |
| Token strings | the gv process, until handed over | never (copied, or saved to a 0600 file) |
| Secret values | decrypted in gv per request | only in the response to one reveal |
| Names, versions, metadata | decrypted in gv | yes |
Threats and controls
| Threat | Control | Where tested (crates/galata_vault::cli/tests/ui.rs) |
|---|---|---|
Another site in the same browser posts to gv ui (CSRF) | SameSite=Strict cookie. Every POST needs Origin equal to http://127.0.0.1:<port> and X-GV-UI: 1. No CORS headers are sent, so cross-origin reads fail. GET changes nothing | every_request_is_checked_and_every_response_hardened |
| DNS rebinding (a name that resolves to 127.0.0.1) | An exact Host match, checked before any handler; otherwise 421 with an empty body | same |
| Another local user or process connects to the port | A 128-bit single-use code, exchanged for a 256-bit session cookie. One session at a time. Loopback only | same (reused code, stale session after a new link) |
| XSS through vault content (names, values) | maud escapes every interpolation. The CSP is default-src 'none' with script-src 'self' and style-src 'self': no inline script, no style attribute anywhere | same (a markup-shaped name) |
| A compromised page tries to keep access | No key or token is reachable. Values come only through reveals, each audited and listed in the terminal. Mint, revoke, rotate and environment delete wait for y in the terminal, with a 4-digit code shown on both sides | a_full_session_leaks_no_key_and_shows_values_only_when_revealed, changes_that_matter_wait_for_the_terminal |
| A stale or copied link, or browser history | The code is in the URL fragment (never sent to a server), spent on first use, lapses after 60 s, and is removed from the address bar immediately | same as the first row |
| A value left on the clipboard | The value goes to the tool on stdin, never in an argument. It is cleared after 30 s, and when gv ui exits, but only if the clipboard still holds that value | the_clipboard_is_cleared_only_while_it_holds_the_value |
| An unattended session | Locked after 15 minutes without a request (the liveness ping doesn’t count). The page removes what it showed on its next 401 | an_idle_session_locks |
| A value or credential in a URL | Only the query keys name, with and all are accepted, and none may contain gvt1_ or gvk1_ | every_request_is_checked_and_every_response_hardened, web.rs unit tests |
gv ui started without a terminal, so no confirmation channel | It refuses to start unless stdin and stdout are TTYs | gv_ui_needs_a_terminal, and the Python suite |
| The server gains decryption code or HTML | The server is unchanged. The linkage guards and the Accept: text/html test still pass | scripts/check-all.sh |
Residual risks, accepted
- Browser extensions with access to local pages can read whatever is on screen, including a value while it is revealed. The mitigation is the default use of copy over reveal, and a clean browser profile.
- Page memory. Hiding a value is a courtesy: page memory is outside
gv’s control until the tab closes. - Clipboard managers may keep a history of copied values. Marking clipboard content as concealed needs native APIs, and is an open question in the design.
- The one-time code briefly appears in
open’s orxdg-open’s arguments. Another user who wins that race gets the session, and the real tab then shows “this link has been used”. The terminal prints every session start.--no-openavoids the race entirely. - Secret names appear in page URLs (history, compare), and therefore in browser history. Values and credentials never do.
- Loopback traffic is plain HTTP. Only root can read it, and root is out of scope.
Manual checklist
Run this against the Galata Vault UI canvas
(https://claude.ai/artifact/2QTpFnAAaavL7PU8RetZqY) before
a release that touches ui/. Check each item in light and dark mode.
-
gv uiprints the banner and a link, and opens the browser;--no-opendoesn’t. - The link opens the projects page, and the address bar shows no
#c=. - Reopening the same link shows “this link has been used”; Enter in the terminal prints a new one.
- Projects: projects and environments with their key kind; add an
environment; delete one only after
yin the terminal. - Secrets:
- values masked;
- reveal shows one value with a countdown, and hides it;
- copy shows the toast, and the terminal lists the copy;
- add a secret; delete one; the history sheet; restore.
- Compare: a shared value is flagged; no value appears on the page.
- Tokens:
- mint shows a code;
- the terminal shows the same code;
yfinishes it, and the token can be copied or saved once;- revoking a
readtoken says it rotated.
- Audit: “chain verified”, and the reads made through the UI appear.
-
lockshows the locked state, and the page keeps nothing. - Ctrl-C in the terminal: the page shows “gv ui has stopped” within a few seconds.
- Esc closes dialogs; focus rings are visible when tabbing.
Running a server
gv-server stores ciphertext, signatures and public keys, and cannot read
any of it: the binary links no decryption code, and
scripts/check-server-linkage.sh fails the build if it ever does. That is
what makes running one a modest undertaking — an operator who reads every
byte on the disk learns names’ HMACs, sizes and times, and no secret value.
The threat model says exactly what an operator
can and cannot do; read it before you decide what this server is worth
protecting.
galata-vault has not been independently audited. Its formats may change before 1.0. Use it at your own risk.
Your own machine
Nothing here is needed:
gv-server local # http://127.0.0.1:8750, data in ~/.local/share/galata-vault
Local mode takes no configuration file, listens on loopback only, and asks
nothing of its callers. gv-server local --print-service launchd (or
systemd) prints a user service definition to adapt.
This page is for the other case: a server that machines other than yours reach.
What this build does not do
Read this before planning a deployment, because it decides the shape of one.
There is one build, with no feature that changes what it enforces. It authenticates every request and enforces the per-vault quotas. It does not implement proof of work, rate limiting, or idle expiry. Those controls are specified — clients understand them, and docs/spec/hosted.md is their appendix — but no server in this repository implements them.
So a configuration naming one is refused at startup, by name, rather than started with the control silently ignored:
| Setting | Startup |
|---|---|
profile (any value, "hosted" named specially) | refused |
production = true | refused |
pow_difficulty greater than 0 | refused |
idle_expiry_days greater than 0 | refused |
[rate_limits] | refused |
[journal] kind = "s3" | refused |
Zero counts as “none” for the two numeric settings, so a configuration that only ever switched them off still starts.
Everything that admission control would have done, the proxy in front must do. Anyone who can reach the server can attempt to create vaults, limited only by the quotas.
The shape of a deployment
clients ──TLS──▶ proxy (terminates TLS, rate-limits, IP-filters)
│ plain HTTP over loopback
▼
gv-server listen = 127.0.0.1:8750
│
├── database (SQLite)
└── journal (a directory beside it)
gv-server serves no TLS itself. It refuses to start on a non-loopback
address unless behind_tls_proxy = true says something in front is
terminating TLS. Setting that flag while nothing does means plaintext on the
wire: the signatures still protect request integrity, but the proxy is what
makes the connection private.
The configuration
# gv-server --config /etc/galata-vault/server.toml
# (or set GV_SERVER_CONFIG)
listen = "127.0.0.1:8750" # default; non-loopback needs behind_tls_proxy
database = "/var/lib/galata-vault/vault.db"
# Only when a proxy really terminates TLS in front.
behind_tls_proxy = true
# Name this server and its version in GET /v1/capabilities. Off by default:
# no client needs it, and it helps anyone fingerprinting the deployment.
advertise_version = false
# Running under `litestream replicate -exec`.
litestream = false
[journal]
kind = "file"
dir = "/var/lib/galata-vault/journal"
# Optional. Unset, the quotas below are the defaults.
[limits]
max_names = 200
max_value_bytes = 16384
max_versions = 20
max_vault_bytes = 4194304
max_tokens = 128
max_configs = 64
max_config_bytes = 262144
max_config_versions = 20
A typo inside [journal] is refused rather than ignored, and so is an
unknown field anywhere in the file.
max_body_bytes is derived from the quotas — the largest rotation batch a
full vault can produce — and setting it below what the quotas require is
refused, naming both numbers.
Before the first start
gv-server check --config /etc/galata-vault/server.toml
That validates the configuration, opens the journal and round-trips a probe through it, then exits. Run it before the first start and after every change: it catches a bad journal path while nothing is serving.
The journal, and what durability you actually have
The file journal is a directory beside the database. It undoes a restored
older copy of the database — the rollback the protocol’s audit chain lets
a client detect — but it does not survive the loss of the disk, because
it is on that disk. gv-server local says so at startup, in a warning.
If the data matters, the disk needs replication underneath: litestream
(set litestream = true when running under litestream replicate -exec) or
snapshots of the volume. Losing the database loses the vaults; there is no
account system and no recovery path on the server side, by design. Owners
hold their own keys, and a vault’s contents cannot be reconstructed from
anything the server has.
Data directory permissions
The data directory must be 0700. The server and the embedded backend both
refuse a more permissive one rather than serve from it, so create it with
the mode set:
install -d -m 700 -o galata-vault -g galata-vault /var/lib/galata-vault
Operating notes
- Run it as its own unprivileged user, owning only the data directory.
- Back up the database and the journal together, from the same moment. A journal older than the database is worse than none.
- Watch the disk. The quotas bound a single vault, not how many vaults exist; nothing here limits vault creation.
- The logs name no secrets — no values, no token strings, no record names in clear. They do carry vault ids and record name HMACs.
- Upgrading is replacing the binary and restarting. Formats may change before 1.0; read CHANGELOG.md before upgrading, and check docs/spec/stability.md for what a format marker guarantees.
Checking a server is honest
A server is not trusted by clients, and you can hold your own to the same standard. The conformance suite runs the protocol against any server:
scripts/conformance.sh # a local gv-server, then the embedded backend
cargo run -p gv-conformance -- --server <url> runs it against a deployed
one. It creates and deletes its own vaults, so point it at a server you are
willing to write to.
The galata-vault protocol: specification
Status: draft (docs/spec/README.md#1).
This directory is the normative specification of the galata-vault protocol:
the bytes a client and a server exchange and store, the checks each side
makes, and the HTTP API. With it and the test vectors in
testdata/vectors/v1/, an implementer can build a client that
interoperates with this repository’s server and passes every vector.
1. Status and version
- Protocol version: 1. Every string prefix, label, path and version marker in these documents is the one protocol version 1 uses (stability.md#2).
- Status: draft, as of 2026-09-15. A draft may still change, but only as stability.md#3 allows: never by silently changing bytes a version marker already names.
- The draft marker is removed only at release 1.0, after the external review of the protocol and the client cryptography. From then on the rules of stability.md#4 apply.
2. Documents
| Document | Covers |
|---|---|
| README.md | Status, conventions, the failure-name registry, the test-vector format, the conformance suite, precedence |
| formats.md | Encodings, base62 and checksummed strings (gvk1_, gvt1_), paths |
| keys.md | Framing, HKDF, the label registry, the key tree, owner, token, generation and name keys |
| records.md | Generation descriptors, bundles, record envelopes, record signatures, the children record, kits |
| signatures.md | Every signature: its signing input, signer and verifier; the request signature GV-Sig |
| protocol.md | The flows: creation, minting, opening, reading, writing, listing, rotation, rekey, rediscovery, revocation |
| http-api.md | The /v1 endpoints, authentication, headers, errors, capabilities, forward compatibility |
| audit.md | Audit rows, their encoding and hash, row formats, the verification algorithm |
| hosted.md | Appendix: proof of work, idle expiry and X-GV-Expires-At, rate limits |
| stability.md | Version markers and the stability policy |
Two companion documents are not normative but are written against this specification:
- ARCHITECTURE.md: the codemap, and the invariants of the implementation;
- docs/threat-model.md: actors, what each can and cannot do, and what remains possible.
3. Conventions
- Keywords. The key words MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY and OPTIONAL are to be interpreted as described in BCP 14 (RFC 2119, RFC 8174) when, and only when, they appear in all capitals.
- Notation.
a ‖ bis the concatenation of byte strings.u8,u16,u32,u64are unsigned andi64is signed two’s complement; all are big-endian unless a section says otherwise.x(n)is a field ofnbytes.- Bytes written in prose are lowercase hexadecimal.
- base64url is RFC 4648 §5, without padding (formats.md#1.3).
frame(label, fields…)is defined in keys.md#1.
- Anchors. Every numbered section has a stable anchor, written on its
own line before the heading (
<a id="8.1"></a>). Cite a section askeys.md#8.1. Test vectors and conformance cases cite anchors, and a test checks that every anchor a vector cites exists. Anchors are never renumbered; a section that is removed keeps its number as a stub that says so.
4. Failure names
Test vectors name the failure an implementation must meet with one of these names. An implementation maps its own errors onto them; its error types and messages are its own. Names are only ever added to this registry: none is renamed, removed or given another meaning.
| Name | Meaning |
|---|---|
checksum_mismatch | A checksummed string’s checksum does not match its prefix and body (formats.md#2.2). |
bad_encoding | Input that is not valid in its encoding: a character outside base62, hex or base64url; a base62 value too large for its width; text that is not UTF-8, JSON or TOML where one is required; a malformed GV-Sig header; a JSON document with a missing, repeated or unknown field where the format is strict; a proof-of-work challenge that does not decode or states a difficulty above the maximum. |
bad_length | A fixed-length field or construct of the wrong length, or longer than its maximum (a base62 or checksummed string with characters past its width, a descriptor of the wrong length, a bundle body that is not its kind’s length, an envelope name longer than 256 bytes). |
truncated | Input shorter than its fixed part: a string cut short, a bundle plaintext shorter than its header, an envelope shorter than its fields or name. |
unknown_version | A version marker this implementation does not know: a string or kit under another version digit, a descriptor, bundle, envelope or children-record version byte, a kit v, GV-Sig v=1, or a value that does not start with the age v1 header. |
unknown_kind | A kind code this implementation does not know: a bundle kind, a record kind. |
unknown_format | A config format code this implementation does not know (records.md#3). |
kind_mismatch | A known kind other than the one expected: a string without the expected prefix, a bundle whose kind is not the scope’s, a child key where a bundle is expected or the reverse, a secret envelope where a config is expected, a recovery kit for a path below a project. |
bad_signature | An Ed25519 signature that does not verify under the key that must have made it, under the rules of signatures.md#1; also a proof-of-work challenge whose authentication tag does not match (hosted.md#2). |
key_mismatch | A public key that does not hash to the pinned vault id (keys.md#5). |
vault_mismatch | A descriptor, bundle, envelope or child key that names another vault than the one expected. |
token_mismatch | A bundle that names another token than its holder. |
generation_mismatch | A bundle, envelope or descriptor that names another generation than the one expected, or a descriptor that is not the generation after its predecessor. |
version_mismatch | An envelope that names another record version than the one expected. |
written_at_mismatch | An envelope that names another write time than the one expected. |
name_mismatch | A decrypted name that does not match the index it was listed under, or an envelope that names another record name. |
descriptor_mismatch | A key in a bundle whose public key is not the one the verified descriptor names. |
decrypt_failed | A ciphertext that does not open with the key given: an AEAD, a sealed box or an age file. |
chain_break | A hash link that does not hold: an audit row that is out of sequence, does not link to the row before it, does not hash to its own hash, or ends elsewhere than the server’s head; a descriptor that does not link to its predecessor’s hash. |
rollback | A server whose audit head is behind the head this client verified before (audit.md#5). |
fork | A server whose audit row at a position this client verified differs from the one it verified. |
unverifiable_newer_format | An audit row in a row format newer than this implementation knows. Not a verdict on the row: the rows before it stand as verified, and the rest are neither verified nor tampered (audit.md#4). |
unknown_value | An audit row, in a known row format, naming an action or actor kind this implementation does not know; the row cannot be hashed, so verification refuses (audit.md#4). |
bad_path | A string that is not a path (formats.md#3). |
bad_server | A server URL a client must not use: not https://, and not http:// to a loopback address. |
bad_proof | A proof-of-work nonce that does not give the challenge’s difficulty (hosted.md#2). |
expired | A proof-of-work challenge opened after its expiry time. |
5. Test vectors
The vectors in testdata/vectors/v1/ are one JSON file per construct. They
cover every deterministic construct in both directions (encodings,
derivations, indexes, signing inputs, Ed25519 signatures, descriptor bytes,
envelope plaintexts, audit hashes), and every randomised construction (age
files, sealed boxes, name ciphertexts) in the open direction: fixed
ciphertext and key in, the plaintext or the failure out. Negative cases name
their failure from §4.
5.1 File format
Each file is a JSON object, and testdata/vectors/schema.json is its JSON
Schema:
{
"suite": "galata-vault test vectors",
"protocol": 2,
"construct": "envelopes",
"spec": "records.md#3",
"generator": "scripts/vectors (independent Python: …)",
"cases": [
{
"id": "envelopes-023",
"description": "a secret opens and every binding matches",
"spec": "records.md#3.3",
"op": "open",
"inputs": { "…": "…" },
"expect": "success",
"outputs": { "…": "…" }
}
]
}
constructis the file’s name;specis the section the file covers.idis<construct>-<three digits>, unique in the file.specin a case is the section that case exercises.opis one of the construct’s operations (§5.2);inputsare its inputs.expectissuccessor a failure name from §4.outputsis REQUIRED whenexpectissuccess, and holds every output the operation produces. On a failure it is OPTIONAL, and holds what an implementation still reports (unverifiable_newer_formatreports the verified prefix).
An implementation passes a case when it meets exactly the expected failure
name, or succeeds with exactly the expected outputs. Byte strings are
lowercase hex, text is a JSON string, integers are JSON numbers, and a field
that is absent in the protocol is null.
5.2 Operations
Inputs and outputs of each construct’s operations. Every name below is a
field of inputs or outputs.
strings (formats.md#2)
| Op | Inputs | Outputs |
|---|---|---|
base62 | bytes | string: fixed-width base62 |
base62_decode | string, length (bytes) | bytes |
checksum | prefix, body | checksum: six base62 characters |
encode_key / decode_key | key / string | string / key |
encode_token / decode_token | token_id, vault_id, secret / string | string / token_id, vault_id, secret |
paths (formats.md#3)
| Op | Inputs | Outputs |
|---|---|---|
parse | path | segments, depth, project |
keys (keys.md)
| Op | Inputs | Outputs |
|---|---|---|
hkdf | ikm, label, data | info (the framed info), okm |
node | root_key, path | node_key, gvk2, owner_sign_seed, owner_sign_pub, owner_box_secret, owner_box_pub, vault_id |
vault_id | owner_sign_pub | vault_id |
token | token_id, vault_id, secret | token (the gvt1_ string), auth_seed, auth_pub, box_secret, box_pub |
name_keys | name_key | index_key, enc_key |
names (keys.md#8)
| Op | Inputs | Outputs |
|---|---|---|
index | name_key, kind (secret or config), name | index |
aad | vault_id, generation, kind | aad |
open | name_key, vault_id, generation, kind, index, name_ct | name |
descriptors (records.md#1)
| Op | Inputs | Outputs |
|---|---|---|
encode | owner_node_key, generation, vault_pub, config_pub, secret_writer_pub, config_writer_pub, prev_hash, created_at | vault_id, descriptor, hash, signing_input, sig |
verify | vault_id (pinned), owner_sign_pub, descriptor, sig | the descriptor’s fields: generation, vault_pub, config_pub, secret_writer_pub, config_writer_pub, prev_hash, created_at |
follows | prev, next (descriptor bytes) | none |
bundles (records.md#2)
| Op | Inputs | Outputs |
|---|---|---|
open_token | token_id, vault_id, token_secret, owner_sign_pub, scope, generation, sealed, sig, and optionally descriptor (generation and the four public keys) | kind, name_key, and each key the kind holds: vault_sk, config_sk, secret_writer, config_writer |
open_owner | owner_node_key, generation, sealed, sig | as open_token |
open_child | owner_node_key (the parent’s), sealed | node_key |
envelopes (records.md#3)
| Op | Inputs | Outputs |
|---|---|---|
encode | kind, format (null for a secret), vault_id, generation, version, written_at, name, body | plaintext |
decode | plaintext | kind, format, vault_id, generation, version, written_at, name, body |
open | open_as (secret or config), secret_key, value_ct, and the expected vault_id, generation, version, written_at, name | body, format |
records (records.md#4)
| Op | Inputs | Outputs |
|---|---|---|
sign | writer_seed, vault_id, generation, kind, name_index, version, written_at, name_ct, value_ct (null for a tombstone) | writer_pub, tombstone, value_ct_hash, name_ct_hash, signing_input, sig |
verify | writer_pub, the fields of sign except the seed, sig | none |
children (records.md#5)
| Op | Inputs | Outputs |
|---|---|---|
encode | children (entries) | plaintext (canonical JSON text) |
parse | plaintext | plaintext (canonical) |
sign | owner_node_key, version, ct | signing_input, sig |
open | owner_node_key, version, ct, sig | plaintext (canonical) |
kits (records.md#6)
| Op | Inputs | Outputs |
|---|---|---|
parse | text | kind, path, server, key (the node key’s bytes) |
signatures (signatures.md)
| Op | Inputs | Outputs |
|---|---|---|
request | signer (owner or token), node_key or token_id and token_secret, vault_id, method, path, body, ts, nonce, if_match, if_none_match | signing_input (text), sig, header |
header | header | actor, token, vault_id, ts, nonce, sig |
descriptor | owner_node_key, descriptor | signing_input, sig |
bundle | owner_node_key, token_id, scope_code, generation, sealed | signing_input, sig |
children | owner_node_key, version, ct | signing_input, sig |
report | token_id, vault_id, token_secret, ts | signing_input, sig |
verify | public_key, message, sig | none |
audit (audit.md)
| Op | Inputs | Outputs |
|---|---|---|
row_hash | row (as served) | encoding (the bytes hashed), hash |
verify | known (a head, or null), page (rows and head, as served) | head (the last verified head), verified (rows verified), unverifiable_from (seq of the first unverifiable row, or null) |
pow (hosted.md#2)
| Op | Inputs | Outputs |
|---|---|---|
issue | server_key, id, expires_at, difficulty | challenge |
open | challenge, server_key, now | id, expires_at, difficulty |
hash | challenge, nonce | hash, zero_bits |
verify | challenge, difficulty, nonce | none |
5.3 The generator
The vectors are generated by scripts/vectors/, an independent Python
implementation of the protocol that shares no code with the Rust crates.
-
What it uses. SHA-256, HMAC, CRC-32 and BLAKE2b from the Python standard library, with HKDF written out from RFC 5869; Ed25519, X25519 and ChaCha20-Poly1305 from
cryptography(OpenSSL); XChaCha20-Poly1305 and the sealed box from PyNaCl (libsodium). BLAKE3 and age v1 (the X25519 recipient and the STREAM payload) are written out from their specifications. It does not wrap the Rustagecrate. -
Checked before it generates. Before writing any file it decrypts every vector of the vendored C2SP CCTV age subset (
testdata/cctv/age/) and requires the outcome each one expects. A disagreement stops generation. The BLAKE3 implementation is checked against published values and against the audit hashes committed before it existed. -
Randomness. Every randomised construction takes its randomness (age file keys, ephemeral keys and nonces, sealed-box ephemeral keys, name nonces) from fixed seeds, so the files regenerate byte for byte.
-
Commands.
uv run --with cryptography --with pynacl scripts/vectors/generate.py # write the files uv run --with cryptography --with pynacl scripts/vectors/generate.py --check # regenerate and diffCI and
scripts/check-all.shrun--check, which fails, naming the file, if a committed file differs from what the generator produces.
5.4 Who runs them
Every vector runs in the Rust crate that owns its construct, and again, every file, through the Python wheel.
| Constructs | Runs in |
|---|---|
strings, paths, audit, pow | galata_vault::proto (crates/galata_vault::proto/tests/vectors.rs) |
keys, names, descriptors, bundles, records, signatures, children | galata_vault::keys (crates/galata_vault::keys/tests/vectors.rs) |
envelopes | galata_vault::seal (crates/galata_vault::seal/tests/vectors.rs) |
kits | galata-vault (crates/galata-vault/tests/vectors.rs) |
| every file | the Python wheel, through galata_vault._vectors (crates/gv-py/tests/test_vectors.py) |
- Signing inputs are checked in the crate that holds the signing key, so each signature case also reproduces its deterministic Ed25519 signature (RFC 8032) byte for byte.
- The runners are compiled only with the cargo feature
vectors. They are pure: inputs in, outputs out, no key kept between calls, no I/O. The wheel’sgalata_vault._vectorsis private and unsupported; it exists so the wheel that ships is the build that is tested. - The vector tests read
testdata/at the repository root, so they run in the workspace only, and are excluded from the crates’ packages.
6. Conformance suite
crates/gv-conformance (publish = false) checks a server’s behaviour
against this specification as a black box.
- Targets.
gv-conformance --server <url>runs over HTTP;gv-conformance --embedded <dir>runs through the SDK’s in-process backend on a data directory (a build with the crate’sembeddedfeature, asscripts/conformance.shmakes). - Safety. It refuses a URL whose host is not a loopback address unless
given
--allow-remote, before sending any request, because it consumes quota and proof of work. It creates its own vaults from random keys and deletes them afterwards. It reads the proof-of-work difficulty from the server’s capabilities. - Output. One line per case: its id, its anchor, then
pass,FAILorskip, with the case’s title and what it found (forFAIL, the reason; forskip, why it does not apply). Then a summary. It exits 1 if any case fails, and 2 on a usage error or a refused target.
6.1 Cases
| Id | Anchor | Checks |
|---|---|---|
| C01 | http-api.md#6 | The capabilities: unauthenticated, JSON, the same for every caller, listing protocol "1" |
| C02 | protocol.md#2 | The vault lifecycle: creation, status, deletion, and the uniform 401 after it |
| C03 | http-api.md#4.2 | A write without a precondition is 428 precondition_required |
| C04 | http-api.md#4.2 | A stale If-Match, and If-None-Match: * on an existing record, are 412 precondition_failed, and nothing is stored |
| C05 | protocol.md#6 | A signed version other than the one the server assigns is 409 version_mismatch |
| C06 | protocol.md#7 | History and tombstones: every retained version listed, an earlier version readable, the latest a 404 after a delete |
| C07 | protocol.md#7 | Listing and pagination: metadata only, the tombstone flag, a cursor |
| C08 | http-api.md#2 | Server-side scope policy, for every scope |
| C09 | http-api.md#6 | Quotas as advertised: a value over max_value_bytes is refused with value_too_large |
| C10 | http-api.md#3 | Uniform 401: a bearer credential, a bad signature, an unknown token and an unknown vault get the same answer |
| C11 | http-api.md#4.4 | Responses a browser will not render: JSON, nosniff, default-src 'none', no-store, even for Accept: text/html (HTTP only) |
| C12 | http-api.md#5 | Error bodies are exactly {error, message}, with a documented code and its status |
| C13 | http-api.md#7.1 | A request body with an unknown field is refused with invalid_request, and nothing changes |
| C14 | http-api.md#1 | Unknown protocol versions in the path (/v0, /v2) are 404 |
| C15 | http-api.md#4.1 | A credential in the query string is refused with 400 credential_in_query (HTTP only) |
| C16 | protocol.md#8 | A rotation that revokes a token: the new generation verifies, the revoked token is refused |
| C17 | records.md#5 | The children record is owner-only |
The embedded target has no HTTP layer, so it reports C11 and C15 as skip,
not applicable; every other case runs on both targets.
7. Precedence
- The specification is the source of truth. Where the code and this specification disagree, one of them has a defect. It SHALL be resolved by a change that updates both.
- Bytes change only under a new marker. Any change to bytes a vector covers MUST come with a new version marker for the affected format (stability.md#2); an existing format is never changed silently.
- Enforcement does not outrank. The vectors and the conformance suite enforce this specification; neither outranks it. A vector that disagrees with the text is itself a defect.
These checks turn a drift between the text and the code into a failing build:
| Check | Holds |
|---|---|
scripts/check-spec-labels.sh | The label registry (keys.md#3) is exactly the set of labels and contexts in the protocol, keys and seal crates, in both directions |
crates/galata_vault::proto/tests/spec_tables.rs | The error-code table (http-api.md#5) is exactly ErrorCode, with each code’s status and retry class; every anchor a vector cites exists |
crates/galata_vault::server_core/tests/route_table.rs | The endpoint table (http-api.md#2) is exactly the server’s route table, by method, path and authentication scheme |
scripts/check-response-types.sh | In galata_vault::proto, only request bodies, the audit row, and types that never cross the wire as a response (the children record’s plaintext, the MCP’s configuration) refuse unknown fields (http-api.md#7) |
| The vector regenerate-and-diff (§5.3) | The committed vectors are what the independent generator produces |
| The vector tests (§5.4) | The Rust crates and the Python wheel agree with every vector |
| The conformance suite (§6) | A server built from this repository behaves as specified, over HTTP and embedded |
8. Coverage
Every normative rule in these documents is enforced by a vector, a conformance case or a guard, except the rules below, which are checked by review.
Rules marked (unit test) or (adversarial test) are exercised by a crate’s own tests or by the malicious-server harness; this list does not count those as enforcement.
formats.md
- #1.1–#1.4: integer and time encodings; exact hex lengths (upper case MAY be accepted); unpadded base64url that decodes to an exact length; the general rule that hashed or signed formats refuse unknown fields (the children and audit parts have vectors).
- #2.2: the checksum catches every single-character substitution (unit test).
- #2.3–#2.5: the scanner patterns (MAY); outer whitespace MAY be trimmed, and inner whitespace MUST NOT be accepted.
- #2.6: a refusal of another version digit SHOULD name the digit and MUST NOT quote the string.
- #3: a path MUST NOT be sent to a server.
keys.md
- #1: one purpose per label, and fixed-width fields except at most the last. The label guard checks which labels exist, not how they are framed.
- #2, #4, #6, #7: key material is random or derived from random 256-bit material; a rekeyed node MAY get a fresh random key; nothing on the wire or in storage derives a token’s box secret (adversarial test).
- #3: a new label lands in the same change as the code that uses it. The guard checks the set, not the timing.
- #8.3: a fresh random nonce for every name encryption.
records.md
- #1.3: the server refuses a creation descriptor that is
not generation 1 with a zero
prev_hash, and a rotation descriptor that does not follow the current one. - #1.4: no key is used before the descriptor verifies.
- #2.4: the server verifies a bundle’s owner signature before storing it.
- #2.5: the order of checks when an input has several faults.
- #3.2: exactly one binary age recipient, named by a verified descriptor.
- #3.3, #4: a record’s signature verifies
before its value is used; the server’s
stale_generationand signature refusals; a tombstone flag that disagrees with the value is an integrity failure (adversarial test). - #5.2: the server refuses an owner write of the children record whose signature does not verify (C17 checks only that tokens are refused).
- #6: kit files are written with mode 0600, and error messages never quote a kit.
signatures.md
- #1: a signature with a small-order
Ris refused (the non-canonicals, identity-key and non-point cases have vectors). - #2.1: a fresh random nonce for every request.
- #2.3: the server’s order of checks; a nonce is spent
only after the signature verifies, and is kept until
ts+ 600 s; vault expiry is checked before the nonce;token_expiredonly after proof of possession. - #7: the server’s handling of a leak report (its signing input has vectors).
audit.md
- #1: which operations append a row, in the same transaction as the action.
- #3: action codes are never reused or renumbered.
- #5: the stored head moves only when verification succeeds, and only to the last verified row (unit and adversarial tests); the 1000-row page limit.
protocol.md
- #2: a client refuses a server without protocol
"1"before sending anything authenticated (unit test); proof of work only when the capabilities ask; the empty children record written underIf-None-Match: *. - #3: the server’s mint refusals, and the TTL defaults and maximums.
- #4.1, #4.2: the client’s opening
checks and their order, and
unsupported_by_clientfor an unknown scope (unit and adversarial tests). - #5, #6: the client’s read checks; no
blind retry after a failed precondition; the server’s
not_age_ciphertext,stale_generationandbad_signaturerefusals. - #8: the client’s rotation steps,
incomplete_rotation, and a body limit that fits the largest batch. - #9, #11, #12: the rekey steps; journaling inside the transaction, and a 503 when the journal fails; every pin rule (adversarial test).
http-api.md
- #1: a known path with another method is 400; an
oversized body is 413
value_too_large; what/healthzand/readyzanswer. - #3.1–#3.3: the nonce is spent only
after the signature and skew verify;
token_expiredonly after proof of possession; a refused scope appends arefusedaudit row. - #4.2: both precondition headers at once, or an
If-None-Matchother than*, isinvalid_request; a quoted or weakIf-Matchis accepted. - #4.5, #4.6:
X-GV-Expires-AtandRetry-After(galata_vault::server tests). - #5.1: codes are only ever added. The error-table test catches a removal or a rename, not a reuse.
- #6.1, #6.3:
serverabsent by default, a missingprotocolsread as["1"], a 404 read as an older server, and the document fetched once per handle (unit tests); ancestors kept alive only when expiry is announced. - #7.1: a new request field is sent only when advertised (no such field exists yet).
- #7.3: which operations refuse with
unsupported_by_client, the status-class kind of an unknown code, and the report of unverifiable audit rows (unit tests).
hosted.md (no server in this repository implements the appendix, so nothing here is covered by a server test)
- #2: with
proof_of_worknull,/v1/challengesis 404 (galata_vault::server tests); the challenge encoding, difficulty and expiry have vectors, and the SDK’s own admission exercises solving one (galata-vault tests). - #3: that no answer carries
X-GV-Expires-Atand no vault expires (galata_vault::server tests); the client’s handling of a server that announces an expiry (galata_vault::cli tests). - #4: not covered. No server here limits a request, and client addresses reach no database, log or metric (galata_vault::server tests).
stability.md
- #2: a byte change comes with a new marker. The regenerate-and-diff catches the byte change, not a missing marker.
- #3–#5: the draft rule, the 1.x read guarantees, dropping a read only in a major release, and the changelog rule.
Formats
Status: draft (docs/spec/README.md#1).
How values are encoded, the checksummed strings that carry node keys and tokens, and paths. Conventions and notation are in README.md#3.
1. Encodings
1.1 Integers
Fixed-width integers inside byte encodings are big-endian: u8, u16,
u32, u64, and i64 in two’s complement. The one exception is the
proof-of-work nonce, which is hashed little-endian (hosted.md#2).
Times are i64 Unix seconds. In JSON, integers are numbers.
1.2 Hex
Fixed-size identifiers (vault ids, token ids, name indexes, hashes) travel in
JSON as lowercase hexadecimal of their exact length: 32 characters for a
16-byte id, 64 for a 32-byte index or hash. A reader MUST refuse any other
length or any character outside 0-9a-f; it MAY accept upper case.
1.3 base64url
Public keys, signatures, ciphertexts and sealed bundles travel in JSON as base64url (RFC 4648 §5) without padding. A reader MUST refuse padding and characters outside the alphabet. A 32-byte key or a 64-byte signature MUST decode to exactly that length.
1.4 JSON
Every HTTP body and the children record’s plaintext are JSON (RFC 8259) in UTF-8. Field names are exactly as these documents give them. Which bodies refuse a field they do not know, and which ignore it, is set by http-api.md#7; formats that are hashed or signed (the audit row, the children record) refuse unknown fields.
2. Base62 and checksummed strings
A node key and a token are shown to people, pasted and scanned for, so they
travel as a prefix, a fixed-width base62 body and a checksum. The body is
alphanumeric and the prefix ends in _, so a double-click selects the
whole string.
Vectors: testdata/vectors/v1/strings.json.
2.1 Alphabet and fixed width
- Alphabet:
0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz, in that order:0is 0,Ais 10,ais 36. - Width:
nbytes are encoded in exactlywidth(n)characters, the smallestwwith 62^w ≥ 256^n: 6 for 4 bytes, 43 for 32, 86 for 64, and 0 for none. - Encoding: the bytes are read as one big-endian unsigned integer and
written in base62, most significant digit first, left-padded with
0to the width. - Decoding: a decoder MUST refuse a string of any other width
(
truncatedif it is shorter,bad_lengthif longer), a character outside the alphabet (bad_encoding), and a value of 256^n or more, which the width can hold butnbytes cannot (bad_encoding).
2.2 Checksum
The checksum is CRC-32 (the IEEE polynomial, as computed by zlib’s crc32)
over the ASCII text prefix ‖ body, taken as a 4-byte big-endian value and
encoded in base62 as six characters (§2.1).
A one-character typo changes one byte of that text, an error burst of at most 8 bits, and CRC-32 detects every burst of 32 bits or fewer, so every single-character substitution is caught before any request or storage lookup. The checksum detects accidents. It is not a security property: anyone can compute it.
2.3 Node keys: gvk1_
gvk1_ ‖ base62(node key, 32 bytes: 43 characters) ‖ checksum (6 characters)
49 characters after the prefix, 54 in all. A node key is a project key or
any key derived beneath one (keys.md#4). Secret scanners MAY
match gvk1_[0-9A-Za-z]{49}; a match is only a candidate until its checksum
verifies.
2.4 Tokens: gvt1_
gvt1_ ‖ base62(token_id(16) ‖ vault_id(16) ‖ token_secret(32): 86 characters) ‖ checksum (6 characters)
92 characters after the prefix, 97 in all. The token carries the vault id so
that its holder can pin its vault before the first request
(protocol.md#4). Scanners MAY match
gvt1_[0-9A-Za-z]{92}, subject to the checksum.
2.5 Parsing
A parser MUST check, in this order, and stop at the first failure:
- Version. A string of the expected kind under another version digit
(
gvk2_…wheregvk1_…is expected) is refused asunknown_version(§2.6). - Prefix. A string that does not begin with the expected prefix is
refused as
kind_mismatch, whether it is the other kind’s string or has no prefix at all. - Length. The rest MUST be ASCII and exactly
width(n) + 6characters: shorter istruncated, longer isbad_length. - Checksum characters. A checksum character outside the alphabet is
bad_encoding. - Checksum. A checksum that does not match
prefix ‖ bodyischecksum_mismatch. - Body. The body decodes as in §2.1: a character outside the
alphabet, or a value too large for
nbytes, isbad_encoding. (With a checksum that matches, only a string built to be wrong reaches this step.)
The checksum is checked before the body is decoded, so a mistyped string is reported as mistyped. An implementation MAY trim surrounding whitespace before parsing, as a convenience; it MUST NOT accept whitespace inside the string.
2.6 Other version digits
The digit in a prefix is the string format’s version. A string of the
expected kind under a digit this implementation does not know MUST be
refused as unknown_version by its prefix and MUST NOT be reinterpreted.
The refusal SHOULD name the digit it found and MUST NOT quote the string.
3. Paths
A path names a node of a project’s key tree: acme, acme/prod,
acme/prod/eu.
- A path is one to four segments joined by
/. The first segment names the project. - A segment matches
[a-z0-9][a-z0-9-]{0,62}: lower-case ASCII letters, digits and-, not starting with-, at most 63 characters. - Anything else is refused as
bad_path: an empty path, an empty segment (a leading, trailing or doubled/), an upper-case letter, any other character, a segment of 64 characters or more, or more than four segments. - A path is a derivation input and a display name. A client MUST NOT send it to a server: nothing on a server names a project, a path or a node.
The rules are narrow on purpose, so that Prod and prod cannot become two
different vaults.
Vectors: testdata/vectors/v1/paths.json.
Keys
Status: draft (docs/spec/README.md#1).
How every key is derived, and the registry of every label that separates one derivation, hash or signature from another. Conventions and notation are in README.md#3.
Vectors: testdata/vectors/v1/keys.json and names.json.
1. Framing
Every HKDF info, every hashed input and every signed input of the protocol
is framed:
frame(label, field₀, field₁, …) = label ‖ 0x00 ‖ field₀ ‖ field₁ ‖ …
- Every label begins
gv/v1/and contains no0x00byte. - Each label has exactly one purpose (§3); a label is never reused for another derivation, hash or signature.
- Fields are fixed-width, except at most the last, so no two framed inputs
of the same label can be read two ways. The
0x00keeps a label from running into its first field.
The request signature’s input is a text form, not a frame: its first line
is the label gv/v1/sig (signatures.md#2).
2. HKDF
Every key derivation is HKDF-SHA256 (RFC 5869) with:
- salt: the 15 ASCII bytes
galata-vault/v1; - info:
frame(label, data)(§1), wheredatais empty unless a section says otherwise; - output: 32 bytes.
derive(ikm, label, data) = HKDF-SHA256(salt = "galata-vault/v1", ikm, info = label ‖ 0x00 ‖ data, L = 32)
The input keying material is always 256 bits from a cryptographically secure random source (a project key, a token secret, a generation’s name key) or a key derived from one. HKDF’s extraction is sound for such input; a password-hardening function would add nothing, and none is used.
3. Label registry
Every label and context string the protocol, key and seal crates use. The
guard scripts/check-spec-labels.sh extracts these strings from
crates/galata_vault::proto, crates/galata_vault::keys and crates/galata_vault::seal and fails if the
code uses one this table does not list, or this table lists one the code no
longer uses.
| Label | Kind | Framed inputs | Output and use |
|---|---|---|---|
gv/v1/child | HKDF info | the child’s segment (ASCII) | A child node key from its parent’s (§4) |
gv/v1/owner-sign | HKDF info | none | A node’s owner Ed25519 seed (§5) |
gv/v1/owner-box | HKDF info | none | A node’s owner X25519 secret (§5) |
gv/v1/token-auth | HKDF info | token_id(16) | A token’s request-signing Ed25519 seed (§6) |
gv/v1/token-box | HKDF info | token_id(16) | A token’s bundle-opening X25519 secret (§6) |
gv/v1/name-index | HKDF info | none | The HMAC key for name indexes, from the name key (§8.1) |
gv/v1/name-enc | HKDF info | none | The XChaCha20-Poly1305 key for names, from the name key (§8.3) |
gv/v1/name | hashed input (AEAD associated data) | vault_id(16) ‖ generation(u32) ‖ kind(u8) | A name ciphertext’s associated data (§8.2) |
gv/v1/vault-id | hashed input | owner_sign_pub(32) | SHA-256; its first 16 bytes are the vault id (§5) |
gv/v1/descriptor | signing context | the descriptor (189 bytes) | The owner’s signature over a generation descriptor (records.md#1.2) |
gv/v1/bundle | signing context | vault_id ‖ token_id ‖ scope(u8) ‖ generation(u32) ‖ SHA-256(sealed) | The owner’s signature over a sealed bundle (signatures.md#4) |
gv/v1/record | signing context | vault_id ‖ generation ‖ kind ‖ index ‖ version ‖ written_at ‖ tombstone ‖ SHA-256(value_ct) ‖ SHA-256(name_ct) | A writer key’s signature over a record (records.md#4) |
gv/v1/children | signing context | vault_id ‖ version(u64) ‖ SHA-256(ct) | The owner’s signature over the children record (records.md#5) |
gv/v1/report | signing context | token_id(16) ‖ ts(i64) | A token-auth signature reporting the token as leaked (signatures.md#7) |
gv/v1/sig | text first line | the rest of the request signing text | The request signature’s input (signatures.md#2) |
galata-vault/v1 | HKDF salt | not framed | The salt of every derivation above (§2) |
galata-vault v1 audit row | BLAKE3 derive-key context | not framed | An audit row’s hash, in every row format (audit.md#2) |
galata-vault v1 proof-of-work | BLAKE3 derive-key context | not framed | The proof-of-work hash (hosted.md#2) |
A new derivation, hash or signature MUST get a new label, added to this table in the same change as the code that uses it.
The labels age v1 uses internally (age-encryption.org/v1/X25519, and the
HKDF infos header and payload) belong to age and are specified by it
(https://age-encryption.org/v1); they are not part of this registry.
4. Key tree
-
A project key is 32 bytes from a cryptographically secure random source. It is the root of the project’s tree and the node key of its project vault.
-
A child node key is derived from its parent’s:
K(path/segment) = derive(K(path), "gv/v1/child", segment)where
segmentis the child’s segment as ASCII bytes (formats.md#3). A node atacme/prod/euis reached fromacmeby derivingprod, theneu. -
Derivation is one-way: holding a node key gives every key beneath it and nothing above or beside it.
-
A node key MAY also be replaced by a fresh random key when a rekey re-roots the node (protocol.md#9); its parent then records it sealed (records.md#5).
Every node is exactly one vault.
5. Owner keys and the vault id
From a node key K:
owner_sign_seed = derive(K, "gv/v1/owner-sign") an Ed25519 seed (RFC 8032)
owner_box_secret = derive(K, "gv/v1/owner-box") an X25519 secret (RFC 7748)
vault_id = SHA-256(frame("gv/v1/vault-id", owner_sign_pub))[0..16]
owner_sign_pubis the Ed25519 public key of the seed, andowner_box_pubthe X25519 public key of the secret, computed with the standard scalar clamping.- The owner signing key signs requests, descriptors, bundles and the children record; the owner box key opens the owner’s bundle, sealed child keys and the children record.
- The vault id is bound to the owner key. Only the holder of the owner
signing key can sign a creation request for its id
(protocol.md#2), so nobody can claim another’s vault id,
even after the vault is deleted. A client that pins the id can check any
owner key a server presents against it, and MUST refuse one that does not
hash to it (
key_mismatch).
6. Token keys
A token is minted on the owner’s client: a random 16-byte token_id and a
random 32-byte token_secret (formats.md#2.4). From them:
token_auth_seed = derive(token_secret, "gv/v1/token-auth", token_id) Ed25519: signs requests
token_box_secret = derive(token_secret, "gv/v1/token-box", token_id) X25519: opens the bundle
- The two keys are independent, and both are bound to the token id.
- The server stores only
token_id,auth_pubandbox_pub(http-api.md#2). A token authenticates by signing requests with its token-auth key; the token string is never sent. - Nothing a client sends, and nothing a server stores, derives
token_box_secret: a request log, a TLS terminator or a database copy holds nothing that opens a token’s bundle.
7. Generation keys
A vault’s keys change at every rotation; each set is a generation, numbered from 1. Every generation has five keys, each 32 bytes from a cryptographically secure random source:
vault_sk, an X25519 secret: opens secret values (the age recipientvault_pub);config_sk, an X25519 secret: opens config documents (the age recipientconfig_pub);name_key, 32 bytes: indexes and encrypts record names (§8);secret_writer, an Ed25519 seed: signs secret records;config_writer, an Ed25519 seed: signs config records.
The owner names the four public keys in the generation’s descriptor (records.md#1) and hands the private keys out in bundles, each holding exactly what its scope allows (records.md#2). The name key has no public half; a bundle that holds it is checked by the records it indexes.
8. Name key
Record names are never sent in plaintext. The server indexes a record by an HMAC of its name, and holds its name encrypted for display.
8.1 Index
index_key = derive(name_key, "gv/v1/name-index")
index = HMAC-SHA256(index_key, kind(u8) ‖ name)
kindis 1 for a secret and 2 for a config, so one name can be both without their indexes colliding.nameis the name’s UTF-8 bytes.- The name key itself is never used as an HMAC key: both uses go through HKDF.
- The index travels as 64 hexadecimal characters. It is stable within a generation and changes at rotation.
8.2 Associated data
aad = frame("gv/v1/name", vault_id(16) ‖ generation(u32) ‖ kind(u8))
It binds a name ciphertext to its vault, generation and kind, so a ciphertext cannot be moved between them.
8.3 Name ciphertext
enc_key = derive(name_key, "gv/v1/name-enc")
name_ct = nonce(24) ‖ XChaCha20-Poly1305(enc_key, nonce, name, aad)
- The nonce is 24 bytes from a cryptographically secure random source, new for every encryption.
- A name is at most 256 bytes of UTF-8 (records.md#3).
- To open a name, a client MUST decrypt it with the associated data of the
vault, generation and kind it expects, and MUST then recompute the index
of the decrypted name and compare it with the index the record was listed
under. A ciphertext shorter than its nonce, one that does not
authenticate, or a plaintext that is not UTF-8 is
decrypt_failed; a name whose index differs isname_mismatch, which catches a server that swaps two records’ names.
Name ciphertexts are randomised, so their vectors are in the open direction
(testdata/vectors/v1/names.json).
Records
Status: draft (docs/spec/README.md#1).
This document defines the byte formats a vault stores and a client verifies:
the generation descriptor, bundles, the record envelope, record signatures,
the children record, and kits. Notation: ‖ is concatenation; integers are
big-endian (u8, u16, u32, u64, i64); base64url is unpadded;
frame(label, …) is label ‖ 0x00 ‖ … (keys.md#1). The failure names in
parentheses are those of the registry (README.md#4); an implementation maps
its own errors onto them.
1. Generation descriptor
Every vault generation has five keys: vault_sk and config_sk (X25519),
name_key (32 bytes), and secret_writer and config_writer (Ed25519
seeds) (keys.md#7). The descriptor is the owner’s signed statement of their
public halves. Vectors: testdata/vectors/v1/descriptors.json.
1.1 Encoding
A descriptor is exactly 189 bytes:
| Offset | Length | Field | Meaning |
|---|---|---|---|
| 0 | 1 | v | the descriptor version, 1 |
| 1 | 16 | vault_id | the vault (keys.md#5) |
| 17 | 4 | generation | u32, 1 for a new vault |
| 21 | 32 | vault_pub | X25519; secrets are encrypted to it |
| 53 | 32 | config_pub | X25519; configs are encrypted to it |
| 85 | 32 | secret_writer_pub | Ed25519; verifies secret records |
| 117 | 32 | config_writer_pub | Ed25519; verifies config records |
| 149 | 32 | prev_hash | SHA-256 of the previous generation’s 189 bytes |
| 181 | 8 | created_at | i64, Unix seconds |
On the wire a descriptor travels as {"descriptor": b64url(189 bytes), "sig": b64url(64 bytes)}.
A reader MUST check the version byte before the length: a descriptor whose
first byte is not 1 is refused as unknown_version whatever its length, and
a descriptor of version 1 that is not 189 bytes long is refused as
bad_length.
1.2 Signature
The owner signing key (keys.md#5) signs
frame("gv/v1/descriptor", descriptor), the 189 bytes prefixed by the label
and 0x00 (signatures.md#3). No other key’s signature is valid.
1.3 Chain
prev_hash is SHA-256 of the previous generation’s 189 bytes, and 32 zero
bytes at generation 1. A descriptor next follows prev exactly when all
three hold, checked in this order:
- both name the same vault (else
vault_mismatch); next.generationisprev.generation + 1(elsegeneration_mismatch);next.prev_hashis SHA-256 ofprev(elsechain_break).
A server MUST refuse a rotation whose new descriptor does not follow the
current one, and a vault creation whose descriptor is not generation 1 with a
zero prev_hash (protocol.md#2, protocol.md#8). Two different descriptors
can both follow the same predecessor: a fork is caught by client pins
(protocol.md#12), not by the chain.
1.4 Verification from a pinned vault id
A client verifies a descriptor against a vault id it pinned itself (an owner derives it from its node key; a token holder reads it from its token string) and an owner signing public key the server presented. In this order:
- the owner signing key MUST hash to the pinned vault id (keys.md#5), else
key_mismatch; - the descriptor MUST decode (§1.1):
unknown_version,bad_length; - the owner’s signature (§1.2) MUST verify, else
bad_signature; - the descriptor MUST name the pinned vault id, else
vault_mismatch.
Only then MAY the client encrypt to vault_pub or config_pub, or accept a
record signed by a writer key the descriptor names. A client that already
verified a generation also applies its pins (protocol.md#12).
2. Bundles
A bundle carries the keys one holder may use, sealed to that holder’s X25519
key and signed by the owner. Vectors: testdata/vectors/v1/bundles.json
(open direction: sealed boxes are randomised).
2.1 Plaintext
plaintext = version(1) = 2 ‖ kind(1) ‖ vault_id(16) ‖ token_id(16) ‖ generation(u32) ‖ body
The header is 38 bytes. The owner’s own bundle uses a token id of 16 zero bytes. The body is the kind’s keys, 32 bytes each, in the order §2.2 lists them.
2.2 Kinds
| Kind | Name | Body | Plaintext length | Held by |
|---|---|---|---|---|
| 1 | names | name_key | 70 | meta |
| 2 | append | name_key ‖ secret_writer ‖ config_writer | 134 | append |
| 3 | config | name_key ‖ config_sk | 102 | config |
| 4 | config-write | name_key ‖ config_sk ‖ config_writer | 134 | config-write |
| 5 | read | name_key ‖ vault_sk ‖ config_sk | 134 | read |
| 6 | full | name_key ‖ vault_sk ‖ config_sk ‖ secret_writer ‖ config_writer | 198 | the owner, admin |
| 7 | child | node_key | 70 | a sealed entry of the children record (§2.6) |
A scope’s bundle MUST be exactly the kind this table gives it. Each kind has
one length: a plaintext of a known kind with any other body length is
refused (bad_length), and a kind this client does not know is refused
(unknown_kind).
The scope a bundle signature binds is a byte: meta 1, append 2, read
3, admin 4, config 5, config-write 6. The owner’s own bundle uses scope
code 0 and the zero token id.
2.3 Sealing
A bundle is sealed with libsodium’s crypto_box_seal: a fresh ephemeral
X25519 key pair (esk, epk), the nonce BLAKE2b-24(epk ‖ recipient_pk), and
crypto_box (X25519, XSalsa20-Poly1305), sent as epk ‖ box. The seal adds
48 bytes. A token’s bundle is sealed to its token_box public key
(keys.md#6); the owner’s bundle and child keys to the owner box public key
(keys.md#5).
2.4 Owner signature
The owner signing key signs:
frame("gv/v1/bundle", vault_id(16) ‖ token_id(16) ‖ scope(u8) ‖ generation(u32) ‖ SHA-256(sealed))
where sealed is the whole sealed box as it travels. On the wire a bundle is
{"sealed": b64url, "sig": b64url}. The server verifies this signature
before it stores a bundle, against the token’s registered scope (protocol.md#3,
protocol.md#8); only the holder can open the seal.
2.5 Opening
A token holder opens its bundle in this order, refusing at the first failure:
- the presented owner signing key MUST hash to the vault id in the token
string (
key_mismatch); - the owner’s signature (§2.4) MUST verify for this vault, this token id,
the token’s scope code and the expected generation (
bad_signature); - the seal MUST open with the token’s box key (
decrypt_failed); - the version byte MUST be 1 (
unknown_version), checked before the header length; the plaintext MUST hold a whole header (truncated); - a child key (kind 7) is not a token or owner bundle (
kind_mismatch); - the plaintext MUST name the same vault id, token id and generation
(
vault_mismatch,token_mismatch,generation_mismatch); - the kind MUST be known and the body its exact length (
unknown_kind,bad_length); - the kind MUST be the one its scope gets (
kind_mismatch); - every key it holds MUST have the public half the descriptor names
(
descriptor_mismatch).
The owner opens its own bundle the same way, with its own box key, the zero
token id and scope code 0, and MUST refuse any kind other than full
(kind_mismatch). A bundle whose keys have not passed step 9 MUST NOT be
used to encrypt, decrypt, sign or verify.
2.6 Child keys
When a rekey re-roots a child (protocol.md#9), its random node key is sealed
(§2.3) to the parent’s owner box key as a kind 7 plaintext naming the
parent’s vault id, the zero token id and generation 0, with the 32-byte node
key as its body. It is not signed on its own: it travels as key_ct inside
the owner-signed children record (§5). The parent’s owner opens it with the
checks of §2.5 steps 3 to 7: the parent’s vault id, the zero token id,
generation 0, kind 7 (any other kind is kind_mismatch), a 32-byte body
(bad_length).
3. Record envelope v3
Secret values and config documents are an envelope, encrypted with age.
Vectors: testdata/vectors/v1/envelopes.json (encoding both ways; age
ciphertexts in the open direction).
3.1 Plaintext
secret = 3 ‖ kind(1) = 1 ‖ vault_id(16) ‖ generation(u32) ‖ version(u64)
‖ written_at(i64) ‖ name_len(u16) ‖ name ‖ value
config = 3 ‖ kind(1) = 2 ‖ format(1) ‖ vault_id(16) ‖ generation(u32) ‖ version(u64)
‖ written_at(i64) ‖ name_len(u16) ‖ name ‖ body
| Field | Length | Meaning |
|---|---|---|
| version | 1 | 1 |
| kind | 1 | 1 secret, 2 config |
| format | 1 | configs only: 1 toml, 2 json, 3 yaml, 4 text |
| vault_id | 16 | the vault |
| generation | 4 | the generation whose key encrypted it |
| version | 8 | the record version the writer signed |
| written_at | 8 | the write time the writer signed |
| name_len | 2 | the name’s length in bytes, at most 256 |
| name | name_len | the record name, UTF-8 |
| value / body | the rest | exactly the bytes written |
A reader decodes in this order: the version byte (unknown_version), the
kind (unknown_kind), for a config the format (unknown_format), the fixed
fields (truncated), the name length, which MUST NOT exceed 256
(bad_length), the name itself (truncated if it runs past the end), and
its UTF-8 (bad_encoding). A missing byte anywhere before the body is
truncated. The body is never interpreted.
3.2 Encryption
The envelope is encrypted with age v1 (https://age-encryption.org/v1) in
the binary format (not armored), to exactly one X25519 recipient: the
descriptor’s vault_pub for a secret, config_pub for a config. The raw
32-byte X25519 keys are age’s keys: an implementation that needs age’s
Bech32 forms encodes the public key with HRP age and the secret key with
HRP AGE-SECRET-KEY- (upper case). A writer MUST encrypt only to a key a
verified descriptor names (§1.4). The server refuses a value that does not
begin with age-encryption.org/v1\n (not_age_ciphertext, http-api.md#5)
and can do nothing else with it.
3.3 Opening and bindings
A reader opens a value in this order:
- it MUST begin with the age v1 header line (
unknown_version); - it MUST decrypt with the generation’s
vault_skorconfig_sk(decrypt_failed); - the plaintext MUST decode (§3.1);
- then each binding MUST equal what the record signature binds and the
server reported, in this order: the kind (
kind_mismatch), the vault id (vault_mismatch), the generation (generation_mismatch), the version (version_mismatch), the write time (written_at_mismatch) and the name (name_mismatch).
So a ciphertext cannot be moved between vaults, generations, versions, names or kinds, even by a server that holds every other part of a genuine record. The record signature (§4) MUST have verified before the value is used.
4. Record signatures
Every secret and config record, tombstones included, carries an Ed25519 signature by the generation’s writer key for its kind, over:
frame("gv/v1/record", vault_id(16) ‖ generation(u32) ‖ kind(u8) ‖ name_index(32)
‖ version(u64) ‖ written_at(i64) ‖ tombstone(u8) ‖ SHA-256(value_ct) ‖ SHA-256(name_ct))
kindis 1 for a secret and 2 for a config;name_indexis the record’s index (keys.md#8.1);name_ctis the name ciphertext (keys.md#8.3) andvalue_ctthe age ciphertext.- A tombstone has no value:
tombstoneis 1 and it signs SHA-256 of the empty string. A value hastombstone0. - Secret records verify only against the descriptor’s
secret_writer_pub, config records only againstconfig_writer_pub. The writer keys are held as §2.2 gives them: the owner andadminhold both,appendholds both,config-writeholds only the config writer, andmeta,readandconfighold none. versionis the version the writer intends to create: 1 underIf-None-Match: *for a new name,v + 1underIf-Match: v, and the next version after a tombstone whenIf-None-Match: *revives it (protocol.md#6).
The server MUST verify every record signature against the current
descriptor before it stores the record (bad_signature), and MUST refuse a
record for any generation but the current one (stale_generation). Every
client MUST verify the signature before it trusts a record’s metadata or
contents, and MUST refuse a record whose signed version differs from the
version the server reports for it. Listings carry value_ct_hash, and
version histories carry value_ct_hash and name_ct_hash, so a holder that
cannot decrypt (meta, append, config) can still verify the names,
versions and tombstones it relies on. A client SHALL treat a tombstone flag
that disagrees with the presence of a value as an integrity failure. Vectors:
testdata/vectors/v1/records.json.
5. Children record
A node’s children record says how to reach each child’s key. It is
owner-only: sealed to the owner, signed by the owner, and served at its own
endpoint (GET and PUT /v1/vault/children, http-api.md#2). Vectors:
testdata/vectors/v1/children.json.
5.1 Plaintext
The plaintext is compact JSON with fields in this order:
{"v":2,"children":[{"seg":"dev","created_at":1757500100,"mode":{"kind":"derived"}},
{"seg":"eu","created_at":1757500300,"mode":{"kind":"sealed","key_ct":"<b64url>"}}]}
vis the record version, 2. A reader MUST refuse any other (unknown_version).childrenis sorted by segment (formats.md#3); the canonical form a writer produces is compact, fields in the order above. A reader MUST accept any order and MUST refuse a segment listed twice, an invalid segment, an unknown field or an unknown mode (bad_encoding).modeis{"kind":"derived"}(the child key is derived from this node’s key, keys.md#4) or{"kind":"sealed","key_ct":<b64url>}, wherekey_ctis a kind 7 box (§2.6).
5.2 Sealing and signature
The plaintext is sealed (§2.3) to the node’s owner box public key. It is
stored and served as {"version": u64, "ct": b64url, "sig": b64url}, where
sig is the owner’s signature over:
frame("gv/v1/children", vault_id(16) ‖ version(u64) ‖ SHA-256(ct))
version follows the preconditions of any record: 1 under
If-None-Match: *, v + 1 under If-Match: v. The server MUST refuse a
children write that is not owner-signed for the version it creates, and
MUST refuse any children read or write by a token (forbidden). Conformance:
“the children record is owner-only” (README.md#6).
5.3 Opening
The owner opens the record in this order: the signature MUST verify for this
vault and the served version (bad_signature); the box MUST open with the
owner box key (decrypt_failed); the plaintext MUST parse (§5.1). Rediscovery
and path resolution MUST refuse a record that fails any of these
(protocol.md#10). No token holds the owner box key or the owner signing key,
so no token can read or forge the record.
6. Kits
A kit carries one node key, its path and its pinned server: a recovery kit for a project root, a delegation kit for any node (whose holder then owns that subtree). It is TOML with exactly these keys:
| Key | Value |
|---|---|
v | the kit version, 2 |
kind | "recovery" or "delegation" |
path | the node’s path (formats.md#3); a project name for a recovery kit |
server | the pinned server URL: https://…, or http:// to a loopback address |
key | the node key as a gvk1_ string (formats.md#2.3) |
A writer renders it as crates/galata-vault/src/owner/kit.rs does, with a
comment header whose first line names the product:
# galata-vault recovery kit (v1)
#
# Whoever holds this file owns the project "acme" and every environment in it.
# There is no account and no reset: lose every copy of this key and
# the secrets are gone. Keep it offline or in a password manager,
# and never commit it.
v = 1
kind = "recovery"
path = "acme"
server = "https://vault.example"
key = "gvk1_…"
A reader MUST accept any TOML document with exactly these keys (comments and key order do not matter) and checks, in order:
- it is TOML with exactly these keys and valid values (
bad_encoding); vis 1; any othervis refused (unknown_version);serveris one a client may use (bad_server);keyparses as agvk1_string, with the failures of formats.md#2.5; a key under another version digit is refused asunknown_version(formats.md#2.6);- a recovery kit’s path is a project (
kind_mismatch).
The vault id is not stored: it is derived from the key. A kit holds the key
itself, so a writer MUST create the file with mode 0600, and error messages
MUST NOT quote a kit’s contents. Vectors: testdata/vectors/v1/kits.json
(the parse direction).
Signatures
Status: draft (docs/spec/README.md#1).
Every signature in the protocol, with its exact signing input, the key that
signs and the key that verifies. Notation: ‖ is concatenation; integers
are big-endian; base64url is unpadded; frame(label, …) is
label ‖ 0x00 ‖ … (keys.md#1). Every label is in the registry (keys.md#3).
Vectors: testdata/vectors/v1/signatures.json reproduces one signing input
and signature for every kind below; the construct files cite the same inputs.
1. Ed25519
Every signature is Ed25519 (RFC 8032). Signing is deterministic, so the vectors reproduce signatures byte for byte from their seeds.
Every verifier MUST verify strictly, as ed25519-dalek’s verify_strict
does, and MUST refuse (bad_signature):
- a signature whose scalar
sis not reduced (s ≥ L); - a public key that does not decode to a curve point;
- a public key of small order, and a signature whose
Ris of small order.
A small-order public key would otherwise accept a signature R = [s]B over
any message. The vectors include a signature with s + L, the identity point
as a public key, and a public key that is not a point.
2. Request signature (GV-Sig v=1)
Owners and tokens authenticate every request the same way: a signature over
the request as sent, carried in Authorization. No credential that decrypts
anything crosses the wire (keys.md#6).
2.1 Signing input
The signing input is ten lines of text joined by \n (0x0A), with no
trailing newline:
| Line | Content |
|---|---|
| 1 | gv/v1/sig |
| 2 | the actor: owner, or token: followed by the token id in lowercase hex |
| 3 | the method, upper case |
| 4 | the path and query, exactly as sent (e.g. /v1/audit?after=5&limit=1000) |
| 5 | lowercase hex SHA-256 of the body; of the empty string when there is none |
| 6 | the vault id, lowercase hex |
| 7 | ts: Unix seconds, decimal |
| 8 | the nonce: 16 random bytes, base64url |
| 9 | the If-Match value, or empty |
| 10 | the If-None-Match value, or empty |
The owner signs with its owner signing key (keys.md#5); a token signs with
its token_auth key (keys.md#6). A client MUST use a fresh random nonce for
every request. Because the preconditions are signed, a server or proxy
cannot turn a conditional write into an unconditional one.
2.2 Header
Authorization: GV-Sig v=1,actor=owner,vault=<hex>,ts=<unix>,nonce=<b64url>,sig=<b64url>
Authorization: GV-Sig v=1,actor=token,token=<hex>,vault=<hex>,ts=<unix>,nonce=<b64url>,sig=<b64url>
The scheme is GV-Sig followed by one space, then comma-separated
name=value parameters. A parser MUST refuse:
- a
vother than2(unknown_version); - a parameter given twice, a parameter it does not know, a missing
parameter,
actor=tokenwithouttoken,actor=ownerwithtoken, a nonce that is not 16 bytes, a signature that is not 64 bytes, or any other scheme such asBearer(bad_encoding).
A server answers every one of these with the uniform 401 (http-api.md#3).
2.3 Verification by the server
The server verifies a request in this order, and answers any failure with
the uniform 401 unauthorized (http-api.md#3):
- the header parses (§2.2);
- the vault (actor
owner) or the token (actortoken) is known, and a token belongs to the vault the header names; tsis within 300 seconds of the server’s clock;- the signature verifies (§1) over the signing input (§2.1) rebuilt from
the request as received, against the stored
owner_sign_pubor the token’s storedauth_pub; - the vault has not expired (hosted.md#3);
- the nonce has not been spent for this vault: the server spends it only
after the signature verifies, so nobody can burn another caller’s nonces,
and remembers it for 600 seconds past
ts.
Only after step 6 does a token holder learn that its token expired
(token_expired). A request that authenticates as a token and asks for an
owner-only operation is refused with 403 forbidden. Vault creation is the
exception to step 2: the vault is not yet known, so the server verifies the
request against the owner signing key in the request body, after checking
that the key hashes to the vault id (protocol.md#2). Conformance: the
“uniform 401” case (README.md#6).
3. Descriptor signature
The owner signing key signs frame("gv/v1/descriptor", descriptor) over the
189-byte descriptor (records.md#1). Verified by every client from its pinned
vault id (records.md#1.4) and by the server before it stores a descriptor
(at creation and at rotation).
4. Bundle signature
The owner signing key signs
frame("gv/v1/bundle", vault_id(16) ‖ token_id(16) ‖ scope(u8) ‖ generation(u32) ‖ SHA-256(sealed))
(records.md#2.4). The owner’s own bundle uses the zero token id and scope 0.
Verified by the holder before it opens the seal (records.md#2.5), and by the
server when a token is registered and when a rotation reseals bundles.
5. Record (writer) signature
The generation’s writer key for the record’s kind signs
frame("gv/v1/record", vault_id ‖ generation ‖ kind ‖ name_index ‖ version ‖ written_at ‖ tombstone ‖ SHA-256(value_ct) ‖ SHA-256(name_ct))
(records.md#4). Verified by the server before it stores a record and by
every client before it trusts one, against the descriptor’s writer key for
that kind only. Writer keys are per generation and per kind, not per token:
the signature proves a holder of the writer key wrote the record, and the
server’s audit row names which credential sent it.
6. Children signature
The owner signing key signs
frame("gv/v1/children", vault_id(16) ‖ version(u64) ‖ SHA-256(ct))
(records.md#5.2). Verified by the server on every write and by the owner on
every read (records.md#5.3).
7. Leaked-token report
Whoever holds a token string can revoke it without sending it: the token’s
token_auth key signs
frame("gv/v1/report", token_id(16) ‖ ts(i64))
and the reporter sends POST /v1/tokens/report with
{"token_id": hex, "ts": i64, "sig": b64url} and no Authorization. The
server MUST verify the signature against the token’s stored auth_pub and
MUST refuse a ts more than 300 seconds from its clock, answering either
failure, and an unknown token, with the uniform 401. The token string never
crosses the wire (protocol.md#11).
8. Summary
| Signature | Signed by | Verified by | Input | Spec |
|---|---|---|---|---|
| request | owner signing key or token_auth | server | the text of §2.1 | §2 |
| descriptor | owner signing key | server; every client | frame(gv/v1/descriptor, descriptor) | records.md#1.2 |
| bundle | owner signing key | server; the holder | frame(gv/v1/bundle, …) | records.md#2.4 |
| record | the generation’s writer key for the kind | server; every client | frame(gv/v1/record, …) | records.md#4 |
| children | owner signing key | server; the owner | frame(gv/v1/children, …) | records.md#5.2 |
| leaked-token report | token_auth | server | frame(gv/v1/report, token_id ‖ ts) | §7 |
The protocol has no rekey proof: a rekey is a client-driven sequence of creations, writes, children updates and deletions, each authenticated as above (protocol.md#9).
Protocol flows
Status: draft (docs/spec/README.md#1).
How clients and a server use the formats of the other documents: creating a vault, minting and registering tokens, opening, reading, writing, listing, rotating, rekeying, rediscovering and revoking. Endpoints and status codes are in http-api.md; bytes in formats.md, keys.md and records.md; signatures in signatures.md.
1. Roles and client state
- Owner: whoever holds a node key (keys.md#4). It derives the node’s owner keys and vault id (keys.md#5), and holds every key of every generation through its owner bundle.
- Token holder: whoever holds a
gvt1_token string (formats.md#2.4). The string names the token id, the vault id and the token secret; the holder derivestoken_authandtoken_box(keys.md#6). Its scope decides which keys its bundle holds (records.md#2.2). - Server: stores ciphertext, signatures and public keys, enforces scopes, preconditions and quotas, and computes the audit chain. It holds no key that opens anything it stores, and is not trusted with anything a client can verify.
A client MAY keep state between sessions, and a client that does detects more (§12): for each vault, the descriptor generation and hash it verified, the latest version of each record it read, and the last audit head it verified. The SDKs let the caller supply and receive these pins.
2. Vault creation
- The owner derives the node’s owner keys and vault id (keys.md#5).
- It fetches the server’s capabilities once per API handle
(http-api.md#6) and MUST refuse, sending nothing authenticated, a server
whose
protocolsdoes not list"1"(unsupported_protocol). - If the capabilities set
proof_of_work, or the server answered 404 (older than the document), it obtains and solves a challenge (hosted.md#2); otherwise it sends none. - It generates generation 1’s five keys (keys.md#7), a descriptor for
generation 1 with a zero
prev_hash(records.md#1), signs it, and seals and signs its own bundle (records.md#2). - It sends
POST /v1/vaultswith the vault id, both owner public keys, the signed descriptor, the owner bundle and any solved challenge, signed as the owner (signatures.md#2).
The server checks, in order: the admission (a solved, unspent, unexpired
challenge when it asks for proof of work); that the vault id is the hash of
the owner signing key (vault_id_mismatch); that the request is signed by
that key for that vault (the uniform 401); that the descriptor verifies for
the vault and is generation 1 with a zero previous hash; that the owner
bundle is owner-signed for generation 1; then it spends the challenge and
the nonce and stores the vault. A vault id that exists is conflict.
- The owner then writes an empty children record at version 1
(records.md#5), under
If-None-Match: *.
Nobody else can create a vault at this id, even after it expires, because the id is the hash of the owner’s signing key. Conformance: the “capabilities” and “vault lifecycle” cases (README.md#6).
3. Minting a token
- The owner generates a random 16-byte token id and a random 32-byte token
secret, and derives
token_authandtoken_box(keys.md#6). - It seals the bundle of the scope’s kind (records.md#2.2) to the token’s
token_boxpublic key and signs it for this vault, token id, scope and the current generation (records.md#2.4). - It sends
POST /v1/tokens, owner-signed, with the token id, both public keys, the scope, the TTL, an optional allow-list, the generation and the signed bundle. - It shows the
gvt1_string once.
The server MUST refuse a registration not signed by the owner (forbidden
for a token), a bundle whose owner signature does not verify for this token,
scope and generation (bad_signature), a generation that is not current
(stale_generation), an allow-list on any scope but read
(invalid_request), and a TTL above the scope’s maximum (ttl_too_long). A
TTL of 0 takes the scope’s default. Defaults and maximums:
| Scope | Default TTL | Maximum TTL |
|---|---|---|
admin | 1 day | 30 days |
| every other scope | 90 days | 365 days |
The server stores the token id, scope, expiry, allow-list, both public keys
and the signed bundle. It never receives the token secret, and nothing it
stores derives token_box. A request body naming a scope the server does
not know is refused (invalid_request, http-api.md#7).
4. Opening a vault
4.1 As the owner
- Capabilities, as in §2 step 2.
GET /v1/vault, owner-signed. The status MUST name the derived vault id, the owner’s own signing and box public keys (key_mismatch).- The descriptor MUST verify from the derived vault id (records.md#1.4), and name the generation the status reports.
- The owner bundle MUST open (records.md#2.5) and hold exactly the descriptor’s keys.
- The client applies its pins (§12).
4.2 As a token holder
- The token string MUST parse, checksum first (formats.md#2.5), before any request.
- Capabilities, as in §2 step 2.
GET /v1/tokens/self, token-signed. The answer MUST name this token and the vault id in the token string (key_mismatch).- A scope the client does not know means it cannot check the bundle’s kind:
it MUST refuse with
unsupported_by_client(http-api.md#7). - The verification chain: the pinned vault id (from the token string) → the owner signing key, which MUST hash to it → the descriptor, which MUST verify (records.md#1.4) → the bundle, which MUST open for this token, scope and generation (records.md#2.5) → the keys, which MUST be the descriptor’s.
- The client applies its pins (§12).
Only after these steps may a client encrypt, verify or decrypt anything for the vault.
5. Reading
- The client computes the record’s index (keys.md#8.1) and fetches
GET /v1/{secrets|configs}/{index}(the latest version) or…/{index}/versions/{version}. - The served record MUST name the requested index, and, for a specific version, that version.
- Its signature MUST verify against the descriptor’s writer key for its kind (records.md#4), before any pin moves.
- For the latest version, a version lower than the one the client last saw is a rollback (§12).
- The client decrypts the name and checks it against the index (keys.md#8.3), then decrypts the value and checks every binding (records.md#3.3).
The server refuses a read by a scope that may not read the kind
(forbidden), and a read token’s read of a secret outside its allow-list
(forbidden); both refusals are audited. A latest version that is a
tombstone is answered 404 not_found, with the tombstone’s version as the
ETag. Every value read is audited with its version and ciphertext hash
(audit.md#1). The conformance case “server-side scope policy” (README.md#6)
checks the scope refusals.
6. Writing and deleting
Every write names the version it creates, and carries a precondition that the request signature covers (signatures.md#2.1):
| Precondition | When | Signed version |
|---|---|---|
If-None-Match: * | the name has never existed | 1 |
If-None-Match: * | the latest version is a tombstone (a revival) | the tombstone’s version + 1 |
If-Match: v | the latest version is the live version v | v + 1 |
- A write sends
PUT /v1/{secrets|configs}/{index}with the name ciphertext, the age ciphertext, the generation, the version, the write time and the writer signature (records.md#4). - A deletion sends
DELETE /v1/{secrets|configs}/{index}withIf-Match: vand a body carrying a signed tombstone for versionv + 1.
The server MUST refuse, and store nothing for: a write without a
precondition (428 precondition_required); both preconditions, or
If-None-Match other than *, or a delete under If-None-Match
(invalid_request); a failed precondition (412 precondition_failed); a
signed version other than the one it would assign (409 version_mismatch);
a generation other than the current one (409 stale_generation); a value
that is not age v1 (not_age_ciphertext); a record whose signature does not
verify against the current descriptor (bad_signature); and a write over a
quota (value_too_large, name_quota_exceeded, vault_quota_exceeded,
config_too_large, config_quota_exceeded). Refused authenticated writes
are audited as refused. A successful write answers with the new version,
also as the ETag. A client whose write fails its precondition MUST NOT
retry it blindly: another writer won, and nothing was overwritten.
Conformance: “a write without a precondition is 428”, “failed preconditions
are 412” and “a signed version other than the assigned one” (README.md#6);
testdata/vectors/v1/signatures.json covers the signed preconditions.
7. Listing and history
GET /v1/{secrets|configs}?limit=<n>&after=<index>lists the latest version of each name, ordered by index, at most 500 per page (100 by default). A page is full when it holdslimititems, and then carriesnext_cursor, the last index, to pass asafter. Each item carries the name index, name ciphertext, version, write time, ciphertext size, tombstone flag, generation,value_ct_hashand signature. Every scope may list.GET /v1/{secrets|configs}/{index}/versionslists every retained version’s metadata, oldest first, with both ciphertext hashes and the signature. The server retains the most recent versions of each name up to the quota (max_versions,max_config_versions), pruning the oldest in the same transaction as a write.
A client verifies each listed signature from the hashes (records.md#4), without decrypting, and decrypts each name and checks it against its index (keys.md#8.3). Conformance: “history and tombstones” and “listing and pagination” (README.md#6).
8. Rotation
A rotation moves a vault to a new generation in one atomic, owner-only batch. It is the only way to cut off a revoked holder’s copies of the keys.
- The owner reads the vault’s status (the token list included) and every retained version of both kinds. The status’s generation MUST be the one the handle verified.
- Before building anything, it MUST refuse with
unsupported_by_clientif any surviving token has a scope the client does not know: it cannot reseal that token’s bundle correctly (http-api.md#7). - It generates the next generation’s keys and a descriptor linked to the current one (records.md#1.3), signed by the owner.
- It verifies every retained version against the current descriptor (a forged record is refused, not re-signed), decrypts it, re-encrypts it to the new keys and re-signs it with the new writer key, keeping its version number and write time; tombstones stay tombstones.
- It seals and signs a new owner bundle, and a new bundle of the right kind for every surviving token, for the new generation.
- It sends
POST /v1/vault/rotationswith the generation and revision the batch was built from, the descriptor, the owner bundle, every rotated secret and config version, every resealed bundle, and the tokens to revoke in the same step.
The server checks: owner-only; every value is age v1; the generation and
revision are current (409 conflict: the vault changed, rebuild); the
descriptor verifies and follows the current one (key_mismatch); every
record verifies under the new writer key for its kind; every bundle is
owner-signed for the new generation and, for a token, its stored scope; and
the batch is complete (incomplete_rotation): every retained version exactly
once with its write time and tombstone flag, every revoked token in the
vault, and exactly one resealed bundle for every surviving token. It then
applies the batch in one transaction, journaled (http-api.md#2). A server’s
request body limit MUST allow the largest batch its quotas allow. After a
rotation, token holders re-open their bundle (§4.2) to use the new
generation. Conformance: “rotation that revokes a token” (README.md#6).
9. Rekey
A rekey re-roots a node: the node gets a fresh random key, and it and every node below it move to new vaults. It is a client-driven sequence of ordinary operations; the server has no rekey operation and no rekey proof (signatures.md#8).
- The owner generates a fresh random node key; every node below derives from it (keys.md#4).
- It creates every new vault (§2) and copies every record into it (§6), re-encrypted and re-signed for the new vault.
- It writes the new vaults’ children records and, if it holds the parent’s
key, points the parent’s children record at the new key as a
sealedentry (records.md#5, records.md#2.6). - It deletes the old vaults last (
DELETE /v1/vault, journaled), which ends every token of theirs.
Each step is recorded before the next begins, and each is idempotent, so an interrupted rekey can be resumed or, before the parent is relinked, aborted. The new recovery or delegation kit (records.md#6) is written before anything changes on the server.
10. Children and rediscovery
Each node’s owner-only children record (records.md#5) lists its children.
From any node key, a client rediscovers the subtree: it opens the node’s
vault (§4.1), opens and verifies its children record (records.md#5.3), and
for each entry derives the child’s key (derived) or opens the sealed child
key (sealed, records.md#2.6), then recurses. A client MUST refuse a
children record that fails verification or parsing; an entry in a mode the
client does not know fails the parse (records.md#5.1), so it refuses the
whole record. A sealed key that does not open is skipped with a warning,
and rediscovery continues with the other children. No token can read the
children record, so no token learns the tree; the conformance case “the
children record is owner-only” (README.md#6) checks the server’s side.
11. Revocation and leak reports
DELETE /v1/tokens/{id}, by the owner or anadmintoken, revokes a token at the server, journaled (http-api.md#2). It is forward-only: the holder keeps whatever keys it already unwrapped, so it can still decrypt what it copied, and a writer key it holds still produces signatures that verify; only the server’s refusal to authenticate the token stops it.- Revoking with rotation (§8, the
revokelist) moves the vault to fresh keys in the same step, so the revoked token’s keys open and sign nothing stored afterwards. POST /v1/tokens/reportrevokes a leaked token on proof of possession with itstoken_authkey (signatures.md#7), audited astoken_report. The token string is never sent.DELETE /v1/vault, owner-only, deletes the vault and every token in it, journaled.
A journaled operation is written to the journal inside its transaction; if
the journal cannot be written, it is not applied and the answer is 503
unavailable. Replay after a restore re-applies it.
12. Pins
A client that keeps state for a vault MUST refuse:
- a descriptor for a generation lower than one it verified
(
generation_rollback); - a different descriptor for a generation it verified
(
generation_rollback); - a newer descriptor that does not follow the pinned one through an unbroken
chain of owner-signed descriptors (
GET /v1/vault/descriptors, records.md#1.3); - a latest version of a record lower than one it read (
version_rollback); - a record whose signed version differs from the version the server reports for it;
- an audit chain that does not continue from its stored head (audit.md#5).
A client that keeps no state cannot detect a server that shows it an older,
correctly signed generation, record version or audit history, or a fork
between clients. These residuals, and the limits that are server policy
only, are listed in the threat model (docs/threat-model.md).
HTTP API
Status: draft (docs/spec/README.md#1).
This document is the reference for the /v1 API: every endpoint, how each
authenticates, which token scopes it permits, its preconditions, its status
codes and its error codes, the capabilities document, and the rules that let
a newer server and an older client work together. The bytes inside the bodies
(descriptors, bundles, records, signatures) are specified in records.md,
signatures.md and audit.md; the flows that use these endpoints are in
protocol.md.
The endpoint table (#2) and the error table (#5.2) are checked mechanically
against the code: the endpoint table against the route table
galata_vault_server_core::ROUTES, and the error table against galata_vault_proto::api::ErrorCode.
A difference in either direction fails the tests.
1. Base
- Every protocol endpoint is under
/v1. A server MUST answer a path under any other version prefix (/v0,/v2, …) with 404not_found, and MUST NOT interpret it as a/v1path. - Every call addresses exactly one vault, and the credential chooses it: the owner signature names the vault id, and a token belongs to one vault. No endpoint takes a vault id in its path, and the server knows no project, path or plaintext name.
- Request and response bodies are JSON (#4.3). A request that carries no body sends none, and its signature covers the empty body.
- The HTTP shell answers two endpoints of its own, outside
/v1:GET /healthzandGET /readyz(503unavailablewhile the journal is unreachable). They take no credential and carry no vault data. The embedded transport serves neither. - An unknown path is 404
not_found. A known path with a method it does not serve is 400invalid_request. - A request body larger than the server’s body limit is refused with 413
value_too_largebefore it is read further. The limit is at least the largest rotation batch the server’s quotas allow (protocol.md#8).
2. Endpoints
Auth names the scheme of #3. Scopes names the token scopes the server
permits; the owner may call every endpoint that accepts an owner signature. A
token whose scope is not permitted gets 403 forbidden (#3.3). Queries:
listings take after (the last name_hmac of the previous page) and limit
(default 100, clamped to 1..500); the audit takes after (a sequence number)
and limit (default and maximum 1000); the descriptor chain takes after (a
generation).
| Method | Path | Auth | Scopes | Request | Response | Notes |
|---|---|---|---|---|---|---|
| GET | /v1/capabilities | none | — | — | Capabilities | 200. Identical for every caller (#6). |
| POST | /v1/challenges | none | — | ChallengeRequest | ChallengeResponse | 200. Served only while capabilities set proof_of_work; otherwise 404 not_found (hosted.md#2.1). |
| POST | /v1/vaults | owner | — (signed by the owner key being registered) | CreateVaultRequest | CreateVaultResponse | 201. 409 conflict if the vault exists; vault_id_mismatch, bad_signature, key_mismatch, invalid_request for a descriptor that is not a first generation; proof-of-work errors (hosted.md#2.3). |
| GET | /v1/vault | owner, token | every scope | — | VaultStatus | 200. tokens only for the owner and admin; owner_bundle only for the owner. |
| DELETE | /v1/vault | owner | owner only | — | DeleteVaultResponse | 200. Journaled; every token of the vault stops authenticating. |
| GET | /v1/vault/descriptors | owner, token | every scope | — | DescriptorList | 200. The owner-signed chain after after, ascending. |
| GET | /v1/vault/children | owner | owner only | — | ChildrenBlob | 200 with ETag; 404 not_found before the first write. |
| PUT | /v1/vault/children | owner | owner only | ChildrenBlob | PutSecretResponse | 201 under If-None-Match: *, 200 under If-Match; 428, 412, 409 version_mismatch as for records; bad_signature unless the owner signed it. |
| POST | /v1/vault/rotations | owner | owner only | RotationRequest | RotationResponse | 200. One atomic, journaled batch; 409 conflict when built from a stale generation or revision; incomplete_rotation, not_age_ciphertext, bad_signature, key_mismatch. |
| POST | /v1/tokens | owner | owner only | RegisterTokenRequest | RegisterTokenResponse | 201. 409 conflict for a registered id; ttl_too_long, token_quota_exceeded, stale_generation, bad_signature; an allow-list only for read. |
| GET | /v1/tokens/self | token | every scope | — | TokenSelf | 200. An owner signature gets 400 invalid_request. |
| POST | /v1/tokens/report | none | — (the token-auth signature is in the body) | ReportTokenRequest | RevokeResponse | 200. Journaled. Every failure is the uniform 401 (#3.2). |
| DELETE | /v1/tokens/{token_id} | owner, token | admin | — | RevokeResponse | 200. Journaled; forward-only (protocol.md#11). |
| GET | /v1/audit | owner, token | every scope | — | AuditPage | 200. The rows after after, and the vault’s head. |
| GET | /v1/secrets | owner, token | every scope | — | SecretList | 200. Metadata only; tombstones included. |
| GET | /v1/secrets/{name_hmac} | owner, token | read, admin | — | SecretVersion | 200 with ETag. A tombstone as latest is 404 not_found with the tombstone’s version as ETag. A read token’s allow-list applies. Audited. |
| PUT | /v1/secrets/{name_hmac} | owner, token | append, admin | PutSecretRequest | PutSecretResponse | 201 under If-None-Match: *, 200 under If-Match, with ETag; 428, 412, 409 version_mismatch, 409 stale_generation, 422 not_age_ciphertext or bad_signature, quota codes. |
| DELETE | /v1/secrets/{name_hmac} | owner, token | append, admin | DeleteRecordRequest | PutSecretResponse | 200 with ETag: a signed tombstone. If-Match only; 428 without it. |
| GET | /v1/secrets/{name_hmac}/versions | owner, token | every scope | — | VersionList | 200. Every retained version’s metadata and signature. |
| GET | /v1/secrets/{name_hmac}/versions/{version} | owner, token | read, admin | — | SecretVersion | 200 with ETag: a retained version, a tombstone included. Allow-list applies. Audited. |
| GET | /v1/configs | owner, token | every scope | — | SecretList | 200. As /v1/secrets. |
| GET | /v1/configs/{name_hmac} | owner, token | read, admin, config, config-write | — | SecretVersion | As the secret read; no allow-list. |
| PUT | /v1/configs/{name_hmac} | owner, token | append, admin, config-write | PutSecretRequest | PutSecretResponse | As the secret write; config quotas. |
| DELETE | /v1/configs/{name_hmac} | owner, token | append, admin, config-write | DeleteRecordRequest | PutSecretResponse | As the secret delete. |
| GET | /v1/configs/{name_hmac}/versions | owner, token | every scope | — | VersionList | As the secret history. |
| GET | /v1/configs/{name_hmac}/versions/{version} | owner, token | read, admin, config, config-write | — | SecretVersion | As the secret version read. |
| GET | /healthz | none | — | — | {"status":"ok"} | 200. HTTP shell only. |
| GET | /readyz | none | — | — | {"status":"ready"} | 200, or 503 unavailable while the journal is unreachable. HTTP shell only. |
3. Authentication
3.1 Schemes
- none. The request carries no
Authorization. An endpoint of this scheme does not authenticate a credential sent to it. - owner.
Authorization: GV-Sig v=1,actor=owner,…, verified against the vault’s storedowner_sign_pub(signatures.md#2). ForPOST /v1/vaultsthe signature is verified against theowner_sign_pubin the body, whose hash MUST be the body’s vault id. - token.
Authorization: GV-Sig v=1,actor=token,token=<id>,…, verified against the token’s registeredauth_pub. The token must belong to the vault the header names. - owner, token. Either of the two.
A server MUST accept a credential only in Authorization (#4.1). It MUST
answer a Bearer credential, a GV-Sig header of any version but 2, and a
malformed header with the uniform 401. It MUST verify the signature and the
skew before it spends the nonce, and MUST spend each nonce once
(signatures.md#2.3).
3.2 The uniform 401
Every authentication failure before a credential is proven gets the same answer, 401 with the body
{"error":"unauthorized","message":"authentication failed"}
whether the token is unknown, revoked or of another vault, the vault does not
exist or has expired (hosted.md#3), the signature does not verify, the
timestamp is outside the skew window, the nonce was already spent, or the
header is missing, malformed, Bearer or not v=1. A server MUST NOT reveal
which. token_expired (401) is answered only after the holder has proved
possession: the signature verified and the nonce was spent.
3.3 Scopes
An authenticated token whose scope does not permit the endpoint gets 403
forbidden, and the server appends an audit row with result refused
(audit.md#1), except where the endpoint refuses before it names an action
(GET /v1/vault/children). An owner-only endpoint called with a token
signature is 403 forbidden. A read token’s allow-list restricts which
secrets it may read; it is enforced by the server only (policy, not
cryptography: the token holds the vault key). The cryptographic limits behind
each scope are in records.md#2.2 and docs/threat-model.md.
4. Headers
4.1 Authorization and credentials
Credentials travel only in Authorization. A server MUST refuse, with 400
credential_in_query and without processing the request, any request whose
query string has a parameter named token, access_token, auth,
authorization, sig, signature, key or secret (case-insensitive), or
contains gvt1_ or gvk1_ (plain or with _ percent-encoded). It MUST NOT
authenticate or log the value.
4.2 Preconditions and ETag
- Every write names the version it expects:
If-None-Match: *to create (or to revive a name whose latest version is a tombstone), orIf-Match: <version>to update or delete.If-Matchaccepts the version bare, quoted, or weak (W/"<version>").If-None-Matchaccepts only*. Both headers together are 400invalid_request; a delete takesIf-Matchonly. - A write without either header MUST be refused with 428
precondition_required. A precondition that does not hold MUST be refused with 412precondition_failed, and nothing is written. - The version the record’s signature binds MUST be the version the
precondition creates (1, or
v + 1); otherwise 409version_mismatch(records.md#4). - Both headers are part of the request signature (
signatures.md#2.1), so an intermediary cannot change them. - A record read, a record write and the children record answer with
ETag: "<version>".
4.3 Content-Type
A client sends Content-Type: application/json with every body. Every
response, success or error, is application/json. A server MUST NOT serve
HTML, whatever Accept asks for.
4.4 Response hygiene
Every response MUST carry:
X-Content-Type-Options: nosniffContent-Security-Policy: default-src 'none'; frame-ancestors 'none'Cache-Control: no-storeReferrer-Policy: no-referrerX-Frame-Options: DENY
An error MUST NOT echo the request body, a header value, a token or ciphertext.
4.5 X-GV-Expires-At
Only a server with an idle-expiry window sends it: Unix seconds, on every answer after authentication (hosted.md#3). A server without a window MUST NOT send it.
4.6 Retry-After
A 429 rate_limited answer carries Retry-After in seconds (hosted.md#4).
5. Errors
5.1 The error body
Every error answer is 4xx or 5xx with the body
{"error": "<code>", "message": "<text for a person>"}
and no other field. Neither field contains ciphertext, token material or a client address, and the message never quotes the request. The code is stable; the message is not, and a client MUST NOT parse it.
Codes are only ever added. A code MUST NOT be renamed, MUST NOT be removed while a supported client may still receive it, and MUST NOT be reused with a different meaning. A new error condition gets a new code. Every code a server emits MUST appear in #5.2 with its status; a client handles a code it does not know as #7.3 says.
5.2 The error table
Retry says what a client may do with the same operation:
yes: nothing changed; the operation may succeed if sent again, signed afresh (a signed request’s nonce is spent), afterRetry-Afterwhere given.refresh: nothing changed; the operation may succeed once rebuilt from fresh state (the current version, generation, revision or a new challenge).no: it fails again as sent; the request, the credential or the vault must change first.
| Code | Status | Retry | Meaning |
|---|---|---|---|
invalid_request | 400 | no | The body, a path parameter, a query value or a header is malformed, a request body carries an unknown field, or the method is not served on the path. |
credential_in_query | 400 | no | A credential in the query string (#4.1). |
challenge_expired | 400 | refresh | The proof-of-work challenge has expired (hosted.md#2.3). |
invalid_proof_of_work | 400 | no | A creation needs a solved challenge and carries none, or one this server did not issue, or a nonce that does not solve it. |
unauthorized | 401 | no | The uniform authentication failure (#3.2). |
token_expired | 401 | no | The token has expired; answered only after the holder proved possession. |
forbidden | 403 | no | The credential’s scope or allow-list does not permit it, or the endpoint is owner-only (#3.3). |
not_found | 404 | no | No such endpoint, record, version or children record, or the latest version is a tombstone. |
conflict | 409 | refresh | The thing exists already (a vault, a token id, a record created twice), or a rotation was built from a stale generation or revision. |
stale_generation | 409 | refresh | A write or registration names a generation that is not the vault’s current one. |
challenge_reused | 409 | refresh | The proof-of-work challenge was already spent. |
version_mismatch | 409 | refresh | The record’s signed version is not the version the precondition creates. |
version_rollback | 409 | no | A client saw a newer version of a record than the server now shows. Reported by clients; no server emits it. |
generation_rollback | 409 | no | A client saw a newer or different generation than the server now shows. Reported by clients; no server emits it. |
precondition_failed | 412 | refresh | If-Match or If-None-Match does not hold. |
value_too_large | 413 | no | A secret value above the per-value quota, or a request body above the body limit. |
config_too_large | 413 | no | A config document above the per-config quota. |
vault_id_mismatch | 422 | no | A creation’s vault id is not the hash of its owner signing key. |
not_age_ciphertext | 422 | no | A value is not age v1 ciphertext. |
incomplete_rotation | 422 | no | A rotation batch leaves out a retained version or a surviving token, or names a token not in the vault. |
ttl_too_long | 422 | no | A token’s lifetime is above its scope’s maximum. |
vault_quota_exceeded | 422 | no | The vault’s storage quota. |
name_quota_exceeded | 422 | no | The vault’s secret-name quota. |
token_quota_exceeded | 422 | no | The vault’s token quota. |
config_quota_exceeded | 422 | no | The vault’s config-document quota. |
bad_signature | 422 | no | A descriptor, bundle, record or children signature does not verify. |
key_mismatch | 422 | no | A key does not match the vault’s descriptor or pinned id, or a new descriptor does not follow the current one. |
precondition_required | 428 | no | A write without If-Match or If-None-Match. |
rate_limited | 429 | yes | A request budget is spent; Retry-After says when to try again (hosted.md#4). |
internal | 500 | yes | The server failed; the operator’s log has the detail. |
unavailable | 503 | yes | A critical operation could not be journaled, so it was not applied; or the journal is unreachable (/readyz). |
A server emits every code in the table except version_rollback
and generation_rollback, which exist so that clients report
those failures with the same code a server would use.
6. Capabilities
6.1 The document
GET /v1/capabilities MUST be answered without authentication, with the same
document for every caller, and with no per-vault data. The embedded transport
MUST return the same document as a server with the same policy.
| Field | Type | Meaning |
|---|---|---|
protocols | array of strings | The protocol versions served: ["1"]. |
formats | object | The version of each format this build implements: descriptor 2, bundle 2, envelope 3, children 2, audit_row 3 (the row format it writes), request_signature 2. 0 or absent means not advertised. |
proof_of_work | {"difficulty": n} or null | What vault creation needs; null when it needs no challenge (hosted.md#2). |
idle_expiry_days | number or null | Days without an authenticated request before a vault expires; null when no vault expires (hosted.md#3). |
limits | object | The per-vault quotas: max_names, max_value_bytes, max_versions, max_vault_bytes (secrets and configs together), max_tokens, max_configs, max_config_bytes, max_config_versions. |
features | array of strings | The optional behaviours in force (#6.2). |
server | string, optional | The server’s name and version, gv-server/<version>. Present only when the operator sets advertise_version = true; absent by default, to limit fingerprinting. |
proof_of_work, idle_expiry_days and limits were the whole document
before this version and keep their names and meaning. A document without
protocols comes from a server that predates the field, and MUST be read as
["1"]. A server that answers the request with 404 predates the document: a
client MUST then assume it asks for proof of work, and learn of expiry only
from its responses.
A default server’s document:
{
"protocols": ["1"],
"formats": {"descriptor": 2, "bundle": 2, "envelope": 3, "children": 2, "audit_row": 3, "request_signature": 2},
"proof_of_work": null,
"idle_expiry_days": null,
"limits": {"max_names": 200, "max_value_bytes": 16384, "max_versions": 20, "max_vault_bytes": 4194304,
"max_tokens": 128, "max_configs": 64, "max_config_bytes": 262144, "max_config_versions": 20},
"features": ["token_report"]
}
6.2 Features
A client MUST ignore a feature name it does not know.
| Feature | Meaning |
|---|---|
token_report | POST /v1/tokens/report is served. Every core serves it. |
rate_limits | A request budget per token and per vault is enforced: any authenticated request may be answered 429 with Retry-After (hosted.md#4). |
6.3 What a client does with it
- A client MUST fetch the capabilities before a handle’s first authenticated request, and SHOULD fetch them at most once per handle and its clones, remembering the answer (a 404 included).
- A client MUST refuse a server whose
protocolsdoes not list"1", withunsupported_protocol, before it sends any authenticated request. - A client MUST request a proof-of-work challenge only when
proof_of_workis set. - A client MUST keep ancestors alive and warn about expiry only when the
server announces expiry, in
idle_expiry_daysor inX-GV-Expires-At. - A client MUST send a request field introduced after protocol version 1 only to a server whose capabilities advertise it (#7.1).
7. Forward compatibility
A newer server must never make an older client lose information or act on something it does not understand.
7.1 Requests are strict
A server MUST refuse a request body that carries a field it does not know,
with 400 invalid_request, and MUST change nothing. It MUST likewise refuse
an enum value it does not know, such as a token scope. Silently ignoring a
field could drop a precondition or a restriction the client meant. A client
MUST send a field introduced after protocol version 1 only when the server’s
capabilities advertise support for it.
7.2 Responses are tolerant
A client MUST ignore a field it does not know in any response body, except an
audit row: every field of a row is hashed, so a row with an unknown field is
malformed and a new row field comes with a new row format (audit.md#4). A
galata_vault::proto unit test (every_response_tolerates_an_unknown_field) parses
every response body with an unknown field added at every level.
7.3 Values a client does not know
-
Kept and shown. A token scope (
TokenSummary.scope,TokenSelf.scope), an error code (ErrorBody.error) and a record writer’s actor kind (SecretVersion.written_by,VersionMeta.written_by) that the client does not know MUST be accepted and kept, by name, for display (in the Rust crates,galata_vault_proto::tolerant::Tolerant). The rest of the response is read normally. -
Never acted on. Every operation whose correctness depends on understanding such a value MUST refuse with
unsupported_by_clientand MUST change nothing:- opening a vault with a token whose scope the client does not know (its bundle’s kind cannot be checked);
- rotation, and so the resealing of every token’s bundle, while any surviving token’s scope is unknown;
- minting a token while the vault holds a token of unknown scope;
- audit verification over a row naming an action or actor the client does
not know (
audit.md#4); - reading, writing or verifying a record of a kind the client does not
know (
records.md#3).
The children record is not tolerant: it carries its own format version, and an entry the client cannot parse makes the record malformed (
records.md#5). -
Unknown error codes. A client MUST keep the code string and the server’s message, and MUST NOT replace the message with a status text. It derives the error’s kind from the HTTP status class: 401 authentication, 403 forbidden, 404 not found, 409 and 412 conflict, anything else other. A refusal whose body names no code is reported as
http_<status>. -
Unknown audit row formats are reported as unverifiable, never as tampered and never as verified (
audit.md#4).
7.4 Codes a client raises
A client reports server codes as the server sent them. It also has codes of its own, which no server emits; the protocol-relevant ones are:
| Code | Meaning |
|---|---|
unsupported_protocol | The server’s capabilities do not list protocol "1"; nothing authenticated was sent. |
unsupported_by_client | The operation depends on a value this client does not know (#7.3); nothing changed. |
http_<status> | The server refused with a body that names no code. |
unreachable | No answer: the connection, TLS or the transport failed, or a redirect (never followed). |
bad_signature, key_mismatch, binding_mismatch, version_rollback, generation_rollback, audit_mismatch | Something the server served does not verify (protocol.md#4, audit.md#5). |
invalid_token | A token string that does not parse or whose checksum does not match (formats.md#2). |
error | A success whose body is not what the protocol says it is. |
The SDK’s full list, including codes about local state, is its code
module (crates/galata-vault/src/error.rs).
8. Bodies
Field types: VaultId and TokenId are 32 lowercase hex characters;
NameHmac and Hash32 are 64. Public keys (Key32, 32 bytes), signatures
(Sig64, 64 bytes) and opaque bytes (B64: ciphertext, sealed bundles,
descriptor bytes) are unpadded base64url. Integers are JSON numbers; times are
Unix seconds. A field shown as T? may be null or, where noted, absent.
Every request body in this section refuses a field it does not know (#7.1); no response body does, except the audit row (#7.2).
8.1 Shared objects
SignedDescriptor:{"descriptor": B64 (189 bytes, records.md#1), "sig": Sig64}.SignedBundle:{"sealed": B64, "sig": Sig64}(records.md#2).Actor:{"kind": "owner"}or{"kind": "token", "id": TokenId}.Limits: the eight quota fields of #6.1, each a number; a missing field takes its default.ChainHead:{"seq": u64, "hash": Hash32}.
8.2 Vaults
ChallengeRequest:{"purpose": "create_vault"}.ChallengeResponse:{"challenge": string, "difficulty": u8, "expires_at": i64}.CreateVaultRequest:{"challenge": string? (absent when none), "nonce": u64? (absent when none), "vault_id": VaultId, "owner_sign_pub": Key32, "owner_box_pub": Key32, "descriptor": SignedDescriptor, "owner_bundle": SignedBundle}.CreateVaultResponse:{"vault_id": VaultId, "generation": u32, "expires_at": i64?}.VaultStatus:{"vault_id", "generation": u32, "revision": u64, "owner_sign_pub": Key32, "owner_box_pub": Key32, "descriptor": SignedDescriptor, "config_count": u32, "created_at": i64, "last_active_at": i64, "expires_at": i64?, "bytes_used": u64, "limits": Limits, "tokens": [TokenSummary]?, "owner_bundle": SignedBundle?}.TokenSummary:{"token_id": TokenId, "scope": string (tolerant, #7.3), "created_at": i64, "expires_at": i64, "box_pub": Key32, "allow_list": [NameHmac]?}.DescriptorList:{"descriptors": [SignedDescriptor]}.RotationRequest:{"from_generation": u32, "from_revision": u64, "descriptor": SignedDescriptor, "owner_bundle": SignedBundle, "secrets": [RotatedVersion], "configs": [RotatedVersion], "tokens": [ResealedBundle], "revoke": [TokenId]}.RotatedVersion:{"old_name_hmac": NameHmac, "version": u64, "name_hmac": NameHmac, "name_ct": B64, "value_ct": B64? (null for a tombstone), "written_at": i64, "sig": Sig64}.ResealedBundle:{"token_id": TokenId, "bundle": SignedBundle}.RotationResponse:{"generation": u32}.DeleteVaultResponse:{"vault_id": VaultId}.ChildrenBlob:{"version": u64, "ct": B64, "sig": Sig64}(records.md#5); both the request and the response of the children endpoints.
8.3 Tokens
RegisterTokenRequest:{"token_id": TokenId, "auth_pub": Key32, "box_pub": Key32, "scope": string (strict: a scope the server does not know is refused), "ttl_secs": u64 (0: the scope's default), "allow_list": [NameHmac]?, "generation": u32, "bundle": SignedBundle}.RegisterTokenResponse:{"token_id": TokenId, "expires_at": i64}.TokenSelf:{"token_id": TokenId, "vault_id": VaultId, "scope": string (tolerant), "expires_at": i64, "allow_list": [NameHmac]?, "owner_sign_pub": Key32, "descriptor": SignedDescriptor, "bundle": SignedBundle}.ReportTokenRequest:{"token_id": TokenId, "ts": i64, "sig": Sig64}(signatures.md#7).RevokeResponse:{"revoked": [TokenId]}.
Scope lifetimes: admin defaults to 1 day and allows at most 30; every other
scope defaults to 90 days and allows at most 365.
8.4 Records
SecretList:{"items": [SecretListItem], "next_cursor": NameHmac?}.SecretListItem:{"name_hmac": NameHmac, "name_ct": B64, "version": u64, "written_at": i64, "size": u32, "tombstone": bool, "generation": u32, "value_ct_hash": Hash32, "sig": Sig64}.SecretVersion:{"name_hmac": NameHmac, "version": u64, "name_ct": B64, "value_ct": B64? (null for a tombstone), "generation": u32, "written_at": i64, "written_by": Actor (tolerant), "tombstone": bool, "sig": Sig64}.PutSecretRequest:{"name_ct": B64, "value_ct": B64, "generation": u32, "version": u64, "written_at": i64, "sig": Sig64}.DeleteRecordRequest:{"name_ct": B64, "generation": u32, "version": u64, "written_at": i64, "sig": Sig64}.PutSecretResponse:{"version": u64}.VersionList:{"versions": [VersionMeta]}.VersionMeta:{"version": u64, "written_at": i64, "written_by": Actor (tolerant), "size": u32, "tombstone": bool, "generation": u32, "value_ct_hash": Hash32, "name_ct_hash": Hash32, "sig": Sig64}.
The same shapes serve /v1/secrets and /v1/configs.
8.5 Audit and errors
AuditPage:{"rows": [AuditRow], "head": ChainHead?}; the row is inaudit.md#1.ErrorBody:{"error": string (tolerant), "message": string}(#5.1).
Audit chain
Status: draft (docs/spec/README.md#1).
Every vault has a hash-chained audit log, computed by the server and
verifiable by every client. Notation: ‖ is concatenation; integers are
big-endian. Vectors: testdata/vectors/v1/audit.json (row encodings and
hashes in both row formats, and the verification algorithm over served
pages).
1. Rows
A server appends one row, in the same transaction as the action, for every
vault creation, rotation and deletion, children write, token mint,
revocation and report, record write, deletion and value read, and every
authenticated attempt refused by a scope, precondition, generation, version
or quota check (result refused). A row served by GET /v1/audit is a JSON
object with these fields, in this order:
| Field | Value | Meaning |
|---|---|---|
v | number | the row format (§4) |
seq | u64 | the row’s position in the vault’s chain, from 1 |
ts | i64 | when it happened, Unix seconds |
actor | {"kind":"owner"} or {"kind":"token","id":<hex>} | who acted |
action | a name from §3 | what was done |
name_hmac | hex, or null | the record index the action concerned |
subject | hex; omitted when none | the token an action was done to (minted, revoked, reported) |
result | "ok" or "refused" | whether the action happened |
version | u64 | the record version written or read; 0 where none applies |
ct_hash | hex; omitted when none | SHA-256 of the stored ciphertext written or read; for a rotation, the new descriptor’s hash |
prev | hex | the previous row’s hash; 32 zero bytes for the first row |
hash | hex | this row’s hash (§2) |
Rows hold no value, no plaintext name and no address. A client that reads a
value can check the ciphertext it was served against the ct_hash of the
verified row that recorded the write.
2. Encoding and hash
A row’s hash covers every field but hash, as these bytes:
row = v(u8) = 1 ‖ fields
fields = seq(u64) ‖ ts(i64) ‖ opt(actor token id) ‖ action(u16) ‖ opt(name_hmac)
‖ result(u8) ‖ opt(subject) ‖ version(u64) ‖ opt(ct_hash) ‖ prev(32)
opt(x) = 0x00 when absent, 0x01 ‖ x when present
- The owner as actor is an absent
opt; a token actor is0x01 ‖ token id. actionis the action’s code (§3);resultis 0 for ok and 1 for refused.hash = BLAKE3(derive-key mode, context "galata-vault v1 audit row", bytes), 32 bytes. The leadingvbyte is the row format, so a row in a later format can never hash as a format 1 row.
3. Actions and results
| Code | Action |
|---|---|
| 1 | vault_create |
| 2 | vault_rotate |
| 4 | vault_delete |
| 5 | vault_expire |
| 6 | children_write |
| 10 | token_mint |
| 11 | token_revoke |
| 12 | token_report |
| 13 | token_list |
| 20 | secret_put |
| 21 | secret_delete |
| 22 | secret_read |
| 30 | config_write |
| 31 | config_delete |
| 32 | config_read |
Code 3 is unassigned and MUST NOT be assigned. A new action gets a new code, only ever appended; a code is never
renumbered. A new result value, or any new field, needs a new row format
(§4), because the encoding has no room for one.
4. Row formats and reading a page
- Format 1 is the only row format:
"v": 1on the wire, and the marker as the first hashed byte (§2).
A row refuses any field it does not have, because every field is hashed. A
page ({"rows": […], "head": …}) is a response like any other and tolerates
fields it does not know (http-api.md#7). A client reads a page’s rows in
order:
- a row without
vis malformed (bad_encoding); - a row whose
vis greater than the newest format the client knows stops the rows the client can verify: that row and every row after it are unverifiable in a newer format (unverifiable_newer_format), and are not kept; - a row in a known format that names an action (§3) or an actor kind the
client does not know also stops the rows it can verify: it cannot be
hashed, so verification refuses it (
unknown_value; the SDKs reportunsupported_by_client, http-api.md#7); - any other row that does not parse as its format, including a row with a
field its format does not have, is malformed (
bad_encoding).
A row in a newer format is never reported as tampered, and never as verified.
5. Verification
A client verifies every row a server served after the head known it
verified last (all pages, in order), the head the server reported, and where
reading the rows stopped (§4), if it did. The algorithm, as
galata_vault_proto::audit::verify_rows implements it:
- With a known head, the server’s head MUST NOT be behind it (
rollback), and MUST NOT differ from it at the same position (fork). A server that reports no head at all is behind any known head (rollback). - The base is the known head. With no known head, the base is the first row
served: its
seq − 1and itsprev. A client with no head therefore trusts where the server’s rows begin; older rows may have moved to an archive. - Each row MUST have the next
seq, MUST name the previous row’s hash (or the base) asprev, and MUST hash to its ownhash(§2); otherwisechain_break. - Unless reading stopped early, the last verified row MUST be the server’s
head (
chain_break). - A stop at an unknown action or actor refuses the verification
(
unknown_value). A stop at a newer format ends it: the rows before it are verified, and the new head is the last of them.
A client MUST store the new head only when the verification succeeds, and
then only up to the last verified row. Every scope may fetch and verify its
vault’s chain. Pages are fetched with GET /v1/audit?after=<seq>&limit=<n>;
a server returns at most 1000 rows per page (http-api.md#2).
6. What verification cannot detect
The chain is computed by the server, with no key. So:
- a server can show different clients different, individually consistent histories (a fork), and a client detects it only at a position it verified before;
- a client with no stored head can be shown any consistent history, including one with rows removed from the end or a different past;
- a server can withhold rows a client has not yet seen, or delete the vault;
- a server can stop serving rows at the start of a newer format.
Detection is relative to the heads clients store. Detecting a fork between
clients that never compare heads needs an external witness, which the
protocol does not have. See the threat model (docs/threat-model.md).
Hosted appendix
Status: draft (docs/spec/README.md#1).
1. Scope of this appendix
A server configured for callers it does not trust may add controls that
gv-server, gv-server local and the SDK’s embedded backend never apply:
proof of work before a vault is created, idle expiry, and rate limits. This
appendix specifies what a client meets from such a server. A client MUST
understand all of it even though most servers use none of it, because it
cannot know in advance which kind of server it talks to: the capabilities
document says (http-api.md#6).
No server in this repository implements this appendix. gv-server
reports proof_of_work and idle_expiry_days as null, answers
POST /v1/challenges with 404, sends no X-GV-Expires-At, and limits no
request. What follows is what a third-party server MAY do, and therefore
what a client MUST handle.
2. Proof of work
A server that wants a proof of work sets proof_of_work in its capabilities
to {"difficulty": n}. A server that sets it to null MUST NOT require a
challenge, MUST answer POST /v1/challenges with 404 not_found, and MUST
NOT check a challenge a creation request carries.
2.1 Challenges
POST /v1/challenges with {"purpose": "create_vault"} answers
{"challenge": string, "difficulty": u8, "expires_at": i64}. A challenge is
stateless:
payload = id(16, random) ‖ expires_at(i64) ‖ difficulty(u8) 25 bytes
tag = BLAKE3-keyed(server_key, payload)[..16]
challenge = base64url(payload ‖ tag) 41 bytes, 55 characters
server_key is 32 random bytes the server holds and never publishes; a
server MAY draw a new one at every start, which invalidates outstanding
challenges. A challenge is valid for 600 seconds.
Only the server that issued a challenge opens it. It checks, in order, and refuses at the first failure:
- the text is unpadded base64url of exactly 41 bytes (
bad_encodingin the vectors); - the tag is the keyed hash of the payload, compared in constant time
(
bad_signature); - the difficulty is at most 40 (
bad_encoding); - the current time is not after
expires_at(expired).
A challenge is single-use: the server remembers its id, until its expiry, once a creation that carries it has verified (signature, descriptor and owner bundle), and refuses it afterwards.
2.2 Solving and verifying
A solution is a nonce, a u64, such that
BLAKE3-derive-key("galata-vault v1 proof-of-work", challenge_text ‖ le64(nonce))
has at least difficulty leading zero bits, counted from the most
significant bit of the first byte. challenge_text is the challenge string’s
ASCII bytes, as issued. Expected work is 2^difficulty hashes. Difficulty 0
accepts any nonce. A nonce that does not solve the challenge is bad_proof
in the vectors.
2.3 Admission
A creation sends the challenge and nonce in CreateVaultRequest’s
challenge and nonce (http-api.md#8.2). A server that asks for proof of
work MUST refuse a creation:
- with no challenge, a challenge it did not issue, or a nonce that does not
solve it: 400
invalid_proof_of_work; - with an expired challenge: 400
challenge_expired; - with a challenge already spent: 409
challenge_reused.
It checks the challenge before anything else about the creation. A difficulty of 24 is about two seconds on a laptop; the maximum a client accepts before it refuses to search is 40.
3. Idle expiry
A server with an idle-expiry window of d days reports idle_expiry_days: d
in its capabilities; one without reports null. Unset and 0 both mean never.
- A vault expires
ddays after its last authenticated request. Every authenticated request, from the owner or any token, moves that time. - A server with a window MUST send
X-GV-Expires-At(Unix seconds) on every answer after authentication, success or error, and MUST NOT send it on an answer given before the caller authenticated. - An expired vault authenticates nothing: every request to it, and to every
token of it, gets the uniform 401 (
http-api.md#3.2), before and after the sweep that deletes it. - A vault’s id is the hash of its owner key, so nobody else can claim an expired vault’s id; its owner can create it again at the same id, with none of the expired contents.
- A client MUST keep a project’s ancestors alive, and warn when an expiry is less than 14 days away, only when the server announces expiry, in its capabilities or its responses.
gv-server,gv-server localand the embedded backend MUST NOT expire a vault for inactivity.
4. Rate limits
A server with rate limits advertises the rate_limits feature
(http-api.md#6.2). Representative budgets:
| Budget | Default | Counted |
|---|---|---|
| challenges per address per hour | 120 | each POST /v1/challenges |
| creations per address per hour | 20 | each vault created (201) |
| failed authentications per address per minute | 30 | each 401; over the budget, the address is refused outright, credential or not, until the window resets |
| leak reports per address per hour | 60 | each POST /v1/tokens/report |
| requests per token per minute | 600 | each authenticated request by that token |
| requests per vault per minute | 1200 | each authenticated request to that vault |
A request over a budget MUST be answered 429 rate_limited with
Retry-After in seconds, and changes nothing. A client SHOULD wait at least
that long before trying again. The address is the connection’s peer, or,
behind a TLS-terminating proxy the operator declared, the rightmost
X-Forwarded-For entry. A server MUST keep client addresses only in memory,
for the length of a window, and MUST NOT write them to its database, audit
chain, archive, logs or metrics.
5. Leak reports
POST /v1/tokens/report is served by every core, not only one that
applies this appendix (the token_report feature). Whoever holds a leaked
token string proves possession with the token’s own token-auth key and never
sends the string (signatures.md#7, protocol.md#11). A server with rate
limits limits it per address (#4).
Stability
Status: draft (docs/spec/README.md#1).
What may change, how, and what a release promises to read. Conventions are in README.md#3.
1. Versions belong to formats
Every stored or signed format carries its own version marker, independent of the version of any crate or package.
A crate’s semantic version describes its programming interface, which will change for reasons that have nothing to do with bytes on disk: a crate may reach 2.0 while every format it writes is unchanged. Stored data outlives any interface: a vault written today is read by releases years from now. So a release states which format versions it reads and writes (§5), and a format’s version changes only when its bytes do.
2. Version markers
| Format | Marker | Current | Read by this release |
|---|---|---|---|
| Node key string (formats.md#2.3) | prefix | gvk1_ | gvk1_ |
| Token string (formats.md#2.4) | prefix | gvt1_ | gvt1_ |
| Key derivations (keys.md#2) | HKDF salt | galata-vault/v1 | galata-vault/v1 |
| Framed inputs (keys.md#3) | label prefix | gv/v1/ | gv/v1/ |
| Generation descriptor (records.md#1) | version byte | 1 | 1 |
| Bundle (records.md#2) | version byte | 1 | 1 |
| Record envelope (records.md#3) | version byte | 1 | 1 |
| Children record (records.md#5) | v | 1 | 1 |
| Kit (records.md#6) | v | 1 | 1 |
| Audit row (audit.md#4) | v | 1 | 1 |
| Request signature (signatures.md#2) | GV-Sig v= | 1 | 1 |
| Value ciphertext (records.md#3) | age header | age-encryption.org/v1 | age v1 |
| HTTP API (http-api.md#1) | path prefix | /v1 | /v1 |
| Capabilities (http-api.md#6) | protocols | ["1"] | a server listing "1"; a document without the field is read as ["1"] |
A server advertises the format versions it implements in its capabilities
(formats, http-api.md#6).
Any change to bytes that a test vector covers MUST come with a new value of that format’s marker (README.md#7). CI regenerates the vectors from the independent generator and fails on any difference, so a silent change cannot pass.
3. Before 1.0
- The specification is marked draft (README.md#1).
- A format MAY change incompatibly, but only under a new version marker:
never by changing the bytes an existing marker names. A reader that does
not know the new marker refuses it as
unknown_version. - The changelog of every 0.x release SHALL name the format versions it reads and writes (§5).
4. From 1.0
- The version markers of §2, as they stand at 1.0, are frozen. Every 1.x release SHALL read every one of them: data written by 1.0 opens and verifies under any later 1.x release exactly as it did under 1.0.
- New constructs SHALL be additive, as new kinds or new versions under new
markers. A reader that meets a kind or version it does not know MUST refuse
it cleanly, as
unknown_kindorunknown_version, and MUST NOT guess. - A 1.x minor release MAY stop writing an old version, but SHALL keep reading it.
- Dropping the ability to read a format version SHALL require a major release.
5. The changelog rule
Every release’s changelog SHALL list, for each format in §2, the versions the release reads and the version it writes, and SHALL call out any change to either. A change that adds a marker names the marker, the format and the reason.
6. The wire API
The HTTP API evolves under the forward-compatibility rules of http-api.md#7: requests stay strict, responses are read tolerantly, and a client never acts on a value it does not understand. Error codes are only ever added, never renamed or reused (http-api.md#5). A new request field is sent only to a server whose capabilities advertise it.
galata-vault
End-to-end-encrypted secrets and config documents with no accounts: the
galata-vault Rust SDK. Values are encrypted, decrypted and
verified on this side; the server only ever stores ciphertext and
signatures. gv, gv ui and the Python package are built on this crate;
there is no second client implementation.
galata-vault has not been independently audited. Its formats may change before 1.0. Use it at your own risk.
Two ways in:
Vault, the token client. Open a vault with an access token, then read, list, write and delete secrets and configs as the token’s scope allows, verify the audit chain, and refresh after a rotation.owner, the owner API. Everythinggvdoes as a project’s owner: create projects and environments, rediscover a subtree from a key, render and parse recovery and delegation kits, mint and revoke tokens, rotate, rekey (with resume and abort), and verify audit chains against a persisted head.
Install
[dependencies]
galata-vault = "0.1"
Stability. The API is 0.x: a 0.y bump may break it, and the changelog
says how. The wire and file formats are specified separately, in the
repository’s docs/spec/, and carry their own version markers: data
written under a marker stays readable by every later release that reads it,
and a format that changes gets a new marker, never a silent change. The minimum supported Rust version
is 1.98; raising it is a minor release. Security fixes cover the latest
0.y release (SECURITY.md).
Opening a vault with a token
A vault is opened with an access token minted by the environment’s owner
(gv token mint --scope …, or Environment::mint), and the server it
belongs to.
#![allow(unused)]
fn main() {
use galata_vault::Vault;
// GV_SERVER, and exactly one of GV_TOKEN or GV_TOKEN_FILE.
let vault = Vault::from_env()?;
// Or explicitly. On Unix the file must be mode 0600 or 0400 and hold one token.
let vault = Vault::from_token_file("/run/secrets/gv-token", "http://127.0.0.1:8443")?;
Ok::<(), galata_vault::Error>(())
}
The server must be https://, or http:// on loopback. A token’s checksum
is checked before any request, and neither the token nor anything decrypted
ever appears in an error.
Opening verifies the vault from the vault id inside the gvt1_ token: the
owner signing key must hash to it, the generation descriptor must be signed
by that key, and the token’s bundle must be owner-signed for this token and
hold exactly the keys the descriptor names. Every request is signed with a
key derived from the token; the token string itself is never sent.
Token files on Windows. This crate cannot verify a Windows ACL without
unsafe code or another dependency, so from_token_file and GV_TOKEN_FILE
refuse the file there (invalid_token_file). A caller that has checked the
file’s access control itself opens it with
Vault::from_token_file_with(path, server, TokenFileCheck::CallerVerified).
Vault::new with a token from a secure source works everywhere.
Reading and writing
#![allow(unused)]
fn main() {
use galata_vault::{Expect, NewConfig, Vault};
let new_body = String::new();
let vault = Vault::from_env()?;
let db = vault.secret("DATABASE_URL")?; // SecretValue
let bytes: &[u8] = db.expose(); // exactly what was written
let set = vault.secrets(&["A", "B"])?; // all, or an error; never some
let all = vault.readable(None)?; // every secret this token may read
let app = vault.config("app")?; // ConfigDocument
let settings: toml::Table = app.deserialize()?; // toml and json only
let version = app.version();
// An edit that fails if someone else wrote in between. Never retried.
vault.set_config("app", NewConfig::toml(new_body).expect_version(version))?;
vault.set_secret_expecting("API_KEY", b"k2", Expect::Version(1))?;
vault.delete_secret("OLD_KEY", None)?; // a signed tombstone; history is kept
let versions = vault.history("API_KEY")?;
Ok::<(), galata_vault::Error>(())
}
Every record read carries a signature by the generation’s writer key for its
kind, over its vault, generation, name index, version, write time and
ciphertext. A record that does not verify is an Integrity error; so is a
value whose encrypted envelope names a different vault, generation, version
or name.
SecretValue and ConfigDocument zeroize on drop, and cannot be printed:
no Display, no Serialize, and a Debug that shows names, versions,
formats and sizes only.
A long-lived handle survives a rotation: a write that meets the new
generation fails with Error::StaleGeneration; vault.refresh() fetches the
new bundle and descriptor, and the retry succeeds.
The owner API
#![allow(unused)]
fn main() {
use std::sync::Arc;
use galata_vault::owner::{Owner, RetiresAllTokens};
use galata_vault::{FileStateStore, KeyStore, Scope};
fn my_key_store() -> Arc<dyn KeyStore> { unimplemented!() }
let mut owner = Owner::open(my_key_store(), Arc::new(FileStateStore::new("/var/lib/acme")))?;
// Nothing reaches the server until the kit is acknowledged.
let init = owner.begin_init("acme", "https://vault.example")?;
store_somewhere_safe(&init.recovery_kit().render()?);
init.confirm_kit_stored()?;
owner.env_add(&"acme/prod".parse()?)?;
let env = owner.environment(&"acme/prod".parse()?)?; // derefs to Vault
env.set_secret("DATABASE_URL", b"postgres://db.internal/app")?;
let token = env.mint(Scope::Read, 0, &[])?; // token.expose(), once
owner.close(env)?; // remembers pins, saves state
fn store_somewhere_safe(_: &str) {}
let _ = (token, RetiresAllTokens);
Ok::<(), Box<dyn std::error::Error>>(())
}
The library asks nothing and prints nothing. Every confirmation gv asks
for is a typestate or an argument: PendingInit::confirm_kit_stored,
export_kit(path, GrantsSubtreeOwnership), begin_rekey(path, RetiresAllTokens). Every warning goes to the Events observer or into an
outcome (Removal, Repair, RekeyOutcome). No kit file is written: the
caller stores what it is handed. A path that is not in the known tree is
refused before any request, with the closest known path as data
(Error::UnknownPath { suggestion, .. }).
Seams
Nothing reaches the host unless it is handed the means:
| seam | what it is | provided |
|---|---|---|
Transport | one method: send the canonical request the protocol signs, get status, headers and body | HttpTransport (feature http); Embedded (feature embedded); RecordingTransport (feature test-util) |
Api | the typed operations over any Transport: builds, signs and interprets every request | always |
ClientBuilder | timeout (120 s), proxy (none unless given; never from the environment), root certificates or the platform verifier, user agent, observer; redirects are never followed | feature http |
KeyStore | where held node keys live | none (bring your own; gv uses the OS keychain) |
StateStore | the known tree, keep-alive times, audit heads, pins, a rekey in progress | FileStateStore (gv’s config.toml and state.toml, mode 0600 on Unix) |
Events | expiry, progress and warnings, for the handle that made the request | NoEvents (the default) |
Nothing is process-global: two handles with two observers each hear only their own notices.
Features
http(default): the HTTP transport. Without it no HTTP client (ureq,rustls,ring) is linked; callers bring aTransportand useVault::with_apiandOwner::with_connector.platform-verifier: verify TLS with the operating system’s trust store.embedded: the in-process backend,galata_vault::embedded::open(dir): aTransportthat runsgalata_vault::server_coreover the SQLite store in a data directory, the layoutgv-server localuses, with no socket, HTTP or async runtime. Every request is signed and checked as over HTTP. It linksgalata_vault::server_core,galata_vault::backendandrusqlite, and no HTTP server, runtime or S3 client (scripts/check-sdk-linkage.sh). One process opens a directory at a time (data_dir_in_useotherwise); several processes share vaults throughgv-server local. The module documentation states what the embedding process can bypass, and which cryptographic checks still bind it.test-util: in-memory key and state stores,RecordingTransport, andgalata_vault::testingfor adversarial test harnesses.
Detecting rollback
#![allow(unused)]
fn main() {
let vault = galata_vault::Vault::from_env()?;
let kept: Option<galata_vault::ChainHead> = None;
let report = vault.verify_audit(kept)?; // every scope may do this
let keep = report.head; // store it; pass it next time
let store = galata_vault::FileStateStore::new("/var/lib/acme");
let report = vault.audit(&store)?; // or keep it in a StateStore
let pins = vault.pins(); // descriptor and record versions seen
vault.set_pins(pins)?; // on a later handle: refuses regressions
Ok::<(), galata_vault::Error>(())
}
A caller that keeps the audit head and the pins across runs detects a server
that shows an older record version (version_rollback), an older or
different generation (generation_rollback), or a history that does not
continue from the kept head (audit_mismatch). The recorded head moves only
when the whole fetched range verifies. A client that keeps nothing cannot
tell a rolled-back or forked server from an honest one.
What a token can do
| scope | list | read secrets | write secrets | read configs | write configs |
|---|---|---|---|---|---|
meta | ✓ | ||||
append | ✓ | ✓ | ✓ | ||
read | ✓ | ✓ | ✓ | ||
admin | ✓ | ✓ | ✓ | ✓ | ✓ |
config | ✓ | ✓ | |||
config-write | ✓ | ✓ | ✓ |
The limits are cryptographic: a scope’s bundle holds only the keys its row
needs. A config or config-write token never holds the vault key, and a
config-write token holds the config writer key but not the secret writer
key, so it cannot read or write a secret whatever it is given. The server’s
scope checks decide what succeeds; the SDK refuses nothing locally that the
scope permits. Minting and rotation need the owner key.
Configs are checked before they are written
- The body must parse in its declared format (
toml,json);yamlandtextmust be UTF-8. - A body holding a credential literal is refused: a
gvt1_token orgvk1_key with a valid checksum, a PEM private key, or0xand exactly 64 hex digits. Config tokens are weaker than secret readers, so a key in a config is a key for every config reader.NewConfig::allow_literals()overrides it for one write.
Errors name the line and the kind of problem, never the text.
Errors
One type, galata_vault::Error: a #[non_exhaustive] enum whose variants
carry structured detail (Conflict { expected, current, .. },
UnknownPath { suggestion, .. }, Integrity { failure, .. }, …) and keep
their source where there is one (a transport failure’s source() is the
I/O or TLS error). It is Send, Sync and Clone.
code() is a stable string, the same the Python package reports and the
same this crate reported before the enum existed; kind() is one of Auth,
Forbidden, NotFound, Conflict, Transport, Invalid, Integrity or
Other. The Integrity codes are bad_signature, key_mismatch,
binding_mismatch, version_rollback, generation_rollback and
audit_mismatch. No error ever holds a token, key
material, a value or a config body.
The host process
The library is blocking; from async code, call it through spawn_blocking. A
Vault is Send + Sync, so one handle serves every thread. It touches no
keychain and no process limits, writes nothing to stdout or stderr, prompts
for nothing, installs no signal handler, starts no runtime and keeps no
process-global state. scripts/check-sdk-linkage.sh keeps keyring, rlimit,
rpassword, clap and pyo3 out of its dependency graph, and
scripts/check-client-linkage.sh keeps value decryption out of galata_vault::client,
the transport crate beneath it.
Examples
cargo run -p galata-vault --example <name> (and --features embedded for
embedded):
token_read: read one secret with a token from the environment.owner_workflow: create a project with an in-memory key store, add an environment, mint, read with the token, rotate.custom_transport: aTransportthat logs each request’s method and path.embedded: open a data directory, create a project and an environment, set a secret and read it back through a read token, with no server.
galata-vault
End-to-end encrypted secrets with no account. Your machine encrypts every secret before it leaves; the server keeps ciphertext it cannot read, and every value you read carries a signature this package verifies.
galata-vault has not been independently audited. Its formats may change before 1.0. Use it at your own risk.
This package gives you:
galata_vault, a token client for applications, for secrets and config documents;gv, the full command line: create projects and environments, set secrets, mint tokens, andgv run -- <cmd>.
pip install galata-vault
Quick start
Start a server on your machine (it needs the gv-server binary; see the
project README), then
create a project and an environment:
gv-server local &
gv init acme --server http://127.0.0.1:8750 # writes acme-recovery.gvkit: keep it safe
gv env add acme/prod
printf %s "postgres://..." | gv set DATABASE_URL --env acme/prod
gv token mint --scope read --env acme/prod # prints a gvt1_ token once
In the application:
import galata_vault
vault = galata_vault.Vault.from_env() # GV_SERVER, and GV_TOKEN or GV_TOKEN_FILE
url = vault.get("DATABASE_URL")
for secret in vault.list():
print(secret.name, secret.version, secret.updated_at)
vault.load_env() # readable secrets into os.environ (existing ones kept)
app = vault.get_config("app") # name, format, version, text, data (exact bytes)
settings = app.parse() # a dict, for toml and json
vault.set_config("app", new_text, "toml", expect_version=app.version)
head = vault.verify_audit(kept_head) # keep it; a server that rewrote history is caught
set_config checks the body before any request. It must parse in its
format, and a credential literal (a key, a token, a PEM block) is refused
unless allow_literals=True. With expect_version, an edit made from a stale
read raises ConflictError and overwrites nothing.
A token can also come from a file: Vault.from_token_file(path, server), or
GV_TOKEN_FILE with from_env(). The file must have mode 0600 or 0400
(checked before it is read) and hold one token. Setting both GV_TOKEN and
GV_TOKEN_FILE is refused.
What a token can do
| Scope | list | get | set | get_config | set_config |
|---|---|---|---|---|---|
meta | ✓ | ||||
append | ✓ | ✓ | ✓ | ||
read | ✓ | ✓ (optionally only listed names) | ✓ | ||
admin | ✓ | ✓ | ✓ | ✓ | ✓ |
config | ✓ | ✓ | |||
config-write | ✓ | ✓ | ✓ |
A meta or append token cannot decrypt a value even in principle: it
never receives the vault key. A config or config-write token receives
the config key only, so it reads configs and never a secret; a
config-write token cannot write a secret either, because it never
receives the key that signs one.
Errors
Every error derives from galata_vault.GalataVaultError and has a stable
code, the same string the Rust SDK reports:
AuthenticationError:invalid_token,unauthorized,token_expired;NotFoundError;ForbiddenError;ConflictError: another writer changed the secret or config first;TransportError: the server is unreachable;IntegrityError: something the server served does not verify (bad_signature,key_mismatch,binding_mismatch,version_rollback,generation_rollback,audit_mismatch);GalataVaultErroritself for everything refused before a request, among theminvalid_config,credential_literal,unsupported_format,invalid_token_fileandinvalid_environment.
No error message or repr contains a token, a key, a secret value or a
config body.
A server may expire vaults after a period without use (local mode does
not). When it announces an expiry less than 14 days away,
galata_vault.ExpiryWarning is issued.
Notes
- Python strings cannot be wiped from memory, so values you read live as long as your references to them.
- The library never changes process limits or touches the OS keychain; the
gvcommand does. - Supported: CPython 3.11 and later, on Linux and macOS.
Security policy
galata-vault keeps other people’s secrets, so a security report is the most useful thing anyone can send us. This policy says which versions get fixes, how to report, and what happens next.
galata-vault has not been independently audited. Its formats may change before 1.0. Use it at your own risk. This notice is replaced by a link to the report once an external review is published in
audit/.
Supported versions
| Version | Security fixes |
|---|---|
Latest 0.y release | Yes |
Any older 0.y | No: upgrade to the latest 0.y |
Before 1.0, only the latest 0.y release receives security fixes. From
1.0, the latest minor release does, and the previous minor release does too
for six months after its successor ships.
This covers every package the project publishes: the crates on crates.io
(galata-vault and the galata-vault-* crates), the galata-vault Python
package on PyPI, and the gv, gv-server and gv-mcp release binaries.
Reporting a vulnerability
Report privately, through GitHub private vulnerability reporting: https://github.com/sercanatalik/galata-vault/security/advisories/new, the “Report a vulnerability” button on the repository’s Security tab. That is the preferred route: it keeps the report, the discussion and the advisory in one private place.
If that is unavailable to you, or you get no acknowledgement within a week, mail sercanatalik@gmail.com instead. Say only that you have a galata-vault security report and how to reach you; wait for a reply before sending details, since that mailbox is ordinary email and not end-to-end encrypted.
Either way: do not open a public issue, pull request or discussion, and do not post details anywhere else until a fix is released.
Please include:
- the affected crate, package or binary, and its version or commit;
- what an attacker needs (which of the actors in the threat model they are) and what they gain;
- steps or a proof of concept that reproduce it.
Check the finding against the threat model first. It says what each actor (a malicious server or operator, a leaked token, a network attacker, another local user) can and cannot do, and what is out of scope, such as a compromised machine while it is unlocked. A way to do something the threat model says is impossible is a vulnerability. Behaviour the threat model already describes (for example, that a server can withhold data, or that a client with no kept state can be shown an old history) is not, though hardening suggestions are welcome as ordinary issues.
Tell us if an AI tool helped. If you used an AI assistant or automated tool to find the issue or to write the report, please say so, and confirm that you reproduced the finding yourself. This helps us triage, and it is not held against a report.
What happens next
- Response time:
TBD (maintainer). Suggested: an acknowledgement within 7 days and a first assessment within 14 days. galata-vault has one maintainer, so these are promises that can be kept, not a service level. - We confirm the issue, agree a disclosure date with you, and prepare a fix privately (in a GitHub security advisory’s private fork).
- Fixes ship as a patch release of every affected package.
- Every fixed vulnerability in a published crate is published as a GitHub security advisory and filed with the RustSec advisory database (rustsec/advisory-db), with the GitHub advisory id listed as an alias in the RustSec entry. Both name the fixed version.
- You are credited in the advisory unless you ask not to be.
Nothing here offers a bug bounty.
Changelog
Every change to galata-vault that a user would notice, newest first. The
format is Keep a Changelog; the
project follows Semantic Versioning,
with the pre-1.0 rule that a 0.y release may break the API and a 0.y.z
release does not (README.md).
The wire and file formats are versioned separately from the crates, by their own markers; docs/spec/stability.md says what a marker guarantees.
Unreleased
Fixed
- A store refuses, at open, a database whose tables are not its version’s
shape. A 0.4
gv-serverstarted on a data directory written by a pre-release build at the same schema version, listened, and failed at the first write withtable vaults has no column named owner_bundle_sig, which the client saw as an internal error. It now compares every table with what this build’s migrations produce and exits at startup, naming the table, the column and the remedy.
0.4.0 - 2026-09-22
Changed
- Ten published crates became one.
galata-vaultis now the only crate on crates.io;galata_vault::proto,::keys,::seal,::client,::backend(wasgalata-vault-store),::server_core,::server,::cliand::mcpare its modules, each behind the feature that needs it. Code that depended ongalata-vaultand used its re-exports is unaffected. Code that depended on one of the nine directly moves to the matching module:galata_vault_proto::api::Scopebecomesgalata_vault::proto::api::Scope, and so on. The nine names stay on crates.io at 0.2.0 and are not updated again. - The separation the crates enforced is now enforced by features, and
checked.
serverdoes not enablesdk, so a server build compiles no value- or name-crypto code and links none of those crates; the SDK’s default build links no database, HTTP server or async runtime. The four linkage guards are replaced by one,scripts/check-linkage.sh, which reads the dependency graph per feature set and reads the source for a serving module that namescrate::keys,crate::sealorcrate::client– the half the compiler used to do for free. - The binaries moved to unpublished crates (
crates/gv,crates/gv-server,crates/gv-mcp), thin wrappers that exist so each keeps its own release target list.gvis therefore no longer installable withcargo install galata-vault-cli: use the installer from a release, the Python package, orcargo install --git. - Match arms that existed only because an enum was
#[non_exhaustive]in another crate are gone; inside one crate the compiler proves them unreachable, and a new variant now fails the build at each site instead of falling into a fallback.
0.3.0 - 2026-09-21
Changed
- A release is now a tag, and nothing else.
scripts/release.sh <version>sets the version everywhere it is written down, dates the changelog entry, runs the gate, commits and tags; pushing that tag publishes the crates (.github/workflows/crates.yml, onecargo publish --workspace), the wheels and the binaries, in parallel, with nothing left to approve. Both uploads authenticate as their workflow through OIDC, so no publishing token is stored. release-plz and its release pull request are gone, along with the by-hand dependency order the ten crates used to be published in – cargo works that out itself.RELEASING.mdis the whole procedure.
Added
scripts/check-version.sh, which fails the build when the workspace version, the versions its path dependencies carry and the newest changelog entry disagree – or, in a release workflow, when they disagree with the tag that started it. The tree is no longer bumped ahead of a release: it carries the released version, and notes wait under## [Unreleased].scripts/trusted-publishers.sh, which registers this repository as the trusted publisher of every published crate on crates.io in one pass, reading the crate list from the workspace.
0.2.0 - 2026-09-21
The gv ui screenshots in the README and docs/local-ui.md, a logo, and
package badges. Version bumped as a minor release at the maintainer’s
request: the changes below are additive, so a 0.1.x would also have been
accurate.
Added
- The audit chain records a token receiving the vault’s token list, as
token_list(code 13). The owner’s own status reads are not recorded – an environment opens with one, and the chain would fill with them – and a lesser scope reading status is not an attempt to list, so it is not a refusal either.
Changed
scripts/check-packaging.sh verifybuilds every published crate from its own.crateagain. It was skipped for 0.1.0 because verification resolves each crate’s siblings from a registry, and they were not published yet. It no longer passes--offline: a machine that has only built this workspace has never downloaded these crates, because the workspace uses path dependencies.
0.1.0 - 2026-09-21
The first release. Ten crates on crates.io, the galata-vault package on
PyPI, and gv, gv-server and gv-mcp binaries with installers and build
attestations on the GitHub release.
Fixed
- Rotation now reports a lost race as stale, not as a missing secret.
Two owners rotating at once: the loser could fail with
not_found(secret NAME has no version N) instead ofstale_generation. A rotation that lands re-indexes every record under the new generation’s name key, so the loser’s index — computed with the generation it opened — stops resolving partway through gathering. The retry loop could not recover, because it absorbed only a revision conflict. Gathering a record that vanishes now rebuilds, and the generation guard then reports it as stale. No data was at risk: the winner’s chain was always intact and no acknowledged write was lost. The error was the bug. scripts/conformance.shwaited about 10 seconds forgv-server localand reported “the server did not start” when it was merely slow to first-exec after being relinked. The budget is now 60 seconds; a server that really fails still reports immediately, because the wait ends as soon as the process does.- An absent token list no longer reads as an empty one.
VaultStatus.tokensis optional to distinguish “you may not see this” from “there are none”, and two callers flattened that away. One of them was the guard that refuses to mint while a token of a scope this client does not know exists — so it waved everything through in exactly the case it was written for. galata_vault::mcp’s stdio test gavegv-mcp20 seconds to answer whilegv-mcpitself allows 60 for the vault opens it does first, so a slow open failed the test rather than the code. It now waits for the readiness line before speaking the protocol.
Added
- An
admintoken can list and revoke tokens throughgvand the SDK, as the specification has always said it may (docs/spec/http-api.md#2). The server allowed it; only the command line refused, telling the holder that “a token cannot do it, whatever its scope”.gv token lsandgv token revokenow accept one,Vault::tokens()andVault::revoke()give the SDK the same view an owner gets, and revoking through a token raises the same forward-only warning, since a token cannot rotate.gv token revoke --rotatestill needs the owner key, and says why. - Continuous integration:
.github/workflows/ci.ymlrunsscripts/check-all.shon every pull request and push,dco.ymlenforces the sign-offCONTRIBUTING.mdrequires, anddeny.ymlrunscargo deny checkon pull requests and on a daily schedule. deploy/README.md: running a server for machines other than your own, and what this build deliberately does not implement.- This changelog, and
CODE_OF_CONDUCT.md.
Releasing galata-vault
A release is one command and one push:
scripts/release.sh 0.2.0 # bump, changelog, gate, commit, tag
git push origin HEAD && git push origin v0.2.0
The tag is the decision. Nothing after it is approved or clicked.
What the tag starts
| What | Where | Built and published by |
|---|---|---|
galata-vault, the one published crate | crates.io | cargo publish --workspace (.github/workflows/crates.yml) |
gv, gv-server, gv-mcp binaries and installers | the GitHub release | dist (dist-workspace.toml, .github/workflows/release.yml) |
The galata-vault Python package (wheels and sdist) | PyPI | maturin (.github/workflows/wheels.yml) |
The three run in parallel off the same v<version> tag, and each one checks
that the tag and the tree name the same version before it uploads anything
(scripts/check-version.sh, which CI runs on every push too).
Everything else in the workspace is publish = false and never reaches
crates.io: gv, gv-server and gv-mcp (thin wrappers that exist so dist
can give each binary its own target list), gv-py, gv-adversary and
gv-conformance. cargo publish --workspace skips them without being told.
Credentials
| Name | Kind | Used by |
|---|---|---|
| crates.io trusted publisher | per crate: this repository, crates.yml, environment release | the crates.io upload |
| PyPI trusted publisher | project galata-vault: this repository, wheels.yml, environment pypi | the PyPI upload |
HOMEBREW_TAP_TOKEN | secret: fine-grained token, contents: write on the tap repository only | dist’s Homebrew job – only if the tap comes back (dist-workspace.toml) |
No release token is stored. Both uploads authenticate as the workflow
itself, through OIDC, and both are bound to a named workflow file and a named
environment: a credential that leaks out of a different workflow does not
exist to leak. The release and pypi environments restrict deployments to
v* tags, which is what makes pushing the tag the gate.
CARGO_REGISTRY_TOKEN carried the 0.1.0 publish, because trusted publishing
cannot create a crate that does not exist yet. Every name exists now, so the
publisher takes over and the secret goes.
Registering the trusted publishers
One published crate means one configuration.
scripts/trusted-publishers.sh makes it from the workspace, so if a second
crate is ever published it is registered by rerunning this:
scripts/trusted-publishers.sh # what it would register
CRATES_IO_TOKEN=cio... scripts/trusted-publishers.sh --apply
CRATES_IO_TOKEN=cio... scripts/trusted-publishers.sh --list
That token is a crates.io API token used once from a laptop and revoked afterwards; it is not the publishing credential and belongs in no secret. The web UI does the same thing one crate at a time, under Settings -> Trusted Publishing on each crate, which is also where the entries are checked and removed:
| Field | Value |
|---|---|
| Repository owner | sercanatalik |
| Repository name | galata-vault |
| Workflow filename | crates.yml |
| Environment | release |
All four must match what the workflow actually is, and the environment is
the part that is easy to leave blank: an empty environment would let any
v* tag run of crates.yml publish, which is exactly the binding being
bought here. Afterwards, delete the now-unused secret:
gh secret delete CARGO_REGISTRY_TOKEN.
What scripts/release.sh does
- Refuses a dirty tree, and a tag that exists here or on the remote.
- Sets
[workspace.package] versionand every workspace path dependency. - Turns
## [Unreleased]inCHANGELOG.mdinto today’s entry, and writes its link definitions. An entry already written by hand for that version is kept and dated. cargo update --workspace, soCargo.locknames the new version.scripts/check-version.sh, thenscripts/check-all.sh– the gate CI runs.--no-verifyskips the second one; nothing else does.git commit -sand an annotated tag. It never pushes.
Between releases the tree carries the version that was last released, and
notes accumulate under ## [Unreleased]. Nothing is pre-bumped; the version
and the changelog move together, in the release commit, and
scripts/check-version.sh fails the build if they ever do not.
Raising the MSRV is a minor release and gets a changelog line. In 0.x, a
0.y bump is breaking and a 0.y.z bump is not; CI’s semver job
(cargo-semver-checks) fails a pull request that breaks a published API
without one.
When a release goes wrong
Each of the three uploads is independent, and each refuses to overwrite, so recovery is always “rerun the part that failed”.
- crates.io: rerun the failed
cratesrun. Cargo refuses a version that is already there, so the crates that landed stay landed; if a later crate in the set is broken, fix it, release the next patch version, and leave the half-published one alone. Never yank to “retry”: a published version stays published. - dist: rerun the failed jobs of
release.ymlfor the tag. To rebuild from scratch, delete the GitHub release (not the tag) and rerun. - PyPI: rerun
wheelsby hand withpublishset. A file already on PyPI is refused; nothing is overwritten.
A release that is broken or insecure is yanked, never deleted, and the
fix ships as a new patch release: cargo yank --version <v> <crate> for
every crate in the set, yank the PyPI release with a reason, and mark the
GitHub release as not the latest, saying why in its notes. A security fix
also gets a GitHub security advisory and a RustSec entry (SECURITY.md).
Verifying a release
gh attestation verify <binary> --repo sercanatalik/galata-vaultfor each binary (dist attests every artifact);cargo audit bin <binary>lists the dependencies compiled into it (cargo-auditable);- every install line in the README works on a clean machine.
Still to do
- Homebrew. The tap does not exist and no README offers
brew install. To add it: createsercanatalik/homebrew-tapand itsHOMEBREW_TAP_TOKEN, restore the three linesdist-workspace.tomlnames, regeneraterelease.ymlwithdist generate, and put the install lines back. - The response time in
SECURITY.md. .github/workflows/release.ymlis generated: after changingdist-workspace.toml, rundist generateand commit the result. Itsplanstep fails when the two disagree.
1.0
1.0 waits for three things: the external review published under audit/
with every high-severity finding fixed, the format declared frozen in
docs/spec/, and OpenSSF Best Practices “passing”.
Contributing to galata-vault
Thank you for helping. galata-vault is a library first: an end-to-end-encrypted
secrets SDK (galata-vault), the gv command line built on it, a server that
cannot read what it stores, and a Python package. Its promises are specified
in docs/spec/ and docs/threat-model.md,
and ARCHITECTURE.md maps the crates.
Security issues are not contributions: never open a public issue or pull request for one. Follow SECURITY.md.
Terms: the Developer Certificate of Origin
Contributions are accepted under the Developer Certificate of Origin 1.1 (DCO). There is no contributor licence agreement. By signing off a commit you certify the DCO for it: that you wrote the change, or otherwise have the right to submit it under the project’s licence.
Sign off every commit with git commit -s, which adds a line with your name
and email:
Signed-off-by: Your Name <you@example.com>
Use a name and address you are happy to publish; they become part of the
public history. A CI check (.github/workflows/dco.yml) refuses a pull
request containing a commit without a Signed-off-by line, and names the
commit. To fix one, git commit --amend -s, or git rebase --signoff for a
series, and force-push the branch.
Contributions are inbound = outbound: unless you say otherwise in the pull request, what you submit is licensed under the project’s terms, MIT (see LICENSE-MIT), with no additional terms or conditions. The project will not relicense to a more restrictive licence; that is why it asks for a DCO and not a CLA.
If an AI assistant helped write a change, say so in the pull request. You are still the one certifying the DCO, and you are expected to understand and stand behind every line.
Before you open a pull request
One command runs what CI runs:
scripts/check-all.sh
It runs, in order:
- every structural guard in
scripts/check-*.sh(the linkage guards that keep decryption code out of the server and the MCP server and host-process code out of the SDK, the packaging guard, the spec label registry, the response types), each also proved able to fail byscripts/test-guards.sh; cargo fmt --check, clippy with-D warningsfor every feature set, and every test;- the adversarial suite against a malicious server, and its plant self-test;
- the spec tables, the protocol vectors (Rust, and the independent Python generator), and the conformance suite;
- the Python package end to end.
Without uv or maturin, the checks that need them print SKIPPED, which
is never a pass; CI has both. Run cargo fmt --all
before committing.
Keep a pull request to one change. Say what it changes and why; link the
issue it closes. User-visible
changes get a line under Unreleased in CHANGELOG.md.
Changing a format starts in the spec
Formats (strings, framing, descriptors, records, bundles, kits, the audit
chain, the HTTP API) are specified in docs/spec/.
Code follows the spec, never the other way round:
- Change the specification first, in the same pull request or one before
it. A new format gets a new version string; an existing one never changes
silently (
docs/spec/stability.md). - Regenerate the test vectors with the independent Python generator
(
uv run --with cryptography --with pynacl scripts/vectors/generate.py), which shares no code with the Rust crates. Never edit a vector file by hand. - Change the Rust code until
scripts/check-all.shpasses: the vector tests, the spec tables, the label registry guard and the conformance suite all read the spec or the vectors.
Data written in a format version stays readable by every later release of the same major version. A change that would break that is a new format.
Crate boundaries are the security design
Some crates must never link some code, and the guards fail the build if they do: the server crates link no decryption, the MCP server links no value crypto, the client transport links neither value crypto nor host-process code, and the SDK links no keychain, prompt, CLI or Python code. If a change needs to cross one of those lines, open an issue to discuss it first.
Code of conduct
Everyone taking part is expected to follow the Code of Conduct.
Contributor Covenant Code of Conduct
Our Pledge
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
Our Standards
Examples of behavior that contributes to a positive environment for our community include:
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
- Focusing on what is best not just for us as individuals, but for the overall community
Examples of unacceptable behavior include:
- The use of sexualized language or imagery, and sexual attention or advances of any kind
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others’ private information, such as a physical or email address, without their explicit permission
- Other conduct which could reasonably be considered inappropriate in a professional setting
Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
Scope
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official email address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at sercanatalik@gmail.com.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the reporter of any incident.
Note that a security vulnerability is not reported here. Report one privately, as SECURITY.md describes.
Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
1. Correction
Community Impact: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
Consequence: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
2. Warning
Community Impact: A violation through a single incident or series of actions.
Consequence: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
3. Temporary Ban
Community Impact: A serious violation of community standards, including sustained inappropriate behavior.
Consequence: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
4. Permanent Ban
Community Impact: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
Consequence: A permanent ban from any sort of public interaction within the community.
Attribution
This Code of Conduct is adapted from the Contributor Covenant, version 2.1, available at https://www.contributor-covenant.org/version/2/1/code_of_conduct.html.
Community Impact Guidelines were inspired by Mozilla’s code of conduct enforcement ladder.
For answers to common questions about this code of conduct, see the FAQ at https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations.