THREAT-MODEL.md
In docs/
In docs/
Status: Informative. Companion document to SECURITY.md and the
SPEC-* normative specs. Audience: integrators, auditors, and
contributors changing crypto- or key-handling code.
This document states what mkit defends, what it does not, and where the lines are drawn. It is the source of truth a reviewer consults when a change touches signing, key files, configuration, transport auth, or the release pipeline.
mkit is a content-addressed VCS with cryptographic signing
(specs/SPEC-SIGNING.md) and an attestation subsystem
(specs/SPEC-ATTESTATIONS.md, specs/SPEC-EXTERNAL-SIGNER.md). mkit defends:
mkit commit, mkit attest,
and external signers reachable through the documented protocol.mkit does NOT defend:
mkit.The following boundaries cross trust domains. Code that moves data across one of these lines MUST treat the input as untrusted.
| Boundary | Trusted side | Untrusted side |
|---|---|---|
| Local user ↔ remote repo author | the running user | files inside a cloned tree, including |
the repo's .mkit/config | ||
| Local user ↔ same-host other UID | the running user | other UIDs on the host |
| Transport peer (network) | the peer the user | every other host on the path |
| configured | ||
| Release pipeline runner | the workflow YAML | any artifact a third-party action emits |
The "remote repo author" line is load-bearing. A user who runs
mkit clone accepts the remote's content into the working tree;
they do NOT thereby accept the remote's choice of signing key, key
file path, external signer binary, or trust-roots file.
For each attacker, this document enumerates what mkit claims to defend and what it does not. "Defend" means the design intends a security property and the implementation has tests or fuzz coverage backing it.
Attacker controls every file in the cloned tree, including
.mkit/config, working-tree contents, ref files, packs, and any
attestation envelope they push.
mkit defends:
specs/SPEC-PACKFILE.md, specs/SPEC-DELTA.md)..mkit / .git (case-insensitive), are rejected
during checkout..mkit/config cannot select which key file is read, which binary
is spawned as an external signer, which keystore backend/key ref is
used, or what argv an external signer gets.clone/pull/fetch. Every
commit/remix/tag a fetch newly introduces is run through
mkit_core::sign::{verify_commit,verify_remix,verify_tag} — the same
check mkit verify <rev> runs manually — before the remote-tracking
ref is published; a structurally invalid or missing signature aborts
the fetch and leaves local state untouched (issue #692). This is a
default, not a guarantee against every key an attacker could produce:
it proves the signature is well-formed over the object's own bytes,
not that the signing key is one the user trusts (no trust-root /
authorized-signer binding yet — separate follow-up work). Opt-out is
explicit and user-scoped only (--no-verify-signatures / the
user-scoped pull.require_signed = false config, never settable from
a cloned repo's own .mkit/config — see §4).mkit does NOT defend:
mkit config attest.external_signer_path
/tmp/evil after cloning. Local choice is the user's responsibility.git-bridge import)Attacker controls a git repository that the user imports or fetches
with mkit git import/fetch/pull (feature git-bridge): every
object byte, ref name, tag chain, and the transport-level ref
advertisement.
mkit defends:
gitparse, fuzzed: commit/tag/tree targets) behind a
git cat-file --batch subprocess; header blocks are size-capped,
tag chains depth-capped (16), trees depth-capped (128)..mkit/git/<name>/raw/ as audit evidence, and
each imported head gets a git-import/v1 attestation binding the
git commit id, canonical remote identity, and mkit twin.
mkit git verify re-checks all of it offline.refs/remotes/<name>/* (plus tags); upstream force-pushes rewind
tracking refs with a loud warning and never touch local branches.mkit does NOT defend:
Attacker has a shell on the same host under a different UID.
mkit defends:
0600, owner-checked against the running euid, and opened
with O_NOFOLLOW so a symlink planted by another UID cannot
redirect reads. The parent directory is created 0700 and its
ownership is checked.mkit does NOT defend against:
/proc/<mkit-pid>/mem (for example,
Linux without kernel.yama.ptrace_scope = 1).Attacker runs as the same UID as mkit, either by tricking the user
into running their code or by exploiting an unrelated process.
mkit does NOT defend against this case. Once an attacker runs as the
same UID, they can read the key file, spawn arbitrary external
signers, edit .mkit/config, and so on. mkit's threat model assumes
the local UID is trusted.
The mitigations in §4 and §5 are about preventing a remote repo from escalating into this position via on-disk config.
Attacker is on the network path between the client and the remote.
mkit defends:
MKIT_SSL_CA_FILE or
http.sslCAInfo. Certificate-chain and hostname verification stay enabled.
Browser clients use browser-managed trust.ssh(1) configuration (see
SSH-SECURITY.md). mkit does not implement its own SSH. A
per-repo ssh.user_known_hosts_file and ssh.identity_file
scoped to user config (§4) let a careful user pin trust without
affecting other SSH sessions.mkit does NOT defend:
mkit+enc://) peer authenticationThe encrypted-stream transport uses two independent, directional authentication mechanisms — do not conflate them:
?pubkey=<…> value in the client's
mkit+enc://<host>:<port>?pubkey=<key> URL pins the server's
static ed25519 key. The client's dial aborts the handshake if the
remote's actual key does not match. This authenticates the SERVER to
the CLIENT (there is no TOFU; see SPEC-TRANSPORT-ENC §1). It does
not say anything about who the client is.?pubkey=. Issue
#178 made the listener fail-closed: it refused to bind without an
allowlist of authorized peers (or an explicit allow-any development
escape). A client whose static ed25519 key is not on the allowlist is
rejected at the handshake and never receives a HelloResponse,
list-refs, packs, or update-ref. The standalone listener has since been
removed and the transport is deprecated (no maintained server); the
policy below applies to any listener built on mkit-transport-enc.
This closes the previous "accepts any peer" gap where the listener
hardcoded a permissive bouncer.The server identity is a stable key file (so pinned client ?pubkey=
values survive restarts) and the allowlist / identity key paths are
user-scoped or CLI-only — never read from repo-local .mkit/config
(§4), so a hostile clone can neither authorize itself as a peer nor
swap the server identity.
Attacker has code execution on the GitHub Actions runner that produces release artifacts.
mkit defends:
docs/RELEASE.md).docs/RELEASE.md).mkit does NOT defend:
mkit reads two configuration files. Their scope is partitioned by
attacker model: a hostile clone can write <repo>/.mkit/config but
it cannot write the user-scoped file at
$XDG_CONFIG_HOME/mkit/config (~/.config/mkit/config by default).
Security-sensitive keys — anything that selects a key path or an external process — live only in user scope. A repo config that attempts to set them is rejected with a warning; the value is ignored.
| Key | Scope | Rationale |
|---|---|---|
signer | User | Selects legacy raw-file vs keystore commit signing. |
key.backend | User | Selects the keystore backend family. |
key.default_ref | User | Selects a private signing key reference. |
key.ed25519_ref | User | Selects the Ed25519 private signing key reference. |
key.secp256k1_ref | User | Selects the secp256k1 private signing key reference. |
key.p256_ref | User | Selects the P-256 private signing key reference. |
signing_key | User | Selects which key file is read for commit signing. |
attest.signer | User | Selects repo-key / external / keystore. |
attest.default_algorithm | User | Selects the attestation signing algorithm. |
attest.external_signer_path | User | Selects which binary is spawned as a signer. |
attest.external_signer_args | User | Argv for the signer subprocess. |
attest.secp256k1_key_path | User | Selects which secp256k1 key file is read. |
attest.p256_key_path | User | Selects which P-256 key file is read. |
ssh.strict_host_key_checking | User | Could weaken host-key verification. |
ssh.user_known_hosts_file | User | Selects which file is the source of trust. |
ssh.identity_file | User | Selects which private key SSH presents. |
user.identity | User | Author identity; cannot be repo-selected for signed data. |
pull.require_signed | User | Disables post-fetch signature verification (§3.1, #692). |
default_branch | Repo | UX default. No security weight. |
remote_endpoint | Repo | Address; trust is on the user's transport config. |
remote_bucket | Repo | Address. |
remote_type | Repo | Dispatch hint to the transport layer. |
Repo-safe keys are applied after the user file and may override user defaults. The security fence is narrower and stricter: private-key, signer, executable, trust-root, and host-key selectors are user-only and are ignored when they appear in repo config.
The per-key classification, the read-site fence, the runtime credential
gate on remote_endpoint, and the rules for adding a new config key
are codified in docs/specs/SPEC-CONFIG-SECURITY.md. That spec is the
authoritative reference when reviewing a change that touches
mkit-cli/src/config.rs or a transport that consumes a
config-derived endpoint, key path, or credential.
remote_endpoint, remote_bucket, and remote_type are repo-safe
to read (a clone needs to be able to nominate a default mirror),
but mkit push, mkit pull, and mkit fetch will refuse to attach
ambient MKIT_API_TOKEN or MKIT_R2_* credentials to a
repo-configured endpoint unless the user has explicitly listed that
endpoint in user-scoped trusted_remote_endpoint. This closes the
hostile-clone credential-exfiltration channel tracked in issue #97
without breaking safe portable defaults for unauthenticated mirrors
and SSH-bearing remotes.
The gate is enforced at a single transport-dispatch choke point
(remote_dispatch::open_trusted), which runs the per-endpoint check
before it constructs any credential-bearing transport. The check is
keyed on the resolved endpoint plus its provenance — repo_chosen
(selected by repo-scoped config: the flat remote_endpoint or a
remote.<name>.url entry) versus user-chosen (user-scoped config or an
explicit CLI argument) — never on a remote name. This means the same
fence applies uniformly whether the credential-bearing endpoint comes
from the legacy single-remote field or from a named remote: a hostile
clone cannot smuggle ambient credentials to a new host by hiding the
endpoint behind a named remote, and a user-chosen endpoint with the
user's own credentials is never second-guessed. Unauthenticated and
SSH/file flows carry no ambient HTTP/S3 credentials and pass the gate
unchanged.
mkit verify-attest and mkit verify --trusted (issue #693) both load
trust roots from $XDG_CONFIG_HOME/mkit/trust-roots.toml by default.
The path is not repo-local for the same reason as §4: a hostile
clone must not choose its own verifier. mkit trust add/list/remove
manages the same file.
A user can override the path on the command line (--trust-roots
<path>) for ad-hoc verification, but the override is per-invocation;
there is no repo config knob that sets it. Both commands additionally
refuse an in-repo trust-roots path unless --trust-roots was passed
explicitly (warn_if_unsafe_trust_roots in
rust/crates/mkit-cli/src/commands/trust_roots.rs) — a hostile clone
that ships .mkit/trust-roots.toml listing attacker keys cannot get it
selected implicitly.
Without --trusted, mkit verify proves only that the object's
embedded signer produced its signature — it does not consult trust
roots at all (SPEC-SIGNING §6.1). A signature from an unlisted,
attacker-controlled key is indistinguishable from a trusted one unless
the caller opts into --trusted. mkit clone/pull/fetch do not run
any verification by default; that remains a separate, tracked gap.
| Property | Value |
|---|---|
| Format | raw 32-byte Ed25519 seed (no PEM, no DER, no password wrap in v1) |
| Permissions | mode 0600, MUST be set on creation |
| Owner | euid of the running process; mismatch is a hard failure |
| Open flag | O_NOFOLLOW — symlink in the path is a hard failure |
| Parent directory | 0700, owner-checked |
| Write strategy | tempfile in same directory, fsync, atomic rename, fsync of parent |
| Zeroization | seed buffers scrubbed at generation and file-I/O boundaries |
KeyPair::generate scrubs its local seed buffer after constructing the
keypair. KeyPair::from_seed takes [u8;32]; callers that own long-lived
secret buffers must use a zeroizing owner before and after the call.
The same protections apply to the secp256k1 and P-256 key files
selected via attest.secp256k1_key_path and attest.p256_key_path.
Issue-complete V1 adds user-scoped software, OS-native, Linux desktop/headless,
and YubiKey-backed keystore backends selected by mkit key ...,
signer = keystore, and attest.signer = keystore.
Security assumptions:
$XDG_DATA_HOME/mkit/keys/, falling back to
~/.local/share/mkit/keys/; software-raw persists under a raw-specific
subtree, and systemd-creds uses a separate user data subtree for encrypted
credential files.signer, key.backend, and every key.*_ref) are
user-scoped. A hostile repo cannot select a backend, label, key reference, or
signing mode.software:<label> stores encrypted-at-rest software records. It protects
against offline disk/backup disclosure to the extent the local OS-protected
envelope material remains unavailable to the attacker. It does not protect
against malware running as the user. Production mkit-cli builds enable the
target OS protector features; the mkit-keystore library keeps default
features empty for lean builds and tests.software-raw:<label> is the explicit raw-file compatibility backend. It
keeps deterministic raw-key behavior for compatibility tests and migration
workflows and is not the secure default.systemd-creds store
extractable 32-byte signing secrets behind their
platform protection boundary. They do not claim hardware binding,
non-extractability, or user presence unless a future implementation changes
the storage primitive and capability report together.systemd-creds is the
headless/server Linux backend and shells out with argv tokens, not shell
interpolation. The encrypted software backend auto-selects Secret Service
only when a desktop session is detected and the protector opens cleanly;
Secret Service errors fail closed rather than silently falling back to a
weaker protector. systemd-creds is selected for headless/server Linux when
no desktop Secret Service session is detected, and writes fail closed if no
usable protector is available.piv-9a, piv-9c,
piv-9e). Both are non-extractable and device-bound from mkit's point of
view. Keystore V1 does not accept YubiKey PINs through environment variables;
PIN- or touch-required signing fails closed with typed authentication errors
until a bounded prompt provider is implemented.KeySigner API returns only a signature and
cannot carry WebAuthn authenticator data plus client data JSON. The YubiKey
keystore fails closed for fido2-*/ctap-* P-256 labels rather than emitting
incomplete assertions.mkit key generate or mkit key import explicitly.Keystore non-goals in V1:
The following are explicitly out of scope. mkit makes no claim and takes no defensive posture against them.
root or Administrator on the host./proc/<pid>/mem is readable by a
non-root user. On Linux, set
kernel.yama.ptrace_scope = 1 (or stricter) before relying on
mkit's key-file protections..mkit/ directory served from a network filesystem (NFS, SMB/CIFS,
or most FUSE-backed network mounts). Every fail-closed exclusion
guarantee that depends on mkit-core::repo_lock — including
SPEC-GC.md's GC-vs-writer guarantee and every lock in
SPEC-CONCURRENCY.md's inventory — assumes flock/fcntl advisory
locking is coherent across all lock holders. That assumption holds on
a local filesystem; it is not guaranteed on NFS (locking is
commonly offloaded to an absent or inconsistent rpc.statd/NLM side
channel under NFSv3, and even NFSv4's in-protocol locking depends on
server/client support mkit does not verify) or on SMB/CIFS. mkit
performs no detection of the underlying filesystem type and has no
fallback locking strategy. Repositories that must be shared across
hosts should use one of mkit's network transports
(docs/specs/SPEC-TRANSPORT.md) instead of a shared network-mounted
.mkit/ directory. See rust/crates/mkit-core/src/repo_lock.rs's
module doc for the full caveat.The following tests, fuzz targets, and CI gates keep this document honest. A change that weakens any of these requires a matching update here.
rust/tests/golden/ pin signing-byte and
signing-hash shapes (specs/SPEC-SIGNING.md §3, specs/SPEC-ATTESTATIONS.md §4).mkit-keystore golden vectors pin deterministic imported-key behavior for
explicit software-raw Ed25519, secp256k1, and P-256 signing, while unit
storage tests assert that software writes encrypted records rather than raw
seeds.cargo fuzz targets cover delta decode, pack reader, the object
deserializer, and the encrypted software key record parser (docs/FUZZ.md).<repo>/.mkit/config
cannot set any user-scoped key (warning plus ignored). Per-key
coverage lives in rust/crates/mkit-cli/tests/repo_config_forbidden_keys.rs,
and the in-process meta-test
every_forbidden_key_is_actually_dropped_from_repo_scope iterates
the entire REPO_FORBIDDEN_KEYS list against a sentinel value.
The exact stderr warning shape is pinned by an insta snapshot.O_NOFOLLOW
behavior and the atomic-write contract.cargo fmt --check, cargo clippy --all-targets --
-D warnings, cargo test --workspace --locked, keystore backend feature
jobs for Linux/macOS with opt-in live native-backend roundtrips,
cargo deny, reproducible-build smoke, mkit version byte-exact assertion.
Windows is not a supported build/test/release target (MKIT-6; see
docs/INVARIANTS.md).See SECURITY.md. Use GitHub Security Advisories.
Do not file public issues for vulnerabilities.