Skip to content

Commit

Permalink
Merge pull request #88 from opentofu/gpg-key-verifications
Browse files Browse the repository at this point in the history
  • Loading branch information
Yantrio authored Dec 7, 2023
2 parents 72f791d + 5998dea commit a26d229
Show file tree
Hide file tree
Showing 9 changed files with 439 additions and 100 deletions.
173 changes: 173 additions & 0 deletions src/cmd/verify-gpg-key/main.go
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
}
41 changes: 41 additions & 0 deletions src/internal/github/user.go
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
}
14 changes: 12 additions & 2 deletions src/internal/gpg/key.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,23 @@ func buildKey(path string) (*Key, error) {

asciiArmor := string(data)

key, err := crypto.NewKeyFromArmored(asciiArmor)
key, err := ParseKey(asciiArmor)
if err != nil {
return nil, fmt.Errorf("could not build public key from ascii armor: %w", err)
return nil, fmt.Errorf("could not parse key: %w", err)
}

return &Key{
ASCIIArmor: asciiArmor,
KeyID: strings.ToUpper(key.GetHexKeyID()),
}, nil
}

// ParseKey parses a GPG key from ascii armor.
func ParseKey(data string) (*crypto.Key, error) {
key, err := crypto.NewKeyFromArmored(data)
if err != nil {
return nil, fmt.Errorf("could not build public key from ascii armor: %w", err)
}

return key, nil
}
86 changes: 86 additions & 0 deletions src/internal/gpg/key_test.go
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)
}
})
}
}
49 changes: 49 additions & 0 deletions src/pkg/verification/render.go
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
}
Loading

0 comments on commit a26d229

Please sign in to comment.