feat: serve local terraform repos as a provider registry
Local terraform repos already spoke the network mirror protocol, which needs
per-consumer .terraformrc config. This adds the provider registry protocol so
`terraform init` installs from a bare source address
(artifactapi.k8s.../{repo}/{type}) with no client setup.
- serve /.well-known/terraform.json service discovery and the providers.v1
versions/download endpoints under /terraform/v1/providers
- map the Terraform namespace to the artifactapi repo name and locate the
provider by type; download_url points back at the existing local file path
- generate SHA256SUMS per version and sign it with a GPG key loaded from
TF_SIGNING_KEY_PATH; advertise the public key + key id in the download
response. No key configured -> registry stays disabled (endpoints 404)
- new internal/tfsign (key loading + detached signing) and
internal/api/terraform (registry handler); export ParseProviderZip for reuse
- add TF_SIGNING_KEY_PATH/PASSPHRASE and TF_PROVIDER_PROTOCOLS config
- unit test signing + verification; dockerised test of the full flow incl.
signature verification against the advertised key
Also anchor the terraform/ gitignore to the repo root so it stops swallowing
internal/api/terraform and internal/provider/terraform test files (the latter
had gone silently untracked).
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
// Package tfsign loads a GPG signing key and produces the detached signatures
|
||||
// the Terraform provider registry protocol requires over SHA256SUMS files.
|
||||
package tfsign
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/openpgp"
|
||||
"golang.org/x/crypto/openpgp/armor"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
entity *openpgp.Entity
|
||||
publicASCII string
|
||||
keyID string
|
||||
}
|
||||
|
||||
// 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.
|
||||
func Load(path, passphrase string) (*Signer, error) {
|
||||
if path == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open signing key: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
keyring, err := openpgp.ReadArmoredKeyRing(f)
|
||||
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)
|
||||
}
|
||||
entity := keyring[0]
|
||||
|
||||
if entity.PrivateKey == nil {
|
||||
return nil, fmt.Errorf("signing key %q has no private key material", path)
|
||||
}
|
||||
if entity.PrivateKey.Encrypted {
|
||||
if err := decrypt(entity, passphrase); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
pub, err := armorPublicKey(entity)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Signer{
|
||||
entity: entity,
|
||||
publicASCII: pub,
|
||||
keyID: entity.PrimaryKey.KeyIdString(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// decrypt unlocks the entity's private key and all subkeys with the passphrase.
|
||||
func decrypt(entity *openpgp.Entity, passphrase string) error {
|
||||
pw := []byte(passphrase)
|
||||
if err := entity.PrivateKey.Decrypt(pw); err != nil {
|
||||
return fmt.Errorf("decrypt signing key: %w", err)
|
||||
}
|
||||
for _, sub := range entity.Subkeys {
|
||||
if sub.PrivateKey != nil && sub.PrivateKey.Encrypted {
|
||||
_ = sub.PrivateKey.Decrypt(pw)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func armorPublicKey(entity *openpgp.Entity) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
w, err := armor.Encode(&buf, openpgp.PublicKeyType, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := entity.Serialize(w); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
// Sign returns a binary detached signature over message, matching the
|
||||
// SHA256SUMS.sig format Terraform verifies.
|
||||
func (s *Signer) Sign(message []byte) ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := openpgp.DetachSign(&buf, s.entity, bytes.NewReader(message), nil); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// PublicKeyArmor returns the ASCII-armored public key for the registry's
|
||||
// signing_keys response.
|
||||
func (s *Signer) PublicKeyArmor() string { return s.publicASCII }
|
||||
|
||||
// KeyID returns the 16-hex-char uppercase key ID Terraform matches against the
|
||||
// signature's issuer.
|
||||
func (s *Signer) KeyID() string { return strings.ToUpper(s.keyID) }
|
||||
@@ -0,0 +1,86 @@
|
||||
package tfsign
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/openpgp"
|
||||
"golang.org/x/crypto/openpgp/armor"
|
||||
)
|
||||
|
||||
// armoredPrivateKey generates a throwaway armored private key for tests.
|
||||
func armoredPrivateKey(t *testing.T) string {
|
||||
t.Helper()
|
||||
e, err := openpgp.NewEntity("artifactapi test", "tf registry", "tf@example.com", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
w, err := armor.Encode(&buf, openpgp.PrivateKeyType, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := e.SerializePrivate(w, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.Close()
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func writeKey(t *testing.T, contents string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(t.TempDir(), "private-key.asc")
|
||||
if err := os.WriteFile(p, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestLoadSignAndVerify(t *testing.T) {
|
||||
path := writeKey(t, armoredPrivateKey(t))
|
||||
s, err := Load(path, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s == nil {
|
||||
t.Fatal("expected a signer")
|
||||
}
|
||||
|
||||
if !regexp.MustCompile(`^[0-9A-F]{16}$`).MatchString(s.KeyID()) {
|
||||
t.Errorf("key id %q is not 16 uppercase hex chars", s.KeyID())
|
||||
}
|
||||
|
||||
msg := []byte("deadbeef terraform-provider-x_1.0.0_linux_amd64.zip\n")
|
||||
sig, err := s.Sign(msg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// The advertised public key must verify the signature over the same bytes.
|
||||
keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(s.PublicKeyArmor())))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := openpgp.CheckDetachedSignature(keyring, bytes.NewReader(msg), bytes.NewReader(sig)); err != nil {
|
||||
t.Errorf("signature did not verify: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEmptyPathDisabled(t *testing.T) {
|
||||
s, err := Load("", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s != nil {
|
||||
t.Error("empty path should yield a nil (disabled) signer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMissingFile(t *testing.T) {
|
||||
if _, err := Load(filepath.Join(t.TempDir(), "nope.asc"), ""); err == nil {
|
||||
t.Error("expected an error for a missing key file")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user