Skip to content

Commit f080bdc

Browse files
committed
Add offline Azure Key Vault encryption support
Signed-off-by: rkthtrifork <rkth@trifork.com>
1 parent 8a423c5 commit f080bdc

File tree

15 files changed

+553
-74
lines changed

15 files changed

+553
-74
lines changed

README.rst

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,24 @@ or, without the version::
426426
427427
$ sops encrypt --azure-kv https://sops.vault.azure.net/keys/sops-key/ test.yaml > test.enc.yaml
428428
429+
For offline encryption, SOPS can encrypt the data key locally with a downloaded
430+
Azure Key Vault RSA public key while still using Azure Key Vault for
431+
decryption. Configure the Azure key as an object in ``.sops.yaml`` and provide
432+
``publicKeyFile``. In this mode, ``version`` must be set because SOPS does not
433+
contact Azure to resolve the latest version during encryption. SOPS persists the
434+
public key in file metadata so later offline edits and rotations can keep
435+
rewrapping the data key without network access.
436+
437+
.. code:: yaml
438+
439+
creation_rules:
440+
- path_regex: \.prod\.yaml$
441+
azure_keyvault:
442+
- vaultUrl: https://sops.vault.azure.net
443+
key: sops-key
444+
version: some-string
445+
publicKeyFile: ./keys/sops-key.pub
446+
429447
And decrypt it using::
430448
431449
$ sops decrypt test.enc.yaml
@@ -1976,6 +1994,13 @@ A key group supports the following keys:
19761994
* ``version`` (string, can be empty): the version of the key to use.
19771995
If empty, the latest key will be used on encryption.
19781996
1997+
Optional keys:
1998+
1999+
* ``publicKeyFile`` (string): local path to an Azure Key Vault RSA public
2000+
key file for offline encryption. When set, encryption uses the local public
2001+
key and decryption still calls Azure Key Vault. ``version`` must be set in
2002+
this mode.
2003+
19792004
Example:
19802005
19812006
.. code:: yaml

azkv/keysource.go

Lines changed: 154 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,16 @@ package azkv // import "github.com/getsops/sops/v3/azkv"
77

88
import (
99
"context"
10+
"crypto/rand"
11+
"crypto/rsa"
12+
"crypto/sha256"
13+
"crypto/x509"
1014
"encoding/base64"
15+
"encoding/json"
16+
"encoding/pem"
1117
"fmt"
18+
"math/big"
19+
"os"
1220
"regexp"
1321
"strings"
1422
"time"
@@ -54,6 +62,9 @@ type MasterKey struct {
5462
// CreationDate of the MasterKey, used to determine if the EncryptedKey
5563
// needs rotation.
5664
CreationDate time.Time
65+
// PublicKey contains an optional locally provided public key used for
66+
// offline encryption. Decryption still uses Azure Key Vault.
67+
PublicKey []byte
5768

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

78+
type jsonWebKeyEnvelope struct {
79+
Key *jsonWebKey `json:"key"`
80+
}
81+
82+
type jsonWebKey struct {
83+
Kid string `json:"kid"`
84+
Kty string `json:"kty"`
85+
N string `json:"n"`
86+
E string `json:"e"`
87+
X5c []string `json:"x5c"`
88+
}
89+
6790
// newMasterKey creates a new MasterKey from a URL, key name and version,
6891
// setting the creation date to the current date.
6992
func newMasterKey(vaultURL string, keyName string, keyVersion string) *MasterKey {
@@ -91,6 +114,30 @@ func NewMasterKeyWithOptionalVersion(vaultURL string, keyName string, keyVersion
91114
return key, nil
92115
}
93116

117+
// NewMasterKeyWithPublicKey creates a new MasterKey that encrypts data keys
118+
// offline with the provided public key and decrypts them through Azure Key Vault.
119+
func NewMasterKeyWithPublicKey(vaultURL string, keyName string, keyVersion string, publicKey []byte) (*MasterKey, error) {
120+
key := newMasterKey(vaultURL, keyName, keyVersion)
121+
key.PublicKey = append([]byte(nil), publicKey...)
122+
123+
if key.Version == "" {
124+
return nil, fmt.Errorf("azure key vault offline encryption requires a key version")
125+
}
126+
if _, err := parsePublicKey(key.PublicKey); err != nil {
127+
return nil, err
128+
}
129+
return key, nil
130+
}
131+
132+
// NewMasterKeyWithPublicKeyFile creates a new MasterKey from a local public key file.
133+
func NewMasterKeyWithPublicKeyFile(vaultURL string, keyName string, keyVersion string, publicKeyPath string) (*MasterKey, error) {
134+
publicKey, err := os.ReadFile(publicKeyPath)
135+
if err != nil {
136+
return nil, fmt.Errorf("failed to read Azure Key Vault public key file %q: %w", publicKeyPath, err)
137+
}
138+
return NewMasterKeyWithPublicKey(vaultURL, keyName, keyVersion, publicKey)
139+
}
140+
94141
// NewMasterKeyFromURL takes an Azure Key Vault key URL, and returns a new
95142
// MasterKey. The URL format is {vaultUrl}/keys/{keyName}/{keyVersion}.
96143
func NewMasterKeyFromURL(url string) (*MasterKey, error) {
@@ -171,7 +218,7 @@ func (key *MasterKey) Encrypt(dataKey []byte) error {
171218
}
172219

173220
func (key *MasterKey) ensureKeyHasVersion(ctx context.Context) error {
174-
if (key.Version != "") {
221+
if key.Version != "" {
175222
// Nothing to do
176223
return nil
177224
}
@@ -203,6 +250,10 @@ func (key *MasterKey) ensureKeyHasVersion(ctx context.Context) error {
203250
// EncryptContext takes a SOPS data key, encrypts it with Azure Key Vault, and stores
204251
// the result in the EncryptedKey field.
205252
func (key *MasterKey) EncryptContext(ctx context.Context, dataKey []byte) error {
253+
if len(key.PublicKey) > 0 {
254+
return key.encryptOffline(dataKey)
255+
}
256+
206257
token, err := key.getTokenCredential()
207258
if err != nil {
208259
log.WithFields(logrus.Fields{"key": key.Name, "version": key.Version}).Info("Encryption failed")
@@ -230,6 +281,23 @@ func (key *MasterKey) EncryptContext(ctx context.Context, dataKey []byte) error
230281
return nil
231282
}
232283

284+
func (key *MasterKey) encryptOffline(dataKey []byte) error {
285+
publicKey, err := parsePublicKey(key.PublicKey)
286+
if err != nil {
287+
log.WithFields(logrus.Fields{"key": key.Name, "version": key.Version}).Info("Encryption failed")
288+
return err
289+
}
290+
291+
encryptedKey, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, publicKey, dataKey, nil)
292+
if err != nil {
293+
log.WithFields(logrus.Fields{"key": key.Name, "version": key.Version}).Info("Encryption failed")
294+
return fmt.Errorf("failed to encrypt sops data key locally with Azure Key Vault public key '%s': %w", key.ToString(), err)
295+
}
296+
key.SetEncryptedDataKey([]byte(base64.RawURLEncoding.EncodeToString(encryptedKey)))
297+
log.WithFields(logrus.Fields{"key": key.Name, "version": key.Version}).Info("Offline encryption succeeded")
298+
return nil
299+
}
300+
233301
// EncryptedDataKey returns the encrypted data key this master key holds.
234302
func (key *MasterKey) EncryptedDataKey() []byte {
235303
return []byte(key.EncryptedKey)
@@ -306,6 +374,9 @@ func (key MasterKey) ToMap() map[string]interface{} {
306374
out["vaultUrl"] = key.VaultURL
307375
out["key"] = key.Name
308376
out["version"] = key.Version
377+
if len(key.PublicKey) > 0 {
378+
out["public_key"] = base64.StdEncoding.EncodeToString(key.PublicKey)
379+
}
309380
out["created_at"] = key.CreationDate.UTC().Format(time.RFC3339)
310381
out["enc"] = key.EncryptedKey
311382
return out
@@ -324,3 +395,85 @@ func (key *MasterKey) getTokenCredential() (azcore.TokenCredential, error) {
324395
}
325396
return key.tokenCredential, nil
326397
}
398+
399+
func parsePublicKey(raw []byte) (*rsa.PublicKey, error) {
400+
if jwk, err := parseJSONWebKey(raw); err == nil {
401+
return jwk, nil
402+
}
403+
if pemKey, err := parsePEMPublicKey(raw); err == nil {
404+
return pemKey, nil
405+
}
406+
return nil, fmt.Errorf("failed to parse Azure Key Vault public key: expected RSA JWK or PEM")
407+
}
408+
409+
func parseJSONWebKey(raw []byte) (*rsa.PublicKey, error) {
410+
var key jsonWebKey
411+
if err := json.Unmarshal(raw, &key); err != nil {
412+
var envelope jsonWebKeyEnvelope
413+
if err := json.Unmarshal(raw, &envelope); err != nil || envelope.Key == nil {
414+
return nil, fmt.Errorf("invalid JSON Web Key")
415+
}
416+
key = *envelope.Key
417+
}
418+
if key.N == "" || key.E == "" {
419+
return nil, fmt.Errorf("json web key is missing modulus or exponent")
420+
}
421+
if key.Kty != "" && key.Kty != "RSA" && key.Kty != "RSA-HSM" {
422+
return nil, fmt.Errorf("unsupported Azure Key Vault key type %q", key.Kty)
423+
}
424+
425+
modulusBytes, err := base64.RawURLEncoding.DecodeString(key.N)
426+
if err != nil {
427+
return nil, fmt.Errorf("failed to decode JSON Web Key modulus: %w", err)
428+
}
429+
exponentBytes, err := base64.RawURLEncoding.DecodeString(key.E)
430+
if err != nil {
431+
return nil, fmt.Errorf("failed to decode JSON Web Key exponent: %w", err)
432+
}
433+
exponent := 0
434+
for _, b := range exponentBytes {
435+
exponent = exponent<<8 + int(b)
436+
}
437+
if exponent == 0 {
438+
return nil, fmt.Errorf("json web key exponent is invalid")
439+
}
440+
441+
return &rsa.PublicKey{
442+
N: new(big.Int).SetBytes(modulusBytes),
443+
E: exponent,
444+
}, nil
445+
}
446+
447+
func parsePEMPublicKey(raw []byte) (*rsa.PublicKey, error) {
448+
block, _ := pem.Decode(raw)
449+
if block == nil {
450+
return nil, fmt.Errorf("no PEM block found")
451+
}
452+
453+
switch block.Type {
454+
case "PUBLIC KEY":
455+
publicKey, err := x509.ParsePKIXPublicKey(block.Bytes)
456+
if err != nil {
457+
return nil, err
458+
}
459+
rsaKey, ok := publicKey.(*rsa.PublicKey)
460+
if !ok {
461+
return nil, fmt.Errorf("PEM public key is not RSA")
462+
}
463+
return rsaKey, nil
464+
case "RSA PUBLIC KEY":
465+
return x509.ParsePKCS1PublicKey(block.Bytes)
466+
case "CERTIFICATE":
467+
cert, err := x509.ParseCertificate(block.Bytes)
468+
if err != nil {
469+
return nil, err
470+
}
471+
rsaKey, ok := cert.PublicKey.(*rsa.PublicKey)
472+
if !ok {
473+
return nil, fmt.Errorf("certificate public key is not RSA")
474+
}
475+
return rsaKey, nil
476+
default:
477+
return nil, fmt.Errorf("unsupported PEM block type %q", block.Type)
478+
}
479+
}

azkv/keysource_test.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,21 @@
11
package azkv
22

33
import (
4+
"crypto/rand"
5+
"crypto/rsa"
6+
"crypto/sha256"
7+
"crypto/x509"
8+
"encoding/base64"
9+
"encoding/json"
10+
"encoding/pem"
11+
"os"
12+
"path/filepath"
413
"testing"
514
"time"
615

716
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
817
"github.com/stretchr/testify/assert"
18+
"github.com/stretchr/testify/require"
919
)
1020

1121
const (
@@ -178,6 +188,23 @@ func TestMasterKey_EncryptIfNeeded(t *testing.T) {
178188
assert.NoError(t, key.EncryptIfNeeded([]byte("other data")))
179189
assert.Equal(t, encryptedKey, key.EncryptedKey)
180190
})
191+
192+
t.Run("offline with public key", func(t *testing.T) {
193+
privateKey := mustGenerateRSAKey(t)
194+
publicKey := pem.EncodeToMemory(&pem.Block{
195+
Type: "PUBLIC KEY",
196+
Bytes: mustMarshalPKIXPublicKey(t, &privateKey.PublicKey),
197+
})
198+
199+
key, err := NewMasterKeyWithPublicKey("https://test.vault.azure.net", "test-key", "test-version", publicKey)
200+
require.NoError(t, err)
201+
202+
require.NoError(t, key.EncryptIfNeeded([]byte("other data")))
203+
204+
plaintext, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, privateKey, mustDecodeRawURL(t, key.EncryptedKey), nil)
205+
require.NoError(t, err)
206+
assert.Equal(t, []byte("other data"), plaintext)
207+
})
181208
}
182209

183210
func TestMasterKey_NeedsRotation(t *testing.T) {
@@ -210,6 +237,72 @@ func TestMasterKey_ToMap(t *testing.T) {
210237
}, key.ToMap())
211238
}
212239

240+
func TestNewMasterKeyWithPublicKeyFile(t *testing.T) {
241+
privateKey := mustGenerateRSAKey(t)
242+
publicKeyPath := filepath.Join(t.TempDir(), "pub.pem")
243+
err := os.WriteFile(publicKeyPath, pem.EncodeToMemory(&pem.Block{
244+
Type: "PUBLIC KEY",
245+
Bytes: mustMarshalPKIXPublicKey(t, &privateKey.PublicKey),
246+
}), 0o600)
247+
require.NoError(t, err)
248+
249+
key, err := NewMasterKeyWithPublicKeyFile("https://test.vault.azure.net", "test-key", "test-version", publicKeyPath)
250+
require.NoError(t, err)
251+
assert.Equal(t, "test-version", key.Version)
252+
assert.NotEmpty(t, key.PublicKey)
253+
}
254+
255+
func TestMasterKey_EncryptOfflineWithJWK(t *testing.T) {
256+
privateKey := mustGenerateRSAKey(t)
257+
jwkBytes, err := json.Marshal(map[string]string{
258+
"kty": "RSA",
259+
"n": base64.RawURLEncoding.EncodeToString(privateKey.N.Bytes()),
260+
"e": base64.RawURLEncoding.EncodeToString(bigEndianBytes(privateKey.E)),
261+
})
262+
require.NoError(t, err)
263+
264+
key, err := NewMasterKeyWithPublicKey("https://test.vault.azure.net", "test-key", "test-version", jwkBytes)
265+
require.NoError(t, err)
266+
require.NoError(t, key.Encrypt([]byte("secret")))
267+
268+
plaintext, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, privateKey, mustDecodeRawURL(t, key.EncryptedKey), nil)
269+
require.NoError(t, err)
270+
assert.Equal(t, []byte("secret"), plaintext)
271+
}
272+
273+
func mustGenerateRSAKey(t *testing.T) *rsa.PrivateKey {
274+
t.Helper()
275+
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
276+
require.NoError(t, err)
277+
return privateKey
278+
}
279+
280+
func mustMarshalPKIXPublicKey(t *testing.T, publicKey *rsa.PublicKey) []byte {
281+
t.Helper()
282+
publicKeyDER, err := x509.MarshalPKIXPublicKey(publicKey)
283+
require.NoError(t, err)
284+
return publicKeyDER
285+
}
286+
287+
func mustDecodeRawURL(t *testing.T, value string) []byte {
288+
t.Helper()
289+
decoded, err := base64.RawURLEncoding.DecodeString(value)
290+
require.NoError(t, err)
291+
return decoded
292+
}
293+
294+
func bigEndianBytes(v int) []byte {
295+
if v == 0 {
296+
return []byte{0}
297+
}
298+
var out []byte
299+
for v > 0 {
300+
out = append([]byte{byte(v & 0xff)}, out...)
301+
v >>= 8
302+
}
303+
return out
304+
}
305+
213306
func TestMasterKey_getTokenCredential(t *testing.T) {
214307
t.Run("with TokenCredential", func(t *testing.T) {
215308
credential, err := azidentity.NewUsernamePasswordCredential("tenant", "client", "username", "password", nil)

cmd/sops/subcommand/keyservice/keyservice.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ func Run(opts Opts) error {
3434
}
3535
defer lis.Close()
3636
grpcServer := grpc.NewServer()
37-
keyservice.RegisterKeyServiceServer(grpcServer, keyservice.Server{
37+
keyservice.RegisterKeyServiceServer(grpcServer, &keyservice.Server{
3838
Prompt: opts.Prompt,
3939
})
4040
log.Infof("Listening on %s://%s", opts.Network, opts.Address)

0 commit comments

Comments
 (0)