No description
  • Rust 84.3%
  • Nix 13.4%
  • Shell 2.3%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Kiara Grouwstra 4f62265bee
All checks were successful
checks-wost-clippy / wost-clippy (push) Successful in 3s
checks-wost-coverage / wost-coverage (push) Successful in 4s
checks-wost-e2e / wost-e2e (push) Successful in 4s
checks-treefmt / treefmt (push) Successful in 5s
checks-wost-interop / wost-interop (push) Successful in 3s
checks-wost-tests / wost-tests (push) Successful in 3s
Nix flake completeness check / _complete (push) Successful in 12s
Merge pull request 'wost: accept every ssh-keygen -Y key type, and gate CI on line coverage' (#1) from key-types-and-coverage into main
Reviewed-on: #1
2026-09-04 12:18:44 +02:00
.forgejo/workflows wost: accept every ssh-keygen -Y key type, and gate CI on line coverage 2026-09-04 10:29:22 +02:00
crates wost: accept every ssh-keygen -Y key type, and gate CI on line coverage 2026-09-04 10:29:22 +02:00
nix wost: accept every ssh-keygen -Y key type, and gate CI on line coverage 2026-09-04 10:29:22 +02:00
npins wost: decentralized transitive trust of source revisions 2026-08-27 08:58:20 +02:00
.envrc wost: decentralized transitive trust of source revisions 2026-08-27 08:58:20 +02:00
.gitignore wost: decentralized transitive trust of source revisions 2026-08-27 08:58:20 +02:00
Cargo.lock wost: accept every ssh-keygen -Y key type, and gate CI on line coverage 2026-09-04 10:29:22 +02:00
Cargo.toml wost: accept every ssh-keygen -Y key type, and gate CI on line coverage 2026-09-04 10:29:22 +02:00
default.nix wost: accept every ssh-keygen -Y key type, and gate CI on line coverage 2026-09-04 10:29:22 +02:00
flake.nix wost: decentralized transitive trust of source revisions 2026-08-27 08:58:20 +02:00
LICENSE wost: decentralized transitive trust of source revisions 2026-08-27 08:58:20 +02:00
README.md wost: accept every ssh-keygen -Y key type, and gate CI on line coverage 2026-09-04 10:29:22 +02:00
shell.nix wost: decentralized transitive trust of source revisions 2026-08-27 08:58:20 +02:00
treefmt.nix wost: decentralized transitive trust of source revisions 2026-08-27 08:58:20 +02:00

wost -- a web of software trust

Decentralized, transitive trust of source revisions.

Anyone who runs code they did not write -- a hosting provider deploying a fork, a distribution packaging an upstream, a team pulling a dependency -- has two options today: read all of it, or trust the repository wholesale. wost offers a third -- borrow the reading of parties you trust, without any central authority deciding whose reading counts.

Parties publish SSHSIG-signed statements. A review endorses one exact (url, rev, narHash) triple. A trust edge grants another key a level and a re-delegation depth. A consumer names a few root keys; trust propagates along the edges, and a revision is accepted once enough sufficiently-trusted parties have signed an accept for it.

The answer is a pin, not a boolean:

{
  "decision": "accept",
  "subject": {
    "url": "https://git.fediversity.eu/fediversity/fediversity",
    "rev": "41483f687baced22003252d3caf94b7d8236afcd",
    "narHash": "sha256-mJq1grdV62ciZL+k2lM8BMnLNSdcTzyVbRNrBOf3sHk="
  },
  "publicKeys": [ { "type": "ssh-ed25519", "key": "AAAAC3Nza..." } ],
  "reviewers": [ { "fingerprint": "SHA256:...", "id": "kiara@procolix.eu", "level": "high", "thoroughness": "medium" } ],
  "reasons": []
}

subject.narHash and publicKeys go straight into builtins.fetchTree, which gives two independent enforcement points: the narHash binds the content, and publicKeys with verifyCommit = true binds the authorship, re-checked by Nix itself at fetch time. Neither one subsumes the other. Nix is a consumer, not a requirement: wost has no Nix dependency, and a consumer that is not Nix can compare the narHash itself. When the consumer is Nix, verified-fetches is a client-side eval feature, so no daemon setting and no trusted-user status is involved.

Trying it

$ nix-shell
$ for name in a b c; do ssh-keygen -q -t ed25519 -N "" -C "$name" -f "$name"; done

$ wost trust --key a --subject-key @b.pub --level high --depth 2 --id b
$ wost trust --key b --subject-key @c.pub --level high --depth 1 --id c
$ wost sign  --key c --url "$url" --rev "$rev" --nar-hash "$narHash" \
             --verdict accept --thoroughness medium

$ wost resolve --root "high:@a.pub" --url "$url" --rev "$rev" --json

A, who has never heard of C, accepts on C's reading. Raise --redundancy to 2 and it rejects: one endorsement is no longer enough.

Commands

wost sign    --key FILE --url URL --rev REV --nar-hash H \
             [--verdict accept|reject] [--thoroughness none|low|medium|high] \
             [--expires WHEN] [--serial N] [--no-save]
wost trust   --key FILE --subject-key KEY --level LEVEL [--depth N] [--id TEXT]
wost verify  FILE... [--allowed-signers FILE --principal NAME] [--revocations FILE]
wost fetch                                   # bounded pull into the local store
wost resolve --url URL --rev REV [--nar-hash H] [--json]
wost feed publish --out DIR --base-url URL --key FILE
wost feed status

Global, accepted before or after the subcommand: --config, --store, --root LEVEL:KEY, --anchor SHA256:..., --feed URL, --redundancy, --trust-level, --byte-budget, --page-budget.

wost resolve exits 0 on accept, 1 on reject and 2 when the resolver itself failed. A caller must branch on all three: reading 2 as accept is the fail-open case.

A KEY is an inline OpenSSH public key line or @path to a file holding one. --root is LEVEL:KEY -- level first, split on the first :, because an OpenSSH key's base64 can end in = padding and an =-split would truncate some keys and not others. Passing any --root replaces the configured root set rather than adding to it.

Config

--config, else $XDG_CONFIG_HOME/wost/config.toml:

store = "/var/lib/wost"
redundancy = 2
trust-level = "medium"
byte-budget = 33554432
page-budget = 1024
anchors = [ "SHA256:..." ]

[[root]]
key = "@/etc/wost/keys/kiara.pub"
level = "high"

[[feed]]
url = "https://fediversity.eu/wost"
key = "@/etc/wost/keys/feed.pub"

A feed's key is the key its index must be signed by. Absent, a configured root must have signed it -- "whoever answered the URL" is not a policy. A --feed on the command line carries no key, so it always takes the root path.

How trust resolves

  • Roots are axioms: each is trusted at a stated level, and a root's reach is unbounded.
  • A key's effective level is the max over all paths of the min edge level along the path. depth bounds how far trust re-delegates from the edge's target, so the first hop is bounded by the edge alone.
  • distrust is absorbing: a distrust edge from a sufficiently-trusted issuer zeroes the target regardless of what else vouches for it. Distrust and reachability are mutually recursive -- a distrusted key cannot distrust -- so resolution is a fixpoint, and roots are never bannable.
  • Expired statements are dropped before any of this runs. serial is monotonic per (key, kind, subject); a higher serial supersedes a lower one, which is how a review is retracted or an edge downgraded.
  • A revision is accepted when at least redundancy distinct keys at effective level >= trust-level have a live accept of that exact tree, and no key at that bar or above has a reject. A single trusted reject is fatal.

publicKeys in the verdict is the authorship set, not the reviewer set: every key clearing the bar, whether or not it reviewed anything. A forge merge commit is signed by the forge, which reviews nothing, so a reviewer-derived publicKeys would reject every tip of main. reviewers is the separate reading set.

Feeds

Statements are authored in git proof repos -- append-only, easy to mirror -- and distributed as a static, paginated, size-capped JSON feed produced by wost feed publish. index.json is itself SSHSIG-signed and carries a monotonic version, an expires, and each page's URL, SHA-256 and byte length.

wost fetch enforces a total byte budget and a page cap, and checks each page against the digest in the signed index before parsing it. A hostile feed cannot make a client download unbounded data, and cannot serve a page the index does not vouch for.

The version and expires are the part a pure web of trust structurally cannot do. A web of trust sees what it is shown, so a mirror that silently withholds a reject or freezes the feed at last month's state is invisible to it. expires bounds the freeze; refusing a version lower than the last one seen bounds the rollback. An index with no expires is refused rather than quietly accepted -- a publisher who omits it has opted their consumers out of a guarantee that is not theirs to give away.

Signatures

SSHSIG (ssh-keygen -Y sign) and nothing else. No envelope of our own: SSHSIG is already a signed structure whose namespace field is hashed into the signed data, so the namespace is cryptographically bound rather than advisory. That is what keeps the statement kinds apart -- wost.review.v1, wost.trust.v1, wost.index.v1 -- and a signature made under one can never be presented as another.

A statement travels as {"payload": "<base64 of the exact statement bytes>", "sig": "<armored SSHSIG>"}. The signed bytes are the transmitted bytes, so there is no canonical-JSON round trip and therefore no canonicalization bug class.

wost and ssh-keygen -Y verify each other's signatures, and checks.wost-interop asserts it in both directions. wost-core::policy implements OpenSSH's allowed_signers format -- cert-authority, namespaces=, valid-after/valid-before, pattern lists -- so the same policy file works with either tool; ssh-key 0.6 ships no parser for it.

Layout

  • crates/wost-core -- statements, wire form, SSHSIG, allowed_signers policy, graph resolution, feeds. No I/O: now is always a parameter, which is what makes expiry and rollback testable against fixtures.
  • crates/wost -- the CLI: config, the content-addressed store, the one module that talks to the network.

Development

$ nix-shell                 # cargo, clippy, rust-analyzer, npins, openssh, jq
$ cargo test
$ nix fmt
$ nix build .#checks.x86_64-linux.wost-e2e -L

.forgejo/workflows/ is generated from the flake's checks by nix-flake-check.sh; _complete.yaml runs it in check mode, so adding a check to default.nix without regenerating is a CI failure rather than an untested attribute.

Licence

EUPL v1.2. See LICENSE.