cli/commands/auth/generate/dpop_generate.go

249 lines
6.7 KiB
Go

package generate
import (
"crypto"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"log"
"os"
"time"
"gitlab.com/gitlab-org/cli/commands/auth/generate/dpop"
"gitlab.com/gitlab-org/cli/commands/cmdutils"
"gitlab.com/gitlab-org/cli/pkg/iostreams"
"github.com/MakeNowJust/heredoc"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/spf13/cobra"
"golang.org/x/crypto/ed25519"
"golang.org/x/crypto/ssh"
"golang.org/x/term"
)
type GenerateOpts struct {
IO *iostreams.IOStreams
PrivateKeyLocation string
PersonalAccessToken string
Hostname string
}
type PasswordReader interface {
Read() ([]byte, error)
}
type ConsolePasswordReader struct{}
func (pr ConsolePasswordReader) Read() ([]byte, error) {
return term.ReadPassword(int(os.Stdin.Fd()))
}
func NewCmdGenerate(f *cmdutils.Factory) *cobra.Command {
opts := &GenerateOpts{
IO: f.IO,
}
cmd := &cobra.Command{
Use: "dpop-gen [flags]",
Short: "Generates a DPoP (demonstrating-proof-of-possession) proof JWT. (Experimental.)",
Long: heredoc.Doc(`
Demonstrating-proof-of-possession (DPoP) is a technique to
cryptographically bind personal access tokens to their owners. This command provides
the tools to manage the client aspects of DPoP. It generates a DPoP proof JWT
(JSON Web Token).
Prerequisites:
- You must have a SSH key pair in RSA, ed25519, or ECDSA format.
- You have enabled DPoP for your account, as described in the [GitLab documentation.](https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html#enable-dpop-for-your-personal-access-tokens)
Use the JWT in combination with a Personal Access Token (PAT) to authenticate to
the GitLab API. Your JWT remains valid for 5 minutes. After it expires, you must
generate another token. Your SSH private key is then used to sign the JWT.
`),
Example: heredoc.Doc(`
# Generate a DPoP JWT for authentication to GitLab
$ glab dpop-gen [flags]
$ glab dpop-gen --private-key "~/.ssh/id_rsa" --pat "glpat-xxxxxxxxxxxxxxxxxxxx"
# No PAT required if you previously used the 'glab auth login' command with a PAT
$ glab dpop-gen --private-key "~/.ssh/id_rsa"
# Generate a DPoP JWT for a different GitLab instance
$ glab dpop-gen --private-key "~/.ssh/id_rsa" --hostname "https://gitlab.com"
`),
Args: cobra.ExactArgs(0),
RunE: func(cmd *cobra.Command, args []string) error {
if opts.PrivateKeyLocation == "" {
return fmt.Errorf("private key location is required")
}
if opts.PersonalAccessToken == "" {
cfg, err := f.Config()
if err != nil {
return fmt.Errorf("could not get config: %w", err)
}
token, err := cfg.Get(opts.Hostname, "token")
if err != nil {
return err
}
if token != "" {
opts.PersonalAccessToken = token
} else {
return fmt.Errorf("personal access token is required")
}
}
privateKey, err := loadPrivateKey(opts.PrivateKeyLocation, ConsolePasswordReader{})
if err != nil {
return err
}
proofString, err := generateDPoPProof(privateKey, opts.PersonalAccessToken)
if err != nil {
return err
}
log.Println("DPoP Proof:", proofString)
return nil
},
}
cmd.Flags().StringVarP(&opts.PrivateKeyLocation, "private-key", "p", "", "Location of the private SSH key on the local system.")
cmd.Flags().StringVar(&opts.PersonalAccessToken, "pat", "", "Personal Access Token (PAT) to generate a DPoP proof for. Defaults to the token set with `glab auth login`. Returns an error if both are empty.")
cmd.Flags().StringVarP(&opts.Hostname, "hostname", "h", "gitlab.com", "The hostname of the GitLab instance to authenticate with. Defaults to 'gitlab.com'.")
return cmd
}
func generateDPoPProof(key crypto.PrivateKey, accessToken string) (string, error) {
signingMethod, err := getSigningMethod(key)
if err != nil {
return "", err
}
hashedToken := sha256.Sum256([]byte(accessToken))
base64UrlEncodedHash := base64.RawURLEncoding.EncodeToString(hashedToken[:])
uuidObj, err := uuid.NewRandom()
if err != nil {
return "", err
}
publicKey, err := getPublicKey(key)
if err != nil {
return "", err
}
sshPubKey, err := ssh.NewPublicKey(publicKey)
if err != nil {
return "", err
}
fingerprint := ssh.FingerprintSHA256(sshPubKey)
now := time.Now()
claims := &dpop.ProofTokenClaims{
RegisteredClaims: &jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(time.Minute * 5)),
ID: uuidObj.String(),
},
AccessTokenHash: base64UrlEncodedHash,
}
signer, ok := key.(crypto.Signer)
if !ok {
return "", fmt.Errorf("key type does not implement crypto.Signer")
}
jwk, err := dpop.Reflect(publicKey)
if err != nil {
return "", err
}
token := &jwt.Token{
Header: map[string]interface{}{
"typ": "dpop+jwt",
"alg": signingMethod.Alg(),
"jwk": jwk,
"kid": fingerprint,
},
Claims: claims,
Method: signingMethod,
}
signedToken, err := token.SignedString(signer)
if err != nil {
return "", err
}
return signedToken, nil
}
func getSigningMethod(key crypto.PrivateKey) (jwt.SigningMethod, error) {
var signingMethod jwt.SigningMethod
switch key := key.(type) {
case *rsa.PrivateKey:
{
if key.N.BitLen() < 2048 {
// Minimum should be 2048 as per https://www.rfc-editor.org/rfc/rfc7518.html#section-3.3
return nil, fmt.Errorf("RSA key size must be greater than 2048 bits")
} else if key.N.BitLen() > 8192 {
// Maximum should be 8192 as per https://docs.gitlab.com/ee/user/ssh.html#rsa-ssh-keys
return nil, fmt.Errorf("RSA key size must be less than 8192 bits")
}
signingMethod = jwt.SigningMethodRS512
}
case *ed25519.PrivateKey:
signingMethod = jwt.SigningMethodEdDSA
default:
return nil, fmt.Errorf("unsupported key type")
}
return signingMethod, nil
}
func getPublicKey(key crypto.PrivateKey) (crypto.PublicKey, error) {
switch key := key.(type) {
case *rsa.PrivateKey:
return key.Public(), nil
case *ed25519.PrivateKey:
publicKey := key.Public().(ed25519.PublicKey)
return publicKey, nil
default:
return nil, fmt.Errorf("unsupported key type")
}
}
func loadPrivateKey(path string, passwordReader PasswordReader) (crypto.PrivateKey, error) {
keyBytes, err := os.ReadFile(path)
if err != nil {
return nil, err
}
privateKey, err := ssh.ParseRawPrivateKey(keyBytes)
if err != nil {
var passphraseMissingErr *ssh.PassphraseMissingError
if errors.As(err, &passphraseMissingErr) {
fmt.Println("SSH private key is encrypted. Enter your key's passphrase: ")
passphrase, err := passwordReader.Read()
if err != nil {
return nil, err
}
privateKey, err = ssh.ParseRawPrivateKeyWithPassphrase(keyBytes, passphrase)
if err != nil {
return nil, err
}
} else {
return nil, err
}
}
return privateKey.(crypto.PrivateKey), nil
}