-
Notifications
You must be signed in to change notification settings - Fork 39
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #88 from opentofu/gpg-key-verifications
- Loading branch information
Showing
9 changed files
with
439 additions
and
100 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,173 @@ | ||
package main | ||
|
||
import ( | ||
"context" | ||
"flag" | ||
"fmt" | ||
"log/slog" | ||
"net/mail" | ||
"os" | ||
"regexp" | ||
|
||
"github.com/ProtonMail/gopenpgp/v2/crypto" | ||
"github.com/opentofu/registry-stable/internal/github" | ||
"github.com/opentofu/registry-stable/internal/gpg" | ||
"github.com/opentofu/registry-stable/pkg/verification" | ||
"github.com/shurcooL/githubv4" | ||
) | ||
|
||
func main() { | ||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) | ||
|
||
keyFile := flag.String("key-file", "", "Location of the GPG key to verify") | ||
username := flag.String("username", "", "Github username to verify the GPG key against") | ||
orgName := flag.String("org", "", "Github organization name to verify the GPG key against") | ||
flag.Parse() | ||
|
||
logger = logger.With(slog.String("github", *username), slog.String("org", *orgName)) | ||
slog.SetDefault(logger) | ||
logger.Debug("Verifying GPG key from location", slog.String("location", *keyFile)) | ||
|
||
token, err := github.EnvAuthToken() | ||
if err != nil { | ||
logger.Error("Initialization Error", slog.Any("err", err)) | ||
os.Exit(1) | ||
} | ||
|
||
ctx := context.Background() | ||
ghClient := github.NewClient(ctx, logger, token) | ||
|
||
result := &verification.Result{} | ||
|
||
s := VerifyKey(*keyFile) | ||
result.Steps = append(result.Steps, s) | ||
|
||
s = VerifyGithubUser(ghClient, *username, *orgName) | ||
result.Steps = append(result.Steps, s) | ||
|
||
// TODO: Add verification to ensure that the key has been used to sign providers in this github organization | ||
|
||
fmt.Println(result.RenderMarkdown()) | ||
} | ||
|
||
func VerifyGithubUser(client github.Client, username string, orgName string) *verification.Step { | ||
verifyStep := &verification.Step{ | ||
Name: "Validate Github user", | ||
} | ||
|
||
var user *github.GHUser | ||
|
||
verifyStep.RunStep("User exists", func() error { | ||
u, err := client.GetUser(username) | ||
if err != nil { | ||
return fmt.Errorf("failed to get user: %w", err) | ||
} | ||
|
||
user = u | ||
return nil | ||
}) | ||
|
||
if user == nil { | ||
// quickly skip the rest of the steps because the first failed | ||
verifyStep.AddStep(fmt.Sprintf("User is a member of the organization %s", orgName), verification.StatusSkipped, "User does not exist") | ||
return verifyStep | ||
} | ||
|
||
s := verifyStep.RunStep(fmt.Sprintf("User is a member of the organization %s", orgName), func() error { | ||
// Todo: maybe handle pagination, but in theory I doubt people are in 99+ organizations | ||
for _, org := range user.User.Organizations.Nodes { | ||
if org.Name == githubv4.String(orgName) { | ||
return nil | ||
} | ||
} | ||
// TODO: Output a helpful error message here that includes the list of organizations the user is a member of | ||
// and how they can publicly display their organization membership | ||
return fmt.Errorf("user is not a member of the organization") | ||
}) | ||
s.Remarks = []string{"If this is incorrect, please ensure that your organization membership is public. For more information, see [Github Docs - Publicizing or hiding organization membership](https://docs.github.com/en/account-and-profile/setting-up-and-managing-your-personal-account-on-github/managing-your-membership-in-organizations/publicizing-or-hiding-organization-membership)"} | ||
|
||
return verifyStep | ||
} | ||
|
||
var gpgNameEmailRegex = regexp.MustCompile(`.*\<(.*)\>`) | ||
|
||
func VerifyKey(location string) *verification.Step { | ||
verifyStep := &verification.Step{ | ||
Name: "Validate GPG key", | ||
} | ||
|
||
// read the key from the filesystem | ||
data, err := os.ReadFile(location) | ||
if err != nil { | ||
verifyStep.AddError(fmt.Errorf("failed to read key file: %w", err)) | ||
verifyStep.Status = verification.StatusFailure | ||
return verifyStep | ||
} | ||
|
||
var key *crypto.Key | ||
verifyStep.RunStep("Key is a valid PGP key", func() error { | ||
k, err := gpg.ParseKey(string(data)) | ||
if err != nil { | ||
return fmt.Errorf("could not parse key: %w", err) | ||
} | ||
key = k | ||
return nil | ||
}) | ||
|
||
verifyStep.RunStep("Key is not expired", func() error { | ||
if key.IsExpired() { | ||
return fmt.Errorf("key is expired") | ||
} | ||
return nil | ||
}) | ||
|
||
verifyStep.RunStep("Key is not revoked", func() error { | ||
if key.IsRevoked() { | ||
return fmt.Errorf("key is revoked") | ||
} | ||
return nil | ||
}) | ||
|
||
verifyStep.RunStep("Key can be used for signing", func() error { | ||
if !key.CanVerify() { | ||
return fmt.Errorf("key cannot be used for signing") | ||
} | ||
return nil | ||
}) | ||
|
||
verifyStep.RunStep("Key has a valid identity and email. (Email is preferable but optional)", func() error { | ||
if key.GetFingerprint() == "" { | ||
return fmt.Errorf("key has no fingerprint") | ||
} | ||
|
||
entity := key.GetEntity() | ||
if entity == nil { | ||
return fmt.Errorf("key has no entity") | ||
} | ||
|
||
identities := entity.Identities | ||
if len(identities) == 0 { | ||
return fmt.Errorf("key has no identities") | ||
} | ||
|
||
for idName, identity := range identities { | ||
if identity.Name == "" { | ||
return fmt.Errorf("Key identity %s has no name", idName) | ||
} | ||
|
||
email := gpgNameEmailRegex.FindStringSubmatch(identity.Name) | ||
if len(email) != 2 { | ||
return fmt.Errorf("Key identity %s has no email", idName) | ||
} | ||
|
||
_, err := mail.ParseAddress(email[1]) | ||
if err != nil { | ||
return fmt.Errorf("Key identity %s has an invalid email: %w", idName, err) | ||
} | ||
} | ||
|
||
return nil | ||
}) | ||
|
||
return verifyStep | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
package github | ||
|
||
import ( | ||
"fmt" | ||
"log/slog" | ||
|
||
"github.com/shurcooL/githubv4" | ||
) | ||
|
||
type GHUser struct { | ||
User struct { | ||
Login githubv4.String | ||
Name githubv4.String | ||
Organizations struct { | ||
Nodes []struct { | ||
Name githubv4.String | ||
} | ||
PageInfo struct { | ||
EndCursor githubv4.String | ||
HasNextPage githubv4.Boolean | ||
} | ||
} `graphql:"organizations(first: 99)"` | ||
} `graphql:"user(login: $login)"` | ||
} | ||
|
||
func (c Client) GetUser(username string) (*GHUser, error) { | ||
logger := c.log.With("username", username) | ||
logger.Debug("GetUser") | ||
variables := map[string]interface{}{ | ||
"login": githubv4.String(username), | ||
} | ||
|
||
var user GHUser | ||
err := c.ghClient.Query(c.ctx, &user, variables) | ||
if err != nil { | ||
logger.Error("unable to fetch user", slog.Any("err", err)) | ||
return nil, fmt.Errorf("unable to fetch user: %w", err) | ||
} | ||
|
||
return &user, nil | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,86 @@ | ||
package gpg | ||
|
||
import ( | ||
"crypto/rand" | ||
"crypto/rsa" | ||
"crypto/x509" | ||
"encoding/pem" | ||
"strings" | ||
"testing" | ||
|
||
"github.com/ProtonMail/gopenpgp/v2/crypto" | ||
"github.com/ProtonMail/gopenpgp/v2/helper" | ||
"github.com/stretchr/testify/assert" | ||
) | ||
|
||
func generatePrivateKey() (string, error) { | ||
// Generate a new RSA private key with 2048 bits | ||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048) | ||
if err != nil { | ||
return "", err | ||
} | ||
// Encode the private key to the PEM format | ||
privateKeyPEM := &pem.Block{ | ||
Type: "RSA PRIVATE KEY", | ||
Bytes: x509.MarshalPKCS1PrivateKey(privateKey), | ||
} | ||
|
||
return string(pem.EncodeToMemory(privateKeyPEM)), nil | ||
} | ||
|
||
func generateGPGKey() (string, error) { | ||
rsaKey, err := helper.GenerateKey("test", "test", []byte("test"), "rsa", 1024) | ||
if err != nil { | ||
panic(err) | ||
} | ||
|
||
keyRing, err := crypto.NewKeyFromArmoredReader(strings.NewReader(rsaKey)) | ||
if err != nil { | ||
panic(err) | ||
} | ||
|
||
publicKey, err := keyRing.GetArmoredPublicKey() | ||
if err != nil { | ||
panic(err) | ||
} | ||
|
||
return publicKey, nil | ||
} | ||
|
||
func TestParseKey(t *testing.T) { | ||
stringPtr := func(s string) *string { | ||
return &s | ||
} | ||
|
||
privateKey, _ := generatePrivateKey() | ||
publicGPGKey, _ := generateGPGKey() | ||
|
||
tests := []struct { | ||
name string | ||
data string | ||
expectedErr *string | ||
}{ | ||
{ | ||
name: "public gpg key should succeed", | ||
data: publicGPGKey, | ||
expectedErr: nil, | ||
}, | ||
{ | ||
name: "private key should fail", | ||
data: privateKey, | ||
expectedErr: stringPtr("could not build public key from ascii armor"), | ||
}, | ||
} | ||
|
||
for _, test := range tests { | ||
t.Run(test.name, func(t *testing.T) { | ||
_, err := ParseKey(test.data) | ||
|
||
if test.expectedErr != nil { | ||
assert.ErrorContains(t, err, *test.expectedErr) | ||
} else { | ||
assert.NoError(t, err) | ||
} | ||
}) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,49 @@ | ||
package verification | ||
|
||
import "fmt" | ||
|
||
func (r *Result) RenderMarkdown() string { | ||
var output string | ||
for _, step := range r.Steps { | ||
output += fmt.Sprintf("## %s\n", step.Name) | ||
for _, remark := range step.Remarks { | ||
output += fmt.Sprintf("> [!NOTE]\n") | ||
output += fmt.Sprintf("> %s\n\n", remark) | ||
} | ||
if step.Status == StatusSuccess { | ||
output += "✅ **Success**\n" | ||
} else if step.Status == StatusFailure { | ||
output += "❌ **Failure**\n" | ||
} else if step.Status == StatusNotRun { | ||
output += "⚠️ **Not Run**\n" | ||
} else if step.Status == StatusSkipped { | ||
output += "⚠️ **Skipped**\n" | ||
} | ||
|
||
for _, err := range step.Errors { | ||
output += fmt.Sprintf("- %s\n", err) | ||
} | ||
for _, subStep := range step.SubSteps { | ||
output += fmt.Sprintf("### %s\n", subStep.Name) | ||
for _, remark := range subStep.Remarks { | ||
output += fmt.Sprintf("> [!NOTE]\n") | ||
output += fmt.Sprintf("> %s\n\n", remark) | ||
} | ||
if subStep.Status == StatusSuccess { | ||
output += "✅ **Success**\n" | ||
} else if subStep.Status == StatusFailure { | ||
output += "❌ **Failure**\n" | ||
} else if subStep.Status == StatusNotRun { | ||
output += "⚠️ **Not Run**\n" | ||
} else if subStep.Status == StatusSkipped { | ||
output += "⚠️ **Skipped**\n" | ||
} | ||
|
||
for _, err := range subStep.Errors { | ||
output += fmt.Sprintf("- %s\n", err) | ||
} | ||
} | ||
output += "\n" | ||
} | ||
return output | ||
} |
Oops, something went wrong.