diff --git a/BUILD.bazel b/BUILD.bazel index cda2e28..10f5eee 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -19,13 +19,18 @@ gazelle(name = "gazelle") go_library( name = "git-ratchet_lib", - srcs = ["main.go"], + srcs = [ + "main.go", + "tlogcmd.go", + ], importpath = "github.com/project-oak/git-ratchet", visibility = ["//visibility:private"], deps = [ + "//internal/gitlog", "//internal/gitutil", "//internal/note", "//internal/policy", + "//internal/tlog", "//internal/witness", "@com_github_google_subcommands//:subcommands", ], diff --git a/README.md b/README.md index 0a24b9b..c841402 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,29 @@ Tag checkpoints do not require ancestry proofs. The witness simply checks that t ## Witness policy A policy specifies the trusted origin key, witness keys, and quorum. The format follows the [C2SP](https://c2sp.org/) [tlog-policy](https://c2sp.org/tlog-policy) specification, extended with the `github-issue://` witness URI scheme for [GitHub Issue witnesses](docs/github-issue-witness.md). +## Checkpoint modes + +git-ratchet supports two checkpoint formats, selected with `--mode`. + +| | `git-checkpoint` (default) | `tlog` | +|---|---|---| +| What is stored | A signed note per ref, at `refs/checkpoints/*` | A Merkle transparency log of ref updates, at `refs/ratchet/log` | +| What the witness checks | Git commit ancestry | Merkle tree consistency | +| Witness protocol | [git-ratchet's own](docs/witness-protocol.md) | C2SP [tlog-witness](https://c2sp.org/tlog-witness) | +| Third-party witnesses | Must run git-ratchet's witness | Any conforming witness | +| Rollback is | Refused at cosigning time | Recorded, and rejected by `verify` | + +`git-checkpoint` mode gives the stronger guarantee — a witness will not cosign a rollback at all — at the cost of requiring every witness operator to run git-ratchet's own implementation. + +`tlog` mode trades that for interoperability: the checkpoint is a standard [tlog-checkpoint](https://c2sp.org/tlog-checkpoint) with no Git-specific fields, so witnesses that have never heard of git-ratchet can cosign it. In exchange, the witness can no longer tell a fast-forward from a rollback, and the ratchet is established by `verify` walking the logged entries — a local, inexpensive walk, since the log ships with the repository. + +See [docs/tlog-variant.md](docs/tlog-variant.md) for the full specification and an honest account of what each mode does and does not guarantee. + +```bash +git-ratchet checkpoint --mode tlog --ref refs/heads/main --key origin.key --policy policy.txt +git-ratchet verify --mode tlog --ref refs/heads/main --policy policy.txt +``` + ## Witnesses git-ratchet supports two types of witnesses: @@ -81,6 +104,8 @@ git-ratchet checkpoint-request \ Produces the add-checkpoint request body (ancestry proof + signed note) without contacting any witnesses. The output can later be submitted to witnesses out-of-band. The origin identity is derived from the key file; use `--origin` to override (required when using `--kms-key`). +The decomposed workflow (`checkpoint-request` / `checkpoint-store`) supports `git-checkpoint` mode only. `tlog` mode requires HTTP witnesses. + ### `git-ratchet checkpoint-store` ``` @@ -101,6 +126,8 @@ git-ratchet verify --policy --ref [--ref ...] [flags] Verifies checkpoint signatures against the policy and confirms each ref still matches the checkpointed commit. The `--ref` flag can be repeated to verify multiple refs. +In `--mode tlog` this additionally walks the logged entries for each ref, checking that branch history only ever moved forward and that each tag was logged exactly once. See [Checkpoint modes](#checkpoint-modes). + ### `git-ratchet audit` ``` diff --git a/docs/tlog-variant.md b/docs/tlog-variant.md new file mode 100644 index 0000000..688c7a8 --- /dev/null +++ b/docs/tlog-variant.md @@ -0,0 +1,267 @@ +# Transparency log mode + +This document specifies `tlog` mode: an alternative to git-ratchet's default +[git-checkpoint](git-checkpoint.md) format in which the repository maintains a +[Merkle transparency log][tlog-tiles] of its own ref updates, stored in the +repository as Git refs, and checkpointed with a standard +[tlog-checkpoint][] cosigned by standard [tlog-witness][] witnesses. + +Both modes ship. Select one with `--mode`: + +``` +git-ratchet checkpoint --mode tlog ... +git-ratchet verify --mode tlog ... +git-ratchet audit --mode tlog ... +witness -mode tlog ... +``` + +`--mode git-checkpoint` is the default and is unchanged. + +[tlog-tiles]: https://c2sp.org/tlog-tiles +[tlog-checkpoint]: https://c2sp.org/tlog-checkpoint +[tlog-witness]: https://c2sp.org/tlog-witness +[tlog-cosignature]: https://c2sp.org/tlog-cosignature +[signed-note]: https://c2sp.org/signed-note + +## Conventions used in this document + +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in [BCP 14][] [RFC 2119][] [RFC 8174][] when, and only when, they appear in all capitals, as shown here. + +[BCP 14]: https://www.rfc-editor.org/info/bcp14 +[RFC 2119]: https://www.rfc-editor.org/rfc/rfc2119.html +[RFC 8174]: https://www.rfc-editor.org/rfc/rfc8174.html + +## Why + +In `git-checkpoint` mode the witness verifies Git commit ancestry. That makes +the ratchet an *enforced* property — a witness will not cosign a rollback — but +it means every witness must run git-ratchet's own witness implementation. Since +the security of a ratchet rests on witness *diversity*, and diversity requires +witness operators who have no relationship with the origin, requiring bespoke +software to participate is a real obstacle. + +`tlog` mode removes that obstacle. The checkpoint is an ordinary +`tlog-checkpoint` and the witness call is an ordinary `tlog-witness` +`add-checkpoint`, so any conforming witness can cosign a git-ratchet log +without knowing what Git is. + +The cost is stated plainly in [Security properties](#security-properties) +below: the ratchet stops being enforced at cosigning time and becomes a +property that verifiers establish for themselves. + +## The log + +Each ratcheted repository has exactly **one** log, covering all of its refs. + +One log per repository, rather than one per ref, is a deliberate choice. A +witness's state — and its operator's onboarding process — is per log. A +repository with fifty tags would otherwise need fifty witness registrations, +which defeats the purpose of speaking a protocol third-party witnesses already +implement. + +### Entries + +A log entry is a single line naming a ref and the object it pointed at: + +``` + +``` + +The ref path MUST begin with `refs/heads/` or `refs/tags/`. The object hash is +hex-encoded: a commit hash for branches and lightweight tags, the tag object +hash for annotated tags — the same value `git-checkpoint` mode binds. + +Entries record **state, not transitions**. An entry does not name the ref's +previous value. The log's ordering already establishes what came before, and a +self-asserted predecessor would be a field that verification must not trust +anyway. + +The Merkle leaf hash of an entry is `SHA-256(0x00 || )`, per +[RFC 6962][] section 2.1, with the entry line taken without a trailing newline. + +[RFC 6962]: https://www.rfc-editor.org/rfc/rfc6962.html + +### Checkpoint + +The log is checkpointed with a [tlog-checkpoint][] body: + +``` + + + +``` + +The origin is the same identifier `git-checkpoint` mode uses — the key name +from the origin's [signed-note][] verifier key. The size is the number of +entries. The root hash is the RFC 6962 Merkle tree hash over all entry leaf +hashes. + +Note what is *not* here: no ref path, no Git object hash, no Git-specific field +of any kind. That is what makes the checkpoint cosignable by a witness that has +never heard of git-ratchet. + +Witnesses append [tlog-cosignature][] lines. For ML-DSA-44 the cosigned message +is the specification's binary struct with its fields carrying their intended +values — `log_origin` is the checkpoint's origin, `end` is the tree size, and +`hash` is the root hash. (In `git-checkpoint` mode there are no such values, so +those fields are filled by repurposing the ref line and object hash; see +`buildCosignedMessage` in `internal/note/note.go`.) + +### Storage + +The log lives at `refs/ratchet/log`, which points at a **commit**. Each +checkpoint adds one commit whose tree is: + +``` +checkpoint the cosigned tlog-checkpoint +tile/entries/ entry bundles, 256 entries each +``` + +Entry bundle paths follow the tlog-tiles scheme: the bundle index in base-1000 +groups of three digits joined by `/`, every group but the last prefixed with +`x`, and a `.p/` suffix while the bundle is not yet full. So bundle 0 +is `tile/entries/000` once full and `tile/entries/000.p/17` at seventeen +entries; bundle 1234567 is `tile/entries/x001/x234/567`. + +Storing the log as a commit rather than a blob has a useful consequence: the +log ref can only be advanced by a fast-forward push, so an ordinary Git server +rejects a rewritten log before any git-ratchet code runs. That is a +belt-and-braces check, not a security control — a server under the origin's +control can be told to accept a force-push — but it costs nothing. + +#### Hash tiles are not stored + +A conforming tlog-tiles log also serves hash tiles under `tile//`. +This implementation does not write them, and recomputes the tree from the +entries instead. + +Hash tiles exist so that a client holding none of the log can verify a proof +against it. Every consumer of a git-ratchet log holds all of it — the log +arrives with the repository — and the witness is sent its consistency proof in +the request and needs no tiles either. Nothing in this design reads them. +Emitting hash tiles would be a small addition if a third-party tlog-tiles +client ever wanted to consume the log directly. + +## Witness protocol + +`tlog` mode speaks [tlog-witness][] `add-checkpoint`: + + POST /add-checkpoint + +with a request body of: + +``` +old + +... + + +``` + +The witness MUST verify the origin signature, look up the tree it last cosigned +for that origin, and verify the RFC 6962 consistency proof from that tree to +the submitted one. It MUST reject a submission whose size is below the size it +holds. + +The witness never sees the entries. It cannot tell a fast-forward from a +rollback, and it is not asked to. + +### Response codes + +| Status | Meaning | +| :--- | :--- | +| **200 OK** | Consistency verified, state updated, cosignature in the body. | +| **400 Bad Request** | Malformed request or checkpoint body. | +| **403 Forbidden** | Origin signature invalid, or the checkpoint's origin does not match the signer. | +| **404 Not Found** | Origin unknown to this witness. | +| **409 Conflict** | The client's `old` size is not the size the witness holds, or the log would shrink. | +| **422 Unprocessable Entity** | The consistency proof does not verify. | + +A 409 body begins with `old ` naming the size the witness actually holds. +This implementation's client uses that to regenerate its proof and resubmit +once, automatically. + +This is the conflict round trip that `git-checkpoint` mode avoids: a commit +chain spans any gap between client and witness, whereas a consistency proof is +anchored to a specific size. The recovery is a single extra request. + +## Verification + +`git-ratchet verify --mode tlog` performs, in order: + +1. Read `refs/ratchet/log` and its stored checkpoint. Verify the origin + signature and witness quorum against the policy. +2. Check the checkpoint's origin matches the policy's log name. +3. Check the entries present reproduce the checkpoint's size and root hash + exactly. Entries beyond the checkpoint are unwitnessed; a mismatch fails. +4. **Walk the entries for each requested ref**, in log order: + - **Branches**: each logged commit MUST be a descendant of the one logged + before it. A break means history was rewritten. + - **Tags**: a tag MUST appear exactly once. A second entry is a move, + whatever object it names. +5. Compare the ref's current value against its latest entry: a branch MUST be + at or behind it, a tag MUST match it exactly. + +Step 4 is the ratchet. It replaces what the witness used to do, and it is +always performed — there is no cheaper verification path, because a cheaper +one would not be safe. + +The walk is inexpensive despite doing more work than `git-checkpoint` mode's +`verify`. The log and the commit objects are in the same repository, so it is a +sequence of local `git merge-base --is-ancestor` calls with nothing to fetch. + +If a logged commit is missing from the object database — because a rollback was +followed by garbage collection — the walk fails with a diagnostic saying so. +That is the correct outcome: the log asserts a commit existed and the +repository cannot produce it. + +## Security properties + +The two modes protect the same thing and detect the same attacks. They differ +in **who establishes the ratchet**, and the difference is worth being precise +about. + +| | `git-checkpoint` | `tlog` | +| :--- | :--- | :--- | +| Witness verifies | Git commit ancestry | Merkle tree consistency | +| Witness can cosign a rollback | No | **Yes** | +| Ratchet established by | The witness, at cosigning time | The verifier, walking the log | +| Verifier work | O(1): check one signed note | O(entries for the ref), all local | +| Usable with third-party witnesses | No | **Yes** | +| Checkpoint meaningful standalone | Yes — asserts a ref is at a commit | No — asserts only a log's head | + +Two consequences deserve emphasis: + +**A witness will cosign a rollback.** This is not a defect; appending a +rolled-back state to a log is a perfectly consistent log operation, and a +witness that only sees tree heads has no basis to object. The end-to-end test +`TestTlogDetectsBranchRollback` asserts exactly this: the checkpoint succeeds, +and `verify` rejects it. + +**A checkpoint no longer means anything on its own.** A `git-checkpoint` is a +semantic attestation — *this witness attests `main` is at this commit, having +arrived there by fast-forward* — and can be quoted as evidence by someone who +does not have the repository. A `tlog-checkpoint` attests only that a log is +append-only and its head is this. Anything that consumes checkpoints outside +`git-ratchet verify` — a build attestation referencing one, say — is relying on +a property `tlog` mode does not provide. + +What is *not* weakened is tamper-evidence. A rollback that reaches the log is +permanent, cosigned, and undeniable; the log cannot be rewritten to remove it +without losing witness cosignatures. Detection moves from the witness to the +verifier, and the verifier can do it with what it already has. + +## Scope + +The following are not implemented in this mode: + +- **Decomposed workflow.** `checkpoint-request` and `checkpoint-store` support + `git-checkpoint` mode only, so the [GitHub Issue witness](github-issue-witness.md) + transport is not available for `tlog` mode. Only HTTP witnesses are. +- **Hash tiles**, for the reason given above. +- **Concurrency.** A single log serialises checkpointing across all of a + repository's refs. Two checkpoint runs that start from the same log head will + race, and the loser's `Save` is rejected by a compare-and-swap on the log ref + rather than silently discarding the winner's entries. Repositories + checkpointing more than one ref concurrently should serialise the runs — for + example with a repository-wide, rather than per-ref, CI concurrency group. diff --git a/e2e/BUILD.bazel b/e2e/BUILD.bazel index 9873bb3..126bbc4 100644 --- a/e2e/BUILD.bazel +++ b/e2e/BUILD.bazel @@ -16,7 +16,10 @@ load("@rules_go//go:def.bzl", "go_test") go_test( name = "e2e_test", - srcs = ["integration_test.go"], + srcs = [ + "integration_test.go", + "tlog_test.go", + ], data = [ "//:git-ratchet", "//witness", diff --git a/e2e/tlog_test.go b/e2e/tlog_test.go new file mode 100644 index 0000000..140f761 --- /dev/null +++ b/e2e/tlog_test.go @@ -0,0 +1,373 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package e2e + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/project-oak/git-ratchet/internal/note" +) + +// tlogFixture is a repository wired up to a running tlog-mode witness. +type tlogFixture struct { + ratchetBin string + repoDir string + keyPath string + policyPath string + + witnessBin string + witnessKey *note.Signer + originKey *note.Signer + originsPath string + statePath string +} + +func newTlogFixture(t *testing.T) *tlogFixture { + t.Helper() + + f := &tlogFixture{ + ratchetBin: mustFindBinary(t), + witnessBin: mustFindWitnessBinary(t), + originKey: mustGenerateKey(t, "test-origin", note.Ed25519Origin, note.RoleOrigin), + witnessKey: mustGenerateKey(t, "test-witness", note.Ed25519Cosigner, note.RoleCosigner), + } + f.repoDir = initTestRepo(t) + + tmpDir := t.TempDir() + witnessKeyPath := filepath.Join(tmpDir, "witness.key") + mustWriteKey(t, witnessKeyPath, f.witnessKey) + + f.originsPath = filepath.Join(tmpDir, "origins.txt") + if err := os.WriteFile(f.originsPath, []byte(f.originKey.VKey()+"\n"), 0644); err != nil { + t.Fatal(err) + } + f.statePath = filepath.Join(tmpDir, "state.json") + f.keyPath = writeKeyFile(t, tmpDir, f.originKey) + + port := getFreePort(t) + stop := startTlogWitnessServer(t, f.witnessBin, port, witnessKeyPath, f.originsPath, f.statePath) + t.Cleanup(stop) + + f.policyPath = writePolicyFile(t, f.repoDir, f.originKey, f.witnessKey, + fmt.Sprintf("http://127.0.0.1:%d", port)) + return f +} + +// startTlogWitnessServer starts the witness binary in transparency-log mode. +func startTlogWitnessServer(t *testing.T, binary string, port int, keyPath, originsPath, statePath string) func() { + t.Helper() + cmd := exec.Command(binary, + "-addr", fmt.Sprintf("127.0.0.1:%d", port), + "-mode", "tlog", + "-key", keyPath, + "-origins-file", originsPath, + "-state-file", statePath, + ) + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + t.Fatalf("starting tlog witness server: %v", err) + } + waitForServer(t, fmt.Sprintf("127.0.0.1:%d", port)) + return func() { + _ = cmd.Process.Kill() + _ = cmd.Wait() + } +} + +// checkpoint runs git-ratchet checkpoint in tlog mode, returning its output. +func (f *tlogFixture) checkpoint(t *testing.T, ref string) (string, error) { + t.Helper() + out, err := exec.Command(f.ratchetBin, + "checkpoint", + "--mode", "tlog", + "--ref", ref, + "--repo", f.repoDir, + "--key", f.keyPath, + "--policy", f.policyPath, + ).CombinedOutput() + return string(out), err +} + +func (f *tlogFixture) mustCheckpoint(t *testing.T, ref string) string { + t.Helper() + out, err := f.checkpoint(t, ref) + if err != nil { + t.Fatalf("checkpoint %s failed: %v\n%s", ref, err, out) + } + return out +} + +// verify runs git-ratchet verify in tlog mode, returning its output. +func (f *tlogFixture) verify(t *testing.T, refs ...string) (string, error) { + t.Helper() + args := []string{"verify", "--mode", "tlog", "--repo", f.repoDir, "--policy", f.policyPath} + for _, ref := range refs { + args = append(args, "--ref", ref) + } + out, err := exec.Command(f.ratchetBin, args...).CombinedOutput() + return string(out), err +} + +// TestTlogIntegration walks the happy path: successive fast-forward commits are +// logged, cosigned, and verify cleanly. +func TestTlogIntegration(t *testing.T) { + f := newTlogFixture(t) + + makeCommit(t, f.repoDir, "first commit") + f.mustCheckpoint(t, "refs/heads/main") + if out, err := f.verify(t, "refs/heads/main"); err != nil { + t.Fatalf("verify after first checkpoint: %v\n%s", err, out) + } + + // A commit that has not been logged leaves the branch ahead of the log. + makeCommit(t, f.repoDir, "second commit") + if out, err := f.verify(t, "refs/heads/main"); err == nil { + t.Errorf("verify should fail while HEAD is ahead of the log:\n%s", out) + } + + f.mustCheckpoint(t, "refs/heads/main") + if out, err := f.verify(t, "refs/heads/main"); err != nil { + t.Fatalf("verify after second checkpoint: %v\n%s", err, out) + } + + makeCommit(t, f.repoDir, "third commit") + out := f.mustCheckpoint(t, "refs/heads/main") + if !strings.Contains(out, "log size 3") { + t.Errorf("expected the log to have grown to 3 entries, got: %s", out) + } + if out, err := f.verify(t, "refs/heads/main"); err != nil { + t.Fatalf("verify after third checkpoint: %v\n%s", err, out) + } + + // The log lives in the repository as a commit ref. + if out := runOutput(t, f.repoDir, "git", "cat-file", "-t", "refs/ratchet/log"); strings.TrimSpace(out) != "commit" { + t.Errorf("refs/ratchet/log should be a commit, got %q", strings.TrimSpace(out)) + } +} + +// TestTlogRecheckpointUnchangedRefDoesNotGrowLog checks that re-checkpointing a +// ref that has not moved refreshes cosignatures without appending a redundant +// entry. +func TestTlogRecheckpointUnchangedRefDoesNotGrowLog(t *testing.T) { + f := newTlogFixture(t) + + makeCommit(t, f.repoDir, "only commit") + f.mustCheckpoint(t, "refs/heads/main") + + out := f.mustCheckpoint(t, "refs/heads/main") + if !strings.Contains(out, "log size 1") { + t.Errorf("re-checkpointing an unchanged ref should leave the log at size 1, got: %s", out) + } + if out, err := f.verify(t, "refs/heads/main"); err != nil { + t.Fatalf("verify after refresh: %v\n%s", err, out) + } +} + +// TestTlogDetectsBranchRollback is the central test for this mode. +// +// The witness only attests that the log grew by appending, so it cosigns a +// rollback quite happily — there is nothing in a consistency proof that says +// anything about Git ancestry. The property is preserved because verify walks +// the logged entries and finds that one does not descend from its predecessor. +func TestTlogDetectsBranchRollback(t *testing.T) { + f := newTlogFixture(t) + + first := makeCommit(t, f.repoDir, "first commit") + makeCommit(t, f.repoDir, "second commit") + f.mustCheckpoint(t, "refs/heads/main") + if out, err := f.verify(t, "refs/heads/main"); err != nil { + t.Fatalf("verify before rollback: %v\n%s", err, out) + } + + // Roll the branch back and log the rolled-back state. + run(t, f.repoDir, "git", "reset", "--hard", first) + + out, err := f.checkpoint(t, "refs/heads/main") + if err != nil { + t.Fatalf("the witness is expected to cosign a rollback in tlog mode, "+ + "since appending to the log is consistent: %v\n%s", err, out) + } + + verifyOut, err := f.verify(t, "refs/heads/main") + if err == nil { + t.Fatalf("verify should reject a logged rollback:\n%s", verifyOut) + } + if !strings.Contains(verifyOut, "history was rewritten") { + t.Errorf("expected a rewritten-history diagnostic, got:\n%s", verifyOut) + } +} + +// TestTlogDetectsTagMove checks the create-once rule for tags: a tag logged +// twice is a moved tag, whatever it points at. +func TestTlogDetectsTagMove(t *testing.T) { + f := newTlogFixture(t) + + makeCommit(t, f.repoDir, "first commit") + run(t, f.repoDir, "git", "tag", "v1.0.0") + f.mustCheckpoint(t, "refs/tags/v1.0.0") + if out, err := f.verify(t, "refs/tags/v1.0.0"); err != nil { + t.Fatalf("verify after tagging: %v\n%s", err, out) + } + + // Move the tag and log it again. + makeCommit(t, f.repoDir, "second commit") + run(t, f.repoDir, "git", "tag", "-f", "v1.0.0") + + if out, err := f.checkpoint(t, "refs/tags/v1.0.0"); err != nil { + t.Fatalf("the witness is expected to cosign a moved tag in tlog mode: %v\n%s", err, out) + } + + verifyOut, err := f.verify(t, "refs/tags/v1.0.0") + if err == nil { + t.Fatalf("verify should reject a tag logged twice:\n%s", verifyOut) + } + if !strings.Contains(verifyOut, "must be logged exactly once") { + t.Errorf("expected a create-once diagnostic, got:\n%s", verifyOut) + } +} + +// TestTlogVerifyRejectsUnloggedRef checks that a ref with no entries is not +// silently treated as verified. +func TestTlogVerifyRejectsUnloggedRef(t *testing.T) { + f := newTlogFixture(t) + + makeCommit(t, f.repoDir, "first commit") + f.mustCheckpoint(t, "refs/heads/main") + + run(t, f.repoDir, "git", "branch", "other") + out, err := f.verify(t, "refs/heads/other") + if err == nil { + t.Fatalf("verify should reject a ref with no log entries:\n%s", out) + } + if !strings.Contains(out, "no log entries") { + t.Errorf("expected a no-entries diagnostic, got:\n%s", out) + } +} + +// TestTlogVerifyRejectsTamperedCheckpoint checks that the stored checkpoint's +// signature is actually enforced. +func TestTlogVerifyRejectsTamperedCheckpoint(t *testing.T) { + f := newTlogFixture(t) + + makeCommit(t, f.repoDir, "first commit") + f.mustCheckpoint(t, "refs/heads/main") + + // Rewrite the checkpoint blob inside the log tree, keeping the tree + // otherwise intact, and repoint the log ref at the result. + original := runOutput(t, f.repoDir, "git", "cat-file", "-p", "refs/ratchet/log:checkpoint") + tampered := []byte(original) + for i := len(tampered) - 5; i < len(tampered)-1; i++ { + tampered[i] ^= 0xFF + } + blob := gitStdin(t, f.repoDir, string(tampered), "hash-object", "-w", "--stdin") + + indexFile := filepath.Join(t.TempDir(), "index") + gitEnv(t, f.repoDir, []string{"GIT_INDEX_FILE=" + indexFile}, "read-tree", "refs/ratchet/log^{tree}") + gitEnv(t, f.repoDir, []string{"GIT_INDEX_FILE=" + indexFile}, + "update-index", "--add", "--cacheinfo", "100644,"+blob+",checkpoint") + tree := strings.TrimSpace(gitEnv(t, f.repoDir, []string{"GIT_INDEX_FILE=" + indexFile}, "write-tree")) + commit := strings.TrimSpace(gitEnv(t, f.repoDir, []string{ + "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t", + }, "commit-tree", tree, "-m", "tampered")) + run(t, f.repoDir, "git", "update-ref", "refs/ratchet/log", commit) + + out, err := f.verify(t, "refs/heads/main") + if err == nil { + t.Fatalf("verify should reject a tampered checkpoint:\n%s", out) + } +} + +// TestTlogWitnessSizeConflictRecovery checks the 409 recovery path: a witness +// that has lost its state reports the size it actually holds, and the client +// regenerates its proof and resubmits without operator intervention. +func TestTlogWitnessSizeConflictRecovery(t *testing.T) { + f := newTlogFixture(t) + + makeCommit(t, f.repoDir, "first commit") + f.mustCheckpoint(t, "refs/heads/main") + makeCommit(t, f.repoDir, "second commit") + f.mustCheckpoint(t, "refs/heads/main") + + // Wipe the witness's state and restart it on a new port. The client still + // believes the witness holds a tree of size 2. + if err := os.Remove(f.statePath); err != nil { + t.Fatal(err) + } + witnessKeyPath := filepath.Join(t.TempDir(), "witness.key") + mustWriteKey(t, witnessKeyPath, f.witnessKey) + port := getFreePort(t) + stop := startTlogWitnessServer(t, f.witnessBin, port, witnessKeyPath, f.originsPath, f.statePath) + defer stop() + f.policyPath = writePolicyFile(t, f.repoDir, f.originKey, f.witnessKey, + fmt.Sprintf("http://127.0.0.1:%d", port)) + + makeCommit(t, f.repoDir, "third commit") + if out, err := f.checkpoint(t, "refs/heads/main"); err != nil { + t.Fatalf("checkpoint should recover from a witness size conflict: %v\n%s", err, out) + } + if out, err := f.verify(t, "refs/heads/main"); err != nil { + t.Fatalf("verify after conflict recovery: %v\n%s", err, out) + } +} + +// TestTlogMultipleRefsShareOneLog checks that branches and tags coexist in a +// single log and are each verified against their own entries. +func TestTlogMultipleRefsShareOneLog(t *testing.T) { + f := newTlogFixture(t) + + makeCommit(t, f.repoDir, "first commit") + run(t, f.repoDir, "git", "tag", "v1.0.0") + f.mustCheckpoint(t, "refs/heads/main") + f.mustCheckpoint(t, "refs/tags/v1.0.0") + + makeCommit(t, f.repoDir, "second commit") + out := f.mustCheckpoint(t, "refs/heads/main") + if !strings.Contains(out, "log size 3") { + t.Errorf("expected three entries across both refs, got: %s", out) + } + + if out, err := f.verify(t, "refs/heads/main", "refs/tags/v1.0.0"); err != nil { + t.Fatalf("verify of both refs: %v\n%s", err, out) + } +} + +// gitStdin runs a git command with content on stdin and returns trimmed output. +func gitStdin(t *testing.T, dir, stdin string, args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd.Stdin = strings.NewReader(stdin) + out, err := cmd.Output() + if err != nil { + t.Fatalf("git %s failed: %v", strings.Join(args, " "), err) + } + return strings.TrimSpace(string(out)) +} + +// gitEnv runs a git command with extra environment variables. +func gitEnv(t *testing.T, dir string, env []string, args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd.Env = append(os.Environ(), env...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %s failed: %v\n%s", strings.Join(args, " "), err, out) + } + return string(out) +} diff --git a/internal/gitlog/BUILD.bazel b/internal/gitlog/BUILD.bazel new file mode 100644 index 0000000..af808af --- /dev/null +++ b/internal/gitlog/BUILD.bazel @@ -0,0 +1,39 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "gitlog", + srcs = [ + "gitlog.go", + "tilepath.go", + ], + importpath = "github.com/project-oak/git-ratchet/internal/gitlog", + visibility = ["//:__subpackages__"], + deps = [ + "//internal/gitutil", + "//internal/tlog", + ], +) + +go_test( + name = "gitlog_test", + srcs = [ + "gitlog_test.go", + "tilepath_test.go", + ], + embed = [":gitlog"], + deps = ["//internal/tlog"], +) diff --git a/internal/gitlog/gitlog.go b/internal/gitlog/gitlog.go new file mode 100644 index 0000000..e30a60f --- /dev/null +++ b/internal/gitlog/gitlog.go @@ -0,0 +1,349 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package gitlog stores a git-ratchet transparency log inside the Git +// repository it describes, as a commit under refs/ratchet/log. +// +// Layout of the log commit's tree: +// +// checkpoint latest cosigned tlog-checkpoint +// tile/entries/ entry bundles, 256 entries each +// +// Entry bundles follow the tlog-tiles path scheme, so the entries a third +// party needs to reconstruct the tree are laid out where a tlog-tiles client +// expects them. Hash tiles are not stored: every consumer of a git-ratchet log +// already has the whole log locally (it arrives with the repository), so the +// tree is recomputed from the entries rather than served from tiles. +// +// Storing the log as a commit rather than a blob buys one thing for free: the +// log ref can only be advanced by a fast-forward push, so an ordinary Git +// server rejects a rewritten log before any git-ratchet code runs. +package gitlog + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/project-oak/git-ratchet/internal/gitutil" + "github.com/project-oak/git-ratchet/internal/tlog" +) + +// LogRef is the ref holding the transparency log. +const LogRef = "refs/ratchet/log" + +// EntriesPerBundle is the number of log entries in a full entry bundle, per +// the tlog-tiles tile height of 8. +const EntriesPerBundle = 256 + +// checkpointPath is where the cosigned checkpoint lives in the log tree. +const checkpointPath = "checkpoint" + +// Entry is a single logged statement: the object a ref pointed at when the +// entry was appended. +// +// Entries record state, not transitions. The log's own ordering establishes +// what the previous state was, so carrying a self-asserted predecessor in the +// entry would add a field that verification must not trust anyway. +type Entry struct { + Ref string // full ref path, e.g. "refs/heads/main" + Hash string // hex object hash the ref pointed at +} + +// String renders the entry's canonical form, which is what gets hashed into +// the Merkle tree and written to the entry bundle. +func (e Entry) String() string { + return e.Ref + " " + e.Hash +} + +// LeafHash returns the RFC 6962 leaf hash of the entry. +func (e Entry) LeafHash() tlog.Hash { + return tlog.HashLeaf([]byte(e.String())) +} + +// ParseEntry parses an entry's canonical form. +func ParseEntry(s string) (Entry, error) { + fields := strings.Fields(s) + if len(fields) != 2 { + return Entry{}, fmt.Errorf("malformed log entry %q: expected 2 fields, got %d", s, len(fields)) + } + if _, err := gitutil.ParseRefKind(fields[0]); err != nil { + return Entry{}, fmt.Errorf("malformed log entry %q: %w", s, err) + } + return Entry{Ref: fields[0], Hash: fields[1]}, nil +} + +// Log is an in-memory view of the repository's transparency log. +type Log struct { + repoDir string + + entries []Entry + checkpoint string // cosigned checkpoint as stored, empty if the log is new + head string // log commit hash, empty if the ref does not exist yet +} + +// Open reads the log from the repository. A repository with no log ref yet +// opens as an empty log. +func Open(repoDir string) (*Log, error) { + l := &Log{repoDir: repoDir} + + if !gitutil.RefExists(repoDir, LogRef) { + return l, nil + } + + head, err := gitutil.ResolveRef(repoDir, LogRef) + if err != nil { + return nil, fmt.Errorf("resolving %s: %w", LogRef, err) + } + l.head = head + + if cp, err := gitutil.CatFile(repoDir, LogRef+":"+checkpointPath); err == nil { + l.checkpoint = cp + } + + entries, err := readEntries(repoDir) + if err != nil { + return nil, err + } + l.entries = entries + return l, nil +} + +// readEntries loads every entry bundle in the log tree, in index order. +func readEntries(repoDir string) ([]Entry, error) { + out, err := gitutil.Run(repoDir, "ls-tree", "-r", "--name-only", LogRef, "tile/entries/") + if err != nil { + // A log with a checkpoint but no entries is not valid, but an empty + // tree is not an error to read. + return nil, nil + } + + // Collect bundle paths keyed by their starting entry index so they can be + // concatenated in order regardless of how ls-tree sorts them. + bundles := make(map[int]string) + for _, path := range strings.Split(strings.TrimSpace(out), "\n") { + path = strings.TrimSpace(path) + if path == "" { + continue + } + idx, err := parseBundlePath(path) + if err != nil { + return nil, fmt.Errorf("unexpected object in log tree: %w", err) + } + if prev, dup := bundles[idx]; dup { + return nil, fmt.Errorf("log tree has two bundles for index %d: %q and %q", idx, prev, path) + } + bundles[idx] = path + } + + var entries []Entry + for i := 0; i < len(bundles); i++ { + path, ok := bundles[i] + if !ok { + return nil, fmt.Errorf("log tree is missing entry bundle %d", i) + } + content, err := gitutil.CatFile(repoDir, LogRef+":"+path) + if err != nil { + return nil, fmt.Errorf("reading entry bundle %s: %w", path, err) + } + for _, line := range strings.Split(strings.TrimRight(content, "\n"), "\n") { + if line == "" { + continue + } + e, err := ParseEntry(line) + if err != nil { + return nil, err + } + entries = append(entries, e) + } + } + return entries, nil +} + +// Size is the number of entries in the log, which is also the tree size the +// checkpoint commits to. +func (l *Log) Size() int { return len(l.entries) } + +// Entries returns the log's entries in order. +func (l *Log) Entries() []Entry { return l.entries } + +// StoredCheckpoint returns the cosigned checkpoint currently in the log, +// or the empty string if the log has never been checkpointed. +func (l *Log) StoredCheckpoint() string { return l.checkpoint } + +// Head returns the log's commit hash, or the empty string for a new log. +func (l *Log) Head() string { return l.head } + +// LeafHashes returns the leaf hash of every entry, in order. +func (l *Log) LeafHashes() []tlog.Hash { + hashes := make([]tlog.Hash, len(l.entries)) + for i, e := range l.entries { + hashes[i] = e.LeafHash() + } + return hashes +} + +// Root returns the Merkle tree hash over all entries. +func (l *Log) Root() tlog.Hash { + return tlog.Root(l.LeafHashes()) +} + +// RootAt returns the Merkle tree hash over the first n entries. +func (l *Log) RootAt(n int) (tlog.Hash, error) { + if n < 0 || n > len(l.entries) { + return tlog.Hash{}, fmt.Errorf("tree size %d out of range for log of size %d", n, len(l.entries)) + } + return tlog.Root(l.LeafHashes()[:n]), nil +} + +// Append adds an entry to the in-memory log. Call Save to persist it. +func (l *Log) Append(e Entry) { + l.entries = append(l.entries, e) +} + +// EntriesFor returns every entry for a ref, in log order. +func (l *Log) EntriesFor(ref string) []Entry { + var out []Entry + for _, e := range l.entries { + if e.Ref == ref { + out = append(out, e) + } + } + return out +} + +// Latest returns the most recent entry for a ref. +func (l *Log) Latest(ref string) (Entry, bool) { + for i := len(l.entries) - 1; i >= 0; i-- { + if l.entries[i].Ref == ref { + return l.entries[i], true + } + } + return Entry{}, false +} + +// ConsistencyProofFrom returns the proof that the log at size m is a prefix of +// the log as it currently stands. +func (l *Log) ConsistencyProofFrom(m int) ([]tlog.Hash, error) { + return tlog.ConsistencyProof(l.LeafHashes(), m) +} + +// InclusionProof returns the audit path for the entry at index i. +func (l *Log) InclusionProof(i int) ([]tlog.Hash, error) { + return tlog.InclusionProof(l.LeafHashes(), i) +} + +// Save writes the log's entries and the given cosigned checkpoint as a new +// commit on the log ref. +// +// The update is compare-and-swap against the head observed at Open time, so a +// log that moved underneath a concurrent checkpointer fails rather than +// silently discarding the other writer's entries. +func (l *Log) Save(checkpoint, message string) error { + blobs := map[string]string{} + + cpBlob, err := gitutil.HashObject(l.repoDir, checkpoint) + if err != nil { + return fmt.Errorf("writing checkpoint blob: %w", err) + } + blobs[checkpointPath] = cpBlob + + // Rebuild every bundle from the full entry list rather than patching the + // previous tree. Identical bundles hash to the objects already in the + // database, so full bundles cost nothing to rewrite, and no superseded + // partial-bundle path can survive into the new tree. + for start := 0; start < len(l.entries); start += EntriesPerBundle { + end := min(start+EntriesPerBundle, len(l.entries)) + + var b strings.Builder + for _, e := range l.entries[start:end] { + b.WriteString(e.String()) + b.WriteByte('\n') + } + blob, err := gitutil.HashObject(l.repoDir, b.String()) + if err != nil { + return fmt.Errorf("writing entry bundle: %w", err) + } + blobs[bundlePath(start/EntriesPerBundle, end-start)] = blob + } + + tree, err := l.writeTree(blobs) + if err != nil { + return err + } + + commit, err := l.commitTree(tree, message) + if err != nil { + return err + } + + // update-ref's compare-and-swap form takes the expected old value; the + // empty string means "the ref must not exist". + if _, err := gitutil.Run(l.repoDir, "update-ref", LogRef, commit, l.head); err != nil { + return fmt.Errorf("updating %s (the log may have been advanced concurrently): %w", LogRef, err) + } + l.head = commit + l.checkpoint = checkpoint + return nil +} + +// writeTree builds a tree object from a path-to-blob mapping, using a scratch +// index so the caller's working tree and index are untouched. +func (l *Log) writeTree(blobs map[string]string) (string, error) { + dir, err := os.MkdirTemp("", "git-ratchet-log-index") + if err != nil { + return "", fmt.Errorf("creating scratch index: %w", err) + } + defer os.RemoveAll(dir) + env := []string{"GIT_INDEX_FILE=" + filepath.Join(dir, "index")} + + for path, blob := range blobs { + if _, err := gitutil.RunWithEnv(l.repoDir, env, + "update-index", "--add", "--cacheinfo", "100644,"+blob+","+path); err != nil { + return "", fmt.Errorf("adding %s to log tree: %w", path, err) + } + } + + tree, err := gitutil.RunWithEnv(l.repoDir, env, "write-tree") + if err != nil { + return "", fmt.Errorf("writing log tree: %w", err) + } + return strings.TrimSpace(tree), nil +} + +// commitTree creates the log commit, chaining it to the previous log head. +func (l *Log) commitTree(tree, message string) (string, error) { + args := []string{"commit-tree", tree} + if l.head != "" { + args = append(args, "-p", l.head) + } + args = append(args, "-m", message) + + // The log commit is machine-generated bookkeeping, so it is attributed to + // git-ratchet rather than to whoever happens to be running the command. + // This also means the command works in a repository with no user identity + // configured, which is the normal situation in CI. + env := []string{ + "GIT_AUTHOR_NAME=git-ratchet", + "GIT_AUTHOR_EMAIL=git-ratchet@localhost", + "GIT_COMMITTER_NAME=git-ratchet", + "GIT_COMMITTER_EMAIL=git-ratchet@localhost", + } + commit, err := gitutil.RunWithEnv(l.repoDir, env, args...) + if err != nil { + return "", fmt.Errorf("creating log commit: %w", err) + } + return strings.TrimSpace(commit), nil +} diff --git a/internal/gitlog/gitlog_test.go b/internal/gitlog/gitlog_test.go new file mode 100644 index 0000000..8ae7406 --- /dev/null +++ b/internal/gitlog/gitlog_test.go @@ -0,0 +1,313 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gitlog + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/project-oak/git-ratchet/internal/tlog" +) + +// initRepo creates a new Git repository in a temp directory with one commit. +func initRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + run(t, dir, "git", "init", "--initial-branch=main", ".") + run(t, dir, "git", "config", "user.email", "test@test.com") + run(t, dir, "git", "config", "user.name", "Test") + if err := os.WriteFile(filepath.Join(dir, "file.txt"), []byte("hello\n"), 0644); err != nil { + t.Fatal(err) + } + run(t, dir, "git", "add", ".") + run(t, dir, "git", "commit", "-m", "initial") + return dir +} + +func run(t *testing.T, dir string, name string, args ...string) string { + t.Helper() + cmd := exec.Command(name, args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s %s failed: %v\n%s", name, strings.Join(args, " "), err, out) + } + return strings.TrimSpace(string(out)) +} + +func mustOpen(t *testing.T, dir string) *Log { + t.Helper() + l, err := Open(dir) + if err != nil { + t.Fatalf("Open: %v", err) + } + return l +} + +func TestOpenEmptyRepo(t *testing.T) { + dir := initRepo(t) + l := mustOpen(t, dir) + + if l.Size() != 0 { + t.Errorf("Size() = %d, want 0", l.Size()) + } + if l.Head() != "" { + t.Errorf("Head() = %q, want empty", l.Head()) + } + if l.StoredCheckpoint() != "" { + t.Errorf("StoredCheckpoint() = %q, want empty", l.StoredCheckpoint()) + } + if l.Root() != tlog.EmptyRoot() { + t.Error("an empty log should have the empty tree root") + } +} + +func TestAppendAndReopen(t *testing.T) { + dir := initRepo(t) + + l := mustOpen(t, dir) + l.Append(Entry{Ref: "refs/heads/main", Hash: "aaaa"}) + l.Append(Entry{Ref: "refs/tags/v1.0.0", Hash: "bbbb"}) + if err := l.Save("checkpoint-body", "log: two entries"); err != nil { + t.Fatalf("Save: %v", err) + } + wantRoot := l.Root() + + reopened := mustOpen(t, dir) + if reopened.Size() != 2 { + t.Fatalf("Size() = %d, want 2", reopened.Size()) + } + if reopened.Root() != wantRoot { + t.Error("root changed across a save/reopen cycle") + } + if reopened.StoredCheckpoint() != "checkpoint-body" { + t.Errorf("StoredCheckpoint() = %q", reopened.StoredCheckpoint()) + } + entries := reopened.Entries() + if entries[0].Ref != "refs/heads/main" || entries[0].Hash != "aaaa" { + t.Errorf("entry 0 = %+v", entries[0]) + } + if entries[1].Ref != "refs/tags/v1.0.0" || entries[1].Hash != "bbbb" { + t.Errorf("entry 1 = %+v", entries[1]) + } +} + +// TestSaveIsFastForward checks that each save chains onto the previous log +// commit, so the log ref only ever advances. +func TestSaveIsFastForward(t *testing.T) { + dir := initRepo(t) + + l := mustOpen(t, dir) + l.Append(Entry{Ref: "refs/heads/main", Hash: "aaaa"}) + if err := l.Save("cp1", "first"); err != nil { + t.Fatal(err) + } + first := l.Head() + + l2 := mustOpen(t, dir) + l2.Append(Entry{Ref: "refs/heads/main", Hash: "bbbb"}) + if err := l2.Save("cp2", "second"); err != nil { + t.Fatal(err) + } + second := l2.Head() + + if first == second { + t.Fatal("the second save should have produced a new commit") + } + // The new head must descend from the old one. + out := run(t, dir, "git", "rev-list", "--first-parent", second) + if !strings.Contains(out, first) { + t.Errorf("log head %s does not descend from %s", second, first) + } +} + +// TestSaveRejectsConcurrentAdvance checks the compare-and-swap: a checkpointer +// holding a stale view must not clobber entries another writer appended. +func TestSaveRejectsConcurrentAdvance(t *testing.T) { + dir := initRepo(t) + + l := mustOpen(t, dir) + l.Append(Entry{Ref: "refs/heads/main", Hash: "aaaa"}) + if err := l.Save("cp1", "first"); err != nil { + t.Fatal(err) + } + + // Two writers both open at size 1. + writerA := mustOpen(t, dir) + writerB := mustOpen(t, dir) + + writerA.Append(Entry{Ref: "refs/heads/main", Hash: "bbbb"}) + if err := writerA.Save("cp2", "from A"); err != nil { + t.Fatalf("first writer should succeed: %v", err) + } + + writerB.Append(Entry{Ref: "refs/heads/other", Hash: "cccc"}) + if err := writerB.Save("cp2b", "from B"); err == nil { + t.Error("expected the stale writer's save to be rejected") + } + + // A's entry must still be there. + final := mustOpen(t, dir) + if final.Size() != 2 { + t.Fatalf("Size() = %d, want 2", final.Size()) + } + if got, _ := final.Latest("refs/heads/main"); got.Hash != "bbbb" { + t.Errorf("latest main entry = %+v, want bbbb", got) + } +} + +// TestBundleRollover exercises the transition from a partial entry bundle to a +// full one and on into a second bundle, which is where a stale partial-bundle +// path would survive into the tree if the tree were patched rather than +// rebuilt. +func TestBundleRollover(t *testing.T) { + dir := initRepo(t) + + const total = EntriesPerBundle + 5 + l := mustOpen(t, dir) + for i := 0; i < total; i++ { + l.Append(Entry{Ref: "refs/heads/main", Hash: fmt.Sprintf("%04x", i)}) + if err := l.Save("cp", fmt.Sprintf("entry %d", i)); err != nil { + t.Fatalf("Save at %d: %v", i, err) + } + } + + reopened := mustOpen(t, dir) + if reopened.Size() != total { + t.Fatalf("Size() = %d, want %d", reopened.Size(), total) + } + for i, e := range reopened.Entries() { + if want := fmt.Sprintf("%04x", i); e.Hash != want { + t.Fatalf("entry %d hash = %q, want %q", i, e.Hash, want) + } + } + + // The full first bundle must live at its unsuffixed path, and only the + // second, still-partial bundle may carry a ".p/" suffix. + paths := run(t, dir, "git", "ls-tree", "-r", "--name-only", LogRef, "tile/entries/") + if !strings.Contains(paths, "tile/entries/000\n") && !strings.HasSuffix(paths, "tile/entries/000") { + t.Errorf("expected a full bundle at tile/entries/000, got:\n%s", paths) + } + if !strings.Contains(paths, "tile/entries/001.p/5") { + t.Errorf("expected a partial bundle at tile/entries/001.p/5, got:\n%s", paths) + } + if strings.Contains(paths, "tile/entries/000.p/") { + t.Errorf("a superseded partial bundle survived into the tree:\n%s", paths) + } +} + +func TestEntriesForAndLatest(t *testing.T) { + dir := initRepo(t) + l := mustOpen(t, dir) + l.Append(Entry{Ref: "refs/heads/main", Hash: "a1"}) + l.Append(Entry{Ref: "refs/heads/dev", Hash: "b1"}) + l.Append(Entry{Ref: "refs/heads/main", Hash: "a2"}) + if err := l.Save("cp", "entries"); err != nil { + t.Fatal(err) + } + + mains := l.EntriesFor("refs/heads/main") + if len(mains) != 2 || mains[0].Hash != "a1" || mains[1].Hash != "a2" { + t.Errorf("EntriesFor(main) = %+v", mains) + } + latest, ok := l.Latest("refs/heads/main") + if !ok || latest.Hash != "a2" { + t.Errorf("Latest(main) = %+v, %v", latest, ok) + } + if _, ok := l.Latest("refs/heads/absent"); ok { + t.Error("Latest should report absence for an unlogged ref") + } +} + +// TestProofsAgainstStoredLog checks that proofs generated from a reopened log +// verify against the roots the log reports. +func TestProofsAgainstStoredLog(t *testing.T) { + dir := initRepo(t) + l := mustOpen(t, dir) + for i := 0; i < 20; i++ { + l.Append(Entry{Ref: "refs/heads/main", Hash: fmt.Sprintf("%04x", i)}) + } + if err := l.Save("cp", "twenty entries"); err != nil { + t.Fatal(err) + } + + reopened := mustOpen(t, dir) + root := reopened.Root() + + for i := 0; i < 20; i++ { + proof, err := reopened.InclusionProof(i) + if err != nil { + t.Fatalf("InclusionProof(%d): %v", i, err) + } + leaf := reopened.Entries()[i].LeafHash() + if err := tlog.VerifyInclusion(leaf, root, proof, i, 20); err != nil { + t.Errorf("VerifyInclusion(%d): %v", i, err) + } + } + + for m := 0; m <= 20; m++ { + proof, err := reopened.ConsistencyProofFrom(m) + if err != nil { + t.Fatalf("ConsistencyProofFrom(%d): %v", m, err) + } + oldRoot, err := reopened.RootAt(m) + if err != nil { + t.Fatal(err) + } + if err := tlog.VerifyConsistency(oldRoot, root, proof, m, 20); err != nil { + t.Errorf("VerifyConsistency(%d): %v", m, err) + } + } +} + +func TestParseEntry(t *testing.T) { + e, err := ParseEntry("refs/heads/main deadbeef") + if err != nil { + t.Fatalf("ParseEntry: %v", err) + } + if e.Ref != "refs/heads/main" || e.Hash != "deadbeef" { + t.Errorf("ParseEntry = %+v", e) + } + if e.String() != "refs/heads/main deadbeef" { + t.Errorf("String() = %q", e.String()) + } + + for _, bad := range []string{ + "refs/heads/main", + "refs/heads/main deadbeef extra", + "refs/notes/x deadbeef", + "", + } { + if _, err := ParseEntry(bad); err == nil { + t.Errorf("ParseEntry(%q): expected an error", bad) + } + } +} + +func TestRootAtOutOfRange(t *testing.T) { + dir := initRepo(t) + l := mustOpen(t, dir) + l.Append(Entry{Ref: "refs/heads/main", Hash: "aa"}) + if _, err := l.RootAt(2); err == nil { + t.Error("expected an error for a size beyond the log") + } + if _, err := l.RootAt(-1); err == nil { + t.Error("expected an error for a negative size") + } +} diff --git a/internal/gitlog/tilepath.go b/internal/gitlog/tilepath.go new file mode 100644 index 0000000..1113f4f --- /dev/null +++ b/internal/gitlog/tilepath.go @@ -0,0 +1,105 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gitlog + +import ( + "fmt" + "strconv" + "strings" +) + +// entriesPrefix is the tlog-tiles directory holding entry bundles. +const entriesPrefix = "tile/entries/" + +// bundlePath returns the path of entry bundle n holding width entries. +// +// The index is encoded in the tlog-tiles style: base-1000 groups of three +// digits, joined by "/", with every group but the last prefixed by "x". A +// bundle that is not yet full carries a ".p/" suffix, so a partial +// bundle never occupies the path its eventual full form will take. +func bundlePath(n, width int) string { + path := entriesPrefix + encodeTileIndex(n) + if width < EntriesPerBundle { + path += ".p/" + strconv.Itoa(width) + } + return path +} + +func encodeTileIndex(n int) string { + // Split into base-1000 groups, most significant first. + var groups []int + for { + groups = append([]int{n % 1000}, groups...) + n /= 1000 + if n == 0 { + break + } + } + + var b strings.Builder + for i, g := range groups { + if i > 0 { + b.WriteByte('/') + } + if i < len(groups)-1 { + b.WriteByte('x') + } + fmt.Fprintf(&b, "%03d", g) + } + return b.String() +} + +// parseBundlePath recovers the bundle index from a path produced by +// bundlePath. The width suffix is not returned: how many entries a bundle +// holds is evident from its contents, and trusting the path would let a +// malformed tree disagree with itself. +func parseBundlePath(path string) (int, error) { + rest, ok := strings.CutPrefix(path, entriesPrefix) + if !ok { + return 0, fmt.Errorf("path %q is not an entry bundle", path) + } + + // Drop a ".p/" suffix if present. + if i := strings.Index(rest, ".p/"); i >= 0 { + width := rest[i+len(".p/"):] + if _, err := strconv.Atoi(width); err != nil { + return 0, fmt.Errorf("path %q has a malformed partial-bundle width %q", path, width) + } + rest = rest[:i] + } + + groups := strings.Split(rest, "/") + n := 0 + for i, g := range groups { + if i < len(groups)-1 { + var ok bool + g, ok = strings.CutPrefix(g, "x") + if !ok { + return 0, fmt.Errorf("path %q has an unprefixed intermediate group", path) + } + } else if strings.HasPrefix(g, "x") { + return 0, fmt.Errorf("path %q has a prefixed final group", path) + } + if len(g) != 3 { + return 0, fmt.Errorf("path %q has a group that is not three digits: %q", path, g) + } + v, err := strconv.Atoi(g) + if err != nil { + return 0, fmt.Errorf("path %q has a non-numeric group %q", path, g) + } + n = n*1000 + v + } + return n, nil +} diff --git a/internal/gitlog/tilepath_test.go b/internal/gitlog/tilepath_test.go new file mode 100644 index 0000000..9d561b3 --- /dev/null +++ b/internal/gitlog/tilepath_test.go @@ -0,0 +1,68 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gitlog + +import "testing" + +func TestBundlePath(t *testing.T) { + for _, tc := range []struct { + n, width int + want string + }{ + {0, EntriesPerBundle, "tile/entries/000"}, + {0, 7, "tile/entries/000.p/7"}, + {1, EntriesPerBundle, "tile/entries/001"}, + {999, EntriesPerBundle, "tile/entries/999"}, + {1000, EntriesPerBundle, "tile/entries/x001/000"}, + {1234567, EntriesPerBundle, "tile/entries/x001/x234/567"}, + {1234567, 3, "tile/entries/x001/x234/567.p/3"}, + } { + if got := bundlePath(tc.n, tc.width); got != tc.want { + t.Errorf("bundlePath(%d, %d) = %q, want %q", tc.n, tc.width, got, tc.want) + } + } +} + +func TestParseBundlePathRoundTrip(t *testing.T) { + for _, n := range []int{0, 1, 42, 999, 1000, 1001, 999999, 1000000, 1234567} { + for _, width := range []int{EntriesPerBundle, 1, 255} { + path := bundlePath(n, width) + got, err := parseBundlePath(path) + if err != nil { + t.Fatalf("parseBundlePath(%q): %v", path, err) + } + if got != n { + t.Errorf("parseBundlePath(%q) = %d, want %d", path, got, n) + } + } + } +} + +func TestParseBundlePathErrors(t *testing.T) { + for _, bad := range []string{ + "checkpoint", + "tile/0/000", + "tile/entries/1", + "tile/entries/0000", + "tile/entries/001/002", + "tile/entries/x001", + "tile/entries/abc", + "tile/entries/000.p/x", + } { + if _, err := parseBundlePath(bad); err == nil { + t.Errorf("parseBundlePath(%q): expected an error", bad) + } + } +} diff --git a/internal/gitutil/repo.go b/internal/gitutil/repo.go index 6fec5ce..1f77293 100644 --- a/internal/gitutil/repo.go +++ b/internal/gitutil/repo.go @@ -19,6 +19,7 @@ package gitutil import ( "encoding/base64" "fmt" + "os" "os/exec" "strings" ) @@ -54,13 +55,10 @@ func checkpointRef(sourceRef string) string { // the corresponding checkpoint ref at it. // ref must be a full ref path, e.g. "refs/heads/main" or "refs/tags/v1.0". func StoreCheckpoint(repoDir, ref, checkpoint string) error { - cmd := exec.Command("git", "-C", repoDir, "hash-object", "-w", "--stdin") - cmd.Stdin = strings.NewReader(checkpoint) - out, err := cmd.Output() + blobHash, err := HashObject(repoDir, checkpoint) if err != nil { return fmt.Errorf("writing checkpoint blob: %w", err) } - blobHash := strings.TrimSpace(string(out)) cpRef := checkpointRef(ref) if _, err := git(repoDir, "update-ref", cpRef, blobHash); err != nil { @@ -84,6 +82,47 @@ func git(repoDir string, args ...string) (string, error) { return string(out), nil } +// Run invokes git in repoDir and returns its combined output. +func Run(repoDir string, args ...string) (string, error) { + return git(repoDir, args...) +} + +// RunWithEnv invokes git in repoDir with additional environment variables +// (each "KEY=value") appended to the current environment. +func RunWithEnv(repoDir string, env []string, args ...string) (string, error) { + cmd := exec.Command("git", append([]string{"-C", repoDir}, args...)...) + cmd.Env = append(os.Environ(), env...) + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("git %s: %s: %w", strings.Join(args, " "), strings.TrimSpace(string(out)), err) + } + return string(out), nil +} + +// HashObject writes content to the object database as a blob and returns its +// hash. +func HashObject(repoDir, content string) (string, error) { + cmd := exec.Command("git", "-C", repoDir, "hash-object", "-w", "--stdin") + cmd.Stdin = strings.NewReader(content) + out, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("writing blob: %w", err) + } + return strings.TrimSpace(string(out)), nil +} + +// RefExists reports whether a ref is present in the repository. +func RefExists(repoDir, ref string) bool { + _, err := git(repoDir, "rev-parse", "--verify", "--quiet", ref) + return err == nil +} + +// CatFile returns the contents of an object, addressed by any revision syntax +// git understands (e.g. "refs/ratchet/log:tile/entries/000"). +func CatFile(repoDir, object string) (string, error) { + return git(repoDir, "cat-file", "-p", object) +} + // IsAncestor reports whether ancestor is an ancestor-or-equal of descendant // in the repository at repoDir. // diff --git a/internal/note/BUILD.bazel b/internal/note/BUILD.bazel index d57d29a..7912455 100644 --- a/internal/note/BUILD.bazel +++ b/internal/note/BUILD.bazel @@ -19,10 +19,12 @@ go_library( srcs = [ "kms.go", "note.go", + "tlog.go", ], importpath = "github.com/project-oak/git-ratchet/internal/note", visibility = ["//:__subpackages__"], deps = [ + "//internal/tlog", "@com_google_cloud_go_kms//apiv1", "@com_google_cloud_go_kms//apiv1/kmspb", "@io_filippo_mldsa//:mldsa", @@ -32,7 +34,13 @@ go_library( go_test( name = "note_test", - srcs = ["note_test.go"], + srcs = [ + "note_test.go", + "tlog_test.go", + ], embed = [":note"], - deps = ["@io_filippo_mldsa//:mldsa"], + deps = [ + "//internal/tlog", + "@io_filippo_mldsa//:mldsa", + ], ) diff --git a/internal/note/note.go b/internal/note/note.go index 5c0c74a..10b9d0a 100644 --- a/internal/note/note.go +++ b/internal/note/note.go @@ -33,7 +33,6 @@ import ( "encoding/binary" "fmt" "os" - "strconv" "strings" "time" @@ -246,10 +245,7 @@ func Cosign(signedNote string, signer *Signer) (string, error) { // cosignature/v1\n // time \n // - cosignMsg := cosignatureV1Prefix + "\n" + - "time " + strconv.FormatUint(timestamp, 10) + "\n" + - body - sig, err = signer.signer.Sign(nil, []byte(cosignMsg), crypto.Hash(0)) + sig, err = signer.signer.Sign(nil, []byte(ed25519CosignMessage(timestamp, body)), crypto.Hash(0)) if err != nil { return "", fmt.Errorf("Ed25519 cosign: %w", err) } @@ -429,10 +425,7 @@ func VerifyCosignature(body, sigLine string, pub crypto.PublicKey, sigType SigTy return fmt.Errorf("expected Ed25519 public key") } timestamp := binary.BigEndian.Uint64(raw[4 : 4+8]) - cosignMsg := cosignatureV1Prefix + "\n" + - "time " + strconv.FormatUint(timestamp, 10) + "\n" + - body - if !ed25519.Verify(edPub, []byte(cosignMsg), raw[4+8:]) { + if !ed25519.Verify(edPub, []byte(ed25519CosignMessage(timestamp, body)), raw[4+8:]) { return fmt.Errorf("cosignature verification failed") } diff --git a/internal/note/tlog.go b/internal/note/tlog.go new file mode 100644 index 0000000..ba2763e --- /dev/null +++ b/internal/note/tlog.go @@ -0,0 +1,178 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package note + +import ( + "crypto" + "crypto/ed25519" + "encoding/base64" + "encoding/binary" + "fmt" + "strconv" + "time" + + "filippo.io/mldsa" + "github.com/project-oak/git-ratchet/internal/tlog" +) + +// This file implements cosignatures over C2SP tlog-checkpoint bodies, used by +// git-ratchet's transparency-log mode. +// +// The distinction from the cosignatures in note.go is confined to ML-DSA-44. +// The Ed25519 cosignature message is generic over the note body, so it is +// identical in both modes. The ML-DSA-44 message is a binary struct with +// fields for the log origin, the tree size and the root hash, which a +// git-checkpoint body has no values for — buildCosignedMessage fills them by +// repurposing the ref line and object hash. A tlog-checkpoint carries exactly +// those three values, so here they can be filled as the specification intends. + +// buildTlogCosignedMessage constructs the binary cosigned_message for an +// ML-DSA-44 cosignature over a tlog-checkpoint body, per the C2SP +// tlog-cosignature specification: +// +// label[12] = "subtree/v1\n\0" +// cosigner_name<1..2^8-1> (length-prefixed) +// timestamp (uint64) +// log_origin<1..2^8-1> (length-prefixed) +// start (uint64) = 0 (full checkpoint) +// end (uint64) tree size +// hash[32] root hash +func buildTlogCosignedMessage(cosignerName string, timestamp uint64, body string) ([]byte, error) { + cp, err := tlog.ParseCheckpoint(body) + if err != nil { + return nil, err + } + if len(cosignerName) > 255 { + return nil, fmt.Errorf("cosigner name is too long to encode: %d bytes", len(cosignerName)) + } + if len(cp.Origin) > 255 { + return nil, fmt.Errorf("log origin is too long to encode: %d bytes", len(cp.Origin)) + } + + var msg []byte + msg = append(msg, cosignedMessageLabel...) + msg = append(msg, byte(len(cosignerName))) + msg = append(msg, cosignerName...) + msg = binary.BigEndian.AppendUint64(msg, timestamp) + msg = append(msg, byte(len(cp.Origin))) + msg = append(msg, cp.Origin...) + msg = binary.BigEndian.AppendUint64(msg, 0) // start: a full checkpoint + msg = binary.BigEndian.AppendUint64(msg, uint64(cp.Size)) + msg = append(msg, cp.Root[:]...) + return msg, nil +} + +// CosignTlogCheckpoint creates a cosignature line for a signed tlog-checkpoint. +// The signer must have RoleCosigner. +// +// Wire format, as in Cosign: keyID(4) || timestamp(8) || signature. +func CosignTlogCheckpoint(signedNote string, signer *Signer) (string, error) { + if signer.Role != RoleCosigner { + return "", fmt.Errorf("CosignTlogCheckpoint requires a cosigner key, got origin") + } + + body, err := ExtractBody(signedNote) + if err != nil { + return "", fmt.Errorf("extracting body: %w", err) + } + if _, err := tlog.ParseCheckpoint(body); err != nil { + return "", fmt.Errorf("not a tlog checkpoint: %w", err) + } + + timestamp := uint64(time.Now().Unix()) + + var sig []byte + switch signer.SigType { + case Ed25519Cosigner: + sig, err = signer.signer.Sign(nil, []byte(ed25519CosignMessage(timestamp, body)), crypto.Hash(0)) + if err != nil { + return "", fmt.Errorf("Ed25519 cosign: %w", err) + } + + case MLDSA44: + msg, err := buildTlogCosignedMessage(signer.Name, timestamp, body) + if err != nil { + return "", fmt.Errorf("building cosigned message: %w", err) + } + sig, err = signer.signer.Sign(nil, msg, &mldsa.Options{}) + if err != nil { + return "", fmt.Errorf("ML-DSA-44 cosign: %w", err) + } + + default: + return "", fmt.Errorf("unsupported cosigner signature type: 0x%02x", signer.SigType) + } + + var raw []byte + raw = append(raw, signer.hash[:]...) + raw = binary.BigEndian.AppendUint64(raw, timestamp) + raw = append(raw, sig...) + + return SigPrefix + signer.Name + " " + base64.StdEncoding.EncodeToString(raw), nil +} + +// VerifyTlogCosignature verifies a witness cosignature over a tlog-checkpoint +// body against a public key. +func VerifyTlogCosignature(body, sigLine string, pub crypto.PublicKey, sigType SigType, cosignerName string) error { + raw, err := DecodeSigLine(sigLine) + if err != nil { + return err + } + + switch sigType { + case Ed25519Cosigner: + if len(raw) < 4+8+ed25519SigSize { + return fmt.Errorf("Ed25519 cosignature too short") + } + edPub, ok := pub.(ed25519.PublicKey) + if !ok { + return fmt.Errorf("expected Ed25519 public key") + } + timestamp := binary.BigEndian.Uint64(raw[4 : 4+8]) + if !ed25519.Verify(edPub, []byte(ed25519CosignMessage(timestamp, body)), raw[4+8:]) { + return fmt.Errorf("cosignature verification failed") + } + + case MLDSA44: + if len(raw) < 4+8+mldsa44SigSize { + return fmt.Errorf("ML-DSA-44 cosignature too short") + } + mlPub, ok := pub.(*mldsa.PublicKey) + if !ok { + return fmt.Errorf("expected ML-DSA-44 public key") + } + timestamp := binary.BigEndian.Uint64(raw[4 : 4+8]) + msg, err := buildTlogCosignedMessage(cosignerName, timestamp, body) + if err != nil { + return fmt.Errorf("building cosigned message: %w", err) + } + if err := mldsa.Verify(mlPub, msg, raw[4+8:], &mldsa.Options{}); err != nil { + return fmt.Errorf("cosignature verification failed: %w", err) + } + + default: + return fmt.Errorf("unsupported cosigner signature type: 0x%02x", sigType) + } + return nil +} + +// ed25519CosignMessage returns the message an Ed25519 cosignature covers, per +// the tlog-cosignature specification. It is generic over the note body, so it +// is shared by both checkpoint formats. +func ed25519CosignMessage(timestamp uint64, body string) string { + return cosignatureV1Prefix + "\n" + + "time " + strconv.FormatUint(timestamp, 10) + "\n" + + body +} diff --git a/internal/note/tlog_test.go b/internal/note/tlog_test.go new file mode 100644 index 0000000..ea2970a --- /dev/null +++ b/internal/note/tlog_test.go @@ -0,0 +1,176 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package note + +import ( + "bytes" + "encoding/binary" + "strings" + "testing" + + "github.com/project-oak/git-ratchet/internal/tlog" +) + +func testTlogCheckpoint() tlog.Checkpoint { + return tlog.Checkpoint{ + Origin: "github.com/example/repo", + Size: 7, + Root: tlog.HashLeaf([]byte("root")), + } +} + +// signTestCheckpoint produces a signed tlog-checkpoint note. +func signTestCheckpoint(t *testing.T, sigType SigType) string { + t.Helper() + signer, err := GenerateKey("test-origin", sigType, RoleOrigin) + if err != nil { + t.Fatal(err) + } + signed, err := Sign(testTlogCheckpoint().Body(), signer) + if err != nil { + t.Fatal(err) + } + return signed +} + +// TestCosignTlogCheckpointRoundTrip covers both signature algorithms. +func TestCosignTlogCheckpointRoundTrip(t *testing.T) { + for _, tc := range []struct { + name string + originSig SigType + cosigSig SigType + }{ + {"ed25519", Ed25519Origin, Ed25519Cosigner}, + {"mldsa44", MLDSA44, MLDSA44}, + } { + t.Run(tc.name, func(t *testing.T) { + signed := signTestCheckpoint(t, tc.originSig) + + cosigner, err := GenerateKey("test-witness", tc.cosigSig, RoleCosigner) + if err != nil { + t.Fatal(err) + } + cosigLine, err := CosignTlogCheckpoint(signed, cosigner) + if err != nil { + t.Fatalf("CosignTlogCheckpoint: %v", err) + } + + body, err := ExtractBody(signed) + if err != nil { + t.Fatal(err) + } + if err := VerifyTlogCosignature(body, cosigLine, cosigner.pub, cosigner.SigType, cosigner.Name); err != nil { + t.Errorf("VerifyTlogCosignature: %v", err) + } + }) + } +} + +// TestVerifyTlogCosignatureRejectsTamperedBody checks that the cosignature is +// bound to the checkpoint contents, not just to the origin. +func TestVerifyTlogCosignatureRejectsTamperedBody(t *testing.T) { + for _, sigType := range []SigType{Ed25519Cosigner, MLDSA44} { + originType := Ed25519Origin + if sigType == MLDSA44 { + originType = MLDSA44 + } + signed := signTestCheckpoint(t, originType) + + cosigner, err := GenerateKey("test-witness", sigType, RoleCosigner) + if err != nil { + t.Fatal(err) + } + cosigLine, err := CosignTlogCheckpoint(signed, cosigner) + if err != nil { + t.Fatal(err) + } + + // Advance the tree size without re-cosigning. + cp := testTlogCheckpoint() + cp.Size = 8 + if err := VerifyTlogCosignature(cp.Body(), cosigLine, cosigner.pub, cosigner.SigType, cosigner.Name); err == nil { + t.Errorf("sigType 0x%02x: a cosignature should not verify against a different tree size", sigType) + } + } +} + +// TestTlogCosignedMessageIsConformant checks that the ML-DSA-44 cosigned +// message carries the checkpoint's real origin, size, and root hash. +// +// The git-checkpoint construction in note.go has no such values to work with +// and repurposes the ref line and object hash instead; this is the case where +// the fields mean what the tlog-cosignature specification says they mean. +func TestTlogCosignedMessageIsConformant(t *testing.T) { + cp := testTlogCheckpoint() + const cosigner = "test-witness" + const timestamp = uint64(1700000000) + + msg, err := buildTlogCosignedMessage(cosigner, timestamp, cp.Body()) + if err != nil { + t.Fatalf("buildTlogCosignedMessage: %v", err) + } + + var want []byte + want = append(want, cosignedMessageLabel...) + want = append(want, byte(len(cosigner))) + want = append(want, cosigner...) + want = binary.BigEndian.AppendUint64(want, timestamp) + want = append(want, byte(len(cp.Origin))) + want = append(want, cp.Origin...) + want = binary.BigEndian.AppendUint64(want, 0) // start + want = binary.BigEndian.AppendUint64(want, uint64(cp.Size)) // end: the real tree size + want = append(want, cp.Root[:]...) // the real root hash + + if !bytes.Equal(msg, want) { + t.Errorf("cosigned message mismatch:\ngot %x\nwant %x", msg, want) + } +} + +// TestCosignTlogCheckpointRejectsGitCheckpointBody checks that the two +// checkpoint formats cannot be crossed over: a git-checkpoint note is not a +// tlog-checkpoint and must not be cosigned as one. +func TestCosignTlogCheckpointRejectsGitCheckpointBody(t *testing.T) { + signer, err := GenerateKey("test-origin", Ed25519Origin, RoleOrigin) + if err != nil { + t.Fatal(err) + } + gitBody := "github.com/example/repo refs/heads/main\n" + + "4f0f30afb02b71590f0b2e0a67f0b846715e1d04\n" + signed, err := Sign(gitBody, signer) + if err != nil { + t.Fatal(err) + } + + cosigner, err := GenerateKey("test-witness", Ed25519Cosigner, RoleCosigner) + if err != nil { + t.Fatal(err) + } + if _, err := CosignTlogCheckpoint(signed, cosigner); err == nil { + t.Error("expected a git-checkpoint body to be rejected as a tlog checkpoint") + } +} + +// TestCosignTlogCheckpointRequiresCosignerKey mirrors the role check on Cosign. +func TestCosignTlogCheckpointRequiresCosignerKey(t *testing.T) { + signed := signTestCheckpoint(t, Ed25519Origin) + originKey, err := GenerateKey("test-origin", Ed25519Origin, RoleOrigin) + if err != nil { + t.Fatal(err) + } + _, err = CosignTlogCheckpoint(signed, originKey) + if err == nil || !strings.Contains(err.Error(), "cosigner key") { + t.Errorf("expected a cosigner-key role error, got %v", err) + } +} diff --git a/internal/policy/policy.go b/internal/policy/policy.go index edc79a5..9e63e22 100644 --- a/internal/policy/policy.go +++ b/internal/policy/policy.go @@ -253,6 +253,21 @@ func Load(path string) (*Policy, error) { return p, nil } +// cosigVerifier verifies a single cosignature line over a note body. The two +// checkpoint formats differ in how ML-DSA-44 cosignatures are constructed, so +// the verification functions below are parameterised by one of these. +type cosigVerifier func(body, sigLine string, pub crypto.PublicKey, sigType note.SigType, cosignerName string) error + +// VerifyTlog is Verify for a C2SP tlog-checkpoint body. +func (p *Policy) VerifyTlog(body string, sigLines []string) error { + return p.verify(body, sigLines, note.VerifyTlogCosignature) +} + +// VerifyQuorumTlog is VerifyQuorum for a C2SP tlog-checkpoint body. +func (p *Policy) VerifyQuorumTlog(body string, sigLines []string) error { + return p.verifyQuorum(body, sigLines, note.VerifyTlogCosignature) +} + // Verify checks that sigLines satisfies the policy: the log signature is valid // and the quorum group is satisfied by the witness cosignatures. // @@ -260,6 +275,10 @@ func Load(path string) (*Policy, error) { // prefix embedded in the raw signature bytes, providing defence-in-depth // against key-confusion attacks where two signers share a name. func (p *Policy) Verify(body string, sigLines []string) error { + return p.verify(body, sigLines, note.VerifyCosignature) +} + +func (p *Policy) verify(body string, sigLines []string, verifyCosig cosigVerifier) error { if p.LogKey == nil { return fmt.Errorf("policy has no log key; cannot verify log signature") } @@ -288,7 +307,7 @@ func (p *Policy) Verify(body string, sigLines []string) error { return fmt.Errorf("log signature not found (expected signer %q)", p.LogName) } - return p.VerifyQuorum(body, sigLines) + return p.verifyQuorum(body, sigLines, verifyCosig) } // VerifyQuorum checks that sigLines satisfies the policy's quorum requirement @@ -296,6 +315,10 @@ func (p *Policy) Verify(body string, sigLines []string) error { // origin side (checkpoint-store) where the origin already signed the note // itself and only needs to confirm that enough witnesses cosigned. func (p *Policy) VerifyQuorum(body string, sigLines []string) error { + return p.verifyQuorum(body, sigLines, note.VerifyCosignature) +} + +func (p *Policy) verifyQuorum(body string, sigLines []string, verifyCosig cosigVerifier) error { // "quorum none": no witnesses required. if p.quorum == nil { return nil @@ -316,7 +339,7 @@ func (p *Policy) VerifyQuorum(body string, sigLines []string) error { if len(raw) < 4 || !bytes.Equal(raw[:4], w.keyHash[:]) { continue } - if err := note.VerifyCosignature(body, line, w.Key, w.SigType, w.SignerName); err == nil { + if err := verifyCosig(body, line, w.Key, w.SigType, w.SignerName); err == nil { witnessed[w.SignerName] = true } break diff --git a/internal/tlog/BUILD.bazel b/internal/tlog/BUILD.bazel new file mode 100644 index 0000000..2b43686 --- /dev/null +++ b/internal/tlog/BUILD.bazel @@ -0,0 +1,34 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "tlog", + srcs = [ + "checkpoint.go", + "tlog.go", + ], + importpath = "github.com/project-oak/git-ratchet/internal/tlog", + visibility = ["//:__subpackages__"], +) + +go_test( + name = "tlog_test", + srcs = [ + "checkpoint_test.go", + "tlog_test.go", + ], + embed = [":tlog"], +) diff --git a/internal/tlog/checkpoint.go b/internal/tlog/checkpoint.go new file mode 100644 index 0000000..ebed563 --- /dev/null +++ b/internal/tlog/checkpoint.go @@ -0,0 +1,100 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tlog + +import ( + "encoding/base64" + "fmt" + "strconv" + "strings" +) + +// Checkpoint is the body of a C2SP tlog-checkpoint: the log's origin, the size +// of the tree, and its root hash. +// +// Unlike git-ratchet's git-checkpoint body, this format carries no Git-specific +// fields, so any conforming tlog witness can parse and cosign it. +type Checkpoint struct { + Origin string + Size int + Root Hash + + // Extensions holds any additional body lines after the root hash. They are + // preserved so that signature verification round-trips exactly. + Extensions []string +} + +// Body renders the checkpoint body, terminated by a newline. This is the exact +// byte sequence the origin signs and witnesses cosign. +// +// \n +// \n +// \n +func (c Checkpoint) Body() string { + var b strings.Builder + b.WriteString(c.Origin) + b.WriteByte('\n') + b.WriteString(strconv.Itoa(c.Size)) + b.WriteByte('\n') + b.WriteString(base64.StdEncoding.EncodeToString(c.Root[:])) + b.WriteByte('\n') + for _, ext := range c.Extensions { + b.WriteString(ext) + b.WriteByte('\n') + } + return b.String() +} + +// ParseCheckpoint parses a tlog-checkpoint body. +func ParseCheckpoint(body string) (Checkpoint, error) { + var cp Checkpoint + + // The body is newline-terminated; drop the trailing empty field. + lines := strings.Split(body, "\n") + if len(lines) > 0 && lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + if len(lines) < 3 { + return cp, fmt.Errorf("malformed tlog checkpoint: need at least 3 lines, got %d", len(lines)) + } + + cp.Origin = lines[0] + if cp.Origin == "" { + return cp, fmt.Errorf("malformed tlog checkpoint: empty origin") + } + + size, err := strconv.Atoi(lines[1]) + if err != nil { + return cp, fmt.Errorf("malformed tlog checkpoint: invalid size %q", lines[1]) + } + if size < 0 { + return cp, fmt.Errorf("malformed tlog checkpoint: negative size %d", size) + } + cp.Size = size + + root, err := base64.StdEncoding.DecodeString(lines[2]) + if err != nil { + return cp, fmt.Errorf("malformed tlog checkpoint: invalid root hash encoding: %w", err) + } + if len(root) != HashSize { + return cp, fmt.Errorf("malformed tlog checkpoint: root hash is %d bytes, want %d", len(root), HashSize) + } + copy(cp.Root[:], root) + + if len(lines) > 3 { + cp.Extensions = lines[3:] + } + return cp, nil +} diff --git a/internal/tlog/checkpoint_test.go b/internal/tlog/checkpoint_test.go new file mode 100644 index 0000000..d927229 --- /dev/null +++ b/internal/tlog/checkpoint_test.go @@ -0,0 +1,91 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tlog + +import ( + "strings" + "testing" +) + +func TestCheckpointBodyFormat(t *testing.T) { + cp := Checkpoint{ + Origin: "github.com/example/repo", + Size: 42, + Root: HashLeaf([]byte("x")), + } + body := cp.Body() + + lines := strings.Split(body, "\n") + if len(lines) != 4 || lines[3] != "" { + t.Fatalf("body should be three newline-terminated lines, got %q", body) + } + if lines[0] != "github.com/example/repo" { + t.Errorf("origin line = %q", lines[0]) + } + if lines[1] != "42" { + t.Errorf("size line = %q", lines[1]) + } +} + +func TestCheckpointRoundTrip(t *testing.T) { + want := Checkpoint{ + Origin: "github.com/example/repo", + Size: 1234, + Root: HashLeaf([]byte("root")), + } + got, err := ParseCheckpoint(want.Body()) + if err != nil { + t.Fatalf("ParseCheckpoint: %v", err) + } + if got.Origin != want.Origin || got.Size != want.Size || got.Root != want.Root { + t.Errorf("round trip mismatch: got %+v, want %+v", got, want) + } +} + +// TestCheckpointExtensionsRoundTrip checks that unknown trailing lines survive +// parsing and re-rendering, so signatures over the body still verify. +func TestCheckpointExtensionsRoundTrip(t *testing.T) { + body := "example.com/log\n7\n" + + "3q2+7w6rvu8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n" + + "custom extension line\n" + cp, err := ParseCheckpoint(body) + if err != nil { + t.Fatalf("ParseCheckpoint: %v", err) + } + if len(cp.Extensions) != 1 || cp.Extensions[0] != "custom extension line" { + t.Fatalf("extensions = %q", cp.Extensions) + } + if cp.Body() != body { + t.Errorf("body did not round trip:\ngot %q\nwant %q", cp.Body(), body) + } +} + +func TestParseCheckpointErrors(t *testing.T) { + valid := Checkpoint{Origin: "o", Size: 1, Root: HashLeaf(nil)}.Body() + + for _, tc := range []struct{ name, body string }{ + {"too short", "example.com/log\n5\n"}, + {"empty origin", "\n5\nAAAA\n"}, + {"non-numeric size", "example.com/log\nmany\n" + strings.SplitN(valid, "\n", 3)[2]}, + {"negative size", "example.com/log\n-1\n" + strings.SplitN(valid, "\n", 3)[2]}, + {"bad base64", "example.com/log\n5\nnot!base64\n"}, + {"short root hash", "example.com/log\n5\nAAAA\n"}, + {"empty", ""}, + } { + if _, err := ParseCheckpoint(tc.body); err == nil { + t.Errorf("%s: expected an error, got none", tc.name) + } + } +} diff --git a/internal/tlog/tlog.go b/internal/tlog/tlog.go new file mode 100644 index 0000000..103da04 --- /dev/null +++ b/internal/tlog/tlog.go @@ -0,0 +1,266 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package tlog implements the RFC 6962 Merkle tree used by git-ratchet's +// transparency-log mode. +// +// Proof generation follows the recursive definitions in RFC 6962 section 2.1 +// directly over the full leaf list. git-ratchet logs hold one entry per +// checkpointed ref update, so the leaf list is small enough to keep in memory +// and recompute on demand; there is no need for incremental tree storage. +// +// Proof verification does not require the leaf list, so witnesses can verify a +// consistency proof knowing only the two tree heads. +package tlog + +import ( + "bytes" + "crypto/sha256" + "fmt" + "math/bits" +) + +// HashSize is the length of a node hash in bytes. +const HashSize = sha256.Size + +// Hash is a Merkle tree node hash. +type Hash [HashSize]byte + +// Prefixes distinguishing leaf hashes from interior node hashes, per +// RFC 6962 section 2.1. +const ( + leafPrefix = 0x00 + interiorPrefix = 0x01 +) + +// HashLeaf returns the leaf hash SHA-256(0x00 || data). +func HashLeaf(data []byte) Hash { + h := sha256.New() + h.Write([]byte{leafPrefix}) + h.Write(data) + var out Hash + copy(out[:], h.Sum(nil)) + return out +} + +// HashChildren returns the interior node hash SHA-256(0x01 || left || right). +func HashChildren(left, right Hash) Hash { + h := sha256.New() + h.Write([]byte{interiorPrefix}) + h.Write(left[:]) + h.Write(right[:]) + var out Hash + copy(out[:], h.Sum(nil)) + return out +} + +// EmptyRoot is the Merkle tree hash of an empty log: SHA-256 of the empty +// string, per RFC 6962 section 2.1. +func EmptyRoot() Hash { + var out Hash + copy(out[:], sha256.New().Sum(nil)) + return out +} + +// splitPoint returns the largest power of two strictly less than n. +// n must be greater than 1. +func splitPoint(n int) int { + return 1 << (bits.Len(uint(n-1)) - 1) +} + +// Root returns the Merkle tree hash of the given leaf hashes. +func Root(leaves []Hash) Hash { + if len(leaves) == 0 { + return EmptyRoot() + } + if len(leaves) == 1 { + return leaves[0] + } + k := splitPoint(len(leaves)) + return HashChildren(Root(leaves[:k]), Root(leaves[k:])) +} + +// InclusionProof returns the audit path for the leaf at index i in a tree of +// the given leaves, per RFC 6962 section 2.1.1. +func InclusionProof(leaves []Hash, i int) ([]Hash, error) { + if i < 0 || i >= len(leaves) { + return nil, fmt.Errorf("leaf index %d out of range for tree of size %d", i, len(leaves)) + } + return inclusionProof(leaves, i), nil +} + +func inclusionProof(leaves []Hash, i int) []Hash { + if len(leaves) == 1 { + return nil + } + k := splitPoint(len(leaves)) + if i < k { + return append(inclusionProof(leaves[:k], i), Root(leaves[k:])) + } + return append(inclusionProof(leaves[k:], i-k), Root(leaves[:k])) +} + +// ConsistencyProof returns the proof that a tree of size m is a prefix of a +// tree of the given leaves, per RFC 6962 section 2.1.2. +// +// A proof from size 0 is empty: every tree is consistent with the empty tree. +func ConsistencyProof(leaves []Hash, m int) ([]Hash, error) { + n := len(leaves) + if m < 0 || m > n { + return nil, fmt.Errorf("old size %d out of range for tree of size %d", m, n) + } + if m == 0 || m == n { + return nil, nil + } + return subProof(leaves, m, true), nil +} + +func subProof(leaves []Hash, m int, complete bool) []Hash { + if m == len(leaves) { + if complete { + return nil + } + return []Hash{Root(leaves)} + } + k := splitPoint(len(leaves)) + if m <= k { + return append(subProof(leaves[:k], m, complete), Root(leaves[k:])) + } + return append(subProof(leaves[k:], m-k, false), Root(leaves[:k])) +} + +// VerifyInclusion checks that leafHash is the leaf at index i in a tree of +// size n with the given root. It does not need the leaf list. +func VerifyInclusion(leafHash, root Hash, proof []Hash, i, n int) error { + got, err := RootFromInclusionProof(leafHash, proof, i, n) + if err != nil { + return err + } + if !bytes.Equal(got[:], root[:]) { + return fmt.Errorf("inclusion proof does not reproduce root") + } + return nil +} + +// RootFromInclusionProof recomputes the tree root implied by an inclusion +// proof for the leaf at index i in a tree of size n. +func RootFromInclusionProof(leafHash Hash, proof []Hash, i, n int) (Hash, error) { + var zero Hash + if n <= 0 { + return zero, fmt.Errorf("invalid tree size %d", n) + } + if i < 0 || i >= n { + return zero, fmt.Errorf("leaf index %d out of range for tree of size %d", i, n) + } + + // The proof splits into an "inner" run, where the leaf's position within + // the tree decides whether each sibling is on the left or the right, and a + // "border" run of left-hand siblings above it. + inner := bits.Len(uint(i ^ (n - 1))) + border := bits.OnesCount(uint(i) >> uint(inner)) + if len(proof) != inner+border { + return zero, fmt.Errorf("inclusion proof has %d hashes, want %d", len(proof), inner+border) + } + + h := leafHash + for j, p := range proof[:inner] { + if (i>>uint(j))&1 == 0 { + h = HashChildren(h, p) + } else { + h = HashChildren(p, h) + } + } + for _, p := range proof[inner:] { + h = HashChildren(p, h) + } + return h, nil +} + +// VerifyConsistency checks that a tree of size m with root oldRoot is a prefix +// of a tree of size n with root newRoot. It does not need the leaf list, so a +// witness can verify an append-only transition knowing only what it stored and +// what it is being asked to sign. +func VerifyConsistency(oldRoot, newRoot Hash, proof []Hash, m, n int) error { + if m < 0 || n < 0 { + return fmt.Errorf("negative tree size") + } + if m > n { + return fmt.Errorf("old size %d exceeds new size %d", m, n) + } + if m == n { + if len(proof) != 0 { + return fmt.Errorf("consistency proof for unchanged size must be empty") + } + if !bytes.Equal(oldRoot[:], newRoot[:]) { + return fmt.Errorf("tree size unchanged but root differs") + } + return nil + } + if m == 0 { + // Every tree is consistent with the empty tree. + if len(proof) != 0 { + return fmt.Errorf("consistency proof from size 0 must be empty") + } + return nil + } + if len(proof) == 0 { + return fmt.Errorf("empty consistency proof") + } + + // Walk up from the old tree's last leaf until it is a left child; that is + // the highest node the two trees can still share. + node, lastNode := m-1, n-1 + for node&1 == 1 { + node >>= 1 + lastNode >>= 1 + } + + // When the old size is an exact power of two its root is a complete + // subtree of the new tree and is not carried in the proof. + oldSeed, newSeed := oldRoot, oldRoot + rest := proof + if node != 0 { + oldSeed, newSeed = proof[0], proof[0] + rest = proof[1:] + } + + for _, p := range rest { + if lastNode == 0 { + return fmt.Errorf("consistency proof too long") + } + if node&1 == 1 || node == lastNode { + oldSeed = HashChildren(p, oldSeed) + newSeed = HashChildren(p, newSeed) + for node&1 == 0 && node != 0 { + node >>= 1 + lastNode >>= 1 + } + } else { + newSeed = HashChildren(newSeed, p) + } + node >>= 1 + lastNode >>= 1 + } + + if lastNode != 0 { + return fmt.Errorf("consistency proof too short") + } + if !bytes.Equal(oldSeed[:], oldRoot[:]) { + return fmt.Errorf("consistency proof does not reproduce old root") + } + if !bytes.Equal(newSeed[:], newRoot[:]) { + return fmt.Errorf("consistency proof does not reproduce new root") + } + return nil +} diff --git a/internal/tlog/tlog_test.go b/internal/tlog/tlog_test.go new file mode 100644 index 0000000..9874a35 --- /dev/null +++ b/internal/tlog/tlog_test.go @@ -0,0 +1,272 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tlog + +import ( + "encoding/hex" + "fmt" + "testing" +) + +// leaves builds n distinct leaf hashes. +func leaves(n int) []Hash { + out := make([]Hash, n) + for i := range out { + out[i] = HashLeaf([]byte(fmt.Sprintf("leaf-%d", i))) + } + return out +} + +// TestEmptyRoot pins the RFC 6962 empty tree hash: SHA-256 of the empty string. +func TestEmptyRoot(t *testing.T) { + r := EmptyRoot() + got := hex.EncodeToString(r[:]) + want := "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + if got != want { + t.Errorf("EmptyRoot() = %s, want %s", got, want) + } +} + +// TestHashLeafEmpty pins the RFC 6962 hash of an empty leaf: SHA-256(0x00). +func TestHashLeafEmpty(t *testing.T) { + h := HashLeaf(nil) + got := hex.EncodeToString(h[:]) + want := "6e340b9cffb37a989ca544e6bb780a2c78901d3fb33738768511a30617afa01d" + if got != want { + t.Errorf("HashLeaf(nil) = %s, want %s", got, want) + } +} + +// TestRootSingleLeaf checks that a one-leaf tree's root is the leaf itself. +func TestRootSingleLeaf(t *testing.T) { + l := leaves(1) + if Root(l) != l[0] { + t.Error("root of a single-leaf tree should be the leaf hash") + } +} + +// TestSplitPoint checks the largest-power-of-two-below-n helper, which decides +// where every interior node splits. +func TestSplitPoint(t *testing.T) { + for _, tc := range []struct{ n, want int }{ + {2, 1}, {3, 2}, {4, 2}, {5, 4}, {7, 4}, {8, 4}, {9, 8}, {16, 8}, {17, 16}, + } { + if got := splitPoint(tc.n); got != tc.want { + t.Errorf("splitPoint(%d) = %d, want %d", tc.n, got, tc.want) + } + } +} + +// TestInclusionProofRoundTrip generates and verifies an inclusion proof for +// every leaf of every tree size up to 64. +func TestInclusionProofRoundTrip(t *testing.T) { + for n := 1; n <= 64; n++ { + l := leaves(n) + root := Root(l) + for i := 0; i < n; i++ { + proof, err := InclusionProof(l, i) + if err != nil { + t.Fatalf("n=%d i=%d: InclusionProof: %v", n, i, err) + } + if err := VerifyInclusion(l[i], root, proof, i, n); err != nil { + t.Errorf("n=%d i=%d: VerifyInclusion: %v", n, i, err) + } + } + } +} + +// TestInclusionProofWrongLeafFails checks that a proof does not verify against +// a leaf hash it was not generated for. +func TestInclusionProofWrongLeafFails(t *testing.T) { + const n = 13 + l := leaves(n) + root := Root(l) + proof, err := InclusionProof(l, 5) + if err != nil { + t.Fatal(err) + } + if err := VerifyInclusion(l[6], root, proof, 5, n); err == nil { + t.Error("expected verification to fail for a mismatched leaf hash") + } +} + +// TestInclusionProofTamperedFails checks that flipping a bit in any proof hash +// is detected. +func TestInclusionProofTamperedFails(t *testing.T) { + const n = 21 + l := leaves(n) + root := Root(l) + proof, err := InclusionProof(l, 9) + if err != nil { + t.Fatal(err) + } + for j := range proof { + tampered := make([]Hash, len(proof)) + copy(tampered, proof) + tampered[j][0] ^= 0x01 + if err := VerifyInclusion(l[9], root, tampered, 9, n); err == nil { + t.Errorf("expected verification to fail with hash %d tampered", j) + } + } +} + +// TestInclusionProofWrongLengthFails checks that proofs of the wrong length are +// rejected rather than silently truncated. +func TestInclusionProofWrongLengthFails(t *testing.T) { + const n = 10 + l := leaves(n) + root := Root(l) + proof, err := InclusionProof(l, 3) + if err != nil { + t.Fatal(err) + } + if err := VerifyInclusion(l[3], root, proof[:len(proof)-1], 3, n); err == nil { + t.Error("expected verification to fail for a short proof") + } + if err := VerifyInclusion(l[3], root, append(proof, proof[0]), 3, n); err == nil { + t.Error("expected verification to fail for a long proof") + } +} + +// TestInclusionProofOutOfRange checks index bounds. +func TestInclusionProofOutOfRange(t *testing.T) { + l := leaves(4) + if _, err := InclusionProof(l, 4); err == nil { + t.Error("expected an error for an out-of-range index") + } + if _, err := InclusionProof(l, -1); err == nil { + t.Error("expected an error for a negative index") + } +} + +// TestConsistencyProofRoundTrip generates and verifies a consistency proof for +// every pair of sizes up to 64. +func TestConsistencyProofRoundTrip(t *testing.T) { + for n := 1; n <= 64; n++ { + l := leaves(n) + newRoot := Root(l) + for m := 0; m <= n; m++ { + oldRoot := Root(l[:m]) + proof, err := ConsistencyProof(l, m) + if err != nil { + t.Fatalf("n=%d m=%d: ConsistencyProof: %v", n, m, err) + } + if err := VerifyConsistency(oldRoot, newRoot, proof, m, n); err != nil { + t.Errorf("n=%d m=%d: VerifyConsistency: %v", n, m, err) + } + } + } +} + +// TestConsistencyProofRejectsForkedTree is the property the witness relies on: +// a tree that replaced an existing leaf rather than appending to it must not +// produce a valid consistency proof. +func TestConsistencyProofRejectsForkedTree(t *testing.T) { + const m = 7 + original := leaves(12) + oldRoot := Root(original[:m]) + + // Fork the log: rewrite leaf 3, which the old tree already committed to. + forked := make([]Hash, len(original)) + copy(forked, original) + forked[3] = HashLeaf([]byte("rewritten")) + + proof, err := ConsistencyProof(forked, m) + if err != nil { + t.Fatal(err) + } + if err := VerifyConsistency(oldRoot, Root(forked), proof, m, len(forked)); err == nil { + t.Error("expected a forked log to fail consistency verification") + } +} + +// TestConsistencyProofTamperedFails checks that flipping a bit in any proof +// hash is detected. +func TestConsistencyProofTamperedFails(t *testing.T) { + const m, n = 5, 17 + l := leaves(n) + oldRoot, newRoot := Root(l[:m]), Root(l) + proof, err := ConsistencyProof(l, m) + if err != nil { + t.Fatal(err) + } + for j := range proof { + tampered := make([]Hash, len(proof)) + copy(tampered, proof) + tampered[j][0] ^= 0x01 + if err := VerifyConsistency(oldRoot, newRoot, tampered, m, n); err == nil { + t.Errorf("expected verification to fail with hash %d tampered", j) + } + } +} + +// TestConsistencyProofEqualSize checks the m == n case: no proof, equal roots. +func TestConsistencyProofEqualSize(t *testing.T) { + l := leaves(9) + root := Root(l) + if err := VerifyConsistency(root, root, nil, 9, 9); err != nil { + t.Errorf("equal roots at equal size should verify: %v", err) + } + if err := VerifyConsistency(root, Root(leaves(8)), nil, 9, 9); err == nil { + t.Error("differing roots at equal size should not verify") + } + if err := VerifyConsistency(root, root, []Hash{root}, 9, 9); err == nil { + t.Error("a non-empty proof at equal size should not verify") + } +} + +// TestConsistencyProofFromEmpty checks that every tree is consistent with the +// empty tree, and that no proof material is expected. +func TestConsistencyProofFromEmpty(t *testing.T) { + l := leaves(6) + proof, err := ConsistencyProof(l, 0) + if err != nil { + t.Fatal(err) + } + if len(proof) != 0 { + t.Errorf("proof from size 0 should be empty, got %d hashes", len(proof)) + } + if err := VerifyConsistency(EmptyRoot(), Root(l), nil, 0, 6); err != nil { + t.Errorf("consistency from the empty tree should verify: %v", err) + } +} + +// TestConsistencyProofShrinkingRejected checks that a log cannot shrink. +func TestConsistencyProofShrinkingRejected(t *testing.T) { + l := leaves(10) + if err := VerifyConsistency(Root(l), Root(l[:4]), nil, 10, 4); err == nil { + t.Error("expected a shrinking tree to be rejected") + } +} + +// TestConsistencyProofOutOfRange checks size bounds on generation. +func TestConsistencyProofOutOfRange(t *testing.T) { + l := leaves(4) + if _, err := ConsistencyProof(l, 5); err == nil { + t.Error("expected an error when the old size exceeds the tree") + } + if _, err := ConsistencyProof(l, -1); err == nil { + t.Error("expected an error for a negative old size") + } +} + +// TestConsistencyProofEmptyRejected checks that a missing proof cannot stand in +// for a real one when the tree has genuinely grown. +func TestConsistencyProofEmptyRejected(t *testing.T) { + l := leaves(9) + if err := VerifyConsistency(Root(l[:5]), Root(l), nil, 5, 9); err == nil { + t.Error("expected an empty proof to be rejected for a grown tree") + } +} diff --git a/internal/witness/BUILD.bazel b/internal/witness/BUILD.bazel index 947faeb..79e33a6 100644 --- a/internal/witness/BUILD.bazel +++ b/internal/witness/BUILD.bazel @@ -18,15 +18,24 @@ go_library( name = "witness", srcs = [ "client.go", + "tlogclient.go", + "tlogproto.go", "verify.go", ], importpath = "github.com/project-oak/git-ratchet/internal/witness", visibility = ["//:__subpackages__"], - deps = ["//internal/gitutil"], + deps = [ + "//internal/gitutil", + "//internal/tlog", + ], ) go_test( name = "witness_test", - srcs = ["verify_test.go"], + srcs = [ + "tlogproto_test.go", + "verify_test.go", + ], embed = [":witness"], + deps = ["//internal/tlog"], ) diff --git a/internal/witness/tlogclient.go b/internal/witness/tlogclient.go new file mode 100644 index 0000000..0c64ab1 --- /dev/null +++ b/internal/witness/tlogclient.go @@ -0,0 +1,121 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package witness + +import ( + "context" + "fmt" + "io" + "net/http" + "strconv" + "strings" + + "github.com/project-oak/git-ratchet/internal/tlog" +) + +// ConflictSizePrefix marks the first line of a 409 response body, which +// carries the tree size the witness actually holds. +// +// The tlog-witness protocol expects a stale client to recover by regenerating +// its proof from the witness's real size and resubmitting. Reporting the size +// in a machine-readable form is what makes that single-round recovery +// possible. +const ConflictSizePrefix = "old " + +// ProofFunc returns a consistency proof from oldSize to the size of the +// checkpoint being submitted. +type ProofFunc func(oldSize int) ([]tlog.Hash, error) + +// CosignTlog submits a signed tlog-checkpoint to a witness and returns the +// cosignature line. +// +// If the witness reports a different stored size (HTTP 409), CosignTlog +// regenerates the consistency proof from the size the witness actually holds +// and retries once. This is the conflict recovery that the git-checkpoint +// protocol sidesteps by sending a commit chain that spans any gap; with a +// Merkle log the proof is anchored to a specific size, so the round trip is +// unavoidable. +func CosignTlog(ctx context.Context, endpoint string, oldSize int, proofFor ProofFunc, signedNote string) (string, error) { + cosig, conflictSize, err := postTlog(ctx, endpoint, oldSize, proofFor, signedNote) + if err == nil { + return cosig, nil + } + if conflictSize < 0 || conflictSize == oldSize { + return "", err + } + + cosig, _, retryErr := postTlog(ctx, endpoint, conflictSize, proofFor, signedNote) + if retryErr != nil { + return "", fmt.Errorf("retry from witness size %d: %w", conflictSize, retryErr) + } + return cosig, nil +} + +// postTlog performs one add-checkpoint round trip. When the witness reports a +// conflicting size, it is returned alongside the error; otherwise the returned +// size is -1. +func postTlog(ctx context.Context, endpoint string, oldSize int, proofFor ProofFunc, signedNote string) (string, int, error) { + proof, err := proofFor(oldSize) + if err != nil { + return "", -1, fmt.Errorf("generating consistency proof from size %d: %w", oldSize, err) + } + + url := strings.TrimRight(endpoint, "/") + "/add-checkpoint" + body := FormatTlogRequest(oldSize, proof, signedNote) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(body)) + if err != nil { + return "", -1, fmt.Errorf("building request for witness %s: %w", endpoint, err) + } + req.Header.Set("Content-Type", "text/plain") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", -1, fmt.Errorf("contacting witness %s: %w", endpoint, err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", -1, fmt.Errorf("reading witness response: %w", err) + } + result := strings.TrimSpace(string(respBody)) + + switch resp.StatusCode { + case http.StatusOK: + return result, -1, nil + case http.StatusConflict: + return "", parseConflictSize(result), &RejectionError{StatusCode: resp.StatusCode, Detail: result} + case http.StatusUnprocessableEntity, http.StatusForbidden: + return "", -1, &RejectionError{StatusCode: resp.StatusCode, Detail: result} + default: + return "", -1, fmt.Errorf("witness HTTP %d: %s", resp.StatusCode, result) + } +} + +// parseConflictSize extracts the witness's stored tree size from a 409 body, +// returning -1 if it is not reported in the expected form. +func parseConflictSize(body string) int { + first, _, _ := strings.Cut(body, "\n") + sizeStr, ok := strings.CutPrefix(strings.TrimSpace(first), ConflictSizePrefix) + if !ok { + return -1 + } + size, err := strconv.Atoi(strings.TrimSpace(sizeStr)) + if err != nil || size < 0 { + return -1 + } + return size +} diff --git a/internal/witness/tlogproto.go b/internal/witness/tlogproto.go new file mode 100644 index 0000000..b61ae6a --- /dev/null +++ b/internal/witness/tlogproto.go @@ -0,0 +1,111 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package witness + +import ( + "encoding/base64" + "fmt" + "strconv" + "strings" + + "github.com/project-oak/git-ratchet/internal/tlog" +) + +// This file implements the add-checkpoint wire format of the C2SP tlog-witness +// protocol, which git-ratchet's transparency-log mode speaks. +// +// Unlike the git-checkpoint request in verify.go, this format carries no Git +// objects: the witness is shown only an old tree size and a consistency proof, +// and checks that the log grew by appending. It has no way to inspect what was +// appended, which is why entry semantics are checked by verifiers walking the +// log rather than by the witness. + +// TlogRequest is a parsed add-checkpoint request. +type TlogRequest struct { + // OldSize is the tree size the client believes the witness last signed. + OldSize int + // Proof is the consistency proof from OldSize to the checkpoint's size. + Proof []tlog.Hash + // Note is the raw signed tlog-checkpoint. + Note string +} + +// FormatTlogRequest renders an add-checkpoint request body: +// +// old \n +// \n (zero or more) +// \n +// +func FormatTlogRequest(oldSize int, proof []tlog.Hash, signedNote string) string { + var b strings.Builder + fmt.Fprintf(&b, "old %d\n", oldSize) + for _, h := range proof { + b.WriteString(base64.StdEncoding.EncodeToString(h[:])) + b.WriteByte('\n') + } + b.WriteByte('\n') + b.WriteString(signedNote) + return b.String() +} + +// ParseTlogRequest parses an add-checkpoint request body. +func ParseTlogRequest(body string) (TlogRequest, error) { + var req TlogRequest + + lines := strings.Split(body, "\n") + if len(lines) == 0 { + return req, fmt.Errorf("malformed request: empty body") + } + + sizeStr, ok := strings.CutPrefix(lines[0], "old ") + if !ok { + return req, fmt.Errorf("malformed request: first line must be \"old \"") + } + oldSize, err := strconv.Atoi(strings.TrimSpace(sizeStr)) + if err != nil { + return req, fmt.Errorf("malformed request: invalid old size %q", sizeStr) + } + if oldSize < 0 { + return req, fmt.Errorf("malformed request: negative old size %d", oldSize) + } + req.OldSize = oldSize + + // Consistency proof hashes run until the blank line separator. + i := 1 + for ; i < len(lines); i++ { + if lines[i] == "" { + break + } + raw, err := base64.StdEncoding.DecodeString(lines[i]) + if err != nil { + return req, fmt.Errorf("malformed request: invalid base64 in consistency proof") + } + if len(raw) != tlog.HashSize { + return req, fmt.Errorf("malformed request: consistency proof hash is %d bytes, want %d", len(raw), tlog.HashSize) + } + var h tlog.Hash + copy(h[:], raw) + req.Proof = append(req.Proof, h) + } + if i >= len(lines) { + return req, fmt.Errorf("malformed request: missing empty line separator") + } + + req.Note = strings.Join(lines[i+1:], "\n") + if strings.TrimSpace(req.Note) == "" { + return req, fmt.Errorf("malformed request: empty signed note") + } + return req, nil +} diff --git a/internal/witness/tlogproto_test.go b/internal/witness/tlogproto_test.go new file mode 100644 index 0000000..abfc21b --- /dev/null +++ b/internal/witness/tlogproto_test.go @@ -0,0 +1,78 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package witness + +import ( + "strings" + "testing" + + "github.com/project-oak/git-ratchet/internal/tlog" +) + +func TestTlogRequestRoundTrip(t *testing.T) { + proof := []tlog.Hash{ + tlog.HashLeaf([]byte("a")), + tlog.HashLeaf([]byte("b")), + } + signedNote := "example.com/log\n7\nAAAA\n\n— origin+abcd1234 sig\n" + + body := FormatTlogRequest(5, proof, signedNote) + req, err := ParseTlogRequest(body) + if err != nil { + t.Fatalf("ParseTlogRequest: %v", err) + } + if req.OldSize != 5 { + t.Errorf("OldSize = %d, want 5", req.OldSize) + } + if len(req.Proof) != 2 || req.Proof[0] != proof[0] || req.Proof[1] != proof[1] { + t.Errorf("Proof = %v", req.Proof) + } + if req.Note != signedNote { + t.Errorf("Note = %q, want %q", req.Note, signedNote) + } +} + +// TestTlogRequestEmptyProof covers the first-submission case, where the client +// has nothing to prove because the witness has no stored state. +func TestTlogRequestEmptyProof(t *testing.T) { + signedNote := "example.com/log\n1\nAAAA\n\n— origin+abcd1234 sig\n" + req, err := ParseTlogRequest(FormatTlogRequest(0, nil, signedNote)) + if err != nil { + t.Fatalf("ParseTlogRequest: %v", err) + } + if req.OldSize != 0 { + t.Errorf("OldSize = %d, want 0", req.OldSize) + } + if len(req.Proof) != 0 { + t.Errorf("Proof = %v, want empty", req.Proof) + } +} + +func TestParseTlogRequestErrors(t *testing.T) { + for _, tc := range []struct{ name, body string }{ + {"empty", ""}, + {"no old line", "\n\nnote\n"}, + {"bad old size", "old xyz\n\nnote\n"}, + {"negative old size", "old -1\n\nnote\n"}, + {"no separator", "old 1\n" + strings.Repeat("A", 44) + "\n"}, + {"bad base64 proof", "old 1\nnot!base64\n\nnote\n"}, + {"short proof hash", "old 1\nAAAA\n\nnote\n"}, + {"empty note", "old 1\n\n\n"}, + } { + if _, err := ParseTlogRequest(tc.body); err == nil { + t.Errorf("%s: expected an error, got none", tc.name) + } + } +} diff --git a/main.go b/main.go index e702bda..ad371d0 100644 --- a/main.go +++ b/main.go @@ -55,6 +55,7 @@ type checkpointCmd struct { keyPath string kmsKey string repoDir string + mode string } func (*checkpointCmd) Name() string { return "checkpoint" } @@ -88,9 +89,14 @@ func (c *checkpointCmd) SetFlags(f *flag.FlagSet) { f.StringVar(&c.keyPath, "key", "", "Path to origin private key file (required unless --kms-key is set)") f.StringVar(&c.kmsKey, "kms-key", "", "GCP KMS key resource name for remote signing (alternative to --key)") f.StringVar(&c.repoDir, "repo", ".", "Path to git repository") + f.StringVar(&c.mode, "mode", modeGitCheckpoint, "Checkpoint format: "+modeGitCheckpoint+" or "+modeTlog) } func (c *checkpointCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...any) subcommands.ExitStatus { + if err := validateMode(c.mode); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + return subcommands.ExitUsageError + } if c.ref == "" || c.policyPath == "" { fmt.Fprintln(os.Stderr, "error: --ref, --policy, and one of --key or --kms-key are required") fmt.Fprint(os.Stderr, c.Usage()) @@ -143,6 +149,14 @@ func (c *checkpointCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...any) su return subcommands.ExitFailure } + if c.mode == modeTlog { + if err := checkpointTlog(c.repoDir, c.ref, origin, signer, pol); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + return subcommands.ExitFailure + } + return subcommands.ExitSuccess + } + // Phase 1: Build the signed checkpoint note and ancestry proof. signed, ancestry, err := buildCheckpointRequest(c.repoDir, c.ref, origin, signer) if err != nil { @@ -477,6 +491,7 @@ type verifyCmd struct { refs stringSlice policyPath string repoDir string + mode string } func (*verifyCmd) Name() string { return "verify" } @@ -499,9 +514,14 @@ func (c *verifyCmd) SetFlags(f *flag.FlagSet) { f.Var(&c.refs, "ref", "Full ref path to verify (e.g. refs/heads/main) (required, repeatable)") f.StringVar(&c.policyPath, "policy", "", "Path to witness policy file (required)") f.StringVar(&c.repoDir, "repo", ".", "Path to git repository") + f.StringVar(&c.mode, "mode", modeGitCheckpoint, "Checkpoint format: "+modeGitCheckpoint+" or "+modeTlog) } func (c *verifyCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...any) subcommands.ExitStatus { + if err := validateMode(c.mode); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + return subcommands.ExitUsageError + } if c.policyPath == "" || len(c.refs) == 0 { fmt.Fprintln(os.Stderr, "error: --policy and at least one --ref are required") fmt.Fprint(os.Stderr, c.Usage()) @@ -522,23 +542,7 @@ func (c *verifyCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...any) subcom return subcommands.ExitFailure } - refs := []string(c.refs) - - // Verify refs in parallel. - type verifyResult struct { - ref string - err error - } - results := make([]verifyResult, len(refs)) - var wg sync.WaitGroup - for i, ref := range refs { - wg.Add(1) - go func(i int, ref string) { - defer wg.Done() - results[i] = verifyResult{ref, verifySingleRef(c.repoDir, ref, pol)} - }(i, ref) - } - wg.Wait() + results := verifyRefs(c.repoDir, []string(c.refs), pol, c.mode) failed := 0 for _, r := range results { @@ -550,12 +554,53 @@ func (c *verifyCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...any) subcom } } if failed > 0 { - fmt.Fprintf(os.Stderr, "\n%d of %d refs failed verification\n", failed, len(refs)) + fmt.Fprintf(os.Stderr, "\n%d of %d refs failed verification\n", failed, len(c.refs)) return subcommands.ExitFailure } return subcommands.ExitSuccess } +// verifyResult pairs a ref with the outcome of verifying it. +type verifyResult struct { + ref string + err error +} + +// verifyRefs verifies each ref against the policy in the given mode. +// +// The two modes parallelise differently. A git-checkpoint verification is +// independent per ref, so those run concurrently. A tlog verification shares +// one log: its checkpoint is verified once up front — a failure there fails +// every ref — and the per-ref walks then run against that single verified log. +func verifyRefs(repoDir string, refs []string, pol *policy.Policy, mode string) []verifyResult { + results := make([]verifyResult, len(refs)) + + if mode == modeTlog { + l, err := openVerifiedLog(repoDir, pol) + if err != nil { + for i, ref := range refs { + results[i] = verifyResult{ref, err} + } + return results + } + for i, ref := range refs { + results[i] = verifyResult{ref, verifySingleRefTlog(repoDir, ref, l)} + } + return results + } + + var wg sync.WaitGroup + for i, ref := range refs { + wg.Add(1) + go func(i int, ref string) { + defer wg.Done() + results[i] = verifyResult{ref, verifySingleRef(repoDir, ref, pol)} + }(i, ref) + } + wg.Wait() + return results +} + // verifySingleRef verifies a single ref's checkpoint against the policy. func verifySingleRef(repoDir, ref string, pol *policy.Policy) error { kind, err := gitutil.ParseRefKind(ref) @@ -624,6 +669,7 @@ type auditCmd struct { refs stringSlice policyPath string repoDir string + mode string } func (*auditCmd) Name() string { return "audit" } @@ -652,9 +698,14 @@ func (c *auditCmd) SetFlags(f *flag.FlagSet) { f.Var(&c.refs, "ref", "Full ref path to verify (e.g. refs/heads/main) (required, repeatable)") f.StringVar(&c.policyPath, "policy", "", "Path to witness policy file (required)") f.StringVar(&c.repoDir, "repo", ".", "Path to git repository") + f.StringVar(&c.mode, "mode", modeGitCheckpoint, "Checkpoint format: "+modeGitCheckpoint+" or "+modeTlog) } func (c *auditCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...any) subcommands.ExitStatus { + if err := validateMode(c.mode); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + return subcommands.ExitUsageError + } if c.policyPath == "" || len(c.refs) == 0 { fmt.Fprintln(os.Stderr, "error: --policy and at least one --ref are required") fmt.Fprint(os.Stderr, c.Usage()) @@ -686,21 +737,7 @@ func (c *auditCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...any) subcomm fmt.Fprintf(os.Stderr, "error: loading policy: %v\n", err) return subcommands.ExitUsageError } - refs := []string(c.refs) - type verifyResult struct { - ref string - err error - } - results := make([]verifyResult, len(refs)) - var wg sync.WaitGroup - for i, ref := range refs { - wg.Add(1) - go func(i int, ref string) { - defer wg.Done() - results[i] = verifyResult{ref, verifySingleRef(c.repoDir, ref, pol)} - }(i, ref) - } - wg.Wait() + results := verifyRefs(c.repoDir, []string(c.refs), pol, c.mode) for _, r := range results { if r.err != nil { fmt.Fprintf(os.Stderr, "FAIL verify %s: %v\n", r.ref, r.err) diff --git a/tlogcmd.go b/tlogcmd.go new file mode 100644 index 0000000..c11fdee --- /dev/null +++ b/tlogcmd.go @@ -0,0 +1,277 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "time" + + "github.com/project-oak/git-ratchet/internal/gitlog" + "github.com/project-oak/git-ratchet/internal/gitutil" + "github.com/project-oak/git-ratchet/internal/note" + "github.com/project-oak/git-ratchet/internal/policy" + "github.com/project-oak/git-ratchet/internal/tlog" + "github.com/project-oak/git-ratchet/internal/witness" +) + +// Checkpoint format modes. See docs/tlog-variant.md for how they differ. +const ( + // modeGitCheckpoint stores a signed note per ref, and witnesses enforce + // Git ancestry when cosigning it. + modeGitCheckpoint = "git-checkpoint" + // modeTlog stores a Merkle transparency log in the repository, and + // witnesses enforce only that the log grew by appending. + modeTlog = "tlog" +) + +// validateMode rejects anything that is not one of the two supported modes. +func validateMode(mode string) error { + if mode != modeGitCheckpoint && mode != modeTlog { + return fmt.Errorf("--mode must be %q or %q, got %q", modeGitCheckpoint, modeTlog, mode) + } + return nil +} + +// checkpointTlog appends the ref's current object hash to the repository's +// transparency log, has the log's new head cosigned by the policy's witnesses, +// and commits both to the log ref. +func checkpointTlog(repoDir, ref, origin string, signer *note.Signer, pol *policy.Policy) error { + l, err := gitlog.Open(repoDir) + if err != nil { + return fmt.Errorf("opening log: %w", err) + } + + objectHash, err := gitutil.ResolveRef(repoDir, ref) + if err != nil { + return fmt.Errorf("resolving ref: %w", err) + } + + // The size the witnesses are expected to be holding is the size of the + // last checkpoint this repository stored. If a witness disagrees it says + // so, and the client regenerates its proof from the size the witness + // actually holds. + oldSize := 0 + if stored := l.StoredCheckpoint(); stored != "" { + body, err := note.ExtractBody(stored) + if err != nil { + return fmt.Errorf("parsing stored checkpoint: %w", err) + } + prev, err := tlog.ParseCheckpoint(body) + if err != nil { + return fmt.Errorf("parsing stored checkpoint: %w", err) + } + oldSize = prev.Size + } + + // Appending an entry identical to the ref's latest logged state would grow + // the log without saying anything new, so re-checkpointing an unchanged + // ref just refreshes the cosignatures on the current head. + appended := false + if latest, ok := l.Latest(ref); !ok || latest.Hash != objectHash { + l.Append(gitlog.Entry{Ref: ref, Hash: objectHash}) + appended = true + } + if l.Size() == 0 { + return fmt.Errorf("refusing to checkpoint an empty log") + } + + cp := tlog.Checkpoint{Origin: origin, Size: l.Size(), Root: l.Root()} + signed, err := note.Sign(cp.Body(), signer) + if err != nil { + return fmt.Errorf("signing checkpoint: %w", err) + } + + cosigLines, err := collectTlogCosignatures(pol, l, oldSize, signed) + if err != nil { + return err + } + + assembled := signed + for _, line := range cosigLines { + assembled = note.AppendSignature(assembled, line) + } + body, sigLines, err := note.ParseSignedNote(assembled) + if err != nil { + return fmt.Errorf("parsing assembled checkpoint: %w", err) + } + if err := pol.VerifyQuorumTlog(body, sigLines); err != nil { + return fmt.Errorf("quorum not satisfied: %w", err) + } + + msg := fmt.Sprintf("ratchet: %s %s (log size %d)", ref, objectHash, l.Size()) + if !appended { + msg = fmt.Sprintf("ratchet: refresh cosignatures at log size %d", l.Size()) + } + if err := l.Save(assembled, msg); err != nil { + return err + } + + fmt.Printf("checkpoint stored at %s (log size %d, %d witness cosignatures)\n", + gitlog.LogRef, l.Size(), len(cosigLines)) + return nil +} + +// collectTlogCosignatures submits the signed checkpoint to every witness in +// the policy, in parallel, and returns the cosignature lines collected. +func collectTlogCosignatures(pol *policy.Policy, l *gitlog.Log, oldSize int, signed string) ([]string, error) { + proofFor := func(m int) ([]tlog.Hash, error) { return l.ConsistencyProofFrom(m) } + + type result struct { + policyName string + cosigLine string + err error + } + witnesses := pol.Witnesses() + ch := make(chan result, len(witnesses)) + for _, w := range witnesses { + go func(w *policy.Witness) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if w.Endpoint != "" && !strings.HasPrefix(w.Endpoint, "http://") && !strings.HasPrefix(w.Endpoint, "https://") { + ch <- result{w.PolicyName, "", fmt.Errorf("unsupported witness transport %q for tlog mode", w.Endpoint)} + return + } + line, err := witness.CosignTlog(ctx, w.Endpoint, oldSize, proofFor, signed) + ch <- result{w.PolicyName, line, err} + }(w) + } + + var cosigLines []string + for range witnesses { + r := <-ch + if r.err != nil { + // A rejection means the witness inspected the transition and + // refused it — the log did not grow by appending. That is the + // strongest signal available that the log has been rewritten, so + // it is never skipped in favour of another witness's quorum. + var rejection *witness.RejectionError + if errors.As(r.err, &rejection) { + return nil, fmt.Errorf("witness %s rejected checkpoint: %w", r.policyName, r.err) + } + fmt.Fprintf(os.Stderr, "warning: witness %s failed (skipped): %v\n", r.policyName, r.err) + continue + } + cosigLines = append(cosigLines, r.cosigLine) + } + return cosigLines, nil +} + +// openVerifiedLog opens the repository's log and checks that its stored +// checkpoint is signed, has quorum, and describes the log actually present. +func openVerifiedLog(repoDir string, pol *policy.Policy) (*gitlog.Log, error) { + l, err := gitlog.Open(repoDir) + if err != nil { + return nil, fmt.Errorf("opening log: %w", err) + } + + stored := l.StoredCheckpoint() + if stored == "" { + return nil, fmt.Errorf("no log checkpoint found (hint: git fetch origin %s:%s)", gitlog.LogRef, gitlog.LogRef) + } + + body, sigLines, err := note.ParseSignedNote(stored) + if err != nil { + return nil, fmt.Errorf("parsing log checkpoint: %w", err) + } + if err := pol.VerifyTlog(body, sigLines); err != nil { + return nil, fmt.Errorf("log checkpoint verification failed: %w", err) + } + + cp, err := tlog.ParseCheckpoint(body) + if err != nil { + return nil, fmt.Errorf("malformed log checkpoint: %w", err) + } + if cp.Origin != pol.LogName { + return nil, fmt.Errorf("checkpoint origin mismatch: checkpoint is from %q but policy expects %q", cp.Origin, pol.LogName) + } + + // The entries present must be exactly the tree the witnesses cosigned. + // Anything else means entries were added or removed without witnessing. + if cp.Size != l.Size() { + return nil, fmt.Errorf("log has %d entries but the cosigned checkpoint commits to %d", l.Size(), cp.Size) + } + if l.Root() != cp.Root { + return nil, fmt.Errorf("log entries do not reproduce the cosigned root hash") + } + return l, nil +} + +// verifySingleRefTlog checks one ref against the verified log. +// +// This is the walk that replaces the witness's ancestry check: the witness +// only attested that the log grew by appending, so every ratchet property is +// established here, from entries the verifier holds locally. +func verifySingleRefTlog(repoDir, ref string, l *gitlog.Log) error { + kind, err := gitutil.ParseRefKind(ref) + if err != nil { + return err + } + + entries := l.EntriesFor(ref) + if len(entries) == 0 { + return fmt.Errorf("no log entries for ref %q", ref) + } + + switch kind { + case gitutil.RefTag: + // Tags are create-once: a second entry for the same tag is a move, + // whatever object it names. + if len(entries) > 1 { + return fmt.Errorf("tag was logged %d times (first %s, last %s); tags must be logged exactly once", + len(entries), entries[0].Hash, entries[len(entries)-1].Hash) + } + case gitutil.RefBranch: + // Each logged state must descend from the one before it. + for i := 1; i < len(entries); i++ { + prev, curr := entries[i-1].Hash, entries[i].Hash + ok, err := gitutil.IsAncestor(repoDir, prev, curr) + if err != nil { + return fmt.Errorf("cannot check ancestry from logged commit %s to %s "+ + "(the object may be missing from this clone, which is itself evidence "+ + "that logged history was discarded): %w", prev, curr, err) + } + if !ok { + return fmt.Errorf("log entry %d for %s (%s) does not descend from entry %d (%s): history was rewritten", + i, ref, curr, i-1, prev) + } + } + } + + latest := entries[len(entries)-1] + localHash, err := gitutil.ResolveRef(repoDir, ref) + if err != nil { + return fmt.Errorf("resolving ref: %w", err) + } + + if kind == gitutil.RefTag { + if localHash != latest.Hash { + return fmt.Errorf("tag does not match log (current: %s, logged: %s)", localHash, latest.Hash) + } + return nil + } + + ok, err := gitutil.IsAncestor(repoDir, localHash, latest.Hash) + if err != nil { + return fmt.Errorf("checking ancestry: %w", err) + } + if !ok { + return fmt.Errorf("local commit %s is ahead of the latest logged commit %s", localHash, latest.Hash) + } + return nil +} diff --git a/witness/BUILD.bazel b/witness/BUILD.bazel index e8fd398..24882fa 100644 --- a/witness/BUILD.bazel +++ b/witness/BUILD.bazel @@ -18,12 +18,16 @@ load("@tar.bzl", "mutate", "tar") go_library( name = "witness_lib", - srcs = ["main.go"], + srcs = [ + "main.go", + "tlog.go", + ], importpath = "github.com/project-oak/git-ratchet/witness", visibility = ["//visibility:private"], deps = [ "//internal/gitutil", "//internal/note", + "//internal/tlog", "//internal/witness", ], ) diff --git a/witness/main.go b/witness/main.go index f39e82a..b5736b8 100644 --- a/witness/main.go +++ b/witness/main.go @@ -40,12 +40,24 @@ type trustedOrigin struct { sigType note.SigType } +// treeState is the witness's stored view of a transparency log: the size and +// root hash of the largest tree it has cosigned for an origin. +type treeState struct { + Size int `json:"size"` + Root string `json:"root"` // base64-encoded root hash +} + type Server struct { witnessKey *note.Signer trustedOrigins map[string]trustedOrigin stateFile string + mode string mu sync.RWMutex - commits map[string]string // branch key -> object hash + + // commits is the git-checkpoint mode state: ref key -> object hash. + commits map[string]string + // trees is the tlog mode state: origin -> tree head. + trees map[string]treeState } var ( @@ -56,6 +68,16 @@ var ( originsFlag = flag.String("origins", "", "Comma-separated list of trusted origin verifier keys (vkeys)") originsFile = flag.String("origins-file", "", "Path to file containing trusted origin verifier keys (one per line)") stateFile = flag.String("state-file", "", "Path to JSON file to persist witness state") + modeFlag = flag.String("mode", modeGitCheckpoint, "Checkpoint format to witness: "+modeGitCheckpoint+" or "+modeTlog) +) + +const ( + // modeGitCheckpoint witnesses git-checkpoint notes, verifying Git commit + // ancestry. See docs/witness-protocol.md. + modeGitCheckpoint = "git-checkpoint" + // modeTlog witnesses C2SP tlog-checkpoint notes, verifying Merkle tree + // consistency. See docs/tlog-variant.md. + modeTlog = "tlog" ) func main() { @@ -104,11 +126,17 @@ func main() { log.Fatalf("failed to parse trusted origins: %v", err) } + if *modeFlag != modeGitCheckpoint && *modeFlag != modeTlog { + log.Fatalf("error: --mode must be %q or %q, got %q", modeGitCheckpoint, modeTlog, *modeFlag) + } + srv := &Server{ witnessKey: wSigner, trustedOrigins: trustedOrig, stateFile: *stateFile, + mode: *modeFlag, commits: make(map[string]string), + trees: make(map[string]treeState), } // Load stored state if any. @@ -116,9 +144,13 @@ func main() { log.Fatalf("failed to load state file: %v", err) } - http.HandleFunc("/add-checkpoint", srv.handleAddCheckpoint) + if srv.mode == modeTlog { + http.HandleFunc("/add-checkpoint", srv.handleAddCheckpointTlog) + } else { + http.HandleFunc("/add-checkpoint", srv.handleAddCheckpoint) + } - log.Printf("Starting witness %q on %s", wSigner.Name, *addr) + log.Printf("Starting witness %q on %s (mode %s)", wSigner.Name, *addr, srv.mode) if len(srv.trustedOrigins) > 0 { log.Printf("Trusted origins: %d configured", len(srv.trustedOrigins)) } else { @@ -239,6 +271,10 @@ func (s *Server) handleAddCheckpoint(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, cosigLine) } +// The two modes track different things — a commit hash per ref, versus a tree +// head per origin — so each persists its own state shape. A state file written +// in one mode is not meaningful in the other, and the witness refuses to +// reinterpret it rather than silently starting from an empty ratchet. func (s *Server) loadState() error { if s.stateFile == "" { return nil @@ -250,14 +286,29 @@ func (s *Server) loadState() error { if err != nil { return err } - return json.Unmarshal(data, &s.commits) + if s.mode == modeTlog { + if err := json.Unmarshal(data, &s.trees); err != nil { + return fmt.Errorf("state file is not %s state (was it written by a %s witness?): %w", + modeTlog, modeGitCheckpoint, err) + } + return nil + } + if err := json.Unmarshal(data, &s.commits); err != nil { + return fmt.Errorf("state file is not %s state (was it written by a %s witness?): %w", + modeGitCheckpoint, modeTlog, err) + } + return nil } func (s *Server) saveState() error { if s.stateFile == "" { return nil } - data, err := json.MarshalIndent(s.commits, "", " ") + var state any = s.commits + if s.mode == modeTlog { + state = s.trees + } + data, err := json.MarshalIndent(state, "", " ") if err != nil { return err } diff --git a/witness/tlog.go b/witness/tlog.go new file mode 100644 index 0000000..1423219 --- /dev/null +++ b/witness/tlog.go @@ -0,0 +1,177 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "encoding/base64" + "fmt" + "io" + "log" + "net/http" + + "github.com/project-oak/git-ratchet/internal/note" + "github.com/project-oak/git-ratchet/internal/tlog" + iwitness "github.com/project-oak/git-ratchet/internal/witness" +) + +// handleAddCheckpointTlog implements the C2SP tlog-witness add-checkpoint call. +// +// The witness verifies only that the log grew by appending: it checks the +// origin signature and a Merkle consistency proof from the tree it last +// cosigned to the tree it is being asked to cosign. It never sees the entries, +// so it cannot and does not check what they say about Git refs — that is the +// verifier's job, walking the log. +func (s *Server) handleAddCheckpointTlog(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + bodyBytes, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "reading request body failed", http.StatusBadRequest) + return + } + + req, err := iwitness.ParseTlogRequest(string(bodyBytes)) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + noteBody, sigLines, err := note.ParseSignedNote(req.Note) + if err != nil { + http.Error(w, fmt.Sprintf("failed to parse signed note: %v", err), http.StatusBadRequest) + return + } + if len(sigLines) == 0 { + http.Error(w, "missing origin signature", http.StatusBadRequest) + return + } + + originSigName, err := note.SigName(sigLines[0]) + if err != nil { + http.Error(w, fmt.Sprintf("failed to parse origin signer name: %v", err), http.StatusBadRequest) + return + } + + s.mu.RLock() + origin, ok := s.trustedOrigins[originSigName] + s.mu.RUnlock() + if !ok { + http.Error(w, fmt.Sprintf("unauthorized origin: %s", originSigName), http.StatusNotFound) + return + } + + if err := note.VerifySignature(noteBody, sigLines[0], origin.pub, origin.sigType); err != nil { + http.Error(w, fmt.Sprintf("invalid origin signature: %v", err), http.StatusForbidden) + return + } + + cp, err := tlog.ParseCheckpoint(noteBody) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // The signer must be the log it claims to be. Without this, any origin the + // witness trusts could advance another origin's stored state. + if cp.Origin != originSigName { + http.Error(w, fmt.Sprintf( + "checkpoint origin %q does not match signer %q", cp.Origin, originSigName), + http.StatusForbidden) + return + } + + if cp.Size == 0 { + http.Error(w, "refusing to cosign an empty tree", http.StatusBadRequest) + return + } + + s.mu.Lock() + defer s.mu.Unlock() + + stored, initialised := s.trees[cp.Origin] + + if initialised { + if req.OldSize != stored.Size { + // Tell the client the size we actually hold so it can regenerate + // its proof and resubmit in one more round trip. + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusConflict) + fmt.Fprintf(w, "%s%d\nwitness holds tree size %d, request assumed %d\n", + iwitness.ConflictSizePrefix, stored.Size, stored.Size, req.OldSize) + return + } + if cp.Size < stored.Size { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusConflict) + fmt.Fprintf(w, "%s%d\nlog may not shrink: stored size %d, submitted size %d\n", + iwitness.ConflictSizePrefix, stored.Size, stored.Size, cp.Size) + return + } + + storedRoot, err := decodeRoot(stored.Root) + if err != nil { + log.Printf("error decoding stored root for %s: %v", cp.Origin, err) + http.Error(w, "internal server error: corrupt stored state", http.StatusInternalServerError) + return + } + if err := tlog.VerifyConsistency(storedRoot, cp.Root, req.Proof, stored.Size, cp.Size); err != nil { + http.Error(w, err.Error(), http.StatusUnprocessableEntity) + return + } + } else if req.OldSize != 0 { + // The client believes this witness has state it does not have. + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusConflict) + fmt.Fprintf(w, "%s0\nwitness holds no tree for origin %s, request assumed size %d\n", + iwitness.ConflictSizePrefix, cp.Origin, req.OldSize) + return + } + + if !initialised || cp.Size > stored.Size { + s.trees[cp.Origin] = treeState{Size: cp.Size, Root: base64.StdEncoding.EncodeToString(cp.Root[:])} + if err := s.saveState(); err != nil { + log.Printf("error saving state file: %v", err) + http.Error(w, "internal server error: saving state failed", http.StatusInternalServerError) + return + } + } + + cosigLine, err := note.CosignTlogCheckpoint(req.Note, s.witnessKey) + if err != nil { + log.Printf("error generating cosignature: %v", err) + http.Error(w, "internal server error: signing failed", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, cosigLine) +} + +func decodeRoot(encoded string) (tlog.Hash, error) { + var h tlog.Hash + raw, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return h, err + } + if len(raw) != tlog.HashSize { + return h, fmt.Errorf("root hash is %d bytes, want %d", len(raw), tlog.HashSize) + } + copy(h[:], raw) + return h, nil +}