Skip to content

Commit

Permalink
Merge pull request #58 from chainguard-dev/create-pull-request/patch
Browse files Browse the repository at this point in the history
Export mono/sdk: refs/heads/main
  • Loading branch information
k4leung4 authored Sep 11, 2024
2 parents 05424c0 + 59fd5a5 commit 3cb4e97
Show file tree
Hide file tree
Showing 25 changed files with 3,536 additions and 176 deletions.
140 changes: 140 additions & 0 deletions auth/headless/code.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/*
Copyright 2024 Chainguard, Inc.
SPDX-License-Identifier: Apache-2.0
*/

package headless

import (
"crypto/aes"
"crypto/cipher"
"crypto/ecdh"
"crypto/rand"
"encoding/base64"
"fmt"
"io"

auth "chainguard.dev/sdk/proto/platform/auth/v1"
)

var URLSafeEncoding = base64.RawURLEncoding // Headless codes must be URL-safe

// headless.Code is a serialized public key that we use to exchange a shared symmetric key.
// This shared symmetric key is used to encrypt the ID token (see Code#NewSession).
//
// After obtaining the shared symmetric key, we throw away our own private key to guarantee
// that the content of the ID token can only be decrypted by the holder of this code's
// private key.
type Code string

// VerifyCode checks if the code is a valid public key.
func VerifyCode(code string) error {
_, err := parsePublic(code)
return err
}

// NewCode creates a code by serializing the public key in an url-safe format.
func NewCode(k *ecdh.PublicKey) Code {
return Code(marshalPublic(k))
}

func marshalPublic(k *ecdh.PublicKey) string {
return URLSafeEncoding.EncodeToString(k.Bytes())
}

func parsePublic(encoded string) (*ecdh.PublicKey, error) {
decoded, err := URLSafeEncoding.DecodeString(encoded)
if err != nil {
return nil, fmt.Errorf("failed to decode public key: %w", err)
}
return ecdhCurve().NewPublicKey(decoded)
}

// NewSession encrypts the idtoken using a shared symmetric key that is
// only available to us and the holder of the private key corresponding to
// this headless Code.
//
// It is important to recall how ECDH works:
// - First, the user generates an EC keypair, and send us their public key
// as the form of a headless login code.
// - We generate a new ephemeral EC keypair for this session.
// - With our private key and their public key, a shared symmetric key is
// obtained by calling ourPriv.ECDH(theirPub).
// - When we send our public key to the user, they can generate the same
// shared symmetric key by calling theirPriv.ECDH(ourPub).
//
// The shared symmetric key obtained by ECDH in this function is used to encrypt
// the idtoken. After the idtoken is encrypted, we throw away our private key
// and the shared symmetric key, so that we ourselves cannot decrypt the idtoken
// ourselves.
//
// We then send the user our public key and the encrypted idtoken. As noted before,
// ECDH allows the user to generate the same shared symmetric key, which can be
// used to decrypt the idtoken.
func (h *Code) NewSession(idtoken []byte) (*auth.HeadlessSession, error) {
theirPub, err := parsePublic(string(*h))
if err != nil {
return nil, fmt.Errorf("failed to parse public key: %w", err)
}
// First we generate a new ephemeral ECDH key pair for this session.
ourPriv, err := GenerateKeyPair()
if err != nil {
return nil, fmt.Errorf("failed to generate key pair: %w", err)
}
// With the user's public key, we can generate a shared symmetric key.
// This shared symmetric key can only be produced by:
// - ourPriv.ECDH(theirPub) OR
// - theirPriv.ECDH(ourPub)
symmetricKey, err := ourPriv.ECDH(theirPub)
if err != nil {
return nil, fmt.Errorf("failed to generate symmetric key: %w", err)
}
// Encrypt the idtoken with the symmetric key.
encryptedIdtoken, err := symmetricEncrypt(idtoken, symmetricKey)
if err != nil {
return nil, fmt.Errorf("failed to encrypt token: %w", err)
}
// Throw away the private key and our copy of the symmetric key, so
// that the symmetric key is only available to the other side by
// calling theirPriv.ECDH(ourPub).
ourPub := []byte(marshalPublic(ourPriv.Public().(*ecdh.PublicKey)))
ourPriv, symmetricKey = nil, nil // nolint
return &auth.HeadlessSession{
EcdhPublicKey: ourPub,
EncryptedIdtoken: encryptedIdtoken,
}, nil
}

// symmetricEncrypt uses AES-GCM to encrypt the payload with the provided key.
// The nonce is prepended to the ciphertext.
//
// See https://pkg.go.dev/crypto/cipher#example-NewGCM-Encrypt
func symmetricEncrypt(payload, key []byte) ([]byte, error) {
if len(key) != ECDHKeyLength {
return nil, fmt.Errorf("invalid key size %d != %d", len(key), ECDHKeyLength)
}
// First create a block cipher
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("failed to create AES cipher: %w", err)
}
// The above is only usable for one block, not a stream.
// Now create a GCM stream cipher based on that.
//
// The advantage of GCM vs CBC is that it provides both encryption and
// authentication, so we can detect if the data has been tampered with,
// instead of just decrypting garbage.
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("failed to create GCM cipher: %w", err)
}
nonce := make([]byte, gcm.NonceSize())
_, err = io.ReadFull(rand.Reader, nonce)
if err != nil {
return nil, fmt.Errorf("failed to generate nonce: %w", err)
}
// gcm.Seal appends the result to the first parameter, which is our
// nonce. So the result is nonce + ciphertext.
result := gcm.Seal(nonce /* prefix */, nonce, payload, nil)
return result, nil
}
102 changes: 102 additions & 0 deletions auth/headless/code_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/*
Copyright 2024 Chainguard, Inc.
SPDX-License-Identifier: Apache-2.0
*/

package headless

import (
"strings"
"testing"
)

func TestVerifyCode(t *testing.T) {
for _, tt := range []struct {
name string
code string
wantErr string
}{
{
name: "invalid public key",
code: "yolo",
wantErr: "invalid public key",
}, {
name: "not base64",
code: "y@l@",
wantErr: "illegal base64",
}, {
name: "valid code",
code: "uFK0TyUaZCMB6QBGC-HVKGJKqI4-U9q5rV9k8I_YUQ4",
},
} {
t.Run(tt.name, func(t *testing.T) {
err := VerifyCode(tt.code)
if tt.wantErr != "" {
if err == nil {
t.Fatal("expected error, see none")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("got %v, want %v", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}

func TestNewSession(t *testing.T) {
for _, tt := range []struct {
name string
code Code
wantErr string
}{{
name: "invalid public key",
code: "yolo",
wantErr: "invalid public key",
}, {
name: "not base64",
code: "y@l@",
wantErr: "illegal base64",
}} {
t.Run(tt.name, func(t *testing.T) {
_, err := tt.code.NewSession([]byte("idtoken"))
if err == nil && tt.wantErr != "" {
t.Fatal("expected error, see none")
}
if err != nil && tt.wantErr == "" {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("got %v, want %v", err, tt.wantErr)
}
})
}
}

func TestEncrypt(t *testing.T) {
for _, tt := range []struct {
name string
key []byte
wantErr string
}{{
name: "invalid key size",
key: []byte("invalid"),
wantErr: "invalid key size",
}} {
t.Run(tt.name, func(t *testing.T) {
_, err := symmetricEncrypt([]byte("token"), tt.key)
if err == nil && tt.wantErr != "" {
t.Fatalf("expected error, got none")
}
if err != nil && tt.wantErr == "" {
t.Fatalf("unexpected error %s", err)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error %s, got %s", tt.wantErr, err)
}
})
}
}
87 changes: 87 additions & 0 deletions auth/headless/headless.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
Copyright 2024 Chainguard, Inc.
SPDX-License-Identifier: Apache-2.0
*/

package headless

import (
"crypto/aes"
"crypto/cipher"
"crypto/ecdh"
"crypto/rand"
"errors"
"fmt"

auth "chainguard.dev/sdk/proto/platform/auth/v1"
)

const (
// X25519 keys are 32 bytes long
ECDHKeyLength = 32
)

// ecdhCurve returns the ecdhCurve used for all ECDH operations in this library.
// It is important to use the same ecdhCurve for all operations in here, as
// the ECDH() calls only work when both sides use the same ecdhCurve.
//
// This is effectively a constant.
func ecdhCurve() ecdh.Curve {
// X25519 gives really short public keys, which is nice as a URL-embedded headless code.
return ecdh.X25519()
}

// GenerateKeyPair generates a new ECDSA key pair.
func GenerateKeyPair() (*ecdh.PrivateKey, error) {
return ecdhCurve().GenerateKey(rand.Reader)
}

// DecryptIDToken decrypts the ID token using the private key.
func DecryptIDToken(sess *auth.HeadlessSession, pk *ecdh.PrivateKey) ([]byte, error) {
serverPub, err := parsePublic(string(sess.EcdhPublicKey))
if err != nil {
return nil, fmt.Errorf("failed to parse public key: %w", err)
}
symKey, err := pk.ECDH(serverPub)
if err != nil {
return nil, fmt.Errorf("failed to decrypt key: %w", err)
}
idtoken, err := symmetricDecrypt(sess.EncryptedIdtoken, symKey)
if err != nil {
return nil, fmt.Errorf("failed to decrypt id token: %w", err)
}
return idtoken, nil
}

// symmetricDecrypt decrypts the payload using the key, using AES-GCM.
// The payload is expected to be the output of #symmetricEncrypt, and
// having the nonce prepended.
//
// See https://pkg.go.dev/crypto/cipher#example-NewGCM-Decrypt
func symmetricDecrypt(ciphertext, key []byte) ([]byte, error) {
if len(key) != ECDHKeyLength {
return nil, fmt.Errorf("invalid key size %d != %d", len(key), ECDHKeyLength)
}

block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("failed to create AES cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("failed to create GCM: %w", err)
}
// We know the ciphertext is actually nonce+ciphertext
// and len(nonce) == NonceSize(). We can separate the two.
nonceSize := gcm.NonceSize()
if len(ciphertext) < nonceSize {
return nil, errors.New("ciphertext too short")
}
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]

plain, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, fmt.Errorf("failed to decrypt using AES-GCM: %w", err)
}
return plain, nil
}
Loading

0 comments on commit 3cb4e97

Please sign in to comment.