Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,24 @@ or, without the version::

$ sops encrypt --azure-kv https://sops.vault.azure.net/keys/sops-key/ test.yaml > test.enc.yaml

For offline encryption, SOPS can encrypt the data key locally with a downloaded
Azure Key Vault RSA public key while still using Azure Key Vault for
decryption. Configure the Azure key as an object in ``.sops.yaml`` and provide
``publicKeyFile``. In this mode, ``version`` must be set because SOPS does not
contact Azure to resolve the latest version during encryption. SOPS persists the
public key in file metadata so later offline edits and rotations can keep
rewrapping the data key without network access.

.. code:: yaml

creation_rules:
- path_regex: \.prod\.yaml$
azure_keyvault:
- vaultUrl: https://sops.vault.azure.net
key: sops-key
version: some-string
publicKeyFile: ./keys/sops-key.pub

And decrypt it using::

$ sops decrypt test.enc.yaml
Expand Down Expand Up @@ -1976,6 +1994,13 @@ A key group supports the following keys:
* ``version`` (string, can be empty): the version of the key to use.
If empty, the latest key will be used on encryption.

Optional keys:

* ``publicKeyFile`` (string): local path to an Azure Key Vault RSA public
key file for offline encryption. When set, encryption uses the local public
key and decryption still calls Azure Key Vault. ``version`` must be set in
this mode.

Example:

.. code:: yaml
Expand Down
155 changes: 154 additions & 1 deletion azkv/keysource.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,16 @@ package azkv // import "github.com/getsops/sops/v3/azkv"

import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"math/big"
"os"
"regexp"
"strings"
"time"
Expand Down Expand Up @@ -54,6 +62,9 @@ type MasterKey struct {
// CreationDate of the MasterKey, used to determine if the EncryptedKey
// needs rotation.
CreationDate time.Time
// PublicKey contains an optional locally provided public key used for
// offline encryption. Decryption still uses Azure Key Vault.
PublicKey []byte

// tokenCredential contains the azcore.TokenCredential used by the Azure
// client. It can be injected by a (local) keyservice.KeyServiceServer
Expand All @@ -64,6 +75,18 @@ type MasterKey struct {
clientOptions *azkeys.ClientOptions
}

type jsonWebKeyEnvelope struct {
Key *jsonWebKey `json:"key"`
}

type jsonWebKey struct {
Kid string `json:"kid"`
Kty string `json:"kty"`
N string `json:"n"`
E string `json:"e"`
X5c []string `json:"x5c"`
}

// newMasterKey creates a new MasterKey from a URL, key name and version,
// setting the creation date to the current date.
func newMasterKey(vaultURL string, keyName string, keyVersion string) *MasterKey {
Expand Down Expand Up @@ -91,6 +114,30 @@ func NewMasterKeyWithOptionalVersion(vaultURL string, keyName string, keyVersion
return key, nil
}

// NewMasterKeyWithPublicKey creates a new MasterKey that encrypts data keys
// offline with the provided public key and decrypts them through Azure Key Vault.
func NewMasterKeyWithPublicKey(vaultURL string, keyName string, keyVersion string, publicKey []byte) (*MasterKey, error) {
key := newMasterKey(vaultURL, keyName, keyVersion)
key.PublicKey = append([]byte(nil), publicKey...)

if key.Version == "" {
return nil, fmt.Errorf("azure key vault offline encryption requires a key version")
}
if _, err := parsePublicKey(key.PublicKey); err != nil {
return nil, err
}
return key, nil
}

// NewMasterKeyWithPublicKeyFile creates a new MasterKey from a local public key file.
func NewMasterKeyWithPublicKeyFile(vaultURL string, keyName string, keyVersion string, publicKeyPath string) (*MasterKey, error) {
publicKey, err := os.ReadFile(publicKeyPath)
if err != nil {
return nil, fmt.Errorf("failed to read Azure Key Vault public key file %q: %w", publicKeyPath, err)
}
return NewMasterKeyWithPublicKey(vaultURL, keyName, keyVersion, publicKey)
}

// NewMasterKeyFromURL takes an Azure Key Vault key URL, and returns a new
// MasterKey. The URL format is {vaultUrl}/keys/{keyName}/{keyVersion}.
func NewMasterKeyFromURL(url string) (*MasterKey, error) {
Expand Down Expand Up @@ -171,7 +218,7 @@ func (key *MasterKey) Encrypt(dataKey []byte) error {
}

func (key *MasterKey) ensureKeyHasVersion(ctx context.Context) error {
if (key.Version != "") {
if key.Version != "" {
// Nothing to do
return nil
}
Expand Down Expand Up @@ -203,6 +250,10 @@ func (key *MasterKey) ensureKeyHasVersion(ctx context.Context) error {
// EncryptContext takes a SOPS data key, encrypts it with Azure Key Vault, and stores
// the result in the EncryptedKey field.
func (key *MasterKey) EncryptContext(ctx context.Context, dataKey []byte) error {
if len(key.PublicKey) > 0 {
return key.encryptOffline(dataKey)
}

token, err := key.getTokenCredential()
if err != nil {
log.WithFields(logrus.Fields{"key": key.Name, "version": key.Version}).Info("Encryption failed")
Expand Down Expand Up @@ -230,6 +281,23 @@ func (key *MasterKey) EncryptContext(ctx context.Context, dataKey []byte) error
return nil
}

func (key *MasterKey) encryptOffline(dataKey []byte) error {
publicKey, err := parsePublicKey(key.PublicKey)
if err != nil {
log.WithFields(logrus.Fields{"key": key.Name, "version": key.Version}).Info("Encryption failed")
return err
}

encryptedKey, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, publicKey, dataKey, nil)
if err != nil {
log.WithFields(logrus.Fields{"key": key.Name, "version": key.Version}).Info("Encryption failed")
return fmt.Errorf("failed to encrypt sops data key locally with Azure Key Vault public key '%s': %w", key.ToString(), err)
}
key.SetEncryptedDataKey([]byte(base64.RawURLEncoding.EncodeToString(encryptedKey)))
log.WithFields(logrus.Fields{"key": key.Name, "version": key.Version}).Info("Offline encryption succeeded")
return nil
}

// EncryptedDataKey returns the encrypted data key this master key holds.
func (key *MasterKey) EncryptedDataKey() []byte {
return []byte(key.EncryptedKey)
Expand Down Expand Up @@ -306,6 +374,9 @@ func (key MasterKey) ToMap() map[string]interface{} {
out["vaultUrl"] = key.VaultURL
out["key"] = key.Name
out["version"] = key.Version
if len(key.PublicKey) > 0 {
out["public_key"] = base64.StdEncoding.EncodeToString(key.PublicKey)
}
out["created_at"] = key.CreationDate.UTC().Format(time.RFC3339)
out["enc"] = key.EncryptedKey
return out
Expand All @@ -324,3 +395,85 @@ func (key *MasterKey) getTokenCredential() (azcore.TokenCredential, error) {
}
return key.tokenCredential, nil
}

func parsePublicKey(raw []byte) (*rsa.PublicKey, error) {
if jwk, err := parseJSONWebKey(raw); err == nil {
return jwk, nil
}
if pemKey, err := parsePEMPublicKey(raw); err == nil {
return pemKey, nil
}
return nil, fmt.Errorf("failed to parse Azure Key Vault public key: expected RSA JWK or PEM")
}

func parseJSONWebKey(raw []byte) (*rsa.PublicKey, error) {
var key jsonWebKey
if err := json.Unmarshal(raw, &key); err != nil {
var envelope jsonWebKeyEnvelope
if err := json.Unmarshal(raw, &envelope); err != nil || envelope.Key == nil {
return nil, fmt.Errorf("invalid JSON Web Key")
}
key = *envelope.Key
}
if key.N == "" || key.E == "" {
return nil, fmt.Errorf("json web key is missing modulus or exponent")
}
if key.Kty != "" && key.Kty != "RSA" && key.Kty != "RSA-HSM" {
return nil, fmt.Errorf("unsupported Azure Key Vault key type %q", key.Kty)
}

modulusBytes, err := base64.RawURLEncoding.DecodeString(key.N)
if err != nil {
return nil, fmt.Errorf("failed to decode JSON Web Key modulus: %w", err)
}
exponentBytes, err := base64.RawURLEncoding.DecodeString(key.E)
if err != nil {
return nil, fmt.Errorf("failed to decode JSON Web Key exponent: %w", err)
}
exponent := 0
for _, b := range exponentBytes {
exponent = exponent<<8 + int(b)
}
if exponent == 0 {
return nil, fmt.Errorf("json web key exponent is invalid")
}

return &rsa.PublicKey{
N: new(big.Int).SetBytes(modulusBytes),
E: exponent,
}, nil
}

func parsePEMPublicKey(raw []byte) (*rsa.PublicKey, error) {
block, _ := pem.Decode(raw)
if block == nil {
return nil, fmt.Errorf("no PEM block found")
}

switch block.Type {
case "PUBLIC KEY":
publicKey, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, err
}
rsaKey, ok := publicKey.(*rsa.PublicKey)
if !ok {
return nil, fmt.Errorf("PEM public key is not RSA")
}
return rsaKey, nil
case "RSA PUBLIC KEY":
return x509.ParsePKCS1PublicKey(block.Bytes)
case "CERTIFICATE":
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, err
}
rsaKey, ok := cert.PublicKey.(*rsa.PublicKey)
if !ok {
return nil, fmt.Errorf("certificate public key is not RSA")
}
return rsaKey, nil
default:
return nil, fmt.Errorf("unsupported PEM block type %q", block.Type)
}
}
93 changes: 93 additions & 0 deletions azkv/keysource_test.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
package azkv

import (
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"os"
"path/filepath"
"testing"
"time"

"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

const (
Expand Down Expand Up @@ -178,6 +188,23 @@ func TestMasterKey_EncryptIfNeeded(t *testing.T) {
assert.NoError(t, key.EncryptIfNeeded([]byte("other data")))
assert.Equal(t, encryptedKey, key.EncryptedKey)
})

t.Run("offline with public key", func(t *testing.T) {
privateKey := mustGenerateRSAKey(t)
publicKey := pem.EncodeToMemory(&pem.Block{
Type: "PUBLIC KEY",
Bytes: mustMarshalPKIXPublicKey(t, &privateKey.PublicKey),
})

key, err := NewMasterKeyWithPublicKey("https://test.vault.azure.net", "test-key", "test-version", publicKey)
require.NoError(t, err)

require.NoError(t, key.EncryptIfNeeded([]byte("other data")))

plaintext, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, privateKey, mustDecodeRawURL(t, key.EncryptedKey), nil)
require.NoError(t, err)
assert.Equal(t, []byte("other data"), plaintext)
})
}

func TestMasterKey_NeedsRotation(t *testing.T) {
Expand Down Expand Up @@ -210,6 +237,72 @@ func TestMasterKey_ToMap(t *testing.T) {
}, key.ToMap())
}

func TestNewMasterKeyWithPublicKeyFile(t *testing.T) {
privateKey := mustGenerateRSAKey(t)
publicKeyPath := filepath.Join(t.TempDir(), "pub.pem")
err := os.WriteFile(publicKeyPath, pem.EncodeToMemory(&pem.Block{
Type: "PUBLIC KEY",
Bytes: mustMarshalPKIXPublicKey(t, &privateKey.PublicKey),
}), 0o600)
require.NoError(t, err)

key, err := NewMasterKeyWithPublicKeyFile("https://test.vault.azure.net", "test-key", "test-version", publicKeyPath)
require.NoError(t, err)
assert.Equal(t, "test-version", key.Version)
assert.NotEmpty(t, key.PublicKey)
}

func TestMasterKey_EncryptOfflineWithJWK(t *testing.T) {
privateKey := mustGenerateRSAKey(t)
jwkBytes, err := json.Marshal(map[string]string{
"kty": "RSA",
"n": base64.RawURLEncoding.EncodeToString(privateKey.N.Bytes()),
"e": base64.RawURLEncoding.EncodeToString(bigEndianBytes(privateKey.E)),
})
require.NoError(t, err)

key, err := NewMasterKeyWithPublicKey("https://test.vault.azure.net", "test-key", "test-version", jwkBytes)
require.NoError(t, err)
require.NoError(t, key.Encrypt([]byte("secret")))

plaintext, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, privateKey, mustDecodeRawURL(t, key.EncryptedKey), nil)
require.NoError(t, err)
assert.Equal(t, []byte("secret"), plaintext)
}

func mustGenerateRSAKey(t *testing.T) *rsa.PrivateKey {
t.Helper()
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
return privateKey
}

func mustMarshalPKIXPublicKey(t *testing.T, publicKey *rsa.PublicKey) []byte {
t.Helper()
publicKeyDER, err := x509.MarshalPKIXPublicKey(publicKey)
require.NoError(t, err)
return publicKeyDER
}

func mustDecodeRawURL(t *testing.T, value string) []byte {
t.Helper()
decoded, err := base64.RawURLEncoding.DecodeString(value)
require.NoError(t, err)
return decoded
}

func bigEndianBytes(v int) []byte {
if v == 0 {
return []byte{0}
}
var out []byte
for v > 0 {
out = append([]byte{byte(v & 0xff)}, out...)
v >>= 8
}
return out
}

func TestMasterKey_getTokenCredential(t *testing.T) {
t.Run("with TokenCredential", func(t *testing.T) {
credential, err := azidentity.NewUsernamePasswordCredential("tenant", "client", "username", "password", nil)
Expand Down
2 changes: 1 addition & 1 deletion cmd/sops/subcommand/keyservice/keyservice.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ func Run(opts Opts) error {
}
defer lis.Close()
grpcServer := grpc.NewServer()
keyservice.RegisterKeyServiceServer(grpcServer, keyservice.Server{
keyservice.RegisterKeyServiceServer(grpcServer, &keyservice.Server{
Prompt: opts.Prompt,
})
log.Infof("Listening on %s://%s", opts.Network, opts.Address)
Expand Down
Loading
Loading