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
52 changes: 52 additions & 0 deletions pkg/encryption/dek.go
Original file line number Diff line number Diff line change
@@ -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
}
89 changes: 89 additions & 0 deletions pkg/encryption/dek_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
74 changes: 74 additions & 0 deletions pkg/encryption/keywrap.go
Original file line number Diff line number Diff line change
@@ -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.
104 changes: 104 additions & 0 deletions pkg/encryption/keywrap_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading