diff --git a/api/protos/backend_service.pb.go b/api/protos/backend_service.pb.go index c97da77..aed63cf 100644 --- a/api/protos/backend_service.pb.go +++ b/api/protos/backend_service.pb.go @@ -4,7 +4,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.32.0 -// protoc v4.25.4 +// protoc v6.32.1 // source: backend_service.proto package protos diff --git a/api/protos/history_events.pb.go b/api/protos/history_events.pb.go index 3ec6a2a..1f41e3e 100644 --- a/api/protos/history_events.pb.go +++ b/api/protos/history_events.pb.go @@ -4,7 +4,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.32.0 -// protoc v4.25.4 +// protoc v6.32.1 // source: history_events.proto package protos diff --git a/api/protos/orchestration.pb.go b/api/protos/orchestration.pb.go index fe4aa7d..b210590 100644 --- a/api/protos/orchestration.pb.go +++ b/api/protos/orchestration.pb.go @@ -4,7 +4,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.32.0 -// protoc v4.25.4 +// protoc v6.32.1 // source: orchestration.proto package protos diff --git a/api/protos/orchestrator_actions.pb.go b/api/protos/orchestrator_actions.pb.go index 10ac22f..cb1ba7f 100644 --- a/api/protos/orchestrator_actions.pb.go +++ b/api/protos/orchestrator_actions.pb.go @@ -4,7 +4,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.32.0 -// protoc v4.25.4 +// protoc v6.32.1 // source: orchestrator_actions.proto package protos diff --git a/api/protos/orchestrator_service.pb.go b/api/protos/orchestrator_service.pb.go index 268f253..a9d9bf6 100644 --- a/api/protos/orchestrator_service.pb.go +++ b/api/protos/orchestrator_service.pb.go @@ -4,7 +4,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.32.0 -// protoc v4.25.4 +// protoc v6.32.1 // source: orchestrator_service.proto package protos diff --git a/api/protos/orchestrator_service_grpc.pb.go b/api/protos/orchestrator_service_grpc.pb.go index 880ab23..d613121 100644 --- a/api/protos/orchestrator_service_grpc.pb.go +++ b/api/protos/orchestrator_service_grpc.pb.go @@ -4,7 +4,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.3.0 -// - protoc v4.25.4 +// - protoc v6.32.1 // source: orchestrator_service.proto package protos diff --git a/api/protos/runtime_state.pb.go b/api/protos/runtime_state.pb.go index 1f6f31a..9aabf12 100644 --- a/api/protos/runtime_state.pb.go +++ b/api/protos/runtime_state.pb.go @@ -13,7 +13,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.32.0 -// protoc v4.25.4 +// protoc v6.32.1 // source: runtime_state.proto package protos diff --git a/backend/backend.go b/backend/backend.go index af09cf5..2deb864 100644 --- a/backend/backend.go +++ b/backend/backend.go @@ -32,6 +32,8 @@ type ( DurableTimer = protos.DurableTimer WorkflowRuntimeState = protos.WorkflowRuntimeState WorkflowRuntimeStateMessage = protos.WorkflowRuntimeStateMessage + SigningCertificate = protos.SigningCertificate + HistorySignature = protos.HistorySignature RerunWorkflowFromEventRequest = protos.RerunWorkflowFromEventRequest ListInstanceIDsRequest = protos.ListInstanceIDsRequest ListInstanceIDsResponse = protos.ListInstanceIDsResponse diff --git a/backend/executor.go b/backend/executor.go index aa07b1d..1fe1607 100644 --- a/backend/executor.go +++ b/backend/executor.go @@ -295,7 +295,7 @@ func (g *grpcExecutor) GetWorkItems(req *protos.GetWorkItemsRequest, stream prot g.logger.Warnf("error while disconnecting work item stream: %v", derr) } - return status.Errorf(codes.Unavailable, message) + return status.Errorf(codes.Unavailable, "%s", message) } defer func() { diff --git a/backend/historysigning/canonical.go b/backend/historysigning/canonical.go new file mode 100644 index 0000000..73fe52a --- /dev/null +++ b/backend/historysigning/canonical.go @@ -0,0 +1,66 @@ +/* +Copyright 2026 The Dapr Authors +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 historysigning + +import ( + "crypto/sha256" + "encoding/binary" + + "google.golang.org/protobuf/proto" + + "github.com/dapr/durabletask-go/api/protos" +) + +// MarshalEvent deterministically marshals a HistoryEvent to bytes. +// The output is stable for a given message within the same binary, making it +// suitable for signing. Events should be marshaled once and the resulting +// bytes used for both signing and persistence. +func MarshalEvent(event *protos.HistoryEvent) ([]byte, error) { + return proto.MarshalOptions{Deterministic: true}.Marshal(event) +} + +// EventsDigest computes the SHA-256 digest of pre-marshaled history event +// bytes. Each event is length-prefixed (big-endian uint64) before being +// written to the hash, preventing ambiguity from concatenation. +// The raw bytes should come from MarshalEvent (at sign time) or directly +// from the state store (at verification time). +func EventsDigest(rawEvents [][]byte) []byte { + h := sha256.New() + var lenBuf [8]byte + for _, b := range rawEvents { + binary.BigEndian.PutUint64(lenBuf[:], uint64(len(b))) + h.Write(lenBuf[:]) + h.Write(b) + } + return h.Sum(nil) +} + +// SignatureDigest computes the SHA-256 digest of a raw serialized +// HistorySignature message. This is the value used in +// previousSignatureDigest chaining. The rawSig bytes must be the exact +// bytes as persisted to the state store — never re-marshal a Go struct, +// as protobuf deterministic marshaling is not stable across binary versions. +func SignatureDigest(rawSig []byte) []byte { + d := sha256.Sum256(rawSig) + return d[:] +} + +// SignatureInput computes the input to the cryptographic signing operation: +// SHA-256(previousSignatureDigest || eventsDigest). +func SignatureInput(previousSignatureDigest, eventsDigest []byte) []byte { + h := sha256.New() + h.Write(previousSignatureDigest) + h.Write(eventsDigest) + return h.Sum(nil) +} diff --git a/backend/historysigning/historysigning_test.go b/backend/historysigning/historysigning_test.go new file mode 100644 index 0000000..dde77b9 --- /dev/null +++ b/backend/historysigning/historysigning_test.go @@ -0,0 +1,1199 @@ +/* +Copyright 2026 The Dapr Authors +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 historysigning + +import ( + "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "errors" + "math/big" + "net/url" + "testing" + "time" + + "github.com/spiffe/go-spiffe/v2/svid/x509svid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/encoding/protowire" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" + "google.golang.org/protobuf/types/known/wrapperspb" + + "github.com/dapr/durabletask-go/api/protos" + "github.com/dapr/kit/crypto/spiffe/signer" + "github.com/dapr/kit/crypto/spiffe/trustanchors/fake" +) + +func newTestSigner(t *testing.T, certDER []byte, key crypto.Signer, authorities ...*x509.Certificate) *signer.Signer { + t.Helper() + certs, err := x509.ParseCertificates(certDER) + require.NoError(t, err) + id, err := x509svid.IDFromCert(certs[0]) + require.NoError(t, err) + source := &staticSVIDSource{svid: &x509svid.SVID{ + ID: id, + Certificates: certs, + PrivateKey: key, + }} + return signer.New(source, fake.New(authorities...)) +} + +// staticSVIDSource is a test implementation of x509svid.Source that returns +// a fixed SVID. +type staticSVIDSource struct { + svid *x509svid.SVID + err error +} + +func (s *staticSVIDSource) GetX509SVID() (*x509svid.SVID, error) { + return s.svid, s.err +} + +func parseCert(t *testing.T, der []byte) *x509.Certificate { + t.Helper() + cert, err := x509.ParseCertificate(der) + require.NoError(t, err) + return cert +} + +func testEvents() []*protos.HistoryEvent { + return []*protos.HistoryEvent{ + { + EventId: 0, + Timestamp: timestamppb.New(time.Date(2026, 3, 18, 12, 0, 0, 0, time.UTC)), + EventType: &protos.HistoryEvent_WorkflowStarted{ + WorkflowStarted: &protos.WorkflowStartedEvent{}, + }, + }, + { + EventId: 1, + Timestamp: timestamppb.New(time.Date(2026, 3, 18, 12, 0, 1, 0, time.UTC)), + EventType: &protos.HistoryEvent_ExecutionStarted{ + ExecutionStarted: &protos.ExecutionStartedEvent{ + Name: "TestWorkflow", + Input: wrapperspb.String(`{"key":"value"}`), + WorkflowInstance: &protos.WorkflowInstance{ + InstanceId: "test-instance-1", + ExecutionId: wrapperspb.String("exec-1"), + }, + }, + }, + }, + { + EventId: 2, + Timestamp: timestamppb.New(time.Date(2026, 3, 18, 12, 0, 2, 0, time.UTC)), + EventType: &protos.HistoryEvent_TaskScheduled{ + TaskScheduled: &protos.TaskScheduledEvent{ + Name: "MyActivity", + Input: wrapperspb.String(`"hello"`), + }, + }, + }, + } +} + +func marshalEvents(t *testing.T, events []*protos.HistoryEvent) [][]byte { + t.Helper() + raw := make([][]byte, len(events)) + for i, e := range events { + b, err := MarshalEvent(e) + require.NoError(t, err) + raw[i] = b + } + return raw +} + +// testCertValidity returns a validity window that covers the timestamps +// used by testEvents (2026-03-18). +func testCertValidity() (notBefore, notAfter time.Time) { + return time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC) +} + +func generateEd25519Cert(t *testing.T) ([]byte, ed25519.PrivateKey) { + t.Helper() + nb, na := testCertValidity() + return generateEd25519CertWithValidity(t, nb, na) +} + +func generateECDSACert(t *testing.T) ([]byte, *ecdsa.PrivateKey) { + t.Helper() + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + nb, na := testCertValidity() + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test"}, + NotBefore: nb, + NotAfter: na, + URIs: []*url.URL{{Scheme: "spiffe", Host: "example.org", Path: "/ns/default/app-b"}}, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv) + require.NoError(t, err) + + return certDER, priv +} + +func generateRSACert(t *testing.T) ([]byte, *rsa.PrivateKey) { + t.Helper() + priv, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + nb, na := testCertValidity() + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test"}, + NotBefore: nb, + NotAfter: na, + URIs: []*url.URL{{Scheme: "spiffe", Host: "example.org", Path: "/ns/default/app-c"}}, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &priv.PublicKey, priv) + require.NoError(t, err) + + return certDER, priv +} + +func TestEventsDigestDeterminism(t *testing.T) { + events := testEvents() + raw := marshalEvents(t, events) + + d1 := EventsDigest(raw) + d2 := EventsDigest(raw) + + assert.Equal(t, d1, d2, "digest must be deterministic") +} + +func TestEventsDigestDiffersOnChange(t *testing.T) { + events := testEvents() + d1 := EventsDigest(marshalEvents(t, events)) + + // Mutate an event + events[1].GetExecutionStarted().Name = "DifferentWorkflow" + d2 := EventsDigest(marshalEvents(t, events)) + + assert.NotEqual(t, d1, d2, "digest must change when events change") +} + +func TestSignAndVerifyEd25519(t *testing.T) { + certDER, priv := generateEd25519Cert(t) + events := testEvents() + raw := marshalEvents(t, events) + + tc := newTestSigner(t, certDER, priv, parseCert(t, certDER)) + + result, err := Sign(tc, SignOptions{ + RawEvents: raw, + StartEventIndex: 0, + ExistingCerts: nil, + }) + require.NoError(t, err) + require.NotNil(t, result.NewCert) + assert.Equal(t, uint64(0), result.CertificateIndex) + + // Verify + certs := []*protos.SigningCertificate{result.NewCert} + err = VerifySignature(tc, result.Signature, certs, raw) + require.NoError(t, err) +} + +func TestSignAndVerifyECDSA(t *testing.T) { + certDER, priv := generateECDSACert(t) + events := testEvents() + raw := marshalEvents(t, events) + + tc := newTestSigner(t, certDER, priv, parseCert(t, certDER)) + + result, err := Sign(tc, SignOptions{ + RawEvents: raw, + StartEventIndex: 0, + ExistingCerts: nil, + }) + require.NoError(t, err) + + certs := []*protos.SigningCertificate{result.NewCert} + err = VerifySignature(tc, result.Signature, certs, raw) + require.NoError(t, err) +} + +func TestSignAndVerifyRSA(t *testing.T) { + certDER, priv := generateRSACert(t) + events := testEvents() + raw := marshalEvents(t, events) + + tc := newTestSigner(t, certDER, priv, parseCert(t, certDER)) + + result, err := Sign(tc, SignOptions{ + RawEvents: raw, + StartEventIndex: 0, + ExistingCerts: nil, + }) + require.NoError(t, err) + + certs := []*protos.SigningCertificate{result.NewCert} + err = VerifySignature(tc, result.Signature, certs, raw) + require.NoError(t, err) +} + +func TestSignChainAndVerify(t *testing.T) { + certDER, priv := generateEd25519Cert(t) + events := testEvents() + raw := marshalEvents(t, events) + + tc := newTestSigner(t, certDER, priv, parseCert(t, certDER)) + + // Sign first 2 events + result1, err := Sign(tc, SignOptions{ + RawEvents: raw[:2], + StartEventIndex: 0, + ExistingCerts: nil, + }) + require.NoError(t, err) + + certs := []*protos.SigningCertificate{result1.NewCert} + + // Sign remaining event, chained to first + result2, err := Sign(tc, SignOptions{ + RawEvents: raw[2:], + StartEventIndex: 2, + PreviousSignatureRaw: result1.RawSignature, + ExistingCerts: certs, + }) + require.NoError(t, err) + assert.Nil(t, result2.NewCert, "cert should be reused") + assert.Equal(t, uint64(0), result2.CertificateIndex) + + // Verify chain + rawSigs := [][]byte{result1.RawSignature, result2.RawSignature} + err = VerifyChain(VerifyChainOptions{RawSignatures: rawSigs, Certs: certs, AllRawEvents: raw, Signer: tc}) + require.NoError(t, err) +} + +func TestCertificateRotation(t *testing.T) { + certDER1, priv1 := generateEd25519Cert(t) + certDER2, priv2 := generateEd25519Cert(t) + events := testEvents() + raw := marshalEvents(t, events) + + cert1 := parseCert(t, certDER1) + cert2 := parseCert(t, certDER2) + tc1 := newTestSigner(t, certDER1, priv1, cert1, cert2) + + // Sign with first cert + result1, err := Sign(tc1, SignOptions{ + RawEvents: raw[:2], + StartEventIndex: 0, + ExistingCerts: nil, + }) + require.NoError(t, err) + require.NotNil(t, result1.NewCert) + + certs := []*protos.SigningCertificate{result1.NewCert} + + // Sign with second cert (rotation) + tc2 := newTestSigner(t, certDER2, priv2, cert1, cert2) + + result2, err := Sign(tc2, SignOptions{ + RawEvents: raw[2:], + StartEventIndex: 2, + PreviousSignatureRaw: result1.RawSignature, + ExistingCerts: certs, + }) + require.NoError(t, err) + require.NotNil(t, result2.NewCert, "new cert should be added on rotation") + assert.Equal(t, uint64(1), result2.CertificateIndex) + + certs = append(certs, result2.NewCert) + + // Verify chain — use tc2 which has both CAs in its trust bundle. + rawSigs := [][]byte{result1.RawSignature, result2.RawSignature} + err = VerifyChain(VerifyChainOptions{RawSignatures: rawSigs, Certs: certs, AllRawEvents: raw, Signer: tc2}) + require.NoError(t, err) +} + +func TestTamperedHistoryDetection(t *testing.T) { + certDER, priv := generateEd25519Cert(t) + events := testEvents() + raw := marshalEvents(t, events) + + tc := newTestSigner(t, certDER, priv, parseCert(t, certDER)) + + result, err := Sign(tc, SignOptions{ + RawEvents: raw, + StartEventIndex: 0, + ExistingCerts: nil, + }) + require.NoError(t, err) + + certs := []*protos.SigningCertificate{result.NewCert} + + // Tamper with an event's raw bytes + events[1].GetExecutionStarted().Name = "TamperedWorkflow" + raw[1], err = MarshalEvent(events[1]) + require.NoError(t, err) + + err = VerifySignature(tc, result.Signature, certs, raw) + require.Error(t, err) + assert.Contains(t, err.Error(), "events digest mismatch") +} + +func TestTruncatedChainDetection(t *testing.T) { + certDER, priv := generateEd25519Cert(t) + events := testEvents() + raw := marshalEvents(t, events) + + tc := newTestSigner(t, certDER, priv, parseCert(t, certDER)) + + result1, err := Sign(tc, SignOptions{ + RawEvents: raw[:2], + StartEventIndex: 0, + ExistingCerts: nil, + }) + require.NoError(t, err) + + certs := []*protos.SigningCertificate{result1.NewCert} + + result2, err := Sign(tc, SignOptions{ + RawEvents: raw[2:], + StartEventIndex: 2, + PreviousSignatureRaw: result1.RawSignature, + ExistingCerts: certs, + }) + require.NoError(t, err) + + // Try to verify chain with first signature removed — fails because + // the second signature has a non-nil previousSignatureDigest at index 0. + rawSigs := [][]byte{result2.RawSignature} + err = VerifyChain(VerifyChainOptions{RawSignatures: rawSigs, Certs: certs, AllRawEvents: raw, Signer: tc}) + require.Error(t, err) + assert.Contains(t, err.Error(), "root signature") +} + +func TestWrongCertificateIndex(t *testing.T) { + // Sign with cert A, then verify with cert B at the same index. + // The cryptographic verification should fail because the public key + // doesn't match. + certDER1, priv1 := generateEd25519Cert(t) + certDER2, _ := generateEd25519Cert(t) + + events := testEvents() + raw := marshalEvents(t, events) + + cert1 := parseCert(t, certDER1) + cert2 := parseCert(t, certDER2) + tc := newTestSigner(t, certDER1, priv1, cert1, cert2) + + result, err := Sign(tc, SignOptions{ + RawEvents: raw, + StartEventIndex: 0, + ExistingCerts: nil, + }) + require.NoError(t, err) + + // Replace the certificate at index 0 with a different cert's DER. + // The signature was produced with cert1's key, but we tell the verifier + // that the cert at index 0 is cert2. + wrongCerts := []*protos.SigningCertificate{{Certificate: certDER2}} + err = VerifySignature(tc, result.Signature, wrongCerts, raw) + require.Error(t, err) + assert.Contains(t, err.Error(), "signature verification failed") +} + +func generateEd25519CertWithValidity(t *testing.T, notBefore, notAfter time.Time) ([]byte, ed25519.PrivateKey) { + t.Helper() + pub, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "test"}, + NotBefore: notBefore, + NotAfter: notAfter, + URIs: []*url.URL{{Scheme: "spiffe", Host: "example.org", Path: "/ns/default/app-a"}}, + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, pub, priv) + require.NoError(t, err) + + return certDER, priv +} + +func TestCertificateExpiredAtEventTime(t *testing.T) { + // Certificate expired before events were created. + certDER, priv := generateEd25519CertWithValidity(t, + time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), + time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC), + ) + + events := testEvents() // events have timestamps in 2026 + raw := marshalEvents(t, events) + + tc := newTestSigner(t, certDER, priv, parseCert(t, certDER)) + + result, err := Sign(tc, SignOptions{ + RawEvents: raw, + StartEventIndex: 0, + }) + require.NoError(t, err) + + certs := []*protos.SigningCertificate{result.NewCert} + err = VerifySignature(tc, result.Signature, certs, raw) + require.Error(t, err) + assert.Contains(t, err.Error(), "certificate not valid at event time") +} + +func TestCertificateNotYetValidAtEventTime(t *testing.T) { + // Certificate validity starts after the events were created. + certDER, priv := generateEd25519CertWithValidity(t, + time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC), + time.Date(2031, 1, 1, 0, 0, 0, 0, time.UTC), + ) + + events := testEvents() // events have timestamps in 2026 + raw := marshalEvents(t, events) + + tc := newTestSigner(t, certDER, priv, parseCert(t, certDER)) + + result, err := Sign(tc, SignOptions{ + RawEvents: raw, + StartEventIndex: 0, + }) + require.NoError(t, err) + + certs := []*protos.SigningCertificate{result.NewCert} + err = VerifySignature(tc, result.Signature, certs, raw) + require.Error(t, err) + assert.Contains(t, err.Error(), "certificate not valid at event time") +} + +func TestCertificateValidAtEventTime(t *testing.T) { + // Certificate validity window covers the event timestamps. + certDER, priv := generateEd25519CertWithValidity(t, + time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC), + ) + + events := testEvents() // events have timestamps in 2026 + raw := marshalEvents(t, events) + + tc := newTestSigner(t, certDER, priv, parseCert(t, certDER)) + + result, err := Sign(tc, SignOptions{ + RawEvents: raw, + StartEventIndex: 0, + }) + require.NoError(t, err) + + certs := []*protos.SigningCertificate{result.NewCert} + err = VerifySignature(tc, result.Signature, certs, raw) + require.NoError(t, err) +} + +func TestSignatureDigestDeterminism(t *testing.T) { + rawSig := []byte{1, 2, 3, 4, 5, 6, 7, 8, 9} + + d1 := SignatureDigest(rawSig) + d2 := SignatureDigest(rawSig) + assert.Equal(t, d1, d2) +} + +func TestEventsDigestWithMapFields(t *testing.T) { + events := []*protos.HistoryEvent{ + { + EventId: 0, + Timestamp: timestamppb.New(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)), + EventType: &protos.HistoryEvent_ExecutionStarted{ + ExecutionStarted: &protos.ExecutionStartedEvent{ + Name: "test", + Tags: map[string]string{ + "zebra": "z", + "alpha": "a", + "middle": "m", + }, + }, + }, + }, + } + + raw := marshalEvents(t, events) + d1 := EventsDigest(raw) + + // Re-marshal and check determinism + raw2 := marshalEvents(t, events) + d2 := EventsDigest(raw2) + assert.Equal(t, d1, d2) +} + +func TestEventsDigestIncludesUnknownFields(t *testing.T) { + // Simulate forward compatibility: an event with an unknown field + // (from a newer proto version) must be included in the digest. + event := &protos.HistoryEvent{ + EventId: 5, + Timestamp: timestamppb.New(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)), + EventType: &protos.HistoryEvent_WorkflowStarted{ + WorkflowStarted: &protos.WorkflowStartedEvent{}, + }, + } + + raw, err := MarshalEvent(event) + require.NoError(t, err) + + d1 := EventsDigest([][]byte{raw}) + + // Append an unknown field (field 999, varint 42) to the raw bytes. + // This simulates what the state store would contain if a newer binary + // wrote an event with a field this binary doesn't know about. + tampered := make([]byte, len(raw)) + copy(tampered, raw) + tampered = protowire.AppendTag(tampered, 999, protowire.VarintType) + tampered = protowire.AppendVarint(tampered, 42) + + d2 := EventsDigest([][]byte{tampered}) + assert.NotEqual(t, d1, d2, "unknown fields must affect the digest") +} + +func TestRawBytesRoundTrip(t *testing.T) { + // Verify that signing raw bytes and then verifying the same raw bytes + // works even after the events are deserialized and re-serialized + // (as long as deterministic marshaling is used). + certDER, priv := generateEd25519Cert(t) + events := testEvents() + raw := marshalEvents(t, events) + + tc := newTestSigner(t, certDER, priv, parseCert(t, certDER)) + + result, err := Sign(tc, SignOptions{ + RawEvents: raw, + StartEventIndex: 0, + }) + require.NoError(t, err) + + // Simulate read from store: unmarshal then re-marshal deterministically + roundTripped := make([][]byte, len(raw)) + for i, b := range raw { + var e protos.HistoryEvent + require.NoError(t, proto.Unmarshal(b, &e)) + roundTripped[i], err = MarshalEvent(&e) + require.NoError(t, err) + } + + certs := []*protos.SigningCertificate{result.NewCert} + err = VerifySignature(tc, result.Signature, certs, roundTripped) + require.NoError(t, err) +} + +// generateCACert creates a self-signed CA certificate and returns its DER +// bytes, parsed certificate, and private key. +func generateCACert(t *testing.T) ([]byte, *x509.Certificate, ed25519.PrivateKey) { + t.Helper() + pub, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + nb, na := testCertValidity() + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "Test CA"}, + NotBefore: nb, + NotAfter: na, + IsCA: true, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign, + } + + caDER, err := x509.CreateCertificate(rand.Reader, template, template, pub, priv) + require.NoError(t, err) + + ca, err := x509.ParseCertificate(caDER) + require.NoError(t, err) + + return caDER, ca, priv +} + +// generateLeafCertSignedByCA creates a leaf certificate signed by the given CA. +func generateLeafCertSignedByCA(t *testing.T, ca *x509.Certificate, caKey ed25519.PrivateKey) ([]byte, ed25519.PrivateKey) { + t.Helper() + pub, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + nb, na := testCertValidity() + template := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "test leaf"}, + NotBefore: nb, + NotAfter: na, + URIs: []*url.URL{{Scheme: "spiffe", Host: "example.org", Path: "/ns/default/app-a"}}, + } + + leafDER, err := x509.CreateCertificate(rand.Reader, template, ca, pub, caKey) + require.NoError(t, err) + + return leafDER, priv +} + +func TestSignAndVerifyWithCertChain(t *testing.T) { + // Create CA and leaf signed by CA. + caDER, ca, caKey := generateCACert(t) + leafDER, leafPriv := generateLeafCertSignedByCA(t, ca, caKey) + + // Build chain: leaf + CA concatenated DER. + chainDER := append(leafDER, caDER...) + + events := testEvents() + raw := marshalEvents(t, events) + + tc := newTestSigner(t, chainDER, leafPriv, ca) + + result, err := Sign(tc, SignOptions{ + RawEvents: raw, + StartEventIndex: 0, + }) + require.NoError(t, err) + require.NotNil(t, result.NewCert) + + // The stored certificate should be the full chain. + assert.Equal(t, chainDER, result.NewCert.GetCertificate()) + + // Verification should succeed — parseCertificateChainDER extracts the leaf. + certs := []*protos.SigningCertificate{result.NewCert} + err = VerifySignature(tc, result.Signature, certs, raw) + require.NoError(t, err) +} + +func TestSignChainVerifyWithCertChain(t *testing.T) { + // Full signing chain with certificate chains (leaf+CA). + caDER, ca, caKey := generateCACert(t) + leafDER, leafPriv := generateLeafCertSignedByCA(t, ca, caKey) + chainDER := append(leafDER, caDER...) + + events := testEvents() + raw := marshalEvents(t, events) + + tc := newTestSigner(t, chainDER, leafPriv, ca) + + // Sign first batch. + result1, err := Sign(tc, SignOptions{ + RawEvents: raw[:2], + StartEventIndex: 0, + }) + require.NoError(t, err) + + certs := []*protos.SigningCertificate{result1.NewCert} + + // Sign second batch chained to first — cert should be reused. + result2, err := Sign(tc, SignOptions{ + RawEvents: raw[2:], + StartEventIndex: 2, + PreviousSignatureRaw: result1.RawSignature, + ExistingCerts: certs, + }) + require.NoError(t, err) + assert.Nil(t, result2.NewCert, "cert chain should be reused") + assert.Equal(t, uint64(0), result2.CertificateIndex) + + rawSigs := [][]byte{result1.RawSignature, result2.RawSignature} + err = VerifyChain(VerifyChainOptions{RawSignatures: rawSigs, Certs: certs, AllRawEvents: raw, Signer: tc}) + require.NoError(t, err) +} + +func TestCertificateRotationWithChains(t *testing.T) { + // Two different CAs, each signing a leaf. Simulate rotation between them. + caDER1, ca1, caKey1 := generateCACert(t) + leafDER1, leafPriv1 := generateLeafCertSignedByCA(t, ca1, caKey1) + chainDER1 := append(leafDER1, caDER1...) + + caDER2, ca2, caKey2 := generateCACert(t) + leafDER2, leafPriv2 := generateLeafCertSignedByCA(t, ca2, caKey2) + chainDER2 := append(leafDER2, caDER2...) + + events := testEvents() + raw := marshalEvents(t, events) + + tc1 := newTestSigner(t, chainDER1, leafPriv1, ca1, ca2) + + result1, err := Sign(tc1, SignOptions{ + RawEvents: raw[:2], + StartEventIndex: 0, + }) + require.NoError(t, err) + require.NotNil(t, result1.NewCert) + + certs := []*protos.SigningCertificate{result1.NewCert} + + // Rotate to second identity. + tc2 := newTestSigner(t, chainDER2, leafPriv2, ca1, ca2) + + result2, err := Sign(tc2, SignOptions{ + RawEvents: raw[2:], + StartEventIndex: 2, + PreviousSignatureRaw: result1.RawSignature, + ExistingCerts: certs, + }) + require.NoError(t, err) + require.NotNil(t, result2.NewCert, "rotation should produce a new cert entry") + assert.Equal(t, uint64(1), result2.CertificateIndex) + + certs = append(certs, result2.NewCert) + + rawSigs := [][]byte{result1.RawSignature, result2.RawSignature} + err = VerifyChain(VerifyChainOptions{RawSignatures: rawSigs, Certs: certs, AllRawEvents: raw, Signer: tc2}) + require.NoError(t, err) +} + +func TestSignWithIntermediateCertChain(t *testing.T) { + // Root CA -> Intermediate CA -> Leaf, stored as leaf+intermediate+root. + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + nb, na := testCertValidity() + rootTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "Root CA"}, + NotBefore: nb, + NotAfter: na, + IsCA: true, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign, + } + rootDER, err := x509.CreateCertificate(rand.Reader, rootTemplate, rootTemplate, rootPub, rootPriv) + require.NoError(t, err) + rootCert, err := x509.ParseCertificate(rootDER) + require.NoError(t, err) + + intermPub, intermPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + intermTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "Intermediate CA"}, + NotBefore: nb, + NotAfter: na, + IsCA: true, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign, + } + intermDER, err := x509.CreateCertificate(rand.Reader, intermTemplate, rootCert, intermPub, rootPriv) + require.NoError(t, err) + intermCert, err := x509.ParseCertificate(intermDER) + require.NoError(t, err) + + leafPub, leafPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + leafTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(3), + Subject: pkix.Name{CommonName: "leaf"}, + NotBefore: nb, + NotAfter: na, + URIs: []*url.URL{{Scheme: "spiffe", Host: "example.org", Path: "/ns/default/app-a"}}, + } + leafDER, err := x509.CreateCertificate(rand.Reader, leafTemplate, intermCert, leafPub, intermPriv) + require.NoError(t, err) + + // Chain: leaf + intermediate + root + var chainDER []byte + chainDER = append(chainDER, leafDER...) + chainDER = append(chainDER, intermDER...) + chainDER = append(chainDER, rootDER...) + + events := testEvents() + raw := marshalEvents(t, events) + + tc := newTestSigner(t, chainDER, leafPriv, rootCert) + + result, err := Sign(tc, SignOptions{ + RawEvents: raw, + StartEventIndex: 0, + }) + require.NoError(t, err) + require.NotNil(t, result.NewCert) + + // Verify — the leaf's public key should be used for verification. + certs := []*protos.SigningCertificate{result.NewCert} + err = VerifySignature(tc, result.Signature, certs, raw) + require.NoError(t, err) +} + +func TestVerifyChainContiguityGap(t *testing.T) { + certDER, priv := generateEd25519Cert(t) + events := testEvents() + raw := marshalEvents(t, events) + + tc := newTestSigner(t, certDER, priv, parseCert(t, certDER)) + + // Sign events [0,1) and [2,3) — skipping event 1. + result1, err := Sign(tc, SignOptions{ + RawEvents: raw[:1], + StartEventIndex: 0, + }) + require.NoError(t, err) + + certs := []*protos.SigningCertificate{result1.NewCert} + + result2, err := Sign(tc, SignOptions{ + RawEvents: raw[2:3], + StartEventIndex: 2, // gap: should be 1 + PreviousSignatureRaw: result1.RawSignature, + ExistingCerts: certs, + }) + require.NoError(t, err) + + rawSigs := [][]byte{result1.RawSignature, result2.RawSignature} + err = VerifyChain(VerifyChainOptions{RawSignatures: rawSigs, Certs: certs, AllRawEvents: raw, Signer: tc}) + require.Error(t, err) + assert.Contains(t, err.Error(), "expected start event index") +} + +func TestVerifyChainCoverageShort(t *testing.T) { + certDER, priv := generateEd25519Cert(t) + events := testEvents() + raw := marshalEvents(t, events) + + tc := newTestSigner(t, certDER, priv, parseCert(t, certDER)) + + // Only sign the first 2 events, but pass all 3 to VerifyChain. + result, err := Sign(tc, SignOptions{ + RawEvents: raw[:2], + StartEventIndex: 0, + }) + require.NoError(t, err) + + certs := []*protos.SigningCertificate{result.NewCert} + rawSigs := [][]byte{result.RawSignature} + err = VerifyChain(VerifyChainOptions{RawSignatures: rawSigs, Certs: certs, AllRawEvents: raw, Signer: tc}) + require.Error(t, err) + assert.Contains(t, err.Error(), "signatures cover events") +} + +func TestVerifyChainEmptyNoEvents(t *testing.T) { + err := VerifyChain(VerifyChainOptions{}) + require.NoError(t, err) +} + +func TestVerifyChainEmptyWithEvents(t *testing.T) { + err := VerifyChain(VerifyChainOptions{AllRawEvents: [][]byte{{1}}}) + require.Error(t, err) + assert.Contains(t, err.Error(), "no signatures but") +} + +func TestVerifyChainWithTrustAnchors(t *testing.T) { + caDER, ca, caKey := generateCACert(t) + leafDER, leafPriv := generateLeafCertSignedByCA(t, ca, caKey) + chainDER := append(leafDER, caDER...) + + events := testEvents() + raw := marshalEvents(t, events) + + tc := newTestSigner(t, chainDER, leafPriv, ca) + + result, err := Sign(tc, SignOptions{ + RawEvents: raw, + StartEventIndex: 0, + }) + require.NoError(t, err) + + certs := []*protos.SigningCertificate{result.NewCert} + rawSigs := [][]byte{result.RawSignature} + + // Verify with the correct CA as trust anchor — should pass. + err = VerifyChain(VerifyChainOptions{ + RawSignatures: rawSigs, + Certs: certs, + AllRawEvents: raw, + Signer: tc, + }) + require.NoError(t, err) +} + +func TestVerifyChainWithWrongTrustAnchor(t *testing.T) { + caDER, ca, caKey := generateCACert(t) + leafDER, leafPriv := generateLeafCertSignedByCA(t, ca, caKey) + chainDER := append(leafDER, caDER...) + + // Create a different CA that did NOT sign the leaf. + _, wrongCA, _ := generateCACert(t) + + events := testEvents() + raw := marshalEvents(t, events) + + // Use the correct trust bundle for signing, but wrong one for verification. + tcSign := newTestSigner(t, chainDER, leafPriv, ca) + + result, err := Sign(tcSign, SignOptions{ + RawEvents: raw, + StartEventIndex: 0, + }) + require.NoError(t, err) + + certs := []*protos.SigningCertificate{result.NewCert} + rawSigs := [][]byte{result.RawSignature} + + // Verify with the wrong CA — should fail. + tcVerify := newTestSigner(t, chainDER, leafPriv, wrongCA) + err = VerifyChain(VerifyChainOptions{ + RawSignatures: rawSigs, + Certs: certs, + AllRawEvents: raw, + Signer: tcVerify, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "chain-of-trust verification failed for certificate index 0") +} + +func TestVerifyChainWithIntermediateAndTrustAnchor(t *testing.T) { + // Root CA -> Intermediate -> Leaf, trust anchor is root. + rootPub, rootPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + nb, na := testCertValidity() + rootTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "Root CA"}, + NotBefore: nb, + NotAfter: na, + IsCA: true, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign, + } + rootDER, err := x509.CreateCertificate(rand.Reader, rootTemplate, rootTemplate, rootPub, rootPriv) + require.NoError(t, err) + rootCert, err := x509.ParseCertificate(rootDER) + require.NoError(t, err) + + intermPub, intermPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + intermTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "Intermediate"}, + NotBefore: nb, + NotAfter: na, + IsCA: true, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign, + } + intermDER, err := x509.CreateCertificate(rand.Reader, intermTemplate, rootCert, intermPub, rootPriv) + require.NoError(t, err) + intermCert, err := x509.ParseCertificate(intermDER) + require.NoError(t, err) + + leafPub, leafPriv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + leafTemplate := &x509.Certificate{ + SerialNumber: big.NewInt(3), + Subject: pkix.Name{CommonName: "leaf"}, + NotBefore: nb, + NotAfter: na, + URIs: []*url.URL{{Scheme: "spiffe", Host: "example.org", Path: "/ns/default/app-a"}}, + } + leafDER, err := x509.CreateCertificate(rand.Reader, leafTemplate, intermCert, leafPub, intermPriv) + require.NoError(t, err) + + // Chain: leaf + intermediate (root is the trust anchor, not in chain) + var chainDER []byte + chainDER = append(chainDER, leafDER...) + chainDER = append(chainDER, intermDER...) + + events := testEvents() + raw := marshalEvents(t, events) + + tc := newTestSigner(t, chainDER, leafPriv, rootCert) + + result, err := Sign(tc, SignOptions{ + RawEvents: raw, + StartEventIndex: 0, + }) + require.NoError(t, err) + + certs := []*protos.SigningCertificate{result.NewCert} + rawSigs := [][]byte{result.RawSignature} + + // Verify with root as trust anchor — should pass via intermediate chain. + err = VerifyChain(VerifyChainOptions{ + RawSignatures: rawSigs, + Certs: certs, + AllRawEvents: raw, + Signer: tc, + }) + require.NoError(t, err) +} + +func TestSignErrors(t *testing.T) { + events := testEvents() + raw := marshalEvents(t, events) + opts := SignOptions{ + RawEvents: raw, + StartEventIndex: 0, + } + + t.Run("source error", func(t *testing.T) { + // SVID source that returns an error. + source := &staticSVIDSource{err: errors.New("svid unavailable")} + s := signer.New(source, nil) + _, err := Sign(s, opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to sign") + }) + + t.Run("no certificates", func(t *testing.T) { + // SVID source that returns no certificates. + source := &staticSVIDSource{svid: &x509svid.SVID{ + Certificates: nil, + PrivateKey: ed25519.NewKeyFromSeed(make([]byte, 32)), + }} + s := signer.New(source, nil) + _, err := Sign(s, opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "no certificates") + }) + + t.Run("empty raw events", func(t *testing.T) { + certDER, priv := generateEd25519Cert(t) + tc := newTestSigner(t, certDER, priv, parseCert(t, certDER)) + _, err := Sign(tc, SignOptions{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "raw events must not be empty") + }) +} + +func TestVerifyChainCertTrustCacheWindow(t *testing.T) { + // Verifies that the cert trust cache uses a [min, max] time window. + // We create 5 signatures with the same cert at event times: + // T1=Jan, T2=Mar, T3=Feb (within [T1,T2] so cached), T4=Apr (after max, re-verify), T5=Mar (within [T1,T4] so cached). + // The cert and CA are valid 2025-2027 to cover all event times. + // + // We instrument this by counting that chain-of-trust verification + // succeeds for all signatures — the cache is an optimisation but the + // end result must be the same. + certDER, priv := generateEd25519CertWithValidity(t, + time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC), + ) + cert := parseCert(t, certDER) + tc := newTestSigner(t, certDER, priv, cert) + + timestamps := []time.Time{ + time.Date(2026, 1, 15, 0, 0, 0, 0, time.UTC), // T1: Jan + time.Date(2026, 3, 15, 0, 0, 0, 0, time.UTC), // T2: Mar (extends max) + time.Date(2026, 2, 15, 0, 0, 0, 0, time.UTC), // T3: Feb (within [T1,T2], cached) + time.Date(2026, 4, 15, 0, 0, 0, 0, time.UTC), // T4: Apr (beyond max, re-verify) + time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC), // T5: Mar (within [T1,T4], cached) + } + + // Create one event per signature, each with a different timestamp. + events := make([]*protos.HistoryEvent, len(timestamps)) + for i, ts := range timestamps { + events[i] = &protos.HistoryEvent{ + EventId: int32(i), + Timestamp: timestamppb.New(ts), + EventType: &protos.HistoryEvent_WorkflowStarted{ + WorkflowStarted: &protos.WorkflowStartedEvent{}, + }, + } + } + raw := marshalEvents(t, events) + + // Sign each event individually, chaining to the previous. + rawSigs := make([][]byte, len(events)) + certs := []*protos.SigningCertificate(nil) + + for i := range events { + opts := SignOptions{ + RawEvents: raw[i : i+1], + StartEventIndex: uint64(i), + ExistingCerts: certs, + } + if i > 0 { + opts.PreviousSignatureRaw = rawSigs[i-1] + } + result, err := Sign(tc, opts) + require.NoError(t, err) + rawSigs[i] = result.RawSignature + if result.NewCert != nil { + certs = append(certs, result.NewCert) + } + } + + // Full chain verification should succeed. + err := VerifyChain(VerifyChainOptions{ + RawSignatures: rawSigs, + Certs: certs, + AllRawEvents: raw, + Signer: tc, + }) + require.NoError(t, err) +} + +func TestVerifyChainCertTrustCacheWindowExpiredBefore(t *testing.T) { + // Cert is valid 2026-03 to 2026-05. Events at T1=Apr (valid), + // T2=Feb (before NotBefore — should fail even though T1 was cached). + // This ensures the cache doesn't incorrectly skip verification for + // times before the window minimum. + certDER, priv := generateEd25519CertWithValidity(t, + time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC), + time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), + ) + cert := parseCert(t, certDER) + tc := newTestSigner(t, certDER, priv, cert) + + events := []*protos.HistoryEvent{ + { + EventId: 0, + Timestamp: timestamppb.New(time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)), + EventType: &protos.HistoryEvent_WorkflowStarted{ + WorkflowStarted: &protos.WorkflowStartedEvent{}, + }, + }, + { + EventId: 1, + Timestamp: timestamppb.New(time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC)), + EventType: &protos.HistoryEvent_WorkflowStarted{ + WorkflowStarted: &protos.WorkflowStartedEvent{}, + }, + }, + } + raw := marshalEvents(t, events) + + result1, err := Sign(tc, SignOptions{ + RawEvents: raw[:1], + StartEventIndex: 0, + }) + require.NoError(t, err) + + certs := []*protos.SigningCertificate{result1.NewCert} + + result2, err := Sign(tc, SignOptions{ + RawEvents: raw[1:], + StartEventIndex: 1, + PreviousSignatureRaw: result1.RawSignature, + ExistingCerts: certs, + }) + require.NoError(t, err) + + rawSigs := [][]byte{result1.RawSignature, result2.RawSignature} + + // Should fail — the second event's timestamp is before the cert's NotBefore. + err = VerifyChain(VerifyChainOptions{ + RawSignatures: rawSigs, + Certs: certs, + AllRawEvents: raw, + Signer: tc, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "certificate not valid at event time") +} diff --git a/backend/historysigning/signer.go b/backend/historysigning/signer.go new file mode 100644 index 0000000..6557d10 --- /dev/null +++ b/backend/historysigning/signer.go @@ -0,0 +1,126 @@ +/* +Copyright 2026 The Dapr Authors +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 historysigning + +import ( + "bytes" + "errors" + "fmt" + + "google.golang.org/protobuf/proto" + + "github.com/dapr/durabletask-go/api/protos" + "github.com/dapr/kit/crypto/spiffe/signer" +) + +// SignResult is the output of a signing operation. +type SignResult struct { + // Signature is the new HistorySignature entry. + Signature *protos.HistorySignature + + // RawSignature is the deterministically marshaled bytes of the Signature. + // These are the exact bytes that should be persisted to the state store + // and used for digest computation in chain linking. + RawSignature []byte + + // NewCert is non-nil only when the certificate rotated and a new + // SigningCertificate entry needs to be appended to the certificate table. + NewCert *protos.SigningCertificate + + // CertificateIndex is the index used in the signature's certificate_index field. + CertificateIndex uint64 +} + +// SignOptions are the parameters for a signing operation. +type SignOptions struct { + // RawEvents is the deterministically marshaled bytes of each event to sign. + // These must come from MarshalEvent. + RawEvents [][]byte + // StartEventIndex is the index of the first event in the overall history. + StartEventIndex uint64 + // PreviousSignatureRaw is the raw serialized bytes of the previous + // HistorySignature in the chain (nil for root). These must be the exact + // bytes from the state store or from SignResult.RawSignature. + PreviousSignatureRaw []byte + // ExistingCerts is the current certificate table. + ExistingCerts []*protos.SigningCertificate +} + +// Sign creates a HistorySignature covering a range of events. The RawEvents +// field must contain the deterministically marshaled bytes of each event in +// the range (from MarshalEvent). It chains to the previous signature (if any) +// and resolves the certificate index against the existing certificate table. +func Sign(s *signer.Signer, opts SignOptions) (*SignResult, error) { + if s == nil { + return nil, errors.New("signer must not be nil") + } + if len(opts.RawEvents) == 0 { + return nil, errors.New("raw events must not be empty") + } + + eventCount := uint64(len(opts.RawEvents)) + eventsDigest := EventsDigest(opts.RawEvents) + + // Determine previous signature digest from raw bytes. + var prevSigDigest []byte + if opts.PreviousSignatureRaw != nil { + prevSigDigest = SignatureDigest(opts.PreviousSignatureRaw) + } + + // Compute the signature input + sigInput := SignatureInput(prevSigDigest, eventsDigest) + + // Sign + sigBytes, certChainDER, err := s.Sign(sigInput) + if err != nil { + return nil, fmt.Errorf("failed to sign: %w", err) + } + + // Resolve certificate index + certIdx, newCert := resolveCertificateIndex(certChainDER, opts.ExistingCerts) + + sig := &protos.HistorySignature{ + StartEventIndex: opts.StartEventIndex, + EventCount: eventCount, + PreviousSignatureDigest: prevSigDigest, + EventsDigest: eventsDigest, + CertificateIndex: certIdx, + Signature: sigBytes, + } + + rawSig, err := proto.MarshalOptions{Deterministic: true}.Marshal(sig) + if err != nil { + return nil, fmt.Errorf("failed to marshal HistorySignature: %w", err) + } + + return &SignResult{ + Signature: sig, + RawSignature: rawSig, + NewCert: newCert, + CertificateIndex: certIdx, + }, nil +} + +// resolveCertificateIndex checks if the current certificate already exists in +// the certificate table. If so, returns its existing index. Otherwise, +// returns a new index and the certificate to append. +func resolveCertificateIndex(certChainDER []byte, existingCerts []*protos.SigningCertificate) (uint64, *protos.SigningCertificate) { + for i, cert := range existingCerts { + if bytes.Equal(cert.GetCertificate(), certChainDER) { + return uint64(i), nil + } + } + newCert := &protos.SigningCertificate{Certificate: certChainDER} + return uint64(len(existingCerts)), newCert +} diff --git a/backend/historysigning/verify.go b/backend/historysigning/verify.go new file mode 100644 index 0000000..05ed64d --- /dev/null +++ b/backend/historysigning/verify.go @@ -0,0 +1,245 @@ +/* +Copyright 2026 The Dapr Authors +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 historysigning + +import ( + "bytes" + "crypto/x509" + "encoding/asn1" + "errors" + "fmt" + "time" + + "google.golang.org/protobuf/proto" + + "github.com/dapr/durabletask-go/api/protos" + "github.com/dapr/kit/crypto/spiffe/signer" +) + +// VerifySignature verifies a single HistorySignature against the raw event +// bytes and certificate table. The allRawEvents slice must contain the exact +// raw bytes as stored in the state store for every history event, in order. +// Never re-marshal events from Go structs for verification, as protobuf +// deterministic marshaling is not stable across binary versions. +func VerifySignature(s *signer.Signer, sig *protos.HistorySignature, certs []*protos.SigningCertificate, allRawEvents [][]byte) error { + _, err := verifySignature(s, sig, certs, allRawEvents) + return err +} + +// verifySignature is the internal implementation that also returns the event +// time of the last event in the signed range, for use in chain-of-trust +// verification. +func verifySignature(s *signer.Signer, sig *protos.HistorySignature, certs []*protos.SigningCertificate, allRawEvents [][]byte) (time.Time, error) { + var zero time.Time + + if s == nil { + return zero, errors.New("signer is required") + } + + if sig.GetEventCount() == 0 { + return zero, errors.New("signature has zero event count") + } + + if sig.GetCertificateIndex() >= uint64(len(certs)) { + return zero, fmt.Errorf("certificate index %d out of range [0, %d)", sig.GetCertificateIndex(), len(certs)) + } + + certEntry := certs[sig.GetCertificateIndex()] + certDER := certEntry.GetCertificate() + + // Parse the leaf certificate for the validity window check. + leaf, err := parseCertificateChainDER(certDER) + if err != nil { + return zero, fmt.Errorf("failed to parse certificate: %w", err) + } + + start := sig.GetStartEventIndex() + count := sig.GetEventCount() + total := uint64(len(allRawEvents)) + + if start > total { + return zero, fmt.Errorf("start event index %d exceeds events length %d", start, len(allRawEvents)) + } + if count > total-start { + return zero, fmt.Errorf("signature event range [%d, %d) exceeds events length %d", + start, start+count, len(allRawEvents)) + } + + end := start + count + + rawSlice := allRawEvents[start:end] + + // Verify the certificate was valid at the time of the last event in the + // signed range. We unmarshal only the last event to extract its timestamp. + lastRaw := rawSlice[len(rawSlice)-1] + var lastEvent protos.HistoryEvent + if err := proto.Unmarshal(lastRaw, &lastEvent); err != nil { + return zero, fmt.Errorf("failed to unmarshal last event in range: %w", err) + } + + if lastEvent.GetTimestamp() == nil { + return zero, fmt.Errorf("last event in range [%d, %d) has no timestamp", start, end) + } + + eventTime := lastEvent.GetTimestamp().AsTime() + if eventTime.Before(leaf.NotBefore) || eventTime.After(leaf.NotAfter) { + return zero, fmt.Errorf("certificate not valid at event time %v (valid %v to %v)", + eventTime, leaf.NotBefore, leaf.NotAfter) + } + + // Recompute events digest + eventsDigest := EventsDigest(rawSlice) + + if !bytes.Equal(eventsDigest, sig.GetEventsDigest()) { + return zero, fmt.Errorf("events digest mismatch for range [%d, %d)", + sig.GetStartEventIndex(), end) + } + + // Verify the cryptographic signature + sigInput := SignatureInput(sig.GetPreviousSignatureDigest(), sig.GetEventsDigest()) + + if err := s.VerifySignature(sigInput, sig.GetSignature(), certDER); err != nil { + return zero, fmt.Errorf("signature verification failed: %w", err) + } + + return eventTime, nil +} + +// VerifyChainOptions are the parameters for chain verification. +type VerifyChainOptions struct { + // RawSignatures is the raw serialized bytes of each HistorySignature, + // as stored in the state store or from SignResult.RawSignature. These + // are the single source of truth — they are both parsed into + // HistorySignature structs and used for digest computation in chain + // linking. + RawSignatures [][]byte + // Certs is the certificate table (sigcert entries). + Certs []*protos.SigningCertificate + // AllRawEvents is the raw marshaled bytes of all history events, in order. + AllRawEvents [][]byte + // Signer provides cryptographic verification and certificate chain-of-trust + // checking. + Signer *signer.Signer +} + +// VerifyChain walks the full signature chain and verifies each signature, +// including chain linkage via previousSignatureDigest, contiguity of event +// ranges, and certificate chain-of-trust against trust anchors. +// The allRawEvents slice must contain the raw marshaled bytes as stored in the +// state store. +func VerifyChain(opts VerifyChainOptions) error { + if len(opts.RawSignatures) == 0 { + if len(opts.AllRawEvents) == 0 { + return nil + } + return fmt.Errorf("no signatures but %d events exist", len(opts.AllRawEvents)) + } + + if opts.Signer == nil { + return errors.New("signer is required") + } + + // Parse all signatures from raw bytes up front. + sigs := make([]*protos.HistorySignature, len(opts.RawSignatures)) + for i, raw := range opts.RawSignatures { + var sig protos.HistorySignature + if err := proto.Unmarshal(raw, &sig); err != nil { + return fmt.Errorf("failed to unmarshal signature %d: %w", i, err) + } + sigs[i] = &sig + } + + // certTrustVerified caches the verified time window [min, max] for each + // certificate index. Since X.509 validity is a continuous interval, if the + // chain-of-trust has been verified at times T1 and T2, it is also valid + // for any time T where T1 <= T <= T2. We only re-verify when an event + // time falls outside the cached window. + type verifiedWindow struct{ min, max time.Time } + certTrustVerified := make(map[uint64]verifiedWindow) + + var expectedStart uint64 + for i, sig := range sigs { + // Verify chain linkage using raw bytes for digest computation. + if i == 0 { + if sig.GetPreviousSignatureDigest() != nil { + return fmt.Errorf("root signature (index 0) must have nil previousSignatureDigest") + } + } else { + expectedPrevDigest := SignatureDigest(opts.RawSignatures[i-1]) + if !bytes.Equal(sig.GetPreviousSignatureDigest(), expectedPrevDigest) { + return fmt.Errorf("signature %d: previousSignatureDigest does not match digest of signature %d", i, i-1) + } + } + + // Verify contiguity + if sig.GetStartEventIndex() != expectedStart { + return fmt.Errorf("signature %d: expected start event index %d, got %d", i, expectedStart, sig.GetStartEventIndex()) + } + + eventTime, err := verifySignature(opts.Signer, sig, opts.Certs, opts.AllRawEvents) + if err != nil { + return fmt.Errorf("signature %d: %w", i, err) + } + + expectedStart = sig.GetStartEventIndex() + sig.GetEventCount() + + // Verify certificate chain-of-trust against trust bundle at event + // time. Skip if the event time falls within the already-verified + // [min, max] window for this cert. + certIdx := sig.GetCertificateIndex() + w, ok := certTrustVerified[certIdx] + if !ok || eventTime.Before(w.min) || eventTime.After(w.max) { + if err := opts.Signer.VerifyCertChainOfTrust(opts.Certs[certIdx].GetCertificate(), eventTime); err != nil { + return fmt.Errorf("signature %d: chain-of-trust verification failed for certificate index %d: %w", i, certIdx, err) + } + if !ok { + certTrustVerified[certIdx] = verifiedWindow{min: eventTime, max: eventTime} + } else { + if eventTime.Before(w.min) { + w.min = eventTime + } + if eventTime.After(w.max) { + w.max = eventTime + } + certTrustVerified[certIdx] = w + } + } + } + + // Verify full coverage + if expectedStart != uint64(len(opts.AllRawEvents)) { + return fmt.Errorf("signatures cover events [0, %d) but %d events exist", expectedStart, len(opts.AllRawEvents)) + } + + return nil +} + +// parseCertificateChainDER parses the leaf (first) certificate from a +// DER-encoded X.509 certificate chain without parsing the remaining +// intermediates/root, since only the leaf is needed for the validity +// window check. +func parseCertificateChainDER(chainDER []byte) (*x509.Certificate, error) { + if len(chainDER) == 0 { + return nil, errors.New("certificate chain is empty") + } + + // Each certificate in the chain is a self-delimiting ASN.1 SEQUENCE. + // Read only the first structure to extract the leaf. + var raw asn1.RawValue + if _, err := asn1.Unmarshal(chainDER, &raw); err != nil { + return nil, fmt.Errorf("failed to read leaf certificate ASN.1: %w", err) + } + + return x509.ParseCertificate(raw.FullBytes) +} diff --git a/go.mod b/go.mod index e52484a..15a9f8a 100644 --- a/go.mod +++ b/go.mod @@ -4,25 +4,28 @@ go 1.26.0 require ( github.com/cenkalti/backoff/v4 v4.3.0 - github.com/dapr/kit v0.15.3-0.20250616160611-598b032bce69 + github.com/dapr/kit v0.17.1-0.20260402173438-be272d92042b github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.7.4 - github.com/stretchr/testify v1.10.0 + github.com/spiffe/go-spiffe/v2 v2.6.0 + github.com/stretchr/testify v1.11.1 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 - go.opentelemetry.io/otel v1.34.0 + go.opentelemetry.io/otel v1.39.0 go.opentelemetry.io/otel/exporters/zipkin v1.34.0 - go.opentelemetry.io/otel/sdk v1.34.0 - go.opentelemetry.io/otel/trace v1.34.0 - google.golang.org/grpc v1.70.0 - google.golang.org/protobuf v1.36.4 + go.opentelemetry.io/otel/sdk v1.39.0 + go.opentelemetry.io/otel/trace v1.39.0 + google.golang.org/grpc v1.79.3 + google.golang.org/protobuf v1.36.11 modernc.org/sqlite v1.34.5 ) require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/go-logr/logr v1.4.2 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect + github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect @@ -33,15 +36,15 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/stretchr/objx v0.5.2 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/otel/metric v1.34.0 // indirect - golang.org/x/crypto v0.32.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel/metric v1.39.0 // indirect + golang.org/x/crypto v0.49.0 // indirect golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect - golang.org/x/net v0.34.0 // indirect - golang.org/x/sync v0.10.0 // indirect - golang.org/x/sys v0.29.0 // indirect - golang.org/x/text v0.21.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250124145028-65684f501c47 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.61.9 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 8760f4c..26c0e20 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,9 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= -github.com/dapr/kit v0.15.3-0.20250616160611-598b032bce69 h1:I1Uoy3fn906AZZdG8+n8fHitgY7Wn9c+smz4WQdOy1Q= -github.com/dapr/kit v0.15.3-0.20250616160611-598b032bce69/go.mod h1:6w2Pr38zOAtBn+ld/jknwI4kgMfwanCIcFVnPykdPZQ= +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/dapr/kit v0.17.1-0.20260402173438-be272d92042b h1:hXbRlNKvmMGbiMSRy3MX04kH5OiMgH82PRuLN7adtwE= +github.com/dapr/kit v0.17.1-0.20260402173438-be272d92042b/go.mod h1:2v02LZdXzPmOadxoT6EMEt0bsEYe6h1fn2ndYWmylCg= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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= @@ -9,15 +11,17 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= -github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +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/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -44,54 +48,60 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb 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/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +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/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +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/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I= -go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= -go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= +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/exporters/zipkin v1.34.0 h1:GSjCkoYqsnvUMCjxF18j2tCWH8fhGZYjH3iYgechPTI= go.opentelemetry.io/otel/exporters/zipkin v1.34.0/go.mod h1:h830hluwAqgSNnZbxL2rJhmAlE7/0SF9esoHVLU04Gc= -go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= -go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= -go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= -go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= -go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU= -go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ= -go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= -go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= -golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= -golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +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/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +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= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA= golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU= -golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= -golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= -golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= -golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE= -golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250124145028-65684f501c47 h1:91mG8dNTpkC0uChJUQ9zCiRqx3GEEFOWaRZ0mI6Oj2I= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250124145028-65684f501c47/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50= -google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ= -google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw= -google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM= -google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5 h1:aJmi6DVGGIStN9Mobk/tZOOQUBbj0BPjZjjnOdoZKts= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=