Skip to content

Support GCP Secret Manager for signer key pair #40

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Nov 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 21 additions & 23 deletions cmd/gcp/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ import (
"syscall"
"time"

"github.com/google/trillian/crypto/keys/pem"
"github.com/google/trillian/monitoring/opencensus"
"github.com/google/trillian/monitoring/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
Expand All @@ -50,25 +49,25 @@ var (
notAfterStart timestampFlag
notAfterLimit timestampFlag

httpEndpoint = flag.String("http_endpoint", "localhost:6962", "Endpoint for HTTP (host:port).")
metricsEndpoint = flag.String("metrics_endpoint", "", "Endpoint for serving metrics; if left empty, metrics will be visible on --http_endpoint.")
tesseraDeadline = flag.Duration("tessera_deadline", time.Second*10, "Deadline for Tessera requests.")
maskInternalErrors = flag.Bool("mask_internal_errors", false, "Don't return error strings with Internal Server Error HTTP responses.")
tracing = flag.Bool("tracing", false, "If true opencensus Stackdriver tracing will be enabled. See https://opencensus.io/.")
tracingProjectID = flag.String("tracing_project_id", "", "project ID to pass to stackdriver. Can be empty for GCP, consult docs for other platforms.")
tracingPercent = flag.Int("tracing_percent", 0, "Percent of requests to be traced. Zero is a special case to use the DefaultSampler.")
origin = flag.String("origin", "", "Origin of the log, for checkpoints and the monitoring prefix.")
projectID = flag.String("project_id", "", "GCP ProjectID.")
bucket = flag.String("bucket", "", "Name of the bucket to store the log in.")
spannerDB = flag.String("spanner_db_path", "", "Spanner database path: projects/{projectId}/instances/{instanceId}/databases/{databaseId}.")
spannerDedupDB = flag.String("spanner_dedup_db_path", "", "Spanner deduplication database path: projects/{projectId}/instances/{instanceId}/databases/{databaseId}.")
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.")
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.")
rejectUnexpired = flag.Bool("reject_unexpired", false, "If true then CTFE rejects certificates that are either currently valid or not yet valid.")
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.")
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.")
privKey = flag.String("private_key", "", "Path to a private key .der file. Used to sign checkpoints and SCTs.")
privKeyPass = flag.String("password", "", "private_key password.")
httpEndpoint = flag.String("http_endpoint", "localhost:6962", "Endpoint for HTTP (host:port).")
metricsEndpoint = flag.String("metrics_endpoint", "", "Endpoint for serving metrics; if left empty, metrics will be visible on --http_endpoint.")
tesseraDeadline = flag.Duration("tessera_deadline", time.Second*10, "Deadline for Tessera requests.")
maskInternalErrors = flag.Bool("mask_internal_errors", false, "Don't return error strings with Internal Server Error HTTP responses.")
tracing = flag.Bool("tracing", false, "If true opencensus Stackdriver tracing will be enabled. See https://opencensus.io/.")
tracingProjectID = flag.String("tracing_project_id", "", "project ID to pass to stackdriver. Can be empty for GCP, consult docs for other platforms.")
tracingPercent = flag.Int("tracing_percent", 0, "Percent of requests to be traced. Zero is a special case to use the DefaultSampler.")
origin = flag.String("origin", "", "Origin of the log, for checkpoints and the monitoring prefix.")
projectID = flag.String("project_id", "", "GCP ProjectID.")
bucket = flag.String("bucket", "", "Name of the bucket to store the log in.")
spannerDB = flag.String("spanner_db_path", "", "Spanner database path: projects/{projectId}/instances/{instanceId}/databases/{databaseId}.")
spannerDedupDB = flag.String("spanner_dedup_db_path", "", "Spanner deduplication database path: projects/{projectId}/instances/{instanceId}/databases/{databaseId}.")
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.")
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.")
rejectUnexpired = flag.Bool("reject_unexpired", false, "If true then CTFE rejects certificates that are either currently valid or not yet valid.")
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.")
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.")
signerPublicKeySecretName = flag.String("signer_public_key_secret_name", "", "Public key secret name for checkpoints and SCTs signer. Format: projects/{projectId}/secrets/{secretName}/versions/{secretVersion}.")
signerPrivateKeySecretName = flag.String("signer_private_key_secret_name", "", "Private key secret name for checkpoints and SCTs signer. Format: projects/{projectId}/secrets/{secretName}/versions/{secretVersion}.")
)

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

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

vCfg, err := sctfe.ValidateLogConfig(*origin, *projectID, *bucket, *spannerDB, *rootsPemFile, *rejectExpired, *rejectUnexpired, *extKeyUsages, *rejectExtensions, notAfterStart.t, notAfterLimit.t, signer)
Expand Down
143 changes: 143 additions & 0 deletions cmd/gcp/secret_manager.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// Copyright 2024 Google LLC. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package main

import (
"context"
"crypto"
"crypto/ecdsa"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"hash/crc32"
"io"

secretmanager "cloud.google.com/go/secretmanager/apiv1"
"cloud.google.com/go/secretmanager/apiv1/secretmanagerpb"
)

// ECDSAWithSHA256Signer implements crypto.Signer using Google Cloud Secret Manager.
// Only crypto.SHA256 and ECDSA are supported.
type ECDSAWithSHA256Signer struct {
publicKey *ecdsa.PublicKey
privateKey *ecdsa.PrivateKey
}

// Public returns the public key stored in the Signer object.
func (s *ECDSAWithSHA256Signer) Public() crypto.PublicKey {
return s.publicKey
}

// Sign signs digest with the private key stored in Google Cloud Secret Manager.
func (s *ECDSAWithSHA256Signer) Sign(rand io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error) {
// Verify hash function and digest bytes length.
if opts == nil {
return nil, errors.New("opts cannot be nil")
}
if opts.HashFunc() != crypto.SHA256 {
return nil, fmt.Errorf("unsupported hash func: %v", opts.HashFunc())
}
if len(digest) != opts.HashFunc().Size() {
return nil, fmt.Errorf("digest bytes length %d does not match hash function bytes length %d", len(digest), opts.HashFunc().Size())
}

return ecdsa.SignASN1(rand, s.privateKey, digest)
}

// NewSecretManagerSigner creates a new signer that uses the ECDSA P-256 key pair in
// Google Cloud Secret Manager for signing digests.
func NewSecretManagerSigner(ctx context.Context, publicKeySecretName, privateKeySecretName string) (*ECDSAWithSHA256Signer, error) {
client, err := secretmanager.NewClient(ctx)
if err != nil {
return nil, fmt.Errorf("failed to create secret manager client: %w", err)
}
defer client.Close()

// Public Key
var publicKey crypto.PublicKey
pemBlock, err := secretPEM(ctx, client, publicKeySecretName)
if err != nil {
return nil, fmt.Errorf("failed to get public key secret PEM (%s): %w", publicKeySecretName, err)
}
switch pemBlock.Type {
case "PUBLIC KEY":
publicKey, err = x509.ParsePKIXPublicKey(pemBlock.Bytes)
default:
return nil, fmt.Errorf("unsupported PEM type: %s", pemBlock.Type)
}
if err != nil {
return nil, err
}
var ecdsaPublicKey *ecdsa.PublicKey
ecdsaPublicKey, ok := publicKey.(*ecdsa.PublicKey)
if !ok {
return nil, fmt.Errorf("the public key stored in Secret Manager is not an ECDSA key")
}

// Private Key
var ecdsaPrivateKey *ecdsa.PrivateKey
pemBlock, err = secretPEM(ctx, client, privateKeySecretName)
if err != nil {
return nil, fmt.Errorf("failed to get private key secret PEM (%s): %w", privateKeySecretName, err)
}
switch pemBlock.Type {
case "EC PRIVATE KEY":
ecdsaPrivateKey, err = x509.ParseECPrivateKey(pemBlock.Bytes)
default:
return nil, fmt.Errorf("unsupported PEM type: %s", pemBlock.Type)
}
if err != nil {
return nil, err
}

// Verify the correctness of the signer key pair
if !ecdsaPrivateKey.PublicKey.Equal(ecdsaPublicKey) {
return nil, errors.New("signer key pair doesn't match")
}

return &ECDSAWithSHA256Signer{
publicKey: ecdsaPublicKey,
privateKey: ecdsaPrivateKey,
}, nil
}

func secretPEM(ctx context.Context, client *secretmanager.Client, secretName string) (*pem.Block, error) {
resp, err := client.AccessSecretVersion(ctx, &secretmanagerpb.AccessSecretVersionRequest{
Name: secretName,
})
if err != nil {
return nil, fmt.Errorf("failed to access secret version: %w", err)
}
if resp.Name != secretName {
return nil, errors.New("request corrupted in-transit")
}
// Verify the data checksum.
crc32c := crc32.MakeTable(crc32.Castagnoli)
checksum := int64(crc32.Checksum(resp.Payload.Data, crc32c))
if checksum != *resp.Payload.DataCrc32C {
return nil, errors.New("Data corruption detected.")
}

pemBlock, rest := pem.Decode([]byte(resp.Payload.Data))
if pemBlock == nil {
return nil, errors.New("failed to decode PEM")
}
if len(rest) > 0 {
return nil, fmt.Errorf("extra data after decoding PEM: %v", rest)
}

return pemBlock, nil
}
12 changes: 11 additions & 1 deletion config.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package sctfe

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

// Validate signer that only ECDSA is supported.
if signer == nil {
return nil, errors.New("empty signer")
}
switch keyType := signer.Public().(type) {
case *ecdsa.PublicKey:
default:
return nil, fmt.Errorf("unsupported key type: %v", keyType)
}

lExtKeyUsages := []string{}
lRejectExtensions := []string{}
if extKeyUsages != "" {
Expand Down
41 changes: 41 additions & 0 deletions deployment/live/gcp/test/.terraform.lock.hcl

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 12 additions & 6 deletions deployment/live/gcp/test/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ Terraforming the project can be done by:
1. `cd` to the relevant directory for the environment to deploy/change (e.g. `ci`)
2. Run `terragrunt apply`

Store the Secret Manager resource ID of signer key pair into the environment variables:

```sh
export SCTFE_SIGNER_ECDSA_P256_PUBLIC_KEY_ID=$(terragrunt output -raw ecdsa_p256_public_key_id)
export SCTFE_SIGNER_ECDSA_P256_PRIVATE_KEY_ID=$(terragrunt output -raw ecdsa_p256_private_key_id)
```

## Run the SCTFE

### With fake chains
Expand All @@ -50,10 +57,10 @@ go run ./cmd/gcp/ \
--bucket=${GOOGLE_PROJECT}-${TESSERA_BASE_NAME}-bucket \
--spanner_db_path=projects/${GOOGLE_PROJECT}/instances/${TESSERA_BASE_NAME}/databases/${TESSERA_BASE_NAME}-db \
--spanner_dedup_db_path=projects/${GOOGLE_PROJECT}/instances/${TESSERA_BASE_NAME}/databases/${TESSERA_BASE_NAME}-dedup-db \
--private_key=./testdata/ct-http-server.privkey.pem \
--password=dirk \
--roots_pem_file=./testdata/fake-ca.cert \
--origin=${TESSERA_BASE_NAME}
--origin=${TESSERA_BASE_NAME} \
--signer_public_key_secret_name=${SCTFE_SIGNER_ECDSA_P256_PUBLIC_KEY_ID} \
--signer_private_key_secret_name=${SCTFE_SIGNER_ECDSA_P256_PRIVATE_KEY_ID}
```

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


Run the SCTFE with the same roots:

```bash
Expand All @@ -140,11 +146,11 @@ go run ./cmd/gcp/ \
--project_id=${GOOGLE_PROJECT} \
--bucket=${GOOGLE_PROJECT}-${TESSERA_BASE_NAME}-bucket \
--spanner_db_path=projects/${GOOGLE_PROJECT}/instances/${TESSERA_BASE_NAME}/databases/${TESSERA_BASE_NAME}-db \
--private_key=./testdata/ct-http-server.privkey.pem \
--password=dirk \
--roots_pem_file=/tmp/hammercfg/roots.pem \
--origin=${TESSERA_BASE_NAME} \
--spanner_dedup_db_path=projects/${GOOGLE_PROJECT}/instances/${TESSERA_BASE_NAME}/databases/${TESSERA_BASE_NAME}-dedup-db \
--signer_public_key_secret_name=${SCTFE_SIGNER_ECDSA_P256_PUBLIC_KEY_ID} \
--signer_private_key_secret_name=${SCTFE_SIGNER_ECDSA_P256_PRIVATE_KEY_ID} \
-v=3
```

Expand Down
52 changes: 52 additions & 0 deletions deployment/modules/gcp/storage/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,55 @@ resource "google_spanner_database" "dedup_db" {
"CREATE TABLE IDSeq (id INT64 NOT NULL, h BYTES(MAX) NOT NULL, idx INT64 NOT NULL,) PRIMARY KEY (id, h)",
]
}

# Secret Manager

# ECDSA key with P256 elliptic curve. Do NOT use this in production environment.
#
# Security Notice
# The private key generated by this resource will be stored unencrypted in your
# Terraform state file. Use of this resource for production deployments is not
# recommended. Instead, generate a private key file outside of Terraform and
# distribute it securely to the system where Terraform will be run.
#
# See https://registry.terraform.io/providers/hashicorp/tls/latest/docs/resources/private_key.
resource "tls_private_key" "sctfe_ecdsa_p256" {
algorithm = "ECDSA"
ecdsa_curve = "P256"
}

resource "google_secret_manager_secret" "sctfe_ecdsa_p256_public_key" {
secret_id = "sctfe-ecdsa-p256-public-key"

labels = {
label = "sctfe-public-key"
}

replication {
auto {}
}
}

resource "google_secret_manager_secret_version" "sctfe_ecdsa_p256_public_key" {
secret = google_secret_manager_secret.sctfe_ecdsa_p256_public_key.id

secret_data = tls_private_key.sctfe_ecdsa_p256.public_key_pem
}

resource "google_secret_manager_secret" "sctfe_ecdsa_p256_private_key" {
secret_id = "sctfe-ecdsa-p256-private-key"

labels = {
label = "sctfe-private-key"
}

replication {
auto {}
}
}

resource "google_secret_manager_secret_version" "sctfe_ecdsa_p256_private_key" {
secret = google_secret_manager_secret.sctfe_ecdsa_p256_private_key.id

secret_data = tls_private_key.sctfe_ecdsa_p256.private_key_pem
}
Loading
Loading