Skip to content

Commit 9a1d5b7

Browse files
committed
Implement Identity-based Transparency Logging Server
This commit provides the full implementation and tests for the Identity-based Transparency Logging Server. This change supports the new entry type, with only Ed25519 signatures as credentials. This will be proceeded by support for OIDC tokens as credentials, and ML-DSA signatures as credentials. Partially implements #827, with the remaining PRs to support ML-DSA and an additional GCP backend for PGI deployment.
1 parent 9bb27d8 commit 9a1d5b7

9 files changed

Lines changed: 878 additions & 3 deletions

File tree

.github/workflows/test.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,8 @@ jobs:
115115
storage: [ {name: 'GCP', compose: 'compose.yml', test: 'TestGCPSpanner'},
116116
{name: 'POSIX', compose: 'posix-compose.yml', test: 'TestPOSIX'},
117117
{name: 'AWS', compose: 'aws-compose.yml', test: 'TestAWS'},
118-
{name: 'GCP Cloud SQL', compose: 'cloudsql-compose.yml', test: 'TestGCPCloudSQL'} ]
118+
{name: 'GCP Cloud SQL', compose: 'cloudsql-compose.yml', test: 'TestGCPCloudSQL'},
119+
{name: 'Identity POSIX', compose: 'identity-posix-compose.yml', test: 'TestIdentityPOSIX'} ]
119120
steps:
120121
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
121122
with:

identity-posix-compose.yml

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
# Copyright 2026 The Sigstore Authors.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
services:
16+
rekor:
17+
build:
18+
context: .
19+
target: deploy
20+
args:
21+
STORAGE_BACKEND: posix
22+
command:
23+
- "rekor-server"
24+
- "serve"
25+
- "--identity-mode=true"
26+
- "--http-address=0.0.0.0"
27+
- "--grpc-address=0.0.0.0"
28+
- "--hostname=rekor-local"
29+
- "--storage-dir=/tmp/identityposixlog"
30+
- "--signer-filepath=/pki/ed25519-priv-key.pem"
31+
- "--checkpoint-interval=2s"
32+
- "--log-level=debug"
33+
- "--request-response-logging=true"
34+
- "--persistent-antispam=true"
35+
- "--witness-policy-path=/witness/policy.yaml"
36+
ports:
37+
- "3006:3000" # http port
38+
- "3007:3001" # grpc port
39+
- "2115:2112" # metrics port
40+
healthcheck:
41+
test:
42+
- CMD-SHELL
43+
- curl http://localhost:3000/healthz | grep '{"status":"SERVING"}'
44+
timeout: 30s
45+
retries: 10
46+
interval: 3s
47+
volumes:
48+
- ./tests/testdata/pki:/pki
49+
- ./tests/testdata/witness:/witness
50+
- identity_posix_storage:/tmp/identityposixlog
51+
depends_on:
52+
nginx:
53+
condition: service_healthy
54+
witness:
55+
condition: service_healthy
56+
nginx:
57+
image: nginx:1.31.1@sha256:5aca99593157f4ae539a5dec1092a0ad8762f8e2eb1789085a13a0f5622369f6
58+
volumes:
59+
- identity_posix_storage:/usr/share/nginx/html
60+
restart: always
61+
ports:
62+
- "8001:80"
63+
healthcheck:
64+
test: ["CMD", "curl", "--fail", "http://localhost/"]
65+
interval: 30s
66+
retries: 10
67+
timeout: 3s
68+
witness:
69+
build:
70+
context: https://github.com/transparency-dev/witness.git#main
71+
dockerfile: cmd/omniwitness/Dockerfile
72+
volumes:
73+
- witness_data:/witness_data:rw
74+
- ./tests/testdata/witness:/witness_config
75+
command:
76+
- "--listen=:8100"
77+
- "--db_file=/witness_data/witness.sqlite"
78+
- "--private_key_path=/witness_config/private.key"
79+
- "--additional_logs=/witness_config/config.yaml"
80+
- "--logtostderr"
81+
- "--v=2"
82+
- "--rate_limit=100"
83+
restart: always
84+
healthcheck:
85+
test:
86+
- CMD-SHELL
87+
- wget --spider --tries=1 http://localhost:8081/metrics || exit 1
88+
timeout: 30s
89+
retries: 10
90+
interval: 3s
91+
ports:
92+
- "8101:8100"
93+
volumes:
94+
identity_posix_storage: {}
95+
witness_data: {}

internal/server/identity_service.go

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,24 @@ package server
1616

1717
import (
1818
"context"
19+
"encoding/hex"
20+
"errors"
21+
"fmt"
22+
"log/slog"
23+
"strconv"
24+
"strings"
1925

20-
pb "github.com/sigstore/rekor-tiles/v2/pkg/generated/protobuf"
2126
"github.com/sigstore/rekor-tiles/v2/internal/tessera"
27+
pb "github.com/sigstore/rekor-tiles/v2/pkg/generated/protobuf"
28+
"github.com/sigstore/rekor-tiles/v2/pkg/types/identity"
2229
"github.com/sigstore/sigstore/pkg/signature"
30+
"github.com/transparency-dev/formats/proof"
31+
ttessera "github.com/transparency-dev/tessera"
2332
"google.golang.org/genproto/googleapis/api/httpbody"
33+
"google.golang.org/grpc"
2434
"google.golang.org/grpc/codes"
2535
"google.golang.org/grpc/health/grpc_health_v1"
36+
"google.golang.org/grpc/metadata"
2637
"google.golang.org/grpc/status"
2738
"google.golang.org/protobuf/types/known/emptypb"
2839
)
@@ -44,7 +55,76 @@ func NewIdentityServer(storage tessera.Storage, readOnly bool, algorithmRegistry
4455
}
4556

4657
func (s *IdentityServer) CreateEntry(ctx context.Context, req *pb.IdentityRequestV001) (*httpbody.HttpBody, error) {
47-
return nil, status.Errorf(codes.Unimplemented, "method CreateEntry not implemented")
58+
if s.readOnly {
59+
slog.WarnContext(ctx, "rekor is in read-only mode, cannot create new entry")
60+
_ = grpc.SetHeader(ctx, metadata.Pairs(httpStatusCodeHeader, "405"))
61+
_ = grpc.SetHeader(ctx, metadata.Pairs(httpErrorMessageHeader, "This log has been frozen, please switch to the latest log."))
62+
return nil, status.Errorf(codes.Unimplemented, "log frozen")
63+
}
64+
65+
leafBytes, err := identity.ToLogEntry(req)
66+
if err != nil {
67+
slog.WarnContext(ctx, "failed validating identity request", "error", err.Error())
68+
return nil, status.Errorf(codes.InvalidArgument, "invalid identity request")
69+
}
70+
71+
entry := ttessera.NewEntry(leafBytes)
72+
tle, err := s.storage.Add(ctx, entry)
73+
if errors.Is(err, ttessera.ErrPushback) {
74+
return nil, status.Errorf(codes.Unavailable, "reached max pushback; retry")
75+
}
76+
if errors.Is(err, context.Canceled) {
77+
return nil, status.Error(codes.Canceled, err.Error())
78+
}
79+
var dupErr tessera.DuplicateError
80+
if errors.As(err, &dupErr) {
81+
_ = grpc.SetHeader(ctx, metadata.Pairs(
82+
duplicateEntryHeader,
83+
strconv.FormatUint(dupErr.Index(), 10)))
84+
return nil, status.Error(codes.AlreadyExists, err.Error())
85+
}
86+
if errors.As(err, &tessera.InclusionProofVerificationError{}) {
87+
getMetrics().inclusionProofFailureCount.Inc()
88+
}
89+
if err != nil {
90+
slog.WarnContext(ctx, "failed to integrate entry", "error", err.Error())
91+
return nil, status.Errorf(codes.Unknown, "failed to integrate entry")
92+
}
93+
94+
_ = grpc.SetHeader(ctx, metadata.Pairs(httpStatusCodeHeader, "201"))
95+
getMetrics().newIdentityEntries.Inc()
96+
97+
var proofHashes [][32]byte
98+
for _, h := range tle.InclusionProof.Hashes {
99+
var arr [32]byte
100+
copy(arr[:], h)
101+
proofHashes = append(proofHashes, arr)
102+
}
103+
104+
var extraData []byte
105+
if len(req.GetContext()) > 0 {
106+
var contextPairs []string
107+
for _, entry := range req.GetContext() {
108+
k := hex.EncodeToString(entry.GetKey())
109+
v := hex.EncodeToString(entry.GetValue())
110+
contextPairs = append(contextPairs, fmt.Sprintf("%s:%s", k, v))
111+
}
112+
extraData = []byte(strings.Join(contextPairs, "\n"))
113+
}
114+
115+
p := proof.TLogProof{
116+
//nolint:gosec
117+
Index: uint64(tle.InclusionProof.LogIndex),
118+
Hashes: proofHashes,
119+
Checkpoint: []byte(tle.InclusionProof.Checkpoint.Envelope),
120+
ExtraData: extraData,
121+
}
122+
proofBytes := p.Marshal()
123+
124+
return &httpbody.HttpBody{
125+
ContentType: "text/plain",
126+
Data: proofBytes,
127+
}, nil
48128
}
49129

50130
func (s *IdentityServer) GetTile(context.Context, *pb.TileRequest) (*httpbody.HttpBody, error) {
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// Copyright 2026 The Sigstore Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package server
16+
17+
import (
18+
"context"
19+
"crypto/ed25519"
20+
"crypto/rand"
21+
"crypto/sha256"
22+
"fmt"
23+
"testing"
24+
25+
rekor_pb "github.com/sigstore/protobuf-specs/gen/pb-go/rekor/v1"
26+
"github.com/sigstore/rekor-tiles/v2/internal/algorithmregistry"
27+
"github.com/sigstore/rekor-tiles/v2/internal/tessera"
28+
pb "github.com/sigstore/rekor-tiles/v2/pkg/generated/protobuf"
29+
"github.com/sigstore/sigstore/pkg/cryptoutils"
30+
"github.com/stretchr/testify/assert"
31+
"google.golang.org/grpc/codes"
32+
"google.golang.org/grpc/status"
33+
)
34+
35+
func TestCreateIdentityEntry(t *testing.T) {
36+
pub, priv, err := ed25519.GenerateKey(rand.Reader)
37+
if err != nil {
38+
t.Fatal(err)
39+
}
40+
pubBytes, err := cryptoutils.MarshalPublicKeyToDER(pub)
41+
if err != nil {
42+
t.Fatal(err)
43+
}
44+
45+
msgHash := sha256.Sum256([]byte("hello world"))
46+
payload := []byte("c2sp.org/identity-transparency/v1\x00")
47+
msgDoubleHash := sha256.Sum256(msgHash[:])
48+
payload = append(payload, msgDoubleHash[:]...)
49+
sig := ed25519.Sign(priv, payload)
50+
51+
tests := []struct {
52+
name string
53+
req *pb.IdentityRequestV001
54+
addFn func() (*rekor_pb.TransparencyLogEntry, error)
55+
expectError error
56+
expectedCode codes.Code
57+
}{
58+
{
59+
name: "valid request",
60+
req: &pb.IdentityRequestV001{
61+
Credential: &pb.IdentityRequestV001_PublicKey{
62+
PublicKey: &pb.PublicKeyCredential{
63+
PublicKey: pubBytes,
64+
Signature: sig,
65+
},
66+
},
67+
Message: msgHash[:],
68+
},
69+
addFn: func() (*rekor_pb.TransparencyLogEntry, error) {
70+
return &rekor_pb.TransparencyLogEntry{
71+
InclusionProof: &rekor_pb.InclusionProof{
72+
LogIndex: 1,
73+
Checkpoint: &rekor_pb.Checkpoint{Envelope: "checkpoint"},
74+
},
75+
}, nil
76+
},
77+
},
78+
{
79+
name: "failed validation",
80+
req: &pb.IdentityRequestV001{
81+
Credential: &pb.IdentityRequestV001_PublicKey{
82+
PublicKey: &pb.PublicKeyCredential{
83+
PublicKey: pubBytes,
84+
Signature: sig,
85+
},
86+
},
87+
Message: make([]byte, 31), // invalid message length
88+
},
89+
addFn: func() (*rekor_pb.TransparencyLogEntry, error) {
90+
return &rekor_pb.TransparencyLogEntry{}, nil
91+
},
92+
expectError: fmt.Errorf("invalid identity request"),
93+
expectedCode: codes.InvalidArgument,
94+
},
95+
{
96+
name: "failed integration",
97+
req: &pb.IdentityRequestV001{
98+
Credential: &pb.IdentityRequestV001_PublicKey{
99+
PublicKey: &pb.PublicKeyCredential{
100+
PublicKey: pubBytes,
101+
Signature: sig,
102+
},
103+
},
104+
Message: msgHash[:],
105+
},
106+
addFn: func() (*rekor_pb.TransparencyLogEntry, error) {
107+
return nil, fmt.Errorf("timed out")
108+
},
109+
expectError: fmt.Errorf("failed to integrate entry"),
110+
expectedCode: codes.Unknown,
111+
},
112+
{
113+
name: "inclusion proof verification failure",
114+
req: &pb.IdentityRequestV001{
115+
Credential: &pb.IdentityRequestV001_PublicKey{
116+
PublicKey: &pb.PublicKeyCredential{
117+
PublicKey: pubBytes,
118+
Signature: sig,
119+
},
120+
},
121+
Message: msgHash[:],
122+
},
123+
addFn: func() (*rekor_pb.TransparencyLogEntry, error) {
124+
return nil, tessera.InclusionProofVerificationError{}
125+
},
126+
expectError: fmt.Errorf("failed to integrate entry"),
127+
expectedCode: codes.Unknown,
128+
},
129+
}
130+
for _, test := range tests {
131+
t.Run(test.name, func(t *testing.T) {
132+
storage := &mockStorage{addFn: test.addFn}
133+
algReg, err := algorithmregistry.AlgorithmRegistry([]string{"ed25519"})
134+
if err != nil {
135+
t.Fatal(err)
136+
}
137+
server := NewIdentityServer(storage, false, algReg)
138+
139+
gotBody, gotErr := server.CreateEntry(context.Background(), test.req)
140+
if test.expectError == nil {
141+
assert.NoError(t, gotErr)
142+
assert.NotNil(t, gotBody)
143+
assert.Equal(t, "text/plain", gotBody.ContentType)
144+
} else {
145+
s, ok := status.FromError(gotErr)
146+
assert.True(t, ok)
147+
assert.Equal(t, test.expectedCode, s.Code())
148+
assert.ErrorContains(t, gotErr, test.expectError.Error())
149+
}
150+
})
151+
}
152+
}

internal/server/metrics.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ type metrics struct {
4242
serverMetrics *grpc_prometheus.ServerMetrics
4343
// metrics
4444
newHashedRekordEntries prometheus.Counter
45+
newIdentityEntries prometheus.Counter
4546
httpLatency *prometheus.HistogramVec
4647
httpRequestsCount *prometheus.CounterVec
4748
httpRequestSize *prometheus.HistogramVec
@@ -70,6 +71,11 @@ var _initMetricsFunc = sync.OnceValue(func() *metrics {
7071
Help: "The total number of new hashedrekord log entries",
7172
})
7273

74+
m.newIdentityEntries = f.NewCounter(prometheus.CounterOpts{
75+
Name: "rekor_v2_new_identity_entries",
76+
Help: "The total number of new identity log entries",
77+
})
78+
7379
// grpc_packet_part should always be "payload" but we can measure "header" or "trailer" in the future
7480
// if we so desire
7581
m.grpcRequestSize = f.NewHistogramVec(prometheus.HistogramOpts{

0 commit comments

Comments
 (0)