feat: self-generate and store the terraform registry signing key
Rather than requiring an operator to create a GPG key and a K8s secret, the registry now provisions itself: on first start artifactapi generates a signing keypair and persists it in a new signing_keys table, so all replicas share one key and there is nothing to set up. TF_SIGNING_KEY_PATH still overrides with a bring-your-own key when set. - signing_keys table + GetSigningKey / InsertSigningKeyIfAbsent (ON CONFLICT DO NOTHING so a replica race converges on one key) - tfsign.Generate, LoadArmored, and LoadOrCreate(store, purpose) - server prefers a configured key file, else LoadOrCreate against the DB - tests: generate/load round-trip, load-or-create generates once then reuses, DB insert idempotency
This commit is contained in:
@@ -112,10 +112,12 @@ matched by *type*. The registry serves service discovery
|
|||||||
(`/.well-known/terraform.json`), the `providers.v1` version/download endpoints,
|
(`/.well-known/terraform.json`), the `providers.v1` version/download endpoints,
|
||||||
and a GPG-signed `SHA256SUMS` per the provider registry protocol.
|
and a GPG-signed `SHA256SUMS` per the provider registry protocol.
|
||||||
|
|
||||||
Signing requires an armored GPG private key, supplied via `TF_SIGNING_KEY_PATH`
|
Signing needs a GPG key. By default artifactapi generates one on first start and
|
||||||
(optionally `TF_SIGNING_KEY_PASSPHRASE`). Without it the registry endpoints stay
|
stores it in the database (`signing_keys` table), so every replica shares it and
|
||||||
disabled. `TF_PROVIDER_PROTOCOLS` (default `5.0,6.0`) sets the advertised plugin
|
there's nothing to provision. To bring your own key instead, point
|
||||||
protocols.
|
`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
|
## Access Control
|
||||||
|
|
||||||
|
|||||||
@@ -158,6 +158,13 @@ func (db *DB) migrate() error {
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_rpm_metadata_repo ON rpm_metadata(repo_name);
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,11 +70,19 @@ func New(cfg *config.Config, version string) (*Server, error) {
|
|||||||
virtEngine := virtual.NewEngine(db, engine)
|
virtEngine := virtual.NewEngine(db, engine)
|
||||||
collector := gc.New(db, s3, 1*time.Hour)
|
collector := gc.New(db, s3, 1*time.Hour)
|
||||||
|
|
||||||
// A failure to load the signing key must not take the server down: the
|
// The terraform registry signs with a GPG key. A configured file wins (BYO
|
||||||
// terraform registry simply stays disabled until a valid key is present.
|
// key); otherwise artifactapi generates one on first start and persists it in
|
||||||
signer, err := tfsign.Load(cfg.TFSigningKeyPath, cfg.TFSigningKeyPassphrase)
|
// 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 {
|
if err != nil {
|
||||||
slog.Warn("terraform provider registry disabled", "error", err)
|
slog.Warn("terraform provider registry disabled", "error", err)
|
||||||
|
signer = nil
|
||||||
}
|
}
|
||||||
tfRegistry := tfregistry.NewHandler(db, signer, cfg.TFProviderProtocols)
|
tfRegistry := tfregistry.NewHandler(db, signer, cfg.TFProviderProtocols)
|
||||||
if tfRegistry.Enabled() {
|
if tfRegistry.Enabled() {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ package tfsign
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -12,6 +13,35 @@ import (
|
|||||||
"golang.org/x/crypto/openpgp/armor"
|
"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
|
// 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.
|
// response needs: a detached signature, the armored public key, and the key ID.
|
||||||
type Signer struct {
|
type Signer struct {
|
||||||
@@ -21,30 +51,61 @@ type Signer struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load reads an armored private key from path, decrypting it with passphrase if
|
// 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
|
// the key is protected. A blank path returns (nil, nil): a nil *Signer means the
|
||||||
// a nil *Signer means the terraform registry is disabled.
|
// caller should fall back to another source (e.g. a DB-stored key).
|
||||||
func Load(path, passphrase string) (*Signer, error) {
|
func Load(path, passphrase string) (*Signer, error) {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
f, err := os.Open(path)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("open signing key: %w", err)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read signing key: %w", err)
|
return nil, fmt.Errorf("read signing key: %w", err)
|
||||||
}
|
}
|
||||||
if len(keyring) == 0 {
|
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]
|
entity := keyring[0]
|
||||||
|
|
||||||
if entity.PrivateKey == nil {
|
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 entity.PrivateKey.Encrypted {
|
||||||
if err := decrypt(entity, passphrase); err != nil {
|
if err := decrypt(entity, passphrase); err != nil {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package tfsign
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"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) {
|
func TestLoadEmptyPathDisabled(t *testing.T) {
|
||||||
s, err := Load("", "")
|
s, err := Load("", "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user