Fast implementation of Git in pure Go codeberg.org/lindenii/furgit
git go
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

object/signed/commit: Add signed commit helpers

Runxi Yu 642b21b8 256d945a

+469
+15
object/signed/commit/commit.go
··· 1 + package signedcommit 2 + 3 + import objectid "codeberg.org/lindenii/furgit/object/id" 4 + 5 + // Commit represents the payload and signatures parsed from a raw comit object. 6 + type Commit struct { 7 + body []byte 8 + payload []byteRange 9 + signatures map[objectid.Algorithm][]byteRange 10 + } 11 + 12 + type byteRange struct { 13 + start int 14 + end int 15 + }
+6
object/signed/commit/doc.go
··· 1 + // Package signedcommit extracts commit signing payloads and signatures from raw 2 + // commit object bodies. 3 + package signedcommit 4 + 5 + // TODO: Consider whether we want to fully copy the bytes into here. 6 + // The Append functions are a bit weird ergonomically.
+134
object/signed/commit/integration_test.go
··· 1 + package signedcommit_test 2 + 3 + import ( 4 + "bytes" 5 + "os" 6 + "os/exec" 7 + "path/filepath" 8 + "testing" 9 + 10 + "codeberg.org/lindenii/furgit/internal/testgit" 11 + objectid "codeberg.org/lindenii/furgit/object/id" 12 + signedcommit "codeberg.org/lindenii/furgit/object/signed/commit" 13 + ) 14 + 15 + func setupSSHSignedCommit( 16 + t *testing.T, 17 + algo objectid.Algorithm, 18 + ) (payload []byte, allowedSignersPath string, signaturePath string) { 19 + t.Helper() 20 + 21 + testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo}) 22 + 23 + signDir := t.TempDir() 24 + signRoot, err := os.OpenRoot(signDir) 25 + if err != nil { 26 + t.Fatalf("os.OpenRoot(%q): %v", signDir, err) 27 + } 28 + 29 + t.Cleanup(func() { _ = signRoot.Close() }) 30 + 31 + privateKeyPath := filepath.Join(signDir, "signing_key") 32 + allowedSignersPath = filepath.Join(signDir, "allowed_signers") 33 + signaturePath = filepath.Join(signDir, "commit.sig") 34 + 35 + cmd := exec.Command( //nolint:noctx 36 + "ssh-keygen", 37 + "-q", 38 + "-t", "ed25519", 39 + "-N", "", 40 + "-C", "runxiyu@umich.edu", 41 + "-f", privateKeyPath, 42 + ) //#nosec G204 43 + out, err := cmd.CombinedOutput() 44 + if err != nil { 45 + t.Fatalf("ssh-keygen generate failed: %v\n%s", err, out) 46 + } 47 + 48 + publicKey, err := signRoot.ReadFile("signing_key.pub") 49 + if err != nil { 50 + t.Fatalf("ReadFile(signing_key.pub): %v", err) 51 + } 52 + 53 + err = signRoot.WriteFile( 54 + "allowed_signers", 55 + append([]byte("runxiyu@umich.edu "), publicKey...), 56 + 0o600, 57 + ) 58 + if err != nil { 59 + t.Fatalf("WriteFile(allowed_signers): %v", err) 60 + } 61 + 62 + testRepo.Run(t, "config", "gpg.format", "ssh") 63 + testRepo.Run(t, "config", "user.signingkey", privateKeyPath) 64 + 65 + testRepo.WriteFile(t, "file.txt", []byte("signed\n"), 0o644) 66 + testRepo.Run(t, "add", "file.txt") 67 + testRepo.Run(t, "commit", "-S", "-m", "signed commit") 68 + 69 + commitID := testRepo.RevParse(t, "HEAD^{commit}") 70 + body := testRepo.CatFile(t, "commit", commitID) 71 + 72 + commit, err := signedcommit.Parse(body) 73 + if err != nil { 74 + t.Fatalf("Parse: %v", err) 75 + } 76 + 77 + signature, ok := commit.AppendSignature(nil, algo) 78 + if !ok { 79 + t.Fatalf("missing %s signature", algo) 80 + } 81 + 82 + err = signRoot.WriteFile("commit.sig", signature, 0o600) 83 + if err != nil { 84 + t.Fatalf("WriteFile(commit.sig): %v", err) 85 + } 86 + 87 + return commit.AppendPayload(nil), allowedSignersPath, signaturePath 88 + } 89 + 90 + func TestSSHSignedCommitIntegration(t *testing.T) { 91 + t.Parallel() 92 + 93 + testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper 94 + payload, allowedSignersPath, signaturePath := setupSSHSignedCommit(t, algo) 95 + 96 + cmd := exec.Command( //nolint:noctx 97 + "ssh-keygen", 98 + "-Y", "verify", 99 + "-n", "git", 100 + "-f", allowedSignersPath, 101 + "-I", "runxiyu@umich.edu", 102 + "-s", signaturePath, 103 + ) //#nosec G204 104 + cmd.Stdin = bytes.NewReader(payload) 105 + out, err := cmd.CombinedOutput() 106 + if err != nil { 107 + t.Fatalf("ssh-keygen verify failed: %v\n%s", err, out) 108 + } 109 + }) 110 + } 111 + 112 + func TestSSHSignedCommitIntegrationRejectsTamperedPayload(t *testing.T) { 113 + t.Parallel() 114 + 115 + testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper 116 + payload, allowedSignersPath, signaturePath := setupSSHSignedCommit(t, algo) 117 + payload = append([]byte(nil), payload...) 118 + payload[len(payload)-2] ^= 1 119 + 120 + cmd := exec.Command( //nolint:noctx 121 + "ssh-keygen", 122 + "-Y", "verify", 123 + "-n", "git", 124 + "-f", allowedSignersPath, 125 + "-I", "runxiyu@umich.edu", 126 + "-s", signaturePath, 127 + ) //#nosec G204 128 + cmd.Stdin = bytes.NewReader(payload) 129 + out, err := cmd.CombinedOutput() 130 + if err == nil { 131 + t.Fatalf("ssh-keygen verify unexpectedly succeeded:\n%s", out) 132 + } 133 + }) 134 + }
+104
object/signed/commit/parse.go
··· 1 + package signedcommit 2 + 3 + import ( 4 + "bytes" 5 + 6 + objectid "codeberg.org/lindenii/furgit/object/id" 7 + ) 8 + 9 + // Parse parses one raw commit object body for signature extraction. 10 + // 11 + // The returned Commit remains valid only while body remains unchanged. 12 + // 13 + // Labels: Deps-Borrowed, Life-Parent. 14 + func Parse(body []byte) (*Commit, error) { 15 + commit := &Commit{ 16 + body: body, 17 + signatures: make(map[objectid.Algorithm][]byteRange), 18 + } 19 + 20 + payloadStart := 0 21 + i := 0 22 + 23 + for i < len(body) { 24 + lineStart := i 25 + 26 + rel := bytes.IndexByte(body[i:], '\n') 27 + next := len(body) 28 + lineEnd := len(body) 29 + if rel >= 0 { 30 + lineEnd = i + rel 31 + next = lineEnd + 1 32 + } 33 + 34 + line := body[lineStart:lineEnd] 35 + i = next 36 + 37 + if len(line) == 0 { 38 + commit.appendPayloadRange(payloadStart, len(body)) 39 + 40 + return commit, nil 41 + } 42 + 43 + if line[0] == ' ' { 44 + continue 45 + } 46 + 47 + if !bytes.HasPrefix(line, []byte("gpgsig")) { 48 + continue 49 + } 50 + 51 + commit.appendPayloadRange(payloadStart, lineStart) 52 + 53 + key, valueStart, found := bytes.Cut(line, []byte{' '}) 54 + if found { 55 + if algo, ok := objectid.ParseSignatureHeaderName(string(key)); ok { 56 + commit.signatures[algo] = append(commit.signatures[algo], byteRange{ 57 + start: lineEnd - len(valueStart), 58 + end: next, 59 + }) 60 + } 61 + } 62 + 63 + for i < len(body) { 64 + rel := bytes.IndexByte(body[i:], '\n') 65 + next = len(body) 66 + lineEnd = len(body) 67 + if rel >= 0 { 68 + lineEnd = i + rel 69 + next = lineEnd + 1 70 + } 71 + 72 + contStart := i 73 + cont := body[contStart:lineEnd] 74 + if len(cont) == 0 || cont[0] != ' ' { 75 + break 76 + } 77 + 78 + if found { 79 + if algo, ok := objectid.ParseSignatureHeaderName(string(key)); ok { 80 + commit.signatures[algo] = append(commit.signatures[algo], byteRange{ 81 + start: contStart + 1, 82 + end: next, 83 + }) 84 + } 85 + } 86 + 87 + i = next 88 + } 89 + 90 + payloadStart = i 91 + } 92 + 93 + commit.appendPayloadRange(payloadStart, len(body)) 94 + 95 + return commit, nil 96 + } 97 + 98 + func (commit *Commit) appendPayloadRange(start, end int) { 99 + if start >= end { 100 + return 101 + } 102 + 103 + commit.payload = append(commit.payload, byteRange{start: start, end: end}) 104 + }
+11
object/signed/commit/payload_append.go
··· 1 + package signedcommit 2 + 3 + // AppendPayload appends the commit verification payload to dst, omitting all 4 + // embedded signature headers. 5 + func (commit *Commit) AppendPayload(dst []byte) []byte { 6 + for _, part := range commit.payload { 7 + dst = append(dst, commit.body[part.start:part.end]...) 8 + } 9 + 10 + return dst 11 + }
+16
object/signed/commit/signature_algorithms.go
··· 1 + package signedcommit 2 + 3 + import objectid "codeberg.org/lindenii/furgit/object/id" 4 + 5 + // Algorithms returns the algorithms for which the commit carries signatures. 6 + func (commit *Commit) Algorithms() []objectid.Algorithm { 7 + var algorithms []objectid.Algorithm 8 + 9 + for _, algo := range objectid.SupportedAlgorithms() { 10 + if _, ok := commit.signatures[algo]; ok { 11 + algorithms = append(algorithms, algo) 12 + } 13 + } 14 + 15 + return algorithms 16 + }
+17
object/signed/commit/signature_append.go
··· 1 + package signedcommit 2 + 3 + import objectid "codeberg.org/lindenii/furgit/object/id" 4 + 5 + // AppendSignature appends the unfolded signature for algo to dst. 6 + func (commit *Commit) AppendSignature(dst []byte, algo objectid.Algorithm) ([]byte, bool) { 7 + signature, ok := commit.signatures[algo] 8 + if !ok { 9 + return dst, false 10 + } 11 + 12 + for _, part := range signature { 13 + dst = append(dst, commit.body[part.start:part.end]...) 14 + } 15 + 16 + return dst, true 17 + }
+166
object/signed/commit/unit_test.go
··· 1 + package signedcommit_test 2 + 3 + import ( 4 + "slices" 5 + "testing" 6 + 7 + objectid "codeberg.org/lindenii/furgit/object/id" 8 + signedcommit "codeberg.org/lindenii/furgit/object/signed/commit" 9 + ) 10 + 11 + func TestParseUpstreamMultiplySignedCommit(t *testing.T) { 12 + t.Parallel() 13 + 14 + // t/t7510-signed-commit.sh 15 + body := []byte("" + 16 + "tree 0cfbf08886fca9a91cb753ec8734c84fcbe52c9f\n" + 17 + "parent 9da738312d24ef0a29be2c8c2b6fc5cf7085a293\n" + 18 + "author A U Thor <author@example.com> 1112912653 -0700\n" + 19 + "committer C O Mitter <committer@example.com> 1112912653 -0700\n" + 20 + "gpgsig -----BEGIN PGP SIGNATURE-----\n" + 21 + " \n" + 22 + " iHQEABECADQWIQRz11h0S+chaY7FTocTtvUezd5DDQUCX/uBDRYcY29tbWl0dGVy\n" + 23 + " QGV4YW1wbGUuY29tAAoJEBO29R7N3kMNd+8AoK1I8mhLHviPH+q2I5fIVgPsEtYC\n" + 24 + " AKCTqBh+VabJceXcGIZuF0Ry+udbBQ==\n" + 25 + " =tQ0N\n" + 26 + " -----END PGP SIGNATURE-----\n" + 27 + "gpgsig-sha256 -----BEGIN PGP SIGNATURE-----\n" + 28 + " \n" + 29 + " iHQEABECADQWIQRz11h0S+chaY7FTocTtvUezd5DDQUCX/uBIBYcY29tbWl0dGVy\n" + 30 + " QGV4YW1wbGUuY29tAAoJEBO29R7N3kMN/NEAn0XO9RYSBj2dFyozi0JKSbssYMtO\n" + 31 + " AJwKCQ1BQOtuwz//IjU8TiS+6S4iUw==\n" + 32 + " =pIwP\n" + 33 + " -----END PGP SIGNATURE-----\n" + 34 + "\n" + 35 + "second\n") 36 + 37 + commit, err := signedcommit.Parse(body) 38 + if err != nil { 39 + t.Fatalf("Parse: %v", err) 40 + } 41 + 42 + gotPayload := string(commit.AppendPayload(nil)) 43 + wantPayload := "" + 44 + "tree 0cfbf08886fca9a91cb753ec8734c84fcbe52c9f\n" + 45 + "parent 9da738312d24ef0a29be2c8c2b6fc5cf7085a293\n" + 46 + "author A U Thor <author@example.com> 1112912653 -0700\n" + 47 + "committer C O Mitter <committer@example.com> 1112912653 -0700\n" + 48 + "\n" + 49 + "second\n" 50 + if gotPayload != wantPayload { 51 + t.Fatalf("payload mismatch:\n got: %q\nwant: %q", gotPayload, wantPayload) 52 + } 53 + 54 + gotSHA1, ok := commit.AppendSignature(nil, objectid.AlgorithmSHA1) 55 + if !ok { 56 + t.Fatal("missing sha1 signature") 57 + } 58 + 59 + wantSHA1 := "" + 60 + "-----BEGIN PGP SIGNATURE-----\n" + 61 + "\n" + 62 + "iHQEABECADQWIQRz11h0S+chaY7FTocTtvUezd5DDQUCX/uBDRYcY29tbWl0dGVy\n" + 63 + "QGV4YW1wbGUuY29tAAoJEBO29R7N3kMNd+8AoK1I8mhLHviPH+q2I5fIVgPsEtYC\n" + 64 + "AKCTqBh+VabJceXcGIZuF0Ry+udbBQ==\n" + 65 + "=tQ0N\n" + 66 + "-----END PGP SIGNATURE-----\n" 67 + if string(gotSHA1) != wantSHA1 { 68 + t.Fatalf("sha1 signature mismatch:\n got: %q\nwant: %q", string(gotSHA1), wantSHA1) 69 + } 70 + 71 + gotSHA256, ok := commit.AppendSignature(nil, objectid.AlgorithmSHA256) 72 + if !ok { 73 + t.Fatal("missing sha256 signature") 74 + } 75 + 76 + wantSHA256 := "" + 77 + "-----BEGIN PGP SIGNATURE-----\n" + 78 + "\n" + 79 + "iHQEABECADQWIQRz11h0S+chaY7FTocTtvUezd5DDQUCX/uBIBYcY29tbWl0dGVy\n" + 80 + "QGV4YW1wbGUuY29tAAoJEBO29R7N3kMN/NEAn0XO9RYSBj2dFyozi0JKSbssYMtO\n" + 81 + "AJwKCQ1BQOtuwz//IjU8TiS+6S4iUw==\n" + 82 + "=pIwP\n" + 83 + "-----END PGP SIGNATURE-----\n" 84 + if string(gotSHA256) != wantSHA256 { 85 + t.Fatalf("sha256 signature mismatch:\n got: %q\nwant: %q", string(gotSHA256), wantSHA256) 86 + } 87 + 88 + gotAlgorithms := commit.Algorithms() 89 + wantAlgorithms := []objectid.Algorithm{ 90 + objectid.AlgorithmSHA1, 91 + objectid.AlgorithmSHA256, 92 + } 93 + if !slices.Equal(gotAlgorithms, wantAlgorithms) { 94 + t.Fatalf("Algorithms() = %v, want %v", gotAlgorithms, wantAlgorithms) 95 + } 96 + } 97 + 98 + func TestParseStripsUnknownGpgsigHeadersFromPayload(t *testing.T) { 99 + t.Parallel() 100 + 101 + body := []byte("" + 102 + "tree deadbeef\n" + 103 + "gpgsig-future header\n" + 104 + " continued\n" + 105 + "\n" + 106 + "message\n") 107 + 108 + commit, err := signedcommit.Parse(body) 109 + if err != nil { 110 + t.Fatalf("Parse: %v", err) 111 + } 112 + 113 + gotPayload := string(commit.AppendPayload(nil)) 114 + wantPayload := "" + 115 + "tree deadbeef\n" + 116 + "\n" + 117 + "message\n" 118 + if gotPayload != wantPayload { 119 + t.Fatalf("payload mismatch:\n got: %q\nwant: %q", gotPayload, wantPayload) 120 + } 121 + 122 + if gotAlgorithms := commit.Algorithms(); len(gotAlgorithms) != 0 { 123 + t.Fatalf("Algorithms() = %v, want none", gotAlgorithms) 124 + } 125 + } 126 + 127 + func TestParseAllowsDuplicateSignatureHeaders(t *testing.T) { 128 + t.Parallel() 129 + 130 + body := []byte("" + 131 + "tree deadbeef\n" + 132 + "gpgsig one\n" + 133 + " two\n" + 134 + "gpgsig three\n" + 135 + " four\n" + 136 + "\n" + 137 + "message\n") 138 + 139 + commit, err := signedcommit.Parse(body) 140 + if err != nil { 141 + t.Fatalf("Parse: %v", err) 142 + } 143 + 144 + gotPayload := string(commit.AppendPayload(nil)) 145 + wantPayload := "" + 146 + "tree deadbeef\n" + 147 + "\n" + 148 + "message\n" 149 + if gotPayload != wantPayload { 150 + t.Fatalf("payload mismatch:\n got: %q\nwant: %q", gotPayload, wantPayload) 151 + } 152 + 153 + gotSignature, ok := commit.AppendSignature(nil, objectid.AlgorithmSHA1) 154 + if !ok { 155 + t.Fatal("missing sha1 signature") 156 + } 157 + 158 + wantSignature := "" + 159 + "one\n" + 160 + "two\n" + 161 + "three\n" + 162 + "four\n" 163 + if string(gotSignature) != wantSignature { 164 + t.Fatalf("signature mismatch:\n got: %q\nwant: %q", string(gotSignature), wantSignature) 165 + } 166 + }