An access platform is a single point through which privileged traffic flows. That makes it the most valuable host in an estate, and it means security here is a property of the architecture rather than a hardening pass at the end.
The design question this document answers is not "how do we keep the gateway from being breached". It is "what does an attacker get when the gateway is breached" — and the answer must be far less than the estate it protects.
| Adversary | Can do | Must not be able to |
|---|---|---|
| Authenticated platform user | open the sessions they hold a grant for | reach a target outside that grant, land as a principal they were not granted, or open a session that is not recorded |
| Compromised data plane node | observe and proxy the sessions currently flowing through it | reach a target with no active grant, recover the CA private key, or sign a certificate without leaving a record elsewhere |
| Compromised control plane | change policy for future sessions | forge or alter a past audit record, or recover a credential that authenticates to a target |
Whoever reads a target's sshd config |
learn which CA the target trusts | derive anything that authenticates to that target |
| Network observer | see ciphertext and connection timing | read session content or replay a session |
| Whoever reads the logs or the database | read operational metadata | recover a certificate, token, password, or private key |
Out of scope for now: an adversary with physical access to a data plane node, and an adversary who already holds root on the target they are attacking.
These hold everywhere. A change that weakens one is a design change, not a refactor.
-
Deny by default. A request with no matching authorization rule is refused. A new endpoint is unreachable until it declares who may call it. A session with no active grant does not open.
-
Validate at the boundary, then trust the value. Input is parsed into a typed value once, at the edge, with unknown fields rejected. Deeper layers never re-parse strings from clients.
-
No standing credential to a target exists in the database, and none on the production path. There is no target password and no target private key anywhere, and nothing in the database can authenticate to any target. This is why the platform is a certificate authority client and not a credential vault.
The signing key is the one credential that could mint access, and the half of this invariant that covers it is not built yet. The intended design is that the key lives in
marstack-secretsbehind a signing call, so the gateway sends a public key and gets a certificate back and never holds the key at all. No such call exists:marstack-secretstoday stores and returns values and has no signing engine, and this repository has no client for it.What exists is a development mode, and it is the only mode.
certs.FileSignerloads a signing key from a local file, so a deployment that wants working sessions holds a key that can mint access to every target that trusts the CA. Anyone who can read that file, or the memory of this process, has it.It cannot happen by accident. The key has to be created by name with
marac ca init --pathand the server pointed at it explicitly; a deployment that never passes that path holds no signing key and cannot open sessions at all, which is the configuration this invariant describes fully. Startup logs the fingerprint atWARNwith a note saying where it belongs, so the trust boundary is visible rather than assumed.Read the first sentence of this invariant as true today and the signing key as the named exception, not as a detail. It is stated at this length because an invariant that quietly stops being true is worse than one that was never claimed.
-
Identity is derived, never asserted. The principal a session lands as comes from the authenticated identity and the matching grant, never from a field in a request body. This is the single most common source of privilege-escalation bugs in platforms of this kind.
-
A session that is not recorded does not open. A recorder that fails to initialise fails the session. Recording is not best-effort, and there is no flag to disable it, because a flag eventually defaults wrong.
-
Authority is scoped when it is minted, not checked when it is used. A certificate carries one principal, one target, one session id, a validity window in minutes, and a
source-addresspinning it to the node that requested it. A leaked certificate is useless from anywhere else and expires before it can be studied. -
The certificate never leaves the data plane. The user receives a connection, never a credential. This is what makes recording unbypassable and revocation immediate.
-
Audit leaves the process before it is worth deleting. Events ship to an append-only sink and recordings to object storage under an object lock. Neither plane holds a delete credential, so an attacker who owns the gateway still cannot erase what it already reported.
-
Secrets never reach a log or an error body.
5xxresponses carry a code and a request id; the detail stays server-side. Certificates, tokens, and key material are never logged at any level, including debug. -
Nothing is executed through a shell. Host commands are built as argument vectors. No string interpolation into a command line, ever.
| Invariant | Enforced by |
|---|---|
| Deny by default | kernel/fault defaults to Internal; authorization middleware refuses routes that declare no policy |
| Validate at the boundary | httpx.Decode sets DisallowUnknownFields and caps body size; kernel/validate |
| No standing credential | there is no schema for one; the signing key is the exception — it sits in a local file and is not yet behind a call in marstack-secrets |
| Derived identity | authorization middleware puts the identity on the request context; handlers read it from there |
| Recording is mandatory | the recorder is opened before the target is dialed, and a failure to open it refuses the session |
| Scoped authority | one place builds the certificate request; principal, target, TTL, and source address are all required fields |
| Certificate stays inside | the mint call returns a connection, not a credential; nothing serialises a certificate to a response |
| Audit ships out | kernel/audit sinks expose Append and nothing else, and an architecture test fails the build if one grows a delete, remove, truncate, purge, rotate, prune, or clear method |
| No secret leakage | httpx.Wrap never writes fault.Unwrap() to the client |
| No shell execution | os/exec with argument slices; gosec flags a shell invocation |
This repository is mostly privilege boundaries and network plumbing, so these rules are specific and non-negotiable.
- Host keys are verified.
ssh.InsecureIgnoreHostKeynever appears, and an architecture test fails the build if it does. A target's host key is pinned before any session can open, and replacing a pin has to be asked for. crypto/randonly.math/randhas no legitimate use in this repository.- Secrets compare in constant time. Token and MAC comparison uses
crypto/subtle. - Every connection carries a deadline. A
net.Connwithout a read and write deadline is a resource leak that an unauthenticated peer controls. - Validity windows come from one place. A TTL is never written as a literal at a call site.
- A parser reads from an untrusted peer with a bounded buffer. Protocol code caps every length it reads before allocating.
A token is mat_<selector>_<verifier>: a 4-character prefix, an 80-bit selector, and a 160-bit
verifier, all from crypto/rand.
Lookup is by selector, comparison is on the verifier. The selector is stored in plaintext under
a unique index, so finding the row is one indexed read. The verifier is stored as a SHA-256 digest
and compared with crypto/subtle.ConstantTimeCompare.
The obvious simpler design — hash the whole token, index the hash, look it up — also works and is one column shorter. It was rejected for two reasons. It hands the secret comparison to SQLite, so the constant-time rule in this document would be documented and never exercised. And it leaves nothing safe to write down: the selector is a non-secret handle that can appear in a log line or an audit record to name which token acted, and a hash-only scheme has no such handle.
SHA-256, not bcrypt or argon2. Slow hashing exists to make low-entropy human passwords expensive to guess. The verifier is 160 bits of random data, so a slow hash would add latency to every authenticated request and remove nothing an attacker could otherwise do. Using argon2 here would look more careful and be strictly worse.
Every authentication failure is the same failure. Malformed, unknown selector, wrong verifier, expired, revoked, and disabled user all return one code and one message. A distinguishable response tells an attacker that the selector half was correct, which turns one unguessable secret into two guessable ones. There is a table-driven test asserting all six are byte-identical.
The secret is returned once, at issue, and never again. Nothing stores it, nothing logs it, and no response can produce it a second time. Losing it means issuing a new one.
A recognisable prefix is a security feature. mat_ makes a leaked token detectable by a secret
scanner that has never heard of this project. .gitleaks.toml carries a rule for the format, so a
token pasted into this repository fails the commit. The rule was verified against a token the
running binary actually issued, because a scanner rule that matches nothing is worse than no rule —
it reports success.
The console at /console/ is a browser client of the same API. It gets no privileged path, no
handler of its own beyond static files, and no way to reach a module directly — see
"The console is a client, not a module" in docs/ARCHITECTURE.md.
Putting a token in a browser creates three problems the CLI does not have. Each is answered separately, because any one of the three answers alone is insufficient.
The page must never hold the token. POST /v1/console/session takes the token once and returns
a cookie: HttpOnly, Secure, SameSite=Strict, path /. HttpOnly is the load-bearing flag —
it means a script injected into the page cannot read the credential, so an XSS becomes a
same-session problem rather than a permanent stolen token. The cookie carries the token itself
rather than a new session identifier, which keeps revocation exact: deleting the token closes the
console at the same instant it closes every CLI using it, with no second table to keep in step.
console.js never constructs an Authorization header and never touches localStorage; a test
asserts both.
A cookie is sent by the browser whether or not the page asked. That is what makes CSRF work, so
the cookie alone authenticates nothing. authz accepts it only when X-Marac-Console is also
present. A cross-site form post or image tag can send the cookie; it cannot set a custom header
without a preflight this origin never grants. SameSite=Strict should stop the request first — the
header requirement is the layer that still holds if a browser, a proxy, or a future same-site
subdomain gets the first one wrong. Bearer tokens are unaffected: a request with an Authorization
header is authenticated the way it always was, so marac and curl never send this header.
Sign-in is refused over cleartext. The cookie is Secure unconditionally, so a browser on a
plain-HTTP origin would discard it and leave the operator retyping a token into a page that never
works. Rather than fail that way, POST /v1/console/session returns 403 insecure_transport
unless the connection is TLS or the peer is on loopback. The standard deployment — TLS terminated
by a proxy on the same host, forwarded to the gateway over 127.0.0.1 — satisfies the loopback
case. X-Forwarded-Proto is deliberately not consulted: it is a header any client can set, and
trusting it would turn the check into decoration.
The page runs under a policy with no inline anything. default-src 'self' with no
unsafe-inline and no unsafe-eval, plus base-uri 'none', object-src 'none' and
frame-ancestors 'none'. CSS and script are separate files for that reason rather than for tidiness.
Tests fail the build if the HTML grows an inline <script>, an on…= attribute, or a <style>
block, because a page can carry all three and look perfectly correct in review while being dead in
a browser. The console handler answers GET and HEAD and rejects every other method.
Signing out clears the screen, not just the cookie. The response expires the cookie, and the page drops the cached sessions, requests, and identity before showing the sign-in dialog. Otherwise the next person to sign in on that machine reads the previous account's data in the gap before the first refresh returns.
The console is not a way around a role. Every list it renders is the same guarded endpoint the CLI calls, so a viewer signing in sees the gateway is alive and is refused the session list — the page says so rather than rendering an empty table that implies there is nothing to see. A requester looking at their own pending request is offered "withdraw" and a chip reading "yours" instead of approve and deny, which shows the self-approval rule rather than hiding it behind a 403 the operator has to provoke. The page hiding a button is presentation, not enforcement: the endpoints refuse the same call regardless, and "Just-in-time access" below is where that is proved.
Roles are viewer, operator, admin, ranked in that order, and a check means at least. An
unrecognised or empty role satisfies nothing — a row whose role column is blank cannot pass the
lowest bar.
A route that declares no policy fails the build. Invariant 1 is enforced structurally: every
route in a platform module is wrapped in Require(role, …) or authz.Public(…), and an
architecture test parses the AST of every module to prove it. Public is a runtime no-op whose only
job is to make "anyone may call this" a written decision. The gap this closes is the one that
matters — a new endpoint added in a hurry is unreachable-by-default rather than open-by-default.
401 and 403 are deliberately distinguishable. Uniformity applies to token validity, where any signal helps a guesser. It does not apply to authorization: a caller who has proved who they are learns nothing dangerous from being told which role they lack, and telling them saves a support round trip. The 403 body names the required role and never echoes back the caller's own name, id, or role.
An authenticator that fails denies. If the store is unreachable, Require returns a 500 and the
handler never runs. There is no code path where an error in authentication falls through to the
protected handler.
Require panics at wiring time on an unknown role. A typo in a route's role would otherwise
produce an endpoint satisfied by nobody, which reads as a permissions bug rather than a typo and
could sit unnoticed for a long time. Failing at startup makes it a two-second fix.
The current split is operator for targets, admin for identity. Registering a target expands what
the platform can reach, so it sits at operator. Deleting one only removes a record and makes the
target unreachable through the platform, which fails safe, so it needs no higher bar than
registration. Creating users and issuing tokens is admin, because an operator who could do it
could mint itself an admin.
Authorization on the API answers "may this caller use this endpoint". Policy answers a different question: "may this caller reach that host as that account". A role never implies the second.
A policy binds one subject — a user id or a role — to one target, with an explicit set of principals. Evaluation is a single indexed lookup and returns the id of the rule that allowed it, so a decision can always be traced to the line that made it.
Roles are matched exactly here, not by rank. This is the opposite of the API authorization rule
in the section above, and the difference is deliberate. Require(operator) is a privilege level, so
an admin satisfying it is correct. A policy is an assignment, and rank inheritance would mean every
admin silently holds every grant any lesser role was ever given. The platform administrator is not
automatically root on every host in the estate, and the blast radius of a stolen admin token stays
bounded by the policies written for admins specifically.
There is a test named TestARoleIsMatchedExactlyAndNotByRank, because this is exactly the kind of
rule a future reader would "fix" as an inconsistency.
Deny by default, and say why. No matching rule is a denial, and the denial carries a reason. An undiagnosable denial gets worked around by granting too much.
A policy cannot name a principal the target refuses. Policy consults the live target inventory
through an interface at create time rather than keeping its own copy. A rule granting root on a
host that only accepts deploy would otherwise sit in the inventory looking like access somebody
has, and be discovered only when a session failed.
A stale policy is inert, not dormant. Deleting a target leaves its policies behind with no
foreign key to clean them up. They can never match again, because target ids are 80 bits of
crypto/rand and are never reused. With sequential ids the same row would become a live grant the
day the counter came round.
Writing policy is admin-only. An operator who could write policy could grant itself any principal on any target, which makes the operator role equal to admin.
Policy says what may be granted. An approved request is what makes it live. A session needs both, so a standing policy is a bound rather than an entitlement.
A request nobody could ever approve is refused when it is raised. Policy is checked at create time using the requester's live identity. Without that check, an approver could rubber-stamp a request that policy forbids, and approval would become a way around policy rather than a gate in front of it.
Self-approval is impossible, including for admins. The account that raised a request cannot approve or deny it, whatever role it holds. Denial is included deliberately: letting a requester close its own record would remove it from an approver's queue. The bootstrap admin is not an exception, which is why the platform is not usable as a single-person system without a second account — that is the point of the control, not an oversight.
Cancelling and denying are different acts by different people. Only the requester may cancel; only a non-requesting admin may deny. An admin who cancels instead of denying leaves no decision record.
The requester comes from the token. There is no field for it, and a body carrying requester_id
is rejected outright by DisallowUnknownFields. This is invariant 4 in the one place it is easiest
to get wrong.
One operator cannot see another's request, and the refusal is a 404. A 403 would confirm the id exists, which leaks who is asking for access to what and when. Admins see everything, because they have to decide.
Expiry is derived from the clock, not stored as a state. Nothing writes expired to a row. A
pending request past its window and an approved request past its grant both report expired when
read, and neither grants anything. There is no reaper job, so there is no reaper that can stop
running and leave a live grant behind.
The grant lookup checks the clock twice. The SQL filter compares RFC3339 strings so the index is
usable, which is correct only while every timestamp is written as UTC. The parsed time.Time is
then checked again in Go. A row stored with a +07:00 offset sorts after a UTC string for the same
instant and would otherwise read as unexpired — there is a test that writes exactly such a row and
was confirmed to fail with the second check removed.
Two overlapping grants resolve to the longer one. The query orders by expiry descending, so a later, longer approval extends access rather than being shadowed by an earlier short one.
An API token authenticates a caller to the control plane. An SSH public key will authenticate a user to the data plane, because that is how SSH clients already work and it means no secret crosses the wire at all.
A fingerprint is globally unique. Two accounts cannot register the same key. If they could, a connection would resolve to two identities and the platform could not say who acted — which is the one thing an access platform exists to answer.
Weak and deprecated algorithms are refused at registration. ssh-dss is not in the accepted
set, and an RSA key under 2048 bits is rejected with its actual bit length in the message. Refusing
at registration rather than at connection time means the operator learns immediately, from the
person adding the key, rather than from a failed session weeks later.
A key carries no privilege of its own. It resolves to an account, and the account's role and policies decide everything. Registering a key never changes what an account may do.
The stored authorized_key line never appears in a response. Listings carry the fingerprint,
which is what an operator needs to match a key to a client, and nothing that could be replayed
elsewhere. A public key is not a secret, but echoing key material back is how inventories become
copies of things better kept in one place.
Fingerprints are ssh.FingerprintSHA256, verified against ssh-keygen -lf. The value an
operator sees in marac key list is byte-identical to what their own tooling prints, so matching a
key by eye is reliable. That was checked against a real generated key rather than assumed.
The CLI takes a key on stdin rather than opening a path. gosec flagged the file read as G304,
and the fix was to remove the file open rather than annotate it — an exclusion for path handling in
the CLI would also hide a real traversal bug in server code later. Piping is also the better Unix
design: the key can come from a file, a clipboard, or ssh-add -L.
A user connects with ssh <principal>:<target>@gateway and no client software. The gateway
authenticates the public key, resolves the destination, and runs policy and grant checks before any
target is touched.
Only the key check happens during the handshake. An unregistered key gets Permission denied (publickey) and learns nothing about what exists. Everything after that runs when the session
channel opens, and the reason is written to the client's stderr — the same 401-versus-403 reasoning
as the HTTP API. Someone who has proved who they are gains nothing dangerous from being told which
check refused them, and a blank refusal is what makes people ask for broader access than they need.
Port forwarding is refused, in both directions and at both layers. tcpip-forward global
requests are answered false, and direct-tcpip channels are rejected as administratively prohibited. A gateway that forwards ports is a route into the network that no policy describes and
no recording captures. Verified with a real OpenSSH client: ssh -R fails at setup, and ssh -L
binds locally as OpenSSH always does but the channel dies on first use with only session channels are permitted.
Agent forwarding, X11, and subsystems are refused. auth-agent-req@openssh.com is the important
one: forwarding an agent into a session lets whatever is on the far side reuse every other key the
user holds. subsystem covers sftp, which would be file transfer with no recording.
Refusing everything is not safer than refusing the right things. The first version rejected every
session request including shell, which meant a refused user saw an empty EOF instead of a reason.
shell, exec, pty-req, env, and window-change are accepted because they are how a terminal
is set up; the capability grants above are what get refused.
A session that never starts is closed. If no shell or exec arrives within the start timeout,
the channel is dropped rather than held open.
The handshake is on a deadline; the session is not. An unauthenticated peer gets 30 seconds and three auth attempts. The deadline is cleared once the handshake completes, because an interactive shell is legitimately idle for long stretches — the unauthenticated phase is the part an attacker controls.
The host key is generated once, at mode 0600, and a corrupt one is a hard failure. Silently generating a replacement would invalidate every client's stored key at the same moment, which is indistinguishable from a man-in-the-middle and teaches operators to click through the warning. The fingerprint is logged at startup so it can be pinned.
A target's identity is a pinned host key. A target registered without one is reachable by nobody: the
data plane refuses with host_key_not_pinned rather than connecting to whatever answers.
Pinning is a separate act from registering, and that is deliberate. Registering a target is
inventory; pinning is a claim about which machine that inventory entry means. Requiring the key at
registration would look stricter and buy nothing, because the usual source is ssh-keyscan, which is
itself trust-on-first-use — it simply reports whatever the host offered. Moving that step into the
operator's shell does not make it verified. What is worth having is the second half: once pinned, a
change is refused.
Replacing a pin needs --replace. A silent replacement is how a man-in-the-middle becomes
permanent: an attacker who can answer for the address once gets their key written down as the truth.
Re-pinning the identical key is also a conflict, because a caller that cannot tell "already correct"
from "silently changed" cannot act on either.
ssh-keyscan output is accepted as-is, and more than one key is refused. sshkey.ParseHostKey
takes known-hosts format or a plain key line, skips the comment header, and normalises to a single
authorized_keys line so one key always has one fingerprint. Without -t, ssh-keyscan emits one
line per algorithm; pinning whichever came first would leave the pin on a key type the client may
never negotiate, so an ambiguous scan is an error that names the fix.
The stored key is never echoed back. Views carry the fingerprint, which is what an operator
matches against ssh-keyscan or their own known_hosts.
A session authenticates to a target with a certificate minted for that session alone. The data plane
generates an ephemeral keypair, asks a Signer to certify the public half, and uses the result. The
private half never leaves the process and the signer never sees it, which is what lets a production
signer be a remote call that only ever signs a blob.
Every certificate carries:
| Field | Value | Why |
|---|---|---|
| principals | exactly one | the account this session lands as, and nothing else |
source-address |
the data plane's own IP, as a host prefix | a leaked certificate is useless from anywhere else |
| validity | minutes | sshd checks a certificate at authentication time only |
KeyId |
the session id | the only label an audit reader has for the certificate |
| serial | 80 bits of crypto/rand |
two certificates are never confusable in a trail |
| extensions | permit-pty and nothing else |
see below |
The extension list is the part most likely to be got wrong. A certificate built without thinking
about extensions inherits OpenSSH's defaults, which grant permit-port-forwarding,
permit-agent-forwarding, permit-X11-forwarding, and permit-user-rc on the target. The SSH front
door refuses all of those on the gateway side; granting them in the certificate would hand them back
on the target side. TestACertificateGrantsNoForwarding asserts the extension map contains
permit-pty alone.
Caps are shared, not per-implementation. certs.Check enforces one principal, a real IP source
address, and a maximum lifetime, and every Signer calls it. A cap that each implementation is
trusted to remember is advice, not a cap.
These properties are tested against a real SSH handshake, not by reading the certificate back.
An sshd configured to trust the CA accepts the certificate for its own principal and refuses it
for another, refuses one signed by a different CA, refuses one whose window has closed, and refuses
one pinned to a different source address. The first attempt at this used
ssh.CertChecker.CheckCert, which validates principals and validity but does not verify the
signature or the authority at all — that happens in Authenticate. The test passed while proving
nothing about the CA, which is why the checks now run through a handshake.
A session is recorded as asciicast v2: a JSON header naming the terminal size, then one
[elapsed, "o", data] line per chunk of output.
The recorder is opened before the target is dialed. Invariant 5 says a session that cannot be recorded does not open, and the ordering is what makes that true rather than aspirational. Dialing first would mean a failure to record leaves behind a completed SSH handshake and a certificate authentication that the target's own auth log records and this platform has none of.
There is a test for it, and the first version of that test was wrong: it counted shells started on the target, which stays zero when the dial succeeds but the shell never runs. It now counts authentications, and was confirmed to fail when the two steps are swapped.
Only the output direction is recorded, and that is a security property rather than a shortcut.
A shell echoes what is typed, so commands appear in the output stream and remain greppable. A
password typed at a sudo prompt is not echoed, so it never reaches the recording. Adding an
input channel would capture commands twice and secrets once. There is a test asserting no "i"
event is ever written.
Recordings are 0600 in a 0700 directory, named by session id. The file is opened with
O_EXCL, so a session id collision fails rather than appending to somebody else's recording.
Every session the data plane opens gets a row before the target is dialed, carrying who connected, from where, as which principal, with which credential, and where the recording is.
A session that cannot be written to the store is refused. The reasoning is the same as the recorder, one step further: a session nobody can list is also a session nobody can kill, and an unkillable session is worse than a refused one. The row is written after the recording is opened and before the target is dialed, and there is a test asserting the target authenticated zero times when the write fails.
A failed dial still closes its row. Otherwise a target that is merely unreachable would leave a row listed as active forever, and the list of live sessions — the thing an incident responder reads first — would fill with sessions that never happened.
Killing is admin-only. An operator can list and read their own sessions but not close one. The kill switch exists for the incident case, and the incident responder is an admin. An operator who wants their own session gone can close their client.
A kill that closed nothing says so. If the row is open but this node holds no socket for it, the
response reports killed: false with the reason. Reporting success would tell an incident responder
the session is gone when it is not, which is the worst available answer.
Rows left open by a crash are closed on the next start. The data plane holds no state across
restarts, so an open row at startup is a phantom: it can never be killed and it makes the live list
lie. Reconcile closes them with exit code -1 and a reason that names the restart, so they are
not mistaken for clean exits.
Two layers, because they defend against different things.
A synchronous append-only file, for durability against a crash. Record writes and fsyncs
before returning, and a write failure is returned to the caller so a session that cannot be recorded
can be refused. The file is opened O_APPEND and never truncated, so restarting the gateway adds to
the trail rather than replacing it.
An asynchronous push to Loki, for durability against whoever owns the host. The local file is deletable by root on the gateway; that is exactly the adversary invariant 8 is about. Shipping is what makes the trail outlive the machine.
Shipping never fails a session. A queue full or a Loki that is down logs an error and increments
a drop counter; the durable write has already happened. Blocking on a log aggregator would let an
outage stall every session, and dropping the durable write to keep the shipment would be the wrong
half to sacrifice. Close drains the queue so a clean shutdown ships what is in flight.
Loki labels stay low cardinality. Only job, action, and outcome are labels. Ids — session,
actor, target — go in the line. Loki indexes labels, so putting a session id in one creates a stream
per session and eventually takes the aggregator down. There is a test asserting no other label is
ever emitted.
The trail carries what the client was not told. A refused SSH authentication returns a uniform
Permission denied so nothing can be enumerated, and records the destination, source address, and
key fingerprint. Those are two halves of one decision: uniform to the stranger, specific to the
investigator. A trail as uniform as the refusal would hide someone probing host names.
Retention is not this platform's job. Sinks can only append. Deciding how long events live belongs to whatever stores them, and a process that can shorten its own trail does not have one.
Every state change in the control plane, every access decision in the data plane, and every refusal at the API boundary:
api.denied target.registered request.raised
ssh.authentication target.deleted request.approved
session.opened target.host_key_pinned request.denied
session.refused policy.created request.cancelled
session.closed policy.deleted user.created / deleted
session.killed key.added / removed token.issued / revoked
Reads are not recorded. Listing targets, reading a session, and asking policy evaluate a
hypothetical leave nothing behind. The trail records what changed and what was decided; recording
every read would bury those under traffic, and the thing an investigator needs is the small set of
events that granted or refused access.
Allowed API calls are not recorded by the guard either. A module records what it changed, so an allowed mutation is already in the trail with its detail. Recording the call as well would double every event and add nothing.
Refusals are recorded with an actor only when there is one. A 403 names the caller, because they proved who they are. A 401 does not, because nothing was proved — putting a name against an unauthenticated attempt would attribute it to whoever the token claimed to be.
A token secret is never in the trail. token.issued carries the token id and the selector — the
non-secret handle the design exists to provide. The trail is written to disk and shipped off host, so
a secret in it is a secret in two more places. There is a test asserting the issued secret does not
appear in the event.
A recording is uploaded to MinIO when its session closes, under <YYYY>/<MM>/<DD>/<session>.cast,
with x-amz-object-lock-mode: COMPLIANCE and a retention date. Compliance mode is chosen over
governance deliberately: governance can be bypassed by a sufficiently privileged user, and that user
is the adversary this invariant exists for.
The client can only put. There is no delete, no remove, and no lifecycle call in
kernel/objstore, and a test asserts the type does not satisfy an interface with one. Object lock
stops a store administrator; having no method stops the gateway itself.
Object lock headers are covered by the signature. They are x-amz-* headers, so they are in
SignedHeaders and cannot be stripped in flight — which is precisely what someone who wanted a
deletable recording would try. There is a test walking every x-amz- header sent and asserting it
was signed.
The local copy is never removed. Uploading does not delete anything, so recordings accumulate on
the gateway. That is an operator's retention job, done from outside this process after confirming the
object exists. A --purge-old-recordings flag would be the one delete this design does not have.
A failed upload does not fail the session. The session already happened; refusing it afterwards
is not available. The failure is logged, recording.not_stored goes in the trail, and the local copy
stays. The event is what makes a one-copy recording detectable.
The payload is hashed in memory, with a cap. SigV4 needs the content hash before the request is sent. The cap is 64 MiB by default and an object over it is refused rather than read, because an unbounded read here is the gateway's memory in the hands of whoever can talk for long enough.
Credentials come from the environment, never a flag. MARAC_S3_ACCESS_KEY and
MARAC_S3_SECRET_KEY. A secret on the command line is visible in ps to every user on the host.
Two things have to be true on the store, and neither is something the gateway can check:
mc mb --with-lock myminio/marac-recordings
mc admin policy create myminio marac-put put-only.json
The bucket must be created with object lock enabled — it cannot be turned on afterwards. And the
gateway's credential should carry s3:PutObject and nothing else:
{"Version":"2012-10-17","Statement":[{"Effect":"Allow",
"Action":["s3:PutObject","s3:PutObjectRetention"],
"Resource":["arn:aws:s3:::marac-recordings/*"]}]}A credential that can delete makes the code's inability to delete beside the point. See
adr/0001-write-sigv4-rather-than-add-an-sdk.md.
The signing key has no home outside this process. Invariant 3 describes it in full, and it is
the largest open item in this document. Every session certificate is minted by a key sitting in a
file on the gateway, and the gateway is the host most worth attacking. Closing it needs a signing
engine in marstack-secrets — send a public key, a principal, a TTL and a source address, get a
certificate back — which does not exist yet. Fetching the key over the network instead would move
where it is stored without changing who can read it, so it is not a shortcut worth taking.
Control plane events are written after the change is committed. A crash between the database commit and the audit write loses the event while keeping the change. Writing the event first would record changes that never happened, and putting it in the same SQLite transaction would mean keeping the trail in the database — which is the thing an attacker who owns the host can edit. The session path does not have this problem: its row and its recording are both opened before the target is touched, so the ordering there refuses rather than loses.
The signing has not been demonstrated against a real MinIO. SigV4 is written here rather than taken from an SDK, and the tests pin its structure, prove every sent header is signed, and prove that changing any input changes the signature. What they cannot prove is agreement with MinIO's own verifier — a canonicalisation mistake would show up as a 403 from the store, not as a failing test. Running a MinIO was not possible in the environment this was written in. Until someone has watched an object land in a bucket, treat the upload as untested at the boundary that matters.
On a store with no users, the first start creates an admin user, issues it a token, and writes the
secret to <data-dir>/bootstrap-token with mode 0600. The log records the path, never the
secret, because invariant 9 applies to the bootstrap path too.
Writing to a file rather than printing to the log is what makes possession of the credential mean something: reading it proves filesystem access to the host, which is the same privilege needed to read the database anyway. It also means the credential is not duplicated into whatever aggregates the logs.
Bootstrap runs only when the user count is zero, so a restart does not mint a second admin, and deleting the file after use closes the window. There is a test asserting the second start writes nothing.
These are not backlog items. Each was considered and rejected, and re-proposing one means arguing against the reason.
A graphical session cannot be audited without pixels, and this platform does not record pixels. The two decisions stand or fall together. RDP is also a stack — MCS channels, CredSSP, and layered bitmap codecs — that would cost more to implement than every other feature combined, for a protocol this platform's audience barely uses.
Storage, a transcoding pipeline, a retention policy, and a player, in exchange for an artefact nobody can search. Asciicast is three orders of magnitude smaller and answers "who ran this command last month" with a query instead of an afternoon.
Honest limitation: asciicast faithfully replays terminal UIs such as vim, htop, and tmux,
because it captures the escape sequences that draw them. Those regions replay correctly but are not
searchable — only the screen being redrawn is visible. The claim is "shell activity is greppable",
not "everything is greppable".
What a proxy intercepts is a PTY byte stream, not a list of commands. It does not know command boundaries, shell state, or aliases. Every one of these defeats a blacklist:
echo cm0gLXJmIC8=|base64 -d|sh
r''m -rf /
vim, then :!rm -rf /
python3 -c 'import os;os.system(...)'
a heredoc piped into sh
tmux inside the session
A blacklist that is bypassed in five minutes is worse than no blacklist, because it makes people believe the session is constrained.
The platform's actual position: it decides which account you become on the target. What that account may do is defined by the target's own configuration. Dangerous patterns are recorded and alerted on; they are never claimed to be blocked.
Per-session ephemeral accounts and dynamic sudo policy require code running on the target. That is an optional add-on behind its own binary, not a reason to put a root daemon on every host.
Storing target passwords and rotating them reintroduces exactly the standing credential that invariant 3 exists to remove. The platform authenticates with short-lived certificates or it does not authenticate.
Requires an agent on every client and hardware attestation to mean anything. That is a separate product, and a posture check without attestation is a self-reported claim.
Run locally before every commit, and again in CI:
make hooks # install .githooks/pre-commit once per clone
make check # vet, test, staticcheck, govulncheck, gosec| Tool | Catches |
|---|---|
go vet |
misuse of stdlib APIs |
staticcheck |
dead code, incorrect nil handling, bad conversions |
govulncheck |
known CVEs on a call path reachable from our code |
gosec |
command injection, weak randomness, unhandled errors, permissive file modes |
gitleaks |
credentials about to be committed |
| CodeQL | taint flows across function boundaries |
govulncheck is preferred over a plain dependency audit because it reports only vulnerabilities
that are actually reachable, which keeps the signal usable.
Two things about this pipeline are worth knowing before changing it.
The Go patch version is a security control. govulncheck fails the build on a standard library
advisory that our code can actually reach, and the fix is usually a patch bump. The version lives in
one place — the go directive in go.mod — because actions/setup-go sets GOTOOLCHAIN=local and
therefore ignores a toolchain directive. Pinning the toolchain separately produces a build that
passes locally and fails in CI with an identical checkout.
gitleaks runs as a binary, not as an action. gitleaks/gitleaks-action@v2 requires a paid
licence for repositories owned by an organisation and fails the job without one. The scanner itself
is open source and written in Go, so CI installs it the same way it installs the other three and
runs the CLI directly. make tools installs the same binary locally.
CodeQL only runs on a public repository. Uploading results requires code scanning, which a private repository gets only with GitHub Advanced Security. The job is gated on visibility rather than deleted, so it starts working the day the repository opens up. A job that can never pass would otherwise leave CI permanently red, which trains everyone to stop reading it — a worse outcome than one fewer scanner.
The project is pre-release and has no users. Once it does, this section gets a contact address and a disclosure window.