diff --git a/command/init.go b/command/init.go index dc153cfa4f6e..e5890a62bef8 100644 --- a/command/init.go +++ b/command/init.go @@ -508,6 +508,17 @@ func (c *InitCommand) getProviders(earlyConfig *earlyconfig.Config, state *state fmt.Sprintf("Error while installing %s v%s: %s.", provider.ForDisplay(), version, err), )) }, + FetchPackageSuccess: func(provider addrs.Provider, version getproviders.Version, localDir string, authResult *getproviders.PackageAuthenticationResult) { + var warning string + if authResult != nil { + warning = authResult.Warning + } + if warning != "" { + warning = c.Colorize().Color(fmt.Sprintf("\n [reset][yellow]Warning: %s[reset]", warning)) + } + + c.Ui.Info(fmt.Sprintf("- Installed %s v%s (%s)%s", provider.ForDisplay(), version, authResult, warning)) + }, } mode := providercache.InstallNewProvidersOnly diff --git a/command/init_test.go b/command/init_test.go index df81157da52f..1ffed35a00f4 100644 --- a/command/init_test.go +++ b/command/init_test.go @@ -945,6 +945,10 @@ func TestInit_providerSource(t *testing.T) { t.Errorf("wrong version selections after upgrade\n%s", diff) } + outputStr := ui.OutputWriter.String() + if want := "Installed hashicorp/test v1.2.3 (verified checksum)"; !strings.Contains(outputStr, want) { + t.Fatalf("unexpected output: %s\nexpected to include %q", outputStr, want) + } } func TestInit_getUpgradePlugins(t *testing.T) { @@ -1101,7 +1105,7 @@ func TestInit_getProviderMissing(t *testing.T) { args := []string{} if code := c.Run(args); code == 0 { - t.Fatalf("expceted error, got output: \n%s", ui.OutputWriter.String()) + t.Fatalf("expected error, got output: \n%s", ui.OutputWriter.String()) } if !strings.Contains(ui.ErrorWriter.String(), "no available releases match") { @@ -1619,7 +1623,7 @@ func installFakeProviderPackagesElsewhere(t *testing.T, cacheDir *providercache. if err != nil { t.Fatalf("failed to prepare fake package for %s %s: %s", name, versionStr, err) } - err = cacheDir.InstallPackage(context.Background(), meta) + _, err = cacheDir.InstallPackage(context.Background(), meta) if err != nil { t.Fatalf("failed to install fake package for %s %s: %s", name, versionStr, err) } diff --git a/internal/getproviders/mock_source.go b/internal/getproviders/mock_source.go index 3c2e80e70d98..fbe558228221 100644 --- a/internal/getproviders/mock_source.go +++ b/internal/getproviders/mock_source.go @@ -2,7 +2,9 @@ package getproviders import ( "archive/zip" + "crypto/sha256" "fmt" + "io" "io/ioutil" "os" @@ -168,6 +170,14 @@ func FakeInstallablePackageMeta(provider addrs.Provider, version Version, target return PackageMeta{}, close, fmt.Errorf("failed to close the mock zip file: %s", err) } + // Compute the SHA256 checksum of the generated file, to allow package + // authentication code to be exercised. + f.Seek(0, io.SeekStart) + h := sha256.New() + io.Copy(h, f) + checksum := [32]byte{} + h.Sum(checksum[:0]) + meta := PackageMeta{ Provider: provider, Version: version, @@ -181,6 +191,8 @@ func FakeInstallablePackageMeta(provider addrs.Provider, version Version, target // (At the time of writing, no caller actually does that, but who // knows what the future holds?) Filename: fmt.Sprintf("terraform-provider-%s_%s_%s.zip", provider.Type, version.String(), target.String()), + + Authentication: NewArchiveChecksumAuthentication(checksum), } return meta, close, nil } diff --git a/internal/getproviders/package_authentication.go b/internal/getproviders/package_authentication.go index 7aba5eb0f2e6..810363e4d2d3 100644 --- a/internal/getproviders/package_authentication.go +++ b/internal/getproviders/package_authentication.go @@ -3,11 +3,60 @@ package getproviders import ( "bytes" "crypto/sha256" + "encoding/hex" "fmt" "io" + "log" "os" + "strings" + + "golang.org/x/crypto/openpgp" + openpgpArmor "golang.org/x/crypto/openpgp/armor" + openpgpErrors "golang.org/x/crypto/openpgp/errors" +) + +type packageAuthenticationResult int + +const ( + verifiedChecksum packageAuthenticationResult = iota + officialProvider + partnerProvider + communityProvider ) +// PackageAuthenticationResult is returned from a PackageAuthentication +// implementation. It is a mostly-opaque type intended for use in UI, which +// implements Stringer and includes an optional Warning field. +// +// A failed PackageAuthentication attempt will return an "unauthenticated" +// result, which is represented by nil. +type PackageAuthenticationResult struct { + result packageAuthenticationResult + Warning string +} + +func (t *PackageAuthenticationResult) String() string { + if t == nil { + return "unauthenticated" + } + return []string{ + "verified checksum", + "official provider", + "partner provider", + "community provider", + }[t.result] +} + +// SigningKey represents a key used to sign packages from a registry, along +// with an optional trust signature from the registry operator. These are +// both in ASCII armored OpenPGP format. +// +// The JSON struct tags represent the field names used by the Registry API. +type SigningKey struct { + ASCIIArmor string `json:"ascii_armor"` + TrustSignature string `json:"trust_signature"` +} + // PackageAuthentication is an interface implemented by the optional package // authentication implementations a source may include on its PackageMeta // objects. @@ -16,15 +65,14 @@ import ( // that a package is what its distributor intended to distribute and that it // has not been tampered with. type PackageAuthentication interface { - // AuthenticatePackage takes the metadata about the package as returned - // by its original source, and also the "localLocation" where it has - // been staged for local inspection (which may or may not be the same - // as the original source location) and returns an error if the - // authentication checks fail. + // AuthenticatePackage takes the local location of a package (which may or + // may not be the same as the original source location), and returns a + // PackageAuthenticationResult, or an error if the authentication checks + // fail. // - // The localLocation is guaranteed not to be a PackageHTTPURL: a - // remote package will always be staged locally for inspection first. - AuthenticatePackage(meta PackageMeta, localLocation PackageLocation) error + // The local location is guaranteed not to be a PackageHTTPURL: a remote + // package will always be staged locally for inspection first. + AuthenticatePackage(localLocation PackageLocation) (*PackageAuthenticationResult, error) } type packageAuthenticationAll []PackageAuthentication @@ -34,18 +82,23 @@ type packageAuthenticationAll []PackageAuthentication // // The checks are processed in the order given, so a failure of an earlier // check will prevent execution of a later one. +// +// The returned result is from the last authentication, so callers should +// take care to order the authentications such that the strongest is last. func PackageAuthenticationAll(checks ...PackageAuthentication) PackageAuthentication { return packageAuthenticationAll(checks) } -func (checks packageAuthenticationAll) AuthenticatePackage(meta PackageMeta, localLocation PackageLocation) error { +func (checks packageAuthenticationAll) AuthenticatePackage(localLocation PackageLocation) (*PackageAuthenticationResult, error) { + var authResult *PackageAuthenticationResult for _, check := range checks { - err := check.AuthenticatePackage(meta, localLocation) + var err error + authResult, err = check.AuthenticatePackage(localLocation) if err != nil { - return err + return authResult, err } } - return nil + return authResult, nil } type archiveHashAuthentication struct { @@ -65,29 +118,222 @@ func NewArchiveChecksumAuthentication(wantSHA256Sum [sha256.Size]byte) PackageAu return archiveHashAuthentication{wantSHA256Sum} } -func (a archiveHashAuthentication) AuthenticatePackage(meta PackageMeta, localLocation PackageLocation) error { +func (a archiveHashAuthentication) AuthenticatePackage(localLocation PackageLocation) (*PackageAuthenticationResult, error) { archiveLocation, ok := localLocation.(PackageLocalArchive) if !ok { // A source should not use this authentication type for non-archive // locations. - return fmt.Errorf("cannot check archive hash for non-archive location %s", localLocation) + return nil, fmt.Errorf("cannot check archive hash for non-archive location %s", localLocation) } f, err := os.Open(string(archiveLocation)) if err != nil { - return err + return nil, err } defer f.Close() h := sha256.New() _, err = io.Copy(h, f) if err != nil { - return err + return nil, err } gotHash := h.Sum(nil) if !bytes.Equal(gotHash, a.WantSHA256Sum[:]) { - return fmt.Errorf("archive has incorrect SHA-256 checksum %x (expected %x)", gotHash, a.WantSHA256Sum[:]) + return nil, fmt.Errorf("archive has incorrect SHA-256 checksum %x (expected %x)", gotHash, a.WantSHA256Sum[:]) + } + return &PackageAuthenticationResult{result: verifiedChecksum}, nil +} + +type matchingChecksumAuthentication struct { + Document []byte + Filename string + WantSHA256Sum [sha256.Size]byte +} + +// NewMatchingChecksumAuthentication returns a PackageAuthentication +// implementation that scans a registry-provided SHA256SUMS document for a +// specified filename, and compares the SHA256 hash against the expected hash. +// This is necessary to ensure that the signed SHA256SUMS document matches the +// declared SHA256 hash for the package, and therefore that a valid signature +// of this document authenticates the package. +// +// This authentication always returns a nil result, since it alone cannot offer +// any assertions about package integrity. It should be combined with other +// authentications to be useful. +func NewMatchingChecksumAuthentication(document []byte, filename string, wantSHA256Sum [sha256.Size]byte) PackageAuthentication { + return matchingChecksumAuthentication{ + Document: document, + Filename: filename, + WantSHA256Sum: wantSHA256Sum, + } +} + +func (m matchingChecksumAuthentication) AuthenticatePackage(location PackageLocation) (*PackageAuthenticationResult, error) { + // Find the checksum in the list with matching filename. The document is + // in the form "0123456789abcdef filename.zip". + filename := []byte(m.Filename) + var checksum []byte + for _, line := range bytes.Split(m.Document, []byte("\n")) { + parts := bytes.Fields(line) + if len(parts) > 1 && bytes.Equal(parts[1], filename) { + checksum = parts[0] + break + } + } + if checksum == nil { + return nil, fmt.Errorf("checksum list has no SHA-256 hash for %q", m.Filename) + } + + // Decode the ASCII checksum into a byte array for comparison. + var gotSHA256Sum [sha256.Size]byte + if _, err := hex.Decode(gotSHA256Sum[:], checksum); err != nil { + return nil, fmt.Errorf("checksum list has invalid SHA256 hash %q: %s", string(checksum), err) + } + + // If the checksums don't match, authentication fails. + if !bytes.Equal(gotSHA256Sum[:], m.WantSHA256Sum[:]) { + return nil, fmt.Errorf("checksum list has unexpected SHA-256 hash %x (expected %x)", gotSHA256Sum, m.WantSHA256Sum[:]) + } + + // Success! But this doesn't result in any real authentication, only a + // lack of authentication errors, so we return a nil result. + return nil, nil +} + +type signatureAuthentication struct { + Document []byte + Signature []byte + Keys []SigningKey +} + +// NewSignatureAuthentication returns a PackageAuthentication implementation +// that verifies the cryptographic signature for a package against any of the +// provided keys. +// +// The signing key for a package will be auto detected by attempting each key +// in turn until one is successful. If such a key is found, there are three +// possible successful authentication results: +// +// 1. If the signing key is the HashiCorp official key, it is an official +// provider; +// 2. Otherwise, if the signing key has a trust signature from the HashiCorp +// Partners key, it is a partner provider; +// 3. If neither of the above is true, it is a community provider. +// +// Any failure in the process of validating the signature will result in an +// unauthenticated result. +func NewSignatureAuthentication(document, signature []byte, keys []SigningKey) PackageAuthentication { + return signatureAuthentication{ + Document: document, + Signature: signature, + Keys: keys, + } +} + +func (s signatureAuthentication) AuthenticatePackage(location PackageLocation) (*PackageAuthenticationResult, error) { + // Find the key that signed the checksum file. This can fail if there is no + // valid signature for any of the provided keys. + signingKey, err := s.findSigningKey() + if err != nil { + return nil, err + } + + // Verify the signature using the HashiCorp public key. If this succeeds, + // this is an official provider. + hashicorpKeyring, err := openpgp.ReadArmoredKeyRing(strings.NewReader(HashicorpPublicKey)) + if err != nil { + return nil, fmt.Errorf("error creating HashiCorp keyring: %s", err) + } + _, err = openpgp.CheckDetachedSignature(hashicorpKeyring, bytes.NewReader(s.Document), bytes.NewReader(s.Signature)) + if err == nil { + return &PackageAuthenticationResult{result: officialProvider}, nil + } + + // If the signing key has a trust signature, attempt to verify it with the + // HashiCorp partners public key. + if signingKey.TrustSignature != "" { + hashicorpPartnersKeyring, err := openpgp.ReadArmoredKeyRing(strings.NewReader(HashicorpPartnersKey)) + if err != nil { + return nil, fmt.Errorf("error creating HashiCorp Partners keyring: %s", err) + } + + authorKey, err := openpgpArmor.Decode(strings.NewReader(signingKey.ASCIIArmor)) + if err != nil { + return nil, fmt.Errorf("error decoding signing key: %s", err) + } + + trustSignature, err := openpgpArmor.Decode(strings.NewReader(signingKey.TrustSignature)) + if err != nil { + return nil, fmt.Errorf("error decoding trust signature: %s", err) + } + + _, err = openpgp.CheckDetachedSignature(hashicorpPartnersKeyring, authorKey.Body, trustSignature.Body) + if err != nil { + return nil, fmt.Errorf("error verifying trust signature: %s", err) + } + + return &PackageAuthenticationResult{result: partnerProvider}, nil + } + + // We have a valid signature, but it's not from the HashiCorp key, and it + // also isn't a trusted partner. This is a community provider. + // FIXME: we may want to add a more detailed warning here explaining the + // difference between partner and community providers. + return &PackageAuthenticationResult{result: communityProvider}, nil +} + +// findSigningKey attempts to verify the signature using each of the keys +// returned by the registry. If a valid signature is found, it returns the +// signing key. +// +// Note: currently the registry only returns one key, but this may change in +// the future. +func (s signatureAuthentication) findSigningKey() (*SigningKey, error) { + for _, key := range s.Keys { + keyring, err := openpgp.ReadArmoredKeyRing(strings.NewReader(key.ASCIIArmor)) + if err != nil { + return nil, fmt.Errorf("error decoding signing key: %s", err) + } + + entity, err := openpgp.CheckDetachedSignature(keyring, bytes.NewReader(s.Document), bytes.NewReader(s.Signature)) + + // If the signature issuer does not match the the key, keep trying the + // rest of the provided keys. + if err == openpgpErrors.ErrUnknownIssuer { + continue + } + + // Any other signature error is terminal. + if err != nil { + return nil, fmt.Errorf("error checking signature: %s", err) + } + + log.Printf("[DEBUG] Provider signed by %s", entityString(entity)) + return &key, nil + } + + // If none of the provided keys issued the signature, this package is + // unsigned. This is currently a terminal authentication error. + return nil, fmt.Errorf("authentication signature from unknown issuer") +} + +// entityString extracts the key ID and identity name(s) from an openpgp.Entity +// for logging. +func entityString(entity *openpgp.Entity) string { + if entity == nil { + return "" } - return nil + + keyID := "n/a" + if entity.PrimaryKey != nil { + keyID = entity.PrimaryKey.KeyIdString() + } + + var names []string + for _, identity := range entity.Identities { + names = append(names, identity.Name) + } + + return fmt.Sprintf("%s %s", keyID, strings.Join(names, ", ")) } diff --git a/internal/getproviders/package_authentication_test.go b/internal/getproviders/package_authentication_test.go new file mode 100644 index 000000000000..f14b2e0bbe06 --- /dev/null +++ b/internal/getproviders/package_authentication_test.go @@ -0,0 +1,566 @@ +package getproviders + +import ( + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "strings" + "testing" + + "golang.org/x/crypto/openpgp" +) + +func TestPackageAuthenticationResult(t *testing.T) { + tests := []struct { + result *PackageAuthenticationResult + want string + }{ + { + nil, + "unauthenticated", + }, + { + &PackageAuthenticationResult{result: verifiedChecksum}, + "verified checksum", + }, + { + &PackageAuthenticationResult{result: officialProvider}, + "official provider", + }, + { + &PackageAuthenticationResult{result: partnerProvider}, + "partner provider", + }, + { + &PackageAuthenticationResult{result: communityProvider}, + "community provider", + }, + } + for _, test := range tests { + if got := test.result.String(); got != test.want { + t.Errorf("wrong value: got %q, want %q", got, test.want) + } + } +} + +// mockAuthentication is an implementation of the PackageAuthentication +// interface which returns fixed values. This is used to test the combining +// logic of PackageAuthenticationAll. +type mockAuthentication struct { + result packageAuthenticationResult + err error +} + +func (m mockAuthentication) AuthenticatePackage(localLocation PackageLocation) (*PackageAuthenticationResult, error) { + if m.err == nil { + return &PackageAuthenticationResult{result: m.result}, nil + } else { + return nil, m.err + } +} + +var _ PackageAuthentication = (*mockAuthentication)(nil) + +// If all authentications succeed, the returned result should come from the +// last authentication. +func TestPackageAuthenticationAll_success(t *testing.T) { + result, err := PackageAuthenticationAll( + &mockAuthentication{result: verifiedChecksum}, + &mockAuthentication{result: communityProvider}, + ).AuthenticatePackage(nil) + + want := PackageAuthenticationResult{result: communityProvider} + if result == nil || *result != want { + t.Errorf("wrong result: want %#v, got %#v", want, result) + } + if err != nil { + t.Errorf("wrong err: got %#v, want nil", err) + } +} + +// If an authentication fails, its error should be returned along with a nil +// result. +func TestPackageAuthenticationAll_failure(t *testing.T) { + someError := errors.New("some error") + result, err := PackageAuthenticationAll( + &mockAuthentication{result: verifiedChecksum}, + &mockAuthentication{err: someError}, + &mockAuthentication{result: communityProvider}, + ).AuthenticatePackage(nil) + + if result != nil { + t.Errorf("wrong result: got %#v, want nil", result) + } + if err != someError { + t.Errorf("wrong err: got %#v, want %#v", err, someError) + } +} + +// Archive checksum authentication requires a file fixture and a known-good +// SHA256 hash. The result should be "verified checksum". +func TestArchiveChecksumAuthentication_success(t *testing.T) { + // Location must be a PackageLocalArchive path + location := PackageLocalArchive("testdata/filesystem-mirror/registry.terraform.io/hashicorp/null/terraform-provider-null_2.1.0_linux_amd64.zip") + + // Known-good SHA256 hash for this archive + wantSHA256Sum := [sha256.Size]byte{ + 0x4f, 0xb3, 0x98, 0x49, 0xf2, 0xe1, 0x38, 0xeb, + 0x16, 0xa1, 0x8b, 0xa0, 0xc6, 0x82, 0x63, 0x5d, + 0x78, 0x1c, 0xb8, 0xc3, 0xb2, 0x59, 0x01, 0xdd, + 0x5a, 0x79, 0x2a, 0xde, 0x97, 0x11, 0xf5, 0x01, + } + + auth := NewArchiveChecksumAuthentication(wantSHA256Sum) + result, err := auth.AuthenticatePackage(location) + + wantResult := PackageAuthenticationResult{result: verifiedChecksum} + if result == nil || *result != wantResult { + t.Errorf("wrong result: got %#v, want %#v", result, wantResult) + } + if err != nil { + t.Errorf("wrong err: got %s, want nil", err) + } +} + +// Archive checksum authentication can fail for various reasons. These test +// cases are almost exhaustive, missing only an io.Copy error which is +// difficult to induce. +func TestArchiveChecksumAuthentication_failure(t *testing.T) { + tests := map[string]struct { + location PackageLocation + err string + }{ + "missing file": { + PackageLocalArchive("testdata/no-package-here.zip"), + "open testdata/no-package-here.zip: no such file or directory", + }, + "checksum mismatch": { + PackageLocalArchive("testdata/filesystem-mirror/registry.terraform.io/hashicorp/null/terraform-provider-null_2.1.0_linux_amd64.zip"), + "archive has incorrect SHA-256 checksum 4fb39849f2e138eb16a18ba0c682635d781cb8c3b25901dd5a792ade9711f501 (expected 0000000000000000000000000000000000000000000000000000000000000000)", + }, + "invalid location": { + PackageLocalDir("testdata/filesystem-mirror/tfe.example.com/AwesomeCorp/happycloud/0.1.0-alpha.2/darwin_amd64"), + "cannot check archive hash for non-archive location testdata/filesystem-mirror/tfe.example.com/AwesomeCorp/happycloud/0.1.0-alpha.2/darwin_amd64", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + // Zero expected checksum, either because we'll error before we + // reach it, or we want to force a checksum mismatch + auth := NewArchiveChecksumAuthentication([sha256.Size]byte{0}) + result, err := auth.AuthenticatePackage(test.location) + + if result != nil { + t.Errorf("wrong result: got %#v, want nil", result) + } + if gotErr := err.Error(); gotErr != test.err { + t.Errorf("wrong err: got %q, want %q", gotErr, test.err) + } + }) + } +} + +// Matching checksum authentication takes a SHA256SUMS document, an archive +// filename, and an expected SHA256 hash. On success both return values should +// be nil. +func TestMatchingChecksumAuthentication_success(t *testing.T) { + // Location is unused + location := PackageLocalArchive("testdata/my-package.zip") + + // Two different checksums for other files + wantSHA256Sum := [sha256.Size]byte{0xde, 0xca, 0xde} + otherSHA256Sum := [sha256.Size]byte{0xc0, 0xff, 0xee} + + document := []byte( + fmt.Sprintf( + "%x README.txt\n%x my-package.zip\n", + otherSHA256Sum, + wantSHA256Sum, + ), + ) + filename := "my-package.zip" + + auth := NewMatchingChecksumAuthentication(document, filename, wantSHA256Sum) + result, err := auth.AuthenticatePackage(location) + + if result != nil { + t.Errorf("wrong result: got %#v, want nil", result) + } + if err != nil { + t.Errorf("wrong err: got %s, want nil", err) + } +} + +// Matching checksum authentication can fail for three reasons: no checksum +// in the document for the filename, invalid checksum value, and non-matching +// checksum value. +func TestMatchingChecksumAuthentication_failure(t *testing.T) { + wantSHA256Sum := [sha256.Size]byte{0xde, 0xca, 0xde} + filename := "my-package.zip" + + tests := map[string]struct { + document []byte + err string + }{ + "no checksum for filename": { + []byte( + fmt.Sprintf( + "%x README.txt", + [sha256.Size]byte{0xbe, 0xef}, + ), + ), + `checksum list has no SHA-256 hash for "my-package.zip"`, + }, + "invalid checksum": { + []byte( + fmt.Sprintf( + "%s README.txt\n%s my-package.zip", + "horses", + "chickens", + ), + ), + `checksum list has invalid SHA256 hash "chickens": encoding/hex: invalid byte: U+0068 'h'`, + }, + "checksum mismatch": { + []byte( + fmt.Sprintf( + "%x README.txt\n%x my-package.zip", + [sha256.Size]byte{0xbe, 0xef}, + [sha256.Size]byte{0xc0, 0xff, 0xee}, + ), + ), + "checksum list has unexpected SHA-256 hash c0ffee0000000000000000000000000000000000000000000000000000000000 (expected decade0000000000000000000000000000000000000000000000000000000000)", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + // Location is unused + location := PackageLocalArchive("testdata/my-package.zip") + + auth := NewMatchingChecksumAuthentication(test.document, filename, wantSHA256Sum) + result, err := auth.AuthenticatePackage(location) + + if result != nil { + t.Errorf("wrong result: got %#v, want nil", result) + } + if gotErr := err.Error(); gotErr != test.err { + t.Errorf("wrong err: got %q, want %q", gotErr, test.err) + } + }) + } +} + +// Signature authentication takes a checksum document, a signature, and a list +// of signing keys. If the document is signed by one of the given keys, the +// authentication is successful. The value of the result depends on the signing +// key and its trust signature. +func TestSignatureAuthentication_success(t *testing.T) { + tests := map[string]struct { + signature string + keys []SigningKey + result PackageAuthenticationResult + }{ + "official provider": { + testHashicorpSignatureGoodBase64, + []SigningKey{ + { + ASCIIArmor: HashicorpPublicKey, + }, + }, + PackageAuthenticationResult{result: officialProvider}, + }, + "partner provider": { + testAuthorSignatureGoodBase64, + []SigningKey{ + { + ASCIIArmor: testAuthorKeyArmor, + TrustSignature: testAuthorKeyTrustSignatureArmor, + }, + }, + PackageAuthenticationResult{result: partnerProvider}, + }, + "community provider": { + testAuthorSignatureGoodBase64, + []SigningKey{ + { + ASCIIArmor: testAuthorKeyArmor, + }, + }, + PackageAuthenticationResult{result: communityProvider}, + }, + "multiple signing keys": { + testAuthorSignatureGoodBase64, + []SigningKey{ + { + ASCIIArmor: HashicorpPartnersKey, + }, + { + ASCIIArmor: testAuthorKeyArmor, + }, + }, + PackageAuthenticationResult{result: communityProvider}, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + // Location is unused + location := PackageLocalArchive("testdata/my-package.zip") + + signature, err := base64.StdEncoding.DecodeString(test.signature) + if err != nil { + t.Fatal(err) + } + + auth := NewSignatureAuthentication([]byte(testShaSums), signature, test.keys) + result, err := auth.AuthenticatePackage(location) + + if result == nil || *result != test.result { + t.Errorf("wrong result: got %#v, want %#v", result, test.result) + } + if err != nil { + t.Errorf("wrong err: got %s, want nil", err) + } + }) + } +} + +// Signature authentication can fail for many reasons, most of which are due +// to OpenPGP failures from malformed keys or signatures. +func TestSignatureAuthentication_failure(t *testing.T) { + tests := map[string]struct { + signature string + keys []SigningKey + err string + }{ + "invalid key": { + testHashicorpSignatureGoodBase64, + []SigningKey{ + { + ASCIIArmor: "invalid PGP armor value", + }, + }, + "error decoding signing key: openpgp: invalid argument: no armored data found", + }, + "invalid signature": { + testSignatureBadBase64, + []SigningKey{ + { + ASCIIArmor: testAuthorKeyArmor, + }, + }, + "error checking signature: openpgp: invalid data: signature subpacket truncated", + }, + "no keys match signature": { + testAuthorSignatureGoodBase64, + []SigningKey{ + { + ASCIIArmor: HashicorpPublicKey, + }, + }, + "authentication signature from unknown issuer", + }, + "invalid trust signature": { + testAuthorSignatureGoodBase64, + []SigningKey{ + { + ASCIIArmor: testAuthorKeyArmor, + TrustSignature: "invalid PGP armor value", + }, + }, + "error decoding trust signature: EOF", + }, + "unverified trust signature": { + testAuthorSignatureGoodBase64, + []SigningKey{ + { + ASCIIArmor: testAuthorKeyArmor, + TrustSignature: testOtherKeyTrustSignatureArmor, + }, + }, + "error verifying trust signature: openpgp: invalid signature: hash tag doesn't match", + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + // Location is unused + location := PackageLocalArchive("testdata/my-package.zip") + + signature, err := base64.StdEncoding.DecodeString(test.signature) + if err != nil { + t.Fatal(err) + } + + auth := NewSignatureAuthentication([]byte(testShaSums), signature, test.keys) + result, err := auth.AuthenticatePackage(location) + + if result != nil { + t.Errorf("wrong result: got %#v, want nil", result) + } + if gotErr := err.Error(); gotErr != test.err { + t.Errorf("wrong err: got %s, want %s", gotErr, test.err) + } + }) + } +} + +// testAuthorKeyArmor is test key ID 5BFEEC4317E746008621970637A6AB3BCF2C170A. +const testAuthorKeyArmor = `-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQENBF5vhgYBCAC40OcC2hEx3yGiLhHMbt7DAVEQ0nZwAWy6oL98niknLumBa1VO +nMYshP+o/FKOFatBl8aXhmDo606P6pD9d4Pg/WNehqT7hGNHcAFlm+8qjQAvE5uX +Z/na/Np7dmWasCiL5hYyHEnKU/XFpc9KyicbkS7n8igP1LEb8xDD1pMLULQsQHA4 +258asvtwjoYTZIij1I6bUE178bGFPNCfj+FzQM8nKzPpDVxZ7njN9c2sB9FEdJ1+ +S9mZQNK5PbJuEAOpD5Jp9BnGE16jsLUhDmvGHBjFZAXMBkNSloEMHhs2ty9lEzoF +eJmJx7XCGw+ds1SWp4MsHQPWzXxAlrfa4GMlABEBAAG0R1RlcnJhZm9ybSBUZXN0 +aW5nIChwbHVnaW4vZGlzY292ZXJ5LykgPHRlcnJhZm9ybSt0ZXN0aW5nQGhhc2hp +Y29ycC5jb20+iQFOBBMBCAA4FiEEW/7sQxfnRgCGIZcGN6arO88sFwoFAl5vhgYC +GwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AACgkQN6arO88sFwpWvQf/apaMu4Bm +ea8AGjdl9acQhHBpWsyiHLIfZvN11xxN/f3+YITvPXIe2PMgveqNfXxu6PIeZGDb +0DBvnBQy/vqmA+sCQ8t8+kIWdfZ1EeM2YcXdmAEtriooLvc85JFYjafLIKSj9N7o +V/R/e1BCW/v1/7Je47c+6FSt3HHhwyT5AZ3BCq1zpw6PeCDSQ/gZr3Mvq4CjeLA/ +K+8TM3KyOF4qBGDvzGzp/t9umQSS2L0ozd90lxJtf5Q8ozqDaBiDo+f/osXT2EvN +VwPP/xh/gABkXiNrPylFbeD+XPAC4N7NmYK5aPDzRYXXknP8e9PDMykoJKZ+bSdz +F3IZ4q5RDHmmNbkBDQReb4YGAQgAt15e1F8TPQQm1jK8+scypHgfmPHbp7Qsulo1 +GTcUd8QmhbR4kayuLDEpJYzq6+IoTM4TPqsdVuq/1Nwey9oyK0wXk/SUR29nRIQh +3GBg7JVg1YsObsfVTvEflYOdjk8T/Udqs4I6HnmSbtzsaohzybutpWXPUkW8OzFI +ATwfVTrrz70Yxs+ly0nSEH2Yf+kg2uYZvv5KsJ3MNENhXnHnlaTy2IfhsxAX0xOG +pa9fXV3NzdEbl0mYaEzMi77qRAyIQ9VrIL5F0yY/LlbpLSl6xk2+BB2v3a1Ey6SJ +w4/le6AM0wlH2hKPCTlkvM0IvUWjlzrPzCkeu027iVc+fqdyiQARAQABiQE2BBgB +CAAgFiEEW/7sQxfnRgCGIZcGN6arO88sFwoFAl5vhgYCGwwACgkQN6arO88sFwqz +nAf/eF4oZG9F8sJX01mVdDm/L7Uthe4xjTdl7jwV4ygNX+pCyWrww3qc3qbd3QKg +CFqIt/TAPE/OxHxCFuxalQefpOqfxjKzvcktxzWmpgxaWsvHaXiS4bKBPz78N/Ke +MUtcjGHyLeSzYPUfjquqDzQxqXidRYhyHGSy9c0NKZ6wCElLZ6KcmCQb4sZxVwfu +ssjwAFbPMp1nr0f5SWCJfhTh7QF7lO2ldJaKMlcBM8aebmqFQ52P7ZWOFcgeerng +G7Zdrci1KEd943HhzDCsUFz4gJwbvUyiAYb2ddndpUBkYwCB/XrHWPOSnGxHgZoo +1gIqed9OV/+s5wKxZPjL0pCStQ== +=mYqJ +-----END PGP PUBLIC KEY BLOCK-----` + +// testAuthorKeyTrustSignatureArmor is a trust signature of the data in +// testAuthorKeyArmor signed with HashicorpPartnersKey. +const testAuthorKeyTrustSignatureArmor = `-----BEGIN PGP SIGNATURE----- + +iQIzBAABCAAdFiEEUYkGV8Ws20uCMIZWfXLUJo5GYPwFAl5w9+YACgkQfXLUJo5G +YPwjRBAAvy9jo3vvetb4qx/z2qhbRH2JbZN9byKuqlIggPzDhhaIsVJVZ9L6H6bE +AMgPe/NaH58wfiqMYenulYxj9tZwJORT/OK0Y9ZFXXZk6kWPMNv7TEppyB0wKgqq +ORKf07KjDcVQslDG9ARgnvDq2GA4UTHxhT0chKHdIKeDLmTm0VSkfNeOhQIkW7vB +S/WT9y78319QJek8OKwJo0Jv0O93rvZZI0JFjXGtP15XNBfObMtPXn3l8qoLzhsv +pJJG/u+BsVZ+y1JDQQlHaD1P2TLW/nGymFq12k693IOCmNyaIOa01Wa9B/j3a3RY +v4SdkULvJKbttNMNBgIMJ74wZp5EUhEFs68sllrIrmthH8bW2fbcHEQ1g/MJCe3+ +43c9aoW8yNQmuEe7yre9lgqcJOIOxlb5XEJhH0Lh+8OBi5aHA/5wXGU5WrhWqHCR +npXBsNqy2sKUuVkEzvn3Hd6aoKncVLrgNR8xA3VP86jJhawvO+M+YYMr1wOVHc/I +PYq9hlyUR8qJ/0RpnaIE1iLbPYfEpGTg7oHORpbQVoZAUwMN/Sdox7sMkqCOb1RJ +Cmy9J5o7iiNOoshvps5cxcbsM7LNfbf0vDhWpckAvsQehrS1mfVuFHkIiotVQhH1 +QXPfvB2cVF/SxMqqHWpnT+8c8klfS03kXSb0BdknrQ4DNPq1H5A= +=3A1s +-----END PGP SIGNATURE-----` + +// testOtherKeyTrustSignatureArmor is a trust signature of another key (not the +// author key), signed with HashicorpPartnersKey. +const testOtherKeyTrustSignatureArmor = `-----BEGIN PGP SIGNATURE----- + +iQIzBAABCAAdFiEEUYkGV8Ws20uCMIZWfXLUJo5GYPwFAl6POvsACgkQfXLUJo5G +YPyGihAAomM1kGmrC5KRgWQ+V47r8wFoIkhsTgAYb9ENOzn/RVJt3SJSstcKxfA3 +7HW5R4kqAoXH1hcPYpUcOcdeAvtZxjGRQ9JgErV8NBg6sR11aQccCzAG4Hy0hWav +/jB5NzTEX5JFEXH6WhpWI1avh0l2j6JxO1K1s+5+5PI3KbuO+XSqeZ3QmUz9FwGu +pr0J6oYcERupzrpnmgMb5fbkpHfzffR2/MOYdF9Hae4EvDS1b7tokuuKsStNnCm0 +ge7PFdekwbj/OiQrQlqM1pOw2siPX3ouWCtW8oExm9tAxNw31Bn2g3oaNMkHMqJd +hlVUZlqeJMyylUat3cY7GTQONfCnoyUHe/wv8exBUbV3v2glp9y2g9i2XmXkHOrV +Z+pnNBc+jdp3a4O0Y8fXXZdjiIolZKY8BbvzheuMrQQIOmw4N3KrZbTpLKuqz8rb +h8bqUbU42oWcJmBvzF4NZ4tQ+aFHs4CbOnjfDfS14baQr2Gqo9BqTfrzS5Pbs8lq +AhY0r+zi71lQ1rBfgZfjd8zWlOzpDO//nwKhGCqYOWke/C/T6o0zxM0R4uR4zXwT +KhvXK8/kK/L8Flaxqme0d5bzXLbsMe9I6I76DY5iNhkiFnnWt4+FhGoIDR03MTKS +SnHodBLlpKLyUXi36DCDy/iKVsieqLsAdcYe0nQFuhoQcOme33A= +=aHOG +-----END PGP SIGNATURE-----` + +// testShaSums is a string that represents the SHA256SUMS file downloaded +// for a release. +const testShaSums = "example shasums data" + +// testAuthorSignatureGoodBase64 is a signature of testShaSums signed with +// testAuthorKeyArmor, which represents the SHA256SUMS.sig file downloaded for +// a release. +const testAuthorSignatureGoodBase64 = `iQEzBAABCAAdFiEEW/7sQxfnRgCGIZcGN6arO88s` + + `FwoFAl5vh7gACgkQN6arO88sFwrAlQf6Al77qzjxNIj+NQNJfBGYUE5jHIgcuWOs1IPRTYUI` + + `rHQIUU2RVrdHoAefKTKNzGde653JK/pYTflSV+6ini3/aZZnXlF6t001w3wswmakdwTr0hXx` + + `Ez/hHYio72Gpn7+T/L+nl6dKkjeGqd/Kor5x2TY9uYB737ESmAe5T8ZlPaGMFHh0mYlNTeRq` + + `4qIKqL6DwddBF4Ju2svn2MeNMGfE358H31mxAl2k4PPrwBTR1sFUCUOzAXVA/g9Ov5Y9ni2G` + + `rkTahBtV9yuUUd1D+oRTTTdP0bj3A+3xxXmKTBhRuvurydPTicKuWzeILIJkcwp7Kl5UbI2N` + + `n1ayZdaCIw/r4w==` + +// testSignatureBadBase64 is an invalid signature. +const testSignatureBadBase64 = `iQEzBAABCAAdFiEEW/7sQxfnRgCGIZcGN6arO88s` + + `4qIKqL6DwddBF4Ju2svn2MeNMGfE358H31mxAl2k4PPrwBTR1sFUCUOzAXVA/g9Ov5Y9ni2G` + + `rkTahBtV9yuUUd1D+oRTTTdP0bj3A+3xxXmKTBhRuvurydPTicKuWzeILIJkcwp7Kl5UbI2N` + + `n1ayZdaCIw/r4w==` + +// testHashicorpSignatureGoodBase64 is a signature of testShaSums signed with +// HashicorpPublicKey, which represents the SHA256SUMS.sig file downloaded for +// an official release. +const testHashicorpSignatureGoodBase64 = `iQFLBAABCAA1FiEEkabn+F0FxlYwvvGJUYUth` + + `zSP/EwFAl5w784XHHNlY3VyaXR5QGhhc2hpY29ycC5jb20ACgkQUYUthzSP/EyB8QgAv9ijp` + + `kTcoFwDAs+1iEUrcW18h/2cU+bvFtdqNDiffzk7+YJ9ioxeWisPta/Z6hEyhdss2+5L1MNbo` + + `oUBLABI+Aebfxa/uYFT2kX6r/eySmlY9kqNVpjXdemOQutS4NNZxdJL7CEbh2qIKCVuyo0ul` + + `YrTdDH35vwVyLXImWiZLnrXcT/fXLpQGx/N8PDy6WmCeju5Y5RD7TuntB71eCaCZi7wFe1tR` + + `qSoe9tD9A7ONB0rGuCY7BxqUj0S81hhz960YbNR9Q81WoNvF7b5SmcLJ1qJx1yvBLyqya6Su` + + `DKjU/YYCh7bwHIYzpk1/nK/7SaTHpisekqojVsfDth4TA+jGA==` + +// entityString function is used for logging the signing key. +func TestEntityString(t *testing.T) { + var tests = []struct { + name string + entity *openpgp.Entity + expected string + }{ + { + "nil", + nil, + "", + }, + { + "testAuthorKeyArmor", + testReadArmoredEntity(t, testAuthorKeyArmor), + "37A6AB3BCF2C170A Terraform Testing (plugin/discovery/) ", + }, + { + "HashicorpPublicKey", + testReadArmoredEntity(t, HashicorpPublicKey), + "51852D87348FFC4C HashiCorp Security ", + }, + { + "HashicorpPartnersKey", + testReadArmoredEntity(t, HashicorpPartnersKey), + "7D72D4268E4660FC HashiCorp Security (Terraform Partner Signing) ", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + actual := entityString(tt.entity) + if actual != tt.expected { + t.Errorf("expected %s, actual %s", tt.expected, actual) + } + }) + } +} + +func testReadArmoredEntity(t *testing.T, armor string) *openpgp.Entity { + data := strings.NewReader(armor) + + el, err := openpgp.ReadArmoredKeyRing(data) + if err != nil { + t.Fatal(err) + } + + if count := len(el); count != 1 { + t.Fatalf("expected 1 entity, got %d", count) + } + + return el[0] +} diff --git a/internal/getproviders/public_keys.go b/internal/getproviders/public_keys.go new file mode 100644 index 000000000000..bbbcdc804fe5 --- /dev/null +++ b/internal/getproviders/public_keys.go @@ -0,0 +1,89 @@ +package getproviders + +// HashicorpPublicKey is the HashiCorp public key, also available at +// https://www.hashicorp.com/security +const HashicorpPublicKey = `-----BEGIN PGP PUBLIC KEY BLOCK----- +Version: GnuPG v1 + +mQENBFMORM0BCADBRyKO1MhCirazOSVwcfTr1xUxjPvfxD3hjUwHtjsOy/bT6p9f +W2mRPfwnq2JB5As+paL3UGDsSRDnK9KAxQb0NNF4+eVhr/EJ18s3wwXXDMjpIifq +fIm2WyH3G+aRLTLPIpscUNKDyxFOUbsmgXAmJ46Re1fn8uKxKRHbfa39aeuEYWFA +3drdL1WoUngvED7f+RnKBK2G6ZEpO+LDovQk19xGjiMTtPJrjMjZJ3QXqPvx5wca +KSZLr4lMTuoTI/ZXyZy5bD4tShiZz6KcyX27cD70q2iRcEZ0poLKHyEIDAi3TM5k +SwbbWBFd5RNPOR0qzrb/0p9ksKK48IIfH2FvABEBAAG0K0hhc2hpQ29ycCBTZWN1 +cml0eSA8c2VjdXJpdHlAaGFzaGljb3JwLmNvbT6JATgEEwECACIFAlMORM0CGwMG +CwkIBwMCBhUIAgkKCwQWAgMBAh4BAheAAAoJEFGFLYc0j/xMyWIIAIPhcVqiQ59n +Jc07gjUX0SWBJAxEG1lKxfzS4Xp+57h2xxTpdotGQ1fZwsihaIqow337YHQI3q0i +SqV534Ms+j/tU7X8sq11xFJIeEVG8PASRCwmryUwghFKPlHETQ8jJ+Y8+1asRydi +psP3B/5Mjhqv/uOK+Vy3zAyIpyDOMtIpOVfjSpCplVRdtSTFWBu9Em7j5I2HMn1w +sJZnJgXKpybpibGiiTtmnFLOwibmprSu04rsnP4ncdC2XRD4wIjoyA+4PKgX3sCO +klEzKryWYBmLkJOMDdo52LttP3279s7XrkLEE7ia0fXa2c12EQ0f0DQ1tGUvyVEW +WmJVccm5bq25AQ0EUw5EzQEIANaPUY04/g7AmYkOMjaCZ6iTp9hB5Rsj/4ee/ln9 +wArzRO9+3eejLWh53FoN1rO+su7tiXJA5YAzVy6tuolrqjM8DBztPxdLBbEi4V+j +2tK0dATdBQBHEh3OJApO2UBtcjaZBT31zrG9K55D+CrcgIVEHAKY8Cb4kLBkb5wM +skn+DrASKU0BNIV1qRsxfiUdQHZfSqtp004nrql1lbFMLFEuiY8FZrkkQ9qduixo +mTT6f34/oiY+Jam3zCK7RDN/OjuWheIPGj/Qbx9JuNiwgX6yRj7OE1tjUx6d8g9y +0H1fmLJbb3WZZbuuGFnK6qrE3bGeY8+AWaJAZ37wpWh1p0cAEQEAAYkBHwQYAQIA +CQUCUw5EzQIbDAAKCRBRhS2HNI/8TJntCAClU7TOO/X053eKF1jqNW4A1qpxctVc +z8eTcY8Om5O4f6a/rfxfNFKn9Qyja/OG1xWNobETy7MiMXYjaa8uUx5iFy6kMVaP +0BXJ59NLZjMARGw6lVTYDTIvzqqqwLxgliSDfSnqUhubGwvykANPO+93BBx89MRG +unNoYGXtPlhNFrAsB1VR8+EyKLv2HQtGCPSFBhrjuzH3gxGibNDDdFQLxxuJWepJ +EK1UbTS4ms0NgZ2Uknqn1WRU1Ki7rE4sTy68iZtWpKQXZEJa0IGnuI2sSINGcXCJ +oEIgXTMyCILo34Fa/C6VCm2WBgz9zZO8/rHIiQm1J5zqz0DrDwKBUM9C +=LYpS +-----END PGP PUBLIC KEY BLOCK-----` + +// HashicorpPartnersKey is a key created by HashiCorp, used to generate and +// verify trust signatures for Partner tier providers. +const HashicorpPartnersKey = `-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +=5/s+ +-----END PGP PUBLIC KEY BLOCK-----` diff --git a/internal/getproviders/registry_client.go b/internal/getproviders/registry_client.go index 5e021beddb79..87ba337b55af 100644 --- a/internal/getproviders/registry_client.go +++ b/internal/getproviders/registry_client.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io/ioutil" "net/http" "net/url" "path" @@ -172,6 +173,9 @@ func (c *registryClient) PackageMeta(provider addrs.Provider, version Version, t return PackageMeta{}, c.errQueryFailed(provider, errors.New(resp.Status)) } + type SigningKeyList struct { + GPGPublicKeys []*SigningKey `json:"gpg_public_keys"` + } type ResponseBody struct { Protocols []string `json:"protocols"` OS string `json:"os"` @@ -180,7 +184,10 @@ func (c *registryClient) PackageMeta(provider addrs.Provider, version Version, t DownloadURL string `json:"download_url"` SHA256Sum string `json:"shasum"` - // TODO: Other metadata for signature checking + SHA256SumsURL string `json:"shasums_url"` + SHA256SumsSignatureURL string `json:"shasums_signature_url"` + + SigningKeys SigningKeyList `json:"signing_keys"` } var body ResponseBody @@ -230,6 +237,7 @@ func (c *registryClient) PackageMeta(provider addrs.Provider, version Version, t fmt.Errorf("registry response includes invalid SHA256 hash %q: %s", body.SHA256Sum, err), ) } + var checksum [sha256.Size]byte _, err = hex.Decode(checksum[:], []byte(body.SHA256Sum)) if err != nil { @@ -238,7 +246,48 @@ func (c *registryClient) PackageMeta(provider addrs.Provider, version Version, t fmt.Errorf("registry response includes invalid SHA256 hash %q: %s", body.SHA256Sum, err), ) } - ret.Authentication = NewArchiveChecksumAuthentication(checksum) + + shasumsURL, err := url.Parse(body.SHA256SumsURL) + if err != nil { + return PackageMeta{}, fmt.Errorf("registry response includes invalid SHASUMS URL: %s", err) + } + shasumsURL = resp.Request.URL.ResolveReference(shasumsURL) + if shasumsURL.Scheme != "http" && shasumsURL.Scheme != "https" { + return PackageMeta{}, fmt.Errorf("registry response includes invalid SHASUMS URL: must use http or https scheme") + } + document, err := c.getFile(shasumsURL) + if err != nil { + return PackageMeta{}, c.errQueryFailed( + provider, + fmt.Errorf("failed to retrieve authentication checksums for provider: %s", err), + ) + } + signatureURL, err := url.Parse(body.SHA256SumsSignatureURL) + if err != nil { + return PackageMeta{}, fmt.Errorf("registry response includes invalid SHASUMS signature URL: %s", err) + } + signatureURL = resp.Request.URL.ResolveReference(signatureURL) + if signatureURL.Scheme != "http" && signatureURL.Scheme != "https" { + return PackageMeta{}, fmt.Errorf("registry response includes invalid SHASUMS signature URL: must use http or https scheme") + } + signature, err := c.getFile(signatureURL) + if err != nil { + return PackageMeta{}, c.errQueryFailed( + provider, + fmt.Errorf("failed to retrieve cryptographic signature for provider: %s", err), + ) + } + + keys := make([]SigningKey, len(body.SigningKeys.GPGPublicKeys)) + for i, key := range body.SigningKeys.GPGPublicKeys { + keys[i] = *key + } + + ret.Authentication = PackageAuthenticationAll( + NewMatchingChecksumAuthentication(document, body.Filename, checksum), + NewArchiveChecksumAuthentication(checksum), + NewSignatureAuthentication(document, signature, keys), + ) return ret, nil } @@ -321,3 +370,22 @@ func (c *registryClient) errUnauthorized(hostname svchost.Hostname) error { HaveCredentials: c.creds != nil, } } + +func (c *registryClient) getFile(url *url.URL) ([]byte, error) { + resp, err := c.httpClient.Get(url.String()) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s", resp.Status) + } + + data, err := ioutil.ReadAll(resp.Body) + if err != nil { + return data, err + } + + return data, nil +} diff --git a/internal/getproviders/registry_client_test.go b/internal/getproviders/registry_client_test.go index 2848652af9c0..454b9b40763a 100644 --- a/internal/getproviders/registry_client_test.go +++ b/internal/getproviders/registry_client_test.go @@ -1,6 +1,7 @@ package getproviders import ( + "encoding/json" "log" "net/http" "net/http/httptest" @@ -91,6 +92,21 @@ func fakeRegistryHandler(resp http.ResponseWriter, req *http.Request) { return } + if strings.HasPrefix(path, "/pkg/") { + switch path { + case "/pkg/awesomesauce/happycloud_1.2.0.zip": + resp.Write([]byte("some zip file")) + case "/pkg/awesomesauce/happycloud_1.2.0_SHA256SUMS": + resp.Write([]byte("000000000000000000000000000000000000000000000000000000000000f00d happycloud_1.2.0.zip\n")) + case "/pkg/awesomesauce/happycloud_1.2.0_SHA256SUMS.sig": + resp.Write([]byte("GPG signature")) + default: + resp.WriteHeader(404) + resp.Write([]byte("unknown package file download")) + } + return + } + if !strings.HasPrefix(path, "/providers/v1/") { resp.WriteHeader(404) resp.Write([]byte(`not a provider registry endpoint`)) @@ -161,12 +177,31 @@ func fakeRegistryHandler(resp http.ResponseWriter, req *http.Request) { resp.Write([]byte(`unsupported OS`)) return } + body := map[string]interface{}{ + "protocols": []string{"5.0"}, + "os": pathParts[4], + "arch": pathParts[5], + "filename": "happycloud_" + pathParts[2] + ".zip", + "shasum": "000000000000000000000000000000000000000000000000000000000000f00d", + "download_url": "/pkg/awesomesauce/happycloud_" + pathParts[2] + ".zip", + "shasums_url": "/pkg/awesomesauce/happycloud_" + pathParts[2] + "_SHA256SUMS", + "shasums_signature_url": "/pkg/awesomesauce/happycloud_" + pathParts[2] + "_SHA256SUMS.sig", + "signing_keys": map[string]interface{}{ + "gpg_public_keys": []map[string]interface{}{ + { + "ascii_armor": HashicorpPublicKey, + }, + }, + }, + } + enc, err := json.Marshal(body) + if err != nil { + resp.WriteHeader(500) + resp.Write([]byte("failed to encode body")) + } resp.Header().Set("Content-Type", "application/json") resp.WriteHeader(200) - // Note that these version numbers are intentionally misordered - // so we can test that the client-side code places them in the - // correct order (lowest precedence first). - resp.Write([]byte(`{"protocols":["5.0"],"os":"` + pathParts[4] + `","arch":"` + pathParts[5] + `","filename":"happycloud_` + pathParts[2] + `.zip","download_url":"/pkg/happycloud_` + pathParts[2] + `.zip","shasum":"000000000000000000000000000000000000000000000000000000000000f00d"}`)) + resp.Write(enc) default: resp.WriteHeader(404) resp.Write([]byte(`unknown namespace/provider/version/architecture`)) diff --git a/internal/getproviders/registry_source_test.go b/internal/getproviders/registry_source_test.go index 12e05ac42ac9..191f773fcd97 100644 --- a/internal/getproviders/registry_source_test.go +++ b/internal/getproviders/registry_source_test.go @@ -23,7 +23,7 @@ func TestSourceAvailableVersions(t *testing.T) { wantErr string }{ // These test cases are relying on behaviors of the fake provider - // registry server implemented in client_test.go. + // registry server implemented in registry_client_test.go. { "example.com/awesomesauce/happycloud", []string{"1.0.0", "1.2.0"}, @@ -124,8 +124,22 @@ func TestSourcePackageMeta(t *testing.T) { ProtocolVersions: VersionList{versions.MustParseVersion("5.0.0")}, TargetPlatform: Platform{"linux", "amd64"}, Filename: "happycloud_1.2.0.zip", - Location: PackageHTTPURL(baseURL + "/pkg/happycloud_1.2.0.zip"), - Authentication: archiveHashAuthentication{[32]uint8{30: 0xf0, 31: 0x0d}}, // fake registry uses a memorable sum + Location: PackageHTTPURL(baseURL + "/pkg/awesomesauce/happycloud_1.2.0.zip"), + Authentication: PackageAuthenticationAll( + NewMatchingChecksumAuthentication( + []byte("000000000000000000000000000000000000000000000000000000000000f00d happycloud_1.2.0.zip\n"), + "happycloud_1.2.0.zip", + [32]byte{30: 0xf0, 31: 0x0d}, + ), + NewArchiveChecksumAuthentication([32]byte{30: 0xf0, 31: 0x0d}), + NewSignatureAuthentication( + []byte("000000000000000000000000000000000000000000000000000000000000f00d happycloud_1.2.0.zip\n"), + []byte("GPG signature"), + []SigningKey{ + {ASCIIArmor: HashicorpPublicKey}, + }, + ), + ), }, ``, }, diff --git a/internal/getproviders/types.go b/internal/getproviders/types.go index 3ee0d817f2c5..4abbac340b9e 100644 --- a/internal/getproviders/types.go +++ b/internal/getproviders/types.go @@ -224,6 +224,7 @@ func (m PackageMeta) UnpackedDirectoryPath(baseDir string) string { // concrete types: PackageLocalArchive, PackageLocalDir, or PackageHTTPURL. type PackageLocation interface { packageLocation() + String() string } // PackageLocalArchive is the location of a provider distribution archive file @@ -233,6 +234,7 @@ type PackageLocation interface { type PackageLocalArchive string func (p PackageLocalArchive) packageLocation() {} +func (p PackageLocalArchive) String() string { return string(p) } // PackageLocalDir is the location of a directory containing an unpacked // provider distribution archive in the local filesystem. Its value is a local @@ -241,12 +243,14 @@ func (p PackageLocalArchive) packageLocation() {} type PackageLocalDir string func (p PackageLocalDir) packageLocation() {} +func (p PackageLocalDir) String() string { return string(p) } // PackageHTTPURL is a provider package location accessible via HTTP. // Its value is a URL string using either the http: scheme or the https: scheme. type PackageHTTPURL string func (p PackageHTTPURL) packageLocation() {} +func (p PackageHTTPURL) String() string { return string(p) } // PackageMetaList is a list of PackageMeta. It's just []PackageMeta with // some methods for convenient sorting and filtering. diff --git a/internal/providercache/dir_modify.go b/internal/providercache/dir_modify.go index 5b6fd2311a11..586269a7705e 100644 --- a/internal/providercache/dir_modify.go +++ b/internal/providercache/dir_modify.go @@ -11,9 +11,9 @@ import ( // InstallPackage takes a metadata object describing a package available for // installation, retrieves that package, and installs it into the receiving // cache directory. -func (d *Dir) InstallPackage(ctx context.Context, meta getproviders.PackageMeta) error { +func (d *Dir) InstallPackage(ctx context.Context, meta getproviders.PackageMeta) (*getproviders.PackageAuthenticationResult, error) { if meta.TargetPlatform != d.targetPlatform { - return fmt.Errorf("can't install %s package into cache directory expecting %s", meta.TargetPlatform, d.targetPlatform) + return nil, fmt.Errorf("can't install %s package into cache directory expecting %s", meta.TargetPlatform, d.targetPlatform) } newPath := getproviders.UnpackedDirectoryPathForPackage( d.baseDir, meta.Provider, meta.Version, d.targetPlatform, @@ -23,23 +23,18 @@ func (d *Dir) InstallPackage(ctx context.Context, meta getproviders.PackageMeta) // incorporate any changes we make here. d.metaCache = nil - // TODO: If meta.Authentication is non-nil, we should call it at some point - // in the rest of this process (perhaps inside installFromLocalArchive and - // installFromLocalDir, so we already have the local copy?) and return an - // error if the authentication fails. - log.Printf("[TRACE] providercache.Dir.InstallPackage: installing %s v%s from %s", meta.Provider, meta.Version, meta.Location) - switch location := meta.Location.(type) { + switch meta.Location.(type) { case getproviders.PackageHTTPURL: - return installFromHTTPURL(ctx, string(location), newPath) + return installFromHTTPURL(ctx, meta, newPath) case getproviders.PackageLocalArchive: - return installFromLocalArchive(ctx, string(location), newPath) + return installFromLocalArchive(ctx, meta, newPath) case getproviders.PackageLocalDir: - return installFromLocalDir(ctx, string(location), newPath) + return installFromLocalDir(ctx, meta, newPath) default: // Should not get here, because the above should be exhaustive for // all implementations of getproviders.Location. - return fmt.Errorf("don't know how to install from a %T location", location) + return nil, fmt.Errorf("don't know how to install from a %T location", meta.Location) } } @@ -67,5 +62,20 @@ func (d *Dir) LinkFromOtherCache(entry *CachedProvider) error { // We re-use the process of installing from a local directory here, because // the two operations are fundamentally the same: symlink if possible, // deep-copy otherwise. - return installFromLocalDir(context.TODO(), currentPath, newPath) + meta := getproviders.PackageMeta{ + Provider: entry.Provider, + Version: entry.Version, + + // FIXME: How do we populate this? + ProtocolVersions: nil, + TargetPlatform: d.targetPlatform, + + // Because this is already unpacked, the filename is synthetic + // based on the standard naming scheme. + Filename: fmt.Sprintf("terraform-provider-%s_%s_%s.zip", + entry.Provider.Type, entry.Version, d.targetPlatform), + Location: getproviders.PackageLocalDir(currentPath), + } + _, err := installFromLocalDir(context.TODO(), meta, newPath) + return err } diff --git a/internal/providercache/dir_modify_test.go b/internal/providercache/dir_modify_test.go index efd1fc7f5d74..02fcd3926e50 100644 --- a/internal/providercache/dir_modify_test.go +++ b/internal/providercache/dir_modify_test.go @@ -1,6 +1,7 @@ package providercache import ( + "context" "io/ioutil" "os" "testing" @@ -12,6 +13,61 @@ import ( "github.com/hashicorp/terraform/internal/getproviders" ) +func TestInstallPackage(t *testing.T) { + tmpDirPath, err := ioutil.TempDir("", "terraform-test-providercache") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDirPath) + + linuxPlatform := getproviders.Platform{ + OS: "linux", + Arch: "amd64", + } + nullProvider := addrs.NewProvider( + addrs.DefaultRegistryHost, "hashicorp", "null", + ) + + tmpDir := newDirWithPlatform(tmpDirPath, linuxPlatform) + + meta := getproviders.PackageMeta{ + Provider: nullProvider, + Version: versions.MustParseVersion("2.1.0"), + + ProtocolVersions: getproviders.VersionList{versions.MustParseVersion("5.0.0")}, + TargetPlatform: linuxPlatform, + + Filename: "terraform-provider-null_2.1.0_linux_amd64.zip", + Location: getproviders.PackageLocalArchive("testdata/terraform-provider-null_2.1.0_linux_amd64.zip"), + } + + result, err := tmpDir.InstallPackage(context.TODO(), meta) + if err != nil { + t.Fatalf("InstallPackage failed: %s", err) + } + if result != nil { + t.Errorf("unexpected result %#v, wanted nil", result) + } + + // Now we should see the same version reflected in the temporary directory. + got := tmpDir.AllAvailablePackages() + want := map[addrs.Provider][]CachedProvider{ + nullProvider: { + CachedProvider{ + Provider: nullProvider, + + Version: versions.MustParseVersion("2.1.0"), + + PackageDir: tmpDirPath + "/registry.terraform.io/hashicorp/null/2.1.0/linux_amd64", + ExecutableFile: tmpDirPath + "/registry.terraform.io/hashicorp/null/2.1.0/linux_amd64/terraform-provider-null", + }, + }, + } + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("wrong cache contents after install\n%s", diff) + } +} + func TestLinkFromOtherCache(t *testing.T) { srcDirPath := "testdata/cachedir" tmpDirPath, err := ioutil.TempDir("", "terraform-test-providercache") diff --git a/internal/providercache/installer.go b/internal/providercache/installer.go index 486d787c657e..2c97eaa6b5c0 100644 --- a/internal/providercache/installer.go +++ b/internal/providercache/installer.go @@ -315,7 +315,7 @@ NeedProvider: installTo = i.targetDir linkTo = nil // no linking needed } - err = installTo.InstallPackage(ctx, meta) + authResult, err := installTo.InstallPackage(ctx, meta) if err != nil { // TODO: Consider retrying for certain kinds of error that seem // likely to be transient. For now, we just treat all errors equally. @@ -350,7 +350,7 @@ NeedProvider: } selected[provider] = version if cb := evts.FetchPackageSuccess; cb != nil { - cb(provider, version, new.PackageDir) + cb(provider, version, new.PackageDir, authResult) } } diff --git a/internal/providercache/installer_events.go b/internal/providercache/installer_events.go index 1bdef97043f2..5d4200e5e463 100644 --- a/internal/providercache/installer_events.go +++ b/internal/providercache/installer_events.go @@ -103,7 +103,7 @@ type InstallerEvents struct { // signals a failure that the installer is considering transient. FetchPackageMeta func(provider addrs.Provider, version getproviders.Version) // fetching metadata prior to real download FetchPackageBegin func(provider addrs.Provider, version getproviders.Version, location getproviders.PackageLocation) - FetchPackageSuccess func(provider addrs.Provider, version getproviders.Version, localDir string) + FetchPackageSuccess func(provider addrs.Provider, version getproviders.Version, localDir string, authResult *getproviders.PackageAuthenticationResult) FetchPackageRetry func(provider addrs.Provider, version getproviders.Version, err error) FetchPackageFailure func(provider addrs.Provider, version getproviders.Version, err error) diff --git a/internal/providercache/package_install.go b/internal/providercache/package_install.go index b80b72e79f98..62c3b8508cbb 100644 --- a/internal/providercache/package_install.go +++ b/internal/providercache/package_install.go @@ -12,6 +12,7 @@ import ( "github.com/hashicorp/terraform/httpclient" "github.com/hashicorp/terraform/internal/copydir" + "github.com/hashicorp/terraform/internal/getproviders" ) // We borrow the "unpack a zip file into a target directory" logic from @@ -21,7 +22,9 @@ import ( // specific protocol and set of expectations.) var unzip = getter.ZipDecompressor{} -func installFromHTTPURL(ctx context.Context, url string, targetDir string) error { +func installFromHTTPURL(ctx context.Context, meta getproviders.PackageMeta, targetDir string) (*getproviders.PackageAuthenticationResult, error) { + url := meta.Location.String() + // When we're installing from an HTTP URL we expect the URL to refer to // a zip file. We'll fetch that into a temporary file here and then // delegate to installFromLocalArchive below to actually extract it. @@ -33,21 +36,21 @@ func installFromHTTPURL(ctx context.Context, url string, targetDir string) error httpClient := httpclient.New() req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { - return fmt.Errorf("invalid provider download request: %s", err) + return nil, fmt.Errorf("invalid provider download request: %s", err) } resp, err := httpClient.Do(req) if err != nil { - return err + return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return fmt.Errorf("unsuccessful request to %s: %s", url, resp.Status) + return nil, fmt.Errorf("unsuccessful request to %s: %s", url, resp.Status) } f, err := ioutil.TempFile("", "terraform-provider") if err != nil { - return fmt.Errorf("failed to open temporary file to download from %s", url) + return nil, fmt.Errorf("failed to open temporary file to download from %s", url) } defer f.Close() @@ -58,31 +61,69 @@ func installFromHTTPURL(ctx context.Context, url string, targetDir string) error err = fmt.Errorf("incorrect response size: expected %d bytes, but got %d bytes", resp.ContentLength, n) } if err != nil { - return err + return nil, err } - // If we managed to download successfully then we can now delegate to - // installFromLocalArchive for extraction. archiveFilename := f.Name() - return installFromLocalArchive(ctx, archiveFilename, targetDir) + localLocation := getproviders.PackageLocalArchive(archiveFilename) + + var authResult *getproviders.PackageAuthenticationResult + if meta.Authentication != nil { + if authResult, err = meta.Authentication.AuthenticatePackage(localLocation); err != nil { + return authResult, err + } + } + + // We can now delegate to installFromLocalArchive for extraction. To do so, + // we construct a new package meta description using the local archive + // path as the location, and skipping authentication. + localMeta := getproviders.PackageMeta{ + Provider: meta.Provider, + Version: meta.Version, + ProtocolVersions: meta.ProtocolVersions, + TargetPlatform: meta.TargetPlatform, + Filename: meta.Filename, + Location: localLocation, + Authentication: nil, + } + if _, err := installFromLocalArchive(ctx, localMeta, targetDir); err != nil { + return nil, err + } + return authResult, nil } -func installFromLocalArchive(ctx context.Context, filename string, targetDir string) error { - return unzip.Decompress(targetDir, filename, true) +func installFromLocalArchive(ctx context.Context, meta getproviders.PackageMeta, targetDir string) (*getproviders.PackageAuthenticationResult, error) { + var authResult *getproviders.PackageAuthenticationResult + if meta.Authentication != nil { + var err error + if authResult, err = meta.Authentication.AuthenticatePackage(meta.Location); err != nil { + return nil, err + } + } + filename := meta.Location.String() + + err := unzip.Decompress(targetDir, filename, true) + if err != nil { + return authResult, err + } + + return authResult, nil } // installFromLocalDir is the implementation of both installing a package from // a local directory source _and_ of linking a package from another cache // in LinkFromOtherCache, because they both do fundamentally the same // operation: symlink if possible, or deep-copy otherwise. -func installFromLocalDir(ctx context.Context, sourceDir string, targetDir string) error { +func installFromLocalDir(ctx context.Context, meta getproviders.PackageMeta, targetDir string) (*getproviders.PackageAuthenticationResult, error) { + sourceDir := meta.Location.String() + absNew, err := filepath.Abs(targetDir) if err != nil { - return fmt.Errorf("failed to make target path %s absolute: %s", targetDir, err) + return nil, fmt.Errorf("failed to make target path %s absolute: %s", targetDir, err) } absCurrent, err := filepath.Abs(sourceDir) if err != nil { - return fmt.Errorf("failed to make source path %s absolute: %s", sourceDir, err) + return nil, fmt.Errorf("failed to make source path %s absolute: %s", sourceDir, err) } // Before we do anything else, we'll do a quick check to make sure that @@ -90,15 +131,15 @@ func installFromLocalDir(ctx context.Context, sourceDir string, targetDir string // disk. This compares the files by their OS-level device and directory // entry identifiers, not by their virtual filesystem paths. if same, err := copydir.SameFile(absNew, absCurrent); same { - return fmt.Errorf("cannot install existing provider directory %s to itself", targetDir) + return nil, fmt.Errorf("cannot install existing provider directory %s to itself", targetDir) } else if err != nil { - return fmt.Errorf("failed to determine if %s and %s are the same: %s", sourceDir, targetDir, err) + return nil, fmt.Errorf("failed to determine if %s and %s are the same: %s", sourceDir, targetDir, err) } // Delete anything that's already present at this path first. err = os.RemoveAll(targetDir) if err != nil && !os.IsNotExist(err) { - return fmt.Errorf("failed to remove existing %s before linking it to %s: %s", sourceDir, targetDir, err) + return nil, fmt.Errorf("failed to remove existing %s before linking it to %s: %s", sourceDir, targetDir, err) } // We'll prefer to create a symlink if possible, but we'll fall back to @@ -117,22 +158,22 @@ func installFromLocalDir(ctx context.Context, sourceDir string, targetDir string parentDir := filepath.Dir(absNew) err = os.MkdirAll(parentDir, 0755) if err != nil && os.IsExist(err) { - return fmt.Errorf("failed to create parent directories leading to %s: %s", targetDir, err) + return nil, fmt.Errorf("failed to create parent directories leading to %s: %s", targetDir, err) } err = os.Symlink(linkTarget, absNew) if err == nil { // Success, then! - return nil + return nil, nil } // If we get down here then symlinking failed and we need a deep copy // instead. err = copydir.CopyDir(absNew, absCurrent) if err != nil { - return fmt.Errorf("failed to either symlink or copy %s to %s: %s", absCurrent, absNew, err) + return nil, fmt.Errorf("failed to either symlink or copy %s to %s: %s", absCurrent, absNew, err) } // If we got here then apparently our copy succeeded, so we're done. - return nil + return nil, nil } diff --git a/internal/providercache/testdata/terraform-provider-null_2.1.0_linux_amd64.zip b/internal/providercache/testdata/terraform-provider-null_2.1.0_linux_amd64.zip new file mode 100644 index 000000000000..4b243f2b6d04 Binary files /dev/null and b/internal/providercache/testdata/terraform-provider-null_2.1.0_linux_amd64.zip differ