v1.2.1

The database alone is useless.

A secret and parameter store that boots sealed. The root key exists only in locked memory, split across share holders. Every value is sealed under its own data key, bound to the tenant, path and version it belongs to, and every access is recorded in a log the store refuses to run without.

Boots sealed A restart is an outage until a quorum of share holders is available. That is the design.
Bound ciphertext Move a value to another tenant, path or version and it stops decrypting.
Auditing cannot be turned off A record that cannot be written refuses the request. A failing sink seals the store.
One dependency A cgo-free SQLite driver. Everything else is the standard library.

Watch it run

A store is initialised into five shares and is live straight away, because the key it just made is still in memory. Then the process is restarted, and it comes back sealed — which is the whole design, and the part worth watching. Three shares walk it back open one at a time. A secret is written and read back in full, and then every file in the data directory is searched for that value and none of them holds it.

A terminal session unsealing a store with a quorum of shares, writing and reading a secret, then finding no trace of its value anywhere in the data directory
Recorded by site/demo.sh into demo.cast, an asciinema recording that is plain text you can read line by line. The script refuses to reach the search step unless the read actually returned the value first, because a search that finds nothing proves nothing if the write never happened.
asciinema rec --command ./site/demo.sh site/demo.cast
agg site/demo.cast site/demo.gif

What it is

One binary, marsec, holding two things an application needs and should never carry in its image: secrets, which are encrypted and versioned, and parameters, which are typed configuration that inherits towards the tenant root.

The architecture is a modular monolith. A secret store is a single consistency domain, so splitting it into services would buy nothing and cost a distributed transaction. Modules do not import each other; the boundary is enforced by a script that runs on every commit.

Linux only. macOS is a development platform. Memory locking, disabled core dumps and an undumpable process have no equivalent there, and the store refuses to start without them unless explicitly told otherwise.

Install

Download an archive from the releases page, or build from source:

git clone https://github.com/MarStack-Labs/marstack-secrets.git
cd marstack-secrets
make build

Requires Go 1.26.6 or newer. Earlier 1.26 patch releases carry standard library vulnerabilities reachable from the TLS listener.

First run

The store starts sealed and unable to decrypt anything. Initialise it once:

curl -s -X POST https://secrets.internal:8200/v1/sys/init \
  -d '{"shares":5,"threshold":3}'

The shares are returned once. They are not stored, not recoverable, and not shown again. Distribute them before you close the terminal.

Then, from each share holder in turn, until a quorum is reached:

curl -s -X POST https://secrets.internal:8200/v1/sys/unseal \
  -d '{"share":"<share>"}'

A wrong quorum discards every collected share and starts again from zero, so a mistake costs a round rather than corrupting anything.

Identities and policy

The first credential comes from the host, not from the network. marsec operator writes to the database directly and does not need the store to be unsealed.

marsec operator identity add service/ci --tenant prod
marsec operator policy put app --tenant prod --rules rules.json
marsec operator policy bind service/ci --tenant prod --policy app
marsec operator bootstrap service/ci --ttl 10m

The bootstrap token is single use. Trading it for a session token consumes it in the same statement that reads it, so two racing clients cannot both win.

Policy is default deny. An explicit denial wins outright; otherwise the most specific matching rule decides alone, so the order rules are written in has no effect. Tenant isolation is checked before any policy is loaded.

POST /v1/sys/policies/check

explains a decision to the caller it concerns, naming the policy and the rule that decided.

Secrets

Versioned, with check-and-set, reversible delete, and irreversible destroy. Authorization runs before storage is touched, so a refusal is identical whether the path exists or not.

printf 'db_password=s3cr3t' | marsec secret put prod/payment-api
marsec secret get prod/payment-api

Every value is sealed under a fresh data key, which is wrapped by a key encryption key derived from the root. The associated data binds the ciphertext to its tenant, path and version, so a row copied to another location fails to decrypt rather than revealing anything.

--value exists and warns about itself. A value on the command line is visible in ps and in shell history. Prefer standard input.

Parameters

Typed configuration, validated on write, resolving upwards towards the tenant root. Asking for apps/payment/log_level falls back to apps/log_level and then log_level, and never leaves the tenant.

marsec param put prod/app/log_level --kind string --value info
marsec param get prod/apps/payment/log_level --verbose

A parameter can refer to a secret. The reference is authorized separately, on the secret's own path, and is never resolved recursively.

Leases

Every read records who holds which version until when. That record is what makes revocation possible: revoking a prefix or an identity takes the holders' session tokens with it.

curl -s -X PUT https://secrets.internal:8200/v1/sys/leases/revoke-prefix \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"prefix":"secret/prod/payment"}'

A lease is reused per identity, path and version, enforced by a partial unique index, so a read loop cannot grow the table without bound.

Rotation

Rotating raises the key encryption key version and rewraps every stored data key onto it. The payload is never re-encrypted, so the cost is one key unwrap per value rather than one re-encryption.

curl -s https://secrets.internal:8200/v1/sys/seal-status | jq .kek_version
curl -s -X POST https://secrets.internal:8200/v1/sys/rotate \
  -H "Authorization: Bearer $TOKEN" \
  -d '{"current_version":1}'

The request must name the version it believes is current. A mismatch is a conflict and nothing moves, so a replayed or accidental call cannot rotate anything.

Rotation reaches every tenant. Every other authorization here is scoped to one tenant; this one is not. An identity granted write on sys/rotate rewraps values it cannot read. It never decrypts them, and a test holds both halves of that, but grant the capability as an operator power rather than a tenant one.

It also does not rotate the root key. Every key encryption key version derives from the same root, so rotating limits a leaked derived key and does nothing about a compromised root.

The console

A browser interface at /ui/ that unseals, reads and writes secrets and parameters, lists leases, explains a policy decision, and rotates. It is off unless asked for:

MARSEC_UI_ENABLED=true marsec server

It serves three files and registers no API route, so it grants nothing of its own. The browser calls the same endpoints with your own token through the same policy checks, and an identity with no policy is refused there exactly as it is refused by curl.

There is no framework and no build step, which keeps the binary one file and lets the content security policy be default-src 'none' with no unsafe-inline anywhere. The session token lives in a JavaScript variable and nowhere else, so reloading the page signs you out.

A value on a screen can be photographed, and the clipboard is outside the process boundary. Enabling the console opens a channel this store otherwise does not have; the trade is recorded rather than absorbed.

The marstack-secrets console initialising a store, showing the five shares, coming back sealed after a restart, and being unsealed one share at a time
Driven by site/ui-demo.sh through Playwright, which restarts the server mid-recording so the console is seen going from unsealed back to sealed and open again. Regenerate it with ./site/ui-demo.sh; it needs npm install playwright.

Numbers

Everything below is measured by scripts/bench.sh in this repository and read from metrics.json when the page loads. Nothing here is typed in by hand, so a figure that has not been measured on the current build shows a dash rather than a number somebody remembered.

One binary
—
server, operator tool, client and agent
Direct dependencies
—
— modules in the whole graph
Start to serving
—
sealed and refusing to decrypt, on an empty data directory
Unseal
—
a quorum of — shares out of —
Secret read
—
p50, p99 —, decrypting every time
Secret write
—
p50 over — secrets, p99 —
Parameter read
—
not encrypted, which is why it is the faster of the two
Audit chain verify
—
over — records, hash by hash

Measured on —, —, build —. —, — runs per figure. Last measured —.

The latency figures are taken with the per-caller rate limit raised, so that they measure the store rather than its limiter. That limit is a real default and this build ships — requests per minute per caller; a client that ignores it will meet it long before it meets these numbers.

One machine, loopback, nothing else running, and a store holding a few hundred entries. These are not a promise about your network or your fleet. The script is in the repository so you can disagree with them on yours.

What the shape buys

Speed is the least interesting thing here. What matters is what an attacker gets for each thing they manage to steal.

The usual arrangementThis one
The store holds the key it decrypts with, so the process can always read everything It starts sealed. The key is split, and a quorum has to reassemble it before anything decrypts
A stolen database is a stolen estate A stolen database decrypts to nothing. The demo above searches every file for a value it just read back
Access is granted for as long as nobody revokes it Access is a lease with a deadline, so revoking a scope is a thing you can actually do
An audit log is a file the server appends to and could rewrite A hash chain the server refuses to run without, and that anyone can verify offline
Dozens of dependencies, each one surface you did not write — direct dependency, on a product whose whole job is holding keys

The audit log

Append-only, hash chained, fsynced before the response returns, and carrying no value. A record that cannot be written refuses the request, and a failing sink seals the store.

marsec operator audit verify

The chain detects a record that was edited, removed or reordered. It does not detect a rewrite of the whole file by whoever can write it, because the chain is recomputed from the same data. There is a test that proves this rather than a claim that implies otherwise.

The agent

Renders secrets into files an unmodified application already reads, and runs a reload command when they change.

marsec agent --config /etc/marstack-secrets/agent.json

The rendered file is written atomically, and it is the only cache. A second copy of a secret on disk would double the exposure to halve an inconvenience, so there is no separate cache to leak.

Before production

CheckWhy
TLS certificate and key are set the store refuses to start otherwise, and plaintext HTTP needs an explicit switch
Swap is disabled a second line behind mlock; the root key must not reach a disk
The audit disk has room a full disk stops the store, because failing to record is failing to serve
Shares are distributed to different people a quorum in one drawer is a root key in one drawer
A restore has been rehearsed make drill proves the code path; only you can prove the runbook
MARSEC_UI_ENABLED is unset unless wanted the console decrypts values onto a screen

What it will not do

These are decisions with reasons, not a backlog.

Not supportedBecause
Auto-unseal it moves the root key to whatever would perform the unsealing, which is then the thing to attack
Root key rotation replacing the root means re-splitting every Shamir share; the key encryption key rotates instead
Response wrapping dropped rather than deferred: every channel it would protect is already closed
Turning auditing off there is no variable for it; a store that cannot record is a store that cannot serve
Parameter versioning and therefore parameter rollback; secrets are versioned, configuration is not
High availability, dynamic secrets, PKI, transit deferred beyond v1 from the start; auto-unseal is a prerequisite for the first, not an extra