diff --git a/pkg/encryption/dek.go b/pkg/encryption/dek.go new file mode 100644 index 00000000..e5483b87 --- /dev/null +++ b/pkg/encryption/dek.go @@ -0,0 +1,52 @@ +package encryption + +import ( + "bytes" + "crypto/rand" + "fmt" + "io" + + "github.com/ipld/go-ipld-prime/codec/dagcbor" + basicnode "github.com/ipld/go-ipld-prime/node/basic" +) + +// GenerateDEK generates a random 256-bit (32-byte) Data Encryption Key. +func GenerateDEK() ([]byte, error) { + dek := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, dek); err != nil { + return nil, fmt.Errorf("generating DEK: %w", err) + } + return dek, nil +} + +// SerializeWrappedPayload encodes {path, dek} as a dag-cbor map. +// The resulting bytes are what gets encrypted by the KEK (local AES-GCM or KMS RSA-OAEP). +// Binding the path to the DEK cryptographically ties each DEK to its file. +func SerializeWrappedPayload(path string, dek []byte) ([]byte, error) { + nb := basicnode.Prototype.Map.NewBuilder() + ma, err := nb.BeginMap(2) + if err != nil { + return nil, fmt.Errorf("building wrapped payload map: %w", err) + } + if err := ma.AssembleKey().AssignString("path"); err != nil { + return nil, err + } + if err := ma.AssembleValue().AssignString(path); err != nil { + return nil, err + } + if err := ma.AssembleKey().AssignString("dek"); err != nil { + return nil, err + } + if err := ma.AssembleValue().AssignBytes(dek); err != nil { + return nil, err + } + if err := ma.Finish(); err != nil { + return nil, fmt.Errorf("finishing wrapped payload map: %w", err) + } + + var buf bytes.Buffer + if err := dagcbor.Encode(nb.Build(), &buf); err != nil { + return nil, fmt.Errorf("dag-cbor encoding wrapped payload: %w", err) + } + return buf.Bytes(), nil +} diff --git a/pkg/encryption/dek_test.go b/pkg/encryption/dek_test.go new file mode 100644 index 00000000..5c07d8b6 --- /dev/null +++ b/pkg/encryption/dek_test.go @@ -0,0 +1,89 @@ +package encryption_test + +import ( + "bytes" + "testing" + + "github.com/ipld/go-ipld-prime/codec/dagcbor" + basicnode "github.com/ipld/go-ipld-prime/node/basic" + "github.com/storacha/guppy/pkg/encryption" + "github.com/stretchr/testify/require" +) + +func TestGenerateDEK_Length(t *testing.T) { + dek, err := encryption.GenerateDEK() + require.NoError(t, err) + require.Len(t, dek, 32, "DEK must be exactly 32 bytes (AES-256)") +} + +func TestGenerateDEK_Unique(t *testing.T) { + dek1, err := encryption.GenerateDEK() + require.NoError(t, err) + + dek2, err := encryption.GenerateDEK() + require.NoError(t, err) + + require.NotEqual(t, dek1, dek2, "two consecutive DEKs must differ") +} + +func TestSerializeWrappedPayload_ValidDagCBOR(t *testing.T) { + dek := make([]byte, 32) + data, err := encryption.SerializeWrappedPayload("/tmp/test.txt", dek) + require.NoError(t, err) + require.NotEmpty(t, data) + + // Must be valid dag-cbor + nb := basicnode.Prototype.Map.NewBuilder() + err = dagcbor.Decode(nb, bytes.NewReader(data)) + require.NoError(t, err) +} + +func TestSerializeWrappedPayload_FieldsRoundTrip(t *testing.T) { + path := "/data/myfile.bin" + dek := []byte("12345678901234567890123456789012") // 32 bytes + + data, err := encryption.SerializeWrappedPayload(path, dek) + require.NoError(t, err) + + nb := basicnode.Prototype.Map.NewBuilder() + err = dagcbor.Decode(nb, bytes.NewReader(data)) + require.NoError(t, err) + n := nb.Build() + + pathNode, err := n.LookupByString("path") + require.NoError(t, err) + gotPath, err := pathNode.AsString() + require.NoError(t, err) + require.Equal(t, path, gotPath) + + dekNode, err := n.LookupByString("dek") + require.NoError(t, err) + gotDEK, err := dekNode.AsBytes() + require.NoError(t, err) + require.Equal(t, dek, gotDEK) +} + +func TestSerializeWrappedPayload_Deterministic(t *testing.T) { + path := "/tmp/file.txt" + dek := make([]byte, 32) + + out1, err := encryption.SerializeWrappedPayload(path, dek) + require.NoError(t, err) + + out2, err := encryption.SerializeWrappedPayload(path, dek) + require.NoError(t, err) + + require.Equal(t, out1, out2, "same path+dek must produce identical bytes") +} + +func TestSerializeWrappedPayload_DifferentPathsDifferentBytes(t *testing.T) { + dek := make([]byte, 32) + + out1, err := encryption.SerializeWrappedPayload("/path/a.txt", dek) + require.NoError(t, err) + + out2, err := encryption.SerializeWrappedPayload("/path/b.txt", dek) + require.NoError(t, err) + + require.NotEqual(t, out1, out2) +} diff --git a/pkg/encryption/keywrap.go b/pkg/encryption/keywrap.go new file mode 100644 index 00000000..321eb37c --- /dev/null +++ b/pkg/encryption/keywrap.go @@ -0,0 +1,74 @@ +package encryption + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "fmt" + "io" +) + +// WrapWithLocalKey encrypts plaintext using AES-256-GCM with the given 32-byte KEK. +// Wire format: nonce(12) || ciphertext || tag(16). +func WrapWithLocalKey(kek, plaintext []byte) ([]byte, error) { + if len(kek) != 32 { + return nil, fmt.Errorf("invalid KEK length: expected 32 bytes, got %d", len(kek)) + } + block, err := aes.NewCipher(kek) + if err != nil { + return nil, fmt.Errorf("creating AES cipher: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("creating GCM: %w", err) + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, fmt.Errorf("generating nonce: %w", err) + } + // gcm.Seal appends tag to ciphertext; result is ciphertext+tag(16) + ciphertext := gcm.Seal(nil, nonce, plaintext, nil) + return append(nonce, ciphertext...), nil +} + +// UnwrapWithLocalKey decrypts an AES-256-GCM wrapped payload. +// Expects wire format: nonce(12) || ciphertext || tag(16). +func UnwrapWithLocalKey(kek, data []byte) ([]byte, error) { + if len(kek) != 32 { + return nil, fmt.Errorf("invalid KEK length: expected 32 bytes, got %d", len(kek)) + } + block, err := aes.NewCipher(kek) + if err != nil { + return nil, fmt.Errorf("creating AES cipher: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("creating GCM: %w", err) + } + nonceSize := gcm.NonceSize() + if len(data) < nonceSize+gcm.Overhead() { + return nil, fmt.Errorf("ciphertext too short: %d bytes", len(data)) + } + nonce, ciphertext := data[:nonceSize], data[nonceSize:] + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, fmt.Errorf("AES-GCM decryption failed: %w", err) + } + return plaintext, nil +} + +// WrapWithRSA encrypts plaintext using RSA-OAEP with SHA-256. +// Used in KMS mode to wrap the serialized DEK payload with the space's public key. +func WrapWithRSA(pubKey *rsa.PublicKey, plaintext []byte) ([]byte, error) { + ciphertext, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, pubKey, plaintext, nil) + if err != nil { + return nil, fmt.Errorf("RSA-OAEP encryption failed: %w", err) + } + return ciphertext, nil +} + +// Note: RSA unwrapping is intentionally absent — the KMS holds the private key exclusively. +// Decryption is delegated via the `space/encryption/key/decrypt` UCAN capability to the KMS gateway, +// which decrypts server-side and returns the plaintext DEK. The private key never leaves KMS. diff --git a/pkg/encryption/keywrap_test.go b/pkg/encryption/keywrap_test.go new file mode 100644 index 00000000..4a9cfc52 --- /dev/null +++ b/pkg/encryption/keywrap_test.go @@ -0,0 +1,104 @@ +package encryption_test + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "testing" + + "github.com/storacha/guppy/pkg/encryption" + "github.com/stretchr/testify/require" +) + +func TestWrapUnwrapWithLocalKey_RoundTrip(t *testing.T) { + kek := make([]byte, 32) + _, err := rand.Read(kek) + require.NoError(t, err) + + plaintext := []byte("super-secret-dek-32-bytes-exactly") + + ciphertext, err := encryption.WrapWithLocalKey(kek, plaintext) + require.NoError(t, err) + require.NotEqual(t, plaintext, ciphertext) + + recovered, err := encryption.UnwrapWithLocalKey(kek, ciphertext) + require.NoError(t, err) + require.Equal(t, plaintext, recovered) +} + +func TestWrapWithLocalKey_DifferentNonceEachCall(t *testing.T) { + kek := make([]byte, 32) + _, err := rand.Read(kek) + require.NoError(t, err) + + plaintext := []byte("same-plaintext-every-time") + + ct1, err := encryption.WrapWithLocalKey(kek, plaintext) + require.NoError(t, err) + + ct2, err := encryption.WrapWithLocalKey(kek, plaintext) + require.NoError(t, err) + + require.NotEqual(t, ct1, ct2, "two wraps of the same plaintext must differ (random nonce)") + + // Both must still decrypt to the same plaintext + r1, err := encryption.UnwrapWithLocalKey(kek, ct1) + require.NoError(t, err) + require.Equal(t, plaintext, r1) + + r2, err := encryption.UnwrapWithLocalKey(kek, ct2) + require.NoError(t, err) + require.Equal(t, plaintext, r2) +} + +func TestUnwrapWithLocalKey_WrongKey(t *testing.T) { + kek := make([]byte, 32) + _, err := rand.Read(kek) + require.NoError(t, err) + + wrongKEK := make([]byte, 32) + _, err = rand.Read(wrongKEK) + require.NoError(t, err) + + ciphertext, err := encryption.WrapWithLocalKey(kek, []byte("secret")) + require.NoError(t, err) + + _, err = encryption.UnwrapWithLocalKey(wrongKEK, ciphertext) + require.Error(t, err, "decryption with wrong key must fail") +} + +func TestWrapWithRSA_NonDeterministic(t *testing.T) { + privKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + plaintext := []byte("dek-payload-32-bytes-exactly!!!!") // 32 bytes + + ct1, err := encryption.WrapWithRSA(&privKey.PublicKey, plaintext) + require.NoError(t, err) + + ct2, err := encryption.WrapWithRSA(&privKey.PublicKey, plaintext) + require.NoError(t, err) + + require.NotEqual(t, ct1, ct2, "RSA-OAEP is randomised — two wraps of the same plaintext must differ") +} + +func TestWrapWithRSA_BothOutputsDecryptCorrectly(t *testing.T) { + privKey, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + plaintext := []byte("dek-payload-32-bytes-exactly!!!!") + + ct1, err := encryption.WrapWithRSA(&privKey.PublicKey, plaintext) + require.NoError(t, err) + + ct2, err := encryption.WrapWithRSA(&privKey.PublicKey, plaintext) + require.NoError(t, err) + + r1, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, privKey, ct1, nil) + require.NoError(t, err) + require.Equal(t, plaintext, r1) + + r2, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, privKey, ct2, nil) + require.NoError(t, err) + require.Equal(t, plaintext, r2) +} diff --git a/pkg/encryption/metadata.go b/pkg/encryption/metadata.go new file mode 100644 index 00000000..d704e7a3 --- /dev/null +++ b/pkg/encryption/metadata.go @@ -0,0 +1,207 @@ +package encryption + +import ( + "bytes" + "fmt" + + "github.com/ipfs/go-cid" + "github.com/ipld/go-ipld-prime/codec/dagcbor" + cidlink "github.com/ipld/go-ipld-prime/linking/cid" + basicnode "github.com/ipld/go-ipld-prime/node/basic" + "github.com/multiformats/go-multicodec" + "github.com/multiformats/go-multihash" +) + +// KMSInfo identifies the key management system and algorithm used to wrap a DEK. +type KMSInfo struct { + Provider string // e.g. "google-kms" ot "local" + KeyID string // KMS key identifier (spaceDID withour did:key) + Algorithm string // e.g. "RSA-OAEP-256" or "AES-256-GCM" +} + +// EncryptedMetadata is the per-file CBOR block uploaded alongside encrypted content. +// It is compatible with JS @storacha/encrypt-upload-client. +type EncryptedMetadata struct { + EncryptedDataCID cid.Cid // CID of the encrypted UnixFS root for this file + EncryptedSymmetricKey []byte // wrapped CBOR({path, dek}) blob + Space string // space DID + Path string // plaintext file path (optional) + KMS KMSInfo +} + +// EncodeMetadataBlock serialises meta as a dag-cbor block and returns its CID and raw bytes. +// Field names match the TypeScript EncryptedMetadata interface for cross-client compatibility. +// CID is computed as dag-cbor multicodec (0x71) + SHA-256 of the encoded bytes. +func EncodeMetadataBlock(meta EncryptedMetadata) (cid.Cid, []byte, error) { + nb := basicnode.Prototype.Map.NewBuilder() + ma, err := nb.BeginMap(5) + if err != nil { + return cid.Undef, nil, fmt.Errorf("beginning metadata map: %w", err) + } + + if err := ma.AssembleKey().AssignString("encryptedDataCID"); err != nil { + return cid.Undef, nil, err + } + if err := ma.AssembleValue().AssignLink(cidlink.Link{Cid: meta.EncryptedDataCID}); err != nil { + return cid.Undef, nil, fmt.Errorf("assigning encryptedDataCID: %w", err) + } + + if err := ma.AssembleKey().AssignString("encryptedSymmetricKey"); err != nil { + return cid.Undef, nil, err + } + if err := ma.AssembleValue().AssignBytes(meta.EncryptedSymmetricKey); err != nil { + return cid.Undef, nil, err + } + + if err := ma.AssembleKey().AssignString("space"); err != nil { + return cid.Undef, nil, err + } + if err := ma.AssembleValue().AssignString(meta.Space); err != nil { + return cid.Undef, nil, err + } + + if err := ma.AssembleKey().AssignString("path"); err != nil { + return cid.Undef, nil, err + } + if err := ma.AssembleValue().AssignString(meta.Path); err != nil { + return cid.Undef, nil, err + } + + if err := ma.AssembleKey().AssignString("kms"); err != nil { + return cid.Undef, nil, err + } + kmsVal, err := ma.AssembleValue().BeginMap(3) + if err != nil { + return cid.Undef, nil, fmt.Errorf("beginning kms map: %w", err) + } + if err := kmsVal.AssembleKey().AssignString("provider"); err != nil { + return cid.Undef, nil, err + } + if err := kmsVal.AssembleValue().AssignString(meta.KMS.Provider); err != nil { + return cid.Undef, nil, err + } + if err := kmsVal.AssembleKey().AssignString("keyId"); err != nil { + return cid.Undef, nil, err + } + if err := kmsVal.AssembleValue().AssignString(meta.KMS.KeyID); err != nil { + return cid.Undef, nil, err + } + if err := kmsVal.AssembleKey().AssignString("algorithm"); err != nil { + return cid.Undef, nil, err + } + if err := kmsVal.AssembleValue().AssignString(meta.KMS.Algorithm); err != nil { + return cid.Undef, nil, err + } + if err := kmsVal.Finish(); err != nil { + return cid.Undef, nil, fmt.Errorf("finishing kms map: %w", err) + } + + if err := ma.Finish(); err != nil { + return cid.Undef, nil, fmt.Errorf("finishing metadata map: %w", err) + } + + var buf bytes.Buffer + if err := dagcbor.Encode(nb.Build(), &buf); err != nil { + return cid.Undef, nil, fmt.Errorf("dag-cbor encoding metadata block: %w", err) + } + data := buf.Bytes() + + digest, err := multihash.Sum(data, multihash.SHA2_256, -1) + if err != nil { + return cid.Undef, nil, fmt.Errorf("computing metadata block multihash: %w", err) + } + c := cid.NewCidV1(uint64(multicodec.DagCbor), digest) + + return c, data, nil +} + +// DecodeMetadataBlock deserialises a dag-cbor metadata block produced by EncodeMetadataBlock. +func DecodeMetadataBlock(data []byte) (EncryptedMetadata, error) { + nb := basicnode.Prototype.Map.NewBuilder() + if err := dagcbor.Decode(nb, bytes.NewReader(data)); err != nil { + return EncryptedMetadata{}, fmt.Errorf("dag-cbor decoding metadata block: %w", err) + } + n := nb.Build() + + var meta EncryptedMetadata + + // encryptedDataCID + cidNode, err := n.LookupByString("encryptedDataCID") + if err != nil { + return EncryptedMetadata{}, fmt.Errorf("missing encryptedDataCID: %w", err) + } + link, err := cidNode.AsLink() + if err != nil { + return EncryptedMetadata{}, fmt.Errorf("encryptedDataCID is not a link: %w", err) + } + cl, ok := link.(cidlink.Link) + if !ok { + return EncryptedMetadata{}, fmt.Errorf("encryptedDataCID link is not a CID link") + } + meta.EncryptedDataCID = cl.Cid + + // encryptedSymmetricKey + eskNode, err := n.LookupByString("encryptedSymmetricKey") + if err != nil { + return EncryptedMetadata{}, fmt.Errorf("missing encryptedSymmetricKey: %w", err) + } + meta.EncryptedSymmetricKey, err = eskNode.AsBytes() + if err != nil { + return EncryptedMetadata{}, fmt.Errorf("encryptedSymmetricKey is not bytes: %w", err) + } + + // space + spaceNode, err := n.LookupByString("space") + if err != nil { + return EncryptedMetadata{}, fmt.Errorf("missing space: %w", err) + } + meta.Space, err = spaceNode.AsString() + if err != nil { + return EncryptedMetadata{}, fmt.Errorf("space is not a string: %w", err) + } + + // path + pathNode, err := n.LookupByString("path") + if err != nil { + return EncryptedMetadata{}, fmt.Errorf("missing path: %w", err) + } + meta.Path, err = pathNode.AsString() + if err != nil { + return EncryptedMetadata{}, fmt.Errorf("path is not a string: %w", err) + } + + // kms + kmsNode, err := n.LookupByString("kms") + if err != nil { + return EncryptedMetadata{}, fmt.Errorf("missing kms: %w", err) + } + + providerNode, err := kmsNode.LookupByString("provider") + if err != nil { + return EncryptedMetadata{}, fmt.Errorf("missing kms.provider: %w", err) + } + meta.KMS.Provider, err = providerNode.AsString() + if err != nil { + return EncryptedMetadata{}, fmt.Errorf("kms.provider is not a string: %w", err) + } + + keyIDNode, err := kmsNode.LookupByString("keyId") + if err != nil { + return EncryptedMetadata{}, fmt.Errorf("missing kms.keyId: %w", err) + } + meta.KMS.KeyID, err = keyIDNode.AsString() + if err != nil { + return EncryptedMetadata{}, fmt.Errorf("kms.keyId is not a string: %w", err) + } + + algorithmNode, err := kmsNode.LookupByString("algorithm") + if err != nil { + return EncryptedMetadata{}, fmt.Errorf("missing kms.algorithm: %w", err) + } + meta.KMS.Algorithm, err = algorithmNode.AsString() + if err != nil { + return EncryptedMetadata{}, fmt.Errorf("kms.algorithm is not a string: %w", err) + } + + return meta, nil +} diff --git a/pkg/encryption/metadata_test.go b/pkg/encryption/metadata_test.go new file mode 100644 index 00000000..15fe1838 --- /dev/null +++ b/pkg/encryption/metadata_test.go @@ -0,0 +1,108 @@ +package encryption_test + +import ( + "testing" + + "github.com/ipfs/go-cid" + "github.com/multiformats/go-multicodec" + "github.com/multiformats/go-multihash" + "github.com/storacha/guppy/pkg/encryption" + "github.com/stretchr/testify/require" +) + +// randomCID builds a deterministic dag-cbor CID from a seed byte for test fixtures. +func randomCID(t *testing.T, seed byte) cid.Cid { + t.Helper() + digest, err := multihash.Sum([]byte{seed}, multihash.SHA2_256, -1) + require.NoError(t, err) + return cid.NewCidV1(uint64(multicodec.DagCbor), digest) +} + +func sampleMeta(t *testing.T) encryption.EncryptedMetadata { + t.Helper() + return encryption.EncryptedMetadata{ + EncryptedDataCID: randomCID(t, 0x01), + EncryptedSymmetricKey: []byte("wrapped-dek-payload-bytes"), + Space: "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + Path: "/backups/db.tar", + KMS: encryption.KMSInfo{ + Provider: "google-kms", + KeyID: "z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + Algorithm: "RSA-OAEP-256", + }, + } +} + +func TestEncodeDecodeMetadataBlock_RoundTrip(t *testing.T) { + meta := sampleMeta(t) + + c, data, err := encryption.EncodeMetadataBlock(meta) + require.NoError(t, err) + require.True(t, c.Defined(), "CID must be defined") + require.NotEmpty(t, data) + + decoded, err := encryption.DecodeMetadataBlock(data) + require.NoError(t, err) + + require.Equal(t, meta.EncryptedDataCID, decoded.EncryptedDataCID) + require.Equal(t, meta.EncryptedSymmetricKey, decoded.EncryptedSymmetricKey) + require.Equal(t, meta.Space, decoded.Space) + require.Equal(t, meta.Path, decoded.Path) + require.Equal(t, meta.KMS.Provider, decoded.KMS.Provider) + require.Equal(t, meta.KMS.KeyID, decoded.KMS.KeyID) + require.Equal(t, meta.KMS.Algorithm, decoded.KMS.Algorithm) +} + +func TestEncodeMetadataBlock_CIDIsDagCBOR(t *testing.T) { + meta := sampleMeta(t) + + c, data, err := encryption.EncodeMetadataBlock(meta) + require.NoError(t, err) + + // CID codec must be dag-cbor (0x71) + require.Equal(t, uint64(multicodec.DagCbor), c.Prefix().Codec) + // CID multihash must be SHA2-256 + require.Equal(t, uint64(multihash.SHA2_256), c.Prefix().MhType) + + // CID must be the SHA2-256 of the dag-cbor bytes + expectedDigest, err := multihash.Sum(data, multihash.SHA2_256, -1) + require.NoError(t, err) + expectedCID := cid.NewCidV1(uint64(multicodec.DagCbor), expectedDigest) + require.Equal(t, expectedCID, c) +} + +func TestEncodeMetadataBlock_Deterministic(t *testing.T) { + meta := sampleMeta(t) + + c1, data1, err := encryption.EncodeMetadataBlock(meta) + require.NoError(t, err) + + c2, data2, err := encryption.EncodeMetadataBlock(meta) + require.NoError(t, err) + + require.Equal(t, data1, data2, "identical inputs must produce identical bytes") + require.Equal(t, c1, c2, "identical inputs must produce identical CID") +} + +func TestEncodeMetadataBlock_LocalMode(t *testing.T) { + meta := encryption.EncryptedMetadata{ + EncryptedDataCID: randomCID(t, 0x02), + EncryptedSymmetricKey: []byte("local-gcm-wrapped-dek"), + Space: "did:key:z6Mk1234", + Path: "/data/file.bin", + KMS: encryption.KMSInfo{ + Provider: "local", + KeyID: "z6Mk1234", + Algorithm: "AES-256-GCM", + }, + } + + c, data, err := encryption.EncodeMetadataBlock(meta) + require.NoError(t, err) + require.True(t, c.Defined()) + + decoded, err := encryption.DecodeMetadataBlock(data) + require.NoError(t, err) + require.Equal(t, meta.KMS.Provider, decoded.KMS.Provider) + require.Equal(t, meta.KMS.Algorithm, decoded.KMS.Algorithm) +}