Skip to content
37 changes: 37 additions & 0 deletions go/tdh2/tdh2hybridCCP/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
## tdh2hybridCCP: Hybrid TDH2 and ChaCha20-Poly1305

This fork of /tdh2/tdh2easy provides a hybrid encryption scheme that uses **Threshold Diffie-Hellman (TDH2)** which is secure against adaptive chosen-ciphertext attacks (CCA2), combined with a ***modern symmetric stream cipher*** **ChaCha20-Poly1305** ***instead of*** **AES-256 in Galois/Counter Mode (GCM)**.

### ChaCha20-Poly1305 replaces AES-256-GCM
The modern stream cipher provides:
- Authenticated Encryption with Associated Data (AEAD), also called Additional Authenticated Data (AAD):
- It encrypts sensitive payload data while allowing additional, authenticated but not encrypted metadata ("associated data") to be authenticated along with the ciphertext which detects any tampering.
- AEAD has become the standard for securing communication, replacing older, less secure methods that combined encryption and Message Authentication Code (MAC) separately.
- Performance:
- Stream ciphers are often faster than AES on devices without hardware acceleration.
- Designed to be fast and efficient, often outperforming separate encryption and authentication mechanisms.
- Verification during Decryption: If the authentication tag does not match the decrypted data and associated data, the decryption fails, ensuring integrity.
- Support for larger plaintext: up to 256 GB compared to maximum ca. 64 GB with AES (RFC5084).

### Example
The [`func TestHybrid()`](./hybrid_test.go) provides running code that steps through the cycle of Distribted Key Generation (DKG), hybrid encryption of plaintext, decryption of shares by parties and their verification before a combiner aggregates the decryption shares, and finally decrypts the ciphertext.

Run it together with other `*_test.go` files after change into subdir `tdhhybridCCP` of this repo:
```
~/tdh2/go/tdh2/tdh2hybridCCP$ go test
Message encrypted successfully.
Decrypted Message: The quick brown fox jumps over the lazy dog's back 0123456789.
PASS
ok github.com/hb9cwp/tdh2/go/tdh2/tdh2hybridCCP 0.109s
```

### References

The implementation "SG02" of TDH2, the threshold cryptosystem proposed by Shoup and Gennaro[^1], in the Rust library "Thetacrypt"[^2] motivated the replacement of AES-GCM by ChaCha20-Poly1305 and the name for this fork of `tdh2easy`:

> "We apply a ***hybrid*** approach to encrypt a _symmetric key_ under the _threshold key_ and the actual _plaintext_ under the _symmetric key_. As a _symmetric encryption scheme_, we use the ***ChaCha20Poly1305***, a stream cipher with a message authentication code."

[^1]: [Securing Threshold Cryptosystems against Chosen Ciphertext Attack](https://www.shoup.net/papers/thresh1.pdf), Victor Shoup & Rosario Gennaro, September 18, 2001.

[^2]: [Thetacrypt: A Distributed Service for Threshold Cryptography](https://arxiv.org/pdf/2502.03247), Cryptology and Data Security Research Group at the University of Bern, 6 February 2025.

133 changes: 133 additions & 0 deletions go/tdh2/tdh2hybridCCP/hybrid_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package tdh2hybridCCP

import (
"bytes"
"fmt"
"testing"
)

func TestHybrid(t *testing.T) {
// Optional: Rename this to 'func main() {...}' to convert to
// a self-contained Go program. Also replace 't.' by 'log.' and
// add prefix 'tdh2hybridCCP.' to import functions & objects.
// Alternatively, rename it to 'func ExampleHybrid()' or similar to test
// only for final output, see https://pkg.go.dev/testing#hdr-Examples

// 1. Setup: Define the threshold (k) and total participants (n).
// We need at least 2 parties to decrypt out of 3 total.
var k, n int = 2, 3

// Perform a distributed key generation (DKG) protocol to create a
// Master Secret, a collective Public Key, and n individual
// Private Key Shares.
// Note: The Master Secret (ms) returned is ignored here, but it will
// be required for re-keying by Redeal(pk, ms, k, n).
//ms, pubKey, privShares, err := tdh2hybridCCP.GenerateKeys(k, n)
_, pubKey, privShares, err := GenerateKeys(k, n)
if err != nil {
t.Fatalf("Failed to generate keys: %v", err)
}

// 2. Encryption
message := []byte("The quick brown fox jumps over the lazy dog's back 0123456789.")

// Anyone can encrypt using the Public Key only.
//cipherText, err := Encrypt(pubKey, message)
aaData := []byte("tests additional authenticated, but not encrypted metadata")
//cipherText, err := tdh2ccp.EncryptWithAaD(pubKey, message, aaData)
cipherText, err := EncryptWithAaD(pubKey, message, aaData)
//var emptyLabel [tdh2.InputSize]byte
//cipherText, err := EncryptWithLabelAndAaD(pubKey, message, emptyLabel, aaData)
if err != nil {
t.Fatalf("Encryption failed: %v", err)
}
fmt.Println("Message encrypted successfully.")

// 3. Decryption of all n shares
// Each participant creates a 'decryption share' from the ciphertext
// using their own private key share, returns a *DecryptionShare.
// ToDo: generalize for k of n (loop)
share0, err := Decrypt(cipherText, privShares[0])
if err != nil {
t.Fatalf("Decryption share0 by party 0 failed: %v", err)
}
share1, err := Decrypt(cipherText, privShares[1])
if err != nil {
t.Fatalf("Decryption share1 by party 1 failed: %v", err)
}
share2, err := Decrypt(cipherText, privShares[2])
if err != nil {
t.Fatalf("Decryption share2 by party 2 failed: %v", err)
}

// 4. Verification: Combiner verifies decrypted shares before aggregating them.
// Observe comment from Aggregate(): "Ciphertext and shares MUST be verified
// before calling Aggregate ..."
// ToDo: generalize for k of n (loop)
err = VerifyShare(cipherText, pubKey, share0)
if err != nil {
t.Fatalf("Verify share0 by combiner failed: %v", err)
}
err = VerifyShare(cipherText, pubKey, share1)
if err != nil {
t.Fatalf("Verify share1 by combiner failed: %v", err)
}
err = VerifyShare(cipherText, pubKey, share2)
if err != nil {
t.Fatalf("Verify share2 by combiner failed: %v", err)
}

// 5. Aggregation: Combine min. k of n decrypted shares to recover the
// original message in cleartext.
// ToDo: Perform fuzzing over cleartext of other messages lenght
// from 0 to max. (2^32 -1)*64 = 256 GB (the first block of 64 byte
// is used by Poly1305), see comment in sym.go.

// Create a slice of the pointers, not a slice of byte slices.
// All the shares have to be distinct and their number has to be
// at least the threshold k.
//decryptionShares := []*tdh2hybridCCP.DecryptionShare{share0, share1}
decryptionShares := []*DecryptionShare{share0, share1}
if _, err := Aggregate(cipherText, decryptionShares, n); err != nil {
t.Fatalf("Aggregation of share0, share1 failed: %v", err)
}
decryptionShares = []*DecryptionShare{share0, share2}
if _, err := Aggregate(cipherText, decryptionShares, n); err != nil {
t.Fatalf("Aggregation of share0, share2 failed: %v", err)
}
decryptionShares = []*DecryptionShare{share1, share2}
if _, err := Aggregate(cipherText, decryptionShares, n); err != nil {
t.Fatalf("Aggregation of share1, share2 failed: %v", err)
}
decryptionShares = []*DecryptionShare{share2, share0} // rotate (reverse) order
if _, err := Aggregate(cipherText, decryptionShares, n); err != nil {
t.Fatalf("Aggregation of share2, share0 failed: %v", err)
}
decryptionShares = []*DecryptionShare{share0, share1, share2} // all shares
if _, err := Aggregate(cipherText, decryptionShares, n); err != nil {
t.Fatalf("Aggregation of share0, share1, share2 failed: %v", err)
}
// make Aggregate() fail:
decryptionShares = []*DecryptionShare{share1, share1} // shares not distinct
if _, err := Aggregate(cipherText, decryptionShares, n); err == nil {
t.Fatalf("Aggregation of share1, share1 must fail: %v", err)
}
decryptionShares = []*DecryptionShare{share1, share1, share2} // shares not distinct
if _, err := Aggregate(cipherText, decryptionShares, n); err == nil {
t.Fatalf("Aggregation of share1, share1, share2 must fail: %v", err)
}
decryptionShares = []*DecryptionShare{share1} // fewer shares than threshold k
if _, err := Aggregate(cipherText, decryptionShares, n); err == nil {
t.Fatalf("Aggregation of share1 must fail: %v", err)
}

decryptionShares = []*DecryptionShare{share0, share1} // repeat one last time
decryptedMsg, err := Aggregate(cipherText, decryptionShares, n)
if err != nil {
t.Fatalf("Aggregation of share0, share1 failed: %v", err)
}
if !bytes.Equal(decryptedMsg, message) {
t.Fatalf("decrypeted message does not match cleartext\n got: %#v\n want: %#v", decryptedMsg, message)
}
fmt.Printf("Decrypted Message: %s\n", string(decryptedMsg))
}
77 changes: 77 additions & 0 deletions go/tdh2/tdh2hybridCCP/sym.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package tdh2hybridCCP

import (
"bytes"
"crypto/rand"
"fmt"

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

// symKey generates a symmetric key.
func symKey(keySize int) ([]byte, error) {
key := make([]byte, keySize)
if _, err := rand.Read(key); err != nil {
return nil, fmt.Errorf("cannot generate key")
}
return key, nil
}

// symEncrypt encrypts the message using the ChaCha20Poly1305 AEAD cipher.
func symEncrypt(msg, key, aaData []byte) ([]byte, []byte, error) {
aead, err := chacha20poly1305.New(key)
if err != nil {
return nil, nil, fmt.Errorf("cannot use ChaCha20Poly1305: %w", err)
}

// Counter overflow is catastrophic security failure because keystream repeats
// and attacker can XOR two ciphertexts to cancel out keystream!
// Never reuse a (key, nonce) pair for more than the limit:
// * AES-256-GCM block size is 16 byte, max. 2^32 *16 = 64 GB (conservative
// limit) and RFC 5084 2^36 - 32 bytes ≈ 68.7 GB (theoretical maximum)
// if uint64(len(msg)) > ((1<<32)-2)*uint64(block.BlockSize()) {
// * ChaCha20-Poly1305 block size is 64 byte, max. 2^32 *64 = 256 GB
// which allows 4× larger messages than AES-256-GCM.
// Its block 0 is used by Poly1305:
if uint64(len(msg)) > ((1<<32)-1)*uint64(64) { //
return nil, nil, fmt.Errorf("message too long")
}
// * XChaCha20-Poly1305 (Extended Nonce Variant) uses a 64-bit counter
// instead of 32-bit, and nonce size of 24 vs 12 bytes. Its block
// size is also 64 byte, max 2^64 *64 ≈ 1.18 × 10^21 bytes (1 Zettabyte!)
// which is far beyond any practical use case, e.g. practically unlimited.

// Generate random nonce (12 bytes for ChaCha20Poly1305, same as AES-GCM)
nonce := make([]byte, aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, nil, fmt.Errorf("failed to generate nonce: %w", err)
}

// Encrypt: prepend nonce to ciphertext is done by passing nonce into first parameter 'dst'
// Format: [nonce][ciphertext + aaData + authN tag]
//return aead.Seal(nonce, nonce, msg, nil), nonce, nil // returns (ctxt, nonce, err)
return aead.Seal(nonce, nonce, msg, aaData), nonce, nil // returns (ctxt, nonce, err)
}

// symDecrypt decrypts the ciphertext using theChaCha20-Poly1305 cipher.
func symDecrypt(nonce, ctxt, key, aaData []byte) ([]byte, error) {
aead, err := chacha20poly1305.New(key)
if err != nil {
return nil, fmt.Errorf("failed to create ChaCha20Poly1305 cipher: %w", err)
}
if len(ctxt) < aead.NonceSize() {
return nil, fmt.Errorf("ciphertext too short")
}

// Extract nonce and encrypted data
nonceRecovered := ctxt[:aead.NonceSize()]
if !bytes.Equal(nonceRecovered, nonce) {
return nil, fmt.Errorf("nonce mismatch")
}
encryptedData := ctxt[aead.NonceSize():]

// Decrypt and verify: AEAD authenticates additional, non-encrypted aaData which
// detects which detects any tampering with metadata
//return aead.Open(nil, nonceRecovered, encryptedData, nil) // authN fails if aaData was set
return aead.Open(nil, nonceRecovered, encryptedData, aaData)
}
Loading