-
Notifications
You must be signed in to change notification settings - Fork 15
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 #58 from chainguard-dev/create-pull-request/patch
Export mono/sdk: refs/heads/main
- Loading branch information
Showing
25 changed files
with
3,536 additions
and
176 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,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 | ||
} |
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,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) | ||
} | ||
}) | ||
} | ||
} |
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,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 | ||
} |
Oops, something went wrong.