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.

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 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