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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user