pax_global_header00006660000000000000000000000064151456410130014512gustar00rootroot0000000000000052 comment=321e480da71fc330df68c9f709a71448f11a45d7 torchwood-0.9.0/000077500000000000000000000000001514564101300135305ustar00rootroot00000000000000torchwood-0.9.0/.github/000077500000000000000000000000001514564101300150705ustar00rootroot00000000000000torchwood-0.9.0/.github/workflows/000077500000000000000000000000001514564101300171255ustar00rootroot00000000000000torchwood-0.9.0/.github/workflows/test.yml000066400000000000000000000035251514564101300206340ustar00rootroot00000000000000name: Go tests on: push: pull_request: schedule: # daily at 09:42 UTC - cron: '42 9 * * *' workflow_dispatch: permissions: contents: read jobs: test: runs-on: ubuntu-latest strategy: fail-fast: false matrix: go: - { go-version: stable } - { go-version: oldstable } - { go-version-file: go.mod } deps: - locked - latest steps: - uses: actions/checkout@v5 with: persist-credentials: false - uses: actions/setup-go@v6 with: go-version: ${{ matrix.go.go-version }} go-version-file: ${{ matrix.go.go-version-file }} - uses: geomys/sandboxed-step@v1.2.0 with: run: | sudo apt-get update && sudo apt-get install -y psmisc # for killall curl --fail --location --remote-name https://github.com/Orange-OpenSource/hurl/releases/download/4.1.0/hurl_4.1.0_amd64.deb sudo apt-get install -y ./hurl_4.1.0_amd64.deb if [ "${{ matrix.deps }}" = "latest" ]; then go get -u -t ./... fi go test ./... go test -short -race ./... staticcheck: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 with: persist-credentials: false - uses: actions/setup-go@v6 with: go-version: stable - uses: geomys/sandboxed-step@v1.1.1 with: run: go run honnef.co/go/tools/cmd/staticcheck@latest ./... govulncheck: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 with: persist-credentials: false - uses: actions/setup-go@v6 with: go-version: stable - uses: geomys/sandboxed-step@v1.1.1 with: run: go run golang.org/x/vuln/cmd/govulncheck@latest ./... torchwood-0.9.0/LICENSE000066400000000000000000000027211514564101300145370ustar00rootroot00000000000000Copyright 2009 The Go Authors Copyright 2023 The Torchwood Authors Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of Google LLC nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. torchwood-0.9.0/NEWS.md000066400000000000000000000123101514564101300146230ustar00rootroot00000000000000## v0.9.0 ### torchwood - Added `FormatProof`, `FormatProofWithExtraData` and `ProofExtraData` to format [c2sp.org/tlog-proof@v1][] inclusion proofs ("spicy signatures"). - Added `Policy` interface and `VerifyProof`/`VerifyCheckpoint` to verify proofs and checkpoints against a configurable (co)signature policy. - Added `ParsePolicy` to parse policies from a format based on the Sigsum textual policies, but using vkeys instead of raw public keys. [c2sp.org/tlog-proof@v1]: https://c2sp.org/tlog-proof ### tesserax - New package with a `TileReader` adapter for `tessera.LogReader`. ### litebastion - `-listen-http` now accepts `host:port` in addition to a bare port number. - ACME now works correctly when using `-listen-http`. - Added `-tls-cert` and `-tls-key` flags to use a provided TLS certificate instead of ACME. `-testcert` was removed. - The backends file is now allowed to be empty. - Added `-obscurity` flag to disable the `/logz` endpoint. ### litewitness - Added `-obscurity` flag to disable the `/` and `/logz` endpoints. ### witnessctl - `add-key` now rejects duplicate keys. - `list-logs` no longer shows duplicate keys and bastions. ### age-keyserver - Added new tlog demo implementing an age keyserver (see https://words.filippo.io/keyserver-tlog/). ## v0.8.0 ### torchwood - Added `TileFS`, a `TileReader` implementation that reads tiles from a filesystem. Supports optional gzip decompression of data tiles. - Added `TileArchiveFS`, an `fs.FS` implementation that reads files from a set of zip archives. ## v0.7.0 Updated golang.org/x/... dependencies. ### torchwood - Added `NewCosignatureVerifier` to parse tlog-cosignature vkeys. - Added `Client.AllEntries` to fetch all entries from a log without stopping at the last full tile boundary. This is useful for one-shot monitors that don't tail the log. ### litewitness - Added support for per-log bastions and `-no-listen` flag. Use the new `add-bastion` and `del-bastion` witnessctl commands to manage them. ### litebastion - Added `-listen-http` flag to accept requests on localhost instead of the public port witnesses use to connect to the bastion. ## v0.6.1 ### torchwood - Fix `CosignatureSigner`/`CosignatureVerifier` to correctly sign and verify checkpoints with extension lines, according to c2sp.org/tlog-cosignature. ## v0.6.0 Switched to Go project LICENSE (BSD-3-Clause). Updated minimum Go version to Go 1.24. ### torchwood - Added tlog client, tiles fetcher, and permanent cache. - Added `HashProof` to prove inclusion of arbitrary tree interior nodes. - Added `ReadTileEntry` and `AppendTileEntry` to read and write entry bundles, and `ReadSumDBEntry` to read Go sumdb entries. ### litewitness - Switched to zombiezen.com/go/sqlite. ### witnessctl - Added `pull-logs` command to fetch logs from the witness network. ### prefix - New *experimental* prefix trie implementation. Unstable. ## v0.5.0 Renamed repository to Torchwood. ### torchwood - Exposed various [c2sp.org/signed-note][], [c2sp.org/tlog-cosignature][], [c2sp.org/tlog-checkpoint][], and [c2sp.org/tlog-tiles][] functions. [c2sp.org/signed-note]: https://c2sp.org/signed-note [c2sp.org/tlog-cosignature]: https://c2sp.org/tlog-cosignature [c2sp.org/tlog-checkpoint]: https://c2sp.org/tlog-checkpoint [c2sp.org/tlog-tiles]: https://c2sp.org/tlog-tiles ## v0.4.3 ### litewitness - Fixed SQLite concurrency issue. - Redacted IP addresses from `/logz`. ### witnessctl - Allow verifier keys that don't match the origin, like the Go sumdb's. ### litebastion - Redacted IP addresses from `/logz`. ## v0.4.2 ### litewitness - Fixed vkey encoding in logs and home page. - Improved `/logz` web page. ### litebastion - Improved `/logz` web page. ## v0.4.1 ### litebastion - Fixed formatting of backend key hashes in logs. ## v0.4.0 ### litebastion - Backend connection lifecycle events (including new details about errors) are now logged at the INFO level (the default). Client-side errors and HTTP/2 debug logs are now logged at the DEBUG level. - `Config.Log` is now a `log/slog.Logger` instead of a `log.Logger`. - `/logz` now exposes the debug logs in a simple public web console. At most ten clients can connect to it at a time. - New `-home-redirect` flag redirects the root to the given URL. - Connections to removed backends are now closed on SIGHUP, using the new `Bastion.FlushBackendConnections` method. ### litewitness - `/logz` now exposes the debug logs in a simple public web console. At most ten clients can connect to it at a time. ## v0.3.0 ### litewitness - Reduced Info log level verbosity, increased Debug log level verbosity. - Sending SIGUSR1 (`killall -USR1 litewitness`) will toggle log level between Info and Debug. - `-key` is now an SSH fingerprint (with `SHA256:` prefix) as printed by `ssh-add -l`. The old format is still accepted for compatibility. - The verifier key of the witness is logged on startup. - A small homepage listing the verifier key and the known logs is served at /. ### witnessctl - New `add-key` and `del-key` commands. - `add-log -key` was removed. The key is now added with `add-key`. ## v0.2.1 ### litewitness - Fix cosignature endianness. https://github.com/FiloSottile/litetlog/issues/12 torchwood-0.9.0/README.md000066400000000000000000000026761514564101300150220ustar00rootroot00000000000000# Torchwood The Torchwood repository is a collection of open-source tooling for tlogs. - [litewitness][] is a cosigning witness backed by SQLite and ssh-agent. It implements [c2sp.org/tlog-witness][]. - [litebastion][] (and [filippo.io/torchwood/bastion][]) is a public-service reverse proxy. It implements [c2sp.org/https-bastion][]. - [filippo.io/torchwood][] implements a [tlog client][] and various [c2sp.org/signed-note][], [c2sp.org/tlog-cosignature][], [c2sp.org/tlog-checkpoint][], and [c2sp.org/tlog-tiles][] functions, including extensions to the [golang.org/x/mod/sumdb/tlog][] and [golang.org/x/mod/sumdb/note][] packages. [filippo.io/torchwood/bastion]: https://pkg.go.dev/filippo.io/torchwood/bastion [filippo.io/torchwood]: https://pkg.go.dev/filippo.io/torchwood [tlog client]: https://pkg.go.dev/filippo.io/torchwood#Client [litebastion]: /cmd/litebastion/README.md [litewitness]: /cmd/litewitness/README.md [c2sp.org/tlog-witness]: https://c2sp.org/tlog-witness [c2sp.org/https-bastion]: https://c2sp.org/https-bastion [c2sp.org/signed-note]: https://c2sp.org/signed-note [c2sp.org/tlog-cosignature]: https://c2sp.org/tlog-cosignature [c2sp.org/tlog-checkpoint]: https://c2sp.org/tlog-checkpoint [c2sp.org/tlog-tiles]: https://c2sp.org/tlog-tiles [golang.org/x/mod/sumdb/tlog]: https://pkg.go.dev/golang.org/x/mod/sumdb/tlog [golang.org/x/mod/sumdb/note]: https://pkg.go.dev/golang.org/x/mod/sumdb/note torchwood-0.9.0/bastion/000077500000000000000000000000001514564101300151675ustar00rootroot00000000000000torchwood-0.9.0/bastion/bastion.go000066400000000000000000000203301514564101300171530ustar00rootroot00000000000000// Package bastion runs a reverse proxy service that allows un-addressable // applications (for example those running behind a firewall or a NAT, or where // the operator doesn't wish to take the DoS risk of being reachable from the // Internet) to accept HTTP requests. // // Backends are identified by an Ed25519 public key, they authenticate with a // self-signed TLS 1.3 certificate, and are reachable at a sub-path prefixed by // the key hash. // // Read more at // https://git.glasklar.is/sigsum/project/documentation/-/blob/main/bastion.md. package bastion import ( "context" "crypto/ed25519" "crypto/sha256" "crypto/tls" "encoding/hex" "errors" "fmt" "log/slog" "net" "net/http" "net/http/httputil" "slices" "strings" "sync" "time" "golang.org/x/crypto/acme" "golang.org/x/net/http2" ) // Config provides parameters for a new Bastion. type Config struct { // GetCertificate returns the certificate for bastion backend connections. GetCertificate func(*tls.ClientHelloInfo) (*tls.Certificate, error) // AllowedBackend returns whether the backend is allowed to // serve requests. It's passed the hash of its Ed25519 public key. // // AllowedBackend may be called concurrently. AllowedBackend func(keyHash [sha256.Size]byte) bool // Log is used to log backend connections states (as INFO) and errors in // forwarding requests (as DEBUG). If nil, [slog.Default] is used. Log *slog.Logger } // A Bastion keeps track of backend connections, and serves HTTP requests by // routing them to the matching backend. type Bastion struct { c *Config proxy *httputil.ReverseProxy pool *backendConnectionsPool tls *tls.Config } type keyHash [sha256.Size]byte func (kh keyHash) String() string { return hex.EncodeToString(kh[:]) } type backendContextKey struct{} // New returns a new Bastion. // // The Config must not be modified after the call to New. func New(c *Config) (*Bastion, error) { b := &Bastion{c: c} b.pool = &backendConnectionsPool{ log: slog.Default(), conns: make(map[keyHash]*http2.ClientConn), } if c.Log != nil { b.pool.log = c.Log } b.proxy = &httputil.ReverseProxy{ Rewrite: func(pr *httputil.ProxyRequest) { pr.Out.URL.Scheme = "https" // needed for the required :scheme header pr.Out.Host = pr.In.Context().Value(backendContextKey{}).(string) pr.SetXForwarded() // We don't interpret the query, so pass it on unmodified. pr.Out.URL.RawQuery = pr.In.URL.RawQuery }, Transport: b.pool, ErrorLog: slog.NewLogLogger(b.pool.log.Handler(), slog.LevelDebug), } b.tls = &tls.Config{ MinVersion: tls.VersionTLS13, // Including acme.ALPNProto lets the GetCertificate function handle // ACME challenges, if supported. VerifyConnection will still reject // connections that don't use the "bastion/0" ALPN protocol. NextProtos: []string{"bastion/0", acme.ALPNProto}, ClientAuth: tls.RequireAnyClientCert, VerifyConnection: func(cs tls.ConnectionState) error { if cs.NegotiatedProtocol != "bastion/0" { return fmt.Errorf("missing ALPN") } h, err := backendHash(cs) if err != nil { return err } if !b.c.AllowedBackend(h) { return fmt.Errorf("unrecognized backend %x", h) } return nil }, GetCertificate: b.c.GetCertificate, } return b, nil } // HandleBackendConnection handles a new backend connection. // // It can be used alternatively to [Bastion.ConfigureServer] to accept backend // connections on a dedicated listener. func (b *Bastion) HandleBackendConnection(conn net.Conn) { tlsConn := tls.Server(conn, b.tls) if err := tlsConn.Handshake(); err != nil { b.pool.log.Debug("failed TLS handshake from backend", "err", err, "remote", conn.RemoteAddr()) conn.Close() return } b.pool.Handle(tlsConn) conn.Close() } // ConfigureServer sets up srv to handle backend connections to the bastion. It // wraps TLSConfig.GetConfigForClient to intercept backend connections, and sets // TLSNextProto for the bastion ALPN protocol. The original tls.Config is still // used for non-bastion backend connections. // // Note that since TLSNextProto won't be nil after a call to ConfigureServer, // the caller might want to call [http2.ConfigureServer] as well. func (b *Bastion) ConfigureServer(srv *http.Server) error { if srv.TLSNextProto == nil { srv.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler)) } srv.TLSNextProto["bastion/0"] = func(_ *http.Server, c *tls.Conn, _ http.Handler) { b.pool.Handle(c) } if srv.TLSConfig == nil { srv.TLSConfig = &tls.Config{} } oldGetConfigForClient := srv.TLSConfig.GetConfigForClient srv.TLSConfig.GetConfigForClient = func(chi *tls.ClientHelloInfo) (*tls.Config, error) { if slices.Contains(chi.SupportedProtos, "bastion/0") { // This is a bastion connection from a backend. return b.tls, nil } if oldGetConfigForClient != nil { return oldGetConfigForClient(chi) } return nil, nil } return nil } func backendHash(cs tls.ConnectionState) (keyHash, error) { pk, ok := cs.PeerCertificates[0].PublicKey.(ed25519.PublicKey) if !ok { return keyHash{}, errors.New("self-signed certificate key type is not Ed25519") } return sha256.Sum256(pk), nil } // ServeHTTP serves requests rooted at "//" by routing them to the // backend that authenticated with that key. Other requests are served a 404 Not // Found status. func (b *Bastion) ServeHTTP(w http.ResponseWriter, r *http.Request) { path := r.URL.Path if !strings.HasPrefix(path, "/") { http.Error(w, "request must start with /KEY_HASH/", http.StatusNotFound) return } path = path[1:] kh, path, ok := strings.Cut(path, "/") if !ok { http.Error(w, "request must start with /KEY_HASH/", http.StatusNotFound) return } ctx := context.WithValue(r.Context(), backendContextKey{}, kh) r = r.Clone(ctx) r.URL.Path = "/" + path b.proxy.ServeHTTP(w, r) } // FlushBackendConnections closes all for backends that don't pass // [Config.AllowedBackend] anymore. // // ctx is passed to [http2.ClientConn.Shutdown], and FlushBackendConnections // waits for all connections to be closed. func (b *Bastion) FlushBackendConnections(ctx context.Context) { wg := sync.WaitGroup{} defer wg.Wait() b.pool.Lock() defer b.pool.Unlock() for kh, cc := range b.pool.conns { if !b.c.AllowedBackend(kh) { wg.Add(1) go func() { if err := cc.Shutdown(ctx); err != nil { cc.Close() } wg.Done() }() delete(b.pool.conns, kh) } } } type backendConnectionsPool struct { log *slog.Logger sync.RWMutex conns map[keyHash]*http2.ClientConn } func (p *backendConnectionsPool) RoundTrip(r *http.Request) (*http.Response, error) { kh, err := hex.DecodeString(r.Host) if err != nil || len(kh) != sha256.Size { // TODO: return this as a response instead. return nil, errors.New("invalid backend key hash") } p.RLock() cc, ok := p.conns[keyHash(kh)] p.RUnlock() if !ok { // TODO: return this as a response instead. return nil, errors.New("backend unavailable") } return cc.RoundTrip(r) } func (p *backendConnectionsPool) Handle(c *tls.Conn) { backend, err := backendHash(c.ConnectionState()) if err != nil { p.log.Info("failed to get backend hash", "err", err) return } l := p.log.With("backend", backend, "remote", c.RemoteAddr()) t := &http2.Transport{ // Send a PING every 15s, with the default 15s timeout. ReadIdleTimeout: 15 * time.Second, CountError: func(errType string) { l.Info("HTTP/2 transport error", "type", errType) }, } cc, err := t.NewClientConn(c) if err != nil { l.Info("failed to convert to HTTP/2 client connection", "err", err) return } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := cc.Ping(ctx); err != nil { l.Info("did not respond to PING", "err", err) return } p.Lock() if oldCC, ok := p.conns[backend]; ok && !oldCC.State().Closed { go func() { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() if err := oldCC.Shutdown(ctx); err != nil { oldCC.Close() } }() } p.conns[backend] = cc p.Unlock() l.Info("accepted new backend connection") // We need not to return, or http.Server will close this connection. // There is no way to wait for the ClientConn's closing, so we poll. for !cc.State().Closed { time.Sleep(1 * time.Second) } l.Info("backend connection closed") } torchwood-0.9.0/bastion/example_test.go000066400000000000000000000036071514564101300202160ustar00rootroot00000000000000package bastion_test import ( "crypto/sha256" "io" "log" "net/http" "sync" "time" "filippo.io/torchwood/bastion" "golang.org/x/crypto/acme/autocert" "golang.org/x/net/http2" ) func Example() { // This example shows how to serve on the same address both a bastion // endpoint, and an unrelated HTTPS server. m := &autocert.Manager{ Cache: autocert.DirCache("/var/lib/example-autocert/"), Prompt: autocert.AcceptTOS, Email: "acme@example.com", HostPolicy: autocert.HostWhitelist("bastion.example.com", "www.example.com"), } var allowedBackendsMu sync.RWMutex var allowedBackends map[[sha256.Size]byte]bool b, err := bastion.New(&bastion.Config{ AllowedBackend: func(keyHash [sha256.Size]byte) bool { allowedBackendsMu.RLock() defer allowedBackendsMu.RUnlock() return allowedBackends[keyHash] }, GetCertificate: m.GetCertificate, }) if err != nil { log.Fatalf("failed to load bastion: %v", err) } mux := http.NewServeMux() // Note the use of a host-specific pattern to route HTTP requests for the // bastion endpoint to the Bastion implementation. mux.Handle("bastion.example.com/", b) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "

Hello, world") }) hs := &http.Server{ Addr: "127.0.0.1:1337", Handler: http.MaxBytesHandler(mux, 10*1024), ReadTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second, TLSConfig: m.TLSConfig(), } // ConfigureServer sets up TLSNextProto and a tls.Config.GetConfigForClient // for backend connections. if err := b.ConfigureServer(hs); err != nil { log.Fatalln("failed to configure bastion:", err) } // HTTP/2 needs to be explicitly re-enabled if desired because it's only // configured automatically by net/http if TLSNextProto is nil. if err := http2.ConfigureServer(hs, nil); err != nil { log.Fatalln("failed to configure HTTP/2:", err) } } torchwood-0.9.0/checkpoint.go000066400000000000000000000062151514564101300162120ustar00rootroot00000000000000// Copyright 2023 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. package torchwood import ( "encoding/base64" "errors" "fmt" "strconv" "strings" "golang.org/x/mod/sumdb/note" "golang.org/x/mod/sumdb/tlog" ) const maxCheckpointSize = 1e6 // A Checkpoint is a tree head to be formatted according to c2sp.org/checkpoint. // // A checkpoint looks like this: // // example.com/origin // 923748 // nND/nri/U0xuHUrYSy0HtMeal2vzD9V4k/BO79C+QeI= // // It can be followed by extra extension lines. type Checkpoint struct { Origin string tlog.Tree // Extension is empty or a sequence of non-empty lines, // each terminated by a newline character. Extension string } // ParseCheckpoint parses a c2sp.org/tlog-checkpoint payload without signatures. func ParseCheckpoint(text string) (Checkpoint, error) { // This is an extended version of tlog.ParseTree. if strings.Count(text, "\n") < 3 || len(text) > maxCheckpointSize { return Checkpoint{}, errors.New("malformed checkpoint") } if !strings.HasSuffix(text, "\n") { return Checkpoint{}, errors.New("malformed checkpoint") } lines := strings.SplitN(text, "\n", 4) n, err := strconv.ParseInt(lines[1], 10, 64) if err != nil || n < 0 || lines[1] != strconv.FormatInt(n, 10) { return Checkpoint{}, errors.New("malformed checkpoint") } h, err := base64.StdEncoding.DecodeString(lines[2]) if err != nil || len(h) != tlog.HashSize { return Checkpoint{}, errors.New("malformed checkpoint") } rest := lines[3] for rest != "" { before, after, found := strings.Cut(rest, "\n") if before == "" || !found { return Checkpoint{}, errors.New("malformed checkpoint") } rest = after } var hash tlog.Hash copy(hash[:], h) return Checkpoint{lines[0], tlog.Tree{N: n, Hash: hash}, lines[3]}, nil } func (c Checkpoint) String() string { return fmt.Sprintf("%s\n%d\n%s\n%s", c.Origin, c.N, base64.StdEncoding.EncodeToString(c.Hash[:]), c.Extension, ) } type unverifiedNoteError struct { err error n *note.Note } func (e *unverifiedNoteError) Error() string { return fmt.Sprintf("note verification failed: %v", e.err) } func (e *unverifiedNoteError) Unwrap() []error { return []error{e.err, ¬e.UnverifiedNoteError{Note: e.n}} } // VerifyCheckpoint parses and verifies a signed c2sp.org/tlog-checkpoint. // // If the note signatures do not satisfy the provided policy, an error wrapping // *[note.UnverifiedNoteError] is returned. func VerifyCheckpoint(signedCheckpoint []byte, policy Policy) (Checkpoint, *note.Note, error) { n, err := note.Open(signedCheckpoint, policy) if err != nil { return Checkpoint{}, nil, err } c, err := ParseCheckpoint(n.Text) if err != nil { return Checkpoint{}, nil, fmt.Errorf("parsing checkpoint: %v", err) } if err := policy.Check(c.Origin, n.Sigs); err != nil { return Checkpoint{}, nil, &unverifiedNoteError{err: err, n: n} } // Check that at least one component of the policy checked the origin. if err := policy.Check("check.invalid", n.Sigs); err == nil { return Checkpoint{}, nil, errors.New("policy is not checking the checkpoint origin") } return c, n, nil } torchwood-0.9.0/cmd/000077500000000000000000000000001514564101300142735ustar00rootroot00000000000000torchwood-0.9.0/cmd/age-keylookup/000077500000000000000000000000001514564101300170475ustar00rootroot00000000000000torchwood-0.9.0/cmd/age-keylookup/default_policy.txt000066400000000000000000000011031514564101300226060ustar00rootroot00000000000000log keyserver.geomys.org+16b31509+ARLJ+pmTj78HzTeBj04V+LVfB+GFAQyrg54CRIju7Nn8 witness TrustFabric transparency.dev/DEV:witness-little-garden+d8042a87+BCtusOxINQNUTN5Oj8HObRkh2yHf/MwYaGX4CPdiVEPM https://api.transparency.dev/dev/witness/little-garden/ witness Mullvad witness.stagemole.eu+67f7aea0+BEqSG3yu9YrmcM3BHvQYTxwFj3uSWakQepafafpUqklv https://witness.stagemole.eu/ witness Geomys witness.navigli.sunlight.geomys.org+a3e00fe2+BNy/co4C1Hn1p+INwJrfUlgz7W55dSZReusH/GhUhJ/G https://witness.navigli.sunlight.geomys.org/ group public 2 TrustFabric Mullvad Geomys quorum public torchwood-0.9.0/cmd/age-keylookup/main.go000066400000000000000000000202321514564101300203210ustar00rootroot00000000000000package main import ( "bytes" "context" "crypto/sha256" _ "embed" "encoding/base64" "encoding/json" "flag" "fmt" "io" "net/http" "net/url" "os" "strings" "time" "filippo.io/mostly-harmless/vrf-r255" "filippo.io/torchwood" "golang.org/x/mod/sumdb/tlog" ) const ( defaultKeyserverURL = "https://keyserver.geomys.org" defaultKeyserverVRFKey = "mKPsDHDcVB95iPXW4Yc7+HPfi3xOw/bHFvfWw6CAMBs=" ) //go:embed default_policy.txt var defaultPolicy []byte func main() { allFlag := flag.Bool("all", false, "list all public keys in the transparency log") flag.Parse() if flag.NArg() != 1 { fmt.Fprintf(os.Stderr, "Usage: age-keylookup [-all] \n") fmt.Fprintf(os.Stderr, "\n") fmt.Fprintf(os.Stderr, "Look up an age public key by email address.\n") fmt.Fprintf(os.Stderr, "\n") fmt.Fprintf(os.Stderr, "With -all, it enumerates all public keys in the transparency log.\n") fmt.Fprintf(os.Stderr, "\n") fmt.Fprintf(os.Stderr, "Example:\n") fmt.Fprintf(os.Stderr, " age-keylookup filippo@example.com\n") fmt.Fprintf(os.Stderr, " age -r $(age-keylookup filippo@example.com) -o secret.txt.age secret.txt\n") fmt.Fprintf(os.Stderr, "\n") fmt.Fprintf(os.Stderr, "Environment:\n") fmt.Fprintf(os.Stderr, " AGE_KEYSERVER_URL Default keyserver URL\n") fmt.Fprintf(os.Stderr, " AGE_KEYSERVER_VRFKEY Default keyserver transparency log VRF public key\n") fmt.Fprintf(os.Stderr, " AGE_KEYSERVER_POLICY Default keyserver transparency log policy\n") os.Exit(2) } email := flag.Arg(0) // Determine server URL server := os.Getenv("AGE_KEYSERVER_URL") if server == "" { server = defaultKeyserverURL } policyBytes := defaultPolicy if policyPath := os.Getenv("AGE_KEYSERVER_POLICY"); policyPath != "" { p, err := os.ReadFile(policyPath) if err != nil { fmt.Fprintf(os.Stderr, "Error: failed to read policy file: %v\n", err) os.Exit(1) } policyBytes = p } policy, err := torchwood.ParsePolicy(policyBytes) if err != nil { fmt.Fprintf(os.Stderr, "Error: invalid policy: %v\n", err) os.Exit(1) } vrfKeyB64 := os.Getenv("AGE_KEYSERVER_VRFKEY") if vrfKeyB64 == "" { vrfKeyB64 = defaultKeyserverVRFKey } vrfKeyBytes, err := base64.StdEncoding.DecodeString(vrfKeyB64) if err != nil { fmt.Fprintf(os.Stderr, "Error: invalid base64 keyserver VRF public key: %v\n", err) os.Exit(1) } vrfKey, err := vrf.NewPublicKey(vrfKeyBytes) if err != nil { fmt.Fprintf(os.Stderr, "Error: invalid keyserver VRF public key: %v\n", err) os.Exit(1) } // Normalize email email = strings.TrimSpace(strings.ToLower(email)) if *allFlag { pubkeys, err := monitorLog(server, policy, vrfKey, email) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } for _, pk := range pubkeys { fmt.Println(pk) } return } pubkey, err := lookupKey(server, policy, vrfKey, email) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } fmt.Println(pubkey) } func lookupKey(serverURL string, policy torchwood.Policy, vrfKey *vrf.PublicKey, email string) (string, error) { // Build the lookup URL lookupURL := serverURL + "/api/lookup?email=" + url.QueryEscape(email) // Create HTTP client with timeout client := &http.Client{ Timeout: 10 * time.Second, } // Make the request resp, err := client.Get(lookupURL) if err != nil { return "", fmt.Errorf("failed to connect to keyserver: %w", err) } defer resp.Body.Close() // Check status code if resp.StatusCode == http.StatusNotFound { return "", fmt.Errorf("no key found for %s", email) } if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return "", fmt.Errorf("keyserver error: %s - %s", resp.Status, string(body)) } // Parse JSON response var result struct { Email string `json:"email"` Pubkey string `json:"pubkey"` Proof string `json:"proof"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return "", fmt.Errorf("failed to parse response: %w", err) } if result.Email != email { return "", fmt.Errorf("keyserver returned unexpected email: %q", result.Email) } if result.Pubkey == "" { return "", fmt.Errorf("empty public key returned") } // Compute and verify VRF hash vrfProofBytes, err := torchwood.ProofExtraData([]byte(result.Proof)) if err != nil { return "", fmt.Errorf("failed to extract VRF proof: %w", err) } vrfProof, err := vrf.NewProof(vrfProofBytes) if err != nil { return "", fmt.Errorf("failed to parse VRF proof: %w", err) } vrfHash, err := vrfKey.Verify(vrfProof, []byte(email)) if err != nil { return "", fmt.Errorf("failed to verify VRF proof: %w", err) } // Verify spicy signature h := sha256.New() h.Write([]byte(result.Pubkey)) entry := h.Sum(vrfHash) // vrf-r255(email) || SHA-256(pubkey) if err := torchwood.VerifyProof(policy, tlog.RecordHash(entry), []byte(result.Proof)); err != nil { return "", fmt.Errorf("failed to verify key proof: %w", err) } return result.Pubkey, nil } func monitorLog(serverURL string, policy torchwood.Policy, vrfKey *vrf.PublicKey, email string) ([]string, error) { // Request the VRF proof and history from the monitor endpoint monitorURL := serverURL + "/api/monitor?email=" + url.QueryEscape(email) client := &http.Client{ Timeout: 10 * time.Second, } resp, err := client.Get(monitorURL) if err != nil { return nil, fmt.Errorf("failed to connect to keyserver: %w", err) } defer resp.Body.Close() if resp.StatusCode == http.StatusNotFound { return nil, fmt.Errorf("no key found for %s", email) } if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("keyserver error: %s - %s", resp.Status, string(body)) } var result struct { Email string `json:"email"` VRFProof []byte `json:"vrf_proof"` History []string `json:"history"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) } if result.Email != email { return nil, fmt.Errorf("keyserver returned unexpected email: %q", result.Email) } // Prepare map of hashes of historical keys historyHashes := make(map[[32]byte]string) for _, pk := range result.History { h := sha256.Sum256([]byte(pk)) historyHashes[h] = pk } // Compute and verify VRF hash vrfProof, err := vrf.NewProof(result.VRFProof) if err != nil { return nil, fmt.Errorf("failed to parse VRF proof: %w", err) } vrfHash, err := vrfKey.Verify(vrfProof, []byte(email)) if err != nil { return nil, fmt.Errorf("failed to verify VRF proof: %w", err) } f, err := torchwood.NewTileFetcher(serverURL+"/tlog", torchwood.WithUserAgent("age-keylookup/1.0")) if err != nil { return nil, fmt.Errorf("failed to create tile fetcher: %w", err) } c, err := torchwood.NewClient(f) if err != nil { return nil, fmt.Errorf("failed to create torchwood client: %w", err) } // Fetch and verify checkpoint signedCheckpoint, err := f.ReadEndpoint(context.Background(), "checkpoint") if err != nil { return nil, fmt.Errorf("failed to read checkpoint: %w", err) } checkpoint, n, err := torchwood.VerifyCheckpoint(signedCheckpoint, policy) if err != nil { return nil, fmt.Errorf("failed to parse checkpoint: %w", err) } // Check the checkpoint is fresh for _, sig := range n.Sigs { if sig.Name == checkpoint.Origin { // The log's signature doesn't include a timestamp, for legacy reasons. continue } t, err := torchwood.CosignatureTimestamp(sig) if err != nil { return nil, fmt.Errorf("failed to extract cosignature %q timestamp: %w", sig.Name, err) } if time.Since(time.Unix(t, 0)) > 6*time.Hour { return nil, fmt.Errorf("checkpoint cosignature %q is too old", sig.Name) } } // Fetch all entries up to the checkpoint size var pubkeys []string for i, entry := range c.AllEntries(context.Background(), checkpoint.Tree, 0) { if len(entry) != 64+32 { return nil, fmt.Errorf("invalid entry size at index %d", i) } if !bytes.Equal(entry[:64], vrfHash) { continue } pk, ok := historyHashes[([32]byte)(entry[64:])] if !ok { return nil, fmt.Errorf("found unknown public key hash in log at index %d", i) } pubkeys = append(pubkeys, pk) } if c.Err() != nil { return nil, fmt.Errorf("error fetching log entries: %w", c.Err()) } return pubkeys, nil } torchwood-0.9.0/cmd/age-keyserver-keygen/000077500000000000000000000000001514564101300203245ustar00rootroot00000000000000torchwood-0.9.0/cmd/age-keyserver-keygen/keygen.go000066400000000000000000000015611514564101300221400ustar00rootroot00000000000000package main import ( "crypto/rand" "encoding/base64" "fmt" "os" "filippo.io/mostly-harmless/vrf-r255" "golang.org/x/mod/sumdb/note" ) func main() { if len(os.Args) != 2 { fmt.Fprintf(os.Stderr, "Usage: %s \n", os.Args[0]) os.Exit(1) } origin := os.Args[1] skey, vkey, err := note.GenerateKey(rand.Reader, origin) if err != nil { fmt.Fprintf(os.Stderr, "Error generating keys: %v\n", err) os.Exit(1) } vrfKey := vrf.GenerateKey() fmt.Printf("Private key (for LOG_KEY in age-keyserver): %s\n", skey) fmt.Printf("Private VRF key (for VRF_KEY in age-keyserver): %s\n", base64.StdEncoding.EncodeToString(vrfKey.Bytes())) fmt.Printf("Public key (for AGE_KEYSERVER_POLICY in age-keylookup): %s\n", vkey) fmt.Printf("Public VRF key (for AGE_KEYSERVER_VRFKEY in age-keylookup): %s\n", base64.StdEncoding.EncodeToString(vrfKey.PublicKey().Bytes())) } torchwood-0.9.0/cmd/age-keyserver/000077500000000000000000000000001514564101300170445ustar00rootroot00000000000000torchwood-0.9.0/cmd/age-keyserver/main.go000066400000000000000000000514641514564101300203310ustar00rootroot00000000000000package main import ( "context" "crypto/hmac" "crypto/rand" "crypto/sha256" "embed" "encoding/base64" "encoding/json" "flag" "fmt" "html/template" "io" "io/fs" "log" "net/http" "net/url" "os" "os/signal" "strconv" "strings" "syscall" "testing" "time" "filippo.io/age" "filippo.io/mostly-harmless/vrf-r255" "filippo.io/torchwood" "filippo.io/torchwood/tesserax" "github.com/transparency-dev/tessera" "github.com/transparency-dev/tessera/storage/posix" "golang.org/x/mod/sumdb/note" "golang.org/x/mod/sumdb/tlog" "golang.org/x/net/http2" "golang.org/x/net/http2/h2c" "zombiezen.com/go/sqlite" "zombiezen.com/go/sqlite/sqlitex" ) var ( //go:embed templates static embeddedFS embed.FS //go:embed witness_policy.txt defaultWitnessPolicy []byte dbPath = flag.String("db", "keyserver.sqlite3", "path to SQLite database") logPath = flag.String("logdir", "keyserver-tlog", "directory for transparency log") listenAddr = flag.String("listen", "localhost:13889", "address to listen on") ) type Server struct { dbpool *sqlitex.Pool templates *template.Template hmacKey []byte vrf *vrf.PrivateKey baseURL string reader tessera.LogReader appender *tessera.Appender awaiter *tessera.PublicationAwaiter policy torchwood.Policy } type KeyData struct { Pubkey string `json:"pubkey"` UpdatedAt int64 `json:"updated_at"` LogIndex int64 `json:"log_index"` VRFProof []byte `json:"vrf_proof"` } const ( linkValidDuration = 10 * time.Minute schema = ` CREATE TABLE IF NOT EXISTS keys ( email TEXT PRIMARY KEY, json_data BLOB ) STRICT; CREATE TABLE IF NOT EXISTS history ( email TEXT NOT NULL, pubkey TEXT NOT NULL ) STRICT; CREATE INDEX IF NOT EXISTS history_email_idx ON history(email); ` ) func main() { flag.Parse() ctx := context.Background() s, err := note.NewSigner(os.Getenv("LOG_KEY")) if err != nil { log.Fatalln("failed to create checkpoint signer:", err) } v, err := torchwood.NewVerifierFromSigner(os.Getenv("LOG_KEY")) if err != nil { log.Fatalln("failed to create checkpoint verifier:", err) } policy := torchwood.ThresholdPolicy(2, torchwood.OriginPolicy(v.Name()), torchwood.SingleVerifierPolicy(v)) vrfKey, err := base64.StdEncoding.DecodeString(os.Getenv("VRF_KEY")) if err != nil { log.Fatalln("failed to decode VRF key:", err) } vrf, err := vrf.NewPrivateKey(vrfKey) if err != nil { log.Fatalln("failed to create VRF from key:", err) } driver, err := posix.New(ctx, posix.Config{ Path: *logPath, }) if err != nil { log.Fatalln("failed to create log storage driver:", err) } witnessPolicy := defaultWitnessPolicy if path := os.Getenv("LOG_WITNESS_POLICY"); path != "" { witnessPolicy, err = os.ReadFile(path) if err != nil { log.Fatalln("failed to read witness policy file:", err) } } witnesses, err := tessera.NewWitnessGroupFromPolicy(witnessPolicy) if err != nil { log.Fatalln("failed to create witness group from policy:", err) } // Since this is a low-traffic but interactive server, disable batching to // remove integration latency for the first request. Keep a 1s checkpoint // interval not to hit the witnesses too often; this will be observed only // if two requests come in quick succession. Finally, only publish a // checkpoint every hour if there are no new entries, making the average qps // on witnesses low. Poll for new checkpoints quickly since it should be // just a read from a hot filesystem cache. checkpointInterval := 1 * time.Second if testing.Testing() { checkpointInterval = 100 * time.Millisecond } appender, shutdown, logReader, err := tessera.NewAppender(ctx, driver, tessera.NewAppendOptions(). WithCheckpointSigner(s). WithBatching(1, tessera.DefaultBatchMaxAge). WithCheckpointInterval(checkpointInterval). WithCheckpointRepublishInterval(1*time.Hour). WithWitnesses(witnesses, nil)) if err != nil { log.Fatalln("failed to create log appender:", err) } defer shutdown(context.Background()) awaiter := tessera.NewPublicationAwaiter(ctx, logReader.ReadCheckpoint, 25*time.Millisecond) // Check for development vs production mode postmarkToken := os.Getenv("POSTMARK_TOKEN") if postmarkToken == "" { log.Println("Running in DEVELOPMENT mode (POSTMARK_TOKEN not set)") log.Println("Login links will be logged to console instead of emailed") } // Generate random HMAC key hmacKey := make([]byte, 32) if _, err := rand.Read(hmacKey); err != nil { log.Fatalln("failed to generate HMAC key:", err) } log.Printf("Generated HMAC key (will invalidate on restart)") // Initialize database dbpool, err := sqlitex.NewPool(*dbPath, sqlitex.PoolOptions{ PoolSize: 10, PrepareConn: func(conn *sqlite.Conn) error { return sqlitex.ExecScript(conn, schema) }, }) if err != nil { log.Fatalln("failed to open database:", err) } defer dbpool.Close() // Parse templates tmplFS, err := fs.Sub(embeddedFS, "templates") if err != nil { log.Fatalln("failed to get templates subdirectory:", err) } templates := template.Must(template.ParseFS(tmplFS, "*.html")) // Determine base URL var baseURL string if postmarkToken == "" { // Development mode: use listen address baseURL = fmt.Sprintf("http://%s", *listenAddr) } else { // Production mode: use hardcoded production URL baseURL = "https://keyserver.geomys.org" } // Create server srv := &Server{ dbpool: dbpool, templates: templates, hmacKey: hmacKey, vrf: vrf, baseURL: baseURL, reader: logReader, appender: appender, awaiter: awaiter, policy: policy, } // Set up routes mux := http.NewServeMux() mux.HandleFunc("GET /{$}", srv.handleHome) mux.HandleFunc("POST /login", srv.handleLogin) mux.HandleFunc("GET /manage", srv.handleManage) mux.HandleFunc("POST /setkey", srv.handleSetKey) mux.HandleFunc("GET /api/lookup", srv.handleLookup) mux.HandleFunc("GET /api/monitor", srv.handleMonitor) mux.HandleFunc("POST /api/verify-token", srv.handleVerifyToken) // Serve static files staticFS, err := fs.Sub(embeddedFS, "static") if err != nil { log.Fatalln("failed to get static subdirectory:", err) } mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))) // Serve tlog-tiles log fs := http.StripPrefix("/tlog/", http.FileServer(http.Dir(*logPath))) mux.Handle("GET /tlog/", fs) // Start server with h2c support log.Println("") log.Printf("Starting age Keyserver on %s", *listenAddr) log.Printf("Open in browser: http://%s", *listenAddr) log.Println("") h2s := &http2.Server{} handler := h2c.NewHandler(mux, h2s) handler = http.MaxBytesHandler(handler, 1<<16) // 64KB max request size server := &http.Server{ Addr: *listenAddr, Handler: handler, } // Set up signal handling for graceful shutdown sigChan := make(chan os.Signal, 1) signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) go func() { <-sigChan log.Println("shutting down server...") ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() if err := server.Shutdown(ctx); err != nil { log.Printf("shutdown error: %v", err) } }() if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalln("server error:", err) } log.Println("shutting down") } func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) { hcaptchaSitekey := os.Getenv("HCAPTCHA_SITEKEY") if hcaptchaSitekey == "" { hcaptchaSitekey = "10000000-ffff-ffff-ffff-000000000001" // hCaptcha test key } data := map[string]string{ "HCaptchaSitekey": hcaptchaSitekey, } if err := s.templates.ExecuteTemplate(w, "home.html", data); err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) log.Printf("template error: %v", err) } } func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "Invalid form data", http.StatusBadRequest) return } email := strings.TrimSpace(r.FormValue("email")) captchaResponse := r.FormValue("h-captcha-response") if email == "" { http.Error(w, "Email is required", http.StatusBadRequest) return } // Emails are technically case sensitive, but users are unlikely to monitor // all case variations, so we normalize to lowercase. We do it before // sending the login link, so normalization can't lead to impersonation. email = strings.ToLower(email) if strings.ContainsAny(email, "\n") { http.Error(w, "Invalid email format", http.StatusBadRequest) return } // Verify captcha if !verifyCaptcha(captchaResponse) { http.Error(w, "Captcha verification failed", http.StatusBadRequest) return } // Generate login link loginLink, ts, sig := s.generateLoginLink(email, r) // Send email via Postmark if err := sendLoginEmail(email, loginLink, ts, sig); err != nil { http.Error(w, "Failed to send email", http.StatusInternalServerError) log.Printf("email error: %v", err) return } // Show confirmation page if err := s.templates.ExecuteTemplate(w, "login_sent.html", map[string]string{ "Email": email, }); err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) log.Printf("template error: %v", err) } } func (s *Server) handleManage(w http.ResponseWriter, r *http.Request) { // Now the token is in the URL fragment, handled client-side // Just serve the manage.html page which will process the fragment if err := s.templates.ExecuteTemplate(w, "manage.html", nil); err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) log.Printf("template error: %v", err) } } func (s *Server) handleVerifyToken(w http.ResponseWriter, r *http.Request) { var req struct { Email string `json:"email"` Ts string `json:"ts"` Sig string `json:"sig"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "Invalid request", http.StatusBadRequest) return } // Verify signature and timestamp if !s.verifyLoginLink(req.Email, req.Sig, req.Ts) { http.Error(w, "Invalid or expired login link", http.StatusUnauthorized) return } // Get current key data if exists keyData, err := s.getKeyData(req.Email) if err != nil { http.Error(w, "Database error", http.StatusInternalServerError) log.Printf("database error: %v", err) return } // Return verification response w.Header().Set("Content-Type", "application/json") if keyData != nil { json.NewEncoder(w).Encode(map[string]any{ "currentKey": keyData.Pubkey, "updatedAt": keyData.UpdatedAt, }) } else { json.NewEncoder(w).Encode(map[string]any{ "currentKey": "", }) } } func (s *Server) handleSetKey(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "Invalid form data", http.StatusBadRequest) return } email := r.FormValue("email") sig := r.FormValue("sig") ts := r.FormValue("ts") pubkey := strings.TrimSpace(r.FormValue("pubkey")) // Verify auth if !s.verifyLoginLink(email, sig, ts) { http.Error(w, "Invalid or expired session", http.StatusUnauthorized) return } // Validate age public key var proof string if pubkey != "" { if _, err := age.ParseX25519Recipient(pubkey); err != nil { http.Error(w, "Invalid age public key format", http.StatusBadRequest) return } // Compute VRF hash and proof vrfProof := s.vrf.Prove([]byte(email)) // Keep track of the unhashed key if err := s.storeHistory(email, pubkey); err != nil { http.Error(w, "Failed to store key history", http.StatusInternalServerError) log.Printf("database error: %v", err) return } // Add to transparency log h := sha256.New() h.Write([]byte(pubkey)) entry := tessera.NewEntry(h.Sum(vrfProof.Hash())) // vrf-r255(email) || SHA-256(pubkey) index, _, err := s.awaiter.Await(r.Context(), s.appender.Add(r.Context(), entry)) if err != nil { http.Error(w, "Failed to add to transparency log", http.StatusInternalServerError) log.Printf("transparency log error: %v", err) return } // Store in database if err := s.storeKey(email, pubkey, int64(index.Index), vrfProof.Bytes()); err != nil { http.Error(w, "Failed to store key", http.StatusInternalServerError) log.Printf("database error: %v", err) return } // Generate proof for success page proofBytes, err := s.makeSpicySignature(r.Context(), int64(index.Index), vrfProof.Bytes()) if err != nil { http.Error(w, "Failed to create proof", http.StatusInternalServerError) log.Printf("proof error: %v", err) return } proof = string(proofBytes) } else { // Delete key if err := s.deleteKey(email); err != nil { http.Error(w, "Failed to delete key", http.StatusInternalServerError) log.Printf("database error: %v", err) return } } // Show success page if err := s.templates.ExecuteTemplate(w, "success.html", map[string]string{ "Email": email, "Pubkey": pubkey, "Proof": proof, }); err != nil { http.Error(w, "Internal server error", http.StatusInternalServerError) log.Printf("template error: %v", err) } } func (s *Server) handleLookup(w http.ResponseWriter, r *http.Request) { email := r.URL.Query().Get("email") if email == "" { http.Error(w, "Email parameter required", http.StatusBadRequest) return } data, err := s.getKeyData(email) if err != nil { http.Error(w, "Database error", http.StatusInternalServerError) log.Printf("database error: %v", err) return } if data == nil { http.Error(w, "No key found for this email", http.StatusNotFound) return } proof, err := s.makeSpicySignature(r.Context(), data.LogIndex, data.VRFProof) if err != nil { http.Error(w, "Failed to create proof", http.StatusInternalServerError) log.Printf("proof error: %v", err) return } // Return as JSON w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{ "email": email, "pubkey": data.Pubkey, "proof": string(proof), }) } func (s *Server) handleMonitor(w http.ResponseWriter, r *http.Request) { email := r.URL.Query().Get("email") if email == "" { http.Error(w, "Email parameter required", http.StatusBadRequest) return } history, err := s.getHistory(email) if err != nil { http.Error(w, "Database error", http.StatusInternalServerError) log.Printf("database error: %v", err) return } // Return as JSON w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]any{ "email": email, "vrf_proof": s.vrf.Prove([]byte(email)).Bytes(), "history": history, }) } func (s *Server) makeSpicySignature(ctx context.Context, index int64, vrfProof []byte) ([]byte, error) { checkpoint, err := s.reader.ReadCheckpoint(ctx) if err != nil { return nil, fmt.Errorf("failed to read checkpoint: %v", err) } c, _, err := torchwood.VerifyCheckpoint(checkpoint, s.policy) if err != nil { return nil, fmt.Errorf("failed to parse checkpoint: %v", err) } p, err := tlog.ProveRecord(c.N, index, torchwood.TileHashReaderWithContext( ctx, c.Tree, tesserax.NewTileReader(s.reader))) if err != nil { return nil, fmt.Errorf("failed to create proof: %v", err) } return torchwood.FormatProofWithExtraData(index, vrfProof, p, checkpoint), nil } func (s *Server) generateHMAC(email string, ts int64) string { msg := fmt.Sprintf("%s:%d", email, ts) h := hmac.New(sha256.New, s.hmacKey) h.Write([]byte(msg)) return base64.URLEncoding.EncodeToString(h.Sum(nil)) } func (s *Server) generateLoginLink(email string, r *http.Request) (loginLink string, ts int64, sig string) { ts = time.Now().Unix() sig = s.generateHMAC(email, ts) loginLink = fmt.Sprintf("%s/manage#email=%s&ts=%d&sig=%s", s.baseURL, url.QueryEscape(email), ts, url.QueryEscape(sig)) return } func (s *Server) verifyLoginLink(email, sig, tsStr string) bool { ts, err := strconv.ParseInt(tsStr, 10, 64) if err != nil { return false } // Check if expired if time.Since(time.Unix(ts, 0)) > linkValidDuration { return false } // Verify HMAC msg := fmt.Sprintf("%s:%d", email, ts) h := hmac.New(sha256.New, s.hmacKey) h.Write([]byte(msg)) expectedSig := base64.URLEncoding.EncodeToString(h.Sum(nil)) return hmac.Equal([]byte(sig), []byte(expectedSig)) } func (s *Server) getKeyData(email string) (*KeyData, error) { conn, err := s.dbpool.Take(context.Background()) if err != nil { return nil, err } defer s.dbpool.Put(conn) var jsonData []byte err = sqlitex.Execute(conn, "SELECT json(json_data) FROM keys WHERE email = ?", &sqlitex.ExecOptions{ Args: []any{email}, ResultFunc: func(stmt *sqlite.Stmt) error { jsonData = make([]byte, stmt.ColumnLen(0)) stmt.ColumnBytes(0, jsonData) return nil }, }) if err != nil { return nil, err } if len(jsonData) == 0 { return nil, nil } var data KeyData if err := json.Unmarshal(jsonData, &data); err != nil { return nil, err } return &data, nil } func (s *Server) storeKey(email, pubkey string, index int64, vrfProof []byte) error { data := KeyData{ Pubkey: pubkey, UpdatedAt: time.Now().Unix(), LogIndex: index, VRFProof: vrfProof, } jsonData, err := json.Marshal(data) if err != nil { return err } conn, err := s.dbpool.Take(context.Background()) if err != nil { return err } defer s.dbpool.Put(conn) return sqlitex.Execute(conn, ` INSERT INTO keys (email, json_data) VALUES (?, JSONB(?)) ON CONFLICT(email) DO UPDATE SET json_data = excluded.json_data `, &sqlitex.ExecOptions{ Args: []any{email, string(jsonData)}, }) } func (s *Server) deleteKey(email string) error { conn, err := s.dbpool.Take(context.Background()) if err != nil { return err } defer s.dbpool.Put(conn) return sqlitex.Execute(conn, "DELETE FROM keys WHERE email = ?", &sqlitex.ExecOptions{ Args: []any{email}, }) } func (s *Server) getHistory(email string) ([]string, error) { conn, err := s.dbpool.Take(context.Background()) if err != nil { return nil, err } defer s.dbpool.Put(conn) var pubkeys []string err = sqlitex.Execute(conn, ` SELECT pubkey FROM history WHERE email = ? `, &sqlitex.ExecOptions{ Args: []any{email}, ResultFunc: func(stmt *sqlite.Stmt) error { pubkey := stmt.ColumnText(0) pubkeys = append(pubkeys, pubkey) return nil }, }) if err != nil { return nil, err } return pubkeys, nil } func (s *Server) storeHistory(email, pubkey string) error { conn, err := s.dbpool.Take(context.Background()) if err != nil { return err } defer s.dbpool.Put(conn) return sqlitex.Execute(conn, ` INSERT INTO history (email, pubkey) VALUES (?, ?) `, &sqlitex.ExecOptions{ Args: []any{email, pubkey}, }) } func verifyCaptcha(response string) bool { if response == "" { return false } hcaptchaSecret := os.Getenv("HCAPTCHA_SECRET") if hcaptchaSecret == "" { log.Println("HCAPTCHA_SECRET not set, skipping captcha verification") return true // Allow in development } data := url.Values{} data.Set("secret", hcaptchaSecret) data.Set("response", response) resp, err := http.PostForm("https://hcaptcha.com/siteverify", data) if err != nil { log.Printf("captcha verification error: %v", err) return false } defer resp.Body.Close() var result struct { Success bool `json:"success"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { log.Printf("captcha response decode error: %v", err) return false } return result.Success } func sendLoginEmail(email, loginLink string, ts int64, sig string) error { postmarkToken := os.Getenv("POSTMARK_TOKEN") if postmarkToken == "" { // Development mode: log the link instead of emailing log.Printf("%s", loginLink) // Write HMAC data to file if specified (for testing) if hmacFile := os.Getenv("AGE_KEYSERVER_HMAC_FILE"); hmacFile != "" { data := fmt.Sprintf("%s\n%d\n%s\n", email, ts, sig) if err := os.WriteFile(hmacFile, []byte(data), 0600); err != nil { log.Printf("warning: failed to write HMAC file: %v", err) } } return nil } fromEmail := os.Getenv("EMAIL_FROM") if fromEmail == "" { fromEmail = "noreply@keyserver.geomys.org" } emailBody := map[string]interface{}{ "From": fromEmail, "To": email, "Subject": "Login to age Keyserver", "TextBody": fmt.Sprintf("Click this link to login and manage your age public key:\n\n%s\n\nThis link will expire in 10 minutes.", loginLink), "HtmlBody": fmt.Sprintf(`

Click this link to login and manage your age public key:

%s

This link will expire in 10 minutes.

`, loginLink, loginLink), } body, err := json.Marshal(emailBody) if err != nil { return err } req, err := http.NewRequest("POST", "https://api.postmarkapp.com/email", strings.NewReader(string(body))) if err != nil { return err } req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/json") req.Header.Set("X-Postmark-Server-Token", postmarkToken) client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return fmt.Errorf("postmark API error: %s - %s", resp.Status, string(body)) } return nil } torchwood-0.9.0/cmd/age-keyserver/main_test.go000066400000000000000000000113201514564101300213530ustar00rootroot00000000000000package main import ( "fmt" "io" "net" "net/http" "net/url" "os" "os/exec" "path/filepath" "runtime" "strconv" "strings" "testing" "time" "filippo.io/age" "github.com/rogpeppe/go-internal/testscript" ) func TestMain(m *testing.M) { testscript.Main(m, map[string]func(){ "age-keyserver": func() { main() }, }) } func TestScript(t *testing.T) { // On macOS, the default TMPDIR is too long for ssh-agent socket paths. if runtime.GOOS == "darwin" { t.Setenv("TMPDIR", "/tmp") } p := testscript.Params{ Dir: "testdata", Setup: func(e *testscript.Env) error { bindir := filepath.SplitList(os.Getenv("PATH"))[0] // Build age-keylookup into the test binary directory cmd := exec.Command("go", "build", "-o", bindir) if testing.CoverMode() != "" { cmd.Args = append(cmd.Args, "-cover") } cmd.Args = append(cmd.Args, "filippo.io/torchwood/cmd/age-keylookup") cmd.Args = append(cmd.Args, "filippo.io/torchwood/cmd/litewitness") cmd.Args = append(cmd.Args, "filippo.io/torchwood/cmd/witnessctl") cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() }, Cmds: map[string]func(ts *testscript.TestScript, neg bool, args []string){ "waitfor": func(ts *testscript.TestScript, neg bool, args []string) { if len(args) != 1 { ts.Fatalf("usage: waitfor ") } if strings.HasPrefix(args[0], "http") { var lastErr error for i := 0; i < 50; i++ { r, err := http.Get(args[0]) if err == nil && r.StatusCode != http.StatusBadGateway { return } time.Sleep(100 * time.Millisecond) lastErr = err } ts.Fatalf("timeout waiting for %s: %v", args[0], lastErr) } protocol := "unix" if strings.Contains(args[0], ":") { protocol = "tcp" } var lastErr error for i := 0; i < 50; i++ { conn, err := net.Dial(protocol, args[0]) if err == nil { conn.Close() return } time.Sleep(100 * time.Millisecond) lastErr = err } ts.Fatalf("timeout waiting for %s: %v", args[0], lastErr) }, "killall": func(ts *testscript.TestScript, neg bool, args []string) { for _, cmd := range ts.BackgroundCmds() { cmd.Process.Signal(os.Interrupt) } }, "linecount": func(ts *testscript.TestScript, neg bool, args []string) { if len(args) != 2 { ts.Fatalf("usage: linecount N") } count, err := strconv.Atoi(args[1]) if err != nil { ts.Fatalf("invalid count: %v", args[1]) } if got := strings.Count(ts.ReadFile(args[0]), "\n"); got != count { ts.Fatalf("%v has %d lines, not %d", args[0], got, count) } }, "insertkey": func(ts *testscript.TestScript, neg bool, args []string) { if len(args) != 3 { ts.Fatalf("usage: insertkey ") } serverURL := args[0] email := args[1] pubkey := args[2] // Validate the public key if _, err := age.ParseX25519Recipient(pubkey); err != nil { ts.Fatalf("invalid age public key: %v", err) } // HMAC file path (must be set in testscript env before starting server) hmacFile := filepath.Join(ts.Getenv("WORK"), "hmac.txt") // Call login endpoint to generate HMAC token loginForm := fmt.Sprintf("email=%s&h-captcha-response=10000000-aaaa-bbbb-cccc-000000000001", url.QueryEscape(email)) resp, err := http.Post(serverURL+"/login", "application/x-www-form-urlencoded", strings.NewReader(loginForm)) if err != nil { ts.Fatalf("failed to call login: %v", err) } resp.Body.Close() if resp.StatusCode != http.StatusOK { ts.Fatalf("login failed: %s", resp.Status) } // Read HMAC data from file hmacData, err := os.ReadFile(hmacFile) if err != nil { ts.Fatalf("failed to read HMAC file: %v", err) } lines := strings.Split(strings.TrimSpace(string(hmacData)), "\n") if len(lines) != 3 { ts.Fatalf("invalid HMAC file format: got %d lines", len(lines)) } hmacEmail := lines[0] hmacTs := lines[1] hmacSig := lines[2] if hmacEmail != email { ts.Fatalf("email mismatch: expected %s, got %s", email, hmacEmail) } // Call setkey endpoint with HMAC token setkeyForm := fmt.Sprintf("email=%s&sig=%s&ts=%s&pubkey=%s", url.QueryEscape(email), url.QueryEscape(hmacSig), hmacTs, url.QueryEscape(pubkey)) resp, err = http.Post(serverURL+"/setkey", "application/x-www-form-urlencoded", strings.NewReader(setkeyForm)) if err != nil { ts.Fatalf("failed to set key: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) ts.Fatalf("setkey failed: %s - %s", resp.Status, string(body)) } }, }, } testscript.Run(t, p) } torchwood-0.9.0/cmd/age-keyserver/static/000077500000000000000000000000001514564101300203335ustar00rootroot00000000000000torchwood-0.9.0/cmd/age-keyserver/static/style.css000066400000000000000000000076761514564101300222250ustar00rootroot00000000000000:root { --bg: #1a1a1a; --fg: #e0e0e0; --fg-dim: #a0a0a0; --accent: #4a9eff; --border: #333; --input-bg: #2a2a2a; --button-bg: #3a3a3a; --button-hover: #4a4a4a; } @media (prefers-color-scheme: light) { :root { --bg: #ffffff; --fg: #1a1a1a; --fg-dim: #666666; --accent: #0066cc; --border: #d0d0d0; --input-bg: #f5f5f5; --button-bg: #e8e8e8; --button-hover: #d8d8d8; } } * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; line-height: 1.6; color: var(--fg); background-color: var(--bg); padding: 2rem 1rem; max-width: 680px; margin: 0 auto; } h1 { font-size: 1.8rem; font-weight: 600; margin-bottom: 1.5rem; margin-top: 2.5rem; color: var(--fg); } h2 { font-size: 1.3rem; font-weight: 500; margin-top: 2rem; margin-bottom: 1rem; color: var(--fg); } p { margin-bottom: 1rem; color: var(--fg-dim); } a { color: var(--accent); text-decoration: none; } a:hover { text-decoration: underline; } form { margin: 1.5rem 0; } label { display: block; margin-bottom: 0.5rem; color: var(--fg); font-weight: 500; } input[type="email"], input[type="text"], textarea { width: 100%; padding: 0.75rem; background: var(--input-bg); border: 1px solid var(--border); border-radius: 4px; color: var(--fg); font-size: 1rem; font-family: inherit; margin-bottom: 1rem; } input[type="email"]:focus, input[type="text"]:focus, textarea:focus { outline: none; border-color: var(--accent); } textarea { resize: vertical; min-height: 100px; font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New", monospace; font-size: 0.9rem; } button { padding: 0.75rem 1.5rem; background: var(--button-bg); border: 1px solid var(--border); border-radius: 4px; color: var(--fg); font-size: 1rem; font-weight: 500; cursor: pointer; transition: background 0.2s; } button:hover { background: var(--button-hover); } button:active { transform: translateY(1px); } .code { font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New", monospace; background: var(--input-bg); padding: 0.2rem 0.4rem; border-radius: 3px; font-size: 0.9rem; word-break: break-all; } .section { margin: 2rem 0; padding: 1.5rem; border: 1px solid var(--border); border-radius: 6px; background: rgba(255, 255, 255, 0.02); } .section h2 { margin-top: 0; } .success { color: #6ade6a; } @media (prefers-color-scheme: light) { .success { color: #00a000; } } .error { color: #ff6b6b; } .dimmed { color: var(--fg-dim); font-size: 0.9rem; } .footer { margin-top: 2rem; padding-top: 1.5rem; border-top: 1px solid var(--border); text-align: center; color: var(--fg-dim); font-size: 0.85rem; } code { font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New", monospace; } .h-captcha { margin-bottom: 1rem; display: flex; } .lookup-section { background: rgba(255, 255, 255, 0.04); } .section form { margin: 0; } .lookup-section input[type="email"] { font-size: 1.05rem; padding: 0.9rem; } .key-result { display: flex; flex-direction: column; gap: 0.75rem; align-items: stretch; margin-top: 0.75rem; padding: 1rem; background: var(--input-bg); border: 1px solid var(--border); border-radius: 4px; } .pubkey-display { flex: 1; font-family: "SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas, "Courier New", monospace; font-size: 0.9rem; color: var(--fg); word-break: break-all; line-height: 1.5; } .copy-button { flex-shrink: 0; padding: 0.5rem 1rem; font-size: 0.9rem; white-space: nowrap; background: var(--button-bg); border: 1px solid var(--border); border-radius: 4px; color: var(--fg); cursor: pointer; transition: background 0.2s; } .copy-button:hover { background: var(--button-hover); } .copy-button:active { transform: translateY(1px); } .copy-button { align-self: flex-start; } #lookup-result p { color: var(--fg); } torchwood-0.9.0/cmd/age-keyserver/templates/000077500000000000000000000000001514564101300210425ustar00rootroot00000000000000torchwood-0.9.0/cmd/age-keyserver/templates/home.html000066400000000000000000000107641514564101300226700ustar00rootroot00000000000000 age Keyserver

age Keyserver

A transparent keyserver for age public keys.

Look up a public key

Use from the command line

Encrypt a file to someone's public key directly:

go install filippo.io/torchwood/cmd/age-keylookup@main
age -r $(age-keylookup alice@example.com)

Submit or manage your key

torchwood-0.9.0/cmd/age-keyserver/templates/login_sent.html000066400000000000000000000030751514564101300240760ustar00rootroot00000000000000 Check your email - age Keyserver

Check your email

✓ We've sent a login link to {{.Email}}

Click the link in the email to manage your age public key. The link will expire in 10 minutes.

← Back to home

torchwood-0.9.0/cmd/age-keyserver/templates/manage.html000066400000000000000000000145731514564101300231720ustar00rootroot00000000000000 Manage your key - age Keyserver

age Keyserver

Authenticating...

torchwood-0.9.0/cmd/age-keyserver/templates/success.html000066400000000000000000000031221514564101300233760ustar00rootroot00000000000000 Success - age Keyserver

Success!

{{if .Pubkey}}

✓ Your public key has been updated for {{.Email}}

Public key:

{{.Pubkey}}

Anyone can now look up your public key using your email address.

{{if .Proof}}
Transparency log proof

The CLI automatically verifies this proof when looking up keys.

{{.Proof}}
{{end}} {{else}}

✓ Your public key has been deleted for {{.Email}}

{{end}}

← Back to home

torchwood-0.9.0/cmd/age-keyserver/testdata/000077500000000000000000000000001514564101300206555ustar00rootroot00000000000000torchwood-0.9.0/cmd/age-keyserver/testdata/age-keylookup.txt000066400000000000000000000056111514564101300241750ustar00rootroot00000000000000# Test age-keylookup against the actual age-keyserver # Start witness exec witnessctl add-log -origin example.com exec witnessctl add-key -origin example.com -key example.com+5800330c+ARPRGiaIwfx6xka5nXhdD/rqojPMjrjhm7OCuy+03Ymz env SSH_AUTH_SOCK=$WORK/sock ! exec ssh-agent -a $SSH_AUTH_SOCK -D & # ssh-agent always exits 2 waitfor $SSH_AUTH_SOCK chmod 600 witness_key.pem exec ssh-add witness_key.pem exec litewitness -ssh-agent=$SSH_AUTH_SOCK -listen localhost:7391 -name=example.com/witness -key=e933707e0e36c30f01d94b5d81e742da373679d88eb0f85f959ccd80b83b992a & waitfor localhost:7391 # Start age-keyserver env HCAPTCHA_SECRET=0x0000000000000000000000000000000000000000 env AGE_KEYSERVER_URL=http://localhost:13893 env AGE_KEYSERVER_HMAC_FILE=$WORK/hmac.txt env LOG_KEY=PRIVATE+KEY+example.com+5800330c+AaAoObvamoDOmN6c30Xh9pH1e/xqKcsU+fNmthQ8qmvM env LOG_WITNESS_POLICY=policy.txt env VRF_KEY=vni5C6++aVMFR5tg3bwvLamWlhJEmVrtNT7uNeyo6gQ= env AGE_KEYSERVER_VRFKEY=cmJCh5QTwp9VqN+QVV+BRxKLKmCFRuVAx+dahotxqw0= env AGE_KEYSERVER_POLICY=policy.txt exec age-keyserver -db=$WORK/test.sqlite3 -listen=localhost:13893 &srv& waitfor http://localhost:13893/ # Insert a test key via HTTP endpoint insertkey http://localhost:13893 test@example.com age1m0lsd7ywk3c66a3pwxsrj86sw0v8sxzwpxf97xhseepsud6fkues0rxq9h # Lookup the key using the CLI exec age-keylookup test@example.com stdout 'age1m0lsd7ywk3c66a3pwxsrj86sw0v8sxzwpxf97xhseepsud6fkues0rxq9h' # Test lookup for non-existent key (should fail) ! exec age-keylookup nonexistent@example.com stderr 'no key found' # Insert multiple keys insertkey http://localhost:13893 alice@example.com age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p insertkey http://localhost:13893 bob@example.com age1lggyhqrw2nlhcxprm67z43rta597azn8gknawjehu9d9dl0jq3yqqvfafg insertkey http://localhost:13893 charlie@example.com age1v9mqpk5wx65vxqz429s93uamfu2z0rm8y9az4kfkt4dp6tua8dhqvh3lff # Lookup each key exec age-keylookup alice@example.com stdout 'age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p' exec age-keylookup bob@example.com stdout 'age1lggyhqrw2nlhcxprm67z43rta597azn8gknawjehu9d9dl0jq3yqqvfafg' exec age-keylookup charlie@example.com stdout 'age1v9mqpk5wx65vxqz429s93uamfu2z0rm8y9az4kfkt4dp6tua8dhqvh3lff' # Stop the server killall wait srv stderr 'shutting down' -- witness_key.pem -- -----BEGIN OPENSSH PRIVATE KEY----- b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtz c2gtZWQyNTUxOQAAACBkhIrYq+1uhZgbOzh1slK4dn67SwL3A6yjsecbvWqOUAAA AIgN5+09DeftPQAAAAtzc2gtZWQyNTUxOQAAACBkhIrYq+1uhZgbOzh1slK4dn67 SwL3A6yjsecbvWqOUAAAAEAx/8IRbsvgA6yqgAq3B1e9fVMgbj/r72ptB5bZVTCz T2SEitir7W6FmBs7OHWyUrh2frtLAvcDrKOx5xu9ao5QAAAAAAECAwQF -----END OPENSSH PRIVATE KEY----- -- policy.txt -- log example.com+5800330c+ARPRGiaIwfx6xka5nXhdD/rqojPMjrjhm7OCuy+03Ymz witness W example.com/witness+10a1c019+BGSEitir7W6FmBs7OHWyUrh2frtLAvcDrKOx5xu9ao5Q http://localhost:7391 quorum W torchwood-0.9.0/cmd/age-keyserver/testdata/age-keyserver.txt000066400000000000000000000103171514564101300241710ustar00rootroot00000000000000# start witness exec witnessctl add-log -origin example.com exec witnessctl add-key -origin example.com -key example.com+5800330c+ARPRGiaIwfx6xka5nXhdD/rqojPMjrjhm7OCuy+03Ymz env SSH_AUTH_SOCK=$WORK/sock ! exec ssh-agent -a $SSH_AUTH_SOCK -D & # ssh-agent always exits 2 waitfor $SSH_AUTH_SOCK chmod 600 witness_key.pem exec ssh-add witness_key.pem exec litewitness -ssh-agent=$SSH_AUTH_SOCK -listen localhost:7390 -name=example.com/witness -key=e933707e0e36c30f01d94b5d81e742da373679d88eb0f85f959ccd80b83b992a & waitfor localhost:7390 # start age-keyserver with test hCaptcha secret env HCAPTCHA_SECRET=0x0000000000000000000000000000000000000000 env LOG_KEY=PRIVATE+KEY+example.com+5800330c+AaAoObvamoDOmN6c30Xh9pH1e/xqKcsU+fNmthQ8qmvM env LOG_WITNESS_POLICY=witness_policy.txt env VRF_KEY=vni5C6++aVMFR5tg3bwvLamWlhJEmVrtNT7uNeyo6gQ= exec age-keyserver -db=$WORK/test.sqlite3 -listen=localhost:13892 &srv& waitfor http://localhost:13892/ # test basic pages are accessible exec hurl --test --error-format long pages.hurl # test tlog endpoints exec hurl --test --error-format long tlog.hurl # test lookup endpoints exec hurl --test --error-format long lookup.hurl # test login endpoint exec hurl --test --error-format long login.hurl # test API endpoints with invalid auth exec hurl --test --error-format long api-invalid-auth.hurl # check that age-keyserver shut down cleanly killall wait srv stderr 'shutting down' -- witness_key.pem -- -----BEGIN OPENSSH PRIVATE KEY----- b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtz c2gtZWQyNTUxOQAAACBkhIrYq+1uhZgbOzh1slK4dn67SwL3A6yjsecbvWqOUAAA AIgN5+09DeftPQAAAAtzc2gtZWQyNTUxOQAAACBkhIrYq+1uhZgbOzh1slK4dn67 SwL3A6yjsecbvWqOUAAAAEAx/8IRbsvgA6yqgAq3B1e9fVMgbj/r72ptB5bZVTCz T2SEitir7W6FmBs7OHWyUrh2frtLAvcDrKOx5xu9ao5QAAAAAAECAwQF -----END OPENSSH PRIVATE KEY----- -- witness_policy.txt -- witness W example.com/witness+10a1c019+BGSEitir7W6FmBs7OHWyUrh2frtLAvcDrKOx5xu9ao5Q http://localhost:7390 quorum W -- pages.hurl -- # Test home page GET http://localhost:13892/ HTTP 200 # Test manage page GET http://localhost:13892/manage HTTP 200 # Test static files are accessible GET http://localhost:13892/static/style.css HTTP 200 -- tlog.hurl -- GET http://localhost:13892/tlog/checkpoint HTTP 200 [Asserts] body contains "— example.com " body contains "— example.com/witness " -- lookup.hurl -- # Test lookup - missing email parameter GET http://localhost:13892/api/lookup HTTP 400 [Asserts] body contains "Email parameter required" # Test lookup - key not found GET http://localhost:13892/api/lookup?email=nonexistent@example.com HTTP 404 [Asserts] body contains "No key found" -- login.hurl -- # Test login - missing email POST http://localhost:13892/login [FormParams] h-captcha-response: 10000000-aaaa-bbbb-cccc-000000000001 HTTP 400 [Asserts] body contains "Email is required" # Test login - missing captcha POST http://localhost:13892/login [FormParams] email: test@example.com HTTP 400 [Asserts] body contains "Captcha verification failed" # Test login - invalid captcha POST http://localhost:13892/login [FormParams] email: test@example.com h-captcha-response: invalid-captcha-token HTTP 400 [Asserts] body contains "Captcha verification failed" # Test login - valid request with test hCaptcha response POST http://localhost:13892/login [FormParams] email: test@example.com h-captcha-response: 10000000-aaaa-bbbb-cccc-000000000001 HTTP 200 [Asserts] body contains "test@example.com" -- api-invalid-auth.hurl -- # Test verify-token with invalid token POST http://localhost:13892/api/verify-token Content-Type: application/json { "email": "test@example.com", "sig": "invalid-sig", "ts": "123456789" } HTTP 401 [Asserts] body contains "Invalid or expired" # Test setkey with invalid auth POST http://localhost:13892/setkey [FormParams] email: test@example.com sig: invalid-sig ts: 123456789 pubkey: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p HTTP 401 [Asserts] body contains "Invalid or expired" # Test setkey with expired token (timestamp from 2020) POST http://localhost:13892/setkey [FormParams] email: test@example.com sig: somevalidsig ts: 1577836800 pubkey: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p HTTP 401 [Asserts] body contains "Invalid or expired" torchwood-0.9.0/cmd/age-keyserver/testdata/monitor.txt000066400000000000000000000047721514564101300231170ustar00rootroot00000000000000# Test monitoring mode # Start witness exec witnessctl add-log -origin example.com exec witnessctl add-key -origin example.com -key example.com+5800330c+ARPRGiaIwfx6xka5nXhdD/rqojPMjrjhm7OCuy+03Ymz env SSH_AUTH_SOCK=$WORK/sock ! exec ssh-agent -a $SSH_AUTH_SOCK -D & # ssh-agent always exits 2 waitfor $SSH_AUTH_SOCK chmod 600 witness_key.pem exec ssh-add witness_key.pem exec litewitness -ssh-agent=$SSH_AUTH_SOCK -listen localhost:7392 -name=example.com/witness -key=e933707e0e36c30f01d94b5d81e742da373679d88eb0f85f959ccd80b83b992a & waitfor localhost:7392 # Start age-keyserver env HCAPTCHA_SECRET=0x0000000000000000000000000000000000000000 env AGE_KEYSERVER_URL=http://localhost:13894 env AGE_KEYSERVER_HMAC_FILE=$WORK/hmac.txt env LOG_KEY=PRIVATE+KEY+example.com+5800330c+AaAoObvamoDOmN6c30Xh9pH1e/xqKcsU+fNmthQ8qmvM env LOG_WITNESS_POLICY=policy.txt env VRF_KEY=vni5C6++aVMFR5tg3bwvLamWlhJEmVrtNT7uNeyo6gQ= env AGE_KEYSERVER_VRFKEY=cmJCh5QTwp9VqN+QVV+BRxKLKmCFRuVAx+dahotxqw0= env AGE_KEYSERVER_POLICY=policy.txt exec age-keyserver -db=$WORK/test.sqlite3 -listen=localhost:13894 &srv& waitfor http://localhost:13894/ # Insert multiple keys insertkey http://localhost:13894 alice@example.com age1cg0zq9v96hm6wt7rgx4zavm34x474vrxlxsq5wvakxwjfmuhyftq0f6szk insertkey http://localhost:13894 bob@example.com age1wn8rtmayctvsfupmg6f0pvx5gtjqkmyw5rrp57l9x7cnay4dpsksyazwm9 insertkey http://localhost:13894 alice@example.com age1h24cj29vfe5t0wq9sp88fawatczsalq33qnh53wzpa03y8eeka7suwjcfs # Lookup latest key exec age-keylookup alice@example.com stdout 'age1h24cj29vfe5t0wq9sp88fawatczsalq33qnh53wzpa03y8eeka7suwjcfs' exec age-keylookup -all alice@example.com stdout 'age1cg0zq9v96hm6wt7rgx4zavm34x474vrxlxsq5wvakxwjfmuhyftq0f6szk' stdout 'age1h24cj29vfe5t0wq9sp88fawatczsalq33qnh53wzpa03y8eeka7suwjcfs' ! stdout 'age1wn8rtmayctvsfupmg6f0pvx5gtjqkmyw5rrp57l9x7cnay4dpsksyazwm9' # Stop the server killall wait srv stderr 'shutting down' -- witness_key.pem -- -----BEGIN OPENSSH PRIVATE KEY----- b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtz c2gtZWQyNTUxOQAAACBkhIrYq+1uhZgbOzh1slK4dn67SwL3A6yjsecbvWqOUAAA AIgN5+09DeftPQAAAAtzc2gtZWQyNTUxOQAAACBkhIrYq+1uhZgbOzh1slK4dn67 SwL3A6yjsecbvWqOUAAAAEAx/8IRbsvgA6yqgAq3B1e9fVMgbj/r72ptB5bZVTCz T2SEitir7W6FmBs7OHWyUrh2frtLAvcDrKOx5xu9ao5QAAAAAAECAwQF -----END OPENSSH PRIVATE KEY----- -- policy.txt -- log example.com+5800330c+ARPRGiaIwfx6xka5nXhdD/rqojPMjrjhm7OCuy+03Ymz witness W example.com/witness+10a1c019+BGSEitir7W6FmBs7OHWyUrh2frtLAvcDrKOx5xu9ao5Q http://localhost:7392 quorum W torchwood-0.9.0/cmd/age-keyserver/witness_policy.txt000066400000000000000000000011031514564101300226530ustar00rootroot00000000000000log keyserver.geomys.org+16b31509+ARLJ+pmTj78HzTeBj04V+LVfB+GFAQyrg54CRIju7Nn8 witness TrustFabric transparency.dev/DEV:witness-little-garden+d8042a87+BCtusOxINQNUTN5Oj8HObRkh2yHf/MwYaGX4CPdiVEPM https://api.transparency.dev/dev/witness/little-garden/ witness Mullvad witness.stagemole.eu+67f7aea0+BEqSG3yu9YrmcM3BHvQYTxwFj3uSWakQepafafpUqklv https://witness.stagemole.eu/ witness Geomys witness.navigli.sunlight.geomys.org+a3e00fe2+BNy/co4C1Hn1p+INwJrfUlgz7W55dSZReusH/GhUhJ/G https://witness.navigli.sunlight.geomys.org/ group public 2 TrustFabric Mullvad Geomys quorum public torchwood-0.9.0/cmd/apt-transport-tlog/000077500000000000000000000000001514564101300200545ustar00rootroot00000000000000torchwood-0.9.0/cmd/apt-transport-tlog/Dockerfile000066400000000000000000000006201514564101300220440ustar00rootroot00000000000000FROM golang:1.22.1-alpine3.19 as build WORKDIR /src RUN apk add build-base COPY go.mod go.sum ./ RUN go mod download COPY ./ ./ RUN go install -trimpath ./cmd/spicy FROM alpine:3.19.1 RUN apk add bash rclone rsync COPY --from=build /go/bin/spicy /usr/local/bin/spicy COPY cmd/apt-transport-tlog/update-bucket.sh /usr/local/bin/update-bucket.sh CMD ["bash", "/usr/local/bin/update-bucket.sh"] torchwood-0.9.0/cmd/apt-transport-tlog/README.md000066400000000000000000000035641514564101300213430ustar00rootroot00000000000000This is an **extremely early** prototype of a transparency log for APT repositories, and specifically for the Debian archive. The design is simple: offline-verifiable proofs of tlog inclusion ("spicy signatures") are generated for each InRelease file (which is the file signed with OpenPGP, and which contains the hashes of everything else in the repository) and hosted at a public URL; an apt transport plugin downloads and verifies the proof each time an InRelease file is being downloaded from the mirror. The proofs are generated with [`spicy`](https://github.com/FiloSottile/torchwood/blob/main/cmd/spicy/spicy.go) (also a prototype) by the `update-bucket.sh` script. It fetches the latest InRelease files every minute, and if any changes are detected it generates and uploads new proofs. The entries of the log are the whole InRelease files. An auditor would ensure they are all available on snapshot.debian.org, and that the repositories are consistent (e.g. that contents of a package version did not change from one iteration to another). In the future, the [checkpoint](https://c2sp.org/tlog-checkpoint) in the spicy signature would be [cosigned](https://c2sp.org/tlog-cosignature) by witnesses to prevent split-view attacks. This is designed to be easy to integrate upstream by any apt repository: `spicy` would be even easier to run at repository update time (same as `gpg -s`), proofs can be stored and distributed along with the InRelease files (as if they were regular detached signatures), and proof verification can be integrated in APT clients regardless of transport (it requires just simple parsing of a textual format, a few SHA-256 hashes, and Ed25519 signature verification). Even if the upstream keys were compromised, this system would ensure that any malfeasance could be detected, and that individual APT users could not be targeted with modified versions of the repository. torchwood-0.9.0/cmd/apt-transport-tlog/fly.toml000066400000000000000000000011031514564101300215360ustar00rootroot00000000000000app = "debian-spicy-signatures" primary_region = "iad" [build] dockerfile = "Dockerfile" [[vm]] memory = "256mb" cpu_kind = "shared" cpus = 1 [env] RCLONE_CONFIG_TIGRIS_TYPE = "s3" RCLONE_CONFIG_TIGRIS_PROVIDER = "Other" RCLONE_CONFIG_TIGRIS_ENDPOINT = "https://fly.storage.tigris.dev" # RCLONE_CONFIG_TIGRIS_ACCESS_KEY_ID secret # RCLONE_CONFIG_TIGRIS_SECRET_ACCESS_KEY secret BUCKET = "tigris:debian-spicy-signatures" TLOG_KEY_PATH = "/etc/spicy/filippo-io-debian-archive.key" [[files]] guest_path = "/etc/spicy/filippo-io-debian-archive.key" secret_name = "TLOG_KEY_BODY" torchwood-0.9.0/cmd/apt-transport-tlog/tlog.py000077500000000000000000000413561514564101300214070ustar00rootroot00000000000000#!/usr/bin/python3 """ Derived from intoto.py by Lukas Puehringer . https://github.com/in-toto/apt-transport-in-toto/blob/81fd97/intoto.py Copyright 2018 New York University Copyright 2024 Filippo Valsorda 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. Install this script as /usr/lib/apt/methods/tlog and make it executable. Change the apt sources.list to use tlog:// instead of https://. Requires the python3-requests package, and spicy in $PATH. """ import os import sys import signal import select import threading import logging import logging.handlers import requests import queue as Queue import subprocess # Configure base logger with lowest log level (i.e. log all messages) and # finetune the actual log levels on handlers logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) # A file handler for debugging purposes LOG_FILE = "/var/log/apt/tlog.log" LOG_HANDLER_FILE = logging.handlers.RotatingFileHandler(LOG_FILE, maxBytes=100000) LOG_HANDLER_FILE.setLevel(logging.DEBUG) logger.addHandler(LOG_HANDLER_FILE) # A stream handler (stderr) LOG_HANDLER_STDERR = logging.StreamHandler() LOG_HANDLER_STDERR.setLevel(logging.INFO) logger.addHandler(LOG_HANDLER_STDERR) APT_METHOD_HTTPS = os.path.join(os.path.dirname(sys.argv[0]), "https") # Global interrupted boolean. Apt may send SIGINT if it is done with its work. # Upon reception we set INTERRUPTED to true, which may be used to gracefully # terminate. INTERRUPTED = False # TODO: Maybe we can replace the signal handler with a KeyboardInterrupt # try/except block in the main loop, for better readability. def signal_handler(*junk): # Set global INTERRUPTED flag telling worker threads to terminate logger.debug("Received SIGINT, setting global INTERRUPTED true") global INTERRUPTED INTERRUPTED = True # Global BROKENPIPE flag should be set to true, if a `write` or `flush` on a # stream raises a BrokenPipeError, to gracefully terminate reader threads. BROKENPIPE = False # APT Method Interface Message definition # The first line of each message is called the message header. The first 3 # digits (called the Status Code) have the usual meaning found in the http # protocol. 1xx is informational, 2xx is successful and 4xx is failure. The 6xx # series is used to specify things sent to the method. After the status code is # an informational string provided for visual debugging # Only the 6xx series of status codes is sent TO the method. Furthermore the # method may not emit status codes in the 6xx range. The Codes 402 and 403 # require that the method continue reading all other 6xx codes until the proper # 602/603 code is received. This means the method must be capable of handling # an unlimited number of 600 messages. # Message types by their status code. CAPABILITES = 100 LOG = 101 STATUS = 102 URI_START = 200 URI_DONE = 201 URI_FAILURE = 400 GENERAL_FAILURE = 401 AUTH_REQUIRED = 402 MEDIA_FAILURE = 403 URI_ACQUIRE = 600 CONFIGURATION = 601 AUTH_CREDENTIALS = 602 MEDIA_CHANGED = 603 MESSAGE_TYPE = { # Method capabilities CAPABILITES: "Capabilities", # General Logging LOG: "Log", # Inter-URI status reporting (logging progress) STATUS: "Status", # URI is starting acquire URI_START: "URI Start", # URI is finished acquire URI_DONE: "URI Done", # URI has failed to acquire URI_FAILURE: "URI Failure", # Method did not like something sent to it GENERAL_FAILURE: "General Failure", # Method requires authorization to access the URI. Authorization is User/Pass AUTH_REQUIRED: "Authorization Required", # Method requires a media change MEDIA_FAILURE: "Media Failure", # Request a URI be acquired URI_ACQUIRE: "URI Acquire", # Sends the configuration space CONFIGURATION: "Configuration", # Response to the 402 message AUTH_CREDENTIALS: "Authorization Credentials", # Response to the 403 message MEDIA_CHANGED: "Media Changed", } def deserialize_one(message_str): """Parse raw message string as it may be read from stdin and return a dictionary that contains message header status code and info and an optional fields dictionary of additional headers and their values. Raise Exception if the message is malformed. { "code": , "info": "", "fields": [ ("
", ""), ] } NOTE: Message field values are NOT deserialized here, e.g. the Last-Modified time stamp remains a string and Config-Item remains a string of item=value pairs. """ lines = message_str.splitlines() if not lines: raise Exception("Invalid empty message:\n{}".format(message_str)) # Deserialize message header message_header = lines.pop(0) message_header_parts = message_header.split() # TODO: Are we too strict about the format (should we not care about info?) if len(message_header_parts) < 2: raise Exception( "Invalid message header: {}, message was:\n{}".format( message_header, message_str ) ) code = None try: code = int(message_header_parts.pop(0)) except ValueError: pass if not code or code not in list(MESSAGE_TYPE.keys()): raise Exception( "Invalid message header status code: {}, message was:\n{}".format( code, message_str ) ) # TODO: Are we too strict about the format (should we not care about info?) info = " ".join(message_header_parts).strip() if info != MESSAGE_TYPE[code]: raise Exception( "Invalid message header info for status code {}:\n{}," " message was: {}".format(code, info, message_str) ) # TODO: Should we assert that the last line is a blank line? if lines and not lines[-1]: lines.pop() # Deserialize header fields header_fields = [] for line in lines: header_field_parts = line.split(":") if len(header_field_parts) < 2: raise Exception( "Invalid header field: {}, message was:\n{}".format(line, message_str) ) field_name = header_field_parts.pop(0).strip() field_value = ":".join(header_field_parts).strip() header_fields.append((field_name, field_value)) # Construct message data message_data = {"code": code, "info": info} if header_fields: message_data["fields"] = header_fields return message_data def serialize_one(message_data): """Create a message string that may be written to stdout. Message data is expected to have the following format: { "code": , "info": "", "fields": [ ("
", ""), ] } """ message_str = "" # Code must be present code = message_data["code"] # Convenience (if info not present, info for code is used ) info = message_data.get("info") or MESSAGE_TYPE[code] # Add message header message_str += "{} {}\n".format(code, info) # Add message header fields and values (must be list of tuples) for field_name, field_value in message_data.get("fields", []): message_str += "{}: {}\n".format(field_name, field_value) # Blank line to mark end of message message_str += "\n" return message_str def read_one(stream): """Read one apt related message from the passed stream, e.g. sys.stdin for messages from apt, or subprocess.stdout for messages from a transport that we open in a subprocess. The end of a message (EOM) is denoted by a blank line ("\n") and end of file (EOF) is denoted by an empty line. Returns either a message including a trailing blank line or None on EOF. """ message_str = "" # Read from stream until we get a SIGINT/BROKENPIPE, or reach EOF (see below) # TODO: Do we need exception handling for the case where we select/read from # a stream that was closed? If so, we should do it in the main loop for # better readability. while not (INTERRUPTED or BROKENPIPE): # pragma: no branch # Only read if there is data on the stream (non-blocking) if not select.select([stream], [], [], 0)[0]: continue # Read one byte from the stream one = os.read(stream.fileno(), 1).decode() # Break on EOF if not one: break # If we read something append it to the message string message_str += one # Break on EOM (and return message below) if len(message_str) >= 2 and message_str[-2:] == "\n\n": break # Return a message if there is one, otherwise return None if message_str: return message_str return None def write_one(message_str, stream): """Write the passed message to the passed stream.""" try: stream.write(message_str) stream.flush() except BrokenPipeError: # TODO: Move exception handling to main loop for better readability global BROKENPIPE BROKENPIPE = True logger.debug( "BrokenPipeError while writing '{}' to '{}'.".format(message_str, stream) ) # Python flushes standard streams on exit; redirect remaining output # to devnull to avoid another BrokenPipeError at shutdown # See https://docs.python.org/3/library/signal.html#note-on-sigpipe devnull = os.open(os.devnull, os.O_WRONLY) os.dup2(devnull, sys.stdout.fileno()) def notify_apt(code, message_text, uri): # Escape LF and CR characters in message bodies to not break the protocol message_text = message_text.replace("\n", "\\n").replace("\r", "\\r") # NOTE: The apt method interface spec references RFC822, which doesn't allow # LF or CR in the message body, except if followed by a LWSP-char (i.e. SPACE # or HTAB, for "folding" of long lines). But apt does not seem to support # folding, and splits lines only at LF. To be safe we escape LF and CR. # See 2.1 Overview in www.fifi.org/doc/libapt-pkg-doc/method.html/ch2.html # See "3.1.1. LONG HEADER FIELDS" and "3.1.2. STRUCTURE OF HEADER FIELDS" in # www.ietf.org/rfc/rfc822.txt write_one( serialize_one( { "code": code, "info": MESSAGE_TYPE[code], "fields": [("Message", message_text), ("URI", uri)], } ), sys.stdout, ) def read_to_queue(stream, queue): """Loop to read messages one at a time from the passed stream until EOF, i.e. the returned message is None, and write to the passed queue. """ while True: msg = read_one(stream) if not msg: return None queue.put(msg) def handle(message_data): logger.debug("Handling message: {}".format(message_data["code"])) if message_data["code"] == CAPABILITES: # TODO(filippo): intercept Capabilities messages to avoid future # features bypassing verification. return True elif message_data["code"] == URI_ACQUIRE: # TODO(filippo): redirect InRelease file fetches to the tlog server. return True elif message_data["code"] == URI_DONE: # TODO(filippo): catch exceptions, print stack trace to stderr, and # notify URI_FAILURE to apt. filename = dict(message_data["fields"]).get("Filename", "") uri = dict(message_data["fields"]).get("URI", "") hit = dict(message_data["fields"]).get("IMS-Hit", "") # TODO(filippo): use Target-Type or Index-File from the URI_ACQUIRE. if not uri.endswith("/InRelease"): return True if hit == "true": return True notify_apt(STATUS, "Fetching InRelease file spicy signature", uri) spicy_uri = ( "https://debian-spicy-signatures.fly.storage.tigris.dev/debian/" + uri.split("/dists/")[-1] + ".spicy" ) logger.debug("Spicy sig URL: {}".format(spicy_uri)) r = requests.get(spicy_uri) r.raise_for_status() with open(filename + ".spicy", "wb") as f: f.write(r.content) notify_apt(STATUS, "Verifying InRelease file spicy signature", uri) subprocess.check_output( [ "spicy", "-verify", "filippo.io/debian-archive+6c61b70b+Aaw9ASjgICSzfKJDcCqz7l3FtSpKvQYCvaRfdfOiIRun", filename, ], stderr=subprocess.STDOUT, ) logger.debug("Verified {} 🌶️".format(uri.split("/dists/")[-1])) # TODO(filippo): use fields from the URI_ACQUIRE. base = uri.split("/dists/")[0] dist = uri.split("/dists/")[1].split("/")[0] print("\r 🌶️ {} {} InRelease.spicy".format(base, dist), file=sys.stderr) return True else: return True def loop(): """Main tlog https transport method loop to relay messages between apt and the apt https transport method and inject spicy verification upon reception of a particular message. """ # Start https transport in a subprocess # Messages from the parent process received on sys.stdin are relayed to the # subprocess' stdin and vice versa, messages written to the subprocess' # stdout are relayed to the parent via sys.stdout. https_proc = subprocess.Popen( [APT_METHOD_HTTPS], stdin=subprocess.PIPE, # nosec stdout=subprocess.PIPE, universal_newlines=True, ) # HTTPS transport message reader thread to add messages from the https # transport (subprocess) to a corresponding queue. https_queue = Queue.Queue() https_thread = threading.Thread( target=read_to_queue, args=(https_proc.stdout, https_queue) ) # APT message reader thread to add messages from apt (parent process) # to a corresponding queue. apt_queue = Queue.Queue() apt_thread = threading.Thread(target=read_to_queue, args=(sys.stdin, apt_queue)) # Start reader threads. # They will run until they see an EOF on their stream, or the global # INTERRUPTED or BROKENPIPE flags are set to true. https_thread.start() apt_thread.start() # Main loop to get messages from queues, i.e. apt queue and https transport # queue, and relay them to the corresponding streams, injecting verification. while True: for name, queue, out in [ ("apt", apt_queue, https_proc.stdin), ("https", https_queue, sys.stdout), ]: should_relay = True try: message = queue.get_nowait() logger.debug("{} sent message:\n{}".format(name, message)) message_data = deserialize_one(message) except Queue.Empty: continue # De-serialization error: Skip message handling, but do relay. except Exception as e: # TODO(filippo): this is insecure, fail closed. logger.debug("Cannot handle message, reason is {}".format(e)) else: logger.debug("Handle message") should_relay = handle(message_data) if should_relay: logger.debug("Relay message") write_one(message, out) # Exit when both threads have terminated (EOF, INTERRUPTED or BROKENPIPE) # NOTE: We do not check if there are still messages on the streams or # in the queue, assuming that there aren't or we can ignore them if both # threads have terminated. if not apt_thread.is_alive() and not https_thread.is_alive(): logger.debug( "The worker threads are dead. Long live the worker threads!" "Terminating." ) # If INTERRUPTED or BROKENPIPE are true it (likely?) means that apt # sent a SIGINT or closed the pipe we were writing to. This means we # should exit and tell the http child process to exit too. # TODO: Could it be that the http child closed a pipe or sent a SITERM? # TODO: Should we behave differently for the two signals? if INTERRUPTED or BROKENPIPE: # pragma: no branch logger.debug("Relay SIGINT to http subprocess") https_proc.send_signal(signal.SIGINT) return if __name__ == "__main__": signal.signal(signal.SIGINT, signal_handler) loop() torchwood-0.9.0/cmd/apt-transport-tlog/update-bucket.sh000066400000000000000000000010571514564101300231500ustar00rootroot00000000000000#!/bin/bash set -xeuo pipefail cd "$(mktemp -d)" rclone -v sync "$BUCKET" . cd debian while true; do sleep 60; date updated=$(rsync debian.csail.mit.edu::debian/dists/ ./ \ --include '*/' --include InRelease --exclude '*' \ --prune-empty-dirs --copy-links \ --out-format='%n' --recursive --times | grep 'InRelease$') || \ continue while IFS= read -r f; do rm "$f.spicy" done <<< "$updated" xargs spicy -assets ../log -key "$TLOG_KEY_PATH" <<< "$updated" rclone -v sync .. "$BUCKET" done torchwood-0.9.0/cmd/litebastion/000077500000000000000000000000001514564101300166105ustar00rootroot00000000000000torchwood-0.9.0/cmd/litebastion/README.md000066400000000000000000000050131514564101300200660ustar00rootroot00000000000000# litebastion litebastion is a public-service reverse proxy for witnesses that can't be exposed directly to the internet. In short, a witness connects to a bastion over TLS with a Ed25519 client certificate, "reverses" the direction of the connection, and serves HTTP/2 requests over that connection. The bastion then proxies requests received at `//*` to that witness. -backends string file of accepted key hashes, one per line, reloaded on SIGHUP The only configuration file of litebastion is the backends file, which lists the acceptable client/witness key hashes. -listen string host and port to listen at (default "localhost:8443") -cache string directory to cache ACME certificates at -email string email address to register the ACME account with -host string host to obtain ACME certificate for -tls-cert string path to TLS certificate -tls-key string path to TLS private key Since litebastion needs to operate at a lower level than HTTPS on the witness side, it can't be behind a reverse proxy, and needs to configure its own TLS certificate. Use the `-cache`, `-email`, and `-host` flags to configure the ACME client. The ALPN ACME challenge is used, so as long as the `-listen` port receives connections to the `-host` name at port 443, everything should just work. Alternatively, if both `-tls-cert` and `-tls-key` are set, ACME is disabled and the provided certificate and private key are used instead. The certificate and key are reloaded on SIGHUP. -obscurity enable obscurity mode (disable / and /logz endpoints) -listen-http [HOST:]PORT only accept HTTP requests at http://HOST:PORT or http://localhost:PORT If you intend to protect backends from unwanted traffic and not forward arbitrary requests from the internet, you can accept HTTP requests on a separate port. Backends will still be able to connect to the public -listen address. This is for example useful when running a bastion for your own log. ## bastion as a library It might be desirable to integrate bastion functionality in an existing binary, for example because there is only one IP address and hence only one port 443 to listen on. In that case, you can use the `filippo.io/torchwood/bastion` package. See [pkg.go.dev](https://pkg.go.dev/filippo.io/torchwood/bastion) for the documentation and in particular the [package example](https://pkg.go.dev/filippo.io/torchwood/bastion#example-package). torchwood-0.9.0/cmd/litebastion/litebastion.go000066400000000000000000000164061514564101300214630ustar00rootroot00000000000000// Command litebastion runs a reverse proxy service that allows un-addressable // applications (for example those running behind a firewall or a NAT, or where // the operator doesn't wish to take the DoS risk of being reachable from the // Internet) to accept HTTP requests. // // Backends are identified by an Ed25519 public key, they authenticate with a // self-signed TLS 1.3 certificate, and are reachable at a sub-path prefixed by // the key hash. // // Read more at https://c2sp.org/https-bastion. package main import ( "context" "crypto/sha256" "crypto/tls" "encoding/hex" "flag" "fmt" "log/slog" "net" "net/http" "os" "os/signal" "strings" "sync" "sync/atomic" "syscall" "time" "filippo.io/torchwood/bastion" "filippo.io/torchwood/internal/slogconsole" "golang.org/x/crypto/acme" "golang.org/x/crypto/acme/autocert" "golang.org/x/net/http2" "golang.org/x/sync/errgroup" ) var listenAddr = flag.String("listen", "localhost:8443", "host and port to listen at") var listenHTTP = flag.String("listen-http", "", "host:port or localhost port to listen for HTTP requests") var tlsCertFile = flag.String("tls-cert", "", "path to TLS certificate; disables ACME") var tlsKeyFile = flag.String("tls-key", "", "path to TLS private key; disables ACME") var autocertCache = flag.String("cache", "", "directory to cache ACME certificates at") var autocertHost = flag.String("host", "", "host to obtain ACME certificate for") var autocertEmail = flag.String("email", "", "") var allowedBackendsFile = flag.String("backends", "", "file of accepted key hashes, one per line, reloaded on SIGHUP") var homeRedirect = flag.String("home-redirect", "", "redirect / to this URL") var obscurityFlag = flag.Bool("obscurity", false, "enable obscurity mode (disable / and /logz endpoints)") type keyHash [sha256.Size]byte func main() { flag.Parse() console := slogconsole.New(nil) console.SetFilter(slogconsole.IPAddressFilter) h := slog.NewTextHandler(os.Stderr, nil) slog.SetDefault(slog.New(slogconsole.MultiHandler(h, console))) http2.VerboseLogs = true // will go to DEBUG due to SetLogLoggerLevel slog.SetLogLoggerLevel(slog.LevelDebug) var reloadCert func() error var getCertificate func(*tls.ClientHelloInfo) (*tls.Certificate, error) switch { case *tlsKeyFile != "" && *tlsCertFile != "": var cert atomic.Pointer[tls.Certificate] reloadCert = func() error { c, err := tls.LoadX509KeyPair(*tlsCertFile, *tlsKeyFile) if err != nil { return err } cert.Store(&c) return nil } if err := reloadCert(); err != nil { logFatal("can't load certificates", "err", err) } getCertificate = func(_ *tls.ClientHelloInfo) (*tls.Certificate, error) { return cert.Load(), nil } case *autocertCache != "" && *autocertHost != "" && *autocertEmail != "": m := &autocert.Manager{ Cache: autocert.DirCache(*autocertCache), Prompt: autocert.AcceptTOS, Email: *autocertEmail, HostPolicy: autocert.HostWhitelist(*autocertHost), } getCertificate = m.GetCertificate reloadCert = func() error { return nil } default: logFatal("-cache, -host, and -email or -tls-key and -tls-cert are required") } if *allowedBackendsFile == "" { logFatal("-backends is missing") } var allowedBackendsMu sync.RWMutex var allowedBackends map[keyHash]bool reloadBackends := func() error { newBackends := make(map[keyHash]bool) backendsList, err := os.ReadFile(*allowedBackendsFile) if err != nil { return err } bs := strings.TrimSpace(string(backendsList)) for _, line := range strings.Split(bs, "\n") { if line == "" { continue } l, err := hex.DecodeString(line) if err != nil { return fmt.Errorf("invalid backend: %q", line) } if len(l) != sha256.Size { return fmt.Errorf("invalid backend: %q", line) } h := keyHash(l) newBackends[h] = true } allowedBackendsMu.Lock() defer allowedBackendsMu.Unlock() allowedBackends = newBackends return nil } if err := reloadBackends(); err != nil { logFatal("failed to load backends", "err", err) } slog.Info("loaded backends", "count", len(allowedBackends)) b, err := bastion.New(&bastion.Config{ AllowedBackend: func(keyHash [sha256.Size]byte) bool { allowedBackendsMu.RLock() defer allowedBackendsMu.RUnlock() return allowedBackends[keyHash] }, GetCertificate: getCertificate, }) if err != nil { logFatal("failed to create bastion", "err", err) } c := make(chan os.Signal, 1) signal.Notify(c, syscall.SIGHUP) go func() { for range c { if err := reloadCert(); err != nil { slog.Error("failed to reload certificate", "err", err) } if err := reloadBackends(); err != nil { slog.Error("failed to reload backends", "err", err) } else { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) b.FlushBackendConnections(ctx) cancel() slog.Info("reloaded backends") } } }() mux := http.NewServeMux() mux.Handle("/", b) if !*obscurityFlag { mux.Handle("/logz", console) } if *homeRedirect != "" { mux.HandleFunc("/{$}", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, *homeRedirect, http.StatusFound) }) } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() serveGroup, ctx := errgroup.WithContext(ctx) if *listenHTTP != "" { if _, _, err := net.SplitHostPort(*listenHTTP); err != nil { *listenHTTP = net.JoinHostPort("localhost", *listenHTTP) } hs := &http.Server{ Addr: *listenHTTP, Handler: http.MaxBytesHandler(mux, 10*1024), ReadTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second, } l, err := net.Listen("tcp", *listenAddr) if err != nil { logFatal("failed to listen for backends", "err", err) } serveGroup.Go(func() error { slog.Info("listening for HTTP", "addr", hs.Addr) return hs.ListenAndServe() }) serveGroup.Go(func() error { slog.Info("listening for backends", "addr", *listenAddr) for { c, err := l.Accept() if err != nil { return err } go b.HandleBackendConnection(c) } }) serveGroup.Go(func() error { <-ctx.Done() slog.Info("shutting down bastion listener") l.Close() slog.Info("shutting down HTTP server") ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() hs.Shutdown(ctx) return nil }) serveGroup.Wait() slog.Info("exiting", "err", context.Cause(ctx)) return } hs := &http.Server{ Addr: *listenAddr, Handler: http.MaxBytesHandler(mux, 10*1024), ReadTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second, TLSConfig: &tls.Config{ NextProtos: []string{acme.ALPNProto}, GetCertificate: getCertificate, }, } if err := b.ConfigureServer(hs); err != nil { logFatal("failed to configure bastion", "err", err) } if err := http2.ConfigureServer(hs, nil); err != nil { logFatal("failed to configure HTTP/2", "err", err) } slog.Info("listening", "addr", *listenAddr) e := make(chan error, 1) go func() { e <- hs.ListenAndServeTLS("", "") }() select { case <-ctx.Done(): slog.Info("shutting down on interrupt") ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() hs.Shutdown(ctx) case err := <-e: slog.Error("server error", "err", err) } } func logFatal(msg string, args ...interface{}) { slog.Error(msg, args...) os.Exit(1) } torchwood-0.9.0/cmd/litewitness/000077500000000000000000000000001514564101300166455ustar00rootroot00000000000000torchwood-0.9.0/cmd/litewitness/README.md000066400000000000000000000130421514564101300201240ustar00rootroot00000000000000# litewitness litewitness is a synchronous low-latency cosigning witness. (A witness is a service that accepts a new signed tree head, checks its consistency with the previous latest tree head, and returns a signature over it.) It implements the [c2sp.org/tlog-witness](https://c2sp.org/tlog-witness) protocol. It's backed by a SQLite database for storage, and by an ssh-agent for private key operations. To install it, use `go install`. ``` # from anywhere go install filippo.io/torchwood/cmd/{litewitness,witnessctl}@latest # from within a source tree go install filippo.io/torchwood/cmd/{litewitness,witnessctl} ``` litewitness has no config file. All configuration is done via command line flags or `witnessctl` (see below). -db string path to sqlite database (default "litewitness.db") The SQLite database is where known trees and tree heads are stored. It needs to be on a filesystem that supports locking (not a network file system). It will be created if it does not exist. -name string URL-like (e.g. example.com/foo) name of this witness The name of the witness is a URL-like value that will appear in cosignature lines. It does not need to be where the witness is reachable but should be recognizable. -key string SSH fingerprint (with SHA256: prefix) of the witness key -ssh-agent string path to ssh-agent socket (default "litewitness.sock") The witness Ed25519 private key is provided by a ssh-agent instance. The socket is specified explicitly because it's recommended that a dedicated instance is run for litewitness. The use of the ssh-agent protocol allows the key to be provided by a key file, a PKCS#11 module, or custom hardware agents. Example of starting a dedicated ssh-agent and loading a key: ``` ssh-agent -a litewitness.sock SSH_AUTH_SOCK=litewitness.sock ssh-add litewitness.pem ``` -bastion string address of the bastion(s) to reverse proxy through, comma separated, the first online one is selected -listen string address to listen for HTTP requests (default "localhost:7380") -no-listen do not open any listening socket, rely exclusively on bastions Only one of `-bastion` or `-listen` must be specified, or `-no-listen` can be used to rely exclusively on per-log bastions configured in the database. The `-bastion` flag will cause litewitness to serve requests through a bastion reverse proxy (see below). The `-listen` flag will listen for HTTP requests on the specified port. (HTTPS needs to be terminated outside of litewitness.) The bastion flag is an optionally comma-separated list of bastions to try in order until one connects successfully. If the connection drops after establishing, litewitness exits. -obscurity enable obscurity mode (disable / and /logz endpoints) Note that the c2sp.org/tlog-witness protocol is not designed to keep the supported logs or their tree states secret. Moreover, litewitness has no access to any secrets (becuase the private key is in ssh-agent) except arguably the IP addresses of its clients (which are always redacted from /logz). Obscurity mode disables the `/` and `/logz` endpoints to make it harder to enumerate the logs known to the witness. ## witnessctl witnessctl is a CLI tool to operate on the litewitness database. It can be used while litewitness is running. witnessctl add-log -db -origin The `add-log` command adds a new known log starting at a size of zero. Removing a log is not supported, as it presents the risk of signing a split view if re-added. To disable a log, remove all its keys. witnessctl add-key -db -origin -key witnessctl del-key -db -origin -key The `add-key` and `del-key` commands add and remove verifier keys for a known log. The name of the key must match the log origin. witnessctl add-bastion -db -origin -bastion witnessctl del-bastion -db -origin -bastion The `add-bastion` and `del-bastion` commands add and remove bastion addresses for a log. Multiple bastions can be configured for a log and will be used simultaneously. Bastion configuration is reloaded when litewitness receives a SIGHUP signal. witnessctl add-sigsum-log -db -key The `add-sigsum-log` command is a helper that adds a new Sigsum log, computing the origin and key from a 32-byte hex-encoded Ed25519 public key. witnessctl list-logs -db The `pull-logs` command fetches a list of known logs from [a witness network log list](https://github.com/transparency-dev/witness-network/blob/main/site/content/participate.md#automate-discovery-of-logs-in-the-selected-lists) URL or file path. It only adds new logs, it does not remove or change the keys of existing ones. It is designed to run in a cronjob, and by default (without `-verbose`) it only prints output if there are logs with keys different from what is already in the database. This is unexpected and manual investigation as to why the log list changed (or disagrees with manual configuration) is needed. witnessctl pull-logs -db -source -verbose The `list-logs` command lists known logs, in JSON lines like the following. {"origin":"sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562","size":5,"root_hash":"QrtXrQZCCvpIgsSmOsah7HdICzMLLyDfxToMql9WTjY=","keys":["sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562+5202289b+Af/cLU2Y5BJNP+r3iMDC+av9eWCD0fBJVDfzAux5zxAP"]} torchwood-0.9.0/cmd/litewitness/litewitness.go000066400000000000000000000300261514564101300215470ustar00rootroot00000000000000package main import ( "context" "crypto" "crypto/ed25519" "crypto/rand" "crypto/sha256" "crypto/tls" "crypto/x509" "crypto/x509/pkix" "encoding/hex" "errors" "flag" "fmt" "html" "io" "log/slog" "math/big" "net" "net/http" "os" "os/signal" "slices" "strings" "syscall" "time" "golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh/agent" "golang.org/x/net/http2" "zombiezen.com/go/sqlite" "zombiezen.com/go/sqlite/sqlitex" "filippo.io/torchwood/internal/slogconsole" "filippo.io/torchwood/internal/witness" ) var nameFlag = flag.String("name", "", "URL-like (e.g. example.com/foo) name of this witness") var dbFlag = flag.String("db", "litewitness.db", "path to sqlite database") var sshAgentFlag = flag.String("ssh-agent", "litewitness.sock", "path to ssh-agent socket") var listenFlag = flag.String("listen", "localhost:7380", "address to listen for HTTP requests") var noListenFlag = flag.Bool("no-listen", false, "do not open any listening socket, rely exclusively on bastions") var keyFlag = flag.String("key", "", "SSH fingerprint (with SHA256: prefix) of the witness key") var bastionFlag = flag.String("bastion", "", "address of the bastion(s) to reverse proxy through, comma separated, the first online one is selected") var testCertFlag = flag.Bool("testcert", false, "use rootCA.pem for connections to the bastion") var obscurityFlag = flag.Bool("obscurity", false, "enable obscurity mode (disable / and /logz endpoints)") type ConnectionSet struct { connections map[string]func() // connection => cancel func connect func(context.Context, string) } func NewConnectionSet(connect func(context.Context, string)) *ConnectionSet { return &ConnectionSet{ connections: make(map[string]func()), connect: connect, } } func (s *ConnectionSet) Configure(ctx context.Context, addrs []string) { slices.Sort(addrs) // Disconnect addresses that have disappeared. var toDelete []string for addr, cancel := range s.connections { if _, found := slices.BinarySearch(addrs, addr); !found { cancel() // Postpone delete, we can't delete while iterating over the map. toDelete = append(toDelete, addr) } } for _, addr := range toDelete { delete(s.connections, addr) } // Connect new bastions. for _, addr := range addrs { if _, found := s.connections[addr]; found { continue } // Quit early on cancel. if ctx.Err() != nil { break } connectionCtx, cancel := context.WithCancel(ctx) s.connections[addr] = cancel go s.connect(connectionCtx, addr) } } func onSignal(signo os.Signal, callback func()) { c := make(chan os.Signal, 1) signal.Notify(c, signo) go func() { for range c { callback() } }() } func main() { flag.Parse() var level = new(slog.LevelVar) h := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level}) console := slogconsole.New(nil) console.SetFilter(slogconsole.IPAddressFilter) slog.SetDefault(slog.New(slogconsole.MultiHandler(h, console))) onSignal(syscall.SIGUSR1, func() { slog.Info("received USR1 signal, toggling log level") if level.Level() == slog.LevelDebug { level.Set(slog.LevelInfo) } else { level.Set(slog.LevelDebug) } }) signer := connectToSSHAgent() w, err := witness.NewWitness(*dbFlag, *nameFlag, signer, slog.Default()) if err != nil { fatal("creating witness", "err", err) } slog.Info("verifier key", "vkey", w.VerifierKey()) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() mux := http.NewServeMux() mux.Handle("/", w) if !*obscurityFlag { mux.Handle("/logz", console) mux.Handle("/{$}", indexHandler(w)) } srv := &http.Server{ Addr: *listenFlag, Handler: http.MaxBytesHandler(mux, 10*1024), ReadTimeout: 5 * time.Second, WriteTimeout: 5 * time.Second, BaseContext: func(net.Listener) context.Context { return ctx }, } e := make(chan error, 1) bastionSet := NewConnectionSet(func(ctx context.Context, addr string) { var delays = []time.Duration{ 100 * time.Millisecond, 1 * time.Second, 1 * time.Second, 1 * time.Second, 5 * time.Second, 15 * time.Second, 30 * time.Second, 1 * time.Minute, } // If a connection survives for resetRetryDelay, reset the retry delay. const resetRetryDelay = 5 * time.Minute retry := 0 for { startTime := time.Now() err := connectToBastion(ctx, addr, signer, srv, true) duration := time.Since(startTime) slog.Warn("bastion connection failed", "bastion", addr, "duration", duration, "err", err) // Quit early on cancel. if ctx.Err() != nil { return } // If the connection lasted long enough, reset the retry delay. if duration >= resetRetryDelay { retry = 0 } // Wait before retrying. var delay time.Duration if retry < len(delays) { delay = delays[retry] } else { delay = delays[len(delays)-1] } slog.Info("waiting before reconnecting to bastion", "bastion", addr, "delay", delay) timer := time.NewTimer(delay) select { case <-ctx.Done(): timer.Stop() return case <-timer.C: } retry++ } }) // Handle log-specific bastions. logBastions, err := w.AllBastions() if err != nil { fatal("failed looking up bastions", "err", err) } bastionSet.Configure(ctx, logBastions) // At this point, ownership of bastionSet belongs with the signal goroutine, // and must no longer be accessed by main goroutine. onSignal(syscall.SIGHUP, func() { slog.Info("received SIGHUP, reconfiguring bastions") logBastions, err := w.AllBastions() if err != nil { slog.Warn("failed looking up bastions", "err", err) return } bastionSet.Configure(ctx, logBastions) }) if *bastionFlag != "" { go func() { for _, bastion := range strings.Split(*bastionFlag, ",") { err := connectToBastion(ctx, bastion, signer, srv, false) if err == errBastionDisconnected { // Connection succeeded and then was interrupted. Restart to // let the scheduler apply any backoff, and then retry all bastions. e <- err return } } e <- errors.New("couldn't connect to any bastion") }() } else if !*noListenFlag { go func() { slog.Info("listening", "addr", *listenFlag) e <- srv.ListenAndServe() }() } else if len(logBastions) == 0 { slog.Warn("configured to not open a listening port, but no bastions configured") } select { case <-ctx.Done(): slog.Info("shutting down") ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() srv.Shutdown(ctx) case err := <-e: fatal("server error", "err", err) } } func connectToSSHAgent() *signer { conn, err := net.Dial("unix", *sshAgentFlag) if err != nil { fatal("dialing ssh-agent", "err", err) } a := agent.NewClient(conn) signers, err := a.Signers() if err != nil { fatal("getting keys from ssh-agent", "err", err) } slog.Info("connected to ssh-agent", "addr", *sshAgentFlag) var signer *signer var keys []string for _, s := range signers { if s.PublicKey().Type() != ssh.KeyAlgoED25519 { continue } ss, err := newSigner(s) if err != nil { fatal("new signer", "err", err) } if ssh.FingerprintSHA256(s.PublicKey()) == *keyFlag { signer = ss break } // For backwards compatibility, also accept a hex-encoded SHA-256 hash // of the public key, which is what -key used to be. hh := sha256.Sum256(ss.Public().(ed25519.PublicKey)) h := hex.EncodeToString(hh[:]) if h == *keyFlag { signer = ss break } keys = append(keys, h) } if signer == nil { fatal("ssh-agent does not contain Ed25519 key", "expected", *keyFlag, "found", keys) } slog.Info("found key", "fingerprint", *keyFlag) return signer } type signer struct { s ssh.Signer p ed25519.PublicKey } func newSigner(s ssh.Signer) (*signer, error) { // agent.Key doesn't implement ssh.CryptoPublicKey. k, err := ssh.ParsePublicKey(s.PublicKey().Marshal()) if err != nil { return nil, errors.New("internal error: ssh public key can't be parsed") } ck, ok := k.(ssh.CryptoPublicKey) if !ok { return nil, errors.New("internal error: ssh public key can't be retrieved") } pk, ok := ck.CryptoPublicKey().(ed25519.PublicKey) if !ok { return nil, errors.New("internal error: ssh public key type is not Ed25519") } return &signer{s: s, p: pk}, nil } func (s *signer) Public() crypto.PublicKey { return s.p } func (s *signer) Sign(rand io.Reader, data []byte, opts crypto.SignerOpts) (signature []byte, err error) { if opts.HashFunc() != crypto.Hash(0) { return nil, errors.New("expected crypto.Hash(0)") } sig, err := s.s.Sign(rand, data) if err != nil { return nil, err } return sig.Blob, nil } const indexHeader = ` litewitness
`

func indexHandler(w *witness.Witness) http.HandlerFunc {
	return func(rw http.ResponseWriter, r *http.Request) {
		db, err := witness.OpenDB(*dbFlag)
		if err != nil {
			http.Error(rw, "internal error", http.StatusInternalServerError)
			return
		}
		defer db.Close()

		rw.Header().Set("Content-Type", "text/html; charset=utf-8")
		io.WriteString(rw, indexHeader)
		fmt.Fprintf(rw, "# litewitness %s\n\n", html.EscapeString(*nameFlag))
		fmt.Fprintf(rw, "%s\n\n", html.EscapeString(w.VerifierKey()))
		fmt.Fprintf(rw, "## Logs\n\n")
		sqlitex.Execute(db, "SELECT origin, tree_size, tree_hash FROM log", &sqlitex.ExecOptions{
			ResultFunc: func(stmt *sqlite.Stmt) error {
				fmt.Fprintf(rw, "- %s\n  (size %d, root %s)\n\n",
					html.EscapeString(stmt.ColumnText(0)),
					stmt.ColumnInt64(1), stmt.ColumnText(2))
				return nil
			},
		})
	}
}

var errBastionDisconnected = errors.New("connection to bastion interrupted")

func connectToBastion(ctx context.Context, bastion string, signer *signer, srv *http.Server, logSpecific bool) error {
	slog.Info("connecting to bastion", "bastion", bastion)
	cert, err := selfSignedCertificate(signer)
	if err != nil {
		fatal("generating self-signed certificate", "err", err)
	}
	dialCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()
	var roots *x509.CertPool
	if *testCertFlag {
		roots = x509.NewCertPool()
		root, err := os.ReadFile("rootCA.pem")
		if err != nil {
			fatal("reading test root", "err", err)
		}
		roots.AppendCertsFromPEM(root)
	}
	conn, err := (&tls.Dialer{
		Config: &tls.Config{
			Certificates: []tls.Certificate{{
				Certificate: [][]byte{cert},
				PrivateKey:  signer,
			}},
			MinVersion: tls.VersionTLS13,
			MaxVersion: tls.VersionTLS13,
			NextProtos: []string{"bastion/0"},
			RootCAs:    roots,
		},
	}).DialContext(dialCtx, "tcp", bastion)
	if err != nil {
		slog.Info("connecting to bastion failed", "bastion", bastion, "err", err)
		return fmt.Errorf("connecting to bastion: %v", err)
	}
	// Ensure that the connection is closed when our context is cancelled.
	ctx, cancel = context.WithCancel(ctx)
	defer cancel()
	go func(ctx context.Context) {
		// TODO: gracefully complete in-flight requests.
		<-ctx.Done()
		conn.Close()
	}(ctx)

	slog.Info("connected to bastion", "bastion", bastion)
	if logSpecific {
		ctx = witness.ContextWithBastion(ctx, bastion)
	}
	// TODO: find a way to surface the fatal error, especially since with
	// TLS 1.3 it might be that the bastion rejected the client certificate.
	(&http2.Server{
		CountError: func(errType string) {
			slog.Debug("HTTP/2 server error", "type", errType)
		},
	}).ServeConn(conn, &http2.ServeConnOpts{
		Context:    ctx,
		BaseConfig: srv,
		Handler:    srv.Handler,
	})
	return errBastionDisconnected
}

func selfSignedCertificate(key crypto.Signer) ([]byte, error) {
	tmpl := &x509.Certificate{
		SerialNumber: big.NewInt(1),
		Subject:      pkix.Name{CommonName: "litewitness"},
		NotBefore:    time.Now().Add(-1 * time.Hour),
		NotAfter:     time.Now().Add(24 * time.Hour),
		KeyUsage:     x509.KeyUsageDigitalSignature,
		ExtKeyUsage:  []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth},
	}
	return x509.CreateCertificate(rand.Reader, tmpl, tmpl, key.Public(), key)
}

func fatal(msg string, args ...any) {
	slog.Error(msg, args...)
	os.Exit(1)
}
torchwood-0.9.0/cmd/litewitness/litewitness_test.go000066400000000000000000000064331514564101300226130ustar00rootroot00000000000000package main

import (
	"crypto/tls"
	"net"
	"net/http"
	"os"
	"os/exec"
	"path/filepath"
	"runtime"
	"slices"
	"strconv"
	"strings"
	"syscall"
	"testing"
	"time"

	"github.com/rogpeppe/go-internal/testscript"
)

func TestMain(m *testing.M) {
	testscript.Main(m, map[string]func(){
		"litewitness": func() {
			main()
		},
	})
}

func TestScript(t *testing.T) {
	// On macOS, the default TMPDIR is too long for ssh-agent socket paths.
	if runtime.GOOS == "darwin" {
		t.Setenv("TMPDIR", "/tmp")
	}
	p := testscript.Params{
		Dir: "testdata",
		Setup: func(e *testscript.Env) error {
			bindir := filepath.SplitList(os.Getenv("PATH"))[0]
			// Coverage is not collected because of https://go.dev/issue/60182.
			cmd := exec.Command("go", "build", "-o", bindir)
			if testing.CoverMode() != "" {
				cmd.Args = append(cmd.Args, "-cover")
			}
			cmd.Args = append(cmd.Args, "filippo.io/torchwood/cmd/witnessctl")
			cmd.Args = append(cmd.Args, "filippo.io/torchwood/cmd/litebastion")
			cmd.Stdout = os.Stdout
			cmd.Stderr = os.Stderr
			return cmd.Run()
		},
		Cmds: map[string]func(ts *testscript.TestScript, neg bool, args []string){
			"waitfor": func(ts *testscript.TestScript, neg bool, args []string) {
				if len(args) != 1 {
					ts.Fatalf("usage: waitfor ")
				}
				if strings.HasPrefix(args[0], "http") {
					var lastErr error
					for i := 0; i < 50; i++ {
						t := http.DefaultTransport.(*http.Transport).Clone()
						t.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
						r, err := (&http.Client{Transport: t}).Get(args[0])
						if err == nil && r.StatusCode != http.StatusBadGateway {
							return
						}
						time.Sleep(100 * time.Millisecond)
						lastErr = err
					}
					ts.Fatalf("timeout waiting for %s: %v", args[0], lastErr)
				}
				protocol := "unix"
				if strings.Contains(args[0], ":") {
					protocol = "tcp"
				}
				var lastErr error
				for i := 0; i < 50; i++ {
					conn, err := net.Dial(protocol, args[0])
					if err == nil {
						conn.Close()
						return
					}
					time.Sleep(100 * time.Millisecond)
					lastErr = err
				}
				ts.Fatalf("timeout waiting for %s: %v", args[0], lastErr)
			},
			"killall": func(ts *testscript.TestScript, neg bool, args []string) {
				if neg {
					ts.Fatalf("unsupported: !killall")
				}
				signo := os.Interrupt
				if len(args) > 0 {
					if strings.HasPrefix(args[0], "-") {
						signalName, _ := strings.CutPrefix(args[0][1:], "SIG")

						if signalName == "HUP" {
							signo = syscall.SIGHUP
							args = args[1:]
						} else {
							ts.Fatalf("kill: unknown signal name %q", signalName)
						}
					}
				}
				for _, cmd := range ts.BackgroundCmds() {
					if len(args) > 0 {
						// Only kill processes with this name.
						name := filepath.Base(cmd.Args[0])
						if !slices.Contains(args, name) {
							continue
						}
					}
					cmd.Process.Signal(signo)
				}
			},
			"linecount": func(ts *testscript.TestScript, neg bool, args []string) {
				if len(args) != 2 {
					ts.Fatalf("usage: linecount  N")
				}
				count, err := strconv.Atoi(args[1])
				if err != nil {
					ts.Fatalf("invalid count: %v", args[1])
				}
				if got := strings.Count(ts.ReadFile(args[0]), "\n"); got != count {
					ts.Fatalf("%v has %d lines, not %d", args[0], got, count)
				}
			},
		},
	}
	testscript.Run(t, p)
}
torchwood-0.9.0/cmd/litewitness/systemd/000077500000000000000000000000001514564101300203355ustar00rootroot00000000000000torchwood-0.9.0/cmd/litewitness/systemd/litewitness-ssh-agent.service000066400000000000000000000004471514564101300261650ustar00rootroot00000000000000[Unit]
Description=Litewitness SSH Agent
StartLimitIntervalSec=0

[Service]
Environment=SSH_AUTH_SOCK=/var/run/litewitness.sock
ExecStart=/usr/bin/ssh-agent -D -a $SSH_AUTH_SOCK
ExecStartPost=/usr/bin/ssh-add /etc/litewitness/litewitness.pem
Restart=always
RestartSteps=10
RestartMaxDelaySec=1m
torchwood-0.9.0/cmd/litewitness/systemd/litewitness.service000066400000000000000000000006631514564101300242760ustar00rootroot00000000000000[Unit]
Description=Litewitness Transparency Log Witness
Wants=network-online.target litewitness-ssh-agent.service
StartLimitIntervalSec=0

[Service]
EnvironmentFile=/etc/litewitness/litewitness.conf
ExecStart=/usr/local/bin/litewitness -name "$ORIGIN" -key "$KEY" \
	-db /var/lib/litewitness/litewitness.db -ssh-agent /var/run/litewitness.sock
Restart=always
RestartSteps=10
RestartMaxDelaySec=1m

[Install]
WantedBy=multi-user.target
torchwood-0.9.0/cmd/litewitness/testdata/000077500000000000000000000000001514564101300204565ustar00rootroot00000000000000torchwood-0.9.0/cmd/litewitness/testdata/bastion.txt000066400000000000000000000167541514564101300226730ustar00rootroot00000000000000# gentest seed b4e385f4358f7373cfa9184b176f3cccf808e795baf04092ddfde9461014f0c4

# set up log
exec witnessctl add-sigsum-log -key=ffdc2d4d98e4124d3feaf788c0c2f9abfd796083d1f0495437f302ec79cf100f

# start bastion
exec litebastion -tls-cert localhost.pem -tls-key localhost-key.pem -backends=backends.txt &litebastion&
waitfor localhost:8443

# start ssh-agent
env SSH_AUTH_SOCK=$WORK/s # barely below the max path length
! exec ssh-agent -a $SSH_AUTH_SOCK -D & # ssh-agent always exits 2
waitfor $SSH_AUTH_SOCK
chmod 600 witness_key.pem
exec ssh-add witness_key.pem

# fail to start litewitness
! exec litewitness -ssh-agent=$SSH_AUTH_SOCK -name=example.com/witness -bastion=0.0.0.0:443,localhost:8443 -testcert -key=e933707e0e36c30f01d94b5d81e742da373679d88eb0f85f959ccd80b83b992a

# reload backends
mv correct_backends.txt backends.txt
killall -SIGHUP litebastion

# start litewitness
exec litewitness -ssh-agent=$SSH_AUTH_SOCK -name=example.com/witness -bastion=0.0.0.0:443,localhost:8443 -testcert -key=e933707e0e36c30f01d94b5d81e742da373679d88eb0f85f959ccd80b83b992a &litewitness&
waitfor https://localhost:8443/e933707e0e36c30f01d94b5d81e742da373679d88eb0f85f959ccd80b83b992a/

# add-checkpoint
exec hurl --cacert rootCA.pem --test add-checkpoint.hurl

# check that litewitness shut down cleanly
killall
wait litewitness
stderr 'shutting down'

# check the litebastion output
killall
wait litebastion
stderr 'reloaded backends'
stderr 'msg="accepted new backend connection" backend=e933707e0e36c30f01d94b5d81e742da373679d88eb0f85f959ccd80b83b992a'

# witnessctl list-logs
exec witnessctl list-logs
stdout sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562
stdout "size":1


-- backends.txt --
f97f12534ff2478cfc36b00d09a85d4faeb6589ac19a0895c348a499627c531c


-- correct_backends.txt --
f97f12534ff2478cfc36b00d09a85d4faeb6589ac19a0895c348a499627c531c
e933707e0e36c30f01d94b5d81e742da373679d88eb0f85f959ccd80b83b992a


-- witness_key.pem --
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtz
c2gtZWQyNTUxOQAAACBkhIrYq+1uhZgbOzh1slK4dn67SwL3A6yjsecbvWqOUAAA
AIgN5+09DeftPQAAAAtzc2gtZWQyNTUxOQAAACBkhIrYq+1uhZgbOzh1slK4dn67
SwL3A6yjsecbvWqOUAAAAEAx/8IRbsvgA6yqgAq3B1e9fVMgbj/r72ptB5bZVTCz
T2SEitir7W6FmBs7OHWyUrh2frtLAvcDrKOx5xu9ao5QAAAAAAECAwQF
-----END OPENSSH PRIVATE KEY-----


-- localhost.pem --
-----BEGIN CERTIFICATE-----
MIID/TCCAmWgAwIBAgIUPCwTk8pjBLPr+3czZAaGcg8RuDowDQYJKoZIhvcNAQEL
BQAwNjELMAkGA1UEBhMCVVMxEDAOBgNVBAoMB1Rlc3QgQ0ExFTATBgNVBAMMDFRl
c3QgUm9vdCBDQTAgFw0yNTA5MzAxMjAzMDdaGA8yMDU1MDkyMzEyMDMwN1owMDEL
MAkGA1UEBhMCVVMxDTALBgNVBAoMBFRlc3QxEjAQBgNVBAMMCWxvY2FsaG9zdDCC
ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKmcTiZ4FX92VKuQU9zioRv7
nqsdEmMoOKs1nVJd7myLMi/02flEbDAmtDkh4LFPuiNw2OaqphBYM4MlZCxofKrL
+/Skb763IOA5Ly97QGrOzYBtksPJlAPlF8pvRJO22ZIlBFpZUsivOZnFyhnvhFki
TMMDpvNqtAAIee3Hv0y4EolhausAecaErlPc510LmcoU+VF5bYgur68nLzSvetzG
qKblYkhFi3pEAJ7wcFUWZbFlNKDQVB+o4aEnqGj5mvYIbsh8UNJIqANA5qgxjoGc
8F5eqWlXmFA9+R9lRe5JW6Ia91V8eKsTCRKuuFvH2gIs8pxrzV3fvdrXXHJCzxUC
AwEAAaOBhjCBgzAUBgNVHREEDTALgglsb2NhbGhvc3QwCQYDVR0TBAIwADALBgNV
HQ8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwHQYDVR0OBBYEFO0cjjCGyrIC
6ov0YT60AlyXsFRsMB8GA1UdIwQYMBaAFA3F8nWmTSb58CraZhp+Ur63NaC7MA0G
CSqGSIb3DQEBCwUAA4IBgQA4EzY7MH95cFlVzbhk1loSlPRgDaWBga1y7U17jOnx
ulqGr6ySInAounhZPDFfwIxyAbZE4N/Bq1omkfI+osvGBbkUdj7Z7ktllj5pON0t
gdmCq/S1Q/tEcBvaS1FjR+AMu2enUVKn6mMXgjyj4xZNutqbXfKD4wdL1qP2KLbZ
M832TqI2tVUVOGSq0zfsPe5sDD/NHSvkKaeM+ZGlB955fzBsICOx4W5mcz/Zhfk5
gbJZsNPVlt8al3/iO0AIOGXzZOnlo8baHmh4TJH+uRYgHh8KFAvA8rebStg+Rn5w
j+uAFgG+gf7JSVo6bjLWMkmkN6ooJFYMwwXav83STOU0TAQdqX+YM97Tke+U3DEd
MqeX+hS+LyXGG0hHardo9xrRs1/W9xP453ybm2KFPODPyPSTuyOSUzleIeRGfmXg
Ri++XfeZMNcPYNAj0joJEmoszmY17vuoBDidhYVQE+g+4uxhJeGROWvNz2ONqxqw
QTyv4nS0hwl7T0VCoBC+bSg=
-----END CERTIFICATE-----


-- localhost-key.pem --
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCpnE4meBV/dlSr
kFPc4qEb+56rHRJjKDirNZ1SXe5sizIv9Nn5RGwwJrQ5IeCxT7ojcNjmqqYQWDOD
JWQsaHyqy/v0pG++tyDgOS8ve0Bqzs2AbZLDyZQD5RfKb0STttmSJQRaWVLIrzmZ
xcoZ74RZIkzDA6bzarQACHntx79MuBKJYWrrAHnGhK5T3OddC5nKFPlReW2ILq+v
Jy80r3rcxqim5WJIRYt6RACe8HBVFmWxZTSg0FQfqOGhJ6ho+Zr2CG7IfFDSSKgD
QOaoMY6BnPBeXqlpV5hQPfkfZUXuSVuiGvdVfHirEwkSrrhbx9oCLPKca81d373a
11xyQs8VAgMBAAECggEAH3wF38s7xmDrX7uXbbHeEUk4j3ACmUh+mH2H2iHYn+qI
4vETQ1vJr3iHzPE2egOgPHL2uH7l+7O7wDUBLuMofTYHa8bYfXEWF7lVwn0hHJKO
ADCW5WQ2ZzCwJWJZOwhew+u+Lp1VKi6oxRw7o2vcSAV/dVXouFfO2RC5vYNuRem5
m/3xaOlDpPgmhkRV/I+AJk0f8zNSWpU+izonxqgSINREyemWVY705s+HirMEQgRV
GN0ftwNGuz7/5rk8eH1iAGsTK9NEi77hCtyUKYhOC8NWzxGgS2mhqHslms8M2ZOm
rBiZZ5Sh5U53r6IWCIKqG92fzFZ/fcSZGUDHrcjcYQKBgQDptvr/f21Ze9OCrGmk
PktnM5v0DiP0z0Ywf28oz9rTneletJqQTsw6uP/GnIQrXwnaTWICjJtiMp9wxdM+
9drKhZufImC0NGTLz1vJxLR5x5ao8T/juPIxm7TLfH5XjZMJgRMD1D5IvW8k9hWU
1yDJxNCx1ikY7N3FP1XwmFrhRQKBgQC5yIeeABqXCy2A/wYrWqZ3zYFWe4t6Qqzm
afuoUGaqXK9GjgDemOlUKzhUb08tnPNXzKJagqmM4F0zKgcwq61ECH3SliMt82PL
1i1KTB2bUSXnwXOsbQyWVXz7YuVsWYakdHt6Vbbmy1JexRhfV3q5HCuj1fElNLTv
YheLV0JLkQKBgEFuhR764fZngHPZKUpeVmXyQPs26kIjtZbmVoyqhK0yTJ/DGHLG
XM8j9Bf6wdYSqYOAnqvwCaCYY6MC/31k/3grp8IJseFBueaFi0EV3SErC7cIs8Zh
hQz2dstxcz232S6UAGrWBQoAXxmN+8TL5dYXUAY52w+rYPtUHA9b2DWxAoGAUlBL
7jBjl5qnPalApYLTkO8nqBazFKdoDerVSpzc8AyCyELwla+wac+AdMCglzgcBUGw
iWOtFbLu+FVdvC3EZglRHjXRPnHBPLYXePzCfWd14PowcywZ0J3t8z+9IMWFx2Wo
s+o4UIezZjPzeYK76DpYB44p+u8gX5PZlK5DvFECgYBAKrvWEOy8J29QB3er+ERv
qvmczAvfJWWgheKMxQhx/3pu6UW/v/hmcKngMWjr/XqxFKp05ca1VBCmPB3s0776
IMJnvyTUFs5S7COpPJOC81JB/28UGQFcAZ+Kiwd1sx5gd1Q+EnaAhyxplWi/m6Zv
tKfWSmtc1XhMiUpw331jNg==
-----END PRIVATE KEY-----


-- rootCA.pem --
-----BEGIN CERTIFICATE-----
MIIETzCCAregAwIBAgIUauyztCDIDrmE46pw/Pg5nn7U2gUwDQYJKoZIhvcNAQEL
BQAwNjELMAkGA1UEBhMCVVMxEDAOBgNVBAoMB1Rlc3QgQ0ExFTATBgNVBAMMDFRl
c3QgUm9vdCBDQTAgFw0yNTA5MzAxMjAyMzFaGA8yMDU1MDkyMzEyMDIzMVowNjEL
MAkGA1UEBhMCVVMxEDAOBgNVBAoMB1Rlc3QgQ0ExFTATBgNVBAMMDFRlc3QgUm9v
dCBDQTCCAaIwDQYJKoZIhvcNAQEBBQADggGPADCCAYoCggGBAKHLd07/qunxEFL8
BDCk1J+2m/TcUM0oG6dFVZjf/4+xSDH3F8tA6ZeqBLriQRBTuqwFBZVvoJwqJjip
CAVzWkvvyiuvAeywJNXIe7kqmmDeAEoq2Z/F9j6p5y8+ddy7VX6pTdsVYOyGsBCx
GZb7tuj0mtLl18HuezH0P7prDHBuNx+FjMblHXr5yslQ95d53v4dmI8ONObhS2ju
ltgveHzaWbssaAqoVwfWJfWJtc0C3IgGh8MgFhhdzTQ4DvHxYLjwGB0eE/Vpu757
B4pvYcT7JjBtjv8VyhW62ZPZNBlNjFOtFHhxZcMGDiOsyy9OgwIzzvy9UMg0WisZ
OxZmRkASuApoMRftSg2ZmTG5THdFgEQD6pqE3llmhO57ZTt1DvPNUlUh8ND9u9Ov
TcCjEeBXJRRjx7tUhob3mU+tYAi9DbXX/Wje0wBaRKh073wzRoAsFU9PspgKsZq4
JNDFSey4DK8W/O8jYvmNfuQ3TJwiAWxNUDDUdGN3TwnaKRQesQIDAQABo1MwUTAd
BgNVHQ4EFgQUDcXydaZNJvnwKtpmGn5Svrc1oLswHwYDVR0jBBgwFoAUDcXydaZN
JvnwKtpmGn5Svrc1oLswDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOC
AYEAK4/oXvmhohTM10N+ZpaHDC8S+xr3z2tayJiNOrzzVE209MEOMCYlEz9IeyUx
q5rmD4SJRLmyR8DpkO3dO4MjZYv2Nq+NS2kb5PmhUKmIEcwfFmXHjX/FCqPVwdH9
yM63o3GS1PhbQ6vzK4NMsOUT3nvnD87gFvXKOSn+gpETmYNA55wD/Qcu8LhqDk0I
YDrXiAja++gEf7/RZ5oPYNXi8t1+PjUDhP8J/tz9G90MZBR/FztTVIGTdpIoVwDB
mMpAHW/7S9hEwVlZ69CUMfrm0uG68xaJPNH1MmaZdEdI0PnSU2nVwLLLqsYQmZqp
+AGOq+6qOcvSMaFIJ3OLPUKqgn40f5J7I6retaER53Rahe/15Hy8H/sRsZQFPjeW
qi86vkgd5GFSu2GNHN/Mhq7dHWq9U0mNO+BZt/G6du3gJ+SaO1UkJhzpNiRY37sR
FU3HWF4uYVCt+OHqJ4we9XUKT80Y4ejNYkG25H+9DOy/Gtwb4qquqBoZYRUUThA1
kJHt
-----END CERTIFICATE-----


-- add-checkpoint.hurl --
POST https://localhost:8443/e933707e0e36c30f01d94b5d81e742da373679d88eb0f85f959ccd80b83b992a/add-checkpoint
```
old 0

sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562
1
KgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=

— sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562 UgIom7fPZTqpxWWhyjWduBvTvGVqsokMbqTArsQilegKoFBJQjUFAmQ0+YeSPM3wfUQMFSzVnnNuWRTYrajXpNUbIQY=
```
HTTP/2 200
[Asserts]
body contains "— example.com/witness"
torchwood-0.9.0/cmd/litewitness/testdata/bastionlocal.txt000066400000000000000000000202351514564101300236730ustar00rootroot00000000000000# gentest seed b4e385f4358f7373cfa9184b176f3cccf808e795baf04092ddfde9461014f0c4

# set up log
exec witnessctl add-sigsum-log -key=ffdc2d4d98e4124d3feaf788c0c2f9abfd796083d1f0495437f302ec79cf100f

# start bastion
exec litebastion -tls-cert localhost.pem -tls-key localhost-key.pem -backends=backends.txt -listen localhost:8444 -listen-http 8080 &litebastion&
waitfor localhost:8444

# start ssh-agent
env SSH_AUTH_SOCK=$WORK/s # barely below the max path length
! exec ssh-agent -a $SSH_AUTH_SOCK -D & # ssh-agent always exits 2
waitfor $SSH_AUTH_SOCK
chmod 600 witness_key.pem
exec ssh-add witness_key.pem

# fail to start litewitness
! exec litewitness -ssh-agent=$SSH_AUTH_SOCK -name=example.com/witness -bastion=0.0.0.0:443,localhost:8444 -testcert -key=e933707e0e36c30f01d94b5d81e742da373679d88eb0f85f959ccd80b83b992a

# reload backends
mv correct_backends.txt backends.txt
killall -SIGHUP litebastion

# start litewitness
exec litewitness -ssh-agent=$SSH_AUTH_SOCK -name=example.com/witness -bastion=0.0.0.0:443,localhost:8444 -testcert -key=e933707e0e36c30f01d94b5d81e742da373679d88eb0f85f959ccd80b83b992a &litewitness&
waitfor http://localhost:8080/e933707e0e36c30f01d94b5d81e742da373679d88eb0f85f959ccd80b83b992a/

# add-checkpoint to bastion interface
! exec hurl --cacert rootCA.pem --test add-checkpoint-public.hurl
# add-checkpoint to local interface
exec hurl --cacert rootCA.pem --test add-checkpoint.hurl

# check that litewitness shut down cleanly
killall
wait litewitness
stderr 'shutting down'

# check the litebastion output
killall
wait litebastion
stderr 'reloaded backends'
stderr 'msg="accepted new backend connection" backend=e933707e0e36c30f01d94b5d81e742da373679d88eb0f85f959ccd80b83b992a'

# witnessctl list-logs
exec witnessctl list-logs
stdout sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562
stdout "size":1


-- backends.txt --
f97f12534ff2478cfc36b00d09a85d4faeb6589ac19a0895c348a499627c531c


-- correct_backends.txt --
f97f12534ff2478cfc36b00d09a85d4faeb6589ac19a0895c348a499627c531c
e933707e0e36c30f01d94b5d81e742da373679d88eb0f85f959ccd80b83b992a


-- witness_key.pem --
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtz
c2gtZWQyNTUxOQAAACBkhIrYq+1uhZgbOzh1slK4dn67SwL3A6yjsecbvWqOUAAA
AIgN5+09DeftPQAAAAtzc2gtZWQyNTUxOQAAACBkhIrYq+1uhZgbOzh1slK4dn67
SwL3A6yjsecbvWqOUAAAAEAx/8IRbsvgA6yqgAq3B1e9fVMgbj/r72ptB5bZVTCz
T2SEitir7W6FmBs7OHWyUrh2frtLAvcDrKOx5xu9ao5QAAAAAAECAwQF
-----END OPENSSH PRIVATE KEY-----


-- localhost.pem --
-----BEGIN CERTIFICATE-----
MIID/TCCAmWgAwIBAgIUPCwTk8pjBLPr+3czZAaGcg8RuDowDQYJKoZIhvcNAQEL
BQAwNjELMAkGA1UEBhMCVVMxEDAOBgNVBAoMB1Rlc3QgQ0ExFTATBgNVBAMMDFRl
c3QgUm9vdCBDQTAgFw0yNTA5MzAxMjAzMDdaGA8yMDU1MDkyMzEyMDMwN1owMDEL
MAkGA1UEBhMCVVMxDTALBgNVBAoMBFRlc3QxEjAQBgNVBAMMCWxvY2FsaG9zdDCC
ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKmcTiZ4FX92VKuQU9zioRv7
nqsdEmMoOKs1nVJd7myLMi/02flEbDAmtDkh4LFPuiNw2OaqphBYM4MlZCxofKrL
+/Skb763IOA5Ly97QGrOzYBtksPJlAPlF8pvRJO22ZIlBFpZUsivOZnFyhnvhFki
TMMDpvNqtAAIee3Hv0y4EolhausAecaErlPc510LmcoU+VF5bYgur68nLzSvetzG
qKblYkhFi3pEAJ7wcFUWZbFlNKDQVB+o4aEnqGj5mvYIbsh8UNJIqANA5qgxjoGc
8F5eqWlXmFA9+R9lRe5JW6Ia91V8eKsTCRKuuFvH2gIs8pxrzV3fvdrXXHJCzxUC
AwEAAaOBhjCBgzAUBgNVHREEDTALgglsb2NhbGhvc3QwCQYDVR0TBAIwADALBgNV
HQ8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwHQYDVR0OBBYEFO0cjjCGyrIC
6ov0YT60AlyXsFRsMB8GA1UdIwQYMBaAFA3F8nWmTSb58CraZhp+Ur63NaC7MA0G
CSqGSIb3DQEBCwUAA4IBgQA4EzY7MH95cFlVzbhk1loSlPRgDaWBga1y7U17jOnx
ulqGr6ySInAounhZPDFfwIxyAbZE4N/Bq1omkfI+osvGBbkUdj7Z7ktllj5pON0t
gdmCq/S1Q/tEcBvaS1FjR+AMu2enUVKn6mMXgjyj4xZNutqbXfKD4wdL1qP2KLbZ
M832TqI2tVUVOGSq0zfsPe5sDD/NHSvkKaeM+ZGlB955fzBsICOx4W5mcz/Zhfk5
gbJZsNPVlt8al3/iO0AIOGXzZOnlo8baHmh4TJH+uRYgHh8KFAvA8rebStg+Rn5w
j+uAFgG+gf7JSVo6bjLWMkmkN6ooJFYMwwXav83STOU0TAQdqX+YM97Tke+U3DEd
MqeX+hS+LyXGG0hHardo9xrRs1/W9xP453ybm2KFPODPyPSTuyOSUzleIeRGfmXg
Ri++XfeZMNcPYNAj0joJEmoszmY17vuoBDidhYVQE+g+4uxhJeGROWvNz2ONqxqw
QTyv4nS0hwl7T0VCoBC+bSg=
-----END CERTIFICATE-----


-- localhost-key.pem --
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCpnE4meBV/dlSr
kFPc4qEb+56rHRJjKDirNZ1SXe5sizIv9Nn5RGwwJrQ5IeCxT7ojcNjmqqYQWDOD
JWQsaHyqy/v0pG++tyDgOS8ve0Bqzs2AbZLDyZQD5RfKb0STttmSJQRaWVLIrzmZ
xcoZ74RZIkzDA6bzarQACHntx79MuBKJYWrrAHnGhK5T3OddC5nKFPlReW2ILq+v
Jy80r3rcxqim5WJIRYt6RACe8HBVFmWxZTSg0FQfqOGhJ6ho+Zr2CG7IfFDSSKgD
QOaoMY6BnPBeXqlpV5hQPfkfZUXuSVuiGvdVfHirEwkSrrhbx9oCLPKca81d373a
11xyQs8VAgMBAAECggEAH3wF38s7xmDrX7uXbbHeEUk4j3ACmUh+mH2H2iHYn+qI
4vETQ1vJr3iHzPE2egOgPHL2uH7l+7O7wDUBLuMofTYHa8bYfXEWF7lVwn0hHJKO
ADCW5WQ2ZzCwJWJZOwhew+u+Lp1VKi6oxRw7o2vcSAV/dVXouFfO2RC5vYNuRem5
m/3xaOlDpPgmhkRV/I+AJk0f8zNSWpU+izonxqgSINREyemWVY705s+HirMEQgRV
GN0ftwNGuz7/5rk8eH1iAGsTK9NEi77hCtyUKYhOC8NWzxGgS2mhqHslms8M2ZOm
rBiZZ5Sh5U53r6IWCIKqG92fzFZ/fcSZGUDHrcjcYQKBgQDptvr/f21Ze9OCrGmk
PktnM5v0DiP0z0Ywf28oz9rTneletJqQTsw6uP/GnIQrXwnaTWICjJtiMp9wxdM+
9drKhZufImC0NGTLz1vJxLR5x5ao8T/juPIxm7TLfH5XjZMJgRMD1D5IvW8k9hWU
1yDJxNCx1ikY7N3FP1XwmFrhRQKBgQC5yIeeABqXCy2A/wYrWqZ3zYFWe4t6Qqzm
afuoUGaqXK9GjgDemOlUKzhUb08tnPNXzKJagqmM4F0zKgcwq61ECH3SliMt82PL
1i1KTB2bUSXnwXOsbQyWVXz7YuVsWYakdHt6Vbbmy1JexRhfV3q5HCuj1fElNLTv
YheLV0JLkQKBgEFuhR764fZngHPZKUpeVmXyQPs26kIjtZbmVoyqhK0yTJ/DGHLG
XM8j9Bf6wdYSqYOAnqvwCaCYY6MC/31k/3grp8IJseFBueaFi0EV3SErC7cIs8Zh
hQz2dstxcz232S6UAGrWBQoAXxmN+8TL5dYXUAY52w+rYPtUHA9b2DWxAoGAUlBL
7jBjl5qnPalApYLTkO8nqBazFKdoDerVSpzc8AyCyELwla+wac+AdMCglzgcBUGw
iWOtFbLu+FVdvC3EZglRHjXRPnHBPLYXePzCfWd14PowcywZ0J3t8z+9IMWFx2Wo
s+o4UIezZjPzeYK76DpYB44p+u8gX5PZlK5DvFECgYBAKrvWEOy8J29QB3er+ERv
qvmczAvfJWWgheKMxQhx/3pu6UW/v/hmcKngMWjr/XqxFKp05ca1VBCmPB3s0776
IMJnvyTUFs5S7COpPJOC81JB/28UGQFcAZ+Kiwd1sx5gd1Q+EnaAhyxplWi/m6Zv
tKfWSmtc1XhMiUpw331jNg==
-----END PRIVATE KEY-----


-- rootCA.pem --
-----BEGIN CERTIFICATE-----
MIIETzCCAregAwIBAgIUauyztCDIDrmE46pw/Pg5nn7U2gUwDQYJKoZIhvcNAQEL
BQAwNjELMAkGA1UEBhMCVVMxEDAOBgNVBAoMB1Rlc3QgQ0ExFTATBgNVBAMMDFRl
c3QgUm9vdCBDQTAgFw0yNTA5MzAxMjAyMzFaGA8yMDU1MDkyMzEyMDIzMVowNjEL
MAkGA1UEBhMCVVMxEDAOBgNVBAoMB1Rlc3QgQ0ExFTATBgNVBAMMDFRlc3QgUm9v
dCBDQTCCAaIwDQYJKoZIhvcNAQEBBQADggGPADCCAYoCggGBAKHLd07/qunxEFL8
BDCk1J+2m/TcUM0oG6dFVZjf/4+xSDH3F8tA6ZeqBLriQRBTuqwFBZVvoJwqJjip
CAVzWkvvyiuvAeywJNXIe7kqmmDeAEoq2Z/F9j6p5y8+ddy7VX6pTdsVYOyGsBCx
GZb7tuj0mtLl18HuezH0P7prDHBuNx+FjMblHXr5yslQ95d53v4dmI8ONObhS2ju
ltgveHzaWbssaAqoVwfWJfWJtc0C3IgGh8MgFhhdzTQ4DvHxYLjwGB0eE/Vpu757
B4pvYcT7JjBtjv8VyhW62ZPZNBlNjFOtFHhxZcMGDiOsyy9OgwIzzvy9UMg0WisZ
OxZmRkASuApoMRftSg2ZmTG5THdFgEQD6pqE3llmhO57ZTt1DvPNUlUh8ND9u9Ov
TcCjEeBXJRRjx7tUhob3mU+tYAi9DbXX/Wje0wBaRKh073wzRoAsFU9PspgKsZq4
JNDFSey4DK8W/O8jYvmNfuQ3TJwiAWxNUDDUdGN3TwnaKRQesQIDAQABo1MwUTAd
BgNVHQ4EFgQUDcXydaZNJvnwKtpmGn5Svrc1oLswHwYDVR0jBBgwFoAUDcXydaZN
JvnwKtpmGn5Svrc1oLswDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOC
AYEAK4/oXvmhohTM10N+ZpaHDC8S+xr3z2tayJiNOrzzVE209MEOMCYlEz9IeyUx
q5rmD4SJRLmyR8DpkO3dO4MjZYv2Nq+NS2kb5PmhUKmIEcwfFmXHjX/FCqPVwdH9
yM63o3GS1PhbQ6vzK4NMsOUT3nvnD87gFvXKOSn+gpETmYNA55wD/Qcu8LhqDk0I
YDrXiAja++gEf7/RZ5oPYNXi8t1+PjUDhP8J/tz9G90MZBR/FztTVIGTdpIoVwDB
mMpAHW/7S9hEwVlZ69CUMfrm0uG68xaJPNH1MmaZdEdI0PnSU2nVwLLLqsYQmZqp
+AGOq+6qOcvSMaFIJ3OLPUKqgn40f5J7I6retaER53Rahe/15Hy8H/sRsZQFPjeW
qi86vkgd5GFSu2GNHN/Mhq7dHWq9U0mNO+BZt/G6du3gJ+SaO1UkJhzpNiRY37sR
FU3HWF4uYVCt+OHqJ4we9XUKT80Y4ejNYkG25H+9DOy/Gtwb4qquqBoZYRUUThA1
kJHt
-----END CERTIFICATE-----


-- add-checkpoint-public.hurl --
POST https://localhost:8444/e933707e0e36c30f01d94b5d81e742da373679d88eb0f85f959ccd80b83b992a/add-checkpoint
```
old 0

sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562
1
KgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=

— sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562 UgIom7fPZTqpxWWhyjWduBvTvGVqsokMbqTArsQilegKoFBJQjUFAmQ0+YeSPM3wfUQMFSzVnnNuWRTYrajXpNUbIQY=
```
HTTP 200
[Asserts]
body contains "— example.com/witness"

-- add-checkpoint.hurl --
POST http://localhost:8080/e933707e0e36c30f01d94b5d81e742da373679d88eb0f85f959ccd80b83b992a/add-checkpoint
```
old 0

sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562
1
KgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=

— sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562 UgIom7fPZTqpxWWhyjWduBvTvGVqsokMbqTArsQilegKoFBJQjUFAmQ0+YeSPM3wfUQMFSzVnnNuWRTYrajXpNUbIQY=
```
HTTP 200
[Asserts]
body contains "— example.com/witness"
torchwood-0.9.0/cmd/litewitness/testdata/bastionlocalhost.txt000066400000000000000000000203031514564101300245650ustar00rootroot00000000000000# gentest seed b4e385f4358f7373cfa9184b176f3cccf808e795baf04092ddfde9461014f0c4

# set up log
exec witnessctl add-sigsum-log -key=ffdc2d4d98e4124d3feaf788c0c2f9abfd796083d1f0495437f302ec79cf100f

# start bastion with host:port -listen-http
exec litebastion -tls-cert localhost.pem -tls-key localhost-key.pem -backends=backends.txt -listen localhost:8445 -listen-http localhost:8081 &litebastion&
waitfor localhost:8445

# start ssh-agent
env SSH_AUTH_SOCK=$WORK/s # barely below the max path length
! exec ssh-agent -a $SSH_AUTH_SOCK -D & # ssh-agent always exits 2
waitfor $SSH_AUTH_SOCK
chmod 600 witness_key.pem
exec ssh-add witness_key.pem

# fail to start litewitness
! exec litewitness -ssh-agent=$SSH_AUTH_SOCK -name=example.com/witness -bastion=0.0.0.0:443,localhost:8445 -testcert -key=e933707e0e36c30f01d94b5d81e742da373679d88eb0f85f959ccd80b83b992a

# reload backends
mv correct_backends.txt backends.txt
killall -SIGHUP litebastion

# start litewitness
exec litewitness -ssh-agent=$SSH_AUTH_SOCK -name=example.com/witness -bastion=0.0.0.0:443,localhost:8445 -testcert -key=e933707e0e36c30f01d94b5d81e742da373679d88eb0f85f959ccd80b83b992a &litewitness&
waitfor http://localhost:8081/e933707e0e36c30f01d94b5d81e742da373679d88eb0f85f959ccd80b83b992a/

# add-checkpoint to bastion interface
! exec hurl --cacert rootCA.pem --test add-checkpoint-public.hurl
# add-checkpoint to local interface
exec hurl --cacert rootCA.pem --test add-checkpoint.hurl

# check that litewitness shut down cleanly
killall
wait litewitness
stderr 'shutting down'

# check the litebastion output
killall
wait litebastion
stderr 'reloaded backends'
stderr 'msg="accepted new backend connection" backend=e933707e0e36c30f01d94b5d81e742da373679d88eb0f85f959ccd80b83b992a'

# witnessctl list-logs
exec witnessctl list-logs
stdout sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562
stdout "size":1


-- backends.txt --
f97f12534ff2478cfc36b00d09a85d4faeb6589ac19a0895c348a499627c531c


-- correct_backends.txt --
f97f12534ff2478cfc36b00d09a85d4faeb6589ac19a0895c348a499627c531c
e933707e0e36c30f01d94b5d81e742da373679d88eb0f85f959ccd80b83b992a


-- witness_key.pem --
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtz
c2gtZWQyNTUxOQAAACBkhIrYq+1uhZgbOzh1slK4dn67SwL3A6yjsecbvWqOUAAA
AIgN5+09DeftPQAAAAtzc2gtZWQyNTUxOQAAACBkhIrYq+1uhZgbOzh1slK4dn67
SwL3A6yjsecbvWqOUAAAAEAx/8IRbsvgA6yqgAq3B1e9fVMgbj/r72ptB5bZVTCz
T2SEitir7W6FmBs7OHWyUrh2frtLAvcDrKOx5xu9ao5QAAAAAAECAwQF
-----END OPENSSH PRIVATE KEY-----


-- localhost.pem --
-----BEGIN CERTIFICATE-----
MIID/TCCAmWgAwIBAgIUPCwTk8pjBLPr+3czZAaGcg8RuDowDQYJKoZIhvcNAQEL
BQAwNjELMAkGA1UEBhMCVVMxEDAOBgNVBAoMB1Rlc3QgQ0ExFTATBgNVBAMMDFRl
c3QgUm9vdCBDQTAgFw0yNTA5MzAxMjAzMDdaGA8yMDU1MDkyMzEyMDMwN1owMDEL
MAkGA1UEBhMCVVMxDTALBgNVBAoMBFRlc3QxEjAQBgNVBAMMCWxvY2FsaG9zdDCC
ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKmcTiZ4FX92VKuQU9zioRv7
nqsdEmMoOKs1nVJd7myLMi/02flEbDAmtDkh4LFPuiNw2OaqphBYM4MlZCxofKrL
+/Skb763IOA5Ly97QGrOzYBtksPJlAPlF8pvRJO22ZIlBFpZUsivOZnFyhnvhFki
TMMDpvNqtAAIee3Hv0y4EolhausAecaErlPc510LmcoU+VF5bYgur68nLzSvetzG
qKblYkhFi3pEAJ7wcFUWZbFlNKDQVB+o4aEnqGj5mvYIbsh8UNJIqANA5qgxjoGc
8F5eqWlXmFA9+R9lRe5JW6Ia91V8eKsTCRKuuFvH2gIs8pxrzV3fvdrXXHJCzxUC
AwEAAaOBhjCBgzAUBgNVHREEDTALgglsb2NhbGhvc3QwCQYDVR0TBAIwADALBgNV
HQ8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwHQYDVR0OBBYEFO0cjjCGyrIC
6ov0YT60AlyXsFRsMB8GA1UdIwQYMBaAFA3F8nWmTSb58CraZhp+Ur63NaC7MA0G
CSqGSIb3DQEBCwUAA4IBgQA4EzY7MH95cFlVzbhk1loSlPRgDaWBga1y7U17jOnx
ulqGr6ySInAounhZPDFfwIxyAbZE4N/Bq1omkfI+osvGBbkUdj7Z7ktllj5pON0t
gdmCq/S1Q/tEcBvaS1FjR+AMu2enUVKn6mMXgjyj4xZNutqbXfKD4wdL1qP2KLbZ
M832TqI2tVUVOGSq0zfsPe5sDD/NHSvkKaeM+ZGlB955fzBsICOx4W5mcz/Zhfk5
gbJZsNPVlt8al3/iO0AIOGXzZOnlo8baHmh4TJH+uRYgHh8KFAvA8rebStg+Rn5w
j+uAFgG+gf7JSVo6bjLWMkmkN6ooJFYMwwXav83STOU0TAQdqX+YM97Tke+U3DEd
MqeX+hS+LyXGG0hHardo9xrRs1/W9xP453ybm2KFPODPyPSTuyOSUzleIeRGfmXg
Ri++XfeZMNcPYNAj0joJEmoszmY17vuoBDidhYVQE+g+4uxhJeGROWvNz2ONqxqw
QTyv4nS0hwl7T0VCoBC+bSg=
-----END CERTIFICATE-----


-- localhost-key.pem --
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCpnE4meBV/dlSr
kFPc4qEb+56rHRJjKDirNZ1SXe5sizIv9Nn5RGwwJrQ5IeCxT7ojcNjmqqYQWDOD
JWQsaHyqy/v0pG++tyDgOS8ve0Bqzs2AbZLDyZQD5RfKb0STttmSJQRaWVLIrzmZ
xcoZ74RZIkzDA6bzarQACHntx79MuBKJYWrrAHnGhK5T3OddC5nKFPlReW2ILq+v
Jy80r3rcxqim5WJIRYt6RACe8HBVFmWxZTSg0FQfqOGhJ6ho+Zr2CG7IfFDSSKgD
QOaoMY6BnPBeXqlpV5hQPfkfZUXuSVuiGvdVfHirEwkSrrhbx9oCLPKca81d373a
11xyQs8VAgMBAAECggEAH3wF38s7xmDrX7uXbbHeEUk4j3ACmUh+mH2H2iHYn+qI
4vETQ1vJr3iHzPE2egOgPHL2uH7l+7O7wDUBLuMofTYHa8bYfXEWF7lVwn0hHJKO
ADCW5WQ2ZzCwJWJZOwhew+u+Lp1VKi6oxRw7o2vcSAV/dVXouFfO2RC5vYNuRem5
m/3xaOlDpPgmhkRV/I+AJk0f8zNSWpU+izonxqgSINREyemWVY705s+HirMEQgRV
GN0ftwNGuz7/5rk8eH1iAGsTK9NEi77hCtyUKYhOC8NWzxGgS2mhqHslms8M2ZOm
rBiZZ5Sh5U53r6IWCIKqG92fzFZ/fcSZGUDHrcjcYQKBgQDptvr/f21Ze9OCrGmk
PktnM5v0DiP0z0Ywf28oz9rTneletJqQTsw6uP/GnIQrXwnaTWICjJtiMp9wxdM+
9drKhZufImC0NGTLz1vJxLR5x5ao8T/juPIxm7TLfH5XjZMJgRMD1D5IvW8k9hWU
1yDJxNCx1ikY7N3FP1XwmFrhRQKBgQC5yIeeABqXCy2A/wYrWqZ3zYFWe4t6Qqzm
afuoUGaqXK9GjgDemOlUKzhUb08tnPNXzKJagqmM4F0zKgcwq61ECH3SliMt82PL
1i1KTB2bUSXnwXOsbQyWVXz7YuVsWYakdHt6Vbbmy1JexRhfV3q5HCuj1fElNLTv
YheLV0JLkQKBgEFuhR764fZngHPZKUpeVmXyQPs26kIjtZbmVoyqhK0yTJ/DGHLG
XM8j9Bf6wdYSqYOAnqvwCaCYY6MC/31k/3grp8IJseFBueaFi0EV3SErC7cIs8Zh
hQz2dstxcz232S6UAGrWBQoAXxmN+8TL5dYXUAY52w+rYPtUHA9b2DWxAoGAUlBL
7jBjl5qnPalApYLTkO8nqBazFKdoDerVSpzc8AyCyELwla+wac+AdMCglzgcBUGw
iWOtFbLu+FVdvC3EZglRHjXRPnHBPLYXePzCfWd14PowcywZ0J3t8z+9IMWFx2Wo
s+o4UIezZjPzeYK76DpYB44p+u8gX5PZlK5DvFECgYBAKrvWEOy8J29QB3er+ERv
qvmczAvfJWWgheKMxQhx/3pu6UW/v/hmcKngMWjr/XqxFKp05ca1VBCmPB3s0776
IMJnvyTUFs5S7COpPJOC81JB/28UGQFcAZ+Kiwd1sx5gd1Q+EnaAhyxplWi/m6Zv
tKfWSmtc1XhMiUpw331jNg==
-----END PRIVATE KEY-----


-- rootCA.pem --
-----BEGIN CERTIFICATE-----
MIIETzCCAregAwIBAgIUauyztCDIDrmE46pw/Pg5nn7U2gUwDQYJKoZIhvcNAQEL
BQAwNjELMAkGA1UEBhMCVVMxEDAOBgNVBAoMB1Rlc3QgQ0ExFTATBgNVBAMMDFRl
c3QgUm9vdCBDQTAgFw0yNTA5MzAxMjAyMzFaGA8yMDU1MDkyMzEyMDIzMVowNjEL
MAkGA1UEBhMCVVMxEDAOBgNVBAoMB1Rlc3QgQ0ExFTATBgNVBAMMDFRlc3QgUm9v
dCBDQTCCAaIwDQYJKoZIhvcNAQEBBQADggGPADCCAYoCggGBAKHLd07/qunxEFL8
BDCk1J+2m/TcUM0oG6dFVZjf/4+xSDH3F8tA6ZeqBLriQRBTuqwFBZVvoJwqJjip
CAVzWkvvyiuvAeywJNXIe7kqmmDeAEoq2Z/F9j6p5y8+ddy7VX6pTdsVYOyGsBCx
GZb7tuj0mtLl18HuezH0P7prDHBuNx+FjMblHXr5yslQ95d53v4dmI8ONObhS2ju
ltgveHzaWbssaAqoVwfWJfWJtc0C3IgGh8MgFhhdzTQ4DvHxYLjwGB0eE/Vpu757
B4pvYcT7JjBtjv8VyhW62ZPZNBlNjFOtFHhxZcMGDiOsyy9OgwIzzvy9UMg0WisZ
OxZmRkASuApoMRftSg2ZmTG5THdFgEQD6pqE3llmhO57ZTt1DvPNUlUh8ND9u9Ov
TcCjEeBXJRRjx7tUhob3mU+tYAi9DbXX/Wje0wBaRKh073wzRoAsFU9PspgKsZq4
JNDFSey4DK8W/O8jYvmNfuQ3TJwiAWxNUDDUdGN3TwnaKRQesQIDAQABo1MwUTAd
BgNVHQ4EFgQUDcXydaZNJvnwKtpmGn5Svrc1oLswHwYDVR0jBBgwFoAUDcXydaZN
JvnwKtpmGn5Svrc1oLswDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOC
AYEAK4/oXvmhohTM10N+ZpaHDC8S+xr3z2tayJiNOrzzVE209MEOMCYlEz9IeyUx
q5rmD4SJRLmyR8DpkO3dO4MjZYv2Nq+NS2kb5PmhUKmIEcwfFmXHjX/FCqPVwdH9
yM63o3GS1PhbQ6vzK4NMsOUT3nvnD87gFvXKOSn+gpETmYNA55wD/Qcu8LhqDk0I
YDrXiAja++gEf7/RZ5oPYNXi8t1+PjUDhP8J/tz9G90MZBR/FztTVIGTdpIoVwDB
mMpAHW/7S9hEwVlZ69CUMfrm0uG68xaJPNH1MmaZdEdI0PnSU2nVwLLLqsYQmZqp
+AGOq+6qOcvSMaFIJ3OLPUKqgn40f5J7I6retaER53Rahe/15Hy8H/sRsZQFPjeW
qi86vkgd5GFSu2GNHN/Mhq7dHWq9U0mNO+BZt/G6du3gJ+SaO1UkJhzpNiRY37sR
FU3HWF4uYVCt+OHqJ4we9XUKT80Y4ejNYkG25H+9DOy/Gtwb4qquqBoZYRUUThA1
kJHt
-----END CERTIFICATE-----


-- add-checkpoint-public.hurl --
POST https://localhost:8445/e933707e0e36c30f01d94b5d81e742da373679d88eb0f85f959ccd80b83b992a/add-checkpoint
```
old 0

sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562
1
KgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=

— sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562 UgIom7fPZTqpxWWhyjWduBvTvGVqsokMbqTArsQilegKoFBJQjUFAmQ0+YeSPM3wfUQMFSzVnnNuWRTYrajXpNUbIQY=
```
HTTP 200
[Asserts]
body contains "— example.com/witness"

-- add-checkpoint.hurl --
POST http://localhost:8081/e933707e0e36c30f01d94b5d81e742da373679d88eb0f85f959ccd80b83b992a/add-checkpoint
```
old 0

sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562
1
KgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=

— sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562 UgIom7fPZTqpxWWhyjWduBvTvGVqsokMbqTArsQilegKoFBJQjUFAmQ0+YeSPM3wfUQMFSzVnnNuWRTYrajXpNUbIQY=
```
HTTP 200
[Asserts]
body contains "— example.com/witness"
torchwood-0.9.0/cmd/litewitness/testdata/gentest/000077500000000000000000000000001514564101300221275ustar00rootroot00000000000000torchwood-0.9.0/cmd/litewitness/testdata/gentest/sigsum.go000066400000000000000000000070601514564101300237700ustar00rootroot00000000000000// Run with "go run -mod=mod ./cmd/litewitness/testdata/gentest"
// and re-run "go mod tidy" after use to clean up its dependencies.

package main

import (
	"crypto/ed25519"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/binary"
	"encoding/hex"
	"encoding/pem"
	"flag"
	"fmt"
	"log"
	"net/url"

	"golang.org/x/crypto/hkdf"
	"golang.org/x/crypto/ssh"
	"golang.org/x/mod/sumdb/note"
	"golang.org/x/mod/sumdb/tlog"
	sigsum "sigsum.org/sigsum-go/pkg/crypto"
	"sigsum.org/sigsum-go/pkg/merkle"
)

var seedFlag = flag.String("seed", "", "hex-encoded seed")

func main() {
	flag.Parse()
	var seed []byte
	if *seedFlag == "" {
		seed = make([]byte, 32)
		if _, err := rand.Read(seed); err != nil {
			log.Fatal(err)
		}
	} else {
		seed = make([]byte, hex.DecodedLen(len(*seedFlag)))
		if _, err := hex.Decode(seed, []byte(*seedFlag)); err != nil {
			log.Fatal(err)
		}
	}
	fmt.Printf("- seed: %x\n", seed)
	h := hkdf.New(sha256.New, seed, []byte("litewitness gentest"), nil)

	publicKey, privateKey, _ := ed25519.GenerateKey(h)
	fmt.Printf("- log private key: %x\n", privateKey.Seed())
	fmt.Printf("- log public key: %x\n", publicKey)

	keyHash := sigsum.HashBytes(publicKey[:])
	fmt.Printf("- log key hash: %x\n", keyHash)
	origin := fmt.Sprintf("sigsum.org/v1/tree/%x", keyHash)
	fmt.Printf("- origin: %s\n", origin)
	fmt.Printf("- origin URL-encoded: %s\n", url.QueryEscape(origin))

	const algEd25519 = 1
	kh := noteKeyHash(origin, append([]byte{algEd25519}, publicKey...))
	vkey := fmt.Sprintf("%s+%08x+%s", origin, kh, base64.StdEncoding.EncodeToString(append([]byte{algEd25519}, publicKey...)))
	skey := fmt.Sprintf("PRIVATE+KEY+%s+%08x+%s", origin, kh, base64.StdEncoding.EncodeToString(append([]byte{algEd25519}, privateKey.Seed()...)))
	s, _ := note.NewSigner(skey)
	fmt.Printf("- log note vkey: %s\n", vkey)
	fmt.Printf("- log note key: %s\n", skey)

	witSeed := make([]byte, ed25519.SeedSize)
	h.Read(witSeed)
	witKey := ed25519.NewKeyFromSeed(witSeed)
	ss, err := ssh.NewSignerFromSigner(witKey)
	if err != nil {
		log.Fatal(err)
	}
	pkHash := sigsum.HashBytes(ss.PublicKey().(ssh.CryptoPublicKey).CryptoPublicKey().(ed25519.PublicKey))
	fmt.Printf("- witness key hash: %s\n", hex.EncodeToString(pkHash[:]))
	fmt.Printf("- witness key: %x\n", witKey)
	pemKey, err := ssh.MarshalPrivateKey(witKey, "")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Printf("- witness key:\n%s", pem.EncodeToMemory(pemKey))

	tree := merkle.NewTree()
	addLeaf := func(leaf sigsum.Hash) {
		if !tree.AddLeafHash(&leaf) {
			panic("duplicate")
		}
		fmt.Printf("- leaf[%d] hash: %x\n", tree.Size(), leaf)
	}
	signTreeHead := func() {
		checkpoint := fmt.Sprintf("%s\n%d\n%s\n", origin, tree.Size(), tlog.Hash(tree.GetRootHash()))
		n, _ := note.Sign(¬e.Note{Text: checkpoint}, s)
		fmt.Printf("- checkpoint (size %d):\n%s\n", tree.Size(), n)
	}
	consistencyProof := func(oldSize uint64) {
		proof, err := tree.ProveConsistency(oldSize, tree.Size())
		if err != nil {
			log.Fatal(err)
		}
		fmt.Printf("- consistency proof from size %d:\n", oldSize)
		fmt.Printf("old %d\n", oldSize)
		for _, p := range proof {
			fmt.Printf("%s\n", base64.StdEncoding.EncodeToString(p[:]))
		}
	}

	addLeaf(sigsum.Hash{42, 0})
	signTreeHead()

	addLeaf(sigsum.Hash{42, 1})
	addLeaf(sigsum.Hash{42, 2})
	signTreeHead()
	consistencyProof(1)

	addLeaf(sigsum.Hash{42, 3})
	addLeaf(sigsum.Hash{42, 4})
	signTreeHead()
	consistencyProof(1)
	consistencyProof(3)
}

func noteKeyHash(name string, key []byte) uint32 {
	h := sha256.New()
	h.Write([]byte(name))
	h.Write([]byte("\n"))
	h.Write(key)
	sum := h.Sum(nil)
	return binary.BigEndian.Uint32(sum)
}
torchwood-0.9.0/cmd/litewitness/testdata/litewitness.txt000066400000000000000000000146361514564101300236030ustar00rootroot00000000000000# gentest seed b4e385f4358f7373cfa9184b176f3cccf808e795baf04092ddfde9461014f0c4

# set up log
exec witnessctl add-sigsum-log -key=ffdc2d4d98e4124d3feaf788c0c2f9abfd796083d1f0495437f302ec79cf100f

# start ssh-agent
env SSH_AUTH_SOCK=$WORK/s # barely below the max path length
! exec ssh-agent -a $SSH_AUTH_SOCK -D & # ssh-agent always exits 2
waitfor $SSH_AUTH_SOCK
chmod 600 other_key.pem
exec ssh-add other_key.pem
chmod 600 witness_key.pem
exec ssh-add witness_key.pem

# start litewitness
exec litewitness -ssh-agent=$SSH_AUTH_SOCK -name=example.com/witness -key=e933707e0e36c30f01d94b5d81e742da373679d88eb0f85f959ccd80b83b992a &litewitness&
waitfor localhost:7380

# add-checkpoint
exec hurl --test --error-format long add-checkpoint.hurl

# check that / shows the log name
exec hurl --test --error-format long index.hurl

# check that litewitness shut down cleanly
killall
wait litewitness
stderr 'shutting down'

# witnessctl list-logs
exec witnessctl list-logs
stdout sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562
stdout "size":5


-- witness_key.pem --
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtz
c2gtZWQyNTUxOQAAACBkhIrYq+1uhZgbOzh1slK4dn67SwL3A6yjsecbvWqOUAAA
AIgN5+09DeftPQAAAAtzc2gtZWQyNTUxOQAAACBkhIrYq+1uhZgbOzh1slK4dn67
SwL3A6yjsecbvWqOUAAAAEAx/8IRbsvgA6yqgAq3B1e9fVMgbj/r72ptB5bZVTCz
T2SEitir7W6FmBs7OHWyUrh2frtLAvcDrKOx5xu9ao5QAAAAAAECAwQF
-----END OPENSSH PRIVATE KEY-----


-- other_key.pem --
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtz
c2gtZWQyNTUxOQAAACDkZam8RBV490MX6kvcJKCMJy57Z3Qcxbn0K3J2mwXX9QAA
AIgezao7Hs2qOwAAAAtzc2gtZWQyNTUxOQAAACDkZam8RBV490MX6kvcJKCMJy57
Z3Qcxbn0K3J2mwXX9QAAAEA+37qVtCUzwBX6u6EmU8B+8qbO8xU4FdvJqU4utc7R
cuRlqbxEFXj3QxfqS9wkoIwnLntndBzFufQrcnabBdf1AAAAAAECAwQF
-----END OPENSSH PRIVATE KEY-----


-- add-checkpoint.hurl --
POST http://localhost:7380/add-checkpoint
```
old 0

sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562
1
KgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=

— sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562 UgIom7fPZTqpxWWhyjWduBvTvGVqsokMbqTArsQilegKoFBJQjUFAmQ0+YeSPM3wfUQMFSzVnnNuWRTYrajXpNUbIQY=
```
HTTP 200
[Asserts]
body contains "— example.com/witness"


POST http://localhost:7380/add-checkpoint
```
old 1
KgEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
KgIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=

sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562
3
RcCI1Nk56ZcSmIEfIn0SleqtV7uvrlXNccFx595Iwl0=

— sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562 UgIom2VbtIcdFbwFAy1n7s6IkAxIY6J/GQOTuZF2ORV39d75cbAj2aQYwyJre36kezNobZs4SUUdrcawfAB8WVrx6gx=
```
HTTP 403
[Asserts]
body contains "invalid signature"


POST http://localhost:7380/add-checkpoint
```
old 1
KgEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
KgIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=

sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e563
3
RcCI1Nk56ZcSmIEfIn0SleqtV7uvrlXNccFx595Iwl0=

— sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e563 UgIom2VbtIcdFbwFAy1n7s6IkAxIY6J/GQOTuZF2ORV39d75cbAj2aQYwyJre36kezNobZs4SUUdrcawfAB8WVrx6go=
```
HTTP 404
[Asserts]
body contains "unknown log"


POST http://localhost:7380/add-checkpoint
```
old 1

sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562
3
RcCI1Nk56ZcSmIEfIn0SleqtV7uvrlXNccFx595Iwl0=

— sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562 UgIom2VbtIcdFbwFAy1n7s6IkAxIY6J/GQOTuZF2ORV39d75cbAj2aQYwyJre36kezNobZs4SUUdrcawfAB8WVrx6go=
```
HTTP 422
[Asserts]
body contains "consistency proof"


POST http://localhost:7380/add-checkpoint
```
old 1
KgEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
KgIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABA=

sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562
3
RcCI1Nk56ZcSmIEfIn0SleqtV7uvrlXNccFx595Iwl0=

— sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562 UgIom2VbtIcdFbwFAy1n7s6IkAxIY6J/GQOTuZF2ORV39d75cbAj2aQYwyJre36kezNobZs4SUUdrcawfAB8WVrx6go=
```
HTTP 422
[Asserts]
body contains "consistency proof"


POST http://localhost:7380/add-checkpoint
```
old 1
KgEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
KgIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=

sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562
3
RcCI1Nk56ZcSmIEfIn0SleqtV7uvrlXNccFx595Iwl0=

— sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562 UgIom2VbtIcdFbwFAy1n7s6IkAxIY6J/GQOTuZF2ORV39d75cbAj2aQYwyJre36kezNobZs4SUUdrcawfAB8WVrx6go=
```
HTTP 200
[Asserts]
body contains "— example.com/witness"


POST http://localhost:7380/add-checkpoint
```
old 1
KgEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
+fUDV+k970B4I3uKrqJM4aP1lloPZP8mvr2Z4wRw2LI=
KgQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=

sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562
5
QrtXrQZCCvpIgsSmOsah7HdICzMLLyDfxToMql9WTjY=

— sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562 UgIomw/EOJmWi0i1FQsOj+etB7F8IccFam/jgd6wzRns4QPVmyEZtdvl1U2KEmLOZ/ASRcWJi0tW90dJWAShei7sDww=
```
HTTP 409
[Asserts]
body == "3\n"


POST http://localhost:7380/add-checkpoint
```
old 3
KgIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
KgMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
wgiIFdZfYNv6WU1OllBKsWnLYIS/DBMqt8Uh/S4OukE=
KgQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=

sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562
5
QrtXrQZCCvpIgsSmOsah7HdICzMLLyDfxToMql9WTjY=

— sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562 UgIomw/EOJmWi0i1FQsOj+etB7F8IccFam/jgd6wzRns4QPVmyEZtdvl1U2KEmLOZ/ASRcWJi0tW90dJWAShei7sDww=
```
HTTP 200
[Asserts]
body contains "— example.com/witness"


POST http://localhost:7380/add-checkpoint
```
old 0

sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562
5
QrtXrQZCCvpIgsSmOsah7HdICzMLLyDfxToMql9WTjY=

— sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562 UgIomw/EOJmWi0i1FQsOj+etB7F8IccFam/jgd6wzRns4QPVmyEZtdvl1U2KEmLOZ/ASRcWJi0tW90dJWAShei7sDww=
```
HTTP 409
[Asserts]
body == "5\n"


-- index.hurl --
GET http://localhost:7380/
HTTP 200
[Asserts]
body contains "sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562"
torchwood-0.9.0/cmd/litewitness/testdata/logbastion.txt000066400000000000000000000220201514564101300233540ustar00rootroot00000000000000# gentest seed b4e385f4358f7373cfa9184b176f3cccf808e795baf04092ddfde9461014f0c4
# gentest seed 8ece6c6015722b4ed02111acbc928cbf15022611be54264b70aa8458a5466f13

# set up log with bastion
exec witnessctl add-sigsum-log -key=ffdc2d4d98e4124d3feaf788c0c2f9abfd796083d1f0495437f302ec79cf100f
exec witnessctl add-bastion -origin=sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562 -bastion=localhost:9443

# start bastion
exec litebastion -listen localhost:9443 -tls-cert localhost.pem -tls-key localhost-key.pem -backends=backends.txt &litebastion&
waitfor localhost:9443

# start ssh-agent
env SSH_AUTH_SOCK=$WORK/s # barely below the max path length
! exec ssh-agent -a $SSH_AUTH_SOCK -D & # ssh-agent always exits 2
waitfor $SSH_AUTH_SOCK
chmod 600 witness_key.pem
exec ssh-add witness_key.pem

# start litewitness
exec litewitness -ssh-agent=$SSH_AUTH_SOCK -name=example.com/witness -testcert -key=e933707e0e36c30f01d94b5d81e742da373679d88eb0f85f959ccd80b83b992a -no-listen &litewitness&
waitfor https://localhost:9443/e933707e0e36c30f01d94b5d81e742da373679d88eb0f85f959ccd80b83b992a/

# add-checkpoint
exec hurl --cacert rootCA.pem --test add-checkpoint.hurl

# start a second bastion on a different port
exec litebastion -listen localhost:9444 -tls-cert localhost.pem -tls-key localhost-key.pem -backends=backends.txt &litebastion2&
waitfor localhost:9444

# add a second log and configure it to use the second bastion
exec witnessctl add-sigsum-log -key=7c589ec3432005b91812adef062f4783ce95c6ff502048ce67063c1b84062e34
exec witnessctl add-bastion -origin=example.org/v1/tree/7c589ec3432005b91812adef062f4783ce95c6ff502048ce67063c1b84062e34 -bastion=localhost:9444

# reload litewitness to pick up new log and bastion
killall -SIGHUP litewitness
waitfor https://localhost:9444/e933707e0e36c30f01d94b5d81e742da373679d88eb0f85f959ccd80b83b992a/

# test that accessing a log configured for bastion 9444 through bastion 9443 fails with 403
! exec hurl --cacert rootCA.pem --test add-checkpoint-second-log.hurl

# check that litewitness shut down cleanly
killall
wait litewitness
stderr 'shutting down'

# check the litebastion output
wait litebastion
stderr 'msg="accepted new backend connection" backend=e933707e0e36c30f01d94b5d81e742da373679d88eb0f85f959ccd80b83b992a'

# witnessctl list-logs
exec witnessctl list-logs
stdout sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562
stdout "size":1


-- backends.txt --
f97f12534ff2478cfc36b00d09a85d4faeb6589ac19a0895c348a499627c531c
e933707e0e36c30f01d94b5d81e742da373679d88eb0f85f959ccd80b83b992a


-- witness_key.pem --
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtz
c2gtZWQyNTUxOQAAACBkhIrYq+1uhZgbOzh1slK4dn67SwL3A6yjsecbvWqOUAAA
AIgN5+09DeftPQAAAAtzc2gtZWQyNTUxOQAAACBkhIrYq+1uhZgbOzh1slK4dn67
SwL3A6yjsecbvWqOUAAAAEAx/8IRbsvgA6yqgAq3B1e9fVMgbj/r72ptB5bZVTCz
T2SEitir7W6FmBs7OHWyUrh2frtLAvcDrKOx5xu9ao5QAAAAAAECAwQF
-----END OPENSSH PRIVATE KEY-----


-- localhost.pem --
-----BEGIN CERTIFICATE-----
MIID/TCCAmWgAwIBAgIUPCwTk8pjBLPr+3czZAaGcg8RuDowDQYJKoZIhvcNAQEL
BQAwNjELMAkGA1UEBhMCVVMxEDAOBgNVBAoMB1Rlc3QgQ0ExFTATBgNVBAMMDFRl
c3QgUm9vdCBDQTAgFw0yNTA5MzAxMjAzMDdaGA8yMDU1MDkyMzEyMDMwN1owMDEL
MAkGA1UEBhMCVVMxDTALBgNVBAoMBFRlc3QxEjAQBgNVBAMMCWxvY2FsaG9zdDCC
ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKmcTiZ4FX92VKuQU9zioRv7
nqsdEmMoOKs1nVJd7myLMi/02flEbDAmtDkh4LFPuiNw2OaqphBYM4MlZCxofKrL
+/Skb763IOA5Ly97QGrOzYBtksPJlAPlF8pvRJO22ZIlBFpZUsivOZnFyhnvhFki
TMMDpvNqtAAIee3Hv0y4EolhausAecaErlPc510LmcoU+VF5bYgur68nLzSvetzG
qKblYkhFi3pEAJ7wcFUWZbFlNKDQVB+o4aEnqGj5mvYIbsh8UNJIqANA5qgxjoGc
8F5eqWlXmFA9+R9lRe5JW6Ia91V8eKsTCRKuuFvH2gIs8pxrzV3fvdrXXHJCzxUC
AwEAAaOBhjCBgzAUBgNVHREEDTALgglsb2NhbGhvc3QwCQYDVR0TBAIwADALBgNV
HQ8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwHQYDVR0OBBYEFO0cjjCGyrIC
6ov0YT60AlyXsFRsMB8GA1UdIwQYMBaAFA3F8nWmTSb58CraZhp+Ur63NaC7MA0G
CSqGSIb3DQEBCwUAA4IBgQA4EzY7MH95cFlVzbhk1loSlPRgDaWBga1y7U17jOnx
ulqGr6ySInAounhZPDFfwIxyAbZE4N/Bq1omkfI+osvGBbkUdj7Z7ktllj5pON0t
gdmCq/S1Q/tEcBvaS1FjR+AMu2enUVKn6mMXgjyj4xZNutqbXfKD4wdL1qP2KLbZ
M832TqI2tVUVOGSq0zfsPe5sDD/NHSvkKaeM+ZGlB955fzBsICOx4W5mcz/Zhfk5
gbJZsNPVlt8al3/iO0AIOGXzZOnlo8baHmh4TJH+uRYgHh8KFAvA8rebStg+Rn5w
j+uAFgG+gf7JSVo6bjLWMkmkN6ooJFYMwwXav83STOU0TAQdqX+YM97Tke+U3DEd
MqeX+hS+LyXGG0hHardo9xrRs1/W9xP453ybm2KFPODPyPSTuyOSUzleIeRGfmXg
Ri++XfeZMNcPYNAj0joJEmoszmY17vuoBDidhYVQE+g+4uxhJeGROWvNz2ONqxqw
QTyv4nS0hwl7T0VCoBC+bSg=
-----END CERTIFICATE-----


-- localhost-key.pem --
-----BEGIN PRIVATE KEY-----
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCpnE4meBV/dlSr
kFPc4qEb+56rHRJjKDirNZ1SXe5sizIv9Nn5RGwwJrQ5IeCxT7ojcNjmqqYQWDOD
JWQsaHyqy/v0pG++tyDgOS8ve0Bqzs2AbZLDyZQD5RfKb0STttmSJQRaWVLIrzmZ
xcoZ74RZIkzDA6bzarQACHntx79MuBKJYWrrAHnGhK5T3OddC5nKFPlReW2ILq+v
Jy80r3rcxqim5WJIRYt6RACe8HBVFmWxZTSg0FQfqOGhJ6ho+Zr2CG7IfFDSSKgD
QOaoMY6BnPBeXqlpV5hQPfkfZUXuSVuiGvdVfHirEwkSrrhbx9oCLPKca81d373a
11xyQs8VAgMBAAECggEAH3wF38s7xmDrX7uXbbHeEUk4j3ACmUh+mH2H2iHYn+qI
4vETQ1vJr3iHzPE2egOgPHL2uH7l+7O7wDUBLuMofTYHa8bYfXEWF7lVwn0hHJKO
ADCW5WQ2ZzCwJWJZOwhew+u+Lp1VKi6oxRw7o2vcSAV/dVXouFfO2RC5vYNuRem5
m/3xaOlDpPgmhkRV/I+AJk0f8zNSWpU+izonxqgSINREyemWVY705s+HirMEQgRV
GN0ftwNGuz7/5rk8eH1iAGsTK9NEi77hCtyUKYhOC8NWzxGgS2mhqHslms8M2ZOm
rBiZZ5Sh5U53r6IWCIKqG92fzFZ/fcSZGUDHrcjcYQKBgQDptvr/f21Ze9OCrGmk
PktnM5v0DiP0z0Ywf28oz9rTneletJqQTsw6uP/GnIQrXwnaTWICjJtiMp9wxdM+
9drKhZufImC0NGTLz1vJxLR5x5ao8T/juPIxm7TLfH5XjZMJgRMD1D5IvW8k9hWU
1yDJxNCx1ikY7N3FP1XwmFrhRQKBgQC5yIeeABqXCy2A/wYrWqZ3zYFWe4t6Qqzm
afuoUGaqXK9GjgDemOlUKzhUb08tnPNXzKJagqmM4F0zKgcwq61ECH3SliMt82PL
1i1KTB2bUSXnwXOsbQyWVXz7YuVsWYakdHt6Vbbmy1JexRhfV3q5HCuj1fElNLTv
YheLV0JLkQKBgEFuhR764fZngHPZKUpeVmXyQPs26kIjtZbmVoyqhK0yTJ/DGHLG
XM8j9Bf6wdYSqYOAnqvwCaCYY6MC/31k/3grp8IJseFBueaFi0EV3SErC7cIs8Zh
hQz2dstxcz232S6UAGrWBQoAXxmN+8TL5dYXUAY52w+rYPtUHA9b2DWxAoGAUlBL
7jBjl5qnPalApYLTkO8nqBazFKdoDerVSpzc8AyCyELwla+wac+AdMCglzgcBUGw
iWOtFbLu+FVdvC3EZglRHjXRPnHBPLYXePzCfWd14PowcywZ0J3t8z+9IMWFx2Wo
s+o4UIezZjPzeYK76DpYB44p+u8gX5PZlK5DvFECgYBAKrvWEOy8J29QB3er+ERv
qvmczAvfJWWgheKMxQhx/3pu6UW/v/hmcKngMWjr/XqxFKp05ca1VBCmPB3s0776
IMJnvyTUFs5S7COpPJOC81JB/28UGQFcAZ+Kiwd1sx5gd1Q+EnaAhyxplWi/m6Zv
tKfWSmtc1XhMiUpw331jNg==
-----END PRIVATE KEY-----


-- rootCA.pem --
-----BEGIN CERTIFICATE-----
MIIETzCCAregAwIBAgIUauyztCDIDrmE46pw/Pg5nn7U2gUwDQYJKoZIhvcNAQEL
BQAwNjELMAkGA1UEBhMCVVMxEDAOBgNVBAoMB1Rlc3QgQ0ExFTATBgNVBAMMDFRl
c3QgUm9vdCBDQTAgFw0yNTA5MzAxMjAyMzFaGA8yMDU1MDkyMzEyMDIzMVowNjEL
MAkGA1UEBhMCVVMxEDAOBgNVBAoMB1Rlc3QgQ0ExFTATBgNVBAMMDFRlc3QgUm9v
dCBDQTCCAaIwDQYJKoZIhvcNAQEBBQADggGPADCCAYoCggGBAKHLd07/qunxEFL8
BDCk1J+2m/TcUM0oG6dFVZjf/4+xSDH3F8tA6ZeqBLriQRBTuqwFBZVvoJwqJjip
CAVzWkvvyiuvAeywJNXIe7kqmmDeAEoq2Z/F9j6p5y8+ddy7VX6pTdsVYOyGsBCx
GZb7tuj0mtLl18HuezH0P7prDHBuNx+FjMblHXr5yslQ95d53v4dmI8ONObhS2ju
ltgveHzaWbssaAqoVwfWJfWJtc0C3IgGh8MgFhhdzTQ4DvHxYLjwGB0eE/Vpu757
B4pvYcT7JjBtjv8VyhW62ZPZNBlNjFOtFHhxZcMGDiOsyy9OgwIzzvy9UMg0WisZ
OxZmRkASuApoMRftSg2ZmTG5THdFgEQD6pqE3llmhO57ZTt1DvPNUlUh8ND9u9Ov
TcCjEeBXJRRjx7tUhob3mU+tYAi9DbXX/Wje0wBaRKh073wzRoAsFU9PspgKsZq4
JNDFSey4DK8W/O8jYvmNfuQ3TJwiAWxNUDDUdGN3TwnaKRQesQIDAQABo1MwUTAd
BgNVHQ4EFgQUDcXydaZNJvnwKtpmGn5Svrc1oLswHwYDVR0jBBgwFoAUDcXydaZN
JvnwKtpmGn5Svrc1oLswDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOC
AYEAK4/oXvmhohTM10N+ZpaHDC8S+xr3z2tayJiNOrzzVE209MEOMCYlEz9IeyUx
q5rmD4SJRLmyR8DpkO3dO4MjZYv2Nq+NS2kb5PmhUKmIEcwfFmXHjX/FCqPVwdH9
yM63o3GS1PhbQ6vzK4NMsOUT3nvnD87gFvXKOSn+gpETmYNA55wD/Qcu8LhqDk0I
YDrXiAja++gEf7/RZ5oPYNXi8t1+PjUDhP8J/tz9G90MZBR/FztTVIGTdpIoVwDB
mMpAHW/7S9hEwVlZ69CUMfrm0uG68xaJPNH1MmaZdEdI0PnSU2nVwLLLqsYQmZqp
+AGOq+6qOcvSMaFIJ3OLPUKqgn40f5J7I6retaER53Rahe/15Hy8H/sRsZQFPjeW
qi86vkgd5GFSu2GNHN/Mhq7dHWq9U0mNO+BZt/G6du3gJ+SaO1UkJhzpNiRY37sR
FU3HWF4uYVCt+OHqJ4we9XUKT80Y4ejNYkG25H+9DOy/Gtwb4qquqBoZYRUUThA1
kJHt
-----END CERTIFICATE-----


-- add-checkpoint.hurl --
POST https://localhost:9443/e933707e0e36c30f01d94b5d81e742da373679d88eb0f85f959ccd80b83b992a/add-checkpoint
```
old 0

sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562
1
KgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=

— sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562 UgIom7fPZTqpxWWhyjWduBvTvGVqsokMbqTArsQilegKoFBJQjUFAmQ0+YeSPM3wfUQMFSzVnnNuWRTYrajXpNUbIQY=
```
HTTP 200
[Asserts]
body contains "— example.com/witness"


-- add-checkpoint-second-log.hurl --
# This test verifies that a request coming through bastion A (9443) for a log configured
# with bastion B (9444) is rejected with a 403
POST https://localhost:9443/e933707e0e36c30f01d94b5d81e742da373679d88eb0f85f959ccd80b83b992a/add-checkpoint
```
old 0

sigsum.org/v1/tree/7c589ec3432005b91812adef062f4783ce95c6ff502048ce67063c1b84062e34
1
KgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=

— sigsum.org/v1/tree/7c589ec3432005b91812adef062f4783ce95c6ff502048ce67063c1b84062e34
```
HTTP 403
[Asserts]
body contains "wrong bastion"

POST https://localhost:9444/e933707e0e36c30f01d94b5d81e742da373679d88eb0f85f959ccd80b83b992a/add-checkpoint
```
old 0

sigsum.org/v1/tree/7c589ec3432005b91812adef062f4783ce95c6ff502048ce67063c1b84062e34
1
KgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=

— sigsum.org/v1/tree/7c589ec3432005b91812adef062f4783ce95c6ff502048ce67063c1b84062e34
```
HTTP 200
torchwood-0.9.0/cmd/litewitness/testdata/loglist.txt000066400000000000000000000111141514564101300226720ustar00rootroot00000000000000# pull logs
exec witnessctl pull-logs -source=log_list.0
! stdout .
! stderr .

# pull logs again, with no changes
exec witnessctl pull-logs -source=log_list.1
! stdout .
! stderr .

# pull logs again, with a key change
exec witnessctl pull-logs -source=log_list.2
! stdout .
stderr 'listed with a different key'

# check state
exec witnessctl list-logs
cmp stdout list-logs.jsonl
! stderr .

# add bastions
exec witnessctl add-bastion -origin sigsum.org/v1/tree/fae7fd8f084f9e7a1482162da8a3e52b08e6c1bac74ab831d00eb5c983b84120 -bastion bastion-1.example.org:666
exec witnessctl add-bastion -origin sigsum.org/v1/tree/fae7fd8f084f9e7a1482162da8a3e52b08e6c1bac74ab831d00eb5c983b84120 -bastion bastion-2.example.org:666
exec witnessctl list-logs
cmp stdout list-logs.2.jsonl
! stderr .

-- log_list.0 --
#
# List:      10qps-100klogs
# Revision:  123
# Generated: YYYY-MM-DD HH:MM:SS UTC
# Other undefined debug information.
#
logs/v0

# gentest seed 467e9782a4eaa2bca9c9f5bcb067fb7bce1bfcae7b18f27bac0900ad93825300
vkey sigsum.org/v1/tree/f48d4a1d0c6370ec189dc537f648ef3bb347b012fbd3c899a630a4cd2e9b8702+9bc54c7f+AVv6q3xDaHxI2aTemqEb7W6ZcbO7QbTqTr20thOqfqsw
qpd 86400
contact https://tlog.foo.org/contact
unknown
foo bar

# gentest seed 07210b16c5409251a63305371bb0e86bbe9e005c41ca1963f9ed3d89931a8a16
vkey sigsum.org/v1/tree/2ef59132082631d13e353b5ae49b22bc51a9bd59f41a2d570960a9658c1ed151+e2137795+ATp+37IPHc3SbPGzFMyZmPTOUlClk6PYPH+Ce5JiCb/h
origin example.com/foo
qpd 24
contact sysadmin (at) bar.org

-- log_list.1 --
logs/v0

# new log
# gentest seed 92540ba271142b0011c96531067b8f1f7695fd16ff7d92833767f821ab983c4d
vkey sigsum.org/v1/tree/fae7fd8f084f9e7a1482162da8a3e52b08e6c1bac74ab831d00eb5c983b84120+7f693d84+AUlxeri80AO7/4j/+OGo+5M2Sud0ktFg34uZl2fZnjJT

# existing log, same key
vkey sigsum.org/v1/tree/2ef59132082631d13e353b5ae49b22bc51a9bd59f41a2d570960a9658c1ed151+e2137795+ATp+37IPHc3SbPGzFMyZmPTOUlClk6PYPH+Ce5JiCb/h
origin example.com/foo

-- log_list.2 --
logs/v0

# new log
# gentest seed 92540ba271142b0011c96531067b8f1f7695fd16ff7d92833767f821ab983c4d
vkey sigsum.org/v1/tree/fae7fd8f084f9e7a1482162da8a3e52b08e6c1bac74ab831d00eb5c983b84120+7f693d84+AUlxeri80AO7/4j/+OGo+5M2Sud0ktFg34uZl2fZnjJT

# existing log, same key
vkey sigsum.org/v1/tree/2ef59132082631d13e353b5ae49b22bc51a9bd59f41a2d570960a9658c1ed151+e2137795+ATp+37IPHc3SbPGzFMyZmPTOUlClk6PYPH+Ce5JiCb/h
origin example.com/foo

# known log, different key
# gentest seed cf8477f7158ac47b018a55fda4bd1af29b0e864cdd3fd495efa477639d0b690c
vkey sigsum.org/v1/tree/80f121ea8f6364ca8c19212d7caf4e235d00aa34a98d86b60204c24f981a4933+a05ec139+ASZ0biMUCMjOMmt0NyD8g6Mt6RGZGXJU/ZpM5Oy0otex
origin sigsum.org/v1/tree/f48d4a1d0c6370ec189dc537f648ef3bb347b012fbd3c899a630a4cd2e9b8702

-- list-logs.jsonl --
{"origin":"example.com/foo","size":0,"root_hash":"47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=","keys":["sigsum.org/v1/tree/2ef59132082631d13e353b5ae49b22bc51a9bd59f41a2d570960a9658c1ed151+e2137795+ATp+37IPHc3SbPGzFMyZmPTOUlClk6PYPH+Ce5JiCb/h"],"bastions":[]}
{"origin":"sigsum.org/v1/tree/f48d4a1d0c6370ec189dc537f648ef3bb347b012fbd3c899a630a4cd2e9b8702","size":0,"root_hash":"47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=","keys":["sigsum.org/v1/tree/f48d4a1d0c6370ec189dc537f648ef3bb347b012fbd3c899a630a4cd2e9b8702+9bc54c7f+AVv6q3xDaHxI2aTemqEb7W6ZcbO7QbTqTr20thOqfqsw"],"bastions":[]}
{"origin":"sigsum.org/v1/tree/fae7fd8f084f9e7a1482162da8a3e52b08e6c1bac74ab831d00eb5c983b84120","size":0,"root_hash":"47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=","keys":["sigsum.org/v1/tree/fae7fd8f084f9e7a1482162da8a3e52b08e6c1bac74ab831d00eb5c983b84120+7f693d84+AUlxeri80AO7/4j/+OGo+5M2Sud0ktFg34uZl2fZnjJT"],"bastions":[]}
-- list-logs.2.jsonl --
{"origin":"example.com/foo","size":0,"root_hash":"47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=","keys":["sigsum.org/v1/tree/2ef59132082631d13e353b5ae49b22bc51a9bd59f41a2d570960a9658c1ed151+e2137795+ATp+37IPHc3SbPGzFMyZmPTOUlClk6PYPH+Ce5JiCb/h"],"bastions":[]}
{"origin":"sigsum.org/v1/tree/f48d4a1d0c6370ec189dc537f648ef3bb347b012fbd3c899a630a4cd2e9b8702","size":0,"root_hash":"47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=","keys":["sigsum.org/v1/tree/f48d4a1d0c6370ec189dc537f648ef3bb347b012fbd3c899a630a4cd2e9b8702+9bc54c7f+AVv6q3xDaHxI2aTemqEb7W6ZcbO7QbTqTr20thOqfqsw"],"bastions":[]}
{"origin":"sigsum.org/v1/tree/fae7fd8f084f9e7a1482162da8a3e52b08e6c1bac74ab831d00eb5c983b84120","size":0,"root_hash":"47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=","keys":["sigsum.org/v1/tree/fae7fd8f084f9e7a1482162da8a3e52b08e6c1bac74ab831d00eb5c983b84120+7f693d84+AUlxeri80AO7/4j/+OGo+5M2Sud0ktFg34uZl2fZnjJT"],"bastions":["bastion-1.example.org:666","bastion-2.example.org:666"]}
torchwood-0.9.0/cmd/litewitness/testdata/obscurity.txt000066400000000000000000000035301514564101300232430ustar00rootroot00000000000000# gentest seed b4e385f4358f7373cfa9184b176f3cccf808e795baf04092ddfde9461014f0c4

# set up log
exec witnessctl add-sigsum-log -key=ffdc2d4d98e4124d3feaf788c0c2f9abfd796083d1f0495437f302ec79cf100f

# start ssh-agent
env SSH_AUTH_SOCK=$WORK/s # barely below the max path length
! exec ssh-agent -a $SSH_AUTH_SOCK -D & # ssh-agent always exits 2
waitfor $SSH_AUTH_SOCK
chmod 600 witness_key.pem
exec ssh-add witness_key.pem

# start litewitness with -obscurity
exec litewitness -ssh-agent=$SSH_AUTH_SOCK -listen=localhost:7395 -name=example.com/witness -key=e933707e0e36c30f01d94b5d81e742da373679d88eb0f85f959ccd80b83b992a -obscurity &litewitness&
waitfor localhost:7395

# check that / returns 404 with -obscurity
exec hurl --test --error-format long obscurity.hurl

# check that add-checkpoint still works
exec hurl --test --error-format long add-checkpoint.hurl

# check that litewitness shut down cleanly
killall
wait litewitness
stderr 'shutting down'


-- witness_key.pem --
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtz
c2gtZWQyNTUxOQAAACBkhIrYq+1uhZgbOzh1slK4dn67SwL3A6yjsecbvWqOUAAA
AIgN5+09DeftPQAAAAtzc2gtZWQyNTUxOQAAACBkhIrYq+1uhZgbOzh1slK4dn67
SwL3A6yjsecbvWqOUAAAAEAx/8IRbsvgA6yqgAq3B1e9fVMgbj/r72ptB5bZVTCz
T2SEitir7W6FmBs7OHWyUrh2frtLAvcDrKOx5xu9ao5QAAAAAAECAwQF
-----END OPENSSH PRIVATE KEY-----


-- obscurity.hurl --
GET http://localhost:7395/
HTTP 404


-- add-checkpoint.hurl --
POST http://localhost:7395/add-checkpoint
```
old 0

sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562
1
KgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=

— sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562 UgIom7fPZTqpxWWhyjWduBvTvGVqsokMbqTArsQilegKoFBJQjUFAmQ0+YeSPM3wfUQMFSzVnnNuWRTYrajXpNUbIQY=
```
HTTP 200
[Asserts]
body contains "— example.com/witness"
torchwood-0.9.0/cmd/litewitness/testdata/sumdb.txt000066400000000000000000000034731514564101300223400ustar00rootroot00000000000000# set up log
exec witnessctl add-log -origin 'go.sum database tree'
exec witnessctl add-key -origin 'go.sum database tree' -key sum.golang.org+033de0ae+Ac4zctda0e5eza+HJyk9SxEdh+s3Ux18htTTAD8OuAn8

# adding the same key again should fail
! exec witnessctl add-key -origin 'go.sum database tree' -key sum.golang.org+033de0ae+Ac4zctda0e5eza+HJyk9SxEdh+s3Ux18htTTAD8OuAn8
stderr 'already exists'

# start ssh-agent
env SSH_AUTH_SOCK=$WORK/s # barely below the max path length
! exec ssh-agent -a $SSH_AUTH_SOCK -D & # ssh-agent always exits 2
waitfor $SSH_AUTH_SOCK
chmod 600 witness_key.pem
exec ssh-add witness_key.pem

# start litewitness
exec litewitness -listen=localhost:7381 -ssh-agent=$SSH_AUTH_SOCK -name=example.com/witness -key=e933707e0e36c30f01d94b5d81e742da373679d88eb0f85f959ccd80b83b992a &litewitness&
waitfor localhost:7381

# add-checkpoint
exec hurl --test --error-format long add-checkpoint.hurl

# check that litewitness shut down cleanly
killall
wait litewitness
stderr 'shutting down'

# witnessctl list-logs
exec witnessctl list-logs
stdout 'go.sum database tree'
stdout "size":35225469


-- witness_key.pem --
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtz
c2gtZWQyNTUxOQAAACBkhIrYq+1uhZgbOzh1slK4dn67SwL3A6yjsecbvWqOUAAA
AIgN5+09DeftPQAAAAtzc2gtZWQyNTUxOQAAACBkhIrYq+1uhZgbOzh1slK4dn67
SwL3A6yjsecbvWqOUAAAAEAx/8IRbsvgA6yqgAq3B1e9fVMgbj/r72ptB5bZVTCz
T2SEitir7W6FmBs7OHWyUrh2frtLAvcDrKOx5xu9ao5QAAAAAAECAwQF
-----END OPENSSH PRIVATE KEY-----


-- add-checkpoint.hurl --
POST http://localhost:7381/add-checkpoint
```
old 0

go.sum database tree
35225469
vt5T6GaLCXvyHFl9VUvvItR43XZxfLgftEcTyO3eJCQ=

— sum.golang.org Az3grpukl5AXaVfYkLiDGORx/DN2nlcS5kZHR5uYOBV2KA2HgXpD+gu9HHONebHLAyaKbbTM75QTtPydhKCExixSfwQ=
```
HTTP 200
[Asserts]
body contains "— example.com/witness"
torchwood-0.9.0/cmd/spicy/000077500000000000000000000000001514564101300154225ustar00rootroot00000000000000torchwood-0.9.0/cmd/spicy/spicy.go000066400000000000000000000206561514564101300171110ustar00rootroot00000000000000package main

import (
	"crypto/rand"
	"flag"
	"fmt"
	"log"
	"os"
	"path/filepath"
	"strconv"
	"strings"

	"filippo.io/torchwood"
	"golang.org/x/mod/sumdb/note"
	"golang.org/x/mod/sumdb/tlog"
)

func main() {
	verifyFlag := flag.String("verify", "",
		"verify the file's spicy signature with the given public key")
	keyFlag := flag.String("key", "",
		"the log's private key path (written by -init)")
	initFlag := flag.String("init", "",
		"initialize a new log with the given name (e.g. example.com/spicy)")
	assetsFlag := flag.String("assets", "",
		"directory where log entries and metadata are stored")
	flag.Parse()

	if *verifyFlag != "" {
		if len(flag.Args()) == 0 {
			log.Fatalf("no files to verify")
		}
		vkey, err := note.NewVerifier(*verifyFlag)
		if err != nil {
			log.Fatalf("could not parse public key: %v", err)
		}
		for _, path := range flag.Args() {
			f, err := os.ReadFile(path)
			if err != nil {
				log.Fatalf("could not read %q: %v", path, err)
			}
			sig, err := os.ReadFile(path + ".spicy")
			if err != nil {
				log.Fatalf("could not read %q: %v", path+".spicy", err)
			}
			s := string(sig)
			s, ok := strings.CutPrefix(s, "index ")
			if !ok {
				log.Fatalf("malformed spicy signature for %q", path)
			}
			i, s, ok := strings.Cut(s, "\n")
			if !ok {
				log.Fatalf("malformed spicy signature for %q", path)
			}
			index, err := strconv.ParseInt(i, 10, 64)
			if err != nil {
				log.Fatalf("malformed spicy signature for %q: %v", path, err)
			}
			var proof tlog.RecordProof
			for {
				var h string
				h, s, ok = strings.Cut(s, "\n")
				if !ok {
					log.Fatalf("malformed spicy signature for %q", path)
				}
				if h == "" {
					break
				}
				hh, err := tlog.ParseHash(h)
				if err != nil {
					log.Fatalf("malformed spicy signature for %q: %v", path, err)
				}
				proof = append(proof, hh)
			}
			m, err := note.Open([]byte(s), note.VerifierList(vkey))
			if err != nil {
				log.Fatalf("could not verify checkpoint for %q: %v", path, err)
			}
			c, err := torchwood.ParseCheckpoint(m.Text)
			if err != nil {
				log.Fatalf("could not parse checkpoint for %q: %v", path, err)
			}
			if c.Origin != vkey.Name() {
				log.Fatalf("spicy signature for %q is for a different log: got %q, want %q", path, c.Origin, vkey.Name())
			}
			if err := tlog.CheckRecord(proof, c.N, c.Hash, index, tlog.RecordHash(f)); err != nil {
				log.Fatalf("could not verify inclusion for %q: %v", path, err)
			}
		}
		fmt.Fprintf(os.Stderr, "Spicy signature(s) verified! 🌶️\n")
		return
	}

	if *initFlag != "" {
		latestPath := filepath.Join(*assetsFlag, "latest")
		if _, err := os.Stat(latestPath); err == nil {
			log.Fatalf("log already initialized, %q exists", latestPath)
		}
		edgePath := filepath.Join(*assetsFlag, "edge")
		if _, err := os.Stat(edgePath); err == nil {
			log.Fatalf("log already initialized, %q exists", edgePath)
		}
		if _, err := os.Stat(*keyFlag); err == nil {
			log.Fatalf("log already initialized, %q exists", *keyFlag)
		}

		skey, vkey, err := note.GenerateKey(rand.Reader, *initFlag)
		if err != nil {
			log.Fatalf("could not generate key: %v", err)
		}
		signer, err := note.NewSigner(skey)
		if err != nil {
			log.Fatalf("could not create signer: %v", err)
		}
		checkpoint, err := note.Sign(¬e.Note{
			Text: torchwood.Checkpoint{
				Origin: *initFlag,
			}.String(),
		}, signer)
		if err != nil {
			log.Fatalf("could not sign checkpoint: %v", err)
		}

		if err := os.WriteFile(*keyFlag, []byte(skey), 0600); err != nil {
			log.Fatalf("could not write key: %v", err)
		}
		if err := os.WriteFile(latestPath, checkpoint, 0644); err != nil {
			log.Fatalf("could not write latest checkpoint: %v", err)
		}
		if err := os.WriteFile(edgePath, []byte("size 0\n"), 0644); err != nil {
			log.Fatalf("could not write edge: %v", err)
		}

		fmt.Fprintf(os.Stderr, "Log initialized! 🌶️\n")
		fmt.Fprintf(os.Stderr, "  - Name: %s\n", *initFlag)
		fmt.Fprintf(os.Stderr, "  - Public key: %s\n", vkey)
		fmt.Fprintf(os.Stderr, "  - Private key path: %s\n", *keyFlag)
		fmt.Fprintf(os.Stderr, "  - Assets directory: %s\n", *assetsFlag)
		return
	}

	if len(flag.Args()) == 0 {
		log.Fatalf("no files to append")
	}

	skey, err := os.ReadFile(*keyFlag)
	if err != nil {
		log.Fatalf("could not read key: %v", err)
	}
	signer, err := note.NewSigner(strings.TrimSpace(string(skey)))
	if err != nil {
		log.Fatalf("could not parse key: %v", err)
	}
	verifier, err := torchwood.NewVerifierFromSigner(strings.TrimSpace(string(skey)))
	if err != nil {
		log.Fatalf("could not create verifier: %v", err)
	}

	checkpoint, err := os.ReadFile(filepath.Join(*assetsFlag, "latest"))
	if err != nil {
		log.Fatalf("could not read latest checkpoint: %v", err)
	}
	n, err := note.Open(checkpoint, note.VerifierList(verifier))
	if err != nil {
		log.Fatalf("could not verify latest checkpoint: %v", err)
	}
	c, err := torchwood.ParseCheckpoint(n.Text)
	if err != nil {
		log.Fatalf("could not parse latest checkpoint: %v", err)
	}

	hashes := make(map[int64]tlog.Hash)
	hashReader := tlog.HashReaderFunc(func(indexes []int64) ([]tlog.Hash, error) {
		list := make([]tlog.Hash, 0, len(indexes))
		for _, id := range indexes {
			h, ok := hashes[id]
			if !ok {
				return nil, fmt.Errorf("index %d not in hashes", id)
			}
			list = append(list, h)
		}
		return list, nil
	})

	edge, err := os.ReadFile(filepath.Join(*assetsFlag, "edge"))
	if err != nil {
		log.Fatalf("could not open edge file: %v", err)
	}
	lines := strings.Split(strings.TrimSpace(string(edge)), "\n")
	if len(lines) < 1 {
		log.Fatalf("malformed edge file")
	}
	if size, ok := strings.CutPrefix(lines[0], "size "); !ok {
		log.Fatalf("malformed edge file: %q", lines[0])
	} else {
		n, err := strconv.ParseInt(size, 10, 64)
		if err != nil {
			log.Fatalf("malformed edge file: %v", err)
		}
		if n != c.N {
			log.Fatalf("edge file size mismatch: got %d, latest checkpoint is %d", n, c.N)
		}
	}
	idx := torchwood.RightEdge(c.N)
	if len(idx) != len(lines[1:]) {
		log.Fatalf("edge hash count mismatch: got %d, want %d", len(lines[1:]), len(idx))
	}
	for i, line := range lines[1:] {
		hash, err := tlog.ParseHash(line)
		if err != nil {
			log.Fatalf("malformed edge file: %v", err)
		}
		hashes[idx[i]] = hash
	}

	fmt.Fprintf(os.Stderr, "Log loaded.\n")
	fmt.Fprintf(os.Stderr, "  - Name: %s\n", c.Origin)
	fmt.Fprintf(os.Stderr, "  - Current size: %d\n", c.N)
	fmt.Fprintf(os.Stderr, "  - Assets directory: %s\n", *assetsFlag)

	for i, path := range flag.Args() {
		if _, err := os.Stat(path + ".spicy"); err == nil {
			log.Fatalf("spicy signature already exists for %q", path)
		}
		f, err := os.ReadFile(path)
		if err != nil {
			log.Fatalf("could not read %q: %v", path, err)
		}
		n := c.N + int64(i)
		hh, err := tlog.StoredHashes(n, f, hashReader)
		if err != nil {
			log.Fatalf("could not append %q: %v", path, err)
		}
		for k, h := range hh {
			hashes[tlog.StoredHashIndex(0, n)+int64(k)] = h
		}
		entryPath := filepath.Join(*assetsFlag, strconv.FormatInt(n, 10))
		if err := os.WriteFile(entryPath, f, 0644); err != nil {
			log.Fatalf("could not copy %q to assets: %v", path, err)
		}
		fmt.Fprintf(os.Stderr, "  + %q is now entry %d\n", path, n)
	}

	N := c.N + int64(len(flag.Args()))
	th, err := tlog.TreeHash(N, hashReader)
	if err != nil {
		log.Fatalf("could not compute tree hash: %v", err)
	}
	newCheckpoint, err := note.Sign(¬e.Note{
		Text: torchwood.Checkpoint{
			Origin: c.Origin,
			Tree:   tlog.Tree{N: N, Hash: th},
		}.String()}, signer)
	if err != nil {
		log.Fatalf("could not sign new checkpoint: %v", err)
	}
	newEdge := fmt.Sprintf("size %d\n", N)
	for _, idx := range torchwood.RightEdge(N) {
		newEdge += fmt.Sprintf("%s\n", hashes[idx])
	}

	if err := os.WriteFile(filepath.Join(*assetsFlag, "latest"), newCheckpoint, 0644); err != nil {
		log.Fatalf("could not write new checkpoint: %v", err)
	}
	if err := os.WriteFile(filepath.Join(*assetsFlag, "edge"), []byte(newEdge), 0644); err != nil {
		log.Fatalf("could not write new edge: %v", err)
	}
	fmt.Fprintf(os.Stderr, "  - New size: %d\n", N)

	for i, path := range flag.Args() {
		s := fmt.Sprintf("index %d\n", c.N+int64(i))
		proof, err := tlog.ProveRecord(N, c.N+int64(i), hashReader)
		if err != nil {
			log.Fatalf("could not prove record %d: %v", c.N+int64(i), err)
		}
		for _, p := range proof {
			s += fmt.Sprintf("%s\n", p)
		}
		s += "\n"
		s += string(newCheckpoint)
		if err := os.WriteFile(path+".spicy", []byte(s), 0644); err != nil {
			log.Fatalf("could not write spicy signature: %v", err)
		}
	}
	fmt.Fprintf(os.Stderr, "Spicy signatures written! 🌶️\n")
}
torchwood-0.9.0/cmd/sumdb-warmup/000077500000000000000000000000001514564101300167165ustar00rootroot00000000000000torchwood-0.9.0/cmd/sumdb-warmup/main.go000066400000000000000000000021171514564101300201720ustar00rootroot00000000000000package main

import (
	"context"
	"io"
	"os"
	"path/filepath"

	"filippo.io/torchwood"
	"github.com/cheggaaa/pb/v3"
	"golang.org/x/mod/sumdb/tlog"
)

func main() {
	latest, err := io.ReadAll(os.Stdin)
	if err != nil {
		panic(err)
	}
	tree, err := tlog.ParseTree(latest)
	if err != nil {
		panic(err)
	}

	cacheDir, err := os.UserCacheDir()
	if err != nil {
		panic(err)
	}
	cacheDir = filepath.Join(cacheDir, "sumdb-warmup")
	if err := os.MkdirAll(cacheDir, 0o755); err != nil {
		panic(err)
	}

	fetcher, err := torchwood.NewTileFetcher("https://sum.golang.org/",
		torchwood.WithTilePath(tlog.Tile.Path))
	if err != nil {
		panic(err)
	}
	dirCache, err := torchwood.NewPermanentCache(fetcher, cacheDir,
		torchwood.WithPermanentCacheTilePath(tlog.Tile.Path))
	if err != nil {
		panic(err)
	}
	client, err := torchwood.NewClient(dirCache, torchwood.WithCutEntry(torchwood.ReadSumDBEntry))
	if err != nil {
		panic(err)
	}

	bar := pb.Start64(tree.N)
	for range client.Entries(context.Background(), tree, 0) {
		bar.Increment()
	}
	bar.Finish()
	if err := client.Err(); err != nil {
		panic(err)
	}
}
torchwood-0.9.0/cmd/witnessctl/000077500000000000000000000000001514564101300164725ustar00rootroot00000000000000torchwood-0.9.0/cmd/witnessctl/loglist.go000066400000000000000000000031401514564101300204740ustar00rootroot00000000000000package main

import (
	"fmt"
	"log"
	"strings"

	"golang.org/x/mod/sumdb/note"
)

func parseLogList(logList []byte, verbose bool) (map[string]string, error) {
	logs := make(map[string]string)
	var sawHeader bool
	var vkey, origin string
	finalizeLogEntry := func() {
		if vkey == "" {
			// The list may be empty.
			return
		}
		defer func() {
			vkey = ""
			origin = ""
		}()
		v, err := note.NewVerifier(vkey)
		if err != nil {
			if verbose {
				log.Printf("Skipping invalid vkey %q: %v", vkey, err)
			}
			return
		}
		if origin == "" {
			origin = v.Name()
		}
		if logs[origin] != "" {
			if verbose {
				log.Printf("Skipping duplicate log entry for %q", origin)
				log.Printf("    - %q", logs[origin])
				log.Printf("    - %q (skipped)", vkey)
			}
			return
		}
		logs[origin] = vkey
	}
	for line := range strings.Lines(string(logList)) {
		line = strings.TrimSpace(line)
		if strings.HasPrefix(line, "#") {
			// Comment line, skip.
			continue
		}
		if line == "" {
			// Empty line, skip.
			continue
		}
		if !sawHeader {
			// First non-comment, non-empty line is the header.
			if line != "logs/v0" {
				return nil, fmt.Errorf("invalid log list header: %q", line)
			}
			sawHeader = true
			continue
		}
		key, value, _ := strings.Cut(line, " ")
		if vkey == "" && key != "vkey" {
			return nil, fmt.Errorf("expected vkey entry, got %q", line)
		}
		switch key {
		case "vkey":
			finalizeLogEntry()
			if value == "" {
				return nil, fmt.Errorf("empty vkey entry")
			}
			vkey = value
		case "origin":
			origin = value
		default:
			// Unknown key, ignore.
		}
	}
	finalizeLogEntry()
	return logs, nil
}
torchwood-0.9.0/cmd/witnessctl/witnessctl.go000066400000000000000000000220711514564101300212220ustar00rootroot00000000000000package main

import (
	"encoding/base64"
	"encoding/hex"
	"flag"
	"fmt"
	"io"
	"log"
	"net"
	"net/http"
	"os"
	"strings"
	"time"

	"filippo.io/torchwood/internal/witness"
	"golang.org/x/mod/sumdb/note"
	sigsum "sigsum.org/sigsum-go/pkg/crypto"
	"sigsum.org/sigsum-go/pkg/merkle"
	"zombiezen.com/go/sqlite"
	"zombiezen.com/go/sqlite/sqlitex"
)

func usage() {
	fmt.Printf("Usage: %s  [options]\n", os.Args[0])
	fmt.Println("Commands:")
	fmt.Println("    add-log -db  -origin ")
	fmt.Println("    add-key -db  -origin  -key ")
	fmt.Println("    del-key -db  -origin  -key ")
	fmt.Println("    add-bastion -db  -origin  -bastion ")
	fmt.Println("    del-bastion -db  -origin  -bastion ")
	fmt.Println("    add-sigsum-log -db  -key ")
	fmt.Println("    pull-logs -db  -source  [-verbose]")
	fmt.Println("    list-logs -db ")
	os.Exit(1)
}

func main() {
	if len(os.Args) < 2 {
		usage()
	}
	fs := flag.NewFlagSet(os.Args[0], flag.ExitOnError)
	dbFlag := fs.String("db", "litewitness.db", "path to sqlite database")
	switch os.Args[1] {
	case "add-log":
		originFlag := fs.String("origin", "", "log name")
		fs.Parse(os.Args[2:])
		db := openDB(*dbFlag)
		addLog(db, *originFlag)
		log.Printf("Added log %q.", *originFlag)

	case "add-key":
		originFlag := fs.String("origin", "", "log name")
		keyFlag := fs.String("key", "", "verifier key")
		fs.Parse(os.Args[2:])
		db := openDB(*dbFlag)
		checkKeyMatches(*originFlag, *keyFlag)
		addKey(db, *originFlag, *keyFlag)
		log.Printf("Added key %q for log %q.", *keyFlag, *originFlag)

	case "del-key":
		originFlag := fs.String("origin", "", "log name")
		keyFlag := fs.String("key", "", "verifier key")
		fs.Parse(os.Args[2:])
		db := openDB(*dbFlag)
		delKey(db, *originFlag, *keyFlag)
		log.Printf("Deleted key %q for log %q.", *keyFlag, *originFlag)

	case "add-bastion":
		originFlag := fs.String("origin", "", "log name")
		bastionFlag := fs.String("bastion", "", "address:port")
		fs.Parse(os.Args[2:])
		checkBastion(*bastionFlag)
		db := openDB(*dbFlag)
		addBastion(db, *originFlag, *bastionFlag)
		log.Printf("Added bastion %q for log %q.", *bastionFlag, *originFlag)

	case "del-bastion":
		originFlag := fs.String("origin", "", "log name")
		bastionFlag := fs.String("bastion", "", "address:port")
		fs.Parse(os.Args[2:])
		db := openDB(*dbFlag)
		delBastion(db, *originFlag, *bastionFlag)
		log.Printf("Deleted bastion %q for log %q.", *bastionFlag, *originFlag)

	case "add-sigsum-log":
		keyFlag := fs.String("key", "", "hex-encoded key")
		fs.Parse(os.Args[2:])
		db := openDB(*dbFlag)
		addSigsumLog(db, *keyFlag)

	case "pull-logs":
		sourceFlag := fs.String("source", "", "witness network log list URL or file path")
		verboseFlag := fs.Bool("verbose", false, "verbose output")
		fs.Parse(os.Args[2:])
		db := openDB(*dbFlag)
		pullLogs(db, *sourceFlag, *verboseFlag)

	case "list-logs":
		fs.Parse(os.Args[2:])
		db := openDB(*dbFlag)
		listLogs(db)

	default:
		usage()
	}
}

func openDB(dbPath string) *sqlite.Conn {
	db, err := witness.OpenDB(dbPath)
	if err != nil {
		log.Fatalf("Error opening database: %v", err)
	}
	return db
}

func addLog(db *sqlite.Conn, origin string) {
	treeHash := merkle.HashEmptyTree()
	if err := sqlitexExec(db, "INSERT INTO log (origin, tree_size, tree_hash) VALUES (?, 0, ?)",
		nil, origin, base64.StdEncoding.EncodeToString(treeHash[:])); err != nil {
		log.Fatalf("Error adding log: %v", err)
	}
}

func checkKeyMatches(origin string, vk string) {
	v, err := note.NewVerifier(vk)
	if err != nil {
		log.Fatalf("Error parsing verifier key: %v", err)
	}
	if v.Name() != origin {
		log.Printf("Warning: verifier key name %q does not match origin %q.", v.Name(), origin)
	}
}

func checkBastion(bastion string) {
	if _, _, err := net.SplitHostPort(bastion); err != nil {
		log.Fatalf("Error parsing bastion %q as address:port: %v", bastion, err)
	}
}

func addKey(db *sqlite.Conn, origin string, vk string) {
	var exists bool
	err := sqlitexExec(db, "SELECT 1 FROM key WHERE origin = ? AND key = ?", func(stmt *sqlite.Stmt) error {
		exists = true
		return nil
	}, origin, vk)
	if err != nil {
		log.Fatalf("Error checking key: %v", err)
	}
	if exists {
		log.Fatalf("Key %q already exists for log %q.", vk, origin)
	}
	err = sqlitexExec(db, "INSERT INTO key (origin, key) VALUES (?, ?)", nil, origin, vk)
	if err != nil {
		log.Fatalf("Error adding key: %v", err)
	}
}

func delKey(db *sqlite.Conn, origin string, vk string) {
	err := sqlitexExec(db, "DELETE FROM key WHERE origin = ? AND key = ?", nil, origin, vk)
	if err != nil {
		log.Fatalf("Error deleting key: %v", err)
	}
	if db.Changes() == 0 {
		log.Fatalf("Key %q not found.", vk)
	}
}

func addBastion(db *sqlite.Conn, origin string, bastion string) {
	err := sqlitexExec(db, "INSERT INTO bastion (origin, bastion) VALUES (?, ?)", nil, origin, bastion)
	if err != nil {
		log.Fatalf("Error adding bastion: %v", err)
	}
}

func delBastion(db *sqlite.Conn, origin string, bastion string) {
	err := sqlitexExec(db, "DELETE FROM bastion WHERE origin = ? AND bastion = ?", nil, origin, bastion)
	if err != nil {
		log.Fatalf("Error deleting bastion: %v", err)
	}
	if db.Changes() == 0 {
		log.Fatalf("Bastion %q not found.", bastion)
	}
}

func addSigsumLog(db *sqlite.Conn, keyFlag string) {
	if len(keyFlag) != sigsum.PublicKeySize*2 {
		log.Fatal("Key must be 32 hex-encoded bytes.")
	}
	var key sigsum.PublicKey
	if _, err := hex.Decode(key[:], []byte(keyFlag)); err != nil {
		log.Fatalf("Error decoding key: %v", err)
	}
	keyHash := sigsum.HashBytes(key[:])
	origin := fmt.Sprintf("sigsum.org/v1/tree/%x", keyHash)
	vk, err := note.NewEd25519VerifierKey(origin, key[:])
	if err != nil {
		log.Fatalf("Error computing verifier key: %v", err)
	}
	addLog(db, origin)
	addKey(db, origin, vk)
	log.Printf("Added Sigsum log %q with key %q.", origin, vk)
}

func pullLogs(db *sqlite.Conn, source string, verbose bool) {
	var logList []byte
	if strings.HasPrefix(source, "https://") {
		client := http.Client{Timeout: 30 * time.Second}
		resp, err := client.Get(source)
		if err != nil {
			log.Fatalf("Error fetching log list: %v", err)
		}
		defer resp.Body.Close()
		if resp.StatusCode != http.StatusOK {
			log.Fatalf("Error fetching log list: HTTP %d", resp.StatusCode)
		}
		logList, err = io.ReadAll(resp.Body)
		if err != nil {
			log.Fatalf("Error reading log list: %v", err)
		}
	} else {
		var err error
		logList, err = os.ReadFile(source)
		if err != nil {
			log.Fatalf("Error reading log list: %v", err)
		}
	}
	logs, err := parseLogList(logList, verbose)
	if err != nil {
		log.Fatalf("Error parsing log list: %v", err)
	}
	for origin, vkey := range logs {
		keys, exists := logKeys(db, origin)
		if exists {
			if keys[vkey] {
				// Key already exists, nothing to do.
				if verbose {
					log.Printf("Log %q with key %q already exists, skipping.", origin, vkey)
				}
				continue
			}
			// Log is known but has no keys, warn.
			if len(keys) == 0 {
				log.Printf("Warning: log %q exists but is listed without any keys in the database.\n", origin)
				log.Printf("The new key was not added automatically to avoid enabling a manually disabled log.\n")
				log.Printf("  - new key:\n")
				log.Printf("    - %q\n", vkey)
				continue
			}
			// Key is different, warn.
			log.Printf("Warning: log %q is listed with a different key than the one in the database.\n", origin)
			log.Printf("  - existing keys:\n")
			for k := range keys {
				log.Printf("    - %q\n", k)
			}
			log.Printf("  - new key:\n")
			log.Printf("    - %q\n", vkey)
			continue
		}
		// New log, add it.
		addLog(db, origin)
		addKey(db, origin, vkey)
		if verbose {
			log.Printf("Added log %q with key %q.", origin, vkey)
		}
	}
}

func logKeys(db *sqlite.Conn, origin string) (keys map[string]bool, exists bool) {
	keys = make(map[string]bool)
	if err := sqlitexExec(db, "SELECT 1 FROM log WHERE origin = ?", func(stmt *sqlite.Stmt) error {
		exists = true
		return nil
	}, origin); err != nil {
		log.Fatalf("Error looking for log %q: %v", origin, err)
	}
	if !exists {
		return keys, false
	}
	if err := sqlitexExec(db, "SELECT key FROM key WHERE origin = ?", func(stmt *sqlite.Stmt) error {
		keys[stmt.ColumnText(0)] = true
		return nil
	}, origin); err != nil {
		log.Fatalf("Error querying keys: %v", err)
	}
	return keys, true
}

func listLogs(db *sqlite.Conn) {
	if err := sqlitexExec(db, `
	SELECT json_object(
		'origin', l.origin,
		'size', l.tree_size,
		'root_hash', l.tree_hash,
		'keys', COALESCE(
			(SELECT json_group_array(k.key) FROM key k WHERE k.origin = l.origin),
			json_array()
		),
		'bastions', COALESCE(
			(SELECT json_group_array(b.bastion) FROM bastion b WHERE b.origin = l.origin),
			json_array()
		))
	FROM log l
	ORDER BY l.origin
	`, func(stmt *sqlite.Stmt) error {
		_, err := fmt.Printf("%s\n", stmt.ColumnText(0))
		return err
	}); err != nil {
		log.Fatalf("Error listing logs: %v", err)
	}
}

func sqlitexExec(conn *sqlite.Conn, query string, resultFn func(stmt *sqlite.Stmt) error, args ...any) error {
	return sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ResultFunc: resultFn, Args: args})
}
torchwood-0.9.0/cosignature.go000066400000000000000000000132631514564101300164070ustar00rootroot00000000000000// Copyright 2023 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package torchwood

import (
	"crypto"
	"crypto/ed25519"
	"crypto/sha256"
	"encoding/base64"
	"encoding/binary"
	"errors"
	"fmt"
	"math"
	"strconv"
	"strings"
	"time"
	"unicode"
	"unicode/utf8"

	"golang.org/x/crypto/cryptobyte"
	"golang.org/x/mod/sumdb/note"
)

const algCosignatureV1 = 4

// NewCosignatureSigner constructs a new [CosignatureSigner] from an Ed25519
// private key.
func NewCosignatureSigner(name string, key crypto.Signer) (*CosignatureSigner, error) {
	if !isValidName(name) {
		return nil, errors.New("invalid name")
	}
	k, ok := key.Public().(ed25519.PublicKey)
	if !ok {
		return nil, errors.New("key type is not Ed25519")
	}

	s := &CosignatureSigner{}
	s.v.name = name
	s.v.hash = keyHash(name, append([]byte{algCosignatureV1}, k...))
	s.v.key = k
	s.sign = func(msg []byte) ([]byte, error) {
		t := uint64(time.Now().Unix())
		m, err := formatCosignatureV1(t, msg)
		if err != nil {
			return nil, err
		}
		s, err := key.Sign(nil, m, crypto.Hash(0))
		if err != nil {
			return nil, err
		}

		// The signature itself is encoded as timestamp || signature.
		sig := make([]byte, 0, 8+ed25519.SignatureSize)
		sig = binary.BigEndian.AppendUint64(sig, t)
		sig = append(sig, s...)
		return sig, nil
	}
	s.v.verify = func(msg, sig []byte) bool {
		if len(sig) != 8+ed25519.SignatureSize {
			return false
		}
		t := binary.BigEndian.Uint64(sig)
		sig = sig[8:]
		m, err := formatCosignatureV1(t, msg)
		if err != nil {
			return false
		}
		return ed25519.Verify(k, m, sig)
	}

	return s, nil
}

func formatCosignatureV1(t uint64, msg []byte) ([]byte, error) {
	// The signed message is in the following format
	//
	//      cosignature/v1
	//      time TTTTTTTTTT
	//      [checkpoint]
	//
	// where TTTTTTTTTT is the current UNIX timestamp.

	c, err := ParseCheckpoint(string(msg))
	if err != nil {
		return nil, fmt.Errorf("message being signed is not a valid checkpoint: %w", err)
	}
	if string(msg) != c.String() {
		return nil, errors.New("message being signed does not match parsed checkpoint")
	}
	return []byte(fmt.Sprintf("cosignature/v1\ntime %d\n%s", t, msg)), nil
}

// CosignatureSigner is a [note.Signer] that produces timestamped
// cosignatures according to c2sp.org/tlog-cosignature.
type CosignatureSigner struct {
	v    CosignatureVerifier
	sign func([]byte) ([]byte, error)
}

func (s *CosignatureSigner) Name() string                    { return s.v.Name() }
func (s *CosignatureSigner) KeyHash() uint32                 { return s.v.KeyHash() }
func (s *CosignatureSigner) Sign(msg []byte) ([]byte, error) { return s.sign(msg) }
func (s *CosignatureSigner) Verifier() *CosignatureVerifier  { return &s.v }

var _ note.Signer = &CosignatureSigner{}

// CosignatureVerifier is a [note.Verifier] that verifies cosignatures
// according to c2sp.org/tlog-cosignature.
type CosignatureVerifier struct {
	verifier
	key ed25519.PublicKey
}

var _ note.Verifier = &CosignatureVerifier{}

// NewCosignatureVerifier constructs a new [CosignatureVerifier] from a
// c2sp.org/signed-note vkey string.
func NewCosignatureVerifier(vkey string) (*CosignatureVerifier, error) {
	name, vkey, _ := strings.Cut(vkey, "+")
	hash16, key64, _ := strings.Cut(vkey, "+")
	hash, err1 := strconv.ParseUint(hash16, 16, 32)
	key, err2 := base64.StdEncoding.DecodeString(key64)
	if len(hash16) != 8 || err1 != nil || err2 != nil || !isValidName(name) || len(key) == 0 {
		return nil, errors.New("malformed verifier id")
	}
	if uint32(hash) != keyHash(name, key) {
		return nil, errors.New("invalid verifier hash")
	}
	alg, key := key[0], key[1:]
	if alg != algCosignatureV1 {
		return nil, errors.New("unknown verifier algorithm")
	}
	if len(key) != ed25519.PublicKeySize {
		return nil, errors.New("malformed verifier public key")
	}
	k := ed25519.PublicKey(key)
	return &CosignatureVerifier{
		verifier: verifier{
			name: name,
			hash: uint32(hash),
			verify: func(msg, sig []byte) bool {
				if len(sig) != 8+ed25519.SignatureSize {
					return false
				}
				t := binary.BigEndian.Uint64(sig)
				sig = sig[8:]
				m, err := formatCosignatureV1(t, msg)
				if err != nil {
					return false
				}
				return ed25519.Verify(k, m, sig)
			},
		},
		key: key,
	}, nil
}

// String returns the vkey encoding of the verifier, according to
// c2sp.org/signed-note.
func (v *CosignatureVerifier) String() string {
	return fmt.Sprintf("%s+%08x+%s", v.name, v.hash, base64.StdEncoding.EncodeToString(
		append([]byte{algCosignatureV1}, v.key...)))
}

// isValidName reports whether name is valid.
// It must be non-empty and not have any Unicode spaces or pluses.
func isValidName(name string) bool {
	return name != "" && utf8.ValidString(name) && strings.IndexFunc(name, unicode.IsSpace) < 0 && !strings.Contains(name, "+")
}

func keyHash(name string, key []byte) uint32 {
	h := sha256.New()
	h.Write([]byte(name))
	h.Write([]byte("\n"))
	h.Write(key)
	sum := h.Sum(nil)
	return binary.BigEndian.Uint32(sum)
}

// CosignatureTimestamp returns the timestamp of the cosignature, which is the
// time at which the witness signed the checkpoint, in seconds since the Unix epoch.
//
// Witnesses can re-sign a checkpoint, but only if it's for the latest tree they
// have seen. Thus, the timestamp can be used to determine if a checkpoint is fresh.
func CosignatureTimestamp(sig note.Signature) (int64, error) {
	sigBytes, err := base64.StdEncoding.DecodeString(sig.Base64)
	if err != nil {
		return 0, err
	}
	var timestamp uint64
	s := cryptobyte.String(sigBytes)
	if !s.Skip(4 /* key hash */) || !s.ReadUint64(×tamp) ||
		timestamp > math.MaxInt64 {
		return 0, errors.New("malformed cosignature")
	}
	return int64(timestamp), nil
}
torchwood-0.9.0/cosignature_test.go000066400000000000000000000023551514564101300174460ustar00rootroot00000000000000package torchwood_test

import (
	"bytes"
	"crypto/ed25519"
	"crypto/rand"
	"testing"

	"filippo.io/torchwood"
	"golang.org/x/mod/sumdb/note"
)

func TestSignerRoundtrip(t *testing.T) {
	_, k, err := ed25519.GenerateKey(rand.Reader)
	if err != nil {
		t.Fatal(err)
	}

	s, err := torchwood.NewCosignatureSigner("example.com", k)
	if err != nil {
		t.Fatal(err)
	}

	msg := "test\n123\nf+7CoKgXKE/tNys9TTXcr/ad6U/K3xvznmzew9y6SP0=\nextension 1\nextension 2\n"
	n, err := note.Sign(¬e.Note{Text: msg}, s)
	if err != nil {
		t.Fatal(err)
	}

	if _, err := note.Open(n, note.VerifierList(s.Verifier())); err != nil {
		t.Fatal(err)
	}

	nn := bytes.Replace(n, []byte("extension 2"), []byte("extension X"), 1)
	if _, err := note.Open(nn, note.VerifierList(s.Verifier())); err == nil {
		t.Fatal("expected error verifying modified note, got nil")
	}

	v, err := torchwood.NewCosignatureVerifier(s.Verifier().String())
	if err != nil {
		t.Fatal(err)
	}
	if v.Name() != "example.com" {
		t.Fatalf("verifier name = %q; want %q", v.Name(), "example.com")
	}
	if v.KeyHash() != s.Verifier().KeyHash() {
		t.Fatalf("verifier hash = %d; want %d", v.KeyHash(), s.Verifier().KeyHash())
	}
	if _, err := note.Open(n, note.VerifierList(v)); err != nil {
		t.Fatal(err)
	}
}
torchwood-0.9.0/go.mod000066400000000000000000000040561514564101300146430ustar00rootroot00000000000000module filippo.io/torchwood

go 1.24.0

require (
	filippo.io/age v1.2.1
	filippo.io/mostly-harmless/vrf-r255 v0.0.0-20251110151915-f587ba8b0f82
	github.com/cheggaaa/pb/v3 v3.1.5
	github.com/rogpeppe/go-internal v1.14.1
	github.com/transparency-dev/tessera v1.0.1
	golang.org/x/crypto v0.46.0
	golang.org/x/mod v0.31.0
	golang.org/x/net v0.47.0
	golang.org/x/sync v0.19.0
	lukechampine.com/blake3 v1.4.1
	sigsum.org/sigsum-go v0.6.1
	zombiezen.com/go/sqlite v1.4.2
)

require (
	filippo.io/edwards25519 v1.1.0 // indirect
	github.com/VividCortex/ewma v1.2.0 // indirect
	github.com/cenkalti/backoff/v5 v5.0.3 // indirect
	github.com/cespare/xxhash/v2 v2.3.0 // indirect
	github.com/dustin/go-humanize v1.0.1 // indirect
	github.com/fatih/color v1.15.0 // indirect
	github.com/go-logr/logr v1.4.3 // indirect
	github.com/go-logr/stdr v1.2.2 // indirect
	github.com/google/uuid v1.6.0 // indirect
	github.com/gtank/ristretto255 v0.2.0 // indirect
	github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
	github.com/klauspost/cpuid/v2 v2.0.9 // indirect
	github.com/mattn/go-colorable v0.1.13 // indirect
	github.com/mattn/go-isatty v0.0.20 // indirect
	github.com/mattn/go-runewidth v0.0.17 // indirect
	github.com/ncruces/go-strftime v0.1.9 // indirect
	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
	github.com/rivo/uniseg v0.4.7 // indirect
	github.com/transparency-dev/formats v0.0.0-20251208091212-1378f9e1b1b7 // indirect
	github.com/transparency-dev/merkle v0.0.2 // indirect
	go.opentelemetry.io/auto/sdk v1.2.1 // indirect
	go.opentelemetry.io/otel v1.39.0 // indirect
	go.opentelemetry.io/otel/metric v1.39.0 // indirect
	go.opentelemetry.io/otel/trace v1.39.0 // indirect
	golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
	golang.org/x/sys v0.39.0 // indirect
	golang.org/x/text v0.32.0 // indirect
	golang.org/x/tools v0.39.0 // indirect
	k8s.io/klog/v2 v2.130.1 // indirect
	modernc.org/libc v1.65.7 // indirect
	modernc.org/mathutil v1.7.1 // indirect
	modernc.org/memory v1.11.0 // indirect
	modernc.org/sqlite v1.37.1 // indirect
)
torchwood-0.9.0/go.sum000066400000000000000000000247231514564101300146730ustar00rootroot00000000000000c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805 h1:u2qwJeEvnypw+OCPUHmoZE3IqwfuN5kgDfo5MLzpNM0=
c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZkG3FHa72zUprnhd3v/Z18Snm4w=
filippo.io/age v1.2.1 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o=
filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
filippo.io/mostly-harmless/vrf-r255 v0.0.0-20251110151915-f587ba8b0f82 h1:ZYps1vXve+JFo/XxLG8bCg2zIL5pn5nys5e+GnbD7nc=
filippo.io/mostly-harmless/vrf-r255 v0.0.0-20251110151915-f587ba8b0f82/go.mod h1:ac5Gah0LmA0/YD4SHdO2M+WUjScWsc99zrAfJK4QViY=
github.com/VividCortex/ewma v1.2.0 h1:f58SaIzcDXrSy3kWaHNvuJgJ3Nmz59Zji6XoJR/q1ow=
github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4=
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/cheggaaa/pb/v3 v3.1.5 h1:QuuUzeM2WsAqG2gMqtzaWithDJv0i+i6UlnwSCI4QLk=
github.com/cheggaaa/pb/v3 v3.1.5/go.mod h1:CrxkeghYTXi1lQBEI7jSn+3svI3cuc19haAj6jM60XI=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/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/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/gtank/ristretto255 v0.2.0 h1:LeOuWr6giplWkkMizx2emfG03SRPJqKt1nfIHLVHQ/0=
github.com/gtank/ristretto255 v0.2.0/go.mod h1:OJ1ox/dWcp7sJ5grYDcZ+kkHYuj5nelW5aaL7ESVXBw=
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/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ=
github.com/mattn/go-runewidth v0.0.17/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/transparency-dev/formats v0.0.0-20251208091212-1378f9e1b1b7 h1:PwfIAvobqihWBi1/KIsw0IzTEJ89rYJqmXfzmqacySw=
github.com/transparency-dev/formats v0.0.0-20251208091212-1378f9e1b1b7/go.mod h1:mQ5ASe7MNPT+yRc47hLguwsNdE2Go0mT6piyzUO+ynw=
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.1 h1:t2PS/GzuxU5x6kAQQ4ZGBBUqLGOF2R+N/jMmxy9gYnw=
github.com/transparency-dev/tessera v1.0.1/go.mod h1:s1dUEOprg84J3WGKGviBn2sz+08l5dR+l0aoXUP1FOs=
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.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
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.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo=
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00=
modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs=
modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
sigsum.org/sigsum-go v0.6.1 h1:yumQb99ySNrLgcwxzmVSJQX+kPkppFVwWdn6/tfnbdI=
sigsum.org/sigsum-go v0.6.1/go.mod h1:VuYGNZBDKuff6QNd9mgN9Nfi5ZWnGq4JZz6FUso42BY=
zombiezen.com/go/sqlite v1.4.2 h1:KZXLrBuJ7tKNEm+VJcApLMeQbhmAUOKA5VWS93DfFRo=
zombiezen.com/go/sqlite v1.4.2/go.mod h1:5Kd4taTAD4MkBzT25mQ9uaAlLjyR0rFhsR6iINO70jc=
torchwood-0.9.0/internal/000077500000000000000000000000001514564101300153445ustar00rootroot00000000000000torchwood-0.9.0/internal/slogconsole/000077500000000000000000000000001514564101300176735ustar00rootroot00000000000000torchwood-0.9.0/internal/slogconsole/slogconsole.go000066400000000000000000000157071514564101300225630ustar00rootroot00000000000000package slogconsole

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"net/http"
	"regexp"
	"slices"
	"strings"
	"sync"
	"time"
)

// Handler is an [slog.Handler] that exposes records over a web console.
//
// It implements [slog.Handler] and [http.Handler]. The HTTP handler accepts
// [server-sent events] requests (with Accept: text/event-stream) and streams
// all records as text to the client. It also serves a simple HTML page that
// connects to the SSE endpoint and prints the logs (with Accept: text/html).
//
// The slog Handler will accept all records (Enabled returns true) if there are
// any web clients connected, and none otherwise. If a client is too slow to
// consume records, they will be dropped.
//
// [server-sent events]: https://html.spec.whatwg.org/multipage/server-sent-events.html
type Handler struct {
	ch *commonHandler
	sh slog.Handler
}

// commonHandler is where all the actual state is.
//
// We need to wrap it to support swapping the slog.Handler for WithAttrs and
// WithGroup. This feels like a significant shortcoming of the slog.Handler
// interface, adding a lot of complexity to otherwise simple Handler
// implementations. (Note how [slog.TextHandler] has to do the same thing.)
type commonHandler struct {
	mu      sync.RWMutex
	clients []chan []byte
	limit   int
	filter  *regexp.Regexp
}

var _ http.Handler = &Handler{}
var _ slog.Handler = &Handler{}

// New returns a new Handler.
//
// opts can be nil, and is passed to [slog.NewTextHandler].
// If Level is not set, it defaults to slog.LevelDebug.
func New(opts *slog.HandlerOptions) *Handler {
	if opts == nil {
		opts = &slog.HandlerOptions{}
	}
	if opts.Level == nil {
		opts.Level = slog.LevelDebug
	}
	h := &commonHandler{limit: 10}
	sh := slog.NewTextHandler(h, opts)
	return &Handler{ch: h, sh: sh}
}

// Handle implements [slog.Handler].
func (h *Handler) Handle(ctx context.Context, r slog.Record) error {
	return h.sh.Handle(ctx, r)
}

// WithAttrs implements [slog.Handler].
func (h *Handler) WithAttrs(attrs []slog.Attr) slog.Handler {
	return &Handler{ch: h.ch, sh: h.sh.WithAttrs(attrs)}
}

// WithGroup implements [slog.Handler].
func (h *Handler) WithGroup(name string) slog.Handler {
	return &Handler{ch: h.ch, sh: h.sh.WithGroup(name)}
}

// Enabled implements [slog.Handler].
func (h *Handler) Enabled(_ context.Context, _ slog.Level) bool {
	h.ch.mu.RLock()
	defer h.ch.mu.RUnlock()
	return len(h.ch.clients) > 0
}

func (h *commonHandler) Write(b []byte) (int, error) {
	h.mu.RLock()
	clients := h.clients
	h.mu.RUnlock()

	for _, c := range clients {
		select {
		case c <- b:
		default:
		}
	}

	return len(b), nil
}

// SetLimit sets the maximum number of clients that can connect to the handler.
// If the limit is reached, new clients will receive a 503 Service Unavailable
// response.
//
// The default limit is 10.
func (h *Handler) SetLimit(limit int) {
	h.ch.mu.Lock()
	defer h.ch.mu.Unlock()
	h.ch.limit = limit
}

var IPAddressFilter = regexp.MustCompile(`\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b|\b(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}\b|\b(?:[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}::(?:[0-9a-fA-F]{1,4})?(?::[0-9a-fA-F]{1,4}){0,6}\b`)

// SetFilter sets a regular expression that will be redacted in the logs. The
// new filter only applies to new clients.
//
// The default is nil, which means no filtering.
func (h *Handler) SetFilter(filter *regexp.Regexp) {
	h.ch.mu.Lock()
	defer h.ch.mu.Unlock()
	h.ch.filter = filter
}

// ServeHTTP implements [http.Handler].
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	accept := strings.Split(r.Header.Get("Accept"), ",")
	for _, a := range accept {
		a, _, _ := strings.Cut(a, ";")
		switch a {
		case "text/event-stream":
			h.ch.serveSSE(w, r)
			return
		case "text/html":
			h.ch.serveHTML(w, r)
			return
		}
	}
	http.Error(w, "unsupported Accept", http.StatusNotAcceptable)
}

func (h *commonHandler) serveSSE(w http.ResponseWriter, r *http.Request) {
	rc := http.NewResponseController(w)

	w.Header().Set("Content-Type", "text/event-stream")
	w.Header().Set("Cache-Control", "no-cache")
	w.WriteHeader(http.StatusOK)
	rc.Flush()

	ch := make(chan []byte, 10)
	h.mu.Lock()
	if len(h.clients) > h.limit {
		h.mu.Unlock()
		http.Error(w, "too many clients", http.StatusServiceUnavailable)
		return
	}
	h.clients = append(h.clients, ch)
	filter := h.filter
	h.mu.Unlock()
	defer func() {
		h.mu.Lock()
		defer h.mu.Unlock()
		h.clients = slices.DeleteFunc(h.clients, func(c chan []byte) bool { return c == ch })
	}()

	// Override the default strict deadline, but force the client to reconnect
	// occasionally (which is handled by the browser).
	rc.SetWriteDeadline(time.Now().Add(30 * time.Minute))

	for {
		select {
		case b := <-ch:
			if filter != nil {
				b = filter.ReplaceAll(b, []byte("***"))
			}
			// Note that TextHandler promises "a single line" "in a single
			// serialized call to io.Writer.Write" for each Record.
			if _, err := fmt.Fprintf(w, "data: %s\n", b); err != nil {
				return
			}
			rc.Flush()
		case <-r.Context().Done():
			return
		}
	}
}

func (h *commonHandler) serveHTML(w http.ResponseWriter, _ *http.Request) {
	w.Header().Set("Content-Type", "text/html")
	fmt.Fprintf(w, `
		
		litewitness
		
		
		

		`)
}

type multiHandler []slog.Handler

// MultiHandler returns a Handler that handles each record with all the given
// handlers.
func MultiHandler(handlers ...slog.Handler) slog.Handler {
	return multiHandler(handlers)
}

func (h multiHandler) Enabled(ctx context.Context, l slog.Level) bool {
	for i := range h {
		if h[i].Enabled(ctx, l) {
			return true
		}
	}
	return false
}

func (h multiHandler) Handle(ctx context.Context, r slog.Record) error {
	var errs []error
	for i := range h {
		if h[i].Enabled(ctx, r.Level) {
			if err := h[i].Handle(ctx, r.Clone()); err != nil {
				errs = append(errs, err)
			}
		}
	}
	return errors.Join(errs...)
}

func (h multiHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
	handlers := make([]slog.Handler, 0, len(h))
	for i := range h {
		handlers = append(handlers, h[i].WithAttrs(attrs))
	}
	return multiHandler(handlers)
}

func (h multiHandler) WithGroup(name string) slog.Handler {
	handlers := make([]slog.Handler, 0, len(h))
	for i := range h {
		handlers = append(handlers, h[i].WithGroup(name))
	}
	return multiHandler(handlers)
}
torchwood-0.9.0/internal/witness/000077500000000000000000000000001514564101300170405ustar00rootroot00000000000000torchwood-0.9.0/internal/witness/witness.go000066400000000000000000000237701514564101300210740ustar00rootroot00000000000000package witness

import (
	"bytes"
	"context"
	"crypto"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"slices"
	"strconv"
	"strings"
	"sync"

	"filippo.io/torchwood"
	"golang.org/x/mod/sumdb/note"
	"golang.org/x/mod/sumdb/tlog"
	"zombiezen.com/go/sqlite"
	"zombiezen.com/go/sqlite/sqlitex"
)

type Witness struct {
	s   *torchwood.CosignatureSigner
	mux *http.ServeMux
	log *slog.Logger

	dmMu sync.Mutex
	db   *sqlite.Conn

	// testingOnlyStallRequest is called after checking a valid tree head, but
	// before committing it to the database. It's used in tests to cause a race
	// between two requests and simulating the risk of a rollback.
	testingOnlyStallRequest func()
}

func OpenDB(dbPath string) (*sqlite.Conn, error) {
	db, err := sqlite.OpenConn(dbPath, 0)
	if err != nil {
		return nil, fmt.Errorf("opening database: %v", err)
	}

	return db, sqlitex.ExecScript(db, `
		PRAGMA strict_types = ON;
		PRAGMA foreign_keys = ON;
		CREATE TABLE IF NOT EXISTS log (
			origin TEXT PRIMARY KEY,
			tree_size INTEGER NOT NULL,
			tree_hash TEXT NOT NULL -- base64-encoded
		);
		CREATE TABLE IF NOT EXISTS key (
			origin TEXT NOT NULL,
			key TEXT NOT NULL, -- note verifier key
			FOREIGN KEY(origin) REFERENCES log(origin)
		);
		CREATE TABLE IF NOT EXISTS bastion (
			origin TEXT NOT NULL,
			bastion TEXT NOT NULL, -- addr:port
			FOREIGN KEY(origin) REFERENCES log(origin)
		);
	`)
}

func NewWitness(dbPath, name string, key crypto.Signer, log *slog.Logger) (*Witness, error) {
	db, err := OpenDB(dbPath)
	if err != nil {
		return nil, fmt.Errorf("initializing database: %v", err)
	}

	s, err := torchwood.NewCosignatureSigner(name, key)
	if err != nil {
		return nil, fmt.Errorf("preparing signer: %v", err)
	}

	w := &Witness{
		db:  db,
		s:   s,
		log: log,
		mux: http.NewServeMux(),
	}
	w.mux.Handle("POST /add-checkpoint", http.HandlerFunc(w.serveAddCheckpoint))
	return w, nil
}

func (w *Witness) Close() error {
	w.dmMu.Lock()
	defer w.dmMu.Unlock()
	return w.db.Close()
}

func (w *Witness) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
	w.mux.ServeHTTP(rw, r)
}

func (w *Witness) VerifierKey() string {
	return w.s.Verifier().String()
}

func (w *Witness) AllBastions() ([]string, error) {
	var bastions []string
	err := w.dbExec("SELECT DISTINCT bastion FROM bastion",
		func(stmt *sqlite.Stmt) error {
			bastions = append(bastions, stmt.GetText("bastion"))
			return nil
		})
	return bastions, err

}

type conflictError struct {
	known int64
}

func (*conflictError) Error() string { return "known tree size doesn't match provided old size" }

var errUnknownLog = errors.New("unknown log")
var errWrongBastion = errors.New("rejected request, bad/missing bastion")
var errInvalidSignature = errors.New("invalid signature")
var errBadRequest = errors.New("invalid input")
var errProof = errors.New("bad consistency proof")

type viaBastionKey struct{}

func ContextWithBastion(ctx context.Context, bastion string) context.Context {
	return context.WithValue(ctx, viaBastionKey{}, bastion)
}

func ContextBastion(ctx context.Context) string {
	if bastion, ok := ctx.Value(viaBastionKey{}).(string); ok {
		return bastion
	}
	return ""
}

func (w *Witness) serveAddCheckpoint(rw http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(r.Body)
	if err != nil {
		w.log.DebugContext(r.Context(), "error reading request body", "error", err)
		http.Error(rw, err.Error(), http.StatusInternalServerError)
		return
	}

	cosig, err := w.processAddCheckpointRequest(body, ContextBastion(r.Context()))
	if err, ok := err.(*conflictError); ok {
		rw.Header().Set("Content-Type", "text/x.tlog.size")
		rw.WriteHeader(http.StatusConflict)
		fmt.Fprintf(rw, "%d\n", err.known)
		return
	}
	switch err {
	case errUnknownLog:
		http.Error(rw, err.Error(), http.StatusNotFound)
		return
	case errInvalidSignature, errWrongBastion:
		http.Error(rw, err.Error(), http.StatusForbidden)
		return
	case errBadRequest:
		http.Error(rw, err.Error(), http.StatusBadRequest)
		return
	case errProof:
		http.Error(rw, err.Error(), http.StatusUnprocessableEntity)
		return
	}
	if err != nil {
		http.Error(rw, err.Error(), http.StatusInternalServerError)
		return
	}
	if _, err := rw.Write(cosig); err != nil {
		w.log.DebugContext(r.Context(), "error writing response", "error", err)
	}
}

func (w *Witness) processAddCheckpointRequest(body []byte, bastion string) (cosig []byte, err error) {
	l := w.log.With("request", string(body))
	defer func() {
		if err != nil {
			l = l.With("error", err)
		}
		l.Debug("processed add-checkpoint request")
	}()
	body, noteBytes, ok := bytes.Cut(body, []byte("\n\n"))
	if !ok {
		return nil, errBadRequest
	}
	lines := strings.Split(string(body), "\n")
	if len(lines) < 1 {
		return nil, errBadRequest
	}
	size, ok := strings.CutPrefix(lines[0], "old ")
	if !ok {
		return nil, errBadRequest
	}
	oldSize, err := strconv.ParseInt(size, 10, 64)
	if err != nil || oldSize < 0 {
		return nil, errBadRequest
	}
	l = l.With("oldSize", oldSize)
	if len(lines[1:]) > 63 {
		// > The client MUST NOT send more than 63 consistency proof lines.
		return nil, errBadRequest
	}
	proof := make(tlog.TreeProof, len(lines[1:]))
	for i, h := range lines[1:] {
		proof[i], err = tlog.ParseHash(h)
		if err != nil {
			return nil, errBadRequest
		}
	}
	origin, _, _ := strings.Cut(string(noteBytes), "\n")
	l = l.With("origin", origin)
	bastions, err := w.getBastions(origin)
	if err != nil {
		return nil, err
	}
	if bastion != "" && !slices.Contains(bastions, bastion) {
		l.Debug("rejected request from unexpected bastion", "bastion", bastion)
		return nil, errWrongBastion
	}
	verifier, err := w.getKeys(origin)
	if err != nil {
		return nil, err
	}
	n, err := note.Open(noteBytes, verifier)
	switch err.(type) {
	case *note.UnverifiedNoteError, *note.InvalidSignatureError:
		return nil, errInvalidSignature
	}
	if err != nil {
		return nil, err
	}
	c, err := torchwood.ParseCheckpoint(n.Text)
	if err != nil {
		return nil, err
	}
	l = l.With("size", c.N)
	if err := w.checkConsistency(c.Origin, oldSize, c.N, c.Hash, proof); err != nil {
		return nil, err
	}
	if w.testingOnlyStallRequest != nil {
		w.testingOnlyStallRequest()
	}
	if err := w.persistTreeHead(c.Origin, oldSize, c.N, c.Hash); err != nil {
		return nil, err
	}
	signed, err := note.Sign(¬e.Note{Text: n.Text}, w.s)
	if err != nil {
		return nil, err
	}
	sigs, err := splitSignatures(signed)
	if err != nil {
		return nil, err
	}
	return sigs, err
}

func splitSignatures(note []byte) ([]byte, error) {
	var sigSplit = []byte("\n\n")
	split := bytes.LastIndex(note, sigSplit)
	if split < 0 {
		return nil, errors.New("invalid note")
	}
	_, sigs := note[:split+1], note[split+2:]
	if len(sigs) == 0 || sigs[len(sigs)-1] != '\n' {
		return nil, errors.New("invalid note")
	}
	return sigs, nil
}

func (w *Witness) checkConsistency(origin string,
	oldSize, newSize int64, newHash tlog.Hash, proof tlog.TreeProof) error {
	if oldSize > newSize {
		return errBadRequest
	}
	knownSize, oldHash, err := w.getLog(origin)
	if err != nil {
		return err
	}
	if knownSize != oldSize {
		return &conflictError{knownSize}
	}
	if oldSize == 0 {
		// This is the first tree head for this log.
		return nil
	}
	if err := tlog.CheckTree(proof, newSize, newHash, oldSize, oldHash); err != nil {
		return errProof
	}
	return nil
}

func (w *Witness) persistTreeHead(origin string, oldSize, newSize int64, newHash tlog.Hash) error {
	// Check oldSize against the database to prevent rolling back on a race.
	// Alternatively, we could use a database transaction which would be cleaner
	// but would encode a critical security semantic in the implicit use of the
	// correct Conn across functions, which is uncomfortable.
	changes, err := w.dbExecWithChanges(`
			UPDATE log SET tree_size = ?, tree_hash = ?
			WHERE origin = ? AND tree_size = ?`,
		nil, newSize, newHash, origin, oldSize)
	if err == nil && changes != 1 {
		knownSize, _, err := w.getLog(origin)
		if err != nil {
			return err
		}
		return &conflictError{knownSize}
	}
	return err
}

func (w *Witness) getLog(origin string) (treeSize int64, treeHash tlog.Hash, err error) {
	found := false
	err = w.dbExec("SELECT tree_size, tree_hash FROM log WHERE origin = ?",
		func(stmt *sqlite.Stmt) error {
			found = true
			treeSize = stmt.GetInt64("tree_size")
			treeHash, err = tlog.ParseHash(stmt.GetText("tree_hash"))
			return nil
		}, origin)
	if err == nil && !found {
		err = errUnknownLog
	}
	return
}

func (w *Witness) getKeys(origin string) (note.Verifiers, error) {
	var keys []string
	err := w.dbExec("SELECT key FROM key WHERE origin = ?",
		func(stmt *sqlite.Stmt) error {
			keys = append(keys, stmt.GetText("key"))
			return nil
		}, origin)
	if err == nil && keys == nil {
		err = errUnknownLog
	}
	if err != nil {
		return nil, err
	}
	var verifiers []note.Verifier
	for _, k := range keys {
		v, err := note.NewVerifier(k)
		if err != nil {
			w.log.Warn("invalid key in database", "key", k, "error", err)
			return nil, fmt.Errorf("invalid key %q: %v", k, err)
		}
		verifiers = append(verifiers, v)
	}
	return note.VerifierList(verifiers...), nil
}

func (w *Witness) getBastions(origin string) ([]string, error) {
	var bastions []string
	err := w.dbExec("SELECT bastion FROM bastion WHERE origin = ?",
		func(stmt *sqlite.Stmt) error {
			bastions = append(bastions, stmt.GetText("bastion"))
			return nil
		}, origin)
	return bastions, err
}

func (w *Witness) dbExec(query string, resultFn func(stmt *sqlite.Stmt) error, args ...interface{}) error {
	w.dmMu.Lock()
	defer w.dmMu.Unlock()
	err := sqlitexExec(w.db, query, resultFn, args...)
	if err != nil {
		w.log.Error("database error", "error", err)
	}
	return err
}

func (w *Witness) dbExecWithChanges(query string, resultFn func(stmt *sqlite.Stmt) error, args ...interface{}) (int, error) {
	w.dmMu.Lock()
	defer w.dmMu.Unlock()
	err := sqlitexExec(w.db, query, resultFn, args...)
	if err != nil {
		w.log.Error("database error", "error", err)
		return 0, err
	}
	return w.db.Changes(), nil
}

func sqlitexExec(conn *sqlite.Conn, query string, resultFn func(stmt *sqlite.Stmt) error, args ...any) error {
	return sqlitex.Execute(conn, query, &sqlitex.ExecOptions{ResultFunc: resultFn, Args: args})
}
torchwood-0.9.0/internal/witness/witness_test.go000066400000000000000000000145321514564101300221270ustar00rootroot00000000000000package witness

import (
	"bytes"
	"crypto/ed25519"
	"encoding/base64"
	"encoding/hex"
	"fmt"
	"log/slog"
	"path/filepath"
	"sync"
	"testing"

	"golang.org/x/mod/sumdb/note"
	"golang.org/x/mod/sumdb/tlog"
	"sigsum.org/sigsum-go/pkg/merkle"
)

func TestRace(t *testing.T) {
	// gentest seed b4e385f4358f7373cfa9184b176f3cccf808e795baf04092ddfde9461014f0c4
	ss := ed25519.PrivateKey(mustDecodeHex(t,
		"31ffc2116ecbe003acaa800ab70757bd7d53206e3febef6a6d0796d95530b34f"+
			"64848ad8abed6e85981b3b3875b252b8767ebb4b02f703aca3b1e71bbd6a8e50"))
	w, err := NewWitness(":memory:", "example.com", ss, slog.New(testLogHandler(t)))
	fatalIfErr(t, err)
	t.Cleanup(func() { w.Close() })
	pk := mustDecodeHex(t, "ffdc2d4d98e4124d3feaf788c0c2f9abfd796083d1f0495437f302ec79cf100f")
	origin := "sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562"

	treeHash := merkle.HashEmptyTree()
	fatalIfErr(t, sqlitexExec(w.db, "INSERT INTO log (origin, tree_size, tree_hash) VALUES (?, 0, ?)",
		nil, origin, base64.StdEncoding.EncodeToString(treeHash[:])))
	k, err := note.NewEd25519VerifierKey(origin, pk[:])
	fatalIfErr(t, err)
	fatalIfErr(t, sqlitexExec(w.db, "INSERT INTO key (origin, key) VALUES (?, ?)", nil, origin, k))

	_, err = w.processAddCheckpointRequest([]byte(`old 0

sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562
1
KgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=

— sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562 UgIom7fPZTqpxWWhyjWduBvTvGVqsokMbqTArsQilegKoFBJQjUFAmQ0+YeSPM3wfUQMFSzVnnNuWRTYrajXpNUbIQY=
`), "")
	fatalIfErr(t, err)

	// Stall the first request updating to the shorter size between getting
	// consistency checked and being committed to the database.
	var firstHalf, secondHalf, final sync.Mutex
	firstHalf.Lock()
	secondHalf.Lock()
	final.Lock()
	w.testingOnlyStallRequest = func() {
		firstHalf.Unlock()
		secondHalf.Lock()
	}
	go func() {
		cosig, err := w.processAddCheckpointRequest([]byte(`old 1
KgEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
KgIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=

sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562
3
RcCI1Nk56ZcSmIEfIn0SleqtV7uvrlXNccFx595Iwl0=

— sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562 UgIom2VbtIcdFbwFAy1n7s6IkAxIY6J/GQOTuZF2ORV39d75cbAj2aQYwyJre36kezNobZs4SUUdrcawfAB8WVrx6go=
`), "")
		if _, ok := err.(*conflictError); !ok {
			t.Errorf("expected conflict, got %v", err)
		}
		if cosig != nil {
			t.Error("returned a cosignature on conflict")
		}
		final.Unlock()
	}()

	// Wait for testingOnlyStallRequest to fire.
	firstHalf.Lock()

	w.testingOnlyStallRequest = nil
	_, err = w.processAddCheckpointRequest([]byte(`old 1
KgEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
+fUDV+k970B4I3uKrqJM4aP1lloPZP8mvr2Z4wRw2LI=
KgQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=

sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562
5
QrtXrQZCCvpIgsSmOsah7HdICzMLLyDfxToMql9WTjY=

— sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562 UgIomw/EOJmWi0i1FQsOj+etB7F8IccFam/jgd6wzRns4QPVmyEZtdvl1U2KEmLOZ/ASRcWJi0tW90dJWAShei7sDww=
`), "")
	if err != nil {
		t.Errorf("racing request failed: %v", err)
	}

	// Unblock testingOnlyStallRequest and wait for that request to finish.
	secondHalf.Unlock()
	final.Lock()

	size, hash, err := w.getLog("sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562")
	if err != nil {
		t.Fatal(err)
	}
	if size != 5 {
		t.Error("log got rollbacked")
	}
	if hash != mustDecodeHash(t, "42bb57ad06420afa4882c4a63ac6a1ec77480b330b2f20dfc53a0caa5f564e36") {
		t.Error("unexpected tree hash")
	}
}

func testLogHandler(t testing.TB) slog.Handler {
	h := slog.NewTextHandler(writerFunc(func(p []byte) (n int, err error) {
		t.Logf("%s", p)
		return len(p), nil
	}), &slog.HandlerOptions{
		AddSource: true,
		Level:     slog.LevelDebug,
		ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
			if a.Key == slog.SourceKey {
				src := a.Value.Any().(*slog.Source)
				a.Value = slog.StringValue(fmt.Sprintf("%s:%d", filepath.Base(src.File), src.Line))
			}
			return a
		},
	})
	return h
}

type writerFunc func(p []byte) (n int, err error)

func (f writerFunc) Write(p []byte) (n int, err error) {
	return f(p)
}

func mustDecodeHex(t *testing.T, s string) []byte {
	t.Helper()
	b, err := hex.DecodeString(s)
	if err != nil {
		t.Fatal(err)
	}
	return b
}

func mustDecodeHash(t *testing.T, s string) tlog.Hash {
	t.Helper()
	b, err := hex.DecodeString(s)
	if err != nil {
		t.Fatal(err)
	}
	return *(*tlog.Hash)(b)
}

func fatalIfErr(t *testing.T, err error) {
	t.Helper()
	if err != nil {
		t.Fatal(err)
	}
}

func TestTooManyProofs(t *testing.T) {
	// gentest seed b4e385f4358f7373cfa9184b176f3cccf808e795baf04092ddfde9461014f0c4
	ss := ed25519.PrivateKey(mustDecodeHex(t,
		"31ffc2116ecbe003acaa800ab70757bd7d53206e3febef6a6d0796d95530b34f"+
			"64848ad8abed6e85981b3b3875b252b8767ebb4b02f703aca3b1e71bbd6a8e50"))
	w, err := NewWitness(":memory:", "example.com", ss, slog.New(testLogHandler(t)))
	fatalIfErr(t, err)
	t.Cleanup(func() { w.Close() })
	origin := "sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562"

	treeHash := merkle.HashEmptyTree()
	fatalIfErr(t, sqlitexExec(w.db, "INSERT INTO log (origin, tree_size, tree_hash) VALUES (?, 0, ?)",
		nil, origin, base64.StdEncoding.EncodeToString(treeHash[:])))
	pk := mustDecodeHex(t, "ffdc2d4d98e4124d3feaf788c0c2f9abfd796083d1f0495437f302ec79cf100f")
	k, err := note.NewEd25519VerifierKey(origin, pk[:])
	fatalIfErr(t, err)
	fatalIfErr(t, sqlitexExec(w.db, "INSERT INTO key (origin, key) VALUES (?, ?)", nil, origin, k))

	// make checkpoint with > 63 proofs
	buf := []byte("old 0\n")
	proofs := bytes.Repeat([]byte("KgEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n"), 64)
	buf = append(buf, proofs...)
	rest := []byte(`
sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562
1
KgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=

— sigsum.org/v1/tree/4d6d8825a6bb689d459628312889dfbb0bcd41b5211d9e1ce768b0ff0309e562 UgIom7fPZTqpxWWhyjWduBvTvGVqsokMbqTArsQilegKoFBJQjUFAmQ0+YeSPM3wfUQMFSzVnnNuWRTYrajXpNUbIQY=
`)
	buf = append(buf, rest...)
	_, err = w.processAddCheckpointRequest(buf, "")
	if err == nil || err != errBadRequest {
		t.Fatal("checkpoint with too many proofs (>63) should have failed with bad request")
	}
}
torchwood-0.9.0/note.go000066400000000000000000000036111514564101300150250ustar00rootroot00000000000000package torchwood

import (
	"crypto/ed25519"
	"encoding/base64"
	"errors"
	"strconv"
	"strings"

	"golang.org/x/mod/sumdb/note"
)

const algEd25519 = 1

// NewVerifierFromSigner constructs a new c2sp.org/signed-note [note.Verifier]
// from an encoded Ed25519 signer key, the same input as [note.NewSigner].
func NewVerifierFromSigner(skey string) (note.Verifier, error) {
	priv1, skey := chop(skey, "+")
	priv2, skey := chop(skey, "+")
	name, skey := chop(skey, "+")
	hash16, key64 := chop(skey, "+")
	hash, err1 := strconv.ParseUint(hash16, 16, 32)
	key, err2 := base64.StdEncoding.DecodeString(key64)
	if priv1 != "PRIVATE" || priv2 != "KEY" || len(hash16) != 8 || err1 != nil || err2 != nil || !isValidName(name) || len(key) == 0 {
		return nil, errors.New("malformed verifier id")
	}

	alg, key := key[0], key[1:]
	if alg != algEd25519 {
		return nil, errors.New("unknown verifier algorithm")
	}
	if len(key) != 32 {
		return nil, errors.New("malformed verifier id")
	}
	pub := ed25519.NewKeyFromSeed(key).Public().(ed25519.PublicKey)
	if uint32(hash) != keyHash(name, append([]byte{algEd25519}, pub...)) {
		return nil, errors.New("invalid verifier hash")
	}

	return &verifier{
		name: name,
		hash: uint32(hash),
		verify: func(msg, sig []byte) bool {
			return ed25519.Verify(pub, msg, sig)
		},
	}, nil
}

type verifier struct {
	name   string
	hash   uint32
	verify func(msg, sig []byte) bool
}

func (v *verifier) Name() string                { return v.name }
func (v *verifier) KeyHash() uint32             { return v.hash }
func (v *verifier) Verify(msg, sig []byte) bool { return v.verify(msg, sig) }

// chop chops s at the first instance of sep, if any,
// and returns the text before and after sep.
// If sep is not present, chop returns before is s and after is empty.
func chop(s, sep string) (before, after string) {
	i := strings.Index(s, sep)
	if i < 0 {
		return s, ""
	}
	return s[:i], s[i+len(sep):]
}
torchwood-0.9.0/policy.go000066400000000000000000000157661514564101300153750ustar00rootroot00000000000000package torchwood

import (
	"errors"
	"fmt"
	"strconv"
	"strings"

	"golang.org/x/mod/sumdb/note"
)

// Policy encodes the requirements for a set of (co)signatures on a
// c2sp.org/tlog-checkpoint.
//
// The Verifier method allows the policy to be passed in as the known parameter
// to [note.Open], while the Check method must be applied to [note.Note.Sigs]
// and [Checkpoint.Origin] after [note.Open] and [ParseCheckpoint].
type Policy interface {
	// Check returns nil if the provided signatures satisfy the policy.
	//
	// The signatures must already have been verified with their respective
	// verifiers, and would usually be obtained from [note.Note.Sigs].
	Check(origin string, sigs []note.Signature) error

	// Verifier implements [note.Verifiers], returning the Verifier for any of
	// the cosigners in the policy.
	Verifier(name string, hash uint32) (note.Verifier, error)
}

// SingleVerifierPolicy returns a Policy that requires a single verifier to have
// signed the note. The origin is not restricted.
func SingleVerifierPolicy(v note.Verifier) Policy {
	return &singleVerifierPolicy{v: v}
}

type singleVerifierPolicy struct {
	v note.Verifier
}

func (w *singleVerifierPolicy) Check(_ string, sigs []note.Signature) error {
	for _, sig := range sigs {
		if sig.Name == w.v.Name() && sig.Hash == w.v.KeyHash() {
			return nil
		}
	}
	return fmt.Errorf("verifier %q (%08x) did not sign", w.v.Name(), w.v.KeyHash())
}

func (w *singleVerifierPolicy) Verifier(name string, hash uint32) (note.Verifier, error) {
	if name == w.v.Name() && hash == w.v.KeyHash() {
		return w.v, nil
	}
	return nil, ¬e.UnknownVerifierError{Name: name, KeyHash: hash}
}

// ThresholdPolicy returns a Policy that requires at least n of the
// provided policies to be satisfied.
//
// It panics if n is less than zero or greater than the number of polcies.
func ThresholdPolicy(n int, policies ...Policy) Policy {
	if n < 0 || n > len(policies) {
		panic(fmt.Errorf("threshold of %d outside bounds for policies %s", n, policies))
	}
	return &thresholdPolicy{policies: policies, threshold: n}
}

type thresholdPolicy struct {
	policies  []Policy
	threshold int
}

func (w *thresholdPolicy) Check(origin string, sigs []note.Signature) error {
	satisfied := 0
	for _, p := range w.policies {
		if err := p.Check(origin, sigs); err == nil {
			satisfied++
		}
	}
	if satisfied >= w.threshold {
		return nil
	}
	return fmt.Errorf("only %d/%d policy components satisfied", satisfied, w.threshold)
}

func (w *thresholdPolicy) Verifier(name string, hash uint32) (note.Verifier, error) {
	var verifier note.Verifier
	for _, p := range w.policies {
		v, err := p.Verifier(name, hash)
		if _, ok := err.(*note.UnknownVerifierError); ok {
			continue
		}
		if err != nil {
			return nil, err
		}
		if verifier != nil {
			// This, for now, requires not having the same verifier in multiple
			// groups, which matches the Sigsum policy specification. If we
			// change our mind, we will need some way to check the verifiers for
			// equality.
			return nil, fmt.Errorf("multiple verifiers found for %q (%08x)", name, hash)
		}
		verifier = v
	}
	if verifier != nil {
		return verifier, nil
	}
	return nil, ¬e.UnknownVerifierError{Name: name, KeyHash: hash}
}

type originPolicy struct {
	origin string
}

// OriginPolicy returns a Policy that enforces the provided origin string.
//
// This is usually combined with a [SingleVerifierPolicy] for the log's public
// key Verifier, using [ThresholdPolicy] with a threshold of 2-of-2.
func OriginPolicy(origin string) Policy {
	return &originPolicy{origin: origin}
}

func (w *originPolicy) Check(origin string, sigs []note.Signature) error {
	if origin != w.origin {
		return fmt.Errorf("checkpoint origin mismatch: got %q, want %q", origin, w.origin)
	}
	return nil
}

func (w *originPolicy) Verifier(name string, hash uint32) (note.Verifier, error) {
	return nil, ¬e.UnknownVerifierError{Name: name, KeyHash: hash}
}

// ParsePolicy parses a [Policy] from the provided byte slice.
//
// The policy format is EXPERIMENTAL and may change in future releases. It is
// based on [the Sigsum policy format] but it uses vkeys instead of raw public
// keys. It is compatible with Tessera witness policies. It currently requires
// the checkpoint origin to match the log vkey name.
//
// [the Sigsum policy format]: https://git.glasklar.is/sigsum/core/sigsum-go/-/blob/main/doc/policy.md
func ParsePolicy(p []byte) (Policy, error) {
	var quorum string
	var logs []Policy
	policies := make(map[string]Policy)
	for i, line := range strings.Split(string(p), "\n") {
		line, _, _ = strings.Cut(line, "#")
		if strings.Trim(line, " \t") == "" {
			continue
		}
		switch fields := strings.Fields(line); fields[0] {
		case "log":
			if len(fields) < 2 {
				return nil, fmt.Errorf("line %d: invalid log definition: %q", i+1, line)
			}
			v, err := note.NewVerifier(fields[1])
			if err != nil {
				return nil, fmt.Errorf("line %d: invalid log vkey %q: %w", i+1, fields[1], err)
			}
			logs = append(logs, ThresholdPolicy(2, OriginPolicy(v.Name()), SingleVerifierPolicy(v)))
		case "witness":
			if len(fields) < 3 {
				return nil, fmt.Errorf("line %d: invalid witness definition: %q", i+1, line)
			}
			name, vkey := fields[1], fields[2]
			if _, ok := policies[name]; ok {
				return nil, fmt.Errorf("line %d: duplicate component name: %q", i+1, name)
			}
			v, err := NewCosignatureVerifier(vkey)
			if err != nil {
				return nil, fmt.Errorf("line %d: invalid witness vkey %q: %w", i+1, vkey, err)
			}
			policies[name] = SingleVerifierPolicy(v)
		case "group":
			if len(fields) < 4 {
				return nil, fmt.Errorf("line %d: invalid group definition: %q", i+1, line)
			}
			name, nStr, children := fields[1], fields[2], fields[3:]
			if _, ok := policies[name]; ok {
				return nil, fmt.Errorf("line %d: duplicate component name: %q", i+1, name)
			}
			var n int
			switch nStr {
			case "any":
				n = 1
			case "all":
				n = len(children)
			default:
				var err error
				n, err = strconv.Atoi(nStr)
				if err != nil || n < 1 || n > len(children) {
					return nil, fmt.Errorf("line %d: invalid group threshold %q", i+1, nStr)
				}
			}
			c := make([]Policy, 0, len(children))
			for _, cn := range children {
				child, ok := policies[cn]
				if !ok {
					return nil, fmt.Errorf("line %d: unknown component %q in group %q definition", i+1, cn, name)
				}
				c = append(c, child)
			}
			policies[name] = ThresholdPolicy(n, c...)
		case "quorum":
			if len(fields) < 2 {
				return nil, fmt.Errorf("line %d: invalid quorum definition: %q", i+1, line)
			}
			if quorum != "" {
				return nil, fmt.Errorf("line %d: multiple quorum definitions", i+1)
			}
			quorum = fields[1]
		default:
			return nil, fmt.Errorf("line %d: unknown keyword: %q", i+1, fields[0])
		}
	}
	logPolicy := ThresholdPolicy(len(logs), logs...)
	switch quorum {
	case "":
		return nil, errors.New("no quorum defined in policy")
	case "none":
		return logPolicy, nil
	default:
		q, ok := policies[quorum]
		if !ok {
			return nil, fmt.Errorf("quorum %q not defined in policy", quorum)
		}
		return ThresholdPolicy(2, q, logPolicy), nil
	}
}
torchwood-0.9.0/prefix/000077500000000000000000000000001514564101300150255ustar00rootroot00000000000000torchwood-0.9.0/prefix/label.go000066400000000000000000000060211514564101300164320ustar00rootroot00000000000000package prefix

import (
	"bytes"
	"errors"
	"math/bits"
	"strings"
)

type Label struct {
	bitLen uint32
	bytes  [32]byte
}

var RootLabel = Label{}

// EmptyNodeLabel is the label of the sibling of the only child of a root node.
var EmptyNodeLabel = Label{0, [32]byte{
	0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01,
	0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01,
	0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01,
	0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01,
}}

func NewLabel(bitLen uint32, label []byte) (Label, error) {
	if bitLen > 256 {
		return Label{}, errors.New("bit length exceeds maximum of 256 bits")
	}
	if len(label) > 32 {
		return Label{}, errors.New("byte slice exceeds maximum length of 32 bytes")
	}
	if len(label) < int(bitLen+7)/8 {
		return Label{}, errors.New("byte slice is too short for the given bit length")
	}
	if bitLen == 0 && bytes.Equal(label, EmptyNodeLabel.bytes[:]) {
		return EmptyNodeLabel, nil
	}
	for i, b := range label {
		switch {
		case i == int(bitLen)/8 && bitLen%8 != 0:
			b = b << (bitLen % 8)
			fallthrough
		case i > int(bitLen)/8:
			if b != 0 {
				return Label{}, errors.New("non-zero bits in unused part of label")
			}
		}
	}
	var b [32]byte
	copy(b[:], label)
	return Label{bitLen, b}, nil
}

func (l Label) String() string {
	if l == EmptyNodeLabel {
		return ""
	}
	if l == RootLabel {
		return ""
	}
	var s strings.Builder
	for i := range l.bitLen {
		bit, _ := l.Bit(i)
		s.WriteByte('0' + bit)
	}
	return s.String()
}

func (l Label) BitLen() uint32 {
	return l.bitLen
}

func (l Label) IsLeaf() bool {
	return l.bitLen == 256
}

func (l Label) Bytes() []byte {
	return l.bytes[:]
}

func (l Label) Bit(i uint32) (byte, error) {
	if i >= l.bitLen {
		return 0, errors.New("bit index out of range")
	}
	byteIndex := i / 8
	bitIndex := i % 8
	return (l.bytes[byteIndex] >> (7 - bitIndex)) & 1, nil
}

// HasPrefix return whether prefix is equal to or a prefix of l.
func (l Label) HasPrefix(prefix Label) bool {
	if prefix == EmptyNodeLabel {
		return false
	}
	if l.bitLen < prefix.bitLen {
		return false
	}
	bitLen := min(l.bitLen, prefix.bitLen)
	byteLen := bitLen / 8
	if !bytes.Equal(l.bytes[:byteLen], prefix.bytes[:byteLen]) {
		return false
	}
	if rem := bitLen % 8; rem != 0 {
		mask := byte(0xFF << (8 - rem))
		if l.bytes[byteLen]&mask != prefix.bytes[byteLen] {
			return false
		}
	}
	return true
}

func LongestCommonPrefix(l1, l2 Label) Label {
	var bitLen uint32
	var bytes [32]byte
	for i := range l1.bytes {
		n := bits.LeadingZeros8(l1.bytes[i] ^ l2.bytes[i])
		n = min(n, int(l1.bitLen)-8*i, int(l2.bitLen)-8*i)
		mask := byte(0xFF << (8 - n))
		bytes[i] = l1.bytes[i] & mask
		bitLen += uint32(n)
		if n < 8 {
			break
		}
	}
	return Label{bitLen, bytes}
}

type Side int

const (
	Left  Side = 0
	Right Side = 1

	NotAPrefix Side = -1
)

func (l Label) SideOf(prefix Label) Side {
	if !l.HasPrefix(prefix) || l.bitLen == prefix.bitLen {
		return NotAPrefix
	}
	b, err := l.Bit(prefix.bitLen)
	if err != nil {
		panic("mpt: internal error: bit index out of range")
	}
	return Side(b)
}
torchwood-0.9.0/prefix/prefixsqlite/000077500000000000000000000000001514564101300175445ustar00rootroot00000000000000torchwood-0.9.0/prefix/prefixsqlite/create.sql000066400000000000000000000004341514564101300215310ustar00rootroot00000000000000CREATE TABLE IF NOT EXISTS nodes (
    label BLOB NOT NULL,
    label_bit_len INTEGER NOT NULL,
    left_label BLOB,
    left_label_bit_len INTEGER,
    right_label BLOB,
    right_label_bit_len INTEGER,
    hash BLOB NOT NULL,
    PRIMARY KEY (label, label_bit_len)
) WITHOUT ROWID;
torchwood-0.9.0/prefix/prefixsqlite/insert.sql000066400000000000000000000003401514564101300215660ustar00rootroot00000000000000INSERT
    OR REPLACE INTO nodes (
        label,
        label_bit_len,
        left_label,
        left_label_bit_len,
        right_label,
        right_label_bit_len,
        hash
    )
VALUES
    (?, ?, ?, ?, ?, ?, ?);
torchwood-0.9.0/prefix/prefixsqlite/load.sql000066400000000000000000000002761514564101300212110ustar00rootroot00000000000000SELECT
    label,
    label_bit_len,
    left_label,
    left_label_bit_len,
    right_label,
    right_label_bit_len,
    hash
FROM
    nodes
WHERE
    label = ?
    AND label_bit_len = ?;
torchwood-0.9.0/prefix/prefixsqlite/path.sql000066400000000000000000000053551514564101300212310ustar00rootroot00000000000000WITH RECURSIVE path(
    label,
    label_bit_len,
    left_label,
    left_label_bit_len,
    right_label,
    right_label_bit_len,
    hash,
    -- 0: target is on the left, 1: target is on the right, -1: target is not a child
    side,
    -- 0: not on the same side of parent as target, 1: on the same side
    onpath
) AS (
    SELECT
        label,
        label_bit_len,
        left_label,
        left_label_bit_len,
        right_label,
        right_label_bit_len,
        hash,
        sideof(
            :label,
            :label_bit_len,
            label,
            label_bit_len
        ) AS side,
        1 AS onpath
    FROM
        nodes
    WHERE
        label = :root_label
        AND label_bit_len = :root_label_bit_len
    UNION
    ALL
    SELECT
        n.label,
        n.label_bit_len,
        n.left_label,
        n.left_label_bit_len,
        n.right_label,
        n.right_label_bit_len,
        n.hash,
        sideof(
            :label,
            :label_bit_len,
            n.label,
            n.label_bit_len
        ) AS side,
        CASE
            -- as a special case, if the tree is empty and the root has two equal
            -- empty children, we record it as off-path, so it gets returned
            WHEN p.left_label = p.right_label
            AND p.left_label_bit_len = p.right_label_bit_len THEN 0
            WHEN p.side = 0
            AND n.label = p.left_label
            AND n.label_bit_len = p.left_label_bit_len THEN 1
            WHEN p.side = 1
            AND n.label = p.right_label
            AND n.label_bit_len = p.right_label_bit_len THEN 1
            ELSE 0
        END AS onpath
    FROM
        nodes n,
        path p
    WHERE
        (
            -- left child
            (
                n.label = p.left_label
                AND n.label_bit_len = p.left_label_bit_len
            )
            OR -- right child
            (
                n.label = p.right_label
                AND n.label_bit_len = p.right_label_bit_len
            )
        )
        AND -- only continue if the target is a prefix of the target
        p.side != -1
        AND -- stop at leaves
        p.label_bit_len < 256
    ORDER BY
        -- first return the sibling, then follow the path
        onpath ASC
)
SELECT
    label,
    label_bit_len,
    left_label,
    left_label_bit_len,
    right_label,
    right_label_bit_len,
    hash
FROM
    path
WHERE
    -- return the siblings of the path to the target
    onpath = 0
    OR -- and the final node which will become the sibling of the target
    -- unless it's the target or the empty child of the root that will be replaced
    onpath = 1
    AND side = -1
    AND label_bit_len != 0
    AND (
        label != :label
        OR label_bit_len != :label_bit_len
    )
torchwood-0.9.0/prefix/prefixsqlite/sqlite.go000066400000000000000000000107261514564101300214020ustar00rootroot00000000000000package prefixsqlite

import (
	"context"
	"embed"
	"errors"
	"slices"

	"filippo.io/torchwood/prefix"
	"zombiezen.com/go/sqlite"
	"zombiezen.com/go/sqlite/sqlitex"
)

type Storage struct {
	pool *sqlitex.Pool
}

//go:embed *.sql
var sql embed.FS

func NewSQLiteStorage(ctx context.Context, dbPath string) (*Storage, error) {
	pool, err := sqlitex.NewPool(dbPath, sqlitex.PoolOptions{
		PrepareConn: func(conn *sqlite.Conn) error {
			if err := conn.CreateFunction("sideof", &sqlite.FunctionImpl{
				NArgs:         4,
				Deterministic: true,
				Scalar: func(ctx sqlite.Context, args []sqlite.Value) (sqlite.Value, error) {
					if args[0].Type() != sqlite.TypeBlob || args[1].Type() != sqlite.TypeInteger ||
						args[2].Type() != sqlite.TypeBlob || args[3].Type() != sqlite.TypeInteger {
						return sqlite.Value{}, errors.New("invalid argument types for sideof")
					}

					labelBytes, labelBitLen := args[0].Blob(), uint32(args[1].Int64())
					prefixBytes, prefixBitLen := args[2].Blob(), uint32(args[3].Int64())

					label, err := prefix.NewLabel(labelBitLen, labelBytes)
					if err != nil {
						return sqlite.Value{}, err
					}
					prefix, err := prefix.NewLabel(prefixBitLen, prefixBytes)
					if err != nil {
						return sqlite.Value{}, err
					}

					return sqlite.IntegerValue(int64(label.SideOf(prefix))), nil
				},
			}); err != nil {
				return err
			}
			return sqlitex.ExecScript(conn, `
				PRAGMA strict_types = ON;
				PRAGMA foreign_keys = ON;
			`)
		},
	})
	if err != nil {
		return nil, err
	}

	conn, err := pool.Take(ctx)
	if err != nil {
		pool.Close()
		return nil, err
	}
	defer pool.Put(conn)

	if err := sqlitex.ExecuteTransientFS(conn, sql, "create.sql", nil); err != nil {
		pool.Close()
		return nil, err
	}

	return &Storage{pool: pool}, nil
}

func (s *Storage) Close() error {
	return s.pool.Close()
}

var _ prefix.Storage = (*Storage)(nil)

func (s *Storage) Load(ctx context.Context, label prefix.Label) (*prefix.Node, error) {
	conn, err := s.pool.Take(ctx)
	if err != nil {
		return nil, err
	}
	defer s.pool.Put(conn)

	var node *prefix.Node
	if err := sqlitex.ExecuteFS(conn, sql, "load.sql", &sqlitex.ExecOptions{
		Args: []any{
			label.Bytes(),
			label.BitLen(),
		},
		ResultFunc: func(stmt *sqlite.Stmt) error {
			var err error
			node, err = nodeFromRow(stmt)
			return err
		},
	}); err != nil {
		return nil, err
	}

	if node == nil {
		return nil, prefix.ErrNodeNotFound
	}
	return node, nil
}

func (s *Storage) LoadPath(ctx context.Context, label prefix.Label) ([]*prefix.Node, error) {
	conn, err := s.pool.Take(ctx)
	if err != nil {
		return nil, err
	}
	defer s.pool.Put(conn)

	var nodes []*prefix.Node
	if err := sqlitex.ExecuteFS(conn, sql, "path.sql", &sqlitex.ExecOptions{
		Named: map[string]any{
			":root_label":         prefix.RootLabel.Bytes(),
			":root_label_bit_len": prefix.RootLabel.BitLen(),
			":label":              label.Bytes(),
			":label_bit_len":      label.BitLen(),
		},
		ResultFunc: func(stmt *sqlite.Stmt) error {
			node, err := nodeFromRow(stmt)
			if err != nil {
				return err
			}
			nodes = append(nodes, node)
			return nil
		},
	}); err != nil {
		return nil, err
	}
	slices.Reverse(nodes)

	return nodes, nil
}

func nodeFromRow(stmt *sqlite.Stmt) (*prefix.Node, error) {
	labelBytes := make([]byte, 32)
	stmt.ColumnBytes(0, labelBytes)
	labelBitLen := stmt.ColumnInt64(1)
	label, err := prefix.NewLabel(uint32(labelBitLen), labelBytes)
	if err != nil {
		return nil, err
	}

	leftBytes := make([]byte, 32)
	stmt.ColumnBytes(2, leftBytes)
	leftBitLen := stmt.ColumnInt64(3)
	left, err := prefix.NewLabel(uint32(leftBitLen), leftBytes)
	if err != nil {
		return nil, err
	}

	rightBytes := make([]byte, 32)
	stmt.ColumnBytes(4, rightBytes)
	rightBitLen := stmt.ColumnInt64(5)
	right, err := prefix.NewLabel(uint32(rightBitLen), rightBytes)
	if err != nil {
		return nil, err
	}

	hashBytes := make([]byte, 32)
	stmt.ColumnBytes(6, hashBytes)

	return &prefix.Node{
		Label: label,
		Left:  left,
		Right: right,
		Hash:  [32]byte(hashBytes),
	}, nil
}

func (s *Storage) Store(ctx context.Context, nodes ...*prefix.Node) error {
	conn, err := s.pool.Take(ctx)
	if err != nil {
		return err
	}
	defer s.pool.Put(conn)

	for _, node := range nodes {
		if err := sqlitex.ExecuteFS(conn, sql, "insert.sql", &sqlitex.ExecOptions{
			Args: []any{
				node.Label.Bytes(), node.Label.BitLen(),
				node.Left.Bytes(), node.Left.BitLen(),
				node.Right.Bytes(), node.Right.BitLen(),
				node.Hash[:],
			},
		}); err != nil {
			return err
		}
	}

	return nil
}
torchwood-0.9.0/prefix/storage.go000066400000000000000000000015701514564101300170230ustar00rootroot00000000000000package prefix

import (
	"context"
	"errors"
)

type Storage interface {
	// Load retrieves the node with the given label.
	Load(ctx context.Context, label Label) (*Node, error)

	// Store stores the given nodes. If a node with the same label already
	// exists, it is replaced. The nodes can be in any order.
	Store(ctx context.Context, nodes ...*Node) error
}

type memoryStorage struct {
	nodes map[Label]*Node
}

func NewMemoryStorage() Storage {
	return &memoryStorage{
		nodes: make(map[Label]*Node),
	}
}

var ErrNodeNotFound = errors.New("node not found")

func (s *memoryStorage) Load(ctx context.Context, label Label) (*Node, error) {
	if node, ok := s.nodes[label]; ok {
		return node, nil
	}
	return nil, ErrNodeNotFound
}

func (s *memoryStorage) Store(ctx context.Context, nodes ...*Node) error {
	for _, node := range nodes {
		s.nodes[node.Label] = node
	}
	return nil
}
torchwood-0.9.0/prefix/tree.go000066400000000000000000000225561514564101300163250ustar00rootroot00000000000000// Package prefix implements a compressed binary Merkle trie, or prefix tree: an
// append-only compressed key-value accumulator based on a sparse binary Merkle
// tree. Keys and values are arbitrary 32-byte strings.
//
// This data structure is sometimes improperly called a "Merkle Patricia Trie",
// despite not implementing the PATRICIA optimization, which elides the actual
// key bits from the intermediate nodes.
//
// It is compatible with the whatsapp_v1 configuration of the akd library, with
// NodeHashingMode::NoLeafEpoch.
//
// This package is NOT STABLE, regardless of the module version, and the API may
// change without notice.
package prefix

import (
	"context"
	"encoding/binary"
	"errors"
	"fmt"
	"slices"
	"strings"
)

type HashFunc func([]byte) [32]byte

type Node struct {
	Label Label
	// If the node is the root, Left and/or Right may be EmptyNodeLabel.
	// If the node is a leaf or empty, Left and Right are undefined.
	Left, Right Label
	// Hash is Hash(value || Hash(Label.Bytes())) where value is
	//   - the entry value for leaf nodes,
	//   - the hash of the children for internal nodes, or
	//   - EmptyValue for empty nodes.
	Hash [32]byte
}

func (n *Node) String() string {
	var s strings.Builder
	fmt.Fprintf(&s, "Node{%s", n.Label)
	if !n.Label.IsLeaf() && n.Label != EmptyNodeLabel {
		fmt.Fprintf(&s, " l:%s r:%s", n.Left, n.Right)
	}
	fmt.Fprintf(&s, " h:%x}", n.Hash)
	return s.String()
}

func nodeHash(h HashFunc, label Label, value [32]byte) [32]byte {
	l := make([]byte, 0, 4+32)
	l = binary.BigEndian.AppendUint32(l, label.bitLen)
	l = append(l, label.bytes[:]...)
	labelHash := h(l)
	return h(append(value[:], labelHash[:]...))
}

func internalNodeValue(h HashFunc, left, right *Node) [32]byte {
	return h(append(left.Hash[:], right.Hash[:]...))
}

func newRootNode(h HashFunc) *Node {
	return &Node{
		Label: RootLabel,
		Hash:  nodeHash(h, RootLabel, h([]byte{0x00})),
		Left:  EmptyNodeLabel,
		Right: EmptyNodeLabel,
	}
}

func newEmptyNode(h HashFunc) *Node {
	// It's unclear if the nested nodeHash is intentional. If it's not, it might
	// be because the akd_core Configuration method that returns the empty root
	// value is called empty_root_value, while the one that returns the empty
	// sibling value is called empty_node_hash despite both returning values.
	//
	// Anyway, empty_root_value returns H(0x00) while empty_node_hash returns
	// H(EmptyNodeLabel || H(0x00)).
	//
	// This is harmless, so we match it to interoperate with akd.
	hash := nodeHash(h, EmptyNodeLabel, nodeHash(h, EmptyNodeLabel, h([]byte{0x00})))
	return &Node{Label: EmptyNodeLabel, Hash: hash}
}

func newLeaf(h HashFunc, label, value [32]byte) *Node {
	l := Label{256, label}
	return &Node{Label: l, Hash: nodeHash(h, l, value)}
}

// newParentNode returns a new internal (or root) node with the provided
// children, of which at most one may be an empty node.
func newParentNode(h HashFunc, a, b *Node) (*Node, error) {
	label := LongestCommonPrefix(b.Label, a.Label)
	if label.BitLen() == 256 {
		return nil, errors.New("nodes are equal")
	}
	if a.Label == EmptyNodeLabel {
		a, b = b, a
	}
	if a.Label == EmptyNodeLabel {
		return nil, errors.New("both nodes are empty")
	}
	parent := &Node{Label: label}
	switch a.Label.SideOf(label) {
	case Left:
		parent.Left = a.Label
		parent.Right = b.Label
		parent.Hash = nodeHash(h, label, internalNodeValue(h, a, b))
	case Right:
		parent.Left = b.Label
		parent.Right = a.Label
		parent.Hash = nodeHash(h, label, internalNodeValue(h, b, a))
	default:
		return nil, errors.New("internal error: non-empty node is not on either side of prefix")
	}
	return parent, nil
}

type Tree struct {
	s Storage
	h HashFunc
}

func NewTree(h HashFunc, s Storage) *Tree {
	return &Tree{h: h, s: s}
}

func InitStorage(ctx context.Context, h HashFunc, s Storage) error {
	return s.Store(ctx, newEmptyNode(h), newRootNode(h))
}

func (t *Tree) RootHash(ctx context.Context) ([32]byte, error) {
	root, err := t.s.Load(ctx, RootLabel)
	if err != nil {
		return [32]byte{}, fmt.Errorf("failed to load root: %w", err)
	}
	return root.Hash, nil
}

type ProofNode struct {
	Label Label
	Hash  [32]byte
}

// Lookup returns whether the given label is present in the tree, and a
// membership or non-membership proof for it.
//
// # Membership proofs
//
// A membership proof is a sequence of sibling nodes from the leaf's sibling to
// the root's child.
//
// Compared to akd_core's Vec, this omits the parent label and
// direction of each sibling, as they can be derived from the label and the tree
// structure.
//
// # Non-membership proofs
//
// A non-membership proof is a sequence of sibling nodes from what would be the
// leaf's sibling (if it was inserted) to the root's child. If the tree is empty,
// a non-membership proof is a single empty node.
//
// When verifying it, check that the first two entries are not prefixes of the
// non-included label, but their parent is, proving the label is not present.
//
// An alternative way to think about it (matching akd_core's NonMembershipProof
// is that the first two entries are the longest prefix's children, the longest
// prefix itself is omitted as it can be derived from its children, and the rest
// of the entries are the membership proof for the longest prefix.
func (t *Tree) Lookup(ctx context.Context, label [32]byte) (bool, []ProofNode, error) {
	found := true
	l := Label{256, label}
	// TODO(filippo): we can optimize this by making loadPath return presence.
	if _, err := t.s.Load(ctx, l); err == ErrNodeNotFound {
		found = false
	} else if err != nil {
		return false, nil, fmt.Errorf("failed to load node %s: %w", l, err)
	}
	path, err := loadPath(ctx, t.s, l)
	if err != nil {
		return false, nil, fmt.Errorf("failed to load path for node %s: %w", l, err)
	}
	proof := make([]ProofNode, 0, len(path))
	for _, sibling := range path {
		proof = append(proof, ProofNode{
			Label: sibling.Label,
			Hash:  sibling.Hash,
		})
	}
	return found, proof, nil
}

// loadPath loads the siblings of the path to reach the given node
// (intuitively, the inclusion proof). If the node is not present, the
// sequence stops with what would be its sibling if it were present. The
// returned nodes are ordered from the node sibling up to the root's child.
func loadPath(ctx context.Context, s Storage, label Label) ([]*Node, error) {
	// If the Storage has a custom implementation of LoadPath, use it.
	if s, ok := s.(interface {
		LoadPath(context.Context, Label) ([]*Node, error)
	}); ok {
		return s.LoadPath(ctx, label)
	}
	var nodes []*Node
	node, err := s.Load(ctx, RootLabel)
	if err != nil {
		return nil, fmt.Errorf("failed to load root: %w", err)
	}
	for node.Label != label {
		if !label.HasPrefix(node.Label) {
			if node.Label != EmptyNodeLabel {
				nodes = append(nodes, node)
			}
			break
		}
		left, err := s.Load(ctx, node.Left)
		if err != nil {
			return nil, fmt.Errorf("failed to load left node %s: %w", node.Left, err)
		}
		right, err := s.Load(ctx, node.Right)
		if err != nil {
			return nil, fmt.Errorf("failed to load left node %s: %w", node.Right, err)
		}
		switch label.SideOf(node.Label) {
		case Left:
			nodes = append(nodes, right)
			node = left
		case Right:
			nodes = append(nodes, left)
			node = right
		}
	}
	slices.Reverse(nodes)
	return nodes, nil
}

func (t *Tree) Insert(ctx context.Context, label, value [32]byte) error {
	leaf := newLeaf(t.h, label, value)

	path, err := loadPath(ctx, t.s, leaf.Label)
	if err != nil {
		return err
	}

	node := leaf
	var changed []*Node
	changed = append(changed, node)
	for _, sibling := range path {
		node, err = newParentNode(t.h, sibling, node)
		if err != nil {
			return err
		}
		changed = append(changed, node)
	}

	return t.s.Store(ctx, changed...)
}

func VerifyMembershipProof(h HashFunc, label, value [32]byte, proof []ProofNode, root [32]byte) error {
	node := newLeaf(h, label, value)
	return verifyInclusion(h, node, proof, root)
}

func verifyInclusion(h HashFunc, node *Node, proof []ProofNode, root [32]byte) error {
	for _, sibling := range proof {
		var err error
		node, err = newParentNode(h, node, &Node{
			Label: sibling.Label,
			Hash:  sibling.Hash,
		})
		if err != nil {
			return fmt.Errorf("failed to compute parent node: %w", err)
		}
	}
	if node.Label != RootLabel {
		return fmt.Errorf("proof does not lead to root, got %s", node.Label)
	}
	if node.Hash != root {
		return fmt.Errorf("proof does not match root hash, got %x, want %x", node.Hash, root)
	}
	return nil
}

func VerifyNonMembershipProof(h HashFunc, label [32]byte, proof []ProofNode, root [32]byte) error {
	if len(proof) == 1 {
		empty, r := newEmptyNode(h), newRootNode(h)
		if proof[0].Label == empty.Label && proof[0].Hash == empty.Hash && r.Hash == root {
			return nil // Empty tree, non-membership is trivially proven.
		}
	}
	if len(proof) < 2 {
		return errors.New("non-membership proof must have at least two entries")
	}
	l := Label{256, label}
	if l.HasPrefix(proof[0].Label) || l.HasPrefix(proof[1].Label) {
		return fmt.Errorf("non-membership is not proven: %s is a prefix of %s or %s", l, proof[0].Label, proof[1].Label)
	}
	parent, err := newParentNode(h, &Node{
		Label: proof[0].Label,
		Hash:  proof[0].Hash,
	}, &Node{
		Label: proof[1].Label,
		Hash:  proof[1].Hash,
	})
	if err != nil {
		return fmt.Errorf("failed to compute parent node: %w", err)
	}
	if !l.HasPrefix(parent.Label) {
		return fmt.Errorf("non-membership is not proven: %s is not a prefix of %s", l, parent.Label)
	}
	return verifyInclusion(h, parent, proof[2:], root)
}
torchwood-0.9.0/prefix/tree_test.go000066400000000000000000000137401514564101300173570ustar00rootroot00000000000000//go:build go1.24

package prefix_test

import (
	"encoding/binary"
	"encoding/hex"
	"math/rand/v2"
	"runtime"
	"testing"

	"lukechampine.com/blake3"

	. "filippo.io/torchwood/prefix"
	"filippo.io/torchwood/prefix/prefixsqlite"
)

func testAllStorage(t *testing.T, f func(t *testing.T, newStorage func(t *testing.T) Storage)) {
	t.Run("memory", func(t *testing.T) {
		f(t, func(t *testing.T) Storage {
			return NewMemoryStorage()
		})
	})

	t.Run("sqlite", func(t *testing.T) {
		f(t, func(t *testing.T) Storage {
			store, err := prefixsqlite.NewSQLiteStorage(t.Context(), "file::memory:?cache=shared")
			if err != nil {
				t.Fatal(err)
			}
			t.Cleanup(func() { fatalIfErr(t, store.Close()) })
			return store
		})
	})
}

func TestFullTree(t *testing.T) {
	testAllStorage(t, testFullTree)
}
func testFullTree(t *testing.T, newStorage func(t *testing.T) Storage) {
	store := newStorage(t)
	fatalIfErr(t, InitStorage(t.Context(), blake3.Sum256, store))
	tree := NewTree(blake3.Sum256, store)

	for n := range 1000 {
		var label [32]byte
		binary.LittleEndian.PutUint16(label[:], uint16(n))
		value := blake3.Sum256(label[:])
		fatalIfErr(t, tree.Insert(t.Context(), label, value))
	}

	rootHash, err := tree.RootHash(t.Context())
	fatalIfErr(t, err)

	store = newStorage(t)
	fatalIfErr(t, InitStorage(t.Context(), blake3.Sum256, store))
	tree = NewTree(blake3.Sum256, store)

	for n := 999; n >= 0; n-- {
		var label [32]byte
		binary.LittleEndian.PutUint16(label[:], uint16(n))
		value := blake3.Sum256(label[:])
		fatalIfErr(t, tree.Insert(t.Context(), label, value))
	}

	rootHash1, err := tree.RootHash(t.Context())
	fatalIfErr(t, err)
	if rootHash1 != rootHash {
		t.Fatalf("after inserting in reverse order: got %x, want %x", rootHash1, rootHash)
	}

	store = newStorage(t)
	fatalIfErr(t, InitStorage(t.Context(), blake3.Sum256, store))
	tree = NewTree(blake3.Sum256, store)

	for _, n := range rand.Perm(1000) {
		var label [32]byte
		binary.LittleEndian.PutUint16(label[:], uint16(n))
		value := blake3.Sum256(label[:])
		fatalIfErr(t, tree.Insert(t.Context(), label, value))
	}

	rootHash1, err = tree.RootHash(t.Context())
	fatalIfErr(t, err)
	if rootHash1 != rootHash {
		t.Fatalf("after inserting in random order: got %x, want %x", rootHash1, rootHash)
	}
}

func TestAccumulated(t *testing.T) {
	testAllStorage(t, testAccumulated)
}
func testAccumulated(t *testing.T, newStorage func(t *testing.T) Storage) {
	if _, ok := newStorage(t).(*prefixsqlite.Storage); ok && testing.Short() {
		t.Skip("skipping accumulated test for sqlite storage in short mode")
	}

	source := blake3.New(0, nil).XOF()
	sink := blake3.New(32, nil)

	for range 100 {
		store := newStorage(t)
		fatalIfErr(t, InitStorage(t.Context(), blake3.Sum256, store))
		tree := NewTree(blake3.Sum256, store)
		rootHash, err := tree.RootHash(t.Context())
		fatalIfErr(t, err)
		sink.Write(rootHash[:])
		for range 1000 {
			var label, value [32]byte
			source.Read(label[:])
			source.Read(value[:])
			fatalIfErr(t, tree.Insert(t.Context(), label, value))
			rootHash, err := tree.RootHash(t.Context())
			fatalIfErr(t, err)
			sink.Write(rootHash[:])
		}
	}

	exp := "dfa5cc5758518f612c53d3434688996895373f29e2df61b7f7c26f0e25b095eb"
	result := sink.Sum(nil)
	if hex.EncodeToString(result) != exp {
		t.Fatalf("expected hash %s, got %x", exp, result)
	}
}

func TestMemoryUsage(t *testing.T) {
	if testing.Short() {
		t.Skip("skipping memory usage test in short mode")
	}

	store := NewMemoryStorage()
	fatalIfErr(t, InitStorage(t.Context(), blake3.Sum256, store))
	tree := NewTree(blake3.Sum256, store)

	runtime.GC()
	var start runtime.MemStats
	runtime.ReadMemStats(&start)

	source := blake3.New(0, nil).XOF()
	for n := range 1000000 {
		var label, value [32]byte
		source.Read(label[:])
		source.Read(value[:])
		fatalIfErr(t, tree.Insert(t.Context(), label, value))

		switch n + 1 {
		case 1000, 10000, 100000, 1000000:
			runtime.GC()
			var m runtime.MemStats
			runtime.ReadMemStats(&m)
			t.Logf("Memory usage after inserting % 8d nodes: % 10d bytes", n+1, int64(m.Alloc)-int64(start.Alloc))
		}
	}
}

func TestMembershipProof(t *testing.T) {
	testAllStorage(t, testMembershipProof)
}
func testMembershipProof(t *testing.T, newStorage func(t *testing.T) Storage) {
	store := newStorage(t)
	fatalIfErr(t, InitStorage(t.Context(), blake3.Sum256, store))
	tree := NewTree(blake3.Sum256, store)

	inserted := make(map[[32]byte]bool)
	check := func(i int) {
		rootHash, err := tree.RootHash(t.Context())
		fatalIfErr(t, err)

		for n := range 100 {
			var label [32]byte
			binary.LittleEndian.PutUint16(label[:], uint16(n))
			value := blake3.Sum256(label[:])

			present, proof, err := tree.Lookup(t.Context(), label)
			fatalIfErr(t, err)
			if inserted[label] {
				if !present {
					t.Fatalf("label %x not found in tree after insertion", label)
				}
				if err := VerifyMembershipProof(blake3.Sum256, label, value, proof, rootHash); err != nil {
					t.Fatalf("membership proof for %x with %d entries failed: %v", label, i, err)
				}
				if err := VerifyNonMembershipProof(blake3.Sum256, label, proof, rootHash); err == nil {
					t.Fatalf("non-membership proof for %x with %d entries should have failed", label, i)
				}
			} else {
				if present {
					t.Fatalf("label %x found in tree after insertion, but it should not be", label)
				}
				if err := VerifyNonMembershipProof(blake3.Sum256, label, proof, rootHash); err != nil {
					t.Fatalf("non-membership proof for %x with %d entries failed: %v", label, i, err)
				}
				if err := VerifyMembershipProof(blake3.Sum256, label, value, proof, rootHash); err == nil {
					t.Fatalf("membership proof for %x with %d entries should have failed", label, i)
				}
			}
		}
	}

	// Run the check on the emtpy tree.
	check(0)

	for i, n := range rand.Perm(100) {
		var label [32]byte
		binary.LittleEndian.PutUint16(label[:], uint16(n))
		value := blake3.Sum256(label[:])
		fatalIfErr(t, tree.Insert(t.Context(), label, value))
		inserted[label] = true
		check(i + 1)
	}
}

func fatalIfErr(t *testing.T, err error) {
	if err != nil {
		t.Helper()
		t.Fatal(err)
	}
}
torchwood-0.9.0/spicy.go000066400000000000000000000115731514564101300152150ustar00rootroot00000000000000package torchwood

import (
	"bytes"
	"encoding/base64"
	"errors"
	"fmt"
	"strconv"
	"strings"

	"golang.org/x/mod/sumdb/tlog"
)

// FormatProof formats a tlog record inclusion proof (a "spicy signature") for
// the record at index idx with proof p and signed checkpoint signedCheckpoint.
//
// The returned byte slice is encoded according to c2sp.org/tlog-proof@v1.
func FormatProof(idx int64, p tlog.RecordProof, signedCheckpoint []byte) []byte {
	return formatProof(idx, p, signedCheckpoint, nil, false)
}

// FormatProofWithExtraData formats a tlog record inclusion proof (a "spicy
// signature") for the record at index idx with proof p and signed checkpoint
// signedCheckpoint, including extra data.
//
// The returned byte slice is encoded according to c2sp.org/tlog-proof@v1.
func FormatProofWithExtraData(idx int64, extra []byte, p tlog.RecordProof, signedCheckpoint []byte) []byte {
	return formatProof(idx, p, signedCheckpoint, extra, true)
}

func formatProof(idx int64, p tlog.RecordProof, signedCheckpoint []byte, extra []byte, withExtra bool) []byte {
	var buf bytes.Buffer
	fmt.Fprintf(&buf, "c2sp.org/tlog-proof@v1\n")
	if withExtra {
		fmt.Fprintf(&buf, "extra %s\n", base64.StdEncoding.EncodeToString([]byte(extra)))
	}
	fmt.Fprintf(&buf, "index %d\n", idx)
	for _, h := range p {
		fmt.Fprintf(&buf, "%s\n", h)
	}
	fmt.Fprintf(&buf, "\n")
	buf.Write(signedCheckpoint)
	return buf.Bytes()
}

// VerifyRecordError is returned by [VerifyProof] when the inclusion proof does
// not verify. It can be used to diagnose the issue or print a better error
// message. All of its fields are unauthenticated and must not be trusted.
type VerifyRecordError struct {
	Index int64
	Extra []byte
}

func (e *VerifyRecordError) Error() string {
	return fmt.Sprintf("tlog record inclusion proof verification failed for index %d", e.Index)
}

// VerifyProof verifies a proof (a "spicy signature" encoded according to
// c2sp.org/tlog-proof@v1) for a record hash rh (generally produced with
// [tlog.RecordHash]).
//
// If the note signatures do not satisfy the provided policy, an error wrapping
// *[note.UnverifiedNoteError] is returned. If the proof is valid but does not
// verify the record hash rh at the given index, a *[VerifyRecordError] is
// returned.
func VerifyProof(policy Policy, rh tlog.Hash, proof []byte) error {
	hdr, rest, ok := strings.Cut(string(proof), "\n")
	if !ok || hdr != "c2sp.org/tlog-proof@v1" {
		return errors.New("malformed tlog proof: missing header, this may not be a tlog proof")
	}
	var extra []byte
	if rest, ok = strings.CutPrefix(rest, "extra "); ok {
		var s string
		s, rest, ok = strings.Cut(rest, "\n")
		if !ok {
			return errors.New("malformed tlog proof: unexpected end of extra line")
		}
		var err error
		extra, err = base64.StdEncoding.DecodeString(s)
		if err != nil {
			return fmt.Errorf("malformed tlog proof: invalid extra: %w", err)
		}
	}
	rest, ok = strings.CutPrefix(rest, "index ")
	if !ok {
		return errors.New("malformed tlog proof: expected index line")
	}
	s, rest, ok := strings.Cut(rest, "\n")
	if !ok {
		return errors.New("malformed tlog proof: unexpected end of index line")
	}
	idx, err := strconv.ParseInt(s, 10, 64)
	if err != nil {
		return fmt.Errorf("malformed tlog proof: invalid index: %w", err)
	}
	if idx < 0 {
		return fmt.Errorf("malformed tlog proof: negative index")
	}
	var p tlog.RecordProof
	for {
		var h64 string
		h64, rest, ok = strings.Cut(rest, "\n")
		if !ok {
			return errors.New("malformed tlog proof: unexpected end of proof lines")
		}
		if h64 == "" {
			break
		}
		h, err := base64.StdEncoding.DecodeString(h64)
		if err != nil {
			return fmt.Errorf("malformed tlog proof: invalid hash: %w", err)
		}
		if len(h) != tlog.HashSize {
			return fmt.Errorf("malformed tlog proof: invalid hash length: got %d, want 32", len(h))
		}
		p = append(p, tlog.Hash(h))
	}
	c, _, err := VerifyCheckpoint([]byte(rest), policy)
	if err != nil {
		return err
	}
	if err := tlog.CheckRecord(p, c.N, c.Hash, idx, rh); err != nil {
		return &VerifyRecordError{
			Index: idx,
			Extra: extra,
		}
	}
	return nil
}

// ProofExtraData extracts the extra data from a tlog proof encoded according to
// c2sp.org/tlog-proof@v1. If no extra data is present, it returns an error.
//
// The extra data is unauthenticated and must not be trusted.
func ProofExtraData(proof []byte) ([]byte, error) {
	hdr, rest, ok := strings.Cut(string(proof), "\n")
	if !ok || hdr != "c2sp.org/tlog-proof@v1" {
		return nil, errors.New("malformed tlog proof: missing header, this may not be a tlog proof")
	}
	line, _, ok := strings.Cut(rest, "\n")
	if !ok {
		return nil, errors.New("malformed tlog proof: unexpected end of proof")
	}
	s, ok := strings.CutPrefix(line, "extra ")
	if !ok {
		return nil, errors.New("tlog proof does not contain extra data")
	}
	extra, err := base64.StdEncoding.DecodeString(s)
	if err != nil {
		return nil, fmt.Errorf("malformed tlog proof: invalid extra: %w", err)
	}
	return extra, nil
}
torchwood-0.9.0/spicy_test.go000066400000000000000000000522661514564101300162600ustar00rootroot00000000000000package torchwood_test

import (
	"bytes"
	"crypto/rand"
	"encoding/base64"
	"errors"
	"fmt"
	"strings"
	"testing"

	"filippo.io/torchwood"
	"golang.org/x/mod/sumdb/note"
	"golang.org/x/mod/sumdb/tlog"
)

// setupTestLog creates a test log with a signer/verifier and returns them
// along with a signed checkpoint for a log with the given size and tree hash.
func setupTestLog(t *testing.T, origin string, size int64, hash tlog.Hash) (note.Signer, note.Verifier, []byte) {
	t.Helper()

	// Generate a test key using note.GenerateKey
	skey, vkey, err := note.GenerateKey(rand.Reader, origin)
	if err != nil {
		t.Fatalf("failed to generate key: %v", err)
	}

	signer, err := note.NewSigner(skey)
	if err != nil {
		t.Fatalf("failed to create signer: %v", err)
	}

	verifier, err := note.NewVerifier(vkey)
	if err != nil {
		t.Fatalf("failed to create verifier: %v", err)
	}

	checkpoint := torchwood.Checkpoint{
		Origin: origin,
		Tree:   tlog.Tree{N: size, Hash: hash},
	}

	signedCheckpoint, err := note.Sign(¬e.Note{Text: checkpoint.String()}, signer)
	if err != nil {
		t.Fatalf("failed to sign checkpoint: %v", err)
	}

	return signer, verifier, signedCheckpoint
}

func TestFormatProof(t *testing.T) {
	origin := "example.com/test"
	hash := tlog.RecordHash([]byte("test data"))

	// Create a simple proof
	proof := tlog.RecordProof{
		tlog.RecordHash([]byte("hash1")),
		tlog.RecordHash([]byte("hash2")),
	}

	_, _, signedCheckpoint := setupTestLog(t, origin, 10, hash)

	formatted := torchwood.FormatProof(5, proof, signedCheckpoint)

	// Check that it starts with the header
	if !bytes.HasPrefix(formatted, []byte("c2sp.org/tlog-proof@v1\n")) {
		t.Errorf("formatted proof missing header")
	}

	// Check that it contains the index
	if !bytes.Contains(formatted, []byte("index 5\n")) {
		t.Errorf("formatted proof missing index")
	}

	// Check that it does NOT contain a extra line
	if bytes.Contains(formatted, []byte("extra ")) {
		t.Errorf("formatted proof should not contain extra")
	}

	// Check that it contains the proof hashes
	for _, h := range proof {
		if !bytes.Contains(formatted, []byte(base64.StdEncoding.EncodeToString(h[:]))) {
			t.Errorf("formatted proof missing hash %x", h)
		}
	}

	// Check that it contains the checkpoint
	if !bytes.Contains(formatted, signedCheckpoint) {
		t.Errorf("formatted proof missing signed checkpoint")
	}
}

func TestFormatProofWithExtra(t *testing.T) {
	origin := "example.com/test"
	hash := tlog.RecordHash([]byte("test data"))
	extra := []byte("test-extra-data")

	proof := tlog.RecordProof{
		tlog.RecordHash([]byte("hash1")),
	}

	_, _, signedCheckpoint := setupTestLog(t, origin, 10, hash)

	formatted := torchwood.FormatProofWithExtraData(3, extra, proof, signedCheckpoint)

	// Check that it starts with the header
	if !bytes.HasPrefix(formatted, []byte("c2sp.org/tlog-proof@v1\n")) {
		t.Errorf("formatted proof missing header")
	}

	// Check that it contains the index
	if !bytes.Contains(formatted, []byte("index 3\n")) {
		t.Errorf("formatted proof missing index")
	}

	// Check that it contains the extra
	expectedExtra := "extra " + base64.StdEncoding.EncodeToString(extra) + "\n"
	if !bytes.Contains(formatted, []byte(expectedExtra)) {
		t.Errorf("formatted proof missing or incorrect extra, got %s", formatted)
	}

	// Check that it contains the checkpoint
	if !bytes.Contains(formatted, signedCheckpoint) {
		t.Errorf("formatted proof missing signed checkpoint")
	}
}

func TestVerifyProof_Valid(t *testing.T) {
	origin := "example.com/test"
	data := []byte("test data for verification")
	recordHash := tlog.RecordHash(data)

	// Build a simple tree with one record
	var hashes []tlog.Hash
	hashReader := tlog.HashReaderFunc(func(indexes []int64) ([]tlog.Hash, error) {
		var result []tlog.Hash
		for _, idx := range indexes {
			if idx < 0 || idx >= int64(len(hashes)) {
				return nil, fmt.Errorf("hash index %d out of range", idx)
			}
			result = append(result, hashes[idx])
		}
		return result, nil
	})

	// Add one record to the tree
	newHashes, err := tlog.StoredHashes(0, data, hashReader)
	if err != nil {
		t.Fatalf("failed to compute stored hashes: %v", err)
	}
	hashes = append(hashes, newHashes...)

	treeHash, err := tlog.TreeHash(1, hashReader)
	if err != nil {
		t.Fatalf("failed to compute tree hash: %v", err)
	}

	_, verifier, signedCheckpoint := setupTestLog(t, origin, 1, treeHash)

	// Generate proof for record 0
	proof, err := tlog.ProveRecord(1, 0, hashReader)
	if err != nil {
		t.Fatalf("failed to generate proof: %v", err)
	}

	// Format the proof
	formattedProof := torchwood.FormatProof(0, proof, signedCheckpoint)

	// Verify the proof - use ThresholdPolicy with OriginPolicy and SingleVerifierPolicy
	policy := torchwood.ThresholdPolicy(2, torchwood.OriginPolicy(origin), torchwood.SingleVerifierPolicy(verifier))
	err = torchwood.VerifyProof(policy, recordHash, formattedProof)
	if err != nil {
		t.Errorf("VerifyProof failed for valid proof: %v", err)
	}
}

func TestVerifyProof_ValidWithExtra(t *testing.T) {
	origin := "example.com/test"
	data := []byte("test data")
	extra := []byte("my-extra")
	recordHash := tlog.RecordHash(data)

	var hashes []tlog.Hash
	hashReader := tlog.HashReaderFunc(func(indexes []int64) ([]tlog.Hash, error) {
		var result []tlog.Hash
		for _, idx := range indexes {
			if idx < 0 || idx >= int64(len(hashes)) {
				return nil, fmt.Errorf("hash index %d out of range", idx)
			}
			result = append(result, hashes[idx])
		}
		return result, nil
	})

	newHashes, err := tlog.StoredHashes(0, data, hashReader)
	if err != nil {
		t.Fatalf("failed to compute stored hashes: %v", err)
	}
	hashes = append(hashes, newHashes...)

	treeHash, err := tlog.TreeHash(1, hashReader)
	if err != nil {
		t.Fatalf("failed to compute tree hash: %v", err)
	}

	_, verifier, signedCheckpoint := setupTestLog(t, origin, 1, treeHash)

	proof, err := tlog.ProveRecord(1, 0, hashReader)
	if err != nil {
		t.Fatalf("failed to generate proof: %v", err)
	}

	formattedProof := torchwood.FormatProofWithExtraData(0, extra, proof, signedCheckpoint)

	policy := torchwood.ThresholdPolicy(2, torchwood.OriginPolicy(origin), torchwood.SingleVerifierPolicy(verifier))
	err = torchwood.VerifyProof(policy, recordHash, formattedProof)
	if err != nil {
		t.Errorf("VerifyProof failed for valid proof with extra: %v", err)
	}
}

func TestVerifyProof_MissingHeader(t *testing.T) {
	proof := []byte("index 0\n\nexample.com/test\n1\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n")

	err := torchwood.VerifyProof(torchwood.ThresholdPolicy(0), tlog.Hash{}, proof)
	if err == nil {
		t.Error("VerifyProof should fail for missing header")
	}
	if !strings.Contains(err.Error(), "missing header") {
		t.Errorf("expected 'missing header' error, got: %v", err)
	}
}

func TestVerifyProof_InvalidHeader(t *testing.T) {
	proof := []byte("wrong-header\nindex 0\n\nexample.com/test\n1\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n")

	err := torchwood.VerifyProof(torchwood.ThresholdPolicy(0), tlog.Hash{}, proof)
	if err == nil {
		t.Error("VerifyProof should fail for invalid header")
	}
	if !strings.Contains(err.Error(), "missing header") {
		t.Errorf("expected 'missing header' error, got: %v", err)
	}
}

func TestVerifyProof_MissingIndex(t *testing.T) {
	proof := []byte("c2sp.org/tlog-proof@v1\n\nexample.com/test\n1\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n")

	err := torchwood.VerifyProof(torchwood.ThresholdPolicy(0), tlog.Hash{}, proof)
	if err == nil {
		t.Error("VerifyProof should fail for missing index")
	}
	if !strings.Contains(err.Error(), "malformed") {
		t.Errorf("expected 'malformed' error, got: %v", err)
	}
}

func TestVerifyProof_InvalidIndex(t *testing.T) {
	proof := []byte("c2sp.org/tlog-proof@v1\nindex notanumber\n\nexample.com/test\n1\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n")

	err := torchwood.VerifyProof(torchwood.ThresholdPolicy(0), tlog.Hash{}, proof)
	if err == nil {
		t.Error("VerifyProof should fail for invalid index")
	}
	if !strings.Contains(err.Error(), "invalid index") {
		t.Errorf("expected 'invalid index' error, got: %v", err)
	}
}

func TestVerifyProof_NegativeIndex(t *testing.T) {
	proof := []byte("c2sp.org/tlog-proof@v1\nindex -1\n\nexample.com/test\n1\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n")

	err := torchwood.VerifyProof(torchwood.ThresholdPolicy(0), tlog.Hash{}, proof)
	if err == nil {
		t.Error("VerifyProof should fail for negative index")
	}
	if !strings.Contains(err.Error(), "negative index") {
		t.Errorf("expected 'negative index' error, got: %v", err)
	}
}

func TestVerifyProof_InvalidExtra(t *testing.T) {
	proof := []byte("c2sp.org/tlog-proof@v1\nextra not-valid-base64!!!\nindex 0\n\nexample.com/test\n1\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n")

	err := torchwood.VerifyProof(torchwood.ThresholdPolicy(0), tlog.Hash{}, proof)
	if err == nil {
		t.Error("VerifyProof should fail for invalid extra encoding")
	}
	if !strings.Contains(err.Error(), "invalid extra") {
		t.Errorf("expected 'invalid extra' error, got: %v", err)
	}
}

func TestVerifyProof_InvalidHash(t *testing.T) {
	proof := []byte("c2sp.org/tlog-proof@v1\nindex 0\nnot-valid-base64!!!\n\nexample.com/test\n1\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n")

	err := torchwood.VerifyProof(torchwood.ThresholdPolicy(0), tlog.Hash{}, proof)
	if err == nil {
		t.Error("VerifyProof should fail for invalid hash encoding")
	}
	if !strings.Contains(err.Error(), "invalid hash") {
		t.Errorf("expected 'invalid hash' error, got: %v", err)
	}
}

func TestVerifyProof_InvalidHashLength(t *testing.T) {
	// Create a base64-encoded hash that's too short (16 bytes instead of 32)
	shortHash := base64.StdEncoding.EncodeToString(make([]byte, 16))
	proof := []byte(fmt.Sprintf("c2sp.org/tlog-proof@v1\nindex 0\n%s\n\nexample.com/test\n1\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n", shortHash))

	err := torchwood.VerifyProof(torchwood.ThresholdPolicy(0), tlog.Hash{}, proof)
	if err == nil {
		t.Error("VerifyProof should fail for invalid hash length")
	}
	if !strings.Contains(err.Error(), "invalid hash length") {
		t.Errorf("expected 'invalid hash length' error, got: %v", err)
	}
}

func TestVerifyProof_OriginMismatch(t *testing.T) {
	origin := "example.com/test"
	wrongOrigin := "wrong.com/test"

	// Create a valid-looking proof with wrong origin
	var hashes []tlog.Hash
	hashReader := tlog.HashReaderFunc(func(indexes []int64) ([]tlog.Hash, error) {
		var result []tlog.Hash
		for _, idx := range indexes {
			if idx < 0 || idx >= int64(len(hashes)) {
				return nil, fmt.Errorf("hash index %d out of range", idx)
			}
			result = append(result, hashes[idx])
		}
		return result, nil
	})

	data := []byte("test")
	newHashes, err := tlog.StoredHashes(0, data, hashReader)
	if err != nil {
		t.Fatalf("failed to compute stored hashes: %v", err)
	}
	hashes = append(hashes, newHashes...)

	treeHash, err := tlog.TreeHash(1, hashReader)
	if err != nil {
		t.Fatalf("failed to compute tree hash: %v", err)
	}

	// Create checkpoint with wrong origin
	_, verifier, signedCheckpoint := setupTestLog(t, wrongOrigin, 1, treeHash)

	proof, err := tlog.ProveRecord(1, 0, hashReader)
	if err != nil {
		t.Fatalf("failed to generate proof: %v", err)
	}

	formattedProof := torchwood.FormatProof(0, proof, signedCheckpoint)

	// Policy expects origin but checkpoint has wrongOrigin
	policy := torchwood.ThresholdPolicy(2, torchwood.OriginPolicy(origin), torchwood.SingleVerifierPolicy(verifier))
	err = torchwood.VerifyProof(policy, tlog.RecordHash(data), formattedProof)
	if err == nil {
		t.Error("VerifyProof should fail for origin mismatch")
	}
	// The threshold policy reports the number of satisfied components
	if !strings.Contains(err.Error(), "policy components satisfied") {
		t.Errorf("expected 'policy components satisfied' error, got: %v", err)
	}
}

func TestVerifyProof_SignatureVerificationFails(t *testing.T) {
	origin := "example.com/test"

	// Create a valid proof structure but with a bad signature
	var hashes []tlog.Hash
	hashReader := tlog.HashReaderFunc(func(indexes []int64) ([]tlog.Hash, error) {
		var result []tlog.Hash
		for _, idx := range indexes {
			if idx < 0 || idx >= int64(len(hashes)) {
				return nil, fmt.Errorf("hash index %d out of range", idx)
			}
			result = append(result, hashes[idx])
		}
		return result, nil
	})

	data := []byte("test")
	newHashes, err := tlog.StoredHashes(0, data, hashReader)
	if err != nil {
		t.Fatalf("failed to compute stored hashes: %v", err)
	}
	hashes = append(hashes, newHashes...)

	treeHash, err := tlog.TreeHash(1, hashReader)
	if err != nil {
		t.Fatalf("failed to compute tree hash: %v", err)
	}

	// Create two different signers
	_, verifier1, signedCheckpoint := setupTestLog(t, origin, 1, treeHash)
	_, verifier2, _ := setupTestLog(t, origin, 1, treeHash)

	proof, err := tlog.ProveRecord(1, 0, hashReader)
	if err != nil {
		t.Fatalf("failed to generate proof: %v", err)
	}

	formattedProof := torchwood.FormatProof(0, proof, signedCheckpoint)

	// Try to verify with the wrong verifier
	wrongPolicy := torchwood.ThresholdPolicy(2, torchwood.OriginPolicy(origin), torchwood.SingleVerifierPolicy(verifier2))
	err = torchwood.VerifyProof(wrongPolicy, tlog.RecordHash(data), formattedProof)
	if err == nil {
		t.Error("VerifyProof should fail when signature verification fails")
	}

	// Verify it works with the correct verifier
	correctPolicy := torchwood.ThresholdPolicy(2, torchwood.OriginPolicy(origin), torchwood.SingleVerifierPolicy(verifier1))
	err = torchwood.VerifyProof(correctPolicy, tlog.RecordHash(data), formattedProof)
	if err != nil {
		t.Errorf("VerifyProof should succeed with correct verifier: %v", err)
	}
}

func TestVerifyProof_RecordVerificationFails(t *testing.T) {
	origin := "example.com/test"
	data := []byte("correct data")
	wrongData := []byte("wrong data")

	var hashes []tlog.Hash
	hashReader := tlog.HashReaderFunc(func(indexes []int64) ([]tlog.Hash, error) {
		var result []tlog.Hash
		for _, idx := range indexes {
			if idx < 0 || idx >= int64(len(hashes)) {
				return nil, fmt.Errorf("hash index %d out of range", idx)
			}
			result = append(result, hashes[idx])
		}
		return result, nil
	})

	// Build tree with correct data
	newHashes, err := tlog.StoredHashes(0, data, hashReader)
	if err != nil {
		t.Fatalf("failed to compute stored hashes: %v", err)
	}
	hashes = append(hashes, newHashes...)

	treeHash, err := tlog.TreeHash(1, hashReader)
	if err != nil {
		t.Fatalf("failed to compute tree hash: %v", err)
	}

	_, verifier, signedCheckpoint := setupTestLog(t, origin, 1, treeHash)

	proof, err := tlog.ProveRecord(1, 0, hashReader)
	if err != nil {
		t.Fatalf("failed to generate proof: %v", err)
	}

	formattedProof := torchwood.FormatProof(0, proof, signedCheckpoint)

	// Try to verify with wrong data
	policy := torchwood.ThresholdPolicy(2, torchwood.OriginPolicy(origin), torchwood.SingleVerifierPolicy(verifier))
	err = torchwood.VerifyProof(policy, tlog.RecordHash(wrongData), formattedProof)
	if err == nil {
		t.Error("VerifyProof should fail when record hash doesn't match")
	}

	// Check that it returns a VerifyRecordError
	var verifyErr *torchwood.VerifyRecordError
	if !errors.As(err, &verifyErr) {
		t.Errorf("expected VerifyRecordError, got: %T", err)
	} else {
		if verifyErr.Index != 0 {
			t.Errorf("expected Index=0, got Index=%d", verifyErr.Index)
		}
		if len(verifyErr.Extra) != 0 {
			t.Errorf("expected empty Extra, got Extra=%q", verifyErr.Extra)
		}
	}
}

func TestVerifyRecordError_WithExtra(t *testing.T) {
	origin := "example.com/test"
	data := []byte("correct data")
	wrongData := []byte("wrong data")
	extra := []byte("test-extra")

	var hashes []tlog.Hash
	hashReader := tlog.HashReaderFunc(func(indexes []int64) ([]tlog.Hash, error) {
		var result []tlog.Hash
		for _, idx := range indexes {
			if idx < 0 || idx >= int64(len(hashes)) {
				return nil, fmt.Errorf("hash index %d out of range", idx)
			}
			result = append(result, hashes[idx])
		}
		return result, nil
	})

	newHashes, err := tlog.StoredHashes(0, data, hashReader)
	if err != nil {
		t.Fatalf("failed to compute stored hashes: %v", err)
	}
	hashes = append(hashes, newHashes...)

	treeHash, err := tlog.TreeHash(1, hashReader)
	if err != nil {
		t.Fatalf("failed to compute tree hash: %v", err)
	}

	_, verifier, signedCheckpoint := setupTestLog(t, origin, 1, treeHash)

	proof, err := tlog.ProveRecord(1, 0, hashReader)
	if err != nil {
		t.Fatalf("failed to generate proof: %v", err)
	}

	formattedProof := torchwood.FormatProofWithExtraData(0, extra, proof, signedCheckpoint)

	policy := torchwood.ThresholdPolicy(2, torchwood.OriginPolicy(origin), torchwood.SingleVerifierPolicy(verifier))
	err = torchwood.VerifyProof(policy, tlog.RecordHash(wrongData), formattedProof)
	if err == nil {
		t.Error("VerifyProof should fail when record hash doesn't match")
	}

	// Check that it returns a VerifyRecordError with the extra
	var verifyErr *torchwood.VerifyRecordError
	if !errors.As(err, &verifyErr) {
		t.Errorf("expected VerifyRecordError, got: %T", err)
	} else {
		if verifyErr.Index != 0 {
			t.Errorf("expected Index=0, got Index=%d", verifyErr.Index)
		}
		if !bytes.Equal(verifyErr.Extra, extra) {
			t.Errorf("expected Extra=%q, got Extra=%q", extra, verifyErr.Extra)
		}
		expectedMsg := "tlog record inclusion proof verification failed for index 0"
		if verifyErr.Error() != expectedMsg {
			t.Errorf("expected error message %q, got %q", expectedMsg, verifyErr.Error())
		}
	}
}

func TestVerifyCheckpoint_UnrestrictedOrigin(t *testing.T) {
	origin := "example.com/test"
	hash := tlog.RecordHash([]byte("test data"))

	_, verifier, signedCheckpoint := setupTestLog(t, origin, 10, hash)

	// SingleVerifierPolicy alone doesn't check the origin
	policy := torchwood.SingleVerifierPolicy(verifier)

	_, _, err := torchwood.VerifyCheckpoint(signedCheckpoint, policy)
	if err == nil {
		t.Error("VerifyCheckpoint should fail when policy doesn't check origin")
	}
	if !strings.Contains(err.Error(), "not checking the checkpoint origin") {
		t.Errorf("expected 'not checking the checkpoint origin' error, got: %v", err)
	}
}

func TestOriginPolicy_Check(t *testing.T) {
	// Test basic OriginPolicy
	policy := torchwood.OriginPolicy("example.com/test")

	// Check with matching origin should pass
	err := policy.Check("example.com/test", nil)
	if err != nil {
		t.Errorf("OriginPolicy.Check should pass for matching origin: %v", err)
	}

	// Check with non-matching origin should fail
	err = policy.Check("wrong.com/test", nil)
	if err == nil {
		t.Error("OriginPolicy.Check should fail for non-matching origin")
	}
	if !strings.Contains(err.Error(), "origin mismatch") {
		t.Errorf("expected 'origin mismatch' error, got: %v", err)
	}
}

func TestOriginPolicy_ThresholdWithMultipleOrigins(t *testing.T) {
	// Test Threshold(1, OriginPolicy("foo"), OriginPolicy("bar")) should work with foo or bar
	policy := torchwood.ThresholdPolicy(1,
		torchwood.OriginPolicy("foo.com/log"),
		torchwood.OriginPolicy("bar.com/log"),
	)

	// Should pass with first origin
	err := policy.Check("foo.com/log", nil)
	if err != nil {
		t.Errorf("ThresholdPolicy should pass with first origin: %v", err)
	}

	// Should pass with second origin
	err = policy.Check("bar.com/log", nil)
	if err != nil {
		t.Errorf("ThresholdPolicy should pass with second origin: %v", err)
	}

	// Should fail with neither origin
	err = policy.Check("other.com/log", nil)
	if err == nil {
		t.Error("ThresholdPolicy should fail with neither origin")
	}
}

func TestOriginPolicy_ThresholdOnlyCountsContributingOrigins(t *testing.T) {
	// A Threshold(1, OriginPolicy("foo"), OriginPolicy("bar")) should work with
	// either foo or bar, not requiring both to match.
	// This tests that only the OriginPolicy that actually matches contributes
	// to the threshold.
	origin := "foo.com/test"
	hash := tlog.RecordHash([]byte("test data"))

	_, verifier, signedCheckpoint := setupTestLog(t, origin, 10, hash)

	// Policy: threshold 2 of [OriginPolicy(foo), OriginPolicy(bar), SingleVerifierPolicy]
	// With origin "foo.com/test", only OriginPolicy(foo) and SingleVerifierPolicy should pass
	// So threshold of 2 should be satisfied
	policy := torchwood.ThresholdPolicy(2,
		torchwood.ThresholdPolicy(1,
			torchwood.OriginPolicy("foo.com/test"),
			torchwood.OriginPolicy("bar.com/test"),
		),
		torchwood.SingleVerifierPolicy(verifier),
	)

	c, _, err := torchwood.VerifyCheckpoint(signedCheckpoint, policy)
	if err != nil {
		t.Errorf("VerifyCheckpoint should succeed: %v", err)
	}
	if c.Origin != origin {
		t.Errorf("expected origin %q, got %q", origin, c.Origin)
	}

	// Now test with bar.com/test origin
	origin2 := "bar.com/test"
	_, verifier2, signedCheckpoint2 := setupTestLog(t, origin2, 10, hash)

	policy2 := torchwood.ThresholdPolicy(2,
		torchwood.ThresholdPolicy(1,
			torchwood.OriginPolicy("foo.com/test"),
			torchwood.OriginPolicy("bar.com/test"),
		),
		torchwood.SingleVerifierPolicy(verifier2),
	)

	c2, _, err := torchwood.VerifyCheckpoint(signedCheckpoint2, policy2)
	if err != nil {
		t.Errorf("VerifyCheckpoint should succeed with second origin: %v", err)
	}
	if c2.Origin != origin2 {
		t.Errorf("expected origin %q, got %q", origin2, c2.Origin)
	}

	// Test that wrong origin fails
	origin3 := "wrong.com/test"
	_, verifier3, signedCheckpoint3 := setupTestLog(t, origin3, 10, hash)

	policy3 := torchwood.ThresholdPolicy(2,
		torchwood.ThresholdPolicy(1,
			torchwood.OriginPolicy("foo.com/test"),
			torchwood.OriginPolicy("bar.com/test"),
		),
		torchwood.SingleVerifierPolicy(verifier3),
	)

	_, _, err = torchwood.VerifyCheckpoint(signedCheckpoint3, policy3)
	if err == nil {
		t.Error("VerifyCheckpoint should fail with wrong origin")
	}
}
torchwood-0.9.0/tesserax/000077500000000000000000000000001514564101300153665ustar00rootroot00000000000000torchwood-0.9.0/tesserax/tesserax.go000066400000000000000000000041671514564101300175630ustar00rootroot00000000000000// Package tesserax implements additional functions for use with the [tessera]
// package. It is a separate package to prevent all torchwood importers from
// incurring a transitive dependency on tessera.
package tesserax

import (
	"context"
	"errors"
	"fmt"

	"filippo.io/torchwood"
	"github.com/transparency-dev/tessera"
	"golang.org/x/mod/sumdb/tlog"
)

// TileReader is a [torchwood.TileReader] implemented by a [tessera.LogReader].
type TileReader struct {
	r tessera.LogReader
}

var _ torchwood.TileReader = (*TileReader)(nil)

// NewTileReader returns a TileReader that reads tiles and checkpoints from the
// given tessera.LogReader.
func NewTileReader(r tessera.LogReader) *TileReader {
	return &TileReader{r: r}
}

// ReadTiles implements [torchwood.TileReader.ReadTiles].
func (tr *TileReader) ReadTiles(ctx context.Context, tiles []tlog.Tile) (data [][]byte, err error) {
	data = make([][]byte, len(tiles))
	for i, t := range tiles {
		if t.H != torchwood.TileHeight {
			return nil, errors.New("unsupported tile height")
		}
		if t.L < -1 {
			return nil, errors.New("invalid tile level")
		}
		if t.L == -1 {
			index, partial := uint64(t.N), uint8(t.W)
			tileData, err := tr.r.ReadEntryBundle(ctx, index, partial)
			if err != nil {
				return nil, fmt.Errorf("failed to read tessera entry bundle index=%d, partial=%d: %w", index, partial, err)
			}
			data[i] = tileData
			continue
		}
		level, index, partial := uint64(t.L), uint64(t.N), uint8(t.W)
		tileData, err := tr.r.ReadTile(ctx, level, index, partial)
		if err != nil {
			return nil, fmt.Errorf("failed to read tessera tile level=%d, index=%d, partial=%d: %w", level, index, partial, err)
		}
		data[i] = tileData
	}
	return data, nil
}

// SaveTiles is a no-op implementation of [torchwood.TileReader.SaveTiles].
func (tr *TileReader) SaveTiles(tiles []tlog.Tile, data [][]byte) {}

// ReadEndpoint exposes the "checkpoint" endpoint via
// [tessera.LogReader.ReadCheckpoint].
func (tr *TileReader) ReadEndpoint(ctx context.Context, path string) ([]byte, error) {
	if path != "checkpoint" {
		return nil, errors.New("unsupported endpoint: " + path)
	}
	return tr.r.ReadCheckpoint(ctx)
}
torchwood-0.9.0/tile.go000066400000000000000000000101141514564101300150110ustar00rootroot00000000000000package torchwood

import (
	"context"
	"encoding/binary"
	"fmt"
	"strings"

	"golang.org/x/mod/sumdb/tlog"
)

const TileHeight = 8
const TileWidth = 1 << TileHeight

// TilePath returns a tile coordinate path describing t, according to
// c2sp.org/tlog-tiles.
//
// For the go.dev/design/25530-sumdb scheme, use [tlog.Tile.Path]. For the
// c2sp.org/static-ct-api scheme, use [filippo.io/sunlight/TilePath].
//
// If t.Height is not TileHeight, TilePath panics.
func TilePath(t tlog.Tile) string {
	if t.H != TileHeight {
		panic(fmt.Sprintf("unexpected tile height %d", t.H))
	}
	if t.L == -1 {
		return "tile/entries/" + strings.TrimPrefix(t.Path(), "tile/8/data/")
	}
	return "tile/" + strings.TrimPrefix(t.Path(), "tile/8/")
}

// ParseTilePath parses a tile coordinate path according to c2sp.org/tlog-tiles.
//
// For the go.dev/design/25530-sumdb scheme, use [tlog.ParseTilePath]. For the
// c2sp.org/static-ct-api scheme, use [filippo.io/sunlight/ParseTilePath].
func ParseTilePath(path string) (tlog.Tile, error) {
	if rest, ok := strings.CutPrefix(path, "tile/entries/"); ok {
		t, err := tlog.ParseTilePath("tile/8/data/" + rest)
		if err != nil {
			return tlog.Tile{}, fmt.Errorf("malformed tile path %q", path)
		}
		return t, nil
	}
	if rest, ok := strings.CutPrefix(path, "tile/"); ok {
		t, err := tlog.ParseTilePath("tile/8/" + rest)
		if err != nil {
			return tlog.Tile{}, fmt.Errorf("malformed tile path %q", path)
		}
		return t, nil
	}
	return tlog.Tile{}, fmt.Errorf("malformed tile path %q", path)
}

// ReadTileEntry reads the next entry from the entry bundle according to
// c2sp.org/tlog-tiles, and returns the remaining data in the tile.
func ReadTileEntry(tile []byte) (entry, rest []byte, err error) {
	if len(tile) < 2 {
		return nil, nil, fmt.Errorf("tile too short")
	}
	l := binary.BigEndian.Uint16(tile)
	tile = tile[2:]
	if len(tile) < int(l) {
		return nil, nil, fmt.Errorf("tile too short for entry length %d", l)
	}
	return tile[:l], tile[l:], nil
}

// AppendTileEntry appends the given entry to the entry bundle, according to
// c2sp.org/tlog-tiles.
func AppendTileEntry(tile []byte, entry []byte) ([]byte, error) {
	if len(entry) > 0xFFFF {
		return nil, fmt.Errorf("entry too long: %d bytes", len(entry))
	}
	tile = binary.BigEndian.AppendUint16(tile, uint16(len(entry)))
	tile = append(tile, entry...)
	return tile, nil
}

// TileReader is an interface equivalent to [tlog.TileReader], but with a
// context parameter for cancellation, a fixed [TileHeight], and a method to
// fetch arbitrary additional endpoints, if available.
type TileReader interface {
	// ReadTiles returns the data for each requested tile.
	// See [tlog.TileReader.ReadTiles] for details.
	ReadTiles(ctx context.Context, tiles []tlog.Tile) (data [][]byte, err error)

	// SaveTiles informs the TileReader that the tile data has been confirmed.
	// See [tlog.TileReader.SaveTiles] for details.
	SaveTiles(tiles []tlog.Tile, data [][]byte)

	// ReadEndpoint fetches an arbitrary endpoint at the given path, such as
	// "checkpoint", if supported.
	ReadEndpoint(ctx context.Context, path string) (data []byte, err error)
}

// TileReaderWithContext is an obsolete name for [TileReader].
//
// Deprecated: use [TileReader] instead.
//
//go:fix inline
type TileReaderWithContext = TileReader

// TileHashReaderWithContext returns a HashReader that satisfies requests by
// loading tiles of the given tree.
//
// It is equivalent to [tlog.TileHashReader], but passes the ctx argument to the
// [TileReader] methods.
func TileHashReaderWithContext(ctx context.Context, tree tlog.Tree, tr TileReader) tlog.HashReader {
	return tlog.HashReaderFunc(func(i []int64) ([]tlog.Hash, error) {
		return tlog.TileHashReader(tree, tileReaderAndContext{tr: tr, ctx: ctx}).ReadHashes(i)
	})
}

type tileReaderAndContext struct {
	tr  TileReader
	ctx context.Context
}

func (tr tileReaderAndContext) Height() int { return TileHeight }
func (tr tileReaderAndContext) ReadTiles(tiles []tlog.Tile) (data [][]byte, err error) {
	return tr.tr.ReadTiles(tr.ctx, tiles)
}
func (tr tileReaderAndContext) SaveTiles(tiles []tlog.Tile, data [][]byte) {
	tr.tr.SaveTiles(tiles, data)
}
torchwood-0.9.0/tlogclient.go000066400000000000000000000645701514564101300162370ustar00rootroot00000000000000package torchwood

import (
	"archive/zip"
	"bytes"
	"compress/gzip"
	"context"
	"errors"
	"fmt"
	"io"
	"io/fs"
	"iter"
	"log/slog"
	"math"
	"net/http"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"time"

	"golang.org/x/mod/sumdb/tlog"
	"golang.org/x/sync/errgroup"
)

// Client is a tlog client that fetches and authenticates tiles, and exposes log
// entries as a Go iterator or by their index.
type Client struct {
	tr      TileReader
	cut     func([]byte) ([]byte, tlog.Hash, []byte, error)
	timeout time.Duration
	err     error
}

// NewClient creates a new [Client] that fetches tiles using the given
// [TileReader]. The TileReader would typically be a [TileFetcher],
// optionally wrapped in a [PermanentCache] to cache tiles on disk.
func NewClient(tr TileReader, opts ...ClientOption) (*Client, error) {
	c := &Client{tr: tr}
	for _, opt := range opts {
		opt(c)
	}
	if c.cut == nil {
		c.cut = func(tile []byte) (entry []byte, rh tlog.Hash, rest []byte, err error) {
			entry, rest, err = ReadTileEntry(tile)
			return entry, tlog.RecordHash(entry), rest, err
		}
	}
	if c.timeout == 0 {
		c.timeout = 5 * time.Minute
	}
	return c, nil
}

// ClientOption is a function that configures a [Client].
type ClientOption func(*Client)

// WithTimeout configures the maximum duration the [Client.Entries] loop will
// block waiting for each next extry. The default is 5 minutes.
func WithTimeout(d time.Duration) ClientOption {
	return func(c *Client) {
		c.timeout = d
	}
}

// WithCutEntry configures the function to split the next entry from a data tile
// (a.k.a. entry bundle).
//
// The entry is surfaced by the Entries method, the record hash is used to check
// inclusion in the tree, and the rest is passed to the next invocation of cut.
//
// The input tile is never empty. cut must not modify the tile.
//
// By default, the c2sp.org/tlog-tiles#log-entries format is used, as
// implemented by [ReadTileEntry]. For the go.dev/design/25530-sumdb format, use
// [ReadSumDBEntry]. For the c2sp.org/static-ct-api format, use
// [filippo.io/sunlight.Client] instead.
func WithCutEntry(cut func(tile []byte) (entry []byte, rh tlog.Hash, rest []byte, err error)) ClientOption {
	return func(c *Client) {
		c.cut = cut
	}
}

// ReadSumDBEntry splits the next entry from a tile according to the
// go.dev/design/25530-sumdb format, for use with [WithCutEntry].
func ReadSumDBEntry(tile []byte) (entry []byte, rh tlog.Hash, rest []byte, err error) {
	if idx := bytes.Index(tile, []byte("\n\n")); idx >= 0 {
		// Add back one of the newlines.
		entry, rest = tile[:idx+1], tile[idx+2:]
	} else {
		entry, rest = tile, nil
	}
	return entry, tlog.RecordHash(entry), rest, nil
}

// WithSumDBEntries configures the function to split the next entry from a tile
// according to the go.dev/design/25530-sumdb format.
//
// Deprecated: use [WithCutEntry] with [ReadSumDBEntry] instead.
//
//go:fix inline
func WithSumDBEntries() ClientOption {
	return WithCutEntry(ReadSumDBEntry)
}

// Err returns the error encountered by the latest [Client.Entries] call.
func (c *Client) Err() error {
	return c.err
}

// Entries returns an iterator that yields entries from the given tree, starting
// at the given index. The first item in the yielded pair is the overall entry
// index in the log, starting at start.
//
// The provided tree should have been verified by the caller, for example by
// verifying the signatures on a [Checkpoint].
//
// Iteration may stop before the size of the tree to avoid fetching a partial
// data tile. Resuming with the same tree will yield the remaining entries,
// however clients tailing a growing log are encouraged to fetch the next
// checkpoint and use that as the tree argument. If this behavior is not
// desired, use [Client.AllEntries] instead.
//
// Callers must check [Client.Err] after the iteration breaks.
func (c *Client) Entries(ctx context.Context, tree tlog.Tree, start int64) iter.Seq2[int64, []byte] {
	c.err = nil
	mainCtx := ctx
	tr := &edgeMemoryCache{tr: c.tr, t: make(map[int][2]tileWithData)}
	return func(yield func(int64, []byte) bool) {
		ctx, cancel := context.WithTimeout(mainCtx, c.timeout)
		defer func() { cancel() }()
		for {
			if err := ctx.Err(); err != nil {
				c.err = err
				return
			}

			base := start / TileWidth * TileWidth
			// In regular operations, don't actually fetch the trailing partial
			// tile, to avoid duplicating that traffic in steady state. The
			// assumption is that a future call to Entries will pass a bigger
			// tree where that tile is full. However, if the tree grows too
			// slowly, we'll get another call where start is at the beginning of
			// the partial tile; in that case, fetch it.
			top := tree.N / TileWidth * TileWidth
			if top-base == 0 {
				top = tree.N
			}
			tiles := make([]tlog.Tile, 0, 50)
			for i := 0; i < 50; i++ {
				tileStart := base + int64(i)*TileWidth
				if tileStart >= top {
					break
				}
				tileEnd := tileStart + TileWidth
				if tileEnd > top {
					tileEnd = top
				}
				tiles = append(tiles, tlog.Tile{H: TileHeight, L: -1,
					N: tileStart / TileWidth, W: int(tileEnd - tileStart)})
			}
			if len(tiles) == 0 {
				return
			}
			tdata, err := tr.ReadTiles(ctx, tiles)
			if err != nil {
				c.err = err
				return
			}

			// TODO: hash data tile directly against level 8 hash.
			indexes := make([]int64, 0, TileWidth*len(tiles))
			for _, t := range tiles {
				for i := range t.W {
					indexes = append(indexes, tlog.StoredHashIndex(0, t.N*TileWidth+int64(i)))
				}
			}
			hashes, err := TileHashReaderWithContext(ctx, tree, tr).ReadHashes(indexes)
			if err != nil {
				c.err = err
				return
			}

			for ti, t := range tiles {
				tileStart := t.N * TileWidth
				tileEnd := tileStart + int64(t.W)
				data := tdata[ti]
				for i := tileStart; i < tileEnd; i++ {
					if err := ctx.Err(); err != nil {
						c.err = err
						return
					}

					if len(data) == 0 {
						c.err = fmt.Errorf("unexpected end of tile data for tile %d", t.N)
						return
					}

					entry, rh, rest, err := c.cut(data)
					if err != nil {
						c.err = fmt.Errorf("failed to cut entry %d: %w", i, err)
						return
					}
					data = rest

					if rh != hashes[i-base] {
						c.err = fmt.Errorf("hash mismatch for entry %d", i)
						return
					}

					if i < start {
						continue
					}
					if !yield(i, entry) {
						return
					}
					cancel()
					ctx, cancel = context.WithTimeout(mainCtx, c.timeout)
					_ = cancel // https://go.dev/issue/25720
				}
				if len(data) != 0 {
					c.err = fmt.Errorf("unexpected leftover data in tile %d", t.N)
					return
				}
				start = tileEnd
			}

			tr.SaveTiles(tiles, tdata)

			if start == top {
				return
			}
		}
	}
}

// AllEntries works like [Client.Entries], but fetches all entries up to the
// size of the tree, including those in partial data tiles.
//
// Callers that are tailing a growing log should instead use [Client.Entries],
// and fetch a new tree every time iteration stops.
func (c *Client) AllEntries(ctx context.Context, tree tlog.Tree, start int64) iter.Seq2[int64, []byte] {
	return func(yield func(int64, []byte) bool) {
		for i, entry := range c.Entries(ctx, tree, start) {
			if !yield(i, entry) {
				return
			}
			start = i + 1
		}
		if c.Err() == nil && start < tree.N && ctx.Err() == nil {
			for i, entry := range c.Entries(ctx, tree, start) {
				if !yield(i, entry) {
					return
				}
			}
		}
	}
}

type tileWithData struct {
	tlog.Tile
	data []byte
}

// edgeMemoryCache is a [TileReader] that caches two edges in the tree: the
// rightmost one that's used to compute the tree hash, and the one that moves
// through the tree as we progress through entries.
type edgeMemoryCache struct {
	tr TileReader
	t  map[int][2]tileWithData // map[level][2]tileWithData
}

func (c *edgeMemoryCache) ReadTiles(ctx context.Context, tiles []tlog.Tile) (data [][]byte, err error) {
	data = make([][]byte, len(tiles))
	missing := make([]tlog.Tile, 0, len(tiles))
	for i, t := range tiles {
		if td := c.t[t.L]; td[0].Tile == t {
			data[i] = td[0].data
		} else if td[1].Tile == t {
			data[i] = td[1].data
		} else {
			missing = append(missing, t)
		}
	}
	if len(missing) == 0 {
		return data, nil
	}
	missingData, err := c.tr.ReadTiles(ctx, missing)
	if err != nil {
		return nil, err
	}
	for i := range data {
		if data[i] == nil {
			data[i] = missingData[0]
			missingData = missingData[1:]
		}
	}
	return data, nil
}

func (c *edgeMemoryCache) SaveTiles(tiles []tlog.Tile, data [][]byte) {
	ts, ds := make([]tlog.Tile, 0, len(tiles)), make([][]byte, 0, len(tiles))
	for i, t := range tiles {
		// If it's already in the memory cache, it was already saved by the
		// lower layer, as well.
		if td := c.t[t.L]; td[0].Tile == t || td[1].Tile == t {
			continue
		}
		ts = append(ts, t)
		ds = append(ds, data[i])
	}
	c.tr.SaveTiles(ts, ds)

	// Concretely, we just save the two rightmost observed tiles at each level,
	// which in practice during a scan will be the two edges.
	for i, t := range tiles {
		td, ok := c.t[t.L]
		switch {
		case !ok:
			c.t[t.L] = [2]tileWithData{{Tile: t, data: data[i]}}
		case td[0].Tile == t || td[1].Tile == t:
			// Already saved.
		case tileLess(td[0].Tile, t) && tileLess(td[0].Tile, td[1].Tile):
			c.t[t.L] = [2]tileWithData{{Tile: t, data: data[i]}, td[1]}
		case tileLess(td[1].Tile, t) && tileLess(td[1].Tile, td[0].Tile):
			c.t[t.L] = [2]tileWithData{td[0], {Tile: t, data: data[i]}}
		}
	}
}

func tileLess(a, b tlog.Tile) bool {
	// A zero tile is always less than any other tile.
	if a == (tlog.Tile{}) {
		return true
	}
	if b == (tlog.Tile{}) {
		return false
	}
	if a.L != b.L {
		panic("different levels")
	}
	return a.N < b.N || (a.N == b.N && a.W < b.W)
}

func (c *edgeMemoryCache) ReadEndpoint(ctx context.Context, path string) (data []byte, err error) {
	return c.tr.ReadEndpoint(ctx, path)
}

// Entry returns a log entry by its index, and an inclusion proof in the tree.
//
// The provided tree should have been verified by the caller, for example by
// verifying the signatures on a [Checkpoint].
func (c *Client) Entry(ctx context.Context, tree tlog.Tree, index int64) ([]byte, tlog.RecordProof, error) {
	if index < 0 || index >= tree.N {
		return nil, nil, fmt.Errorf("tlog: invalid index %d for tree of size %d", index, tree.N)
	}

	dataTile := tlog.Tile{H: TileHeight, L: -1, N: index / TileWidth, W: TileWidth}
	dataTile.W = min(dataTile.W, int(tree.N-dataTile.N*TileWidth))
	data, err := c.tr.ReadTiles(ctx, []tlog.Tile{dataTile})
	if err != nil {
		return nil, nil, fmt.Errorf("tlog: failed to read tile %s: %w", dataTile.Path(), err)
	}

	tile := data[0]
	var entry []byte
	var rh tlog.Hash
	for range index - dataTile.N*TileWidth + 1 {
		if len(tile) == 0 {
			return nil, nil, fmt.Errorf("tlog: no entry at index %d in tile %s", index, dataTile.Path())
		}
		entry, rh, tile, err = c.cut(tile)
		if err != nil {
			return nil, nil, fmt.Errorf("tlog: failed to cut entry %d from tile %s: %w", index, dataTile.Path(), err)
		}
	}

	proof, err := tlog.ProveRecord(tree.N, index, TileHashReaderWithContext(ctx, tree, c.tr))
	if err != nil {
		return nil, nil, fmt.Errorf("tlog: failed to prove entry %d in tree of size %d: %w", index, tree.N, err)
	}
	if err := tlog.CheckRecord(proof, tree.N, tree.Hash, index, rh); err != nil {
		return nil, nil, fmt.Errorf("tlog: data entry %d does not match Merkle tree: %w", index, err)
	}

	return entry, proof, nil
}

// TileFetcher is a [TileReader] that fetches tiles from a remote server.
type TileFetcher struct {
	base     string
	hc       *http.Client
	ua       string
	log      *slog.Logger
	limit    int
	tilePath func(tlog.Tile) string
}

// NewTileFetcher creates a new [TileFetcher] that fetches tiles from the given
// base URL. By default, it fetches tiles according to c2sp.org/tlog-tiles.
func NewTileFetcher(base string, opts ...TileFetcherOption) (*TileFetcher, error) {
	if !strings.HasSuffix(base, "/") {
		base += "/"
	}

	tf := &TileFetcher{base: base}
	for _, opt := range opts {
		opt(tf)
	}
	if tf.tilePath == nil {
		tf.tilePath = TilePath
	}
	if tf.hc == nil {
		transport := http.DefaultTransport.(*http.Transport).Clone()
		transport.MaxIdleConnsPerHost = transport.MaxIdleConns
		tf.hc = &http.Client{
			Transport: transport,
			Timeout:   10 * time.Second,
		}
	}
	if tf.ua == "" {
		tf.ua = "filippo.io/torchwood.Client"
	}
	if tf.log == nil {
		tf.log = slog.New(slogDiscardHandler{})
	}

	return tf, nil
}

// TileFetcherOption is a function that configures a [TileFetcher].
type TileFetcherOption func(*TileFetcher)

// WithTileFetcherLogger configures the logger used by the TileFetcher.
// By default, log lines are discarded.
func WithTileFetcherLogger(log *slog.Logger) TileFetcherOption {
	return func(f *TileFetcher) {
		f.log = log
	}
}

// WithHTTPClient configures the HTTP client used by the TileFetcher.
//
// Note that TileFetcher may need to make multiple parallel requests to
// the same host, more than the default MaxIdleConnsPerHost.
func WithHTTPClient(hc *http.Client) TileFetcherOption {
	return func(f *TileFetcher) {
		f.hc = hc
	}
}

// WithUserAgent configures the User-Agent header used by the TileFetcher.
// By default, the User-Agent is "filippo.io/torchwood.Client".
func WithUserAgent(ua string) TileFetcherOption {
	return func(f *TileFetcher) {
		f.ua = ua
	}
}

// WithConcurrencyLimit configures the maximum number of concurrent requests
// made by the TileFetcher. By default, there is no limit.
func WithConcurrencyLimit(limit int) TileFetcherOption {
	return func(f *TileFetcher) {
		f.limit = limit
	}
}

// WithTilePath configures the function used to generate the tile path from a
// [tlog.Tile]. By default, TileFetcher uses the c2sp.org/tlog-tiles scheme
// implemented by [TilePath]. For the go.dev/design/25530-sumdb scheme, use
// [tlog.Tile.Path]. For the c2sp.org/static-ct-api scheme, use
// [filippo.io/sunlight.TilePath].
func WithTilePath(tilePath func(tlog.Tile) string) TileFetcherOption {
	return func(f *TileFetcher) {
		f.tilePath = tilePath
	}
}

// ReadTiles implements [TileReader].
//
// It retries 429 and 5xx responses, and network errors.
func (f *TileFetcher) ReadTiles(ctx context.Context, tiles []tlog.Tile) (data [][]byte, err error) {
	data = make([][]byte, len(tiles))
	errGroup, ctx := errgroup.WithContext(ctx)
	if f.limit > 0 {
		errGroup.SetLimit(f.limit)
	}
	for i, t := range tiles {
		errGroup.Go(func() error {
			if t.H != TileHeight {
				return fmt.Errorf("unexpected tile height %d", t.H)
			}
			path := f.tilePath(t)
			d, err := f.ReadEndpoint(ctx, path)
			data[i] = d
			return err
		})
	}
	return data, errGroup.Wait()
}

// ReadEndpoint fetches an arbitrary path.
//
// It retries 429 and 5xx responses, and network errors.
func (f *TileFetcher) ReadEndpoint(ctx context.Context, path string) (data []byte, err error) {
	req, err := http.NewRequestWithContext(ctx, "GET", f.base+path, nil)
	if err != nil {
		return nil, fmt.Errorf("%s: failed to create request: %w", path, err)
	}
	var errs error
	var retryAfter time.Time
	for j := range 5 {
		if j > 0 {
			// Wait 1s, 5s, 25s, or 125s before retrying.
			pause := time.Duration(math.Pow(5, float64(j-1))) * time.Second
			if !retryAfter.IsZero() {
				pause = time.Until(retryAfter)
				retryAfter = time.Time{}
			}
			f.log.InfoContext(ctx, "retrying GET request", "path", path,
				"pause", pause, "errs", errs, "retry", j)
			select {
			case <-ctx.Done():
				return nil, ctx.Err()
			case <-time.After(pause):
			}
		}
		req.Header.Set("User-Agent", f.ua)
		resp, err := f.hc.Do(req)
		if err != nil {
			errs = errors.Join(errs, err)
			continue
		}
		defer resp.Body.Close()
		switch {
		case resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode >= 500:
			retryAfter = parseRetryAfter(resp.Header.Get("Retry-After"))
			errs = errors.Join(errs, fmt.Errorf("unexpected status code %d", resp.StatusCode))
			continue
		case resp.StatusCode != http.StatusOK:
			return nil, fmt.Errorf("%s: unexpected status code %d", path, resp.StatusCode)
		}
		data, err := io.ReadAll(resp.Body)
		if err != nil {
			errs = errors.Join(errs, err)
			continue
		}
		f.log.InfoContext(ctx, "fetched resource", "path", path, "size", len(data))
		return data, nil
	}
	return nil, fmt.Errorf("%s: %w", path, errs)
}

// parseRetryAfter parses the Retry-After header value. It returns the time
// to wait before retrying the request. If the header is not present or
// invalid, it returns zero.
func parseRetryAfter(header string) time.Time {
	if header == "" {
		return time.Time{}
	}
	n, err := strconv.Atoi(header)
	if err == nil {
		return time.Now().Add(time.Duration(n) * time.Second)
	}
	t, err := http.ParseTime(header)
	if err == nil {
		return t
	}
	return time.Time{}
}

// SaveTiles implements [TileReader]. It does nothing.
func (f *TileFetcher) SaveTiles(tiles []tlog.Tile, data [][]byte) {}

type slogDiscardHandler struct{}

func (slogDiscardHandler) Enabled(context.Context, slog.Level) bool  { return false }
func (slogDiscardHandler) Handle(context.Context, slog.Record) error { return nil }
func (slogDiscardHandler) WithAttrs(attrs []slog.Attr) slog.Handler  { return slogDiscardHandler{} }
func (slogDiscardHandler) WithGroup(name string) slog.Handler        { return slogDiscardHandler{} }

// PermanentCache is a [TileReader] that caches verified, non-partial tiles in a
// filesystem directory. It passes through ReadEndpoint calls without caching.
type PermanentCache struct {
	tr       TileReader
	dir      string
	log      *slog.Logger
	tilePath func(tlog.Tile) string
}

// NewPermanentCache creates a new [PermanentCache] that caches tiles in the
// given directory. The directory must exist.
func NewPermanentCache(tr TileReader, dir string, opts ...PermanentCacheOption) (*PermanentCache, error) {
	if fi, err := os.Stat(dir); err != nil || !fi.IsDir() {
		return nil, fmt.Errorf("cache directory %q does not exist or is not a directory: %w", dir, err)
	}
	c := &PermanentCache{tr: tr, dir: dir}
	for _, opt := range opts {
		opt(c)
	}
	if c.log == nil {
		c.log = slog.New(slogDiscardHandler{})
	}
	if c.tilePath == nil {
		c.tilePath = TilePath
	}
	return c, nil
}

// PermanentCacheOption is a function that configures a [PermanentCache].
type PermanentCacheOption func(*PermanentCache)

// WithPermanentCacheLogger configures the logger used by the PermanentCache.
// By default, log lines are discarded.
func WithPermanentCacheLogger(log *slog.Logger) PermanentCacheOption {
	return func(c *PermanentCache) {
		c.log = log
	}
}

// WithPermanentCacheTilePath configures the function used to generate the tile
// path from a [tlog.Tile]. By default, PermanentCache uses the
// c2sp.org/tlog-tiles scheme implemented by [TilePath]. For the
// go.dev/design/25530-sumdb scheme, use [tlog.Tile.Path]. For the
// c2sp.org/static-ct-api scheme, use [filippo.io/sunlight.TilePath].
func WithPermanentCacheTilePath(tilePath func(tlog.Tile) string) PermanentCacheOption {
	return func(f *PermanentCache) {
		f.tilePath = tilePath
	}
}

// ReadTiles implements [TileReader].
func (c *PermanentCache) ReadTiles(ctx context.Context, tiles []tlog.Tile) (data [][]byte, err error) {
	data = make([][]byte, len(tiles))
	missing := make([]tlog.Tile, 0, len(tiles))
	for i, t := range tiles {
		if t.H != TileHeight {
			return nil, fmt.Errorf("unexpected tile height %d", t.H)
		}
		path := filepath.Join(c.dir, c.tilePath(t))
		if d, err := os.ReadFile(path); errors.Is(err, os.ErrNotExist) {
			missing = append(missing, t)
		} else if err != nil {
			return nil, err
		} else {
			c.log.Info("loaded tile from cache", "path", c.tilePath(t), "size", len(d))
			data[i] = d
		}
	}
	if len(missing) == 0 {
		return data, nil
	}
	missingData, err := c.tr.ReadTiles(ctx, missing)
	if err != nil {
		return nil, err
	}
	for i := range data {
		if data[i] == nil {
			data[i] = missingData[0]
			missingData = missingData[1:]
		}
	}
	return data, nil
}

// SaveTiles implements [TileReader].
func (c *PermanentCache) SaveTiles(tiles []tlog.Tile, data [][]byte) {
	for i, t := range tiles {
		if t.H != TileHeight {
			c.log.Error("unexpected tile height", "tile", t, "height", t.H)
			continue
		}
		if t.W != TileWidth {
			continue // skip partial tiles
		}
		path := filepath.Join(c.dir, c.tilePath(t))
		if _, err := os.Stat(path); err == nil {
			continue
		}
		if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
			c.log.Error("failed to create directory", "path", path, "error", err)
			return
		}
		if err := os.WriteFile(path, data[i], 0600); err != nil {
			c.log.Error("failed to write file", "path", path, "error", err)
		} else {
			c.log.Info("saved tile to cache", "path", c.tilePath(t), "size", len(data[i]))
		}
	}
	c.tr.SaveTiles(tiles, data)
}

// ReadEndpoint passes through to the underlying TileReader.
func (c *PermanentCache) ReadEndpoint(ctx context.Context, path string) (data []byte, err error) {
	return c.tr.ReadEndpoint(ctx, path)
}

// TileFS is a [TileReader] that reads tiles from a [fs.FS].
type TileFS struct {
	fs       fs.FS
	tilePath func(tlog.Tile) string
	gzip     bool
}

// NewTileFS creates a new [TileFS] that reads tiles from the given [fs.FS].
// By default, it expects tiles to be laid out according to c2sp.org/tlog-tiles.
func NewTileFS(f fs.FS, opts ...TileFSOption) (*TileFS, error) {
	tf := &TileFS{fs: f}
	for _, opt := range opts {
		opt(tf)
	}
	if tf.tilePath == nil {
		tf.tilePath = TilePath
	}
	return tf, nil
}

// TileFSOption is a function that configures a [TileFS].
type TileFSOption func(*TileFS)

// WithTileFSTilePath configures the function used to generate the tile path
// from a [tlog.Tile]. By default, TileFS uses the c2sp.org/tlog-tiles scheme
// implemented by [TilePath]. For the go.dev/design/25530-sumdb scheme, use
// [tlog.Tile.Path]. For the c2sp.org/static-ct-api scheme, use
// [filippo.io/sunlight.TilePath].
func WithTileFSTilePath(tilePath func(tlog.Tile) string) TileFSOption {
	return func(f *TileFS) {
		f.tilePath = tilePath
	}
}

// WithGzipDataTiles configures the TileFS to transparently decompress
// gzip-compressed data tiles.
func WithGzipDataTiles() TileFSOption {
	return func(f *TileFS) {
		f.gzip = true
	}
}

// ReadTiles implements [TileReader].
func (f *TileFS) ReadTiles(ctx context.Context, tiles []tlog.Tile) (data [][]byte, err error) {
	data = make([][]byte, len(tiles))
	for i, t := range tiles {
		if t.H != TileHeight {
			return nil, fmt.Errorf("unexpected tile height %d", t.H)
		}
		path := f.tilePath(t)
		d, err := fs.ReadFile(f.fs, path)
		if err != nil {
			return nil, fmt.Errorf("failed to read tile %s: %w", path, err)
		}
		if f.gzip && t.L == -1 {
			gr, err := gzip.NewReader(bytes.NewReader(d))
			if err != nil {
				return nil, fmt.Errorf("failed to create gzip reader for tile %s: %w", path, err)
			}
			decompressed, err := io.ReadAll(gr)
			if err != nil {
				return nil, fmt.Errorf("failed to decompress tile %s: %w", path, err)
			}
			if err := gr.Close(); err != nil {
				return nil, fmt.Errorf("failed to close gzip reader for tile %s: %w", path, err)
			}
			d = decompressed
		}
		data[i] = d
	}
	return data, nil
}

// ReadEndpoint fetches an arbitrary path.
func (f *TileFS) ReadEndpoint(ctx context.Context, path string) (data []byte, err error) {
	// Callers should use [os.Root] as a more robust protection, and FS
	// implementations should check ValidPath, but avoid the most trivial
	// directory traversal here as well.
	if !fs.ValidPath(path) {
		return nil, fmt.Errorf("invalid path %q", path)
	}
	return fs.ReadFile(f.fs, path)
}

// SaveTiles implements [TileReader]. It does nothing.
func (f *TileFS) SaveTiles(tiles []tlog.Tile, data [][]byte) {}

// TileArchiveFS is an [fs.FS] that reads tiles and accessory files from a
// collection of zip files, numbered 000.zip, 001.zip, ...
//
// Each zip file contains the corresponding level 2 tile, and all the full tiles
// below it. All other files (higher-level tiles, partial tiles on the right
// edge, checkpoint, etc.) are expected to be present in every zip file.
//
// It supports both c2sp.org/tlog-tiles and c2sp.org/static-ct-api tile layouts,
// but not go.dev/design/25530-sumdb.
//
// See also https://github.com/geomys/ct-archive/blob/main/README.md#archival-format.
type TileArchiveFS struct {
	zips fs.FS

	// cachedReader, if not nil, is the cachedIndex-th zip file.
	cachedReader *zip.Reader
	cachedFile   fs.File
	cachedIndex  int
}

// NewTileArchiveFS creates a new [TileArchiveFS] that reads zip files from
// the root of the given [fs.FS]. f.Open must return files that implement
// [io.ReaderAt].
func NewTileArchiveFS(f fs.FS) *TileArchiveFS {
	return &TileArchiveFS{zips: f}
}

// Open implements [fs.FS].
func (tf *TileArchiveFS) Open(name string) (fs.File, error) {
	var zipIndex int
	t, ok := parseMultiTilePath(name)
	switch {
	case !ok || t.L > 2 || t.W != TileWidth:
		// All zip files contain this file, so use the cached one, if any.
		zipIndex = tf.cachedIndex
	case t.L == 2:
		zipIndex = int(t.N)
	case t.L == 1:
		zipIndex = int(t.N / TileWidth)
	default: // levels 0 and -1
		zipIndex = int(t.N / (TileWidth * TileWidth))
	}
	zr, err := tf.zipReader(zipIndex)
	if err != nil {
		return nil, &fs.PathError{Op: "open", Path: name, Err: err}
	}
	f, err := zr.Open(name)
	if err != nil {
		return nil, &fs.PathError{Op: "open", Path: name, Err: fmt.Errorf("reading from %03d.zip: %w", zipIndex, err)}
	}
	return f, nil
}

func parseMultiTilePath(path string) (tlog.Tile, bool) {
	// Convert c2sp.org/static-ct-api to c2sp.org/tlog-tiles.
	if rest, ok := strings.CutPrefix(path, "tile/data/"); ok {
		path = "tile/entries/" + rest
	}
	tile, err := ParseTilePath(path)
	if err != nil {
		return tlog.Tile{}, false
	}
	return tile, true
}

func (tf *TileArchiveFS) zipReader(index int) (*zip.Reader, error) {
	if tf.cachedReader != nil && tf.cachedIndex == index {
		return tf.cachedReader, nil
	}
	if tf.cachedFile != nil {
		if err := tf.cachedFile.Close(); err != nil {
			return nil, fmt.Errorf("failed to close previous zip file: %w", err)
		}
	}
	zipPath := fmt.Sprintf("%03d.zip", index)
	f, err := tf.zips.Open(zipPath)
	if err != nil {
		return nil, fmt.Errorf("failed to open zip file: %w", err)
	}
	at, ok := f.(io.ReaderAt)
	if !ok {
		return nil, &fs.PathError{Op: "open", Path: zipPath, Err: errors.New("zip file does not implement io.ReaderAt")}
	}
	fi, err := f.Stat()
	if err != nil {
		return nil, fmt.Errorf("failed to stat zip file %q: %w", zipPath, err)
	}
	zr, err := zip.NewReader(at, fi.Size())
	if err != nil {
		return nil, &fs.PathError{Op: "open", Path: zipPath, Err: fmt.Errorf("failed to read zip file: %w", err)}
	}
	tf.cachedReader = zr
	tf.cachedFile = f
	tf.cachedIndex = index
	return zr, nil
}

func (tf *TileArchiveFS) Close() error {
	if tf.cachedFile != nil {
		err := tf.cachedFile.Close()
		tf.cachedReader = nil
		tf.cachedFile = nil
		tf.cachedIndex = 0
		return err
	}
	return nil
}
torchwood-0.9.0/tlogclient_test.go000066400000000000000000000120621514564101300172630ustar00rootroot00000000000000//go:build go1.24

package torchwood_test

import (
	"bytes"
	"fmt"
	"log/slog"
	"path/filepath"
	"testing"

	"filippo.io/torchwood"
	"golang.org/x/mod/sumdb/tlog"
)

func TestReadEndpoint(t *testing.T) {
	handler, _ := testLogHandler(t)
	fetcher, err := torchwood.NewTileFetcher("https://sum.golang.org/",
		torchwood.WithTileFetcherLogger(slog.New(handler)))
	if err != nil {
		t.Fatal(err)
	}
	data, err := fetcher.ReadEndpoint(t.Context(), "latest")
	if err != nil {
		t.Fatal(err)
	}
	if !bytes.HasPrefix(data, []byte("go.sum database tree\n")) {
		t.Fatalf("got %q, want prefix %q", data, "go.sum database tree\n")
	}
}

func TestSumDB(t *testing.T) {
	latest := []byte(`go.sum database tree
31048497
InZSsRXdXKTMF3W5wEcd9T6ro5zyOiRMGQsEPSTco6U=
`)
	tree, err := tlog.ParseTree(latest)
	if err != nil {
		t.Fatal(err)
	}

	handler, _ := testLogHandler(t)

	for _, idx := range []int64{0, 1, 255, 256, 257,
		31048497 - 31048497%256 - 1, 31048497 - 31048497%256,
		31048497 - 31048497%256 + 1, 31048497 - 1} {
		t.Run(fmt.Sprintf("Entry%d", idx), func(t *testing.T) {
			fetcher, err := torchwood.NewTileFetcher("https://sum.golang.org/",
				torchwood.WithTilePath(tlog.Tile.Path),
				torchwood.WithTileFetcherLogger(slog.New(handler)))
			if err != nil {
				t.Fatal(err)
			}
			client, err := torchwood.NewClient(fetcher, torchwood.WithSumDBEntries())
			if err != nil {
				t.Fatal(err)
			}
			entry, proof, err := client.Entry(t.Context(), tree, idx)
			if err != nil {
				t.Fatal(err)
			}
			rh := tlog.RecordHash(entry)
			if err := tlog.CheckRecord(proof, tree.N, tree.Hash, idx, rh); err != nil {
				t.Fatalf("CheckRecord failed: %v", err)
			}
		})
	}

	tests := []struct {
		start     int64
		expect    int
		expectAll int
	}{
		{0, 1000, 1000},
		{100000, 1000, 1000},
		{31048497 - 1000, 1000 - 31048497%256, 1000},              // Stop before the partial (without AllEntries).
		{31048497 - 31048497%256, 31048497 % 256, 31048497 % 256}, // Consume the partial.
		{31048497, 0, 0},
	}

	for _, tt := range tests {
		t.Run(fmt.Sprintf("Start%d", tt.start), func(t *testing.T) {
			t.Run("NoCache", func(t *testing.T) {
				fetcher, err := torchwood.NewTileFetcher("https://sum.golang.org/",
					torchwood.WithTilePath(tlog.Tile.Path),
					torchwood.WithTileFetcherLogger(slog.New(handler)))
				if err != nil {
					t.Fatal(err)
				}
				client, err := torchwood.NewClient(fetcher, torchwood.WithSumDBEntries())
				if err != nil {
					t.Fatal(err)
				}

				count := 0
				for range client.Entries(t.Context(), tree, tt.start) {
					count++
					if count >= 1000 {
						break
					}
				}
				if err := client.Err(); err != nil {
					t.Fatal(err)
				}
				if count != tt.expect {
					t.Errorf("got %d entries, want %d", count, tt.expect)
				}

				count = 0
				for range client.AllEntries(t.Context(), tree, tt.start) {
					count++
					if count >= 1000 {
						break
					}
				}
				if err := client.Err(); err != nil {
					t.Fatal(err)
				}
				if count != tt.expectAll {
					t.Errorf("got %d entries with AllEntries, want %d", count, tt.expectAll)
				}
			})

			t.Run("DirCache", func(t *testing.T) {
				fetcher, err := torchwood.NewTileFetcher("https://sum.golang.org/",
					torchwood.WithTilePath(tlog.Tile.Path),
					torchwood.WithTileFetcherLogger(slog.New(handler)))
				if err != nil {
					t.Fatal(err)
				}
				dirCache, err := torchwood.NewPermanentCache(fetcher, t.TempDir(),
					torchwood.WithPermanentCacheLogger(slog.New(handler)),
					torchwood.WithPermanentCacheTilePath(tlog.Tile.Path))
				if err != nil {
					t.Fatal(err)
				}
				client, err := torchwood.NewClient(dirCache, torchwood.WithSumDBEntries())
				if err != nil {
					t.Fatal(err)
				}

				count := 0
				for range client.Entries(t.Context(), tree, tt.start) {
					count++
					if count >= 1000 {
						break
					}
				}
				if err := client.Err(); err != nil {
					t.Fatal(err)
				}
				if count != tt.expect {
					t.Errorf("got %d entries, want %d", count, tt.expect)
				}

				// Again, from cache.
				client, err = torchwood.NewClient(dirCache, torchwood.WithSumDBEntries())
				if err != nil {
					t.Fatal(err)
				}
				count = 0
				for range client.Entries(t.Context(), tree, tt.start) {
					count++
					if count >= 1000 {
						break
					}
				}
				if err := client.Err(); err != nil {
					t.Fatal(err)
				}
				if count != tt.expect {
					t.Errorf("got %d entries, want %d", count, tt.expect)
				}
			})
		})
	}
}

func testLogHandler(t testing.TB) (slog.Handler, *slog.LevelVar) {
	level := &slog.LevelVar{}
	level.Set(slog.LevelDebug)
	h := slog.NewTextHandler(writerFunc(func(p []byte) (n int, err error) {
		t.Logf("%s", p)
		return len(p), nil
	}), &slog.HandlerOptions{
		AddSource: true,
		Level:     level,
		ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
			if a.Key == slog.SourceKey {
				src := a.Value.Any().(*slog.Source)
				a.Value = slog.StringValue(fmt.Sprintf("%s:%d", filepath.Base(src.File), src.Line))
			}
			return a
		},
	})
	return h, level
}

type writerFunc func(p []byte) (n int, err error)

func (f writerFunc) Write(p []byte) (n int, err error) {
	return f(p)
}
torchwood-0.9.0/tlogx.go000066400000000000000000000146021514564101300152170ustar00rootroot00000000000000// Copyright 2023 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

// Package torchwood implements a tlog client and various c2sp.org/signed-note,
// c2sp.org/tlog-cosignature, c2sp.org/tlog-checkpoint, and c2sp.org/tlog-tiles
// functions, including extensions to the [golang.org/x/mod/sumdb/tlog] and
// [golang.org/x/mod/sumdb/note] packages.
package torchwood

import (
	"errors"
	"fmt"

	"golang.org/x/mod/sumdb/tlog"
)

// RightEdge returns the stored hash indexes of the right edge of a tree of
// size n. These are the same hashes that are combined into a [tlog.TreeHash]
// and allow producing record and tree proofs for any size bigger than n. See
// [tlog.StoredHashIndex] for the definition of stored hash indexes.
func RightEdge(n int64) []int64 {
	var lo int64
	var idx []int64
	for lo < n {
		k, level := maxpow2(n - lo + 1)
		idx = append(idx, tlog.StoredHashIndex(level, lo>>level))
		lo += k
	}
	return idx
}

// A HashProof is a verifiable proof that a particular tree head contains a
// particular sub-tree hash. A [tlog.RecordProof] is a special case of a
// HashProof where the sub-tree has height 0.
type HashProof []tlog.Hash

// ProveHash returns the proof that the tree of size t contains the hash with
// [tlog.StoredHashIndex] i.
func ProveHash(t, i int64, r tlog.HashReader) (HashProof, error) {
	if t < 0 || i < 0 || i >= tlog.StoredHashIndex(0, t) {
		return nil, fmt.Errorf("tlog: invalid inputs in ProveHash")
	}
	indexes := hashProofIndex(0, t, i, nil)
	if len(indexes) == 0 {
		return HashProof{}, nil
	}
	hashes, err := r.ReadHashes(indexes)
	if err != nil {
		return nil, err
	}
	if len(hashes) != len(indexes) {
		return nil, fmt.Errorf("tlog: ReadHashes(%d indexes) = %d hashes", len(indexes), len(hashes))
	}

	p, hashes := hashProof(0, t, i, hashes)
	if len(hashes) != 0 {
		panic("tlog: bad index math in ProveHash")
	}
	return p, nil
}

// hashProofIndex builds the list of indexes needed to construct the proof
// that hash i is contained in the subtree with leaves [lo, hi).
// It appends those indexes to need and returns the result.
func hashProofIndex(lo, hi, i int64, need []int64) []int64 {
	l, n := tlog.SplitStoredHashIndex(i)
	if !(lo <= n<= tlog.StoredHashIndex(0, t) {
		return fmt.Errorf("tlog: invalid inputs in CheckHash")
	}
	th2, err := runHashProof(p, 0, t, i, h)
	if err != nil {
		return err
	}
	if th2 == th {
		return nil
	}
	return errProofFailed
}

// runHashProof runs the proof p that hash i is contained in the subtree with leaves [lo, hi).
// Running the proof means constructing and returning the implied hash of that subtree.
func runHashProof(p HashProof, lo, hi, i int64, h tlog.Hash) (tlog.Hash, error) {
	l, n := tlog.SplitStoredHashIndex(i)
	if !(lo <= n<>uint(level)))
		lo += k
	}
	return need
}

// subTreeHash computes the hash for the subtree containing records [lo, hi),
// assuming that hashes are the hashes corresponding to the indexes
// returned by subTreeIndex(lo, hi).
// It returns any leftover hashes.
func subTreeHash(lo, hi int64, hashes []tlog.Hash) (tlog.Hash, []tlog.Hash) {
	// Repeatedly partition the tree into a left side with 2^level nodes,
	// for as large a level as possible, and a right side with the fringe.
	// The left hash is stored directly and can be read from storage.
	// The right side needs further computation.
	numTree := 0
	for lo < hi {
		k, _ := maxpow2(hi - lo + 1)
		if lo&(k-1) != 0 || lo >= hi {
			panic("tlog: bad math in subTreeHash")
		}
		numTree++
		lo += k
	}

	if len(hashes) < numTree {
		panic("tlog: bad index math in subTreeHash")
	}

	// Reconstruct hash.
	h := hashes[numTree-1]
	for i := numTree - 2; i >= 0; i-- {
		h = tlog.NodeHash(hashes[i], h)
	}
	return h, hashes[numTree:]
}

// maxpow2 returns k, the maximum power of 2 smaller than n,
// as well as l = log₂ k (so k = 1<