From 504f6b4d2d438c990a202a2c965369d8b789a6ba Mon Sep 17 00:00:00 2001 From: Martin Hutchinson Date: Mon, 19 Oct 2026 16:23:13 +0000 Subject: [PATCH] feat(vindex): add wire codecs and metrics leaf packages --- .gitignore | 4 + go.mod | 62 +- go.sum | 210 +------ vindex/internal/metrics/metrics.go | 192 ++++++ vindex/internal/metrics/metrics_test.go | 135 +++++ vindex/internal/wire/policy.go | 417 +++++++++++++ vindex/internal/wire/wire.go | 748 ++++++++++++++++++++++++ vindex/internal/wire/wire_test.go | 725 +++++++++++++++++++++++ 8 files changed, 2229 insertions(+), 264 deletions(-) create mode 100644 vindex/internal/metrics/metrics.go create mode 100644 vindex/internal/metrics/metrics_test.go create mode 100644 vindex/internal/wire/policy.go create mode 100644 vindex/internal/wire/wire.go create mode 100644 vindex/internal/wire/wire_test.go diff --git a/.gitignore b/.gitignore index 4711883c..c6fc0948 100644 --- a/.gitignore +++ b/.gitignore @@ -6,8 +6,12 @@ /logandmap /sumdbindex /sumdbverify +/vindexd # Go test binaries and coverage/profile outputs *.test *.out *.prof + +# WebAssembly binaries +*.wasm diff --git a/go.mod b/go.mod index 00482ce7..a633f23b 100644 --- a/go.mod +++ b/go.mod @@ -5,78 +5,24 @@ go 1.27.0 toolchain go1.27.3 require ( - filippo.io/sunlight v0.10.2 - filippo.io/torchwood v0.10.1-0.20261024135344-1c05157b1fd2 - github.com/cockroachdb/pebble v1.1.5 - github.com/go-git/go-git/v5 v5.19.3 - github.com/google/go-cmp v0.7.0 - github.com/gorilla/mux v1.8.1 github.com/prometheus/client_golang v1.25.0 - github.com/transparency-dev/formats v0.1.1 + github.com/transparency-dev/formats v0.1.2-0.20261027211732-55fe9bf0fa53 github.com/transparency-dev/merkle v0.0.2 - github.com/transparency-dev/tessera v1.0.4 - go.opentelemetry.io/otel v1.47.0 - go.opentelemetry.io/otel/exporters/prometheus v0.69.0 - go.opentelemetry.io/otel/metric v1.47.0 - go.opentelemetry.io/otel/sdk/metric v1.47.0 - golang.org/x/crypto v0.58.0 golang.org/x/mod v0.42.0 - golang.org/x/net v0.62.0 - golang.org/x/sync v0.24.0 k8s.io/klog/v2 v2.140.0 ) require ( - dario.cat/mergo v1.0.0 // indirect - filippo.io/mldsa v0.0.0-20260711112038-ff3f469cee29 // indirect - github.com/DataDog/zstd v1.4.5 // indirect - github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/ProtonMail/go-crypto v1.1.6 // indirect + filippo.io/mldsa v1.0.0 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/cloudflare/circl v1.6.3 // indirect - github.com/cockroachdb/errors v1.11.3 // indirect - github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce // indirect - github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b // indirect - github.com/cockroachdb/redact v1.1.5 // indirect - github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 // indirect - github.com/cyphar/filepath-securejoin v0.6.1 // indirect - github.com/emirpasic/gods v1.18.1 // indirect - github.com/getsentry/sentry-go v0.27.0 // indirect - github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect - github.com/go-git/go-billy/v5 v5.9.2 // indirect github.com/go-logr/logr v1.4.4 // indirect - github.com/go-logr/stdr v1.2.2 // indirect - github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect - github.com/golang/snappy v0.0.4 // indirect - github.com/google/certificate-transparency-go v1.3.3 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect - github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect - github.com/kevinburke/ssh_config v1.2.0 // indirect - github.com/klauspost/compress v1.20.1 // indirect - github.com/kr/pretty v0.3.1 // indirect - github.com/kr/text v0.2.0 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/pjbgf/sha1cd v0.7.0 // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/prometheus/client_model v0.6.3 // indirect github.com/prometheus/common v0.72.0 // indirect - github.com/prometheus/otlptranslator v1.0.0 // indirect github.com/prometheus/procfs v0.22.0 // indirect - github.com/rogpeppe/go-internal v1.15.0 // indirect - github.com/sergi/go-diff v1.4.0 // indirect - github.com/skeema/knownhosts v1.3.1 // indirect - github.com/xanzy/ssh-agent v0.3.3 // indirect - go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/otel/log v1.47.0 // indirect - go.opentelemetry.io/otel/sdk v1.47.0 // indirect - go.opentelemetry.io/otel/trace v1.47.0 // indirect - golang.org/x/exp v0.0.0-20260603202125-055de637280b // indirect + golang.org/x/crypto v0.58.0 // indirect golang.org/x/sys v0.49.0 // indirect - golang.org/x/text v0.43.0 // indirect google.golang.org/protobuf v1.36.12 // indirect - gopkg.in/warnings.v0 v0.1.2 // indirect ) diff --git a/go.sum b/go.sum index a70b97fb..57a04acf 100644 --- a/go.sum +++ b/go.sum @@ -1,246 +1,44 @@ -dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= -dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= -filippo.io/mldsa v0.0.0-20260711112038-ff3f469cee29 h1:11cJmDX5FvZu/znhVjfwxs9jZcos6d8O6Bc9Fpdv87Y= -filippo.io/mldsa v0.0.0-20260711112038-ff3f469cee29/go.mod h1:32qQ5yj3R24Eu03iWFWchdC3OB653wPvoepWejkefbY= -filippo.io/sunlight v0.9.0 h1:YQnOlIjRuTsXzsXJdm+MMivu6OndV94iq/sMDASOn4k= -filippo.io/sunlight v0.9.0/go.mod h1:o/a7PJt6Bw0bYhWnZDC8hHAUEQShVxUgsvltrHF/KoQ= -filippo.io/sunlight v0.10.2 h1:XKjgEIlDMRxMIDpEAvH7cSHx/PCPMO5IJUgcIb3bo/A= -filippo.io/sunlight v0.10.2/go.mod h1:jMYE8Fh4L94M9vkiX4N9Q3h5WLZMjKCxSCHq8gajzeM= -filippo.io/torchwood v0.9.1-0.20260708154658-e0a6396dd50c h1:t259W9JzlCso86LB0V92ZmRJluFg1wEsiH4OonKAB5U= -filippo.io/torchwood v0.9.1-0.20260708154658-e0a6396dd50c/go.mod h1:kKEN0olmFcNGfsPWT3M46wig9CUz7/vHIAENPh9pLXc= -filippo.io/torchwood v0.10.1-0.20261024135344-1c05157b1fd2 h1:jvnFpZovtSQykQNSPLtlEmP5SClXCWn+1zCHU8jz6cY= -filippo.io/torchwood v0.10.1-0.20261024135344-1c05157b1fd2/go.mod h1:hZt8lhTBBCK77TmuK+q0D7mNcnt2ovQkJs+zdonDRog= -github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= -github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= -github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= -github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= -github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= -github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= -github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= -github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +filippo.io/mldsa v1.0.0 h1:DSyjDhIEKrGEO/RZfI60iuXI83Yzy8jDcbqMuPySK5U= +filippo.io/mldsa v1.0.0/go.mod h1:32qQ5yj3R24Eu03iWFWchdC3OB653wPvoepWejkefbY= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= -github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= -github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= -github.com/cockroachdb/datadriven v1.0.3-0.20230413201302-be42291fc80f h1:otljaYPt5hWxV3MUfO5dFPFiOXg9CyG5/kCfayTqsJ4= -github.com/cockroachdb/datadriven v1.0.3-0.20230413201302-be42291fc80f/go.mod h1:a9RdTaap04u637JoCzcUoIcDmvwSUtcUFtT/C3kJlTU= -github.com/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I= -github.com/cockroachdb/errors v1.11.3/go.mod h1:m4UIW4CDjx+R5cybPsNrRbreomiFqt8o1h1wUVazSd8= -github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce h1:giXvy4KSc/6g/esnpM7Geqxka4WSqI1SZc7sMJFd3y4= -github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce/go.mod h1:9/y3cnZ5GKakj/H4y9r9GTjCvAFta7KLgSHPJJYc52M= -github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE= -github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs= -github.com/cockroachdb/pebble v1.1.5 h1:5AAWCBWbat0uE0blr8qzufZP5tBjkRyy/jWe1QWLnvw= -github.com/cockroachdb/pebble v1.1.5/go.mod h1:17wO9el1YEigxkP/YtV8NtCivQDgoCyBg5c4VR/eOWo= -github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30= -github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= -github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo= -github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= -github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= -github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= -github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= -github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= -github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps= -github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= -github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= -github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= -github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= -github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= -github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= -github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= -github.com/go-git/go-billy/v5 v5.9.2 h1:OXFSRyz4g20upsGDJgQG9Bak1l/ZEv8GHVYB52O71sE= -github.com/go-git/go-billy/v5 v5.9.2/go.mod h1:ExsU+jcGwXTBOnyilvAnEM1wug1IxHr4yP2ZXsNRtV0= -github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= -github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= -github.com/go-git/go-git/v5 v5.19.3 h1:qHttbZ+Am7wEjdTpR1XMQ4rl42FK9SIXzZ4o9x7s6M4= -github.com/go-git/go-git/v5 v5.19.3/go.mod h1:Ye4C8dVigkqrv9UsB4NMdSLV4yAIkLiIhW61gcOyhl4= -github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= -github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= -github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= -github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/certificate-transparency-go v1.3.3 h1:hq/rSxztSkXN2tx/3jQqF6Xc0O565UQPdHrOWvZwybo= -github.com/google/certificate-transparency-go v1.3.3/go.mod h1:iR17ZgSaXRzSa5qvjFl8TnVD5h8ky2JMVio+dzoKMgA= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= -github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= -github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= -github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= -github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= -github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= -github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= -github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= -github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= -github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= -github.com/pjbgf/sha1cd v0.7.0 h1:ZRNPKHj+gfkLBf0KJv/p1Hmz+7bqCT5o311YX0nq+DA= -github.com/pjbgf/sha1cd v0.7.0/go.mod h1:pKR5Li+qTCo+ebqITZfwPlJGoCFCvSO00qxjbNtTUFQ= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.25.0 h1:cDtW1TcFsMi1RJc8khwCKB4VhzwV04aKqbYtNOXuiK0= github.com/prometheus/client_golang v1.25.0/go.mod h1:j2X/4/kaQHLPnmyEU8zjmkndo1T317VpCIQEml8LTKk= github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo= github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM= github.com/prometheus/common v0.72.0 h1:tAYsE+sPJxIncDAobm4H5aQjmox9ZxEIIqPbiffa8G4= github.com/prometheus/common v0.72.0/go.mod h1:77NWqAQ2tXT7BIK40qjJdw5Acrsrg1TlHAnsQi3i6mk= -github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= -github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics= github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= -github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= -github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= -github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= -github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= -github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= -github.com/transparency-dev/formats v0.1.1 h1:4bVHJc+KdBgpA1OJD1yjI+g0i5Z1graCppTMH8lWKJI= -github.com/transparency-dev/formats v0.1.1/go.mod h1:qtZ8goRuJ8FTBG9c9+Bj0rn2rUG7eG/AUTkr+Aw3jFw= +github.com/transparency-dev/formats v0.1.2-0.20261027211732-55fe9bf0fa53 h1:yhYk19lcG42d9r9m957D5HpDIeP+NgpuTKy/S50++y0= +github.com/transparency-dev/formats v0.1.2-0.20261027211732-55fe9bf0fa53/go.mod h1:AcGPVj62+dtCMlD/pdeG1LeUkYo0D+IHRWg1LmQazGE= github.com/transparency-dev/merkle v0.0.2 h1:Q9nBoQcZcgPamMkGn7ghV8XiTZ/kRxn1yCG81+twTK4= github.com/transparency-dev/merkle v0.0.2/go.mod h1:pqSy+OXefQ1EDUVmAJ8MUhHB9TXGuzVAT58PqBoHz1A= -github.com/transparency-dev/tessera v1.0.4 h1:IXjGowubcLR0T3zUQrysSMKgtTh9KGKQ0iajUiubyP4= -github.com/transparency-dev/tessera v1.0.4/go.mod h1:WD/EMM6RXWRyImk9yyJ2hrs8xdknN/lpwUrFR2GemfU= -github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= -github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= -go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM= -go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU= -go.opentelemetry.io/otel/exporters/prometheus v0.69.0 h1:LQVBTGnHvzrfGJpDoCSVTY7eaLZArvaTSq5l84Skxew= -go.opentelemetry.io/otel/exporters/prometheus v0.69.0/go.mod h1:moif0nlgj/M16NRT8lDZVt8MC52+EOAJfR8EKRJ8LyU= -go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI= -go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw= -go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY= -go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU= -go.opentelemetry.io/otel/metric/x v0.69.0 h1:DjRLr15H83v+hCW7JA9NoJvOkYTtmq5YoDRbe9deYpM= -go.opentelemetry.io/otel/metric/x v0.69.0/go.mod h1:uVvsMPMFFyj/HUQfrUnH3JjnOQ1dwFDorgFLRBasM0k= -go.opentelemetry.io/otel/sdk v1.47.0 h1:zWXEr4j2lFefG87TU6Yg8a7ngfohIKFZHKp0Hf5hC6I= -go.opentelemetry.io/otel/sdk v1.47.0/go.mod h1:VUc24kiOeoGsxG8G9ULx3fWKvB7jMhnGE8Oi607lgR0= -go.opentelemetry.io/otel/sdk/metric v1.47.0 h1:lfISg2j93VT6yqdk9OfUaZmw/GfcZqCCV3jdXtsPnKw= -go.opentelemetry.io/otel/sdk/metric v1.47.0/go.mod h1:ypLp+mW1Nt2x+Szt3b5/i1syodyts49lMOwxpDI3VGw= -go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrFNWSrM= -go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= -golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54= golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w= -golang.org/x/exp v0.0.0-20260603202125-055de637280b h1:v1uXiEBHo8QA0LiGCo7UgHMzHT4Kdfpl2zmtH5vaP1Q= -golang.org/x/exp v0.0.0-20260603202125-055de637280b/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg= golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= -golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= -golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY= -golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA= -golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM= -golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc= -golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI= -golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= -gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= diff --git a/vindex/internal/metrics/metrics.go b/vindex/internal/metrics/metrics.go new file mode 100644 index 00000000..34b23abe --- /dev/null +++ b/vindex/internal/metrics/metrics.go @@ -0,0 +1,192 @@ +// Copyright 2026 The Transparency Authors. All Rights Reserved. +// +// 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 metrics defines the Prometheus observability metrics for VIndex. +package metrics + +import ( + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" +) + +var ( + // Checkpoint Progression Gauges (Monotonic Sizes) + InputTreeSize = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "vindex_input_tree_size", + Help: "Latest discovered size of the upstream Input Log checkpoint.", + }) + + KVCommittedSize = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "vindex_kv_committed_size", + Help: "Highest leaf index + 1 durably committed to the Pebble KV store (m_kv_size).", + }) + + OutputTreeSize = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "vindex_output_tree_size", + Help: "Total committed size of the Output Log.", + }) + + ServingTreeSize = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "vindex_serving_tree_size", + Help: "Input Log size covered by the active serving state exposed to readers.", + }) + + // Stage Throughput Monotonic Counters + LeavesDownloadedTotal = promauto.NewCounter(prometheus.CounterOpts{ + Name: "vindex_leaves_downloaded_total", + Help: "Cumulative number of raw input log leaves downloaded from upstream.", + }) + + LeavesMappedTotal = promauto.NewCounter(prometheus.CounterOpts{ + Name: "vindex_leaves_mapped_total", + Help: "Cumulative number of input log leaves executed through the MapFn.", + }) + + LeavesIndexedTotal = promauto.NewCounter(prometheus.CounterOpts{ + Name: "vindex_leaves_indexed_total", + Help: "Cumulative number of input log leaves committed into Pebble inverted chunks.", + }) + + KeysMappedTotal = promauto.NewCounter(prometheus.CounterOpts{ + Name: "vindex_keys_mapped_total", + Help: "Cumulative number of search key entries produced by the MapFn.", + }) + + // Pipeline Health & Subsystem Metrics + InputFetchErrorsTotal = promauto.NewCounter(prometheus.CounterOpts{ + Name: "vindex_input_fetch_errors_total", + Help: "Total number of network or HTTP errors encountered while polling/fetching from the upstream log.", + }) + + WitnessSignaturesCount = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "vindex_witness_signatures_count", + Help: "Number of valid witness signatures attached to the latest active Output Log checkpoint.", + }) + + WitnessErrorsTotal = promauto.NewCounter(prometheus.CounterOpts{ + Name: "vindex_witness_errors_total", + Help: "Total number of failed witness signing attempts.", + }) + + TileCacheBytes = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "vindex_tile_cache_bytes", + Help: "Estimated total bytes stored in the local managed tile cache directory.", + }) + + // Ingestion Metrics + IngestionLag = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "vindex_ingestion_lag", + Help: "Difference between Input Log size and last indexed leaf index (m_kv_size).", + }) + + MapDurationSeconds = promauto.NewHistogram(prometheus.HistogramOpts{ + Name: "vindex_map_duration_seconds", + Help: "Time spent executing the WASM MapFn per leaf bundle in seconds.", + Buckets: prometheus.DefBuckets, + }) + + MapErrorsTotal = promauto.NewCounterVec(prometheus.CounterOpts{ + Name: "vindex_map_errors_total", + Help: "Rate of mapping failures labeled by policy.", + }, []string{"policy"}) + + // Indexing & Commit Metrics + IndexingLag = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "vindex_indexing_lag", + Help: "Difference between m_kv_size and Output_Size.", + }) + + WitnessWaitSeconds = promauto.NewHistogram(prometheus.HistogramOpts{ + Name: "vindex_witness_wait_seconds", + Help: "Latency waiting for remote Output Log witness signatures in seconds.", + Buckets: prometheus.DefBuckets, + }) + + MPTLockWaitSeconds = promauto.NewHistogram(prometheus.HistogramOpts{ + Name: "vindex_mpt_lock_wait_seconds", + Help: "Wait duration to acquire mpt_lock.Lock() in seconds.", + Buckets: prometheus.DefBuckets, + }) + + MPTPredictDurationSeconds = promauto.NewHistogram(prometheus.HistogramOpts{ + Name: "vindex_mpt_predict_duration_seconds", + Help: "Time spent predicting MPT root hash without lock in seconds.", + Buckets: prometheus.DefBuckets, + }) + + MPTWriteDurationSeconds = promauto.NewHistogram(prometheus.HistogramOpts{ + Name: "vindex_mpt_write_duration_seconds", + Help: "Critical section duration applying MPT mutations in seconds.", + Buckets: prometheus.DefBuckets, + }) + + OutputLogAppendDurationSeconds = promauto.NewHistogram(prometheus.HistogramOpts{ + Name: "vindex_output_log_append_duration_seconds", + Help: "Time spent appending leaf and fetching proof from Output Log in seconds.", + Buckets: prometheus.DefBuckets, + }) + + PebbleApplyDurationSeconds = promauto.NewHistogram(prometheus.HistogramOpts{ + Name: "vindex_pebble_apply_duration_seconds", + Help: "Time spent committing atomic batch to Pebble in seconds.", + Buckets: prometheus.DefBuckets, + }) + + // Lookup Metrics + LookupLatencySeconds = promauto.NewHistogram(prometheus.HistogramOpts{ + Name: "vindex_lookup_latency_seconds", + Help: "Latency of the Lookup API endpoint in seconds.", + Buckets: prometheus.DefBuckets, + }) + + LookupResultsReturned = promauto.NewHistogram(prometheus.HistogramOpts{ + Name: "vindex_lookup_results_returned", + Help: "Count of index pointers returned per lookup.", + Buckets: prometheus.ExponentialBuckets(1, 2, 16), + }) + + // Storage Metrics + MPTMMapBytes = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "vindex_mpt_mmap_bytes", + Help: "Virtual memory size of the MPT mmap allocation.", + }) + + // Independent Auditor Metrics + AuditorRootMismatchesTotal = promauto.NewCounter(prometheus.CounterOpts{ + Name: "vindex_auditor_root_mismatches_total", + Help: "Cumulative number of detected root hash mismatches between local MPT calculation and OutputLog leaf commitments.", + }) + + AuditorRootMismatch = promauto.NewGauge(prometheus.GaugeOpts{ + Name: "vindex_auditor_root_mismatch", + Help: "Current root mismatch status (0 = no mismatch / healthy, 1 = root mismatch detected).", + }) +) + +// RecordAuditorRootMismatch records a root hash mismatch event by incrementing +// AuditorRootMismatchesTotal and setting AuditorRootMismatch to 1. +func RecordAuditorRootMismatch() { + AuditorRootMismatchesTotal.Inc() + AuditorRootMismatch.Set(1) +} + +// ResetAuditorRootMismatch resets the AuditorRootMismatch gauge to 0 (healthy). +func ResetAuditorRootMismatch() { + AuditorRootMismatch.Set(0) +} + +// SetAuditorRootMismatch sets the AuditorRootMismatch gauge to val. +func SetAuditorRootMismatch(val float64) { + AuditorRootMismatch.Set(val) +} diff --git a/vindex/internal/metrics/metrics_test.go b/vindex/internal/metrics/metrics_test.go new file mode 100644 index 00000000..0e99619e --- /dev/null +++ b/vindex/internal/metrics/metrics_test.go @@ -0,0 +1,135 @@ +// Copyright 2026 The Transparency Authors. All Rights Reserved. +// +// 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 metrics + +import ( + "testing" + + "github.com/prometheus/client_golang/prometheus/testutil" +) + +func TestMetrics_RegistrationAndMutation(t *testing.T) { + // 1. Checkpoint Progression Gauges + InputTreeSize.Set(100) + if v := testutil.ToFloat64(InputTreeSize); v != 100 { + t.Fatalf("InputTreeSize = %v, want 100", v) + } + + KVCommittedSize.Set(80) + if v := testutil.ToFloat64(KVCommittedSize); v != 80 { + t.Fatalf("KVCommittedSize = %v, want 80", v) + } + + OutputTreeSize.Set(75) + if v := testutil.ToFloat64(OutputTreeSize); v != 75 { + t.Fatalf("OutputTreeSize = %v, want 75", v) + } + + ServingTreeSize.Set(70) + if v := testutil.ToFloat64(ServingTreeSize); v != 70 { + t.Fatalf("ServingTreeSize = %v, want 70", v) + } + + // 2. Stage Throughput Counters + LeavesDownloadedTotal.Add(1000) + if v := testutil.ToFloat64(LeavesDownloadedTotal); v < 1000 { + t.Fatalf("LeavesDownloadedTotal = %v, want >= 1000", v) + } + + LeavesMappedTotal.Add(500) + if v := testutil.ToFloat64(LeavesMappedTotal); v < 500 { + t.Fatalf("LeavesMappedTotal = %v, want >= 500", v) + } + + LeavesIndexedTotal.Add(400) + if v := testutil.ToFloat64(LeavesIndexedTotal); v < 400 { + t.Fatalf("LeavesIndexedTotal = %v, want >= 400", v) + } + + KeysMappedTotal.Add(1200) + if v := testutil.ToFloat64(KeysMappedTotal); v < 1200 { + t.Fatalf("KeysMappedTotal = %v, want >= 1200", v) + } + + // 3. Pipeline Health & Subsystem Metrics + InputFetchErrorsTotal.Inc() + if v := testutil.ToFloat64(InputFetchErrorsTotal); v < 1 { + t.Fatalf("InputFetchErrorsTotal = %v, want >= 1", v) + } + + WitnessSignaturesCount.Set(3) + if v := testutil.ToFloat64(WitnessSignaturesCount); v != 3 { + t.Fatalf("WitnessSignaturesCount = %v, want 3", v) + } + + WitnessErrorsTotal.Inc() + if v := testutil.ToFloat64(WitnessErrorsTotal); v < 1 { + t.Fatalf("WitnessErrorsTotal = %v, want >= 1", v) + } + + TileCacheBytes.Set(1048576) + if v := testutil.ToFloat64(TileCacheBytes); v != 1048576 { + t.Fatalf("TileCacheBytes = %v, want 1048576", v) + } + + MPTPredictDurationSeconds.Observe(0.123) + OutputLogAppendDurationSeconds.Observe(0.045) + + MapErrorsTotal.WithLabelValues("skip").Inc() + if v := testutil.ToFloat64(MapErrorsTotal.WithLabelValues("skip")); v < 1 { + t.Fatalf("MapErrorsTotal(skip) = %v, want >= 1", v) + } + + LookupResultsReturned.Observe(4) + + IngestionLag.Set(20) + if v := testutil.ToFloat64(IngestionLag); v != 20 { + t.Fatalf("IngestionLag = %v, want 20", v) + } + + IndexingLag.Set(5) + if v := testutil.ToFloat64(IndexingLag); v != 5 { + t.Fatalf("IndexingLag = %v, want 5", v) + } + + MapDurationSeconds.Observe(0.002) + WitnessWaitSeconds.Observe(0.25) + MPTLockWaitSeconds.Observe(0.001) + MPTWriteDurationSeconds.Observe(0.015) + PebbleApplyDurationSeconds.Observe(0.03) + LookupLatencySeconds.Observe(0.005) + + MPTMMapBytes.Set(65536) + if v := testutil.ToFloat64(MPTMMapBytes); v != 65536 { + t.Fatalf("MPTMMapBytes = %v, want 65536", v) + } + + ResetAuditorRootMismatch() + if v := testutil.ToFloat64(AuditorRootMismatch); v != 0 { + t.Fatalf("AuditorRootMismatch after reset = %v, want 0", v) + } + beforeMismatches := testutil.ToFloat64(AuditorRootMismatchesTotal) + RecordAuditorRootMismatch() + if v := testutil.ToFloat64(AuditorRootMismatch); v != 1 { + t.Fatalf("AuditorRootMismatch after record = %v, want 1", v) + } + if v := testutil.ToFloat64(AuditorRootMismatchesTotal); v != beforeMismatches+1 { + t.Fatalf("AuditorRootMismatchesTotal = %v, want %v", v, beforeMismatches+1) + } + SetAuditorRootMismatch(0) + if v := testutil.ToFloat64(AuditorRootMismatch); v != 0 { + t.Fatalf("AuditorRootMismatch after SetAuditorRootMismatch(0) = %v, want 0", v) + } +} diff --git a/vindex/internal/wire/policy.go b/vindex/internal/wire/policy.go new file mode 100644 index 00000000..5bfc6d13 --- /dev/null +++ b/vindex/internal/wire/policy.go @@ -0,0 +1,417 @@ +// Copyright 2026 The Transparency Authors. All Rights Reserved. +// +// 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 wire + +import ( + "bytes" + "crypto/ed25519" + "crypto/sha256" + "encoding/binary" + "errors" + "fmt" + "math" + "os" + "strings" + + f_log "github.com/transparency-dev/formats/log" + f_note "github.com/transparency-dev/formats/note" + "github.com/transparency-dev/formats/policy" + "golang.org/x/mod/sumdb/note" +) + +// Policy verifies a signed checkpoint note against a log and witness trust policy, +// returning the parsed log.Checkpoint if valid. +type Policy interface { + Verify(checkpoint []byte) (*f_log.Checkpoint, error) +} + +func validateSignedCheckpointBytes(checkpoint []byte, expectedOrigin string) error { + if len(checkpoint) == 0 { + return errors.New("empty checkpoint") + } + if bytes.ContainsRune(checkpoint, '\r') { + return errors.New("carriage return not allowed in checkpoint") + } + if !bytes.HasSuffix(checkpoint, []byte("\n")) || bytes.HasSuffix(checkpoint, []byte("\n\n")) { + return errors.New("invalid checkpoint termination") + } + cpParts := bytes.Split(checkpoint, []byte("\n\n")) + if len(cpParts) != 2 || len(cpParts[0]) == 0 || len(bytes.TrimSpace(cpParts[1])) == 0 { + return errors.New("invalid signed checkpoint structure") + } + hdr, err := ParseCheckpointHeader(cpParts[0]) + if err != nil { + return err + } + if expectedOrigin != "" && hdr.Origin != expectedOrigin { + return fmt.Errorf("checkpoint origin %q does not match expected origin %q", hdr.Origin, expectedOrigin) + } + return nil +} + +type builtPolicy struct { + origin string + logV note.Verifier + tlog policy.TLogPolicy +} + +func (p *builtPolicy) Verify(checkpoint []byte) (*f_log.Checkpoint, error) { + if err := validateSignedCheckpointBytes(checkpoint, p.origin); err != nil { + return nil, fmt.Errorf("invalid checkpoint format: %w", err) + } + cp, _, _, err := f_log.ParseCheckpoint(checkpoint, p.origin, p.logV) + if err != nil { + return nil, fmt.Errorf("failed to verify log checkpoint: %w", err) + } + if !p.tlog.Satisfied(checkpoint) { + return nil, fmt.Errorf("checkpoint does not satisfy witness quorum %q", p.tlog.Quorum) + } + return cp, nil +} + +type parsedPolicy struct { + origin string + tlog policy.TLogPolicy +} + +func (p *parsedPolicy) Verify(checkpoint []byte) (*f_log.Checkpoint, error) { + if err := validateSignedCheckpointBytes(checkpoint, p.origin); err != nil { + return nil, fmt.Errorf("invalid checkpoint format: %w", err) + } + return p.tlog.Verify(checkpoint) +} + +// ParseVerifier parses a public key verifier string or file path into a note.Verifier. +// +// Supported schemes: +// 1. Standard note / C2SP log or cosignature verifier format: +// "++" +// 2. MTC format (Merkle Tree Certificates cosigned subtree signature): +// "mtc++++" +func ParseVerifier(keyOrPath string) (note.Verifier, error) { + s := strings.TrimSpace(keyOrPath) + if s == "" { + return nil, errors.New("empty verifier key") + } + isFilePath := strings.Count(s, "+") < 2 + if !isFilePath { + if info, err := os.Stat(s); err == nil && !info.IsDir() { + isFilePath = true + } + } + if isFilePath { + data, err := os.ReadFile(s) + if err != nil { + return nil, fmt.Errorf("failed to read verifier key file %q: %w", s, err) + } + s = string(bytes.TrimSpace(data)) + if s == "" { + return nil, errors.New("empty verifier key") + } + } + + if strings.HasPrefix(s, "mtc+") { + parts := strings.SplitN(s, "+", 5) + if len(parts) != 5 { + return nil, fmt.Errorf("invalid MTC verifier %q: expected 'mtc++++'", s) + } + name := parts[1] + cosignerID := parts[2] + logID := parts[3] + pubKeyB64 := parts[4] + + pubKeyBytes, err := strictBase64.DecodeString(pubKeyB64) + if err != nil { + return nil, fmt.Errorf("failed to decode MTC public key base64: %w", err) + } + if len(pubKeyBytes) != ed25519.PublicKeySize { + return nil, fmt.Errorf("invalid MTC Ed25519 public key size %d, expected %d", len(pubKeyBytes), ed25519.PublicKeySize) + } + return NewMTCVerifier(name, ed25519.PublicKey(pubKeyBytes), cosignerID, logID) + } + + return f_note.NewVerifier(s) +} + +// ParseWitnessVerifier parses a witness cosignature or note verifier key string or file path. +func ParseWitnessVerifier(keyOrPath string) (note.Verifier, error) { + if strings.TrimSpace(keyOrPath) == "" { + return nil, errors.New("empty witness verifier key") + } + return ParseVerifier(keyOrPath) +} + +// BuildPolicy constructs a Policy enforcing log origin, primary log verifier, +// and an optional witness quorum. +func BuildPolicy(origin string, v note.Verifier, witnesses []note.Verifier, witnessThreshold int) (Policy, error) { + if v == nil { + return nil, errors.New("verifier is required") + } + effOrigin := strings.TrimSpace(origin) + if effOrigin == "" { + effOrigin = v.Name() + } + if witnessThreshold <= 0 { + return &builtPolicy{ + origin: effOrigin, + logV: v, + tlog: policy.TLogPolicy{Quorum: "none"}, + }, nil + } + + type verifierID struct { + name string + hash uint32 + } + primaryID := verifierID{name: v.Name(), hash: v.KeyHash()} + seen := make(map[verifierID]struct{}, len(witnesses)) + uniqueWitnesses := make([]note.Verifier, 0, len(witnesses)) + for _, w := range witnesses { + if w == nil { + continue + } + id := verifierID{name: w.Name(), hash: w.KeyHash()} + if id == primaryID { + return nil, errors.New("witness verifier collides with primary log verifier") + } + if _, dup := seen[id]; dup { + continue + } + seen[id] = struct{}{} + uniqueWitnesses = append(uniqueWitnesses, w) + } + if len(uniqueWitnesses) < witnessThreshold { + return nil, fmt.Errorf("insufficient distinct witness verifiers: got %d, threshold %d", len(uniqueWitnesses), witnessThreshold) + } + + polWitnesses := make([]policy.Witness, len(uniqueWitnesses)) + memberNames := make([]string, len(uniqueWitnesses)) + for i, w := range uniqueWitnesses { + wName := fmt.Sprintf("w%d", i) + polWitnesses[i] = policy.Witness{Name: wName, Verifier: w} + memberNames[i] = wName + } + const groupName = "quorum_group" + return &builtPolicy{ + origin: effOrigin, + logV: v, + tlog: policy.TLogPolicy{ + Witnesses: polWitnesses, + Groups: []policy.Group{ + { + Name: groupName, + Threshold: uint(witnessThreshold), + Members: memberNames, + }, + }, + Quorum: groupName, + }, + }, nil +} + +// ParsePolicy parses a c2sp.org/tlog-policy document into a Policy. +func ParsePolicy(b []byte) (Policy, error) { + var p policy.TLogPolicy + if err := p.Unmarshal(b); err != nil { + return nil, err + } + return &parsedPolicy{tlog: p}, nil +} + +// ParsePolicyFile reads a c2sp.org/tlog-policy file from disk and parses it into a Policy. +func ParsePolicyFile(policyPath string) (Policy, error) { + b, err := os.ReadFile(strings.TrimSpace(policyPath)) + if err != nil { + return nil, err + } + return ParsePolicy(b) +} + +// ParsePolicyOrVerifier resolves either a tlog-policy file (when policyPath is non-empty) +// or builds a policy from pubKeyOrPath, origin, and optional witness quorum parameters. +func ParsePolicyOrVerifier(policyPath, pubKeyOrPath, origin string, witnesses []note.Verifier, witnessThreshold int) (Policy, error) { + if strings.TrimSpace(policyPath) != "" { + pol, err := ParsePolicyFile(policyPath) + if err != nil { + return nil, err + } + if pp, ok := pol.(*parsedPolicy); ok { + pp.origin = strings.TrimSpace(origin) + } + return pol, nil + } + v, err := ParseVerifier(pubKeyOrPath) + if err != nil { + return nil, err + } + return BuildPolicy(origin, v, witnesses, witnessThreshold) +} + +// MTCVerifier implements note.Verifier for MTC checkpoint signatures. +type MTCVerifier struct { + name string + pubKey ed25519.PublicKey + keyHash uint32 + cosignerIDBytes []byte + logIDBytes []byte +} + +// NewMTCVerifier creates a new MTCVerifier. +func NewMTCVerifier(name string, pubKey ed25519.PublicKey, cosignerID, logID string) (*MTCVerifier, error) { + if err := ValidateInputOrigin(name); err != nil { + return nil, fmt.Errorf("invalid MTC verifier name: %w", err) + } + if len(pubKey) != ed25519.PublicKeySize { + return nil, fmt.Errorf("invalid MTC Ed25519 public key size %d, expected %d", len(pubKey), ed25519.PublicKeySize) + } + h := sha256.New() + h.Write([]byte(name)) + h.Write([]byte{0x0a, 0xff}) + h.Write([]byte("mtc-checkpoint/v1")) + sum := h.Sum(nil) + keyHash := binary.BigEndian.Uint32(sum[:4]) + + cosignerIDBytes, err := encodeRelativeOIDStr(cosignerID) + if err != nil { + return nil, fmt.Errorf("failed to encode cosigner ID: %w", err) + } + if len(cosignerIDBytes) > 255 { + return nil, fmt.Errorf("encoded cosigner ID length %d exceeds 255 bytes", len(cosignerIDBytes)) + } + + logIDBytes, err := encodeRelativeOIDStr(logID) + if err != nil { + return nil, fmt.Errorf("failed to encode log ID: %w", err) + } + if len(logIDBytes) > 255 { + return nil, fmt.Errorf("encoded log ID length %d exceeds 255 bytes", len(logIDBytes)) + } + + return &MTCVerifier{ + name: name, + pubKey: bytes.Clone(pubKey), + keyHash: keyHash, + cosignerIDBytes: cosignerIDBytes, + logIDBytes: logIDBytes, + }, nil +} + +// Name returns the verifier name. +func (v *MTCVerifier) Name() string { + return v.name +} + +// KeyHash returns the verifier key hash. +func (v *MTCVerifier) KeyHash() uint32 { + return v.keyHash +} + +// Verify verifies the signature over the checkpoint message. +func (v *MTCVerifier) Verify(msg, sig []byte) bool { + if len(v.pubKey) != ed25519.PublicKeySize || bytes.ContainsRune(msg, '\r') { + return false + } + lines := strings.Split(string(msg), "\n") + if len(lines) != 3 && (len(lines) != 4 || lines[3] != "") { + return false + } + if err := ValidateInputOrigin(lines[0]); err != nil { + return false + } + size, err := parseUint64Strict(lines[1]) + if err != nil { + return false + } + if len(lines[2]) != 44 { + return false + } + hash, err := strictBase64.DecodeString(lines[2]) + if err != nil || len(hash) != sha256.Size { + return false + } + + if len(v.cosignerIDBytes) > 255 || len(v.logIDBytes) > 255 { + return false + } + + var input []byte + input = append(input, []byte("mtc-subtree/v1\n")...) + input = append(input, 0) + input = append(input, byte(len(v.cosignerIDBytes))) + input = append(input, v.cosignerIDBytes...) + input = append(input, byte(len(v.logIDBytes))) + input = append(input, v.logIDBytes...) + + var startBytes [8]byte + binary.BigEndian.PutUint64(startBytes[:], 0) + input = append(input, startBytes[:]...) + + var endBytes [8]byte + binary.BigEndian.PutUint64(endBytes[:], size) + input = append(input, endBytes[:]...) + + input = append(input, hash...) + + return ed25519.Verify(v.pubKey, input, sig) +} + +var _ note.Verifier = (*MTCVerifier)(nil) + +func encodeRelativeOIDStr(s string) ([]byte, error) { + if s == "" { + return nil, errors.New("empty relative OID") + } + parts := strings.Split(s, ".") + components := make([]uint32, 0, len(parts)) + for _, p := range parts { + v, err := parseUint64Strict(p) + if err != nil { + return nil, err + } + if v > math.MaxUint32 { + return nil, fmt.Errorf("OID arc %q overflows uint32", p) + } + components = append(components, uint32(v)) + } + return encodeRelativeOID(components), nil +} + +func encodeRelativeOID(components []uint32) []byte { + var encoded []byte + for _, c := range components { + encoded = append(encoded, encodeBase128(c)...) + } + return encoded +} + +func encodeBase128(v uint32) []byte { + if v == 0 { + return []byte{0} + } + var chunks []byte + for v > 0 { + chunks = append(chunks, byte(v&0x7f)) + v >>= 7 + } + encoded := make([]byte, 0, len(chunks)) + for i := len(chunks) - 1; i >= 0; i-- { + b := chunks[i] + if i > 0 { + b |= 0x80 + } + encoded = append(encoded, b) + } + return encoded +} diff --git a/vindex/internal/wire/wire.go b/vindex/internal/wire/wire.go new file mode 100644 index 00000000..d641e5dd --- /dev/null +++ b/vindex/internal/wire/wire.go @@ -0,0 +1,748 @@ +// Copyright 2026 The Transparency Authors. All Rights Reserved. +// +// 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 wire implements C2SP tlog-vindex wire formatting, section parsing, +// Output Log leaf encoding, and RFC 6962 value log compact range root computation. +package wire + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "errors" + "fmt" + "math" + "math/bits" + "strconv" + "strings" + + "github.com/transparency-dev/formats/log" + f_proof "github.com/transparency-dev/formats/proof" + "github.com/transparency-dev/merkle/compact" + "github.com/transparency-dev/merkle/rfc6962" +) + +// ErrOutputLogCorrupted indicates that an Output Log leaf payload is malformed. +var ErrOutputLogCorrupted = errors.New("output log leaf format corrupted") + +var strictBase64 = base64.StdEncoding.Strict() + +// OutputLogLeaf represents a parsed 6-line c2sp.org/tlog-vindex-state@v1 Output Log entry. +type OutputLogLeaf struct { + InputOrigin string + From uint64 + InputSize uint64 + InputRoot [sha256.Size]byte + MPTRoot [sha256.Size]byte + InCP *log.Checkpoint +} + +// KeyValueRoot represents a canonical key and its 32-byte value log root yielded by a prefix scan or point lookup. +type KeyValueRoot struct { + Key string + ValueRoot [sha256.Size]byte +} + +// PrefixKeyVal is an alias for KeyValueRoot. +type PrefixKeyVal = KeyValueRoot + +// KeysSection represents a parsed c2sp.org/tlog-vindex-keys@v1 stanza. +type KeysSection struct { + MPTProof []byte + Matches []KeyValueRoot +} + +// ValidateCanonicalKey checks that key is between 1 and 1024 printable ASCII bytes (0x21..0x7E), +// containing no NUL (0x00) bytes or ASCII whitespace. +func ValidateCanonicalKey(key []byte) error { + if len(key) == 0 || len(key) > 1024 { + return fmt.Errorf("invalid canonical key length %d: must be 1..1024 bytes", len(key)) + } + for _, b := range key { + if b < 0x21 || b > 0x7E { + return fmt.Errorf("invalid canonical key byte 0x%02x: must be printable ASCII 0x21..0x7E", b) + } + } + return nil +} + +// ValidateInputOrigin checks that origin is a non-empty printable ASCII string in 0x20..0x7E +// with no leading or trailing spaces. +func ValidateInputOrigin(origin string) error { + if origin == "" { + return errors.New("empty input-origin") + } + if origin[0] == ' ' || origin[len(origin)-1] == ' ' { + return fmt.Errorf("input-origin %q must not have leading or trailing spaces", origin) + } + for i := 0; i < len(origin); i++ { + if origin[i] < 0x20 || origin[i] > 0x7E { + return fmt.Errorf("invalid byte 0x%02x in input-origin %q: must be printable ASCII 0x20..0x7E", origin[i], origin) + } + } + return nil +} + +// PercentEncodeCanonicalKey percent-encodes any byte outside the RFC 3986 unreserved set +// (ALPHA, DIGIT, '-', '.', '_', '~'), escaping '+' as "%2B" and query delimiters. +func PercentEncodeCanonicalKey(key string) string { + const hexUpper = "0123456789ABCDEF" + var sb strings.Builder + sb.Grow(len(key)) + for i := 0; i < len(key); i++ { + b := key[i] + if (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') || (b >= '0' && b <= '9') || + b == '-' || b == '.' || b == '_' || b == '~' { + sb.WriteByte(b) + } else { + sb.WriteByte('%') + sb.WriteByte(hexUpper[b>>4]) + sb.WriteByte(hexUpper[b&0x0F]) + } + } + return sb.String() +} + +// BindingSection represents a parsed c2sp.org/tlog-proof@v1 stanza and trailing Output Log checkpoint. +type BindingSection struct { + InputOrigin string + From uint64 + InputSize uint64 + InputRoot [sha256.Size]byte + MPTRoot [sha256.Size]byte + OutLeafIdx uint64 + InclusionProof [][]byte + OutSignedCP []byte + RawOutLeaf []byte + RawProofAndCP []byte +} + +// ValuesSection represents a parsed c2sp.org/tlog-vindex-values@v1 response. +type ValuesSection struct { + Preceding uint64 + CompactRange [][sha256.Size]byte + Indices []uint64 +} + +func parseUint64Strict(s string) (uint64, error) { + if s == "" { + return 0, errors.New("empty integer") + } + if len(s) > 1 && s[0] == '0' { + return 0, fmt.Errorf("leading zeroes not allowed in %q", s) + } + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return 0, fmt.Errorf("invalid decimal character in %q", s) + } + } + return strconv.ParseUint(s, 10, 64) +} + +// ParseCheckpointHeader parses a raw signed-note / tlog-checkpoint header extracting Origin, Size, and Hash. +func ParseCheckpointHeader(rawCP []byte) (*log.Checkpoint, error) { + if bytes.ContainsRune(rawCP, '\r') { + return nil, errors.New("carriage return not allowed in checkpoint") + } + lines := bytes.Split(rawCP, []byte("\n")) + if len(lines) < 3 { + return nil, fmt.Errorf("invalid checkpoint format: expected at least 3 lines, got %d", len(lines)) + } + origin := string(lines[0]) + if err := ValidateInputOrigin(origin); err != nil { + return nil, fmt.Errorf("invalid checkpoint origin %q: %w", origin, err) + } + size, err := parseUint64Strict(string(lines[1])) + if err != nil { + return nil, fmt.Errorf("failed to parse checkpoint size %q: %w", string(lines[1]), err) + } + hashStr := string(lines[2]) + if len(hashStr) != 44 { + return nil, fmt.Errorf("failed to decode 32-byte checkpoint root hash %q: invalid base64 length %d", hashStr, len(hashStr)) + } + hashBytes, err := strictBase64.DecodeString(hashStr) + if err != nil || len(hashBytes) != 32 { + return nil, fmt.Errorf("failed to decode 32-byte checkpoint root hash %q", hashStr) + } + return &log.Checkpoint{ + Origin: origin, + Size: size, + Hash: hashBytes, + }, nil +} + +// ParseOutputLogLeafFull parses a raw 6-line Output Log entry payload (c2sp.org/tlog-vindex-state@v1). +func ParseOutputLogLeafFull(leafData []byte) (*OutputLogLeaf, error) { + if len(leafData) == 0 { + return nil, fmt.Errorf("%w: empty leaf data", ErrOutputLogCorrupted) + } + if bytes.ContainsRune(leafData, '\r') { + return nil, fmt.Errorf("%w: carriage return not allowed in leaf", ErrOutputLogCorrupted) + } + if leafData[len(leafData)-1] != '\n' { + return nil, fmt.Errorf("%w: leaf must end with a newline", ErrOutputLogCorrupted) + } + lines := bytes.Split(leafData[:len(leafData)-1], []byte("\n")) + if len(lines) != 6 { + return nil, fmt.Errorf("%w: expected exactly 6 lines in tlog-vindex-state@v1, got %d", ErrOutputLogCorrupted, len(lines)) + } + if string(lines[0]) != "c2sp.org/tlog-vindex-state@v1" { + return nil, fmt.Errorf("%w: invalid leaf header %q", ErrOutputLogCorrupted, string(lines[0])) + } + if !bytes.HasPrefix(lines[1], []byte("input-origin ")) { + return nil, fmt.Errorf("%w: expected 'input-origin ' line, got %q", ErrOutputLogCorrupted, string(lines[1])) + } + inputOrigin := string(lines[1][len("input-origin "):]) + if err := ValidateInputOrigin(inputOrigin); err != nil { + return nil, fmt.Errorf("%w: %w", ErrOutputLogCorrupted, err) + } + + if !bytes.HasPrefix(lines[2], []byte("from ")) { + return nil, fmt.Errorf("%w: expected 'from ' line, got %q", ErrOutputLogCorrupted, string(lines[2])) + } + from, err := parseUint64Strict(string(lines[2][len("from "):])) + if err != nil { + return nil, fmt.Errorf("%w: invalid from index: %w", ErrOutputLogCorrupted, err) + } + + if !bytes.HasPrefix(lines[3], []byte("input-size ")) { + return nil, fmt.Errorf("%w: expected 'input-size ' line, got %q", ErrOutputLogCorrupted, string(lines[3])) + } + inputSize, err := parseUint64Strict(string(lines[3][len("input-size "):])) + if err != nil { + return nil, fmt.Errorf("%w: invalid input-size: %w", ErrOutputLogCorrupted, err) + } + if from > inputSize { + return nil, fmt.Errorf("%w: from (%d) exceeds input-size (%d)", ErrOutputLogCorrupted, from, inputSize) + } + + if !bytes.HasPrefix(lines[4], []byte("input-root ")) { + return nil, fmt.Errorf("%w: expected 'input-root ' line, got %q", ErrOutputLogCorrupted, string(lines[4])) + } + inRootB64 := string(lines[4][len("input-root "):]) + if len(inRootB64) != 44 { + return nil, fmt.Errorf("%w: invalid input-root base64 length %d", ErrOutputLogCorrupted, len(inRootB64)) + } + inRootBytes, err := strictBase64.DecodeString(inRootB64) + if err != nil || len(inRootBytes) != sha256.Size { + return nil, fmt.Errorf("%w: invalid input-root base64 %q: %v", ErrOutputLogCorrupted, inRootB64, err) + } + var inputRoot [sha256.Size]byte + copy(inputRoot[:], inRootBytes) + + if !bytes.HasPrefix(lines[5], []byte("mpt-root ")) { + return nil, fmt.Errorf("%w: expected 'mpt-root ' line, got %q", ErrOutputLogCorrupted, string(lines[5])) + } + mptRootB64 := string(lines[5][len("mpt-root "):]) + if len(mptRootB64) != 44 { + return nil, fmt.Errorf("%w: invalid mpt-root base64 length %d", ErrOutputLogCorrupted, len(mptRootB64)) + } + mptRootBytes, err := strictBase64.DecodeString(mptRootB64) + if err != nil || len(mptRootBytes) != sha256.Size { + return nil, fmt.Errorf("%w: invalid mpt-root base64 %q: %v", ErrOutputLogCorrupted, mptRootB64, err) + } + var mptRoot [sha256.Size]byte + copy(mptRoot[:], mptRootBytes) + + return &OutputLogLeaf{ + InputOrigin: inputOrigin, + From: from, + InputSize: inputSize, + InputRoot: inputRoot, + MPTRoot: mptRoot, + InCP: &log.Checkpoint{ + Origin: inputOrigin, + Size: inputSize, + Hash: bytes.Clone(inputRoot[:]), + }, + }, nil +} + +// ParseOutputLogLeaf parses a raw Output Log leaf payload into the 32-byte MPTRoot +// and the unsigned Input Log checkpoint header (Origin, Size, Hash). +func ParseOutputLogLeaf(leafData []byte) (mptRoot [sha256.Size]byte, inCP *log.Checkpoint, err error) { + leaf, err := ParseOutputLogLeafFull(leafData) + if err != nil { + return [sha256.Size]byte{}, nil, err + } + return leaf.MPTRoot, leaf.InCP, nil +} + +// FormatStateEntry formats a 6-line Output Log state commitment entry according to c2sp.org/tlog-vindex-state@v1. +func FormatStateEntry(inputOrigin string, from, inputSize uint64, inputRoot, mptRoot [sha256.Size]byte) []byte { + var buf bytes.Buffer + buf.WriteString("c2sp.org/tlog-vindex-state@v1\n") + buf.WriteString("input-origin ") + buf.WriteString(inputOrigin) + buf.WriteByte('\n') + buf.WriteString("from ") + buf.WriteString(strconv.FormatUint(from, 10)) + buf.WriteByte('\n') + buf.WriteString("input-size ") + buf.WriteString(strconv.FormatUint(inputSize, 10)) + buf.WriteByte('\n') + buf.WriteString("input-root ") + buf.WriteString(base64.StdEncoding.EncodeToString(inputRoot[:])) + buf.WriteByte('\n') + buf.WriteString("mpt-root ") + buf.WriteString(base64.StdEncoding.EncodeToString(mptRoot[:])) + buf.WriteByte('\n') + return buf.Bytes() +} + +// FormatOutputLogLeafFull formats a 6-line Output Log leaf from an Input Log checkpoint header, start index from, and MPTRoot. +func FormatOutputLogLeafFull(mptRoot [sha256.Size]byte, from uint64, inCP *log.Checkpoint) []byte { + var origin string + var size uint64 + var inRoot [sha256.Size]byte + if inCP != nil { + origin = inCP.Origin + size = inCP.Size + copy(inRoot[:], inCP.Hash) + } + return FormatStateEntry(origin, from, size, inRoot, mptRoot) +} + +// FormatOutputLogLeaf formats a 6-line Output Log leaf with from=0. +func FormatOutputLogLeaf(mptRoot [sha256.Size]byte, inCP *log.Checkpoint) []byte { + return FormatOutputLogLeafFull(mptRoot, 0, inCP) +} + +// FormatKeysSection formats the c2sp.org/tlog-vindex-keys@v1 stanza. +func FormatKeysSection(mptProof []byte, mode string, matches []KeyValueRoot) []byte { + _ = mode + var buf bytes.Buffer + buf.WriteString("c2sp.org/tlog-vindex-keys@v1\n") + if len(mptProof) > 0 { + buf.WriteString("proof ") + buf.WriteString(base64.StdEncoding.EncodeToString(mptProof)) + buf.WriteByte('\n') + } else { + buf.WriteString("proof\n") + } + for _, m := range matches { + buf.WriteString(m.Key) + buf.WriteByte(' ') + buf.WriteString(base64.StdEncoding.EncodeToString(m.ValueRoot[:])) + buf.WriteByte('\n') + } + return buf.Bytes() +} + +// ParseKeysSection parses a c2sp.org/tlog-vindex-keys@v1 stanza. +func ParseKeysSection(section []byte) (*KeysSection, error) { + if len(section) == 0 { + return nil, errors.New("empty keys section") + } + if bytes.ContainsRune(section, '\r') { + return nil, errors.New("carriage return not allowed in keys section") + } + trimmed := bytes.TrimSuffix(section, []byte("\n")) + if len(trimmed) == 0 || bytes.HasSuffix(trimmed, []byte("\n")) { + return nil, errors.New("invalid trailing newlines in keys section") + } + lines := strings.Split(string(trimmed), "\n") + if len(lines) < 2 || lines[0] != "c2sp.org/tlog-vindex-keys@v1" { + return nil, fmt.Errorf("invalid keys section header") + } + var mptProof []byte + if lines[1] == "proof" { + mptProof = nil + } else if strings.HasPrefix(lines[1], "proof ") { + b64 := lines[1][len("proof "):] + if b64 == "" { + return nil, errors.New("empty proof base64 after trailing space not allowed; use bare 'proof'") + } + decoded, err := strictBase64.DecodeString(b64) + if err != nil { + return nil, fmt.Errorf("invalid base64 in proof line: %w", err) + } + if len(decoded) == 0 { + return nil, errors.New("empty proof base64 not allowed; use bare 'proof'") + } + mptProof = decoded + } else { + return nil, fmt.Errorf("missing proof line in keys section: got %q", lines[1]) + } + + var matches []KeyValueRoot + for idx := 2; idx < len(lines); idx++ { + line := lines[idx] + spaceIdx := strings.IndexByte(line, ' ') + if spaceIdx <= 0 || strings.LastIndexByte(line, ' ') != spaceIdx { + return nil, fmt.Errorf("invalid key-value-root line %q", line) + } + key := line[:spaceIdx] + if err := ValidateCanonicalKey([]byte(key)); err != nil { + return nil, fmt.Errorf("invalid key in line %q: %w", line, err) + } + valB64 := line[spaceIdx+1:] + if len(valB64) != 44 { + return nil, fmt.Errorf("invalid value root base64 length in line %q", line) + } + valBytes, err := strictBase64.DecodeString(valB64) + if err != nil || len(valBytes) != sha256.Size { + return nil, fmt.Errorf("invalid value root base64 in line %q", line) + } + var valueRoot [sha256.Size]byte + copy(valueRoot[:], valBytes) + matches = append(matches, KeyValueRoot{Key: key, ValueRoot: valueRoot}) + } + + for i := 1; i < len(matches); i++ { + if matches[i].Key <= matches[i-1].Key { + return nil, fmt.Errorf("keys not in strictly ascending order: %q vs %q", matches[i-1].Key, matches[i].Key) + } + } + + return &KeysSection{ + MPTProof: mptProof, + Matches: matches, + }, nil +} + +// FormatBindingSection formats the c2sp.org/tlog-proof@v1 stanza and trailing Output Log checkpoint. +func FormatBindingSection(outLeafRaw []byte, leafIdx uint64, inclusionProof [][]byte, outSignedCP []byte) []byte { + hashes := make([][sha256.Size]byte, len(inclusionProof)) + for i, p := range inclusionProof { + copy(hashes[i][:], p) + } + trimmed := bytes.TrimRight(outSignedCP, "\n") + cp := make([]byte, len(trimmed)+1) + copy(cp, trimmed) + cp[len(trimmed)] = '\n' + return (f_proof.TLogProof{ + ExtraData: outLeafRaw, + Index: leafIdx, + Hashes: hashes, + Checkpoint: cp, + }).Marshal() +} + +// ParseBindingSection parses the c2sp.org/tlog-proof@v1 stanza and trailing Output Log checkpoint. +func ParseBindingSection(section []byte) (*BindingSection, error) { + if len(section) == 0 { + return nil, errors.New("empty binding section") + } + if bytes.ContainsRune(section, '\r') { + return nil, errors.New("carriage return not allowed in binding section") + } + if !bytes.HasSuffix(section, []byte("\n")) || bytes.HasSuffix(section, []byte("\n\n")) { + return nil, errors.New("invalid binding section termination") + } + parts := bytes.SplitN(section, []byte("\n\n"), 2) + if len(parts) != 2 || len(parts[0]) == 0 || len(parts[1]) == 0 { + return nil, errors.New("binding section must contain tlog-proof stanza and output log checkpoint") + } + proofLines := strings.Split(string(parts[0]), "\n") + if len(proofLines) < 3 { + return nil, errors.New("tlog-proof stanza requires at least 3 lines") + } + if len(proofLines)-3 > 63 { + return nil, fmt.Errorf("too many audit path hashes in tlog-proof: got %d, max 63", len(proofLines)-3) + } + if proofLines[0] != "c2sp.org/tlog-proof@v1" { + return nil, fmt.Errorf("invalid tlog-proof header %q", proofLines[0]) + } + if !strings.HasPrefix(proofLines[1], "extra ") { + return nil, fmt.Errorf("expected 'extra ' line in tlog-proof, got %q", proofLines[1]) + } + extraB64 := proofLines[1][len("extra "):] + if extraB64 == "" { + return nil, errors.New("empty extra field in tlog-proof") + } + if _, err := strictBase64.DecodeString(extraB64); err != nil { + return nil, fmt.Errorf("invalid base64 in extra field: %w", err) + } + if !strings.HasPrefix(proofLines[2], "index ") { + return nil, fmt.Errorf("expected 'index ' line in tlog-proof, got %q", proofLines[2]) + } + if _, err := parseUint64Strict(proofLines[2][len("index "):]); err != nil { + return nil, fmt.Errorf("invalid index in tlog-proof: %w", err) + } + for i := 3; i < len(proofLines); i++ { + hB64 := proofLines[i] + if len(hB64) != 44 { + return nil, fmt.Errorf("invalid audit path hash length in line %d", i) + } + if _, err := strictBase64.DecodeString(hB64); err != nil { + return nil, fmt.Errorf("invalid audit path hash in line %d", i) + } + } + + var tp f_proof.TLogProof + if err := tp.Unmarshal(section); err != nil { + return nil, err + } + if len(tp.ExtraData) == 0 { + return nil, errors.New("empty extra field in tlog-proof") + } + outLeaf, err := ParseOutputLogLeafFull(tp.ExtraData) + if err != nil { + return nil, fmt.Errorf("invalid output log leaf in extra field: %w", err) + } + + inclusionProof := make([][]byte, len(tp.Hashes)) + for i := range tp.Hashes { + hCopy := make([]byte, sha256.Size) + copy(hCopy, tp.Hashes[i][:]) + inclusionProof[i] = hCopy + } + + outSignedCP := tp.Checkpoint + if !bytes.HasSuffix(outSignedCP, []byte("\n")) || bytes.HasSuffix(outSignedCP, []byte("\n\n")) { + return nil, errors.New("invalid output log checkpoint termination") + } + cpParts := bytes.Split(outSignedCP, []byte("\n\n")) + if len(cpParts) != 2 || len(cpParts[0]) == 0 || len(bytes.TrimSpace(cpParts[1])) == 0 { + return nil, errors.New("invalid output log checkpoint structure") + } + if _, err := ParseCheckpointHeader(cpParts[0]); err != nil { + return nil, fmt.Errorf("invalid output log checkpoint header: %w", err) + } + + return &BindingSection{ + InputOrigin: outLeaf.InputOrigin, + From: outLeaf.From, + InputSize: outLeaf.InputSize, + InputRoot: outLeaf.InputRoot, + MPTRoot: outLeaf.MPTRoot, + OutLeafIdx: tp.Index, + InclusionProof: inclusionProof, + OutSignedCP: outSignedCP, + RawOutLeaf: tp.ExtraData, + RawProofAndCP: bytes.Clone(section), + }, nil +} + +// FormatKeysResponseFull builds the plain-text C2SP response for GET /keys?key= or ?prefix=. +func FormatKeysResponseFull(rawCheckpoint []byte, outputLogIndex uint64, mptRoot [sha256.Size]byte, from uint64, inCP *log.Checkpoint, outputLogProof [][sha256.Size]byte, mode string, mptProof []byte, matches []KeyValueRoot) []byte { + keysSec := FormatKeysSection(mptProof, mode, matches) + outLeafRaw := FormatOutputLogLeafFull(mptRoot, from, inCP) + proofBytes := make([][]byte, len(outputLogProof)) + for i := range outputLogProof { + proofBytes[i] = outputLogProof[i][:] + } + bindSec := FormatBindingSection(outLeafRaw, outputLogIndex, proofBytes, rawCheckpoint) + + var sb bytes.Buffer + sb.Write(keysSec) + sb.WriteByte('\n') + sb.Write(bindSec) + return sb.Bytes() +} + +// FormatKeysResponse builds the plain-text C2SP response for GET /keys?key= or ?prefix= +// with default from=0. +func FormatKeysResponse(rawCheckpoint []byte, outputLogIndex uint64, mptRoot [sha256.Size]byte, inCP *log.Checkpoint, outputLogProof [][sha256.Size]byte, mode string, mptProof []byte, matches []KeyValueRoot) []byte { + return FormatKeysResponseFull(rawCheckpoint, outputLogIndex, mptRoot, 0, inCP, outputLogProof, mode, mptProof, matches) +} + +// FormatValuesResponse builds the plain-text C2SP response for GET /values?key=&before=. +func FormatValuesResponse(preceding uint64, compactRange [][sha256.Size]byte, indices []uint64) []byte { + var sb bytes.Buffer + sb.WriteString("c2sp.org/tlog-vindex-values@v1\n") + sb.WriteString("preceding ") + sb.WriteString(strconv.FormatUint(preceding, 10)) + sb.WriteByte('\n') + for _, h := range compactRange { + sb.WriteString(base64.StdEncoding.EncodeToString(h[:])) + sb.WriteByte('\n') + } + sb.WriteByte('\n') + for _, idx := range indices { + sb.WriteString(strconv.FormatUint(idx, 10)) + sb.WriteByte('\n') + } + return sb.Bytes() +} + +// ParseValuesResponse parses a c2sp.org/tlog-vindex-values@v1 response body. +func ParseValuesResponse(body []byte) (*ValuesSection, error) { + if len(body) == 0 { + return nil, errors.New("empty values response") + } + if bytes.ContainsRune(body, '\r') { + return nil, errors.New("carriage return not allowed in values response") + } + if !bytes.HasSuffix(body, []byte("\n")) || bytes.HasSuffix(body, []byte("\n\n")) { + return nil, errors.New("invalid values response termination") + } + parts := bytes.Split(body, []byte("\n\n")) + if len(parts) != 2 || len(parts[0]) == 0 || len(parts[1]) == 0 { + return nil, errors.New("values response must contain header block and indices block separated by a single blank line") + } + headerLines := strings.Split(string(parts[0]), "\n") + if len(headerLines) < 2 { + return nil, errors.New("values header block requires at least 2 lines") + } + if headerLines[0] != "c2sp.org/tlog-vindex-values@v1" { + return nil, fmt.Errorf("invalid values header %q", headerLines[0]) + } + if !strings.HasPrefix(headerLines[1], "preceding ") { + return nil, fmt.Errorf("expected 'preceding ' line, got %q", headerLines[1]) + } + preceding, err := parseUint64Strict(headerLines[1][len("preceding "):]) + if err != nil { + return nil, fmt.Errorf("invalid preceding size: %w", err) + } + var compactRange [][sha256.Size]byte + for i := 2; i < len(headerLines); i++ { + hB64 := headerLines[i] + if len(hB64) != 44 { + return nil, fmt.Errorf("invalid compact range hash length in line %d", i) + } + hBytes, err := strictBase64.DecodeString(hB64) + if err != nil || len(hBytes) != sha256.Size { + return nil, fmt.Errorf("invalid compact range hash in line %d", i) + } + var h [sha256.Size]byte + copy(h[:], hBytes) + compactRange = append(compactRange, h) + } + if len(compactRange) != bits.OnesCount64(preceding) { + return nil, fmt.Errorf("compact range hash count %d does not match popcount(preceding=%d)=%d", len(compactRange), preceding, bits.OnesCount64(preceding)) + } + + idxBlock := strings.TrimSuffix(string(parts[1]), "\n") + if idxBlock == "" { + return nil, errors.New("values response must contain at least one index") + } + idxLines := strings.Split(idxBlock, "\n") + indices := make([]uint64, 0, len(idxLines)) + for i, line := range idxLines { + idx, err := parseUint64Strict(line) + if err != nil { + return nil, fmt.Errorf("invalid entry index %q: %w", line, err) + } + if i > 0 && idx <= indices[i-1] { + return nil, fmt.Errorf("entry indices not strictly ascending: %d followed by %d", indices[i-1], idx) + } + indices = append(indices, idx) + } + if preceding > indices[0] { + return nil, fmt.Errorf("preceding count (%d) exceeds first entry index (%d)", preceding, indices[0]) + } + return &ValuesSection{ + Preceding: preceding, + CompactRange: compactRange, + Indices: indices, + }, nil +} + +var emptyRoot = sha256.Sum256(nil) + +// EmptyRoot returns the RFC 6962 root hash of an empty tree (SHA256("")). +func EmptyRoot() [sha256.Size]byte { + return emptyRoot +} + +// LeafHash computes the RFC 6962 standard leaf hash: SHA256(0x00 || data). +// For leaves up to 511 bytes (including all 8-byte index leaves in VIndex), +// an inline stack buffer avoids heap allocations. +func LeafHash(data []byte) [sha256.Size]byte { + if len(data) <= 511 { + var b [512]byte + b[0] = 0x00 + copy(b[1:], data) + return sha256.Sum256(b[:1+len(data)]) + } + h := sha256.New() + h.Write([]byte{0x00}) + h.Write(data) + var out [sha256.Size]byte + h.Sum(out[:0]) + return out +} + +// InteriorHash computes the RFC 6962 standard interior node hash: SHA256(0x01 || left || right). +func InteriorHash(left, right [sha256.Size]byte) [sha256.Size]byte { + var b [1 + 2*sha256.Size]byte + b[0] = 0x01 + copy(b[1:], left[:]) + copy(b[1+sha256.Size:], right[:]) + return sha256.Sum256(b[:]) +} + +// ComputeValueLogRoot reconstructs the RFC 6962 value log root from a preceding compact range +// and a slice of strictly ascending uint64 leaf indices. +func ComputeValueLogRoot(compactRange [][sha256.Size]byte, preceding uint64, indices []uint64) ([sha256.Size]byte, error) { + if len(compactRange) != bits.OnesCount64(preceding) { + return [sha256.Size]byte{}, fmt.Errorf("mismatched compact range: preceding=%d (popcount=%d), hashes=%d", preceding, bits.OnesCount64(preceding), len(compactRange)) + } + if len(indices) > 0 { + if preceding > indices[0] { + return [sha256.Size]byte{}, fmt.Errorf("preceding count (%d) exceeds first entry index (%d)", preceding, indices[0]) + } + if uint64(len(indices)) > math.MaxUint64-preceding { + return [sha256.Size]byte{}, errors.New("value log leaf count overflows uint64") + } + for i := 1; i < len(indices); i++ { + if indices[i] <= indices[i-1] { + return [sha256.Size]byte{}, fmt.Errorf("entry indices not strictly ascending: %d followed by %d", indices[i-1], indices[i]) + } + } + } + if len(compactRange) == 0 && len(indices) == 0 { + return EmptyRoot(), nil + } + + rf := &compact.RangeFactory{ + Hash: func(l, r []byte) []byte { + if len(l) == sha256.Size && len(r) == sha256.Size { + var left, right [sha256.Size]byte + copy(left[:], l) + copy(right[:], r) + h := InteriorHash(left, right) + out := make([]byte, sha256.Size) + copy(out, h[:]) + return out + } + return rfc6962.DefaultHasher.HashChildren(l, r) + }, + } + + var cr *compact.Range + if preceding > 0 { + hashes := make([][]byte, len(compactRange)) + for i := range compactRange { + hashes[i] = compactRange[i][:] + } + var err error + cr, err = rf.NewRange(0, preceding, hashes) + if err != nil { + return [sha256.Size]byte{}, fmt.Errorf("invalid compact range: %w", err) + } + } else { + cr = rf.NewEmptyRange(0) + } + + for _, idx := range indices { + var b [8]byte + binary.BigEndian.PutUint64(b[:], idx) + lh := LeafHash(b[:]) + if err := cr.Append(lh[:], nil); err != nil { + return [sha256.Size]byte{}, err + } + } + + r, err := cr.GetRootHash(nil) + if err != nil { + return [sha256.Size]byte{}, err + } + var root [sha256.Size]byte + copy(root[:], r) + return root, nil +} diff --git a/vindex/internal/wire/wire_test.go b/vindex/internal/wire/wire_test.go new file mode 100644 index 00000000..6c202875 --- /dev/null +++ b/vindex/internal/wire/wire_test.go @@ -0,0 +1,725 @@ +// Copyright 2026 The Transparency Authors. All Rights Reserved. +// +// 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 wire + +import ( + "bytes" + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/binary" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/transparency-dev/formats/log" + f_note "github.com/transparency-dev/formats/note" + "github.com/transparency-dev/merkle/rfc6962" + "golang.org/x/mod/sumdb/note" +) + +func TestOutputLogLeafRoundTrip(t *testing.T) { + mptRoot := sha256.Sum256([]byte("mpt-root")) + inRoot := sha256.Sum256([]byte("in-root")) + inCP := &log.Checkpoint{ + Origin: "go.sum database tree", + Size: 42, + Hash: inRoot[:], + } + + leaf := FormatOutputLogLeafFull(mptRoot, 10, inCP) + parsed, err := ParseOutputLogLeafFull(leaf) + if err != nil { + t.Fatalf("ParseOutputLogLeafFull failed: %v", err) + } + if parsed.MPTRoot != mptRoot { + t.Errorf("mptRoot = %x, want %x", parsed.MPTRoot, mptRoot) + } + if parsed.InputOrigin != "go.sum database tree" { + t.Errorf("inputOrigin = %q, want %q", parsed.InputOrigin, "go.sum database tree") + } + if parsed.From != 10 { + t.Errorf("from = %d, want 10", parsed.From) + } + if parsed.InputSize != 42 { + t.Errorf("inputSize = %d, want 42", parsed.InputSize) + } + if parsed.InputRoot != inRoot { + t.Errorf("inputRoot = %x, want %x", parsed.InputRoot, inRoot) + } + if parsed.InCP.Origin != "go.sum database tree" || parsed.InCP.Size != 42 || !bytes.Equal(parsed.InCP.Hash, inRoot[:]) { + t.Errorf("gotCP = %+v", parsed.InCP) + } + + // Simple leaf round-trip (from=0) + simpleLeaf := FormatOutputLogLeaf(mptRoot, inCP) + gotRoot, gotCP, err := ParseOutputLogLeaf(simpleLeaf) + if err != nil { + t.Fatalf("ParseOutputLogLeaf failed: %v", err) + } + if gotRoot != mptRoot || gotCP.Size != 42 || gotCP.Origin != "go.sum database tree" || !bytes.Equal(gotCP.Hash, inRoot[:]) { + t.Errorf("simple leaf round-trip mismatch") + } + + // Reject fewer or more than 6 lines, or trailing data after 6th newline + if _, err := ParseOutputLogLeafFull(append(bytes.Clone(leaf), '\n')); err == nil { + t.Errorf("expected error for trailing newline after 6th line") + } + if _, err := ParseOutputLogLeafFull(append(bytes.Clone(leaf), []byte("extra line\n")...)); err == nil { + t.Errorf("expected error for 7th line") + } + if _, err := ParseOutputLogLeafFull(FormatStateEntry(" leading-space", 0, 10, inRoot, mptRoot)); err == nil { + t.Errorf("expected error for leading space in input-origin") + } + if _, err := ParseOutputLogLeafFull(FormatStateEntry("example.com/log", 11, 10, inRoot, mptRoot)); err == nil { + t.Errorf("expected error when from > input-size") + } + + // Reject hex-encoded checkpoint root hash (must be strict 44-char base64) + hexCP := []byte("example.com/inputlog\n42\n0101010101010101010101010101010101010101010101010101010101010101\n") + if _, err := ParseCheckpointHeader(hexCP); err == nil { + t.Errorf("expected ParseCheckpointHeader to reject 64-char hex hash, got nil") + } + + if got := PercentEncodeCanonicalKey("a+b/c?d=e&f#g~h_i-j.k"); got != "a%2Bb%2Fc%3Fd%3De%26f%23g~h_i-j.k" { + t.Errorf("PercentEncodeCanonicalKey = %q", got) + } +} + +func TestFormatAndParseSections(t *testing.T) { + mptRoot := sha256.Sum256([]byte("mpt-root")) + valueRoot := sha256.Sum256([]byte("value-root")) + inRoot := sha256.Sum256([]byte("in-root")) + rawOutCP := []byte("example.com/vindex\n1\n" + base64.StdEncoding.EncodeToString(mptRoot[:]) + "\n\n— example.com/vindex sig==\n") + inCP := &log.Checkpoint{ + Origin: "example.com/inputlog", + Size: 10, + Hash: inRoot[:], + } + + keysBody := FormatKeysResponseFull( + rawOutCP, + 0, + mptRoot, + 2, + inCP, + [][32]byte{valueRoot}, + "inclusion", + []byte("proof-bytes"), + []KeyValueRoot{{Key: "com.google.mail.", ValueRoot: valueRoot}}, + ) + + stanzas := bytes.SplitN(keysBody, []byte("\n\n"), 2) + if len(stanzas) != 2 { + t.Fatalf("expected 2 top-level sections, got %d", len(stanzas)) + } + keysSec, err := ParseKeysSection(stanzas[0]) + if err != nil { + t.Fatalf("ParseKeysSection failed: %v", err) + } + if !bytes.Equal(keysSec.MPTProof, []byte("proof-bytes")) { + t.Errorf("keysSec.MPTProof = %q, want proof-bytes", keysSec.MPTProof) + } + if len(keysSec.Matches) != 1 || keysSec.Matches[0].Key != "com.google.mail." || keysSec.Matches[0].ValueRoot != valueRoot { + t.Errorf("keysSec.Matches = %+v", keysSec.Matches) + } + + bindSec, err := ParseBindingSection(stanzas[1]) + if err != nil { + t.Fatalf("ParseBindingSection failed: %v", err) + } + if bindSec.MPTRoot != mptRoot || bindSec.InputOrigin != "example.com/inputlog" || bindSec.InputSize != 10 || bindSec.InputRoot != inRoot || bindSec.From != 2 || bindSec.OutLeafIdx != 0 { + t.Errorf("bindSec = %+v", bindSec) + } + if len(bindSec.InclusionProof) != 1 || !bytes.Equal(bindSec.InclusionProof[0], valueRoot[:]) { + t.Errorf("bindSec.InclusionProof = %+v", bindSec.InclusionProof) + } + if !bytes.Equal(bindSec.RawProofAndCP, stanzas[1]) { + t.Errorf("bindSec.RawProofAndCP = %q, want %q", bindSec.RawProofAndCP, stanzas[1]) + } + + // Test bare 'proof' line when mptProof is empty + emptyProofSec := FormatKeysSection(nil, "inclusion", []KeyValueRoot{{Key: "com.google.mail.", ValueRoot: valueRoot}}) + if bytes.Contains(emptyProofSec, []byte("proof ")) || !bytes.Contains(emptyProofSec, []byte("proof\n")) { + t.Errorf("expected bare 'proof' line without trailing space when mptProof is empty, got:\n%s", string(emptyProofSec)) + } + if _, err := ParseKeysSection(emptyProofSec); err != nil { + t.Fatalf("ParseKeysSection(emptyProofSec) failed: %v", err) + } + if _, err := ParseKeysSection([]byte("c2sp.org/tlog-vindex-keys@v1\nproof \n")); err == nil { + t.Errorf("expected error for 'proof ' with trailing space and empty base64") + } + + valuesBody := FormatValuesResponse(1, [][32]byte{valueRoot}, []uint64{5, 8}) + valSec, err := ParseValuesResponse(valuesBody) + if err != nil { + t.Fatalf("ParseValuesResponse failed: %v", err) + } + if valSec.Preceding != 1 || len(valSec.CompactRange) != 1 || valSec.CompactRange[0] != valueRoot { + t.Errorf("valSec header = %+v", valSec) + } + if len(valSec.Indices) != 2 || valSec.Indices[0] != 5 || valSec.Indices[1] != 8 { + t.Errorf("valSec.Indices = %v, want [5 8]", valSec.Indices) + } +} + +func TestLeafAndInteriorHashMatchRFC6962(t *testing.T) { + for _, sz := range []int{0, 8, 64, 511, 512, 1024} { + data := bytes.Repeat([]byte{0xAB}, sz) + got := LeafHash(data) + want := rfc6962.DefaultHasher.HashLeaf(data) + if !bytes.Equal(got[:], want) { + t.Fatalf("LeafHash(len=%d) = %x, want %x", sz, got, want) + } + } + + l := sha256.Sum256([]byte("left")) + r := sha256.Sum256([]byte("right")) + gotInt := InteriorHash(l, r) + wantInt := rfc6962.DefaultHasher.HashChildren(l[:], r[:]) + if !bytes.Equal(gotInt[:], wantInt) { + t.Fatalf("InteriorHash = %x, want %x", gotInt, wantInt) + } + + var b0, b1 [8]byte + binary.BigEndian.PutUint64(b0[:], 10) + binary.BigEndian.PutUint64(b1[:], 20) + h0 := LeafHash(b0[:]) + root2, err := ComputeValueLogRoot(nil, 0, []uint64{10, 20}) + if err != nil { + t.Fatalf("ComputeValueLogRoot(2) failed: %v", err) + } + rootFromCompactRange, err := ComputeValueLogRoot([][32]byte{h0}, 1, []uint64{20}) + if err != nil { + t.Fatalf("ComputeValueLogRoot(compactRange+1) failed: %v", err) + } + if root2 != rootFromCompactRange { + t.Fatalf("root2 %x != rootFromCompactRange %x", root2, rootFromCompactRange) + } +} + +func TestPackageDependencyBoundaries(t *testing.T) { + cases := []struct { + pkg string + forbidden []string + }{ + { + pkg: "github.com/transparency-dev/incubator/vindex/internal/metrics", + forbidden: []string{ + "github.com/cockroachdb/pebble", + "github.com/tetratelabs/wazero", + "github.com/transparency-dev/tessera", + "filippo.io/torchwood", + "github.com/transparency-dev/incubator/vindex/internal/wire", + "github.com/transparency-dev/incubator/vindex/internal/tree", + "github.com/transparency-dev/incubator/vindex/internal/kvstore", + "github.com/transparency-dev/incubator/vindex/internal/ingest", + }, + }, + { + pkg: "github.com/transparency-dev/incubator/vindex/internal/wire", + forbidden: []string{ + "github.com/cockroachdb/pebble", + "github.com/tetratelabs/wazero", + "github.com/transparency-dev/tessera", + "filippo.io/torchwood", + "github.com/transparency-dev/incubator/vindex/internal/metrics", + "github.com/transparency-dev/incubator/vindex/internal/tree", + "github.com/transparency-dev/incubator/vindex/internal/kvstore", + "github.com/transparency-dev/incubator/vindex/internal/ingest", + }, + }, + { + pkg: "github.com/transparency-dev/incubator/vindex/client", + forbidden: []string{ + "github.com/cockroachdb/pebble", + "github.com/tetratelabs/wazero", + "github.com/transparency-dev/incubator/vindex/internal/tree", + "github.com/transparency-dev/incubator/vindex/internal/kvstore", + "github.com/transparency-dev/incubator/vindex/internal/ingest", + "github.com/transparency-dev/incubator/vindex/internal/mtc", + }, + }, + { + pkg: "github.com/transparency-dev/incubator/vindex/cmd/vindex-wasm", + forbidden: []string{ + "net/http", + "github.com/transparency-dev/tessera", + "github.com/transparency-dev/incubator/vindex/internal/ingest", + }, + }, + { + pkg: "github.com/transparency-dev/incubator/vindex/internal/mtc", + forbidden: []string{ + "github.com/transparency-dev/incubator/vindex/internal/ingest", + "github.com/transparency-dev/incubator/vindex/internal/kvstore", + "github.com/transparency-dev/incubator/vindex/internal/tree", + }, + }, + } + + modRootOut, err := exec.Command("go", "list", "-m", "-f", "{{.Dir}}").CombinedOutput() + if err != nil { + t.Fatalf("go list -m failed: %v\n%s", err, string(modRootOut)) + } + modRoot := strings.TrimSpace(string(modRootOut)) + + for _, tc := range cases { + relDir := strings.TrimPrefix(tc.pkg, "github.com/transparency-dev/incubator/") + goFiles, err := filepath.Glob(filepath.Join(modRoot, relDir, "*.go")) + if err != nil || len(goFiles) == 0 { + continue + } + out, err := exec.Command("go", "list", "-deps", tc.pkg).CombinedOutput() + if err != nil { + t.Fatalf("go list -deps %s failed: %v\n%s", tc.pkg, err, string(out)) + } + deps := strings.Split(strings.TrimSpace(string(out)), "\n") + for _, dep := range deps { + for _, bad := range tc.forbidden { + if dep == bad || strings.HasPrefix(dep, bad+"/") { + t.Errorf("package %s transitively depends on forbidden package %s (via %s)", tc.pkg, bad, dep) + } + } + } + } +} + +func TestMTCVerifier(t *testing.T) { + name := "oid/1.3.6.1.4.1.44363.47.1.44363.48.8" + pubKeyB64 := "teYkXkxVoKhT1PxKODAyZFqUk8KZ4tUjzS6yAvvZ8hU=" + cosignerID := "44363.48.9" + logID := "44363.48.8" + + pubKeyBytes, err := base64.StdEncoding.DecodeString(pubKeyB64) + if err != nil { + t.Fatalf("Failed to decode pubKey: %v", err) + } + pubKey := ed25519.PublicKey(pubKeyBytes) + + v, err := NewMTCVerifier(name, pubKey, cosignerID, logID) + if err != nil { + t.Fatalf("NewMTCVerifier failed: %v", err) + } + + if v.Name() != name { + t.Errorf("Name() = %q, want %q", v.Name(), name) + } + + wantKeyHash := uint32(0x8e6a3b1a) + if v.KeyHash() != wantKeyHash { + t.Errorf("KeyHash() = 0x%x, want 0x%x", v.KeyHash(), wantKeyHash) + } + + checkpointText := `bootstrap-mtca.cloudflareresearch.com/logs/shard3 +197762974 +7NWRnNW49lCjHOLMyMLBYRkRTGxDhVEmQhTw2gD/Pig=` + + sigB64 := "jmo7GsZLYkXSa+C4eXII6rUTN8BECzdUogRIlzML8wqeWnFuTjOjAOGrHu79pnjuZBx1syo5FYNs5sKpj2D93QEkLws=" + sigBytes, err := base64.StdEncoding.DecodeString(sigB64) + if err != nil { + t.Fatalf("Failed to decode signature: %v", err) + } + rawSig := sigBytes[4:] + + if !v.Verify([]byte(checkpointText), rawSig) { + t.Error("Verification failed") + } + + parsed, err := ParseVerifier("mtc+" + name + "+" + cosignerID + "+" + logID + "+" + pubKeyB64) + if err != nil { + t.Fatalf("ParseVerifier(mtc+...) failed: %v", err) + } + if !parsed.Verify([]byte(checkpointText), rawSig) { + t.Error("ParseVerifier MTC verification failed") + } +} + +func TestParseVerifierAndWitnessVerifier(t *testing.T) { + skey, vkey, err := note.GenerateKey(rand.Reader, "example.com/log") + if err != nil { + t.Fatalf("note.GenerateKey failed: %v", err) + } + signer, err := note.NewSigner(skey) + if err != nil { + t.Fatalf("note.NewSigner failed: %v", err) + } + + v, err := ParseVerifier(vkey) + if err != nil { + t.Fatalf("ParseVerifier(inline) failed: %v", err) + } + if v.Name() != "example.com/log" { + t.Fatalf("v.Name() = %q, want example.com/log", v.Name()) + } + + keyPath := filepath.Join(t.TempDir(), "log.pub") + if err := os.WriteFile(keyPath, []byte(vkey+"\n"), 0o600); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + vFromFile, err := ParseVerifier(keyPath) + if err != nil { + t.Fatalf("ParseVerifier(file) failed: %v", err) + } + if vFromFile.KeyHash() != v.KeyHash() { + t.Fatalf("KeyHash mismatch: %x != %x", vFromFile.KeyHash(), v.KeyHash()) + } + + // Cosignature v1 witness key + cosigSKey, cosigRawVKey, err := note.GenerateKey(rand.Reader, "witness.example/w1") + if err != nil { + t.Fatalf("note.GenerateKey failed: %v", err) + } + cosigSigner, err := f_note.NewSignerForCosignatureV1(cosigSKey) + if err != nil { + t.Fatalf("NewSignerForCosignatureV1 failed: %v", err) + } + cosigVKey, err := f_note.VKeyToCosignatureV1(cosigRawVKey) + if err != nil { + t.Fatalf("VKeyToCosignatureV1 failed: %v", err) + } + wv, err := ParseWitnessVerifier(cosigVKey) + if err != nil { + t.Fatalf("ParseWitnessVerifier(cosig) failed: %v", err) + } + + root := sha256.Sum256([]byte("tree-root")) + cpText := fmt.Sprintf("example.com/log\n5\n%s\n", base64.StdEncoding.EncodeToString(root[:])) + signed, err := note.Sign(¬e.Note{Text: cpText}, signer, cosigSigner) + if err != nil { + t.Fatalf("note.Sign failed: %v", err) + } + + pol, err := BuildPolicy("example.com/log", v, []note.Verifier{wv}, 1) + if err != nil { + t.Fatalf("BuildPolicy failed: %v", err) + } + cp, err := pol.Verify(signed) + if err != nil { + t.Fatalf("pol.Verify with cosignature witness failed: %v", err) + } + if cp.Size != 5 || cp.Origin != "example.com/log" { + t.Fatalf("unexpected verified checkpoint: %+v", cp) + } + + // Fallback witness verifier via standard note key file + witSKey, witVKey, err := note.GenerateKey(rand.Reader, "witness.example/note") + if err != nil { + t.Fatalf("GenerateKey failed: %v", err) + } + witSigner, err := note.NewSigner(witSKey) + if err != nil { + t.Fatalf("NewSigner failed: %v", err) + } + witPath := filepath.Join(t.TempDir(), "wit.pub") + if err := os.WriteFile(witPath, []byte(witVKey+"\n"), 0o600); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + wvNote, err := ParseWitnessVerifier(witPath) + if err != nil { + t.Fatalf("ParseWitnessVerifier(file) failed: %v", err) + } + signed2, err := note.Sign(¬e.Note{Text: cpText}, signer, witSigner) + if err != nil { + t.Fatalf("note.Sign failed: %v", err) + } + pol2, err := ParsePolicyOrVerifier("", keyPath, "", []note.Verifier{wvNote}, 1) + if err != nil { + t.Fatalf("ParsePolicyOrVerifier failed: %v", err) + } + if _, err := pol2.Verify(signed2); err != nil { + t.Fatalf("pol2.Verify(signed2) failed: %v", err) + } +} + +func TestBuildPolicyErrorsAndPolicyFile(t *testing.T) { + skey, vkey, err := note.GenerateKey(rand.Reader, "example.com/log") + if err != nil { + t.Fatalf("GenerateKey failed: %v", err) + } + signer, _ := note.NewSigner(skey) + v, _ := ParseVerifier(vkey) + _, wvkey, _ := note.GenerateKey(rand.Reader, "witness.example/w1") + wv, _ := ParseVerifier(wvkey) + + if _, err := BuildPolicy("", nil, nil, 0); err == nil { + t.Fatal("expected error for nil primary verifier") + } + if _, err := BuildPolicy("", v, []note.Verifier{v}, 1); err == nil { + t.Fatal("expected error when witness collides with primary verifier") + } + if _, err := BuildPolicy("", v, []note.Verifier{wv, wv}, 2); err == nil { + t.Fatal("expected error when duplicate witnesses fall short of threshold") + } + + // Valid tlog-policy file via ParsePolicyOrVerifier + policyContent := fmt.Sprintf("log %s\nquorum none\n", vkey) + policyPath := filepath.Join(t.TempDir(), "log.policy") + if err := os.WriteFile(policyPath, []byte(policyContent), 0o600); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + pol, err := ParsePolicyOrVerifier(policyPath, "", "", nil, 0) + if err != nil { + t.Fatalf("ParsePolicyOrVerifier(policyFile) failed: %v", err) + } + root := sha256.Sum256([]byte("policy-file-root")) + cpText := fmt.Sprintf("example.com/log\n7\n%s\n", base64.StdEncoding.EncodeToString(root[:])) + signed, err := note.Sign(¬e.Note{Text: cpText}, signer) + if err != nil { + t.Fatalf("Sign failed: %v", err) + } + if _, err := pol.Verify(signed); err != nil { + t.Fatalf("pol.Verify with policy file failed: %v", err) + } +} + +func TestBuildPolicy_CustomOriginAndWitnessThresholds(t *testing.T) { + skey, vkey, err := note.GenerateKey(rand.Reader, "signer.example/key") + if err != nil { + t.Fatalf("GenerateKey failed: %v", err) + } + logSigner, _ := note.NewSigner(skey) + logVerifier, _ := ParseVerifier(vkey) + + w1SKey, w1RawVKey, _ := note.GenerateKey(rand.Reader, "witness.example/w1") + w1Signer, _ := f_note.NewSignerForCosignatureV1(w1SKey) + w1CosigVKey, _ := f_note.VKeyToCosignatureV1(w1RawVKey) + w1Verifier, _ := ParseWitnessVerifier(w1CosigVKey) + + w2SKey, w2RawVKey, _ := note.GenerateKey(rand.Reader, "witness.example/w2") + w2Signer, _ := f_note.NewSignerForCosignatureV1(w2SKey) + w2CosigVKey, _ := f_note.VKeyToCosignatureV1(w2RawVKey) + w2Verifier, _ := ParseWitnessVerifier(w2CosigVKey) + + customOrigin := "custom.origin/log" + pol, err := BuildPolicy(customOrigin, logVerifier, []note.Verifier{w1Verifier, w2Verifier}, 2) + if err != nil { + t.Fatalf("BuildPolicy failed: %v", err) + } + + root := sha256.Sum256([]byte("custom-origin-root")) + validCPText := fmt.Sprintf("%s\n42\n%s\n", customOrigin, base64.StdEncoding.EncodeToString(root[:])) + wrongOriginText := fmt.Sprintf("signer.example/key\n42\n%s\n", base64.StdEncoding.EncodeToString(root[:])) + + // 1. Valid: signed by log + w1 + w2 with custom origin + signedAll, err := note.Sign(¬e.Note{Text: validCPText}, logSigner, w1Signer, w2Signer) + if err != nil { + t.Fatalf("Sign failed: %v", err) + } + cp, err := pol.Verify(signedAll) + if err != nil { + t.Fatalf("pol.Verify(signedAll) failed: %v", err) + } + if cp.Origin != customOrigin || cp.Size != 42 { + t.Fatalf("unexpected checkpoint: %+v", cp) + } + + // 2. Wrong origin (matches signer name instead of custom origin) + signedWrongOrigin, _ := note.Sign(¬e.Note{Text: wrongOriginText}, logSigner, w1Signer, w2Signer) + if _, err := pol.Verify(signedWrongOrigin); err == nil { + t.Fatal("expected failure when checkpoint origin does not match custom origin") + } + + // 3. Missing primary log signature (signed by w1 + w2 only) + signedNoLog, _ := note.Sign(¬e.Note{Text: validCPText}, w1Signer, w2Signer) + if _, err := pol.Verify(signedNoLog); err == nil { + t.Fatal("expected failure when primary log signature is missing") + } + + // 4. Insufficient witness signatures (only 1 of 2 required) + signedOneWit, _ := note.Sign(¬e.Note{Text: validCPText}, logSigner, w1Signer) + if _, err := pol.Verify(signedOneWit); err == nil { + t.Fatal("expected failure when only 1 of 2 required witnesses signed") + } + + // 5. Duplicate signature from the same witness (w1 signs twice) + signedDupWit, _ := note.Sign(¬e.Note{Text: validCPText}, logSigner, w1Signer, w1Signer) + if _, err := pol.Verify(signedDupWit); err == nil { + t.Fatal("expected failure when duplicate witness signatures do not meet distinct threshold") + } +} + +func TestParsePolicyFile_MultiWitnessGroupQuorum(t *testing.T) { + skey, vkey, err := note.GenerateKey(rand.Reader, "example.com/log") + if err != nil { + t.Fatalf("GenerateKey failed: %v", err) + } + logSigner, _ := note.NewSigner(skey) + + w1SKey, w1RawVKey, _ := note.GenerateKey(rand.Reader, "witness.example/w1") + w1Signer, _ := f_note.NewSignerForCosignatureV1(w1SKey) + w1VKey, _ := f_note.VKeyToCosignatureV1(w1RawVKey) + + w2SKey, w2RawVKey, _ := note.GenerateKey(rand.Reader, "witness.example/w2") + w2Signer, _ := f_note.NewSignerForCosignatureV1(w2SKey) + w2VKey, _ := f_note.VKeyToCosignatureV1(w2RawVKey) + + policyContent := fmt.Sprintf( + "log %s\nwitness w1 %s\nwitness w2 %s\ngroup g1 2 w1 w2\nquorum g1\n", + vkey, w1VKey, w2VKey, + ) + policyPath := filepath.Join(t.TempDir(), "quorum.policy") + if err := os.WriteFile(policyPath, []byte(policyContent), 0o600); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + pol, err := ParsePolicyOrVerifier(policyPath, "", "", nil, 0) + if err != nil { + t.Fatalf("ParsePolicyOrVerifier failed: %v", err) + } + + root := sha256.Sum256([]byte("quorum-root")) + cpText := fmt.Sprintf("example.com/log\n99\n%s\n", base64.StdEncoding.EncodeToString(root[:])) + + signedBoth, err := note.Sign(¬e.Note{Text: cpText}, logSigner, w1Signer, w2Signer) + if err != nil { + t.Fatalf("Sign failed: %v", err) + } + if _, err := pol.Verify(signedBoth); err != nil { + t.Fatalf("expected valid quorum checkpoint to pass: %v", err) + } + + signedOnlyW1, _ := note.Sign(¬e.Note{Text: cpText}, logSigner, w1Signer) + if _, err := pol.Verify(signedOnlyW1); err == nil { + t.Fatal("expected checkpoint with only 1 of 2 quorum witnesses to fail") + } +} + +func TestAdversarialSpecStrictness(t *testing.T) { + skey, vkey, err := note.GenerateKey(rand.Reader, "example.com/outlog") + if err != nil { + t.Fatalf("GenerateKey failed: %v", err) + } + logSigner, _ := note.NewSigner(skey) + logVerifier, _ := ParseVerifier(vkey) + pol, _ := BuildPolicy("example.com/outlog", logVerifier, nil, 0) + + inRoot := sha256.Sum256([]byte("in-root")) + mptRoot := sha256.Sum256([]byte("mpt-root")) + outLeafRaw := FormatStateEntry("example.com/inlog", 0, 10, inRoot, mptRoot) + outRoot := LeafHash(outLeafRaw) + cpBody := fmt.Sprintf("example.com/outlog\n1\n%s\n", base64.StdEncoding.EncodeToString(outRoot[:])) + outSignedCP, err := note.Sign(¬e.Note{Text: cpBody}, logSigner) + if err != nil { + t.Fatalf("note.Sign failed: %v", err) + } + + validBinding := FormatBindingSection(outLeafRaw, 0, nil, outSignedCP) + + // 1. ParseBindingSection rejects truncated checkpoint missing trailing \n + truncated := validBinding[:len(validBinding)-1] + if _, err := ParseBindingSection(truncated); err == nil { + t.Fatal("expected ParseBindingSection to reject binding section missing trailing newline") + } + + // 2. ParseBindingSection rejects unsigned checkpoint (no \n\n before signature block) + unsignedBinding := FormatBindingSection(outLeafRaw, 0, nil, []byte(cpBody)) + if _, err := ParseBindingSection(unsignedBinding); err == nil { + t.Fatal("expected ParseBindingSection to reject unsigned checkpoint") + } + + // 3. ParseBindingSection rejects > 63 inclusion proof hashes + tooManyHashes := make([][]byte, 64) + for i := range tooManyHashes { + tooManyHashes[i] = inRoot[:] + } + hugeBinding := FormatBindingSection(outLeafRaw, 0, tooManyHashes, outSignedCP) + if _, err := ParseBindingSection(hugeBinding); err == nil { + t.Fatal("expected ParseBindingSection to reject > 63 audit path hashes") + } + + // 4. Policy.Verify rejects non-32-byte hash, leading-zero size, and CRLF in signed checkpoints + shortHashCP, _ := note.Sign(¬e.Note{Text: "example.com/outlog\n1\nAAAA\n"}, logSigner) + if _, err := pol.Verify(shortHashCP); err == nil { + t.Fatal("expected Policy.Verify to reject checkpoint with non-32-byte hash") + } + leadingZeroCP, _ := note.Sign(¬e.Note{Text: fmt.Sprintf("example.com/outlog\n01\n%s\n", base64.StdEncoding.EncodeToString(outRoot[:]))}, logSigner) + if _, err := pol.Verify(leadingZeroCP); err == nil { + t.Fatal("expected Policy.Verify to reject checkpoint with leading-zero size") + } + crlfCP := bytes.ReplaceAll(outSignedCP, []byte("\n"), []byte("\r\n")) + if _, err := pol.Verify(crlfCP); err == nil { + t.Fatal("expected Policy.Verify to reject CRLF checkpoint") + } + + // 5. ParsePolicyOrVerifier enforces origin when policyPath != "" and origin != "" + policyPath := filepath.Join(t.TempDir(), "test.policy") + if err := os.WriteFile(policyPath, []byte(fmt.Sprintf("log %s\nquorum none\n", vkey)), 0o600); err != nil { + t.Fatalf("WriteFile failed: %v", err) + } + polWithOrigin, err := ParsePolicyOrVerifier(policyPath, "", "different.example/outlog", nil, 0) + if err != nil { + t.Fatalf("ParsePolicyOrVerifier failed: %v", err) + } + if _, err := polWithOrigin.Verify(outSignedCP); err == nil { + t.Fatal("expected ParsePolicyOrVerifier with mismatched origin to reject checkpoint") + } + + // 6. MTCVerifier rejects forged extension lines, leading-zero size, and bad pubkey size + if _, err := NewMTCVerifier("example.com/mtc", ed25519.PublicKey("short"), "1.2", "1.3"); err == nil { + t.Fatal("expected NewMTCVerifier to reject invalid public key size") + } + mtcPub, mtcPriv, _ := ed25519.GenerateKey(rand.Reader) + mtcV, err := NewMTCVerifier("example.com/mtc", mtcPub, "44363.48.9", "44363.48.8") + if err != nil { + t.Fatalf("NewMTCVerifier failed: %v", err) + } + var mtcInput []byte + mtcInput = append(mtcInput, []byte("mtc-subtree/v1\n")...) + mtcInput = append(mtcInput, 0) + mtcInput = append(mtcInput, byte(len(mtcV.cosignerIDBytes))) + mtcInput = append(mtcInput, mtcV.cosignerIDBytes...) + mtcInput = append(mtcInput, byte(len(mtcV.logIDBytes))) + mtcInput = append(mtcInput, mtcV.logIDBytes...) + var startBytes, endBytes [8]byte + binary.BigEndian.PutUint64(endBytes[:], 5) + mtcInput = append(mtcInput, startBytes[:]...) + mtcInput = append(mtcInput, endBytes[:]...) + mtcInput = append(mtcInput, outRoot[:]...) + mtcSig := ed25519.Sign(mtcPriv, mtcInput) + + validMTCMsg := fmt.Sprintf("example.com/mtc\n5\n%s\n", base64.StdEncoding.EncodeToString(outRoot[:])) + if !mtcV.Verify([]byte(validMTCMsg), mtcSig) { + t.Fatal("expected valid MTC message to verify") + } + forgedExtMsg := validMTCMsg + "evil-extension-line\n" + if mtcV.Verify([]byte(forgedExtMsg), mtcSig) { + t.Fatal("expected MTCVerifier to reject checkpoint with forged extension line") + } + leadingZeroMTCMsg := fmt.Sprintf("example.com/mtc\n05\n%s\n", base64.StdEncoding.EncodeToString(outRoot[:])) + if mtcV.Verify([]byte(leadingZeroMTCMsg), mtcSig) { + t.Fatal("expected MTCVerifier to reject checkpoint with leading-zero size") + } + + // 7. ParseValuesResponse and ComputeValueLogRoot reject preceding > indices[0] and unsorted indices + invalidValResp := FormatValuesResponse(2, [][32]byte{inRoot}, []uint64{1, 5}) + if _, err := ParseValuesResponse(invalidValResp); err == nil { + t.Fatal("expected ParseValuesResponse to reject preceding (2) > indices[0] (1)") + } + if _, err := ComputeValueLogRoot([][32]byte{inRoot}, 2, []uint64{1, 5}); err == nil { + t.Fatal("expected ComputeValueLogRoot to reject preceding (2) > indices[0] (1)") + } + if _, err := ComputeValueLogRoot(nil, 0, []uint64{5, 5}); err == nil { + t.Fatal("expected ComputeValueLogRoot to reject non-ascending indices") + } + + // 8. ParseVerifier returns clear file error for nonexistent file path + if _, err := ParseVerifier(filepath.Join(t.TempDir(), "does-not-exist.pub")); err == nil { + t.Fatal("expected ParseVerifier to fail on missing file path") + } +}