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
| Check | Why |
|---|---|
| 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 supported | Because |
|---|---|
| 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 |