Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Secure document encryption key exchange #2891

Merged
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
101 commits
Select commit Hold shift + click to select a range
8a4b322
Update protobuf
islamaliev Jul 13, 2024
8c81b46
Fix after rebase
islamaliev Jul 17, 2024
b6ef757
Enable very naive key exchange
islamaliev Jul 24, 2024
5d18970
Add protobuf data for key request/response
islamaliev Jul 27, 2024
83cb8da
Encrypt peer-to-peer data exchange
islamaliev Jul 27, 2024
3153090
Merge remote-tracking branch 'upstream/develop' into feat/encryption-…
islamaliev Jul 27, 2024
c2ff5a3
Doc field key exchange
islamaliev Jul 30, 2024
1505ace
Decrypt doc-level and field-level block simultaneously
islamaliev Aug 1, 2024
4782ea5
Add encryption with ECDH
islamaliev Aug 3, 2024
5d83085
Remove unnecessary pub key transit
islamaliev Aug 3, 2024
cf5c559
Implement ECIES
islamaliev Aug 3, 2024
d46b881
Adjustments
islamaliev Aug 3, 2024
4eafe57
Polish
islamaliev Aug 4, 2024
abb3f99
Remove unnecessary peerInfo transit
islamaliev Aug 4, 2024
58bf7ce
Polish
islamaliev Aug 4, 2024
9d89392
Merge remote-tracking branch 'upstream/develop' into feat/encryption-…
islamaliev Aug 4, 2024
a24c9b0
Make all mission enc keys be batched
islamaliev Aug 5, 2024
31e0c37
Load latest available encrypted block from Blockstore instead of fetc…
islamaliev Aug 5, 2024
809e181
Make block store height of where encryption started
islamaliev Aug 7, 2024
a127298
Make failed tests show this 'path: commits[2].links[1].cid' instead o…
islamaliev Aug 7, 2024
f62156c
Merge blocks starting from the first encrypted
islamaliev Aug 7, 2024
cf4ac1c
Add more options to AES encryption/decryption
islamaliev Aug 7, 2024
e5e5fc2
Add associated data to ECIES
islamaliev Aug 7, 2024
8bd3880
Improve session handling
islamaliev Aug 7, 2024
6ad6061
Merge remote-tracking branch 'upstream/develop' into feat/encryption-…
islamaliev Aug 7, 2024
5e72e9c
Split server into 2 files
islamaliev Aug 7, 2024
7357e9a
Polish
islamaliev Aug 7, 2024
60bbb91
Improve documentation
islamaliev Aug 7, 2024
cdf797c
Polish
islamaliev Aug 7, 2024
bf9d685
Minor improvements
islamaliev Aug 7, 2024
42648bc
Remove unnecessary method
islamaliev Aug 8, 2024
1aafe65
Fixed encryptor tests
islamaliev Aug 8, 2024
5cd8977
Polish
islamaliev Aug 8, 2024
f79bf47
Patch for change detector
islamaliev Aug 8, 2024
f0c3cde
Adjust encryption to work with sec. indexes
islamaliev Aug 8, 2024
20f574c
Pass EncStoreDocKey to encryptor
islamaliev Aug 12, 2024
dcf36e7
Store enc key CID in a block instead of height
islamaliev Aug 13, 2024
c70571d
Merge remote-tracking branch 'upstream/develop' into feat/encryption-…
islamaliev Aug 13, 2024
21c98e7
Add minor PR changes
islamaliev Aug 13, 2024
09fb855
Wait for sync on goroutine if count > 1
islamaliev Aug 13, 2024
f85e9a2
Add docs
islamaliev Aug 13, 2024
a8415bf
Add tests for ECIES
islamaliev Aug 13, 2024
c836968
Add AES tests
islamaliev Aug 13, 2024
fdfa6d4
Polish
islamaliev Aug 13, 2024
25d4373
Add unmarshal tests for block
islamaliev Aug 13, 2024
658a092
Add tests for Cid
islamaliev Aug 13, 2024
168f943
Skip even attempt to index if doc is encrypted
islamaliev Aug 13, 2024
ff277b6
Fix tests
islamaliev Aug 13, 2024
20a1c79
Don't request enc keys if not pending
islamaliev Aug 13, 2024
3a8f09b
Handle AnyOf for doc (not only fields)
islamaliev Aug 13, 2024
53a3e95
Add a test
islamaliev Aug 13, 2024
89ff5f0
Fix lint
islamaliev Aug 13, 2024
16b175d
Fix an issue with overwriting AAD
islamaliev Aug 14, 2024
f115a14
Remove cache from encryptor
islamaliev Aug 14, 2024
0831b01
Make block.GetPrevBlockCids return all heads
islamaliev Aug 14, 2024
af9009b
Moved schemaRoot to session
islamaliev Aug 14, 2024
c8d2d44
Rename Id to ID
islamaliev Aug 16, 2024
bfd89c2
PR polish
islamaliev Aug 16, 2024
c02e5f7
Adjust phony
islamaliev Aug 16, 2024
a01faa1
Fix mistake in AAD
islamaliev Aug 18, 2024
e19cd2b
Remove unnecessary encryptor test
islamaliev Aug 19, 2024
ecd5082
Remove unused exch field of Peer struct
islamaliev Aug 19, 2024
0f561a7
Remove global functions from encryption package
islamaliev Aug 19, 2024
ae716eb
Add more docs
islamaliev Aug 19, 2024
260fa59
Merge remote-tracking branch 'upstream/develop' into feat/encryption-…
islamaliev Sep 1, 2024
714fb3c
Initial KMS implementation
islamaliev Sep 8, 2024
7ff6019
Keep only 1 executeMerge (WIP)
islamaliev Sep 9, 2024
55965e6
Make it work with 2 events
islamaliev Sep 10, 2024
807c252
FIx indexing after decryption
islamaliev Sep 10, 2024
3deb0c3
Change KMS test setup, Rename p2p KMS to pubsub
islamaliev Sep 10, 2024
9dc089b
Strong error types for crypto package
islamaliev Sep 10, 2024
302191c
Use response chan instead of another event
islamaliev Sep 10, 2024
4e35ca0
Polish
islamaliev Sep 10, 2024
88519eb
Remove unused method
islamaliev Sep 10, 2024
c4f8270
Polish
islamaliev Sep 12, 2024
4d97c06
Make encryption key be store in dedicated IPLD block
islamaliev Sep 14, 2024
f84a4d4
Add mocks for encstore
islamaliev Sep 14, 2024
a11d199
Remove unused files
islamaliev Sep 14, 2024
d2fec1f
Fix lint
islamaliev Sep 14, 2024
a9e286f
Add options to ECIES
islamaliev Sep 14, 2024
ab05781
Remove EncStoreKey
islamaliev Sep 14, 2024
bae47d7
Polish
islamaliev Sep 14, 2024
73e7069
Request encBlocks' cids in batches
islamaliev Sep 15, 2024
c7121a4
Minor refactor
islamaliev Sep 15, 2024
0a218ab
Make KMS also wait on ctx.Done()
islamaliev Sep 15, 2024
16d1f8e
Polish
islamaliev Sep 16, 2024
9045ffe
Merge remote-tracking branch 'upstream/develop' into feat/encryption-…
islamaliev Sep 16, 2024
32f2471
Update go mod
islamaliev Sep 16, 2024
b2c1f9d
Lint polish
islamaliev Sep 16, 2024
913f8d7
Merge remote-tracking branch 'upstream/develop' into feat/encryption-…
islamaliev Sep 16, 2024
c2acc3c
Merge remote-tracking branch 'upstream/develop' into feat/encryption-…
islamaliev Sep 17, 2024
ab44314
Add comments
islamaliev Sep 17, 2024
3a72abb
Fix lint
islamaliev Sep 17, 2024
05a4b9a
Merge remote-tracking branch 'upstream/develop' into feat/encryption-…
islamaliev Sep 17, 2024
673bf54
Merge remote-tracking branch 'upstream/develop' into feat/encryption-…
islamaliev Sep 17, 2024
bb1466a
Merge remote-tracking branch 'upstream/develop' into feat/encryption-…
islamaliev Sep 19, 2024
ef9022f
PR fix up
islamaliev Sep 19, 2024
bc335af
Add tests for checking encryption of empty and nil values
islamaliev Sep 19, 2024
8cdb515
Merge remote-tracking branch 'upstream/develop' into feat/encryption-…
islamaliev Sep 19, 2024
90866e1
Merge remote-tracking branch 'upstream/develop' into feat/encryption-…
islamaliev Sep 21, 2024
a492a1d
PR fixup
islamaliev Sep 23, 2024
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
2 changes: 1 addition & 1 deletion cli/collection_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ func setContextDocEncryption(cmd *cobra.Command, shouldEncryptDoc bool, encryptF
}
ctx := cmd.Context()
if txn != nil {
islamaliev marked this conversation as resolved.
Show resolved Hide resolved
ctx = encryption.ContextWithStore(ctx, txn)
ctx = encryption.ContextWithStore(ctx, txn.Encstore())
islamaliev marked this conversation as resolved.
Show resolved Hide resolved
}
ctx = encryption.SetContextConfigFromParams(ctx, shouldEncryptDoc, encryptFields)
cmd.SetContext(ctx)
Expand Down
5 changes: 5 additions & 0 deletions client/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ type DB interface {
// It sits within the rootstore returned by [Root].
Blockstore() datastore.Blockstore

// Encstore returns the store, that contains all known encryption keys for documents and their fields.
//
// It sits within the rootstore returned by [Root].
Encstore() datastore.DSReaderWriter

// Peerstore returns the peerstore where known host information is stored.
//
// It sits within the rootstore returned by [Root].
Expand Down
50 changes: 23 additions & 27 deletions internal/encryption/aes.go → crypto/aes.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,69 +8,65 @@
// by the Apache License, Version 2.0, included in the file
// licenses/APL.txt.

package encryption
package crypto

import (
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"fmt"
)

// EncryptAES encrypts data using AES-GCM with a provided key.
func EncryptAES(plainText, key []byte) ([]byte, error) {
// The nonce is prepended to the cipherText.
func EncryptAES(plainText, key, additionalData []byte, prependNonce bool) ([]byte, []byte, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, err
return nil, nil, err
}

nonce, err := generateNonceFunc()
if err != nil {
return nil, err
return nil, nil, err
}

aesGCM, err := cipher.NewGCM(block)
if err != nil {
return nil, err
return nil, nil, err
}

cipherText := aesGCM.Seal(nonce, nonce, plainText, nil)

buf := make([]byte, base64.StdEncoding.EncodedLen(len(cipherText)))
base64.StdEncoding.Encode(buf, cipherText)
var cipherText []byte
if prependNonce {
cipherText = aesGCM.Seal(nonce, nonce, plainText, additionalData)
} else {
cipherText = aesGCM.Seal(nil, nonce, plainText, additionalData)
}

return buf, nil
return cipherText, nonce, nil
}

// DecryptAES decrypts AES-GCM encrypted data with a provided key.
func DecryptAES(cipherTextBase64, key []byte) ([]byte, error) {
cipherText := make([]byte, base64.StdEncoding.DecodedLen(len(cipherTextBase64)))
n, err := base64.StdEncoding.Decode(cipherText, []byte(cipherTextBase64))

if err != nil {
return nil, err
// The nonce is expected to be prepended to the cipherText.
func DecryptAES(nonce, cipherText, key, additionalData []byte) ([]byte, error) {
if len(nonce) == 0 {
if len(cipherText) < AESNonceSize {
// TODO return typed error
return nil, fmt.Errorf("cipherText too short")
}
nonce = cipherText[:AESNonceSize]
cipherText = cipherText[AESNonceSize:]
}

cipherText = cipherText[:n]

block, err := aes.NewCipher(key)
if err != nil {
return nil, err
}

if len(cipherText) < nonceLength {
return nil, fmt.Errorf("cipherText too short")
}

nonce := cipherText[:nonceLength]
cipherText = cipherText[nonceLength:]

aesGCM, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}

plainText, err := aesGCM.Open(nil, nonce, cipherText, nil)
plainText, err := aesGCM.Open(nil, nonce, cipherText, additionalData)
if err != nil {
return nil, err
}
Expand Down
164 changes: 164 additions & 0 deletions crypto/ecies.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
// Current Implementation Analysis:

Check failure on line 1 in crypto/ecies.go

View workflow job for this annotation

GitHub Actions / Lint GoLang job

Actual: urrent Implementation Analysis:
// 1. Key Generation: Correctly uses X25519 for key generation.
// 2. ECDH: Properly performs the ECDH operation.
// 3. Key Derivation: Uses SHA-256 on the shared secret, which is simplistic.
// 4. Encryption: Uses AES (implementation not shown).
// 5. MAC: Not implemented.

// Improvements Needed:
// 1. Use a proper Key Derivation Function (KDF)
// 2. Implement HMAC for message authentication
// 3. Use authenticated encryption (e.g., AES-GCM) instead of AES
// 4. Standardize the output format

// Here's an improved version of the EncryptECDH and DecryptECDH functions:

package crypto

import (
"crypto/ecdh"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"fmt"

"golang.org/x/crypto/hkdf"
)

const X25519PublicKeySize = 32
const HMACSize = 32
const AESKeySize = 32

const minCipherTextSize = 16

// GenerateX25519 generates a new X25519 private key.
func GenerateX25519() (*ecdh.PrivateKey, error) {
return ecdh.X25519().GenerateKey(rand.Reader)
}

// X25519PublicKeyFromBytes creates a new X25519 public key from the given bytes.
func X25519PublicKeyFromBytes(publicKeyBytes []byte) (*ecdh.PublicKey, error) {
return ecdh.X25519().NewPublicKey(publicKeyBytes)
}

// EncryptECIES encrypts plaintext using a custom Elliptic Curve Integrated Encryption Scheme (ECIES)
// with X25519 for key agreement, HKDF for key derivation, AES for encryption, and HMAC for authentication.
//
// The function:
// - Generates an ephemeral X25519 key pair
// - Performs ECDH with the provided public key
// - Derives encryption and HMAC keys using HKDF
// - Encrypts the plaintext using a custom AES encryption function
// - Computes an HMAC over the ciphertext
//
// The output format is: [ephemeral public key | encrypted data (including nonce) | HMAC]
//
// Parameters:
// - plainText: The message to encrypt
// - publicKey: The recipient's X25519 public key
// - associatedData: Optional associated data for additional authentication
//
// Returns:
// - Byte slice containing the encrypted message and necessary metadata for decryption
// - Error if any step of the encryption process fails
func EncryptECIES(plainText []byte, publicKey *ecdh.PublicKey, associatedData []byte) ([]byte, error) {
ephemeralPrivate, err := GenerateX25519()
if err != nil {
return nil, fmt.Errorf("failed to generate ephemeral key: %w", err)
islamaliev marked this conversation as resolved.
Show resolved Hide resolved
}
ephemeralPublic := ephemeralPrivate.PublicKey()

sharedSecret, err := ephemeralPrivate.ECDH(publicKey)
if err != nil {
return nil, fmt.Errorf("ECDH failed: %w", err)

Check failure on line 73 in crypto/ecies.go

View workflow job for this annotation

GitHub Actions / Lint GoLang job

string-format: must not start with a capital letter (revive)
}

kdf := hkdf.New(sha256.New, sharedSecret, nil, nil)
aesKey := make([]byte, AESKeySize)
hmacKey := make([]byte, HMACSize)
if _, err := kdf.Read(aesKey); err != nil {
return nil, fmt.Errorf("KDF failed for AES key: %w", err)

Check failure on line 80 in crypto/ecies.go

View workflow job for this annotation

GitHub Actions / Lint GoLang job

string-format: must not start with a capital letter (revive)
}
if _, err := kdf.Read(hmacKey); err != nil {
return nil, fmt.Errorf("KDF failed for HMAC key: %w", err)

Check failure on line 83 in crypto/ecies.go

View workflow job for this annotation

GitHub Actions / Lint GoLang job

string-format: must not start with a capital letter (revive)
}

fullAssociatedData := append(ephemeralPublic.Bytes(), associatedData...)
cipherText, _, err := EncryptAES(plainText, aesKey, fullAssociatedData, true)
if err != nil {
return nil, fmt.Errorf("failed to encrypt: %w", err)
}

mac := hmac.New(sha256.New, hmacKey)
mac.Write(cipherText)
macSum := mac.Sum(nil)

result := append(ephemeralPublic.Bytes(), cipherText...)
result = append(result, macSum...)

return result, nil
}

// DecryptECIES decrypts ciphertext encrypted with EncryptECIES using the provided private key.
//
// The function:
// - Extracts the ephemeral public key from the ciphertext
// - Performs ECDH with the provided private key
// - Derives decryption and HMAC keys using HKDF
// - Verifies the HMAC
// - Decrypts the message using a custom AES decryption function
//
// The expected input format is: [ephemeral public key | encrypted data (including nonce) | HMAC]
//
// Parameters:
// - cipherText: The encrypted message, including all necessary metadata
// - privateKey: The recipient's X25519 private key
// - associatedData: Optional associated data used during encryption for additional authentication
//
// Returns:
// - Byte slice containing the decrypted plaintext
// - Error if any step of the decryption process fails, including authentication failure
func DecryptECIES(cipherText []byte, privateKey *ecdh.PrivateKey, associatedData []byte) ([]byte, error) {
if len(cipherText) < X25519PublicKeySize+AESNonceSize+HMACSize+minCipherTextSize {
return nil, fmt.Errorf("ciphertext too short")
}

ephemeralPublicBytes := cipherText[:X25519PublicKeySize]
ephemeralPublic, err := ecdh.X25519().NewPublicKey(ephemeralPublicBytes)
if err != nil {
return nil, fmt.Errorf("failed to parse ephemeral public key: %w", err)
}

sharedSecret, err := privateKey.ECDH(ephemeralPublic)
if err != nil {
return nil, fmt.Errorf("ECDH failed: %w", err)

Check failure on line 134 in crypto/ecies.go

View workflow job for this annotation

GitHub Actions / Lint GoLang job

string-format: must not start with a capital letter (revive)
}

kdf := hkdf.New(sha256.New, sharedSecret, nil, nil)
aesKey := make([]byte, AESKeySize)
hmacKey := make([]byte, HMACSize)
if _, err := kdf.Read(aesKey); err != nil {
return nil, fmt.Errorf("KDF failed for AES key: %w", err)

Check failure on line 141 in crypto/ecies.go

View workflow job for this annotation

GitHub Actions / Lint GoLang job

string-format: must not start with a capital letter (revive)
}
if _, err := kdf.Read(hmacKey); err != nil {
return nil, fmt.Errorf("KDF failed for HMAC key: %w", err)

Check failure on line 144 in crypto/ecies.go

View workflow job for this annotation

GitHub Actions / Lint GoLang job

string-format: must not start with a capital letter (revive)
}

macSum := cipherText[len(cipherText)-HMACSize:]
cipherTextWithNonce := cipherText[X25519PublicKeySize : len(cipherText)-HMACSize]

mac := hmac.New(sha256.New, hmacKey)
mac.Write(cipherTextWithNonce)
expectedMAC := mac.Sum(nil)
if !hmac.Equal(macSum, expectedMAC) {
return nil, fmt.Errorf("HMAC verification failed")

Check failure on line 154 in crypto/ecies.go

View workflow job for this annotation

GitHub Actions / Lint GoLang job

string-format: must not start with a capital letter (revive)
}

fullAssociatedData := append(ephemeralPublicBytes, associatedData...)
plainText, err := DecryptAES(nil, cipherTextWithNonce, aesKey, fullAssociatedData)
if err != nil {
return nil, fmt.Errorf("failed to decrypt: %w", err)
}

return plainText, nil
}
47 changes: 47 additions & 0 deletions crypto/ephemeral.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package crypto

import (
"crypto/ecdh"
"crypto/rand"
"crypto/sha256"
"fmt"
)

const (
// EphemeralKeyLength is the size of the ECDH ephemeral key in bytes.
EphemeralKeyLength = 65
)

// EncryptWithEphemeralKey encrypts a key using a randomly generated ephemeral ECDH key and a provided public key.
// It returns the encrypted key prepended with the ephemeral public key.
func EncryptWithEphemeralKey(plainText, publicKeyBytes []byte) ([]byte, error) {
ephemeralPriv, err := ecdh.P256().GenerateKey(rand.Reader)
if err != nil {
return nil, fmt.Errorf("failed to generate ephemeral key: %w", err)
}

ephPubKeyBytes := ephemeralPriv.PublicKey().Bytes()
sharedSecret := sha256.Sum256(append(ephPubKeyBytes, publicKeyBytes...))

return append(ephPubKeyBytes, xorBytes(plainText, sharedSecret[:])...), nil
}

func xorBytes(data, xor []byte) []byte {
result := make([]byte, len(data))
for i := range data {
result[i] = data[i] ^ xor[i%len(xor)]
}
return result
}

// DecryptWithEphemeralKey decrypts data that was encrypted using EncryptWithEphemeralKey.
// It expects the input to be the ephemeral public key followed by the encrypted data.
func DecryptWithEphemeralKey(encryptedData, publicKeyBytes []byte) ([]byte, error) {
ephPubKeyBytes := encryptedData[:EphemeralKeyLength]
cipherText := make([]byte, len(encryptedData)-EphemeralKeyLength)
copy(cipherText, encryptedData[EphemeralKeyLength:])

sharedSecret := sha256.Sum256(append(ephPubKeyBytes, publicKeyBytes...))

return xorBytes(cipherText, sharedSecret[:]), nil
}
52 changes: 52 additions & 0 deletions crypto/nonce.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Copyright 2024 Democratized Data Foundation
//
// Use of this software is governed by the Business Source License
// included in the file licenses/BSL.txt.
//
// As of the Change Date specified in that file, in accordance with
// the Business Source License, use of this software will be governed
// by the Apache License, Version 2.0, included in the file
// licenses/APL.txt.

package crypto

import (
"crypto/rand"
"errors"
"io"
"os"
"strings"
)

const AESNonceSize = 12

var generateNonceFunc = generateNonce

func generateNonce() ([]byte, error) {
nonce := make([]byte, AESNonceSize)
islamaliev marked this conversation as resolved.
Show resolved Hide resolved
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}

return nonce, nil
}

// generateTestNonce generates a deterministic nonce for testing.
func generateTestNonce() ([]byte, error) {
nonce := []byte("deterministic nonce for testing")

if len(nonce) < AESNonceSize {
return nil, errors.New("nonce length is longer than available deterministic nonce")
}

return nonce[:AESNonceSize], nil
}

func init() {
arg := os.Args[0]
// If the binary is a test binary, use a deterministic nonce.
// TODO: We should try to find a better way to detect this https://github.com/sourcenetwork/defradb/issues/2801
if strings.HasSuffix(arg, ".test") || strings.Contains(arg, "/defradb/tests/") {
generateNonceFunc = generateTestNonce
}
}
3 changes: 1 addition & 2 deletions datastore/multi.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,7 @@ type multistore struct {
head DSReaderWriter
peer DSBatching
system DSReaderWriter
// block DSReaderWriter
dag Blockstore
dag Blockstore
}

var _ MultiStore = (*multistore)(nil)
Expand Down
Loading
Loading