From fc51c638b16dcbc18719c1683e47bda2d82bfb3d Mon Sep 17 00:00:00 2001 From: Philippe Boneff Date: Wed, 19 Aug 2026 16:33:39 +0000 Subject: [PATCH 1/2] [MTC] Fetch subtree cosignatures from witnesses --- .../log/internal/subtreewitness/gateway.go | 237 +++++++++++++ .../internal/subtreewitness/gateway_test.go | 311 ++++++++++++++++++ cmd/mtc/log/mtc.go | 57 +++- cmd/mtc/log/mtc_test.go | 133 +++++++- cmd/mtc/log/posix/main.go | 10 + witness.go | 10 +- 6 files changed, 730 insertions(+), 28 deletions(-) create mode 100644 cmd/mtc/log/internal/subtreewitness/gateway.go create mode 100644 cmd/mtc/log/internal/subtreewitness/gateway_test.go diff --git a/cmd/mtc/log/internal/subtreewitness/gateway.go b/cmd/mtc/log/internal/subtreewitness/gateway.go new file mode 100644 index 000000000..21bf4f866 --- /dev/null +++ b/cmd/mtc/log/internal/subtreewitness/gateway.go @@ -0,0 +1,237 @@ +// Copyright 2026 The Tessera authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package subtreewitness provides a gateway for subtree cosigning with witnesses. +package subtreewitness + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "log/slog" + "maps" + "net/http" + "net/url" + "slices" + "sync" + + f_note "github.com/transparency-dev/formats/note" + "github.com/transparency-dev/tessera" + "github.com/transparency-dev/tessera/cmd/mtc/log/internal/mtcproof" + wc "github.com/transparency-dev/witness/client/http" + "golang.org/x/mod/sumdb/note" +) + +// ErrPolicyNotSatisfied is returned when witness responses do not satisfy the required policy. +var ErrPolicyNotSatisfied = errors.New("witness policy was not satisfied") + +type witnessKey struct { + name string + keyHash uint32 +} + +type witness struct { + client SubtreeWitnessClient + verifier f_note.SubtreeVerifier + cosignerID []byte +} + +// SubtreeWitnessClient defines the interface for calling a witness's sign-subtree endpoint. +type SubtreeWitnessClient interface { + SignSubtree(ctx context.Context, start, end uint64, subRoot []byte, proof [][]byte, rawCp []byte) ([]byte, error) +} + +// Gateway manages concurrent requests to subtree witnesses and evaluates policy satisfaction. +type Gateway struct { + witnesses map[witnessKey]witness + policy tessera.WitnessGroup +} + +// New creates a new subtree witness Gateway. +// It only creates witness endpoints which implement f_note.SubtreeVerifier, i.e. which +// use ML-DSA signatures. +// SPEC: [DRAFT] Chrome Quantum-resistant Root Program Policy, Version 0.3.0, Section 3.1. +// "Mirroring Cosigner Keys MUST be ML-DSA-44" +func New(httpClient *http.Client, policy tessera.WitnessGroup) (*Gateway, error) { + if httpClient == nil { + httpClient = http.DefaultClient + } + + witnesses := make(map[witnessKey]witness) + for uStr, vs := range policy.WitnessEndpoints() { + u, err := url.Parse(uStr) + if err != nil { + return nil, fmt.Errorf("invalid witness URL %q: %w", uStr, err) + } + if len(vs) == 0 { + return nil, fmt.Errorf("no verifiers for witness %s", uStr) + } + client := wc.NewWitness(u, httpClient) + for _, v := range vs { + if sv, ok := v.(f_note.SubtreeVerifier); ok { + cosignerID, err := mtcproof.ParseCosignerID(sv.Name()) + if err != nil { + return nil, fmt.Errorf("invalid cosigner ID for witness %s: %w", sv.Name(), err) + } + witnesses[witnessKey{name: sv.Name(), keyHash: sv.KeyHash()}] = witness{ + client: client, + verifier: sv, + cosignerID: cosignerID, + } + } + } + } + + return &Gateway{ + witnesses: witnesses, + policy: policy, + }, nil +} + +// CosignSubtree sends concurrent subtree cosigning requests to all witnesses and returns gathered +// SubtreeSignatures as soon as the policy the Gateway was constructed with is satisfied. +// +// CosignSubtree checks for policy satisfaction on a reconstructed checkpoint, containing the +// checkpoint signatures corresponding to the collected subtree cosignatures. This means that policy +// will be met once sufficient cosignatures from cosigners who have signed a corresponding +// checkpoint have been collected. +// TODO: implement subtree cosignature policy matching directly. +func (gw *Gateway) CosignSubtree(ctx context.Context, origin string, start, end uint64, subRoot []byte, consProof [][]byte, rawCp []byte) ([]mtcproof.SubtreeSignature, error) { + if len(gw.witnesses) == 0 { + if gw.policy.Satisfied(rawCp) { + return nil, nil + } + return nil, ErrPolicyNotSatisfied + } + + // Open the checkpoint without verifying it to extract all signatures. + _, err := note.Open(rawCp, note.VerifierList()) + var unverified *note.UnverifiedNoteError + if !errors.As(err, &unverified) { + return nil, fmt.Errorf("failed to parse checkpoint note: %v", err) + } + n := unverified.Note + + // reconstructCp is used for policy checking. + reconstructedCp := fmt.Appendf(nil, "%s\n", n.Text) + + cpSigs := make(map[witnessKey]string) + for _, s := range n.UnverifiedSigs { + cpSigs[witnessKey{name: s.Name, keyHash: s.Hash}] = s.Base64 + } + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + var waitGroup sync.WaitGroup + type sigOrErr struct { + sig []byte + err error + } + results := make(chan sigOrErr, len(gw.witnesses)) + + // Kick off a goroutine for each witness and send result to results chan + for _, w := range gw.witnesses { + waitGroup.Add(1) + go func(w witness) { + defer waitGroup.Done() + sig, err := w.client.SignSubtree(ctx, start, end, subRoot, consProof, rawCp) + results <- sigOrErr{ + sig: sig, + err: err, + } + }(w) + } + + go func() { + waitGroup.Wait() + close(results) + }() + + verifiedSubtreeSigs := make(map[witnessKey]mtcproof.SubtreeSignature) + err = ErrPolicyNotSatisfied + + // Consume the results coming back from each witness + for r := range results { + if r.err != nil { + err = errors.Join(err, r.err) + continue + } + + var sigNote *note.UnverifiedNoteError + // Use note.Open on a synthetic note to enforce strict signature formatting. + _, sigErr := note.Open(append([]byte("text\n\n"), r.sig...), note.VerifierList()) + if !errors.As(sigErr, &sigNote) { + slog.WarnContext(ctx, "Failed to parse witness subtree signature response", slog.Any("error", sigErr)) + continue + } + + for _, s := range sigNote.Note.UnverifiedSigs { + raw, bErr := base64.StdEncoding.DecodeString(s.Base64) + if bErr != nil || len(raw) < 4 { + slog.WarnContext(ctx, "Failed to decode witness subtree signature base64", slog.String("witness", s.Name), slog.Any("error", bErr)) + continue + } + keyHash := s.Hash + sigBytes := raw[4:] + + k := witnessKey{name: s.Name, keyHash: keyHash} + // SPEC: draft-ietf-plants-merkle-tree-certs section 6.2. + // "An MTCProof parser MUST reject the input if there are duplicate cosigner_id values" + if _, ok := verifiedSubtreeSigs[k]; ok { + continue + } + + b64, ok := cpSigs[k] + if !ok { + slog.WarnContext(ctx, "Received subtree signature from witness not present on checkpoint", + slog.String("witness", s.Name), + slog.String("key_hash", fmt.Sprintf("%08x", keyHash)), + ) + continue + } + + w, ok := gw.witnesses[k] + if !ok { + slog.ErrorContext(ctx, "Received subtree signature from unknown witness key", + slog.String("witness", s.Name), + slog.String("key_hash", fmt.Sprintf("%08x", keyHash)), + ) + continue + } + + if !w.verifier.VerifySubtree(0, origin, start, end, subRoot, sigBytes) { + slog.ErrorContext(ctx, "Subtree signature verification failed", + slog.String("witness", s.Name), + slog.Uint64("start", start), + slog.Uint64("end", end), + ) + continue + } + + verifiedSubtreeSigs[k] = mtcproof.SubtreeSignature{ + CosignerID: w.cosignerID, + Signature: sigBytes, + } + reconstructedCp = fmt.Appendf(reconstructedCp, "— %s %s\n", s.Name, b64) + } + + if gw.policy.Satisfied(reconstructedCp) { + return slices.Collect(maps.Values(verifiedSubtreeSigs)), nil + } + } + + return nil, err +} diff --git a/cmd/mtc/log/internal/subtreewitness/gateway_test.go b/cmd/mtc/log/internal/subtreewitness/gateway_test.go new file mode 100644 index 000000000..c02425c63 --- /dev/null +++ b/cmd/mtc/log/internal/subtreewitness/gateway_test.go @@ -0,0 +1,311 @@ +// Copyright 2026 The Tessera authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package subtreewitness + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/binary" + "errors" + "fmt" + "net/url" + "testing" + + f_log "github.com/transparency-dev/formats/log" + f_note "github.com/transparency-dev/formats/note" + "github.com/transparency-dev/tessera" + "golang.org/x/mod/sumdb/note" +) + +type mockSubtreeClient struct { + signFunc func(ctx context.Context, start, end uint64, subRoot []byte, proof [][]byte, rawCp []byte) ([]byte, error) +} + +func (m *mockSubtreeClient) SignSubtree(ctx context.Context, start, end uint64, subRoot []byte, proof [][]byte, rawCp []byte) ([]byte, error) { + return m.signFunc(ctx, start, end, subRoot, proof, rawCp) +} + +// mustSignSubtree signs a subtree and formats the signature as a note-style signature line. +func mustSignSubtree(t *testing.T, s f_note.SubtreeSigner, origin string, start, end uint64, root []byte) (rawSig []byte, sigLine []byte) { + t.Helper() + rawSig, err := s.SignSubtree(0, origin, start, end, root) + if err != nil { + t.Fatalf("SignSubtree: %v", err) + } + buf := binary.BigEndian.AppendUint32(nil, s.KeyHash()) + buf = append(buf, rawSig...) + sigLine = []byte(fmt.Sprintf("— %s %s\n", s.Name(), base64.StdEncoding.EncodeToString(buf))) + return rawSig, sigLine +} + +func TestNew(t *testing.T) { + _, vkeyValid, err := f_note.GenerateMLDSAKey("oid/1.3.6.1.4.1.32473.106") + if err != nil { + t.Fatalf("GenerateMLDSAKey: %v", err) + } + u1, _ := url.Parse("https://wit1.example.com") + wValid, err := tessera.NewWitness(vkeyValid, u1) + if err != nil { + t.Fatalf("NewWitness: %v", err) + } + + _, vkeyNonSubtree, err := note.GenerateKey(nil, "non-subtree-witness") + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + u2, _ := url.Parse("https://wit2.example.com") + wNonSubtree, err := tessera.NewWitness(vkeyNonSubtree, u2) + if err != nil { + t.Fatalf("NewWitness: %v", err) + } + + _, vkeyInvalidName, err := f_note.GenerateMLDSAKey("invalid-name-not-oid") + if err != nil { + t.Fatalf("GenerateMLDSAKey: %v", err) + } + u3, _ := url.Parse("https://wit3.example.com") + wInvalidName, err := tessera.NewWitness(vkeyInvalidName, u3) + if err != nil { + t.Fatalf("NewWitness: %v", err) + } + + tests := []struct { + name string + policy tessera.WitnessGroup + wantWitnesses int + wantErr bool + }{ + { + name: "valid single subtree witness", + policy: tessera.NewWitnessGroup(1, wValid), + wantWitnesses: 1, + wantErr: false, + }, + { + name: "non-subtree verifier is skipped", + policy: tessera.NewWitnessGroup(1, wNonSubtree), + wantWitnesses: 0, + wantErr: false, + }, + { + name: "mixed subtree and non-subtree witnesses", + policy: tessera.NewWitnessGroup(1, wValid, wNonSubtree), + wantWitnesses: 1, + wantErr: false, + }, + { + name: "invalid cosigner name in subtree verifier", + policy: tessera.NewWitnessGroup(1, wInvalidName), + wantWitnesses: 0, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + gw, err := New(nil, tc.policy) + if (err != nil) != tc.wantErr { + t.Fatalf("NewGateway() error = %v, wantErr %v", err, tc.wantErr) + } + if !tc.wantErr && len(gw.witnesses) != tc.wantWitnesses { + t.Errorf("got %d witnesses in gateway, want %d", len(gw.witnesses), tc.wantWitnesses) + } + }) + } +} + +func TestGateway_CosignSubtree(t *testing.T) { + origin := "example.com/log" + start := uint64(0) + end := uint64(1024) + root := bytes.Repeat([]byte{0xcc}, 32) + cp := f_log.Checkpoint{ + Origin: origin, + Size: end, + Hash: root, + } + cpText := string(cp.Marshal()) + + logSKey, _, _ := f_note.GenerateMLDSAKey(origin) + logSigner, _ := f_note.NewMLDSASigner(logSKey) + + skey1, vkey1, _ := f_note.GenerateMLDSAKey("oid/1.3.6.1.4.1.32473.106") + signer1, _ := f_note.NewMLDSASigner(skey1) + ver1, err := f_note.NewMLDSAVerifier(vkey1) + if err != nil { + t.Fatalf("NewMLDSAVerifier: %v", err) + } + + rawCpWithWit, err := note.Sign(¬e.Note{Text: cpText}, logSigner, signer1) + if err != nil { + t.Fatalf("note.Sign: %v", err) + } + rawCpNoWit, err := note.Sign(¬e.Note{Text: cpText}, logSigner) + if err != nil { + t.Fatalf("note.Sign: %v", err) + } + + rawSubSig, subSigLine := mustSignSubtree(t, signer1, origin, start, end, root) + + corruptSubSig := bytes.Clone(rawSubSig) + corruptSubSig[0] ^= 0xff + corruptBuf := binary.BigEndian.AppendUint32(nil, signer1.KeyHash()) + corruptBuf = append(corruptBuf, corruptSubSig...) + corruptSubSigLine := []byte(fmt.Sprintf("— %s %s\n", signer1.Name(), base64.StdEncoding.EncodeToString(corruptBuf))) + + u1, _ := url.Parse("https://wit1.example.com") + w1, err := tessera.NewWitness(vkey1, u1) + if err != nil { + t.Fatalf("NewWitness: %v", err) + } + policy1 := tessera.NewWitnessGroup(1, w1) + + tests := []struct { + name string + witnesses map[witnessKey]witness + policy tessera.WitnessGroup + rawCp []byte + wantSigs int + wantSubSig []byte + wantErr error + }{ + { + name: "policy satisfied with valid witness signature", + witnesses: map[witnessKey]witness{ + {name: ver1.Name(), keyHash: ver1.KeyHash()}: { + client: &mockSubtreeClient{ + signFunc: func(ctx context.Context, start, end uint64, subRoot []byte, proof [][]byte, rawCp []byte) ([]byte, error) { + return subSigLine, nil + }, + }, + verifier: ver1, + cosignerID: []byte{0x01}, + }, + }, + policy: policy1, + rawCp: rawCpWithWit, + wantSigs: 1, + wantSubSig: rawSubSig, + }, + { + name: "duplicate witness signature response is deduplicated", + witnesses: map[witnessKey]witness{ + {name: ver1.Name(), keyHash: ver1.KeyHash()}: { + client: &mockSubtreeClient{ + signFunc: func(ctx context.Context, start, end uint64, subRoot []byte, proof [][]byte, rawCp []byte) ([]byte, error) { + return append(bytes.Clone(subSigLine), subSigLine...), nil + }, + }, + verifier: ver1, + cosignerID: []byte{0x01}, + }, + }, + policy: policy1, + rawCp: rawCpWithWit, + wantSigs: 1, + wantSubSig: rawSubSig, + }, + { + name: "policy not satisfied when witness key not on checkpoint", + witnesses: map[witnessKey]witness{ + {name: ver1.Name(), keyHash: ver1.KeyHash()}: { + client: &mockSubtreeClient{ + signFunc: func(ctx context.Context, start, end uint64, subRoot []byte, proof [][]byte, rawCp []byte) ([]byte, error) { + return subSigLine, nil + }, + }, + verifier: ver1, + cosignerID: []byte{0x01}, + }, + }, + policy: policy1, + rawCp: rawCpNoWit, + wantSigs: 0, + wantErr: ErrPolicyNotSatisfied, + }, + { + name: "policy not satisfied when subtree signature verification fails", + witnesses: map[witnessKey]witness{ + {name: ver1.Name(), keyHash: ver1.KeyHash()}: { + client: &mockSubtreeClient{ + signFunc: func(ctx context.Context, start, end uint64, subRoot []byte, proof [][]byte, rawCp []byte) ([]byte, error) { + return corruptSubSigLine, nil + }, + }, + verifier: ver1, + cosignerID: []byte{0x01}, + }, + }, + policy: policy1, + rawCp: rawCpWithWit, + wantSigs: 0, + wantErr: ErrPolicyNotSatisfied, + }, + { + name: "policy not satisfied when witness fails", + witnesses: map[witnessKey]witness{ + {name: ver1.Name(), keyHash: ver1.KeyHash()}: { + client: &mockSubtreeClient{ + signFunc: func(ctx context.Context, start, end uint64, subRoot []byte, proof [][]byte, rawCp []byte) ([]byte, error) { + return nil, errors.New("witness down") + }, + }, + verifier: ver1, + cosignerID: []byte{0x01}, + }, + }, + policy: policy1, + rawCp: rawCpWithWit, + wantSigs: 0, + wantErr: ErrPolicyNotSatisfied, + }, + { + name: "policy not satisfied when gateway has no witnesses", + witnesses: map[witnessKey]witness{}, + policy: policy1, + rawCp: rawCpNoWit, + wantSigs: 0, + wantErr: ErrPolicyNotSatisfied, + }, + { + name: "empty policy satisfied with empty gateway", + witnesses: map[witnessKey]witness{}, + policy: tessera.WitnessGroup{}, + rawCp: rawCpNoWit, + wantSigs: 0, + wantErr: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + gw := &Gateway{ + witnesses: tc.witnesses, + policy: tc.policy, + } + verified, err := gw.CosignSubtree(context.Background(), origin, start, end, root, nil, tc.rawCp) + if !errors.Is(err, tc.wantErr) { + t.Fatalf("got error %v, want %v", err, tc.wantErr) + } + if len(verified) != tc.wantSigs { + t.Fatalf("got %d verified sigs, want %d", len(verified), tc.wantSigs) + } + if tc.wantSigs > 0 && !bytes.Equal(verified[0].Signature, tc.wantSubSig) { + t.Errorf("got signature %x, want %x", verified[0].Signature, tc.wantSubSig) + } + }) + } +} diff --git a/cmd/mtc/log/mtc.go b/cmd/mtc/log/mtc.go index 4fce6b696..66dcab561 100644 --- a/cmd/mtc/log/mtc.go +++ b/cmd/mtc/log/mtc.go @@ -35,6 +35,7 @@ import ( "github.com/transparency-dev/tessera/cmd/mtc/log/internal/entry" "github.com/transparency-dev/tessera/cmd/mtc/log/internal/landmark" "github.com/transparency-dev/tessera/cmd/mtc/log/internal/mtcproof" + "github.com/transparency-dev/tessera/cmd/mtc/log/internal/subtreewitness" "github.com/transparency-dev/tessera/internal/parse" "golang.org/x/crypto/cryptobyte" "golang.org/x/crypto/cryptobyte/asn1" @@ -228,7 +229,7 @@ type MTCLog struct { origin string subtreeSigner note.SubtreeSigner logCosignerID []byte - subtreeWitnesses tessera.WitnessGroup + subtreeGateway *subtreewitness.Gateway } // AddTBSRsp contains enough information from the log @@ -435,14 +436,23 @@ func NewMTCLog(ctx context.Context, a *tessera.Appender, opts *Options) (*MTCLog return nil, fmt.Errorf("checkOriginSignerName: %v", err) } + var gateway *subtreewitness.Gateway + if len(opts.subtreeWitnesses.Components) > 0 { + gw, err := subtreewitness.New(opts.httpClient, opts.subtreeWitnesses) + if err != nil { + return nil, fmt.Errorf("creating subtree witness gateway: %w", err) + } + gateway = gw + } + l := &MTCLog{ - a: a, - reader: opts.reader, - maxCertLifetime: opts.maxCertLifetime, - origin: opts.origin, - subtreeSigner: opts.subtreeSigner, - logCosignerID: logCosignerID, - subtreeWitnesses: opts.subtreeWitnesses, + a: a, + reader: opts.reader, + maxCertLifetime: opts.maxCertLifetime, + origin: opts.origin, + subtreeSigner: opts.subtreeSigner, + logCosignerID: logCosignerID, + subtreeGateway: gateway, } cpReader, err := checkpoint.NewReader(ctx, opts.reader.ReadCheckpoint, l.getSubtreeSigs) @@ -492,10 +502,24 @@ func (l *MTCLog) getSubtreeSigs(ctx context.Context, start, end uint64, rawCp [] return nil, fmt.Errorf("cannot sign subtree [%d, %d): %v", start, end, err) } - // TODO: fetch signatures from mirrors. - return []mtcproof.SubtreeSignature{ + allSigs := []mtcproof.SubtreeSignature{ {CosignerID: l.logCosignerID, Signature: selfSig}, - }, nil + } + + if l.subtreeGateway != nil { + consProof, err := pb.SubtreeConsistencyProof(ctx, start, end) + if err != nil { + return nil, fmt.Errorf("cannot get subtree consistency proof for [%d, %d): %v", start, end, err) + } + + witnessSigs, err := l.subtreeGateway.CosignSubtree(ctx, l.origin, start, end, subRoot, consProof, rawCp) + if err != nil { + return nil, fmt.Errorf("failed to fetch subtree cosignatures for [%d, %d): %v", start, end, err) + } + allSigs = append(allSigs, witnessSigs...) + } + + return allSigs, nil } // AddTBS adds a TBSCertificateLogEntry to the log. @@ -540,6 +564,15 @@ func (l *MTCLog) AddTBS(ctx context.Context, tbs TBSCertificateLogEntry) (*AddTB if err != nil { return nil, fmt.Errorf("failed to get subtree signatures: %v", err) } + if numSigs := len(subtreeSigs); numSigs < 2 { + // TODO: this is not strictly enforced for now, for development purposes. Consider + // enforcing it. + // SPEC: [DRAFT] Chrome Quantum-resistant Root Program Policy, Version 0.3.0, Section 2.4.5. + // "Standalone certificates MUST have at least 2 cosignatures. One of these + // MUST be from the MTC CA Operator, and one MUST be from a Mirroring + // Cosigner recognized by Chrome and not operated by the MTC CA Operator." + slog.WarnContext(ctx, "collected less than 2 subtree signatures", slog.Int("num_sigs", numSigs)) + } extBytes, err := entry.ExtractExtensions(eb) if err != nil { @@ -662,7 +695,7 @@ func CreateSignerAndOrigin(caID string, logNumber uint64, privKey string) (origi return origin, s, nil } -// checkOriginSigner verifies that an origin and signer match with each other. +// checkOriginSignerName verifies that an origin and signer match with each other. // // SPEC: draft-ietf-plants-merkle-tree-certs section 5.2. // "Each issuance log has a log ID, which is a trust anchor ID constructed by concatenating the following OID components: diff --git a/cmd/mtc/log/mtc_test.go b/cmd/mtc/log/mtc_test.go index 0c33e22fa..5d5496eb2 100644 --- a/cmd/mtc/log/mtc_test.go +++ b/cmd/mtc/log/mtc_test.go @@ -18,9 +18,16 @@ import ( "bytes" "context" stdasn1 "encoding/asn1" + "encoding/base64" + "encoding/binary" "errors" "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" "os" + "strings" "sync" "testing" "time" @@ -667,11 +674,23 @@ func TestNewMTCLog(t *testing.T) { t.Fatalf("NewMLDSASigner failed: %v", err) } + _, vkey1, err := note.GenerateMLDSAKey("oid/1.3.6.1.4.1.32473.106") + if err != nil { + t.Fatalf("GenerateMLDSAKey: %v", err) + } + witURL, _ := url.Parse("http://wit1.example.com") + w1, err := tessera.NewWitness(vkey1, witURL) + if err != nil { + t.Fatalf("NewWitness: %v", err) + } + witnessGroup := tessera.NewWitnessGroup(1, w1) + tests := []struct { - name string - appender *tessera.Appender - opts *Options - wantErr bool + name string + appender *tessera.Appender + opts *Options + wantGateway bool + wantErr bool }{ { name: "valid default options (47-day certs)", @@ -699,6 +718,20 @@ func TestNewMTCLog(t *testing.T) { WithLandmarkInterval(2 * time.Hour), wantErr: false, }, + { + name: "valid with single witness policy", + appender: &tessera.Appender{}, + opts: newDummyOptions().WithSubtreeWitnesses(witnessGroup), + wantGateway: true, + wantErr: false, + }, + { + name: "valid with empty witness group", + appender: &tessera.Appender{}, + opts: newDummyOptions().WithSubtreeWitnesses(tessera.WitnessGroup{}), + wantGateway: false, + wantErr: false, + }, { name: "nil appender", appender: nil, @@ -731,8 +764,13 @@ func TestNewMTCLog(t *testing.T) { if (err != nil) != tc.wantErr { t.Fatalf("NewMTCLog() error = %v, wantErr %v", err, tc.wantErr) } - if !tc.wantErr && l == nil { - t.Fatal("NewMTCLog() returned nil instance on success") + if !tc.wantErr { + if l == nil { + t.Fatal("NewMTCLog() returned nil instance on success") + } + if got := l.subtreeGateway != nil; got != tc.wantGateway { + t.Errorf("has subtreeGateway = %v, want %v", got, tc.wantGateway) + } } }) } @@ -816,7 +854,67 @@ func unmarshalMTCProof(data []byte) (*parsedMTCProof, error) { return &p, nil } -func setupTestMTCLog(t *testing.T) *MTCLog { +func setupTestWitness(t *testing.T) (tessera.WitnessGroup, note.SubtreeVerifier) { + t.Helper() + sKey, vKey, err := note.GenerateMLDSAKey("oid/1.3.6.1.4.1.32473.106.1") + if err != nil { + t.Fatalf("GenerateMLDSAKey: %v", err) + } + signer, _ := note.NewMLDSASigner(sKey) + verifier, _ := note.NewMLDSAVerifier(vKey) + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + switch r.URL.Path { + case "/add-checkpoint": + var unverified *snote.UnverifiedNoteError + if _, err := snote.Open(body, snote.VerifierList()); !errors.As(err, &unverified) { + http.Error(w, fmt.Sprintf("invalid checkpoint note: %v", err), http.StatusBadRequest) + return + } + _, cpText, _ := strings.Cut(unverified.Note.Text, "\n\n") + signed, err := snote.Sign(&snote.Note{Text: cpText}, signer) + if err != nil { + http.Error(w, fmt.Sprintf("sign checkpoint: %v", err), http.StatusInternalServerError) + return + } + if _, err := w.Write(signed[len(cpText)+1:]); err != nil { + t.Errorf("write /add-checkpoint response: %v", err) + } + case "/sign-subtree": + lines := strings.Split(string(body), "\n") + var start, end uint64 + if _, err := fmt.Sscanf(lines[0], "subtree %d %d", &start, &end); err != nil { + http.Error(w, fmt.Sprintf("scan subtree range: %v", err), http.StatusBadRequest) + return + } + subRoot, err := base64.StdEncoding.DecodeString(lines[1]) + if err != nil { + http.Error(w, fmt.Sprintf("decode subRoot: %v", err), http.StatusBadRequest) + return + } + rawSig, err := signer.SignSubtree(0, testOrigin, start, end, subRoot) + if err != nil { + http.Error(w, fmt.Sprintf("sign subtree: %v", err), http.StatusInternalServerError) + return + } + buf := binary.BigEndian.AppendUint32(nil, signer.KeyHash()) + if _, err := fmt.Fprintf(w, "— %s %s\n", signer.Name(), base64.StdEncoding.EncodeToString(append(buf, rawSig...))); err != nil { + t.Errorf("write /sign-subtree response: %v", err) + } + } + })) + t.Cleanup(ts.Close) + + witURL, _ := url.Parse(ts.URL) + w, err := tessera.NewWitness(vKey, witURL) + if err != nil { + t.Fatalf("NewWitness: %v", err) + } + return tessera.NewWitnessGroup(1, w), verifier +} + +func setupTestMTCLog(t *testing.T) (*MTCLog, note.SubtreeVerifier) { t.Helper() ctx := t.Context() storageDir := t.TempDir() @@ -832,10 +930,13 @@ func setupTestMTCLog(t *testing.T) *MTCLog { t.Fatalf("Failed to create test signer: %v", err) } + witGroup, witVerifier := setupTestWitness(t) + opts := tessera.NewAppendOptions(). WithCheckpointSigner(signer). WithBatching(4, 500*time.Millisecond). - WithCheckpointInterval(500*time.Millisecond) + WithCheckpointInterval(500 * time.Millisecond). + WithWitnesses(witGroup, &tessera.WitnessOptions{Timeout: time.Second}) appender, _, reader, err := tessera.NewAppender(ctx, driver, opts) if err != nil { t.Fatalf("Failed to initialize Tessera appender: %v", err) @@ -847,16 +948,17 @@ func setupTestMTCLog(t *testing.T) *MTCLog { WithLandmarksStorage(dummyLandmarksStorage{}). WithMaxCertLifetime(7*24*time.Hour). WithOrigin(testOrigin). - WithSubtreeSigner(mustTestSigner())) + WithSubtreeSigner(mustTestSigner()). + WithSubtreeWitnesses(witGroup)) if err != nil { t.Fatalf("Failed to initialize MTC log: %v", err) } - return mtcLog + return mtcLog, witVerifier } func TestMTCLog_AddTBS(t *testing.T) { ctx := t.Context() - mtcLog := setupTestMTCLog(t) + mtcLog, witVerifier := setupTestMTCLog(t) now := time.Now().Truncate(time.Second) makeEntry := func(id int) TBSCertificateLogEntry { @@ -972,15 +1074,18 @@ func TestMTCLog_AddTBS(t *testing.T) { if len(subRoot) != 32 { t.Fatalf("subRoot length = %d, want 32", len(subRoot)) } - if len(proofData.Signatures) != 1 { - t.Fatalf("got %d signatures, want 1", len(proofData.Signatures)) + if len(proofData.Signatures) != 2 { + t.Fatalf("got %d signatures, want 2", len(proofData.Signatures)) } if !mtcLog.subtreeSigner.Verifier().VerifySubtree(0, mtcLog.origin, tc.wantStart, tc.wantEnd, subRoot, proofData.Signatures[0].Signature) { - t.Errorf("VerifySubtree failed for entry%d signature", tc.entryIdx) + t.Errorf("VerifySubtree failed for log signature on entry%d", tc.entryIdx) } if !bytes.Equal(proofData.Signatures[0].CosignerID, wantCosignerID) { t.Errorf("CosignerID = %x, want %x", proofData.Signatures[0].CosignerID, wantCosignerID) } + if !witVerifier.VerifySubtree(0, mtcLog.origin, tc.wantStart, tc.wantEnd, subRoot, proofData.Signatures[1].Signature) { + t.Errorf("VerifySubtree failed for witness signature on entry%d", tc.entryIdx) + } }) } } diff --git a/cmd/mtc/log/posix/main.go b/cmd/mtc/log/posix/main.go index 76b2817c1..6236b1fa1 100644 --- a/cmd/mtc/log/posix/main.go +++ b/cmd/mtc/log/posix/main.go @@ -182,6 +182,16 @@ func newAppenderFromFlags(ctx context.Context, origin string, signer note.Subtre WithGarbageCollectionInterval(*garbageCollectionInterval) if *mirrorPolicyFile != "" { + // TODO: enforce these checks at the policy level and/or when publishing checkpoints. + // + // SPEC: [DRAFT] Chrome Quantum-resistant Root Program Policy, Version 0.3.0, Section 3.1. + // "Mirroring Cosigner Keys MUST be ML-DSA-44" + // + // SPEC: [DRAFT] Chrome Quantum-resistant Root Program Policy, Version 0.3.0, Section 2.4.5. + // "In order for landmarks to be served by Chrome's Landmark Service, all + // checkpoints MUST be served with a minimum of 2 cosignatures. One of these + // MUST be from the MTC CA Operator and one MUST be from a Mirroring Cosigner + // recognized by Chrome and not operated by the MTC CA Operator." opts = opts.WithMirrors(policy, nil) } diff --git a/witness.go b/witness.go index a2908228e..cf27c0460 100644 --- a/witness.go +++ b/witness.go @@ -190,9 +190,15 @@ func isBadName(n string) bool { // NewWitness returns a Witness given a verifier key and the root URL for where this // witness can be reached. func NewWitness(vkey string, witnessRoot *url.URL) (Witness, error) { - v, err := f_note.NewVerifierForCosignatureV1(vkey) + var v note.Verifier + var err error + v, err = f_note.NewMLDSAVerifier(vkey) if err != nil { - return Witness{}, err + var v1Err error + v, v1Err = f_note.NewVerifierForCosignatureV1(vkey) + if v1Err != nil { + return Witness{}, fmt.Errorf("failed to parse verifier key %q as ML-DSA (%v) or Cosignature V1 (%w)", vkey, err, v1Err) + } } return Witness{ From e9e221beec420d1ee28a7bacf5377d0946b0857a Mon Sep 17 00:00:00 2001 From: Philippe Boneff Date: Mon, 24 Aug 2026 12:45:48 +0000 Subject: [PATCH 2/2] comments --- cmd/mtc/log/internal/subtreewitness/gateway.go | 4 +++- cmd/mtc/log/mtc.go | 2 +- cmd/mtc/log/mtc_test.go | 9 ++------- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/cmd/mtc/log/internal/subtreewitness/gateway.go b/cmd/mtc/log/internal/subtreewitness/gateway.go index 21bf4f866..2121792e5 100644 --- a/cmd/mtc/log/internal/subtreewitness/gateway.go +++ b/cmd/mtc/log/internal/subtreewitness/gateway.go @@ -38,6 +38,7 @@ import ( var ErrPolicyNotSatisfied = errors.New("witness policy was not satisfied") type witnessKey struct { + // string representation of the cosignerID name string keyHash uint32 } @@ -109,6 +110,7 @@ func New(httpClient *http.Client, policy tessera.WitnessGroup) (*Gateway, error) // checkpoint have been collected. // TODO: implement subtree cosignature policy matching directly. func (gw *Gateway) CosignSubtree(ctx context.Context, origin string, start, end uint64, subRoot []byte, consProof [][]byte, rawCp []byte) ([]mtcproof.SubtreeSignature, error) { + // TODO: consider disallowing this if empty policies are not allowed. if len(gw.witnesses) == 0 { if gw.policy.Satisfied(rawCp) { return nil, nil @@ -124,7 +126,7 @@ func (gw *Gateway) CosignSubtree(ctx context.Context, origin string, start, end } n := unverified.Note - // reconstructCp is used for policy checking. + // reconstructedCp is used for policy checking. reconstructedCp := fmt.Appendf(nil, "%s\n", n.Text) cpSigs := make(map[witnessKey]string) diff --git a/cmd/mtc/log/mtc.go b/cmd/mtc/log/mtc.go index 66dcab561..056724c1a 100644 --- a/cmd/mtc/log/mtc.go +++ b/cmd/mtc/log/mtc.go @@ -571,7 +571,7 @@ func (l *MTCLog) AddTBS(ctx context.Context, tbs TBSCertificateLogEntry) (*AddTB // "Standalone certificates MUST have at least 2 cosignatures. One of these // MUST be from the MTC CA Operator, and one MUST be from a Mirroring // Cosigner recognized by Chrome and not operated by the MTC CA Operator." - slog.WarnContext(ctx, "collected less than 2 subtree signatures", slog.Int("num_sigs", numSigs)) + slog.WarnContext(ctx, "Collected less than 2 subtree signatures", slog.Int("num_sigs", numSigs)) } extBytes, err := entry.ExtractExtensions(eb) diff --git a/cmd/mtc/log/mtc_test.go b/cmd/mtc/log/mtc_test.go index 5d5496eb2..9d6d5dcfe 100644 --- a/cmd/mtc/log/mtc_test.go +++ b/cmd/mtc/log/mtc_test.go @@ -1028,11 +1028,6 @@ func TestMTCLog_AddTBS(t *testing.T) { {name: "entry 4 in single-entry subtree [4, 5)", entryIdx: 4, wantStart: 4, wantEnd: 5, wantProofLen: 0}, } - wantCosignerID, err := mtcproof.ParseCosignerID(mtcLog.subtreeSigner.Name()) - if err != nil { - t.Fatalf("ParseCosignerID: %v", err) - } - for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { rsp := responses[tc.entryIdx] @@ -1080,8 +1075,8 @@ func TestMTCLog_AddTBS(t *testing.T) { if !mtcLog.subtreeSigner.Verifier().VerifySubtree(0, mtcLog.origin, tc.wantStart, tc.wantEnd, subRoot, proofData.Signatures[0].Signature) { t.Errorf("VerifySubtree failed for log signature on entry%d", tc.entryIdx) } - if !bytes.Equal(proofData.Signatures[0].CosignerID, wantCosignerID) { - t.Errorf("CosignerID = %x, want %x", proofData.Signatures[0].CosignerID, wantCosignerID) + if !bytes.Equal(proofData.Signatures[0].CosignerID, mtcLog.logCosignerID) { + t.Errorf("CosignerID = %x, want %x", proofData.Signatures[0].CosignerID, mtcLog.logCosignerID) } if !witVerifier.VerifySubtree(0, mtcLog.origin, tc.wantStart, tc.wantEnd, subRoot, proofData.Signatures[1].Signature) { t.Errorf("VerifySubtree failed for witness signature on entry%d", tc.entryIdx)