Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

A free and open-source forge that lives in your Git repo.

One trust model: attribution at the boundary, and a log of what it decided

f50f6fabc7de2f2d33d22a4f2a8e5518ddb5becb · Keenan Nemetz · 17d ago · parent 671f2aa7

One trust model: attribution at the boundary, and a log of what it decided

The system had two answers to "who did this". Runner results were attributed
by a commit signature; issues, comments, approvals and every push were
attributed by the account the SSH handshake resolved. One was cryptographic
and lived in the object, three were boundary-based and lived in the
instance's word.

Keeping both meant keeping allowed_signers generation, ssh-keygen
-Y find-principals semantics, a --key contract in the runner, a pre-flight key
check before every build, a key column in the queue, and the standing
obligation to keep all of it agreeing with the mechanism used everywhere
else. The signature lost on three counts: it restated the handshake (a runner
signs with the key it already pushes with, so the only thing it added was
portability nobody consumes), it proved attribution rather than truth (a
runner can sign a lie), and an assurance mechanism nobody verifies is a
liability, because it looks like security.

So the signature is gone, and what replaced it is the thing a trust-the-box
model actually requires: a record. post-receive now writes one line per
accepted ref update — ref, old, new, account, repo — into the same
append-only log as the privileged acts. In post-receive rather than
pre-receive because pre-receive returning 0 is a permission and this is the
event, and it can never fail a push: the update has already landed, and
reporting it as rejected would make a client re-send something that worked.

Attribution is unchanged in substance — a record must still name the account
that pushed it, which is the rule an issue's author and an approval's owner
already follow — and the gate's three checks simplify to two, since "the
commit must be signed by that account's key" and "the signature's principal
must be that account" both went with the signature. What was a signed record
is now a plain commit on the metadata branch; the run page says "reported by"
and links the commit that carries it.

Deletions, net 467 lines: CommitFilesSigned and CommitTreeAsSigned collapse
into the unsigned forms, VerifyCommitSignature and signedByRE go,
allowedSigners/rosterKeys go with them, the runner loses loadSigningKey,
loadPrivateKey, publicKeyFile, keyFromSSHCommand and checkSigningKey along
with the --key flag and the ping round-trip that validated a key before work,
and Record.Key, Attestation.Fingerprint and their usage go from the record
format and the queue.

The docs now state the model once, in one place — README "The trust model"
and roadmap "The trust model" — including the cost, which is real: a clone
can no longer show who pushed what, and that is the wrong trade for an
instance mirrored outside its operator's control. If that arrives, the answer
is per-repository mandatory signing implemented once, not a second mechanism
kept warm in the meantime. Slices 4 and 6 of Phase 4 are marked removed with
their reasoning kept, because the proposal will come back.

Measured: TestPostReceiveRecordsThePush (both ref updates logged with actor
and account, a deletion logged as "-> none", the CI spool still written) and
TestPostReceiveSurvivesAnUnwritableLog (a full disk cannot fail a push);
TestARecordCannotBeAttributedToAnotherAccount and TestARecordNeedsNoSignature
replace the three signature tests; TestARecordMustDescribeTheLeasedJob stays,
because "the record must describe the job you were given" is a boundary check
and not a crypto one.

internal/hook/records.go +9−89

@@ -20,12 +20,9 @@ package hook
20 20
21 21 import (
22 22 "fmt"
23 "os"
24 "strings"
25 23
26 24 "github.com/rodent-software/vole/internal/acl"
27 25 "github.com/rodent-software/vole/internal/gitx"
28 "github.com/rodent-software/vole/internal/identity"
29 26 "github.com/rodent-software/vole/internal/runs"
30 27 )
31 28
@@ -47,16 +44,6 @@ func recordFindings(g *gitx.Repo, c change, a *acl.ACL, actor, actorID, repoName
47 44 "An admin can allow that account with `vole acl runners %s --add %s`",
48 45 c.ref, actor, repoName, people, repoName, actor))
49 46 }
50 keys, err := rosterKeys(usersDB, actorID)
51 if err != nil {
52 return append(problems, fmt.Sprintf("%s: cannot check who %s is, refusing the record: %v", c.ref, actor, err))
53 }
54 signers, err := allowedSigners(keys, actorID)
55 if err != nil {
56 return append(problems, fmt.Sprintf("%s: %v", c.ref, err))
57 }
58 defer os.Remove(signers)
59
60 47 if runsDB == "" {
61 48 return append(problems, fmt.Sprintf(
62 49 "%s: this instance has no run queue, so a run result has nothing to be a result of", c.ref))
@@ -68,14 +55,17 @@ func recordFindings(g *gitx.Repo, c change, a *acl.ACL, actor, actorID, repoName
68 55 defer store.Close()
69 56
70 57 for _, rec := range added {
71 problems = append(problems, recordProblems(g, store, signers, rec, actor, actorID, repoName)...)
58 problems = append(problems, recordProblems(g, store, rec, actor, actorID, repoName)...)
72 59 }
73 60 return problems
74 61 }
75 62
76 // recordProblems checks one added record against the account that pushed it, the
77 // signature on the commit that carries it, and the job the queue handed out.
78 func recordProblems(g *gitx.Repo, store *runs.Store, signers string, rec runs.Record, actor, actorID, repoName string) []string {
63 // recordProblems checks one added record against the account that pushed it and the job
64 // the queue handed out. Attribution is the boundary's: the record has to name the
65 // account that pushed it, and that is the same rule an issue's author and an approval's
66 // owner follow. There is no signature to check, because there is no signature anywhere
67 // else either.
68 func recordProblems(g *gitx.Repo, store *runs.Store, rec runs.Record, actor, actorID, repoName string) []string {
79 69 where := runs.RecordPath(rec.RunID, rec.Attempt)
80 70 var out []string
81 71
@@ -87,24 +77,10 @@ func recordProblems(g *gitx.Repo, store *runs.Store, signers string, rec runs.Re
87 77 "a runner reports its own results", where, rec.Signer, actor))
88 78 }
89 79
90 // The signature is the attestation, so this is the check the record exists for.
91 // The allowed-signers file holds only this account's keys, so a good signature is
92 // one this account could have made.
80 // The record has to be in a commit, because the commit is what the branch carries and
81 // what a reader follows back to the bytes.
93 82 if rec.Commit == "" {
94 83 out = append(out, fmt.Sprintf("%s: the commit that adds the record could not be found", where))
95 } else if principal, fp, err := g.VerifyCommitSignature(rec.Commit, signers); err != nil {
96 out = append(out, fmt.Sprintf(
97 "%s: the commit %s that carries this record is not signed by a key of %s (%s)",
98 where, short(rec.Commit), actor, err))
99 } else {
100 if principal != actorID {
101 out = append(out, fmt.Sprintf(
102 "%s: the signature names %s, not %s", where, principal, actor))
103 }
104 if rec.Key != fp {
105 out = append(out, fmt.Sprintf(
106 "%s: the record names key %s but the commit was signed with %s", where, rec.Key, fp))
107 }
108 84 }
109 85
110 86 if rec.Repo != repoName {
@@ -136,62 +112,6 @@ func recordProblems(g *gitx.Repo, store *runs.Store, signers string, rec runs.Re
136 112 return out
137 113 }
138 114
139 // allowedSigners writes an OpenSSH allowed-signers file holding one account's keys,
140 // with the account id as the principal. It is the whole identity rule for a signed
141 // commit: a signature verifies only if one of these keys made it, and the principal
142 // git reports is the account, not whatever name the commit claims.
143 //
144 // The file is temporary because it is per-push state, not configuration: the roster is
145 // the configuration.
146 func allowedSigners(keys []identity.Key, principal string) (string, error) {
147 var b strings.Builder
148 for _, k := range keys {
149 f := strings.Fields(k.Line)
150 if len(f) < 2 {
151 continue
152 }
153 fmt.Fprintf(&b, "%s %s %s\n", principal, f[0], f[1])
154 }
155 if b.Len() == 0 {
156 return "", fmt.Errorf("%s has no keys a commit could be signed with", principal)
157 }
158 f, err := os.CreateTemp("", "vole-allowed-signers-*")
159 if err != nil {
160 return "", err
161 }
162 if _, err := f.WriteString(b.String()); err != nil {
163 f.Close()
164 os.Remove(f.Name())
165 return "", err
166 }
167 if err := f.Close(); err != nil {
168 os.Remove(f.Name())
169 return "", err
170 }
171 return f.Name(), nil
172 }
173
174 // rosterKeys loads the keys the account holds now. A retired key cannot report, which
175 // is the point of retiring it.
176 func rosterKeys(usersDB, accountID string) ([]identity.Key, error) {
177 if usersDB == "" {
178 return nil, fmt.Errorf("this instance has no roster, so a record cannot be attributed to anybody")
179 }
180 store, err := identity.Open(usersDB)
181 if err != nil {
182 return nil, err
183 }
184 defer store.Close()
185 acct, err := store.Find(accountID)
186 if err != nil {
187 return nil, err
188 }
189 if acct == nil {
190 return nil, fmt.Errorf("no account %s", accountID)
191 }
192 return acct.Keys, nil
193 }
194
195 115 // describe renders a run's queue state for a refusal.
196 116 func describe(run *runs.Run) string {
197 117 if run.LeaseOwner == "" {