From a227166c42f6da4715d37373bcd004643721d425 Mon Sep 17 00:00:00 2001 From: Ben Birt Date: Mon, 3 Aug 2026 13:12:37 +0000 Subject: [PATCH] Add transparency-log checkpoint mode alongside git-checkpoint Implements the variant proposed in project-oak/git-ratchet#11: a Merkle transparency log of ref updates, stored in the repository as Git refs and checkpointed with a standard C2SP tlog-checkpoint that standard tlog-witness witnesses can cosign. Both modes ship; select with --mode on checkpoint/verify/audit and the witness server. git-checkpoint remains the default and is unchanged. The motivation is witness diversity. In git-checkpoint mode the witness checks Git commit ancestry, which makes the ratchet an enforced property but requires every witness operator to run git-ratchet's own implementation. A tlog checkpoint carries no Git-specific fields, so witnesses that have never heard of git-ratchet can cosign it. The trade is that a witness verifying only tree consistency will cosign a rollback -- appending a rolled-back state to a log is a consistent operation. The ratchet is preserved by verify walking the logged entries: branch entries must each descend from the last, tags must be logged exactly once. The walk is local and cheap because the log ships with the repository, and it is always performed; there is no cheaper verification path, because a cheaper one would not be safe. TestTlogDetectsBranchRollback asserts both halves of this: the checkpoint succeeds, and verify rejects it. Design decisions: - One log per repository, not per ref. Witness state and operator onboarding are per log, so per-ref logs would multiply the registrations that speaking a standard protocol was meant to avoid. - Entries record state (" "), not transitions. The log's ordering already establishes the predecessor; a self-asserted one could not be trusted anyway. - The log ref points at a commit, so it can only be advanced by a fast-forward push and an ordinary Git server rejects a rewritten log for free. - Merkle math is implemented in-tree against RFC 6962 rather than taking a dependency; no new modules are required. - Entry bundles use the tlog-tiles path scheme. Hash tiles are not stored: every consumer holds the whole log, and witnesses are sent their consistency proof in the request. ML-DSA-44 cosignatures over a tlog-checkpoint use the tlog-cosignature binary struct with its fields carrying their intended values -- log_origin, tree size and root hash -- rather than the repurposing a git-checkpoint body forces. Not implemented for tlog mode: the decomposed checkpoint-request/checkpoint-store workflow, and therefore the GitHub Issue witness transport. See docs/tlog-variant.md. --- BUILD.bazel | 7 +- README.md | 27 +++ docs/tlog-variant.md | 267 +++++++++++++++++++++ e2e/BUILD.bazel | 5 +- e2e/tlog_test.go | 373 +++++++++++++++++++++++++++++ internal/gitlog/BUILD.bazel | 39 +++ internal/gitlog/gitlog.go | 349 +++++++++++++++++++++++++++ internal/gitlog/gitlog_test.go | 313 ++++++++++++++++++++++++ internal/gitlog/tilepath.go | 105 ++++++++ internal/gitlog/tilepath_test.go | 68 ++++++ internal/gitutil/repo.go | 47 +++- internal/note/BUILD.bazel | 12 +- internal/note/note.go | 11 +- internal/note/tlog.go | 178 ++++++++++++++ internal/note/tlog_test.go | 176 ++++++++++++++ internal/policy/policy.go | 27 ++- internal/tlog/BUILD.bazel | 34 +++ internal/tlog/checkpoint.go | 100 ++++++++ internal/tlog/checkpoint_test.go | 91 +++++++ internal/tlog/tlog.go | 266 ++++++++++++++++++++ internal/tlog/tlog_test.go | 272 +++++++++++++++++++++ internal/witness/BUILD.bazel | 13 +- internal/witness/tlogclient.go | 121 ++++++++++ internal/witness/tlogproto.go | 111 +++++++++ internal/witness/tlogproto_test.go | 78 ++++++ main.go | 103 +++++--- tlogcmd.go | 277 +++++++++++++++++++++ witness/BUILD.bazel | 6 +- witness/main.go | 61 ++++- witness/tlog.go | 177 ++++++++++++++ 30 files changed, 3654 insertions(+), 60 deletions(-) create mode 100644 docs/tlog-variant.md create mode 100644 e2e/tlog_test.go create mode 100644 internal/gitlog/BUILD.bazel create mode 100644 internal/gitlog/gitlog.go create mode 100644 internal/gitlog/gitlog_test.go create mode 100644 internal/gitlog/tilepath.go create mode 100644 internal/gitlog/tilepath_test.go create mode 100644 internal/note/tlog.go create mode 100644 internal/note/tlog_test.go create mode 100644 internal/tlog/BUILD.bazel create mode 100644 internal/tlog/checkpoint.go create mode 100644 internal/tlog/checkpoint_test.go create mode 100644 internal/tlog/tlog.go create mode 100644 internal/tlog/tlog_test.go create mode 100644 internal/witness/tlogclient.go create mode 100644 internal/witness/tlogproto.go create mode 100644 internal/witness/tlogproto_test.go create mode 100644 tlogcmd.go create mode 100644 witness/tlog.go 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 +}