Skip to content

Commit 9d5daa4

Browse files
authored
Support GCP Secret Manager for signer key pair (#40)
* Support GCP Secret Manager for signer * Add terraform dependency lock file * Add security warning to tls_private_key.sctfe-ecdsa-p256 resource * Rename `Signer` to `ECDSAWithSHA256Signer` * Wrap err with key secret name * Refactor `pem.Decode` * Fix readme after rebase * Fix TF resources naming convention * Fix `panic` when `opts` is `nil` * Verify the correctness of the signer key pair * Refactor `ECDSAWithSHA256Signer` to use `ecdsa.{Public,Private}Key`
1 parent 64b5509 commit 9d5daa4

File tree

9 files changed

+293
-30
lines changed

9 files changed

+293
-30
lines changed

cmd/gcp/main.go

Lines changed: 21 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,6 @@ import (
2727
"syscall"
2828
"time"
2929

30-
"github.com/google/trillian/crypto/keys/pem"
3130
"github.com/google/trillian/monitoring/opencensus"
3231
"github.com/google/trillian/monitoring/prometheus"
3332
"github.com/prometheus/client_golang/prometheus/promhttp"
@@ -50,25 +49,25 @@ var (
5049
notAfterStart timestampFlag
5150
notAfterLimit timestampFlag
5251

53-
httpEndpoint = flag.String("http_endpoint", "localhost:6962", "Endpoint for HTTP (host:port).")
54-
metricsEndpoint = flag.String("metrics_endpoint", "", "Endpoint for serving metrics; if left empty, metrics will be visible on --http_endpoint.")
55-
tesseraDeadline = flag.Duration("tessera_deadline", time.Second*10, "Deadline for Tessera requests.")
56-
maskInternalErrors = flag.Bool("mask_internal_errors", false, "Don't return error strings with Internal Server Error HTTP responses.")
57-
tracing = flag.Bool("tracing", false, "If true opencensus Stackdriver tracing will be enabled. See https://opencensus.io/.")
58-
tracingProjectID = flag.String("tracing_project_id", "", "project ID to pass to stackdriver. Can be empty for GCP, consult docs for other platforms.")
59-
tracingPercent = flag.Int("tracing_percent", 0, "Percent of requests to be traced. Zero is a special case to use the DefaultSampler.")
60-
origin = flag.String("origin", "", "Origin of the log, for checkpoints and the monitoring prefix.")
61-
projectID = flag.String("project_id", "", "GCP ProjectID.")
62-
bucket = flag.String("bucket", "", "Name of the bucket to store the log in.")
63-
spannerDB = flag.String("spanner_db_path", "", "Spanner database path: projects/{projectId}/instances/{instanceId}/databases/{databaseId}.")
64-
spannerDedupDB = flag.String("spanner_dedup_db_path", "", "Spanner deduplication database path: projects/{projectId}/instances/{instanceId}/databases/{databaseId}.")
65-
rootsPemFile = flag.String("roots_pem_file", "", "Path to the file containing root certificates that are acceptable to the log. The certs are served through get-roots endpoint.")
66-
rejectExpired = flag.Bool("reject_expired", false, "If true then the certificate validity period will be checked against the current time during the validation of submissions. This will cause expired certificates to be rejected.")
67-
rejectUnexpired = flag.Bool("reject_unexpired", false, "If true then CTFE rejects certificates that are either currently valid or not yet valid.")
68-
extKeyUsages = flag.String("ext_key_usages", "", "If set, will restrict the set of such usages that the server will accept. By default all are accepted. The values specified must be ones known to the x509 package.")
69-
rejectExtensions = flag.String("reject_extension", "", "A list of X.509 extension OIDs, in dotted string form (e.g. '2.3.4.5') which, if present, should cause submissions to be rejected.")
70-
privKey = flag.String("private_key", "", "Path to a private key .der file. Used to sign checkpoints and SCTs.")
71-
privKeyPass = flag.String("password", "", "private_key password.")
52+
httpEndpoint = flag.String("http_endpoint", "localhost:6962", "Endpoint for HTTP (host:port).")
53+
metricsEndpoint = flag.String("metrics_endpoint", "", "Endpoint for serving metrics; if left empty, metrics will be visible on --http_endpoint.")
54+
tesseraDeadline = flag.Duration("tessera_deadline", time.Second*10, "Deadline for Tessera requests.")
55+
maskInternalErrors = flag.Bool("mask_internal_errors", false, "Don't return error strings with Internal Server Error HTTP responses.")
56+
tracing = flag.Bool("tracing", false, "If true opencensus Stackdriver tracing will be enabled. See https://opencensus.io/.")
57+
tracingProjectID = flag.String("tracing_project_id", "", "project ID to pass to stackdriver. Can be empty for GCP, consult docs for other platforms.")
58+
tracingPercent = flag.Int("tracing_percent", 0, "Percent of requests to be traced. Zero is a special case to use the DefaultSampler.")
59+
origin = flag.String("origin", "", "Origin of the log, for checkpoints and the monitoring prefix.")
60+
projectID = flag.String("project_id", "", "GCP ProjectID.")
61+
bucket = flag.String("bucket", "", "Name of the bucket to store the log in.")
62+
spannerDB = flag.String("spanner_db_path", "", "Spanner database path: projects/{projectId}/instances/{instanceId}/databases/{databaseId}.")
63+
spannerDedupDB = flag.String("spanner_dedup_db_path", "", "Spanner deduplication database path: projects/{projectId}/instances/{instanceId}/databases/{databaseId}.")
64+
rootsPemFile = flag.String("roots_pem_file", "", "Path to the file containing root certificates that are acceptable to the log. The certs are served through get-roots endpoint.")
65+
rejectExpired = flag.Bool("reject_expired", false, "If true then the certificate validity period will be checked against the current time during the validation of submissions. This will cause expired certificates to be rejected.")
66+
rejectUnexpired = flag.Bool("reject_unexpired", false, "If true then CTFE rejects certificates that are either currently valid or not yet valid.")
67+
extKeyUsages = flag.String("ext_key_usages", "", "If set, will restrict the set of such usages that the server will accept. By default all are accepted. The values specified must be ones known to the x509 package.")
68+
rejectExtensions = flag.String("reject_extension", "", "A list of X.509 extension OIDs, in dotted string form (e.g. '2.3.4.5') which, if present, should cause submissions to be rejected.")
69+
signerPublicKeySecretName = flag.String("signer_public_key_secret_name", "", "Public key secret name for checkpoints and SCTs signer. Format: projects/{projectId}/secrets/{secretName}/versions/{secretVersion}.")
70+
signerPrivateKeySecretName = flag.String("signer_private_key_secret_name", "", "Private key secret name for checkpoints and SCTs signer. Format: projects/{projectId}/secrets/{secretName}/versions/{secretVersion}.")
7271
)
7372

7473
// nolint:staticcheck
@@ -77,10 +76,9 @@ func main() {
7776
flag.Parse()
7877
ctx := context.Background()
7978

80-
// TODO(phboneff): move to something else, like KMS
81-
signer, err := pem.ReadPrivateKeyFile(*privKey, *privKeyPass)
79+
signer, err := NewSecretManagerSigner(ctx, *signerPublicKeySecretName, *signerPrivateKeySecretName)
8280
if err != nil {
83-
klog.Exitf("Can't open key: %v", err)
81+
klog.Exitf("Can't create secret manager signer: %v", err)
8482
}
8583

8684
vCfg, err := sctfe.ValidateLogConfig(*origin, *projectID, *bucket, *spannerDB, *rootsPemFile, *rejectExpired, *rejectUnexpired, *extKeyUsages, *rejectExtensions, notAfterStart.t, notAfterLimit.t, signer)

cmd/gcp/secret_manager.go

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
// Copyright 2024 Google LLC. All Rights Reserved.
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 main
16+
17+
import (
18+
"context"
19+
"crypto"
20+
"crypto/ecdsa"
21+
"crypto/x509"
22+
"encoding/pem"
23+
"errors"
24+
"fmt"
25+
"hash/crc32"
26+
"io"
27+
28+
secretmanager "cloud.google.com/go/secretmanager/apiv1"
29+
"cloud.google.com/go/secretmanager/apiv1/secretmanagerpb"
30+
)
31+
32+
// ECDSAWithSHA256Signer implements crypto.Signer using Google Cloud Secret Manager.
33+
// Only crypto.SHA256 and ECDSA are supported.
34+
type ECDSAWithSHA256Signer struct {
35+
publicKey *ecdsa.PublicKey
36+
privateKey *ecdsa.PrivateKey
37+
}
38+
39+
// Public returns the public key stored in the Signer object.
40+
func (s *ECDSAWithSHA256Signer) Public() crypto.PublicKey {
41+
return s.publicKey
42+
}
43+
44+
// Sign signs digest with the private key stored in Google Cloud Secret Manager.
45+
func (s *ECDSAWithSHA256Signer) Sign(rand io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) {
46+
// Verify hash function and digest bytes length.
47+
if opts == nil {
48+
return nil, errors.New("opts cannot be nil")
49+
}
50+
if opts.HashFunc() != crypto.SHA256 {
51+
return nil, fmt.Errorf("unsupported hash func: %v", opts.HashFunc())
52+
}
53+
if len(digest) != opts.HashFunc().Size() {
54+
return nil, fmt.Errorf("digest bytes length %d does not match hash function bytes length %d", len(digest), opts.HashFunc().Size())
55+
}
56+
57+
return ecdsa.SignASN1(rand, s.privateKey, digest)
58+
}
59+
60+
// NewSecretManagerSigner creates a new signer that uses the ECDSA P-256 key pair in
61+
// Google Cloud Secret Manager for signing digests.
62+
func NewSecretManagerSigner(ctx context.Context, publicKeySecretName, privateKeySecretName string) (*ECDSAWithSHA256Signer, error) {
63+
client, err := secretmanager.NewClient(ctx)
64+
if err != nil {
65+
return nil, fmt.Errorf("failed to create secret manager client: %w", err)
66+
}
67+
defer client.Close()
68+
69+
// Public Key
70+
var publicKey crypto.PublicKey
71+
pemBlock, err := secretPEM(ctx, client, publicKeySecretName)
72+
if err != nil {
73+
return nil, fmt.Errorf("failed to get public key secret PEM (%s): %w", publicKeySecretName, err)
74+
}
75+
switch pemBlock.Type {
76+
case "PUBLIC KEY":
77+
publicKey, err = x509.ParsePKIXPublicKey(pemBlock.Bytes)
78+
default:
79+
return nil, fmt.Errorf("unsupported PEM type: %s", pemBlock.Type)
80+
}
81+
if err != nil {
82+
return nil, err
83+
}
84+
var ecdsaPublicKey *ecdsa.PublicKey
85+
ecdsaPublicKey, ok := publicKey.(*ecdsa.PublicKey)
86+
if !ok {
87+
return nil, fmt.Errorf("the public key stored in Secret Manager is not an ECDSA key")
88+
}
89+
90+
// Private Key
91+
var ecdsaPrivateKey *ecdsa.PrivateKey
92+
pemBlock, err = secretPEM(ctx, client, privateKeySecretName)
93+
if err != nil {
94+
return nil, fmt.Errorf("failed to get private key secret PEM (%s): %w", privateKeySecretName, err)
95+
}
96+
switch pemBlock.Type {
97+
case "EC PRIVATE KEY":
98+
ecdsaPrivateKey, err = x509.ParseECPrivateKey(pemBlock.Bytes)
99+
default:
100+
return nil, fmt.Errorf("unsupported PEM type: %s", pemBlock.Type)
101+
}
102+
if err != nil {
103+
return nil, err
104+
}
105+
106+
// Verify the correctness of the signer key pair
107+
if !ecdsaPrivateKey.PublicKey.Equal(ecdsaPublicKey) {
108+
return nil, errors.New("signer key pair doesn't match")
109+
}
110+
111+
return &ECDSAWithSHA256Signer{
112+
publicKey: ecdsaPublicKey,
113+
privateKey: ecdsaPrivateKey,
114+
}, nil
115+
}
116+
117+
func secretPEM(ctx context.Context, client *secretmanager.Client, secretName string) (*pem.Block, error) {
118+
resp, err := client.AccessSecretVersion(ctx, &secretmanagerpb.AccessSecretVersionRequest{
119+
Name: secretName,
120+
})
121+
if err != nil {
122+
return nil, fmt.Errorf("failed to access secret version: %w", err)
123+
}
124+
if resp.Name != secretName {
125+
return nil, errors.New("request corrupted in-transit")
126+
}
127+
// Verify the data checksum.
128+
crc32c := crc32.MakeTable(crc32.Castagnoli)
129+
checksum := int64(crc32.Checksum(resp.Payload.Data, crc32c))
130+
if checksum != *resp.Payload.DataCrc32C {
131+
return nil, errors.New("Data corruption detected.")
132+
}
133+
134+
pemBlock, rest := pem.Decode([]byte(resp.Payload.Data))
135+
if pemBlock == nil {
136+
return nil, errors.New("failed to decode PEM")
137+
}
138+
if len(rest) > 0 {
139+
return nil, fmt.Errorf("extra data after decoding PEM: %v", rest)
140+
}
141+
142+
return pemBlock, nil
143+
}

config.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ package sctfe
1616

1717
import (
1818
"crypto"
19+
"crypto/ecdsa"
1920
"errors"
2021
"fmt"
2122
"strings"
@@ -32,7 +33,6 @@ type ValidatedLogConfig struct {
3233
// is also its submission prefix, as per https://c2sp.org/static-ct-api.
3334
Origin string
3435
// Used to sign the checkpoint and SCTs.
35-
// TODO(phboneff): check that this is RSA or ECDSA only.
3636
Signer crypto.Signer
3737
// If set, ExtKeyUsages will restrict the set of such usages that the
3838
// server will accept. By default all are accepted. The values specified
@@ -90,6 +90,16 @@ func ValidateLogConfig(origin string, projectID string, bucket string, spannerDB
9090
return nil, errors.New("empty rootsPemFile")
9191
}
9292

93+
// Validate signer that only ECDSA is supported.
94+
if signer == nil {
95+
return nil, errors.New("empty signer")
96+
}
97+
switch keyType := signer.Public().(type) {
98+
case *ecdsa.PublicKey:
99+
default:
100+
return nil, fmt.Errorf("unsupported key type: %v", keyType)
101+
}
102+
93103
lExtKeyUsages := []string{}
94104
lRejectExtensions := []string{}
95105
if extKeyUsages != "" {

deployment/live/gcp/test/.terraform.lock.hcl

Lines changed: 41 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

deployment/live/gcp/test/README.md

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,13 @@ Terraforming the project can be done by:
3838
1. `cd` to the relevant directory for the environment to deploy/change (e.g. `ci`)
3939
2. Run `terragrunt apply`
4040

41+
Store the Secret Manager resource ID of signer key pair into the environment variables:
42+
43+
```sh
44+
export SCTFE_SIGNER_ECDSA_P256_PUBLIC_KEY_ID=$(terragrunt output -raw ecdsa_p256_public_key_id)
45+
export SCTFE_SIGNER_ECDSA_P256_PRIVATE_KEY_ID=$(terragrunt output -raw ecdsa_p256_private_key_id)
46+
```
47+
4148
## Run the SCTFE
4249

4350
### With fake chains
@@ -50,10 +57,10 @@ go run ./cmd/gcp/ \
5057
--bucket=${GOOGLE_PROJECT}-${TESSERA_BASE_NAME}-bucket \
5158
--spanner_db_path=projects/${GOOGLE_PROJECT}/instances/${TESSERA_BASE_NAME}/databases/${TESSERA_BASE_NAME}-db \
5259
--spanner_dedup_db_path=projects/${GOOGLE_PROJECT}/instances/${TESSERA_BASE_NAME}/databases/${TESSERA_BASE_NAME}-dedup-db \
53-
--private_key=./testdata/ct-http-server.privkey.pem \
54-
--password=dirk \
5560
--roots_pem_file=./testdata/fake-ca.cert \
56-
--origin=${TESSERA_BASE_NAME}
61+
--origin=${TESSERA_BASE_NAME} \
62+
--signer_public_key_secret_name=${SCTFE_SIGNER_ECDSA_P256_PUBLIC_KEY_ID} \
63+
--signer_private_key_secret_name=${SCTFE_SIGNER_ECDSA_P256_PRIVATE_KEY_ID}
5764
```
5865

5966
In a different terminal you can either mint and submit certificates manually, or
@@ -131,7 +138,6 @@ go run ./client/ctclient get-roots --log_uri=${SRC_LOG_URI} --text=false > /tmp/
131138
sed -i 's-""-"/tmp/hammercfg/roots.pem"-g' /tmp/hammercfg/hammer.cfg
132139
```
133140

134-
135141
Run the SCTFE with the same roots:
136142

137143
```bash
@@ -140,11 +146,11 @@ go run ./cmd/gcp/ \
140146
--project_id=${GOOGLE_PROJECT} \
141147
--bucket=${GOOGLE_PROJECT}-${TESSERA_BASE_NAME}-bucket \
142148
--spanner_db_path=projects/${GOOGLE_PROJECT}/instances/${TESSERA_BASE_NAME}/databases/${TESSERA_BASE_NAME}-db \
143-
--private_key=./testdata/ct-http-server.privkey.pem \
144-
--password=dirk \
145149
--roots_pem_file=/tmp/hammercfg/roots.pem \
146150
--origin=${TESSERA_BASE_NAME} \
147151
--spanner_dedup_db_path=projects/${GOOGLE_PROJECT}/instances/${TESSERA_BASE_NAME}/databases/${TESSERA_BASE_NAME}-dedup-db \
152+
--signer_public_key_secret_name=${SCTFE_SIGNER_ECDSA_P256_PUBLIC_KEY_ID} \
153+
--signer_private_key_secret_name=${SCTFE_SIGNER_ECDSA_P256_PRIVATE_KEY_ID} \
148154
-v=3
149155
```
150156

deployment/modules/gcp/storage/main.tf

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,55 @@ resource "google_spanner_database" "dedup_db" {
6565
"CREATE TABLE IDSeq (id INT64 NOT NULL, h BYTES(MAX) NOT NULL, idx INT64 NOT NULL,) PRIMARY KEY (id, h)",
6666
]
6767
}
68+
69+
# Secret Manager
70+
71+
# ECDSA key with P256 elliptic curve. Do NOT use this in production environment.
72+
#
73+
# Security Notice
74+
# The private key generated by this resource will be stored unencrypted in your
75+
# Terraform state file. Use of this resource for production deployments is not
76+
# recommended. Instead, generate a private key file outside of Terraform and
77+
# distribute it securely to the system where Terraform will be run.
78+
#
79+
# See https://registry.terraform.io/providers/hashicorp/tls/latest/docs/resources/private_key.
80+
resource "tls_private_key" "sctfe_ecdsa_p256" {
81+
algorithm = "ECDSA"
82+
ecdsa_curve = "P256"
83+
}
84+
85+
resource "google_secret_manager_secret" "sctfe_ecdsa_p256_public_key" {
86+
secret_id = "sctfe-ecdsa-p256-public-key"
87+
88+
labels = {
89+
label = "sctfe-public-key"
90+
}
91+
92+
replication {
93+
auto {}
94+
}
95+
}
96+
97+
resource "google_secret_manager_secret_version" "sctfe_ecdsa_p256_public_key" {
98+
secret = google_secret_manager_secret.sctfe_ecdsa_p256_public_key.id
99+
100+
secret_data = tls_private_key.sctfe_ecdsa_p256.public_key_pem
101+
}
102+
103+
resource "google_secret_manager_secret" "sctfe_ecdsa_p256_private_key" {
104+
secret_id = "sctfe-ecdsa-p256-private-key"
105+
106+
labels = {
107+
label = "sctfe-private-key"
108+
}
109+
110+
replication {
111+
auto {}
112+
}
113+
}
114+
115+
resource "google_secret_manager_secret_version" "sctfe_ecdsa_p256_private_key" {
116+
secret = google_secret_manager_secret.sctfe_ecdsa_p256_private_key.id
117+
118+
secret_data = tls_private_key.sctfe_ecdsa_p256.private_key_pem
119+
}

0 commit comments

Comments
 (0)