diff --git a/README.md b/README.md index c28c455..e662976 100644 --- a/README.md +++ b/README.md @@ -112,10 +112,12 @@ matched by *type*. The registry serves service discovery (`/.well-known/terraform.json`), the `providers.v1` version/download endpoints, and a GPG-signed `SHA256SUMS` per the provider registry protocol. -Signing requires an armored GPG private key, supplied via `TF_SIGNING_KEY_PATH` -(optionally `TF_SIGNING_KEY_PASSPHRASE`). Without it the registry endpoints stay -disabled. `TF_PROVIDER_PROTOCOLS` (default `5.0,6.0`) sets the advertised plugin -protocols. +Signing needs a GPG key. By default artifactapi generates one on first start and +stores it in the database (`signing_keys` table), so every replica shares it and +there's nothing to provision. To bring your own key instead, point +`TF_SIGNING_KEY_PATH` at an armored private key (optionally +`TF_SIGNING_KEY_PASSPHRASE`), which takes precedence over the generated one. +`TF_PROVIDER_PROTOCOLS` (default `5.0,6.0`) sets the advertised plugin protocols. ## Access Control diff --git a/internal/database/postgres.go b/internal/database/postgres.go index 32cc528..0e5b4e8 100644 --- a/internal/database/postgres.go +++ b/internal/database/postgres.go @@ -158,6 +158,13 @@ func (db *DB) migrate() error { ); CREATE INDEX IF NOT EXISTS idx_rpm_metadata_repo ON rpm_metadata(repo_name); + + CREATE TABLE IF NOT EXISTS signing_keys ( + purpose TEXT PRIMARY KEY, + private_key_armor TEXT NOT NULL, + key_id TEXT NOT NULL, + created_at TIMESTAMPTZ DEFAULT NOW() + ); `) return err } diff --git a/internal/database/signing_keys.go b/internal/database/signing_keys.go new file mode 100644 index 0000000..479f092 --- /dev/null +++ b/internal/database/signing_keys.go @@ -0,0 +1,35 @@ +package database + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" +) + +// GetSigningKey returns the stored armored private key and key id for a purpose. +// found is false when no key has been generated yet. +func (db *DB) GetSigningKey(ctx context.Context, purpose string) (armor, keyID string, found bool, err error) { + row := db.Pool.QueryRow(ctx, ` + SELECT private_key_armor, key_id FROM signing_keys WHERE purpose = $1 + `, purpose) + if err := row.Scan(&armor, &keyID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return "", "", false, nil + } + return "", "", false, err + } + return armor, keyID, true, nil +} + +// InsertSigningKeyIfAbsent stores a freshly generated key, doing nothing if +// another replica already inserted one. Callers re-read with GetSigningKey to +// pick up whichever key won the race. +func (db *DB) InsertSigningKeyIfAbsent(ctx context.Context, purpose, armor, keyID string) error { + _, err := db.Pool.Exec(ctx, ` + INSERT INTO signing_keys (purpose, private_key_armor, key_id) + VALUES ($1, $2, $3) + ON CONFLICT (purpose) DO NOTHING + `, purpose, armor, keyID) + return err +} diff --git a/internal/database/signing_keys_test.go b/internal/database/signing_keys_test.go new file mode 100644 index 0000000..25f35d3 --- /dev/null +++ b/internal/database/signing_keys_test.go @@ -0,0 +1,31 @@ +package database + +import "testing" + +func TestSigningKeyRoundTripAndIdempotency(t *testing.T) { + requireDB(t) + + const purpose = "terraform-provider-test" + + // Absent to start. + if _, _, found, err := testDB.GetSigningKey(ctx(), purpose); err != nil || found { + t.Fatalf("expected no key, got found=%v err=%v", found, err) + } + + if err := testDB.InsertSigningKeyIfAbsent(ctx(), purpose, "ARMOR-1", "KEYID1"); err != nil { + t.Fatal(err) + } + + // A second insert must not overwrite (models the replica race). + if err := testDB.InsertSigningKeyIfAbsent(ctx(), purpose, "ARMOR-2", "KEYID2"); err != nil { + t.Fatal(err) + } + + armor, keyID, found, err := testDB.GetSigningKey(ctx(), purpose) + if err != nil || !found { + t.Fatalf("expected key, found=%v err=%v", found, err) + } + if armor != "ARMOR-1" || keyID != "KEYID1" { + t.Errorf("key was overwritten: armor=%q key_id=%q", armor, keyID) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index d017532..91114ec 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -70,11 +70,19 @@ func New(cfg *config.Config, version string) (*Server, error) { virtEngine := virtual.NewEngine(db, engine) collector := gc.New(db, s3, 1*time.Hour) - // A failure to load the signing key must not take the server down: the - // terraform registry simply stays disabled until a valid key is present. - signer, err := tfsign.Load(cfg.TFSigningKeyPath, cfg.TFSigningKeyPassphrase) + // The terraform registry signs with a GPG key. A configured file wins (BYO + // key); otherwise artifactapi generates one on first start and persists it in + // the database so every replica shares it. A failure here must not take the + // server down — the registry just stays disabled. + var signer *tfsign.Signer + if cfg.TFSigningKeyPath != "" { + signer, err = tfsign.Load(cfg.TFSigningKeyPath, cfg.TFSigningKeyPassphrase) + } else { + signer, err = tfsign.LoadOrCreate(context.Background(), db, "terraform-provider") + } if err != nil { slog.Warn("terraform provider registry disabled", "error", err) + signer = nil } tfRegistry := tfregistry.NewHandler(db, signer, cfg.TFProviderProtocols) if tfRegistry.Enabled() { diff --git a/internal/tfsign/signer.go b/internal/tfsign/signer.go index ec1b022..5245448 100644 --- a/internal/tfsign/signer.go +++ b/internal/tfsign/signer.go @@ -4,6 +4,7 @@ package tfsign import ( "bytes" + "context" "fmt" "os" "strings" @@ -12,6 +13,35 @@ import ( "golang.org/x/crypto/openpgp/armor" ) +// KeyStore persists a generated signing key. *database.DB satisfies it. +type KeyStore interface { + GetSigningKey(ctx context.Context, purpose string) (armor, keyID string, found bool, err error) + InsertSigningKeyIfAbsent(ctx context.Context, purpose, armor, keyID string) error +} + +// LoadOrCreate returns a signer for purpose, generating and persisting a new key +// the first time it is needed. It is safe across replicas: a lost insert race +// just re-reads whichever key won. +func LoadOrCreate(ctx context.Context, store KeyStore, purpose string) (*Signer, error) { + armored, _, found, err := store.GetSigningKey(ctx, purpose) + if err != nil { + return nil, err + } + if !found { + newArmor, keyID, err := Generate() + if err != nil { + return nil, err + } + if err := store.InsertSigningKeyIfAbsent(ctx, purpose, newArmor, keyID); err != nil { + return nil, err + } + if armored, _, _, err = store.GetSigningKey(ctx, purpose); err != nil { + return nil, err + } + } + return LoadArmored(armored, "") +} + // Signer holds a decrypted GPG entity and exposes what the registry download // response needs: a detached signature, the armored public key, and the key ID. type Signer struct { @@ -21,30 +51,61 @@ type Signer struct { } // Load reads an armored private key from path, decrypting it with passphrase if -// the key is protected. A blank path returns (nil, nil): signing is optional, and -// a nil *Signer means the terraform registry is disabled. +// the key is protected. A blank path returns (nil, nil): a nil *Signer means the +// caller should fall back to another source (e.g. a DB-stored key). func Load(path, passphrase string) (*Signer, error) { if path == "" { return nil, nil } - - f, err := os.Open(path) + data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("open signing key: %w", err) } - defer f.Close() + return fromArmor(string(data), passphrase, path) +} - keyring, err := openpgp.ReadArmoredKeyRing(f) +// LoadArmored builds a signer from an in-memory armored private key, e.g. one +// read from the database. A blank key returns (nil, nil). +func LoadArmored(armored, passphrase string) (*Signer, error) { + if armored == "" { + return nil, nil + } + return fromArmor(armored, passphrase, "stored key") +} + +// Generate creates a fresh signing keypair and returns the armored private key +// (to persist) and its uppercase key id. +func Generate() (armoredPrivateKey, keyID string, err error) { + entity, err := openpgp.NewEntity("artifactapi terraform registry", "provider signing", "artifactapi@localhost", nil) + if err != nil { + return "", "", err + } + var buf bytes.Buffer + w, err := armor.Encode(&buf, openpgp.PrivateKeyType, nil) + if err != nil { + return "", "", err + } + if err := entity.SerializePrivate(w, nil); err != nil { + return "", "", err + } + if err := w.Close(); err != nil { + return "", "", err + } + return buf.String(), strings.ToUpper(entity.PrimaryKey.KeyIdString()), nil +} + +func fromArmor(armored, passphrase, src string) (*Signer, error) { + keyring, err := openpgp.ReadArmoredKeyRing(strings.NewReader(armored)) if err != nil { return nil, fmt.Errorf("read signing key: %w", err) } if len(keyring) == 0 { - return nil, fmt.Errorf("signing key %q contains no entities", path) + return nil, fmt.Errorf("signing key (%s) contains no entities", src) } entity := keyring[0] if entity.PrivateKey == nil { - return nil, fmt.Errorf("signing key %q has no private key material", path) + return nil, fmt.Errorf("signing key (%s) has no private key material", src) } if entity.PrivateKey.Encrypted { if err := decrypt(entity, passphrase); err != nil { diff --git a/internal/tfsign/signer_test.go b/internal/tfsign/signer_test.go index b6c1c6a..03a6a18 100644 --- a/internal/tfsign/signer_test.go +++ b/internal/tfsign/signer_test.go @@ -2,6 +2,7 @@ package tfsign import ( "bytes" + "context" "os" "path/filepath" "regexp" @@ -69,6 +70,74 @@ func TestLoadSignAndVerify(t *testing.T) { } } +func TestGenerateAndLoadArmored(t *testing.T) { + priv, keyID, err := Generate() + if err != nil { + t.Fatal(err) + } + if !regexp.MustCompile(`^[0-9A-F]{16}$`).MatchString(keyID) { + t.Errorf("generated key id %q malformed", keyID) + } + + s, err := LoadArmored(priv, "") + if err != nil { + t.Fatal(err) + } + if s.KeyID() != keyID { + t.Errorf("loaded key id %q != generated %q", s.KeyID(), keyID) + } + + msg := []byte("abc terraform-provider-x_1.0.0_linux_amd64.zip\n") + sig, err := s.Sign(msg) + if err != nil { + t.Fatal(err) + } + keyring, _ := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(s.PublicKeyArmor()))) + if _, err := openpgp.CheckDetachedSignature(keyring, bytes.NewReader(msg), bytes.NewReader(sig)); err != nil { + t.Errorf("signature did not verify: %v", err) + } +} + +// memStore is an in-memory KeyStore that records how many keys it accepted. +type memStore struct { + armor, keyID string + found bool + inserts int +} + +func (m *memStore) GetSigningKey(_ context.Context, _ string) (string, string, bool, error) { + return m.armor, m.keyID, m.found, nil +} + +func (m *memStore) InsertSigningKeyIfAbsent(_ context.Context, _, armor, keyID string) error { + if !m.found { // ON CONFLICT DO NOTHING + m.armor, m.keyID, m.found = armor, keyID, true + m.inserts++ + } + return nil +} + +func TestLoadOrCreateGeneratesOnceThenReuses(t *testing.T) { + store := &memStore{} + + first, err := LoadOrCreate(context.Background(), store, "terraform-provider") + if err != nil || first == nil { + t.Fatalf("first LoadOrCreate: signer=%v err=%v", first, err) + } + + second, err := LoadOrCreate(context.Background(), store, "terraform-provider") + if err != nil || second == nil { + t.Fatalf("second LoadOrCreate: signer=%v err=%v", second, err) + } + + if store.inserts != 1 { + t.Errorf("expected exactly one key generated, got %d", store.inserts) + } + if first.KeyID() != second.KeyID() { + t.Errorf("key id changed between loads: %q vs %q", first.KeyID(), second.KeyID()) + } +} + func TestLoadEmptyPathDisabled(t *testing.T) { s, err := Load("", "") if err != nil {