- Rust 84.3%
- Nix 13.4%
- Shell 2.3%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
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
Reviewed-on: #1 |
||
| .forgejo/workflows | ||
| crates | ||
| nix | ||
| npins | ||
| .envrc | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| default.nix | ||
| flake.nix | ||
| LICENSE | ||
| README.md | ||
| shell.nix | ||
| treefmt.nix | ||
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.
depthbounds how far trust re-delegates from the edge's target, so the first hop is bounded by the edge alone. distrustis 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.
serialis 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
redundancydistinct keys at effective level >=trust-levelhave a liveacceptof that exact tree, and no key at that bar or above has areject. A single trustedrejectis 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_signerspolicy, graph resolution, feeds. No I/O:nowis 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.