Envelope Encryption Technical Specification¶
This document provides technical details for developers and security auditors reviewing the private market encryption implementation.
Overview¶
Private markets use envelope encryption to protect market metadata. The scheme provides: - O(1) content encryption regardless of participant count - Efficient participant addition without re-encryption - Deterministic key derivation from wallet signatures - Session-cached keys to minimize signature requests
Cryptographic Primitives¶
v2.0 (Current - Post-Quantum)¶
| Component | Algorithm | Library |
|---|---|---|
| Key Exchange | X-Wing (X25519 + ML-KEM-768) | @noble/curves, @noble/post-quantum |
| Key Derivation | HKDF-SHA256 | @noble/hashes/hkdf |
| Symmetric Encryption | ChaCha20-Poly1305 | @noble/ciphers/chacha |
| Wallet Key Derivation | Keccak-256 | ethers.js |
| X-Wing Combiner | SHA3-256 | @noble/hashes/sha3 |
v1.0 (Legacy - Classical)¶
| Component | Algorithm | Library |
|---|---|---|
| Key Exchange | X25519 (Curve25519 ECDH) | @noble/curves/ed25519 |
| Key Derivation | HKDF-SHA256 | @noble/hashes/hkdf |
| Symmetric Encryption | ChaCha20-Poly1305 | @noble/ciphers/chacha |
| Wallet Key Derivation | Keccak-256 | ethers.js |
All cryptographic operations use the Noble suite, which provides audited, side-channel resistant implementations.
Post-Quantum Security (v2.0)¶
Version 2.0 uses X-Wing, a hybrid key encapsulation mechanism combining classical X25519 with post-quantum ML-KEM-768. This protects against "harvest now, decrypt later" attacks where an adversary stores encrypted data today and decrypts it with future quantum computers.
X-Wing Key Sizes¶
| Component | v1.0 (X25519) | v2.0 (X-Wing) |
|---|---|---|
| Public Key | 32 bytes | 1216 bytes |
| Secret Key | 32 bytes | 32 bytes (seed) |
| Ciphertext | 32 bytes | 1120 bytes |
| Shared Secret | 32 bytes | 32 bytes |
X-Wing Combiner¶
Per the IETF X-Wing specification, the shared secret is derived as:
Where:
- XWING_LABEL = "\\./\n\\./\n" (domain separator)
- ss_ML-KEM = ML-KEM-768 shared secret (32 bytes)
- ss_X25519 = X25519 shared secret (32 bytes)
- ct_X25519 = X25519 ephemeral public key (32 bytes)
- pk_X25519 = Recipient's X25519 public key (32 bytes)
Key Derivation¶
Wallet to Encryption Key¶
Message: "FairWins Market Encryption v1"
Signature: wallet.signMessage(Message)
PrivateKey: keccak256(utf8ToBytes(Signature))[0:32]
PublicKey: x25519.getPublicKey(PrivateKey)
The signature is an Ethereum personal_sign (EIP-191) of the fixed message. Keccak-256 hashing ensures uniform distribution across the key space.
Security consideration: The signature must remain secret. It's stored in sessionStorage (not localStorage) to limit persistence.
Key Encryption Key (KEK) Derivation¶
For each recipient, an ephemeral X25519 keypair is generated:
EphemeralPrivate: randomBytes(32)
EphemeralPublic: x25519.getPublicKey(EphemeralPrivate)
SharedSecret: x25519.getSharedSecret(EphemeralPrivate, RecipientPublic)
KEK: HKDF(SHA256, SharedSecret, salt="", info="FairWins_Envelope_v1", length=32)
Envelope Structure¶
v2.0 (X-Wing - Post-Quantum)¶
{
"version": "2.0",
"algorithm": "xwing-chacha20poly1305",
"signingVersion": 2,
"content": {
"nonce": "<hex: 12 bytes>",
"ciphertext": "<hex: encrypted data + 16-byte auth tag>"
},
"keys": [
{
"address": "<lowercase ethereum address>",
"xwingCiphertext": "<hex: 1120 bytes (ML-KEM ciphertext + X25519 ephemeral)>",
"nonce": "<hex: 12 bytes>",
"wrappedKey": "<hex: encrypted DEK + 16-byte auth tag>"
}
]
}
v1.0 (X25519 - Classical)¶
{
"version": "1.0",
"algorithm": "x25519-chacha20poly1305",
"signingVersion": 2,
"content": {
"nonce": "<hex: 12 bytes>",
"ciphertext": "<hex: encrypted data + 16-byte auth tag>"
},
"keys": [
{
"address": "<lowercase ethereum address>",
"ephemeralPublicKey": "<hex: 32 bytes>",
"nonce": "<hex: 12 bytes>",
"wrappedKey": "<hex: encrypted DEK + 16-byte auth tag>"
}
]
}
Version Detection¶
function isXWingEnvelope(envelope) {
return envelope?.algorithm === 'xwing-chacha20poly1305'
}
function isX25519Envelope(envelope) {
return envelope?.algorithm === 'x25519-chacha20poly1305'
}
Encryption Flow¶
Content Encryption¶
DEK: randomBytes(32)
ContentNonce: randomBytes(12)
Plaintext: JSON.stringify(metadata)
Ciphertext: ChaCha20Poly1305(DEK, ContentNonce).encrypt(Plaintext)
Key Wrapping (per recipient)¶
For each recipient:
EphemeralKeyPair: generateEphemeralKeyPair()
SharedSecret: x25519.ECDH(EphemeralKeyPair.private, recipient.publicKey)
KEK: HKDF(SharedSecret, info="FairWins_Envelope_v1")
KeyNonce: randomBytes(12)
WrappedDEK: ChaCha20Poly1305(KEK, KeyNonce).encrypt(DEK)
Decryption Flow¶
1. Find wrapped key entry matching user address
2. Compute SharedSecret: x25519.ECDH(myPrivateKey, entry.ephemeralPublicKey)
3. Derive KEK: HKDF(SharedSecret, info="FairWins_Envelope_v1")
4. Unwrap DEK: ChaCha20Poly1305(KEK, entry.nonce).decrypt(entry.wrappedKey)
5. Decrypt content: ChaCha20Poly1305(DEK, content.nonce).decrypt(content.ciphertext)
6. Parse JSON
Adding Participants¶
An existing participant can add new participants without the original creator:
1. Decrypt envelope to recover DEK (requires existing participant's key)
2. Generate new ephemeral keypair for new recipient
3. Compute SharedSecret with new recipient's public key
4. Derive new KEK
5. Wrap DEK for new recipient
6. Append new key entry to envelope.keys
This enables invitation chains where any participant can invite others.
Session Management¶
Signature Caching¶
// Cache key format
const cacheKey = `fairwins_encryption_signature_${address.toLowerCase()}`
// Storage: sessionStorage (cleared on tab close)
sessionStorage.setItem(cacheKey, signature)
Concurrent Request Prevention¶
A global promise prevents multiple simultaneous signature requests:
let initializationPromise = null
async function initializeKeys() {
if (initializationPromise) {
return initializationPromise // Wait for existing request
}
initializationPromise = (async () => {
try {
const result = await deriveKeyPair(signer)
// ... cache and return
} finally {
initializationPromise = null
}
})()
return initializationPromise
}
Security Considerations¶
Strengths¶
- Forward secrecy per-recipient: Each recipient has unique ephemeral key, limiting exposure if one recipient's key is compromised
- Authenticated encryption: ChaCha20-Poly1305 provides both confidentiality and integrity
- Deterministic keys: Users can always recover access with their wallet
- No key server: Keys are derived client-side; no central authority has access
Limitations¶
- No backward secrecy: Removed participants may have cached the DEK
- Metadata exposure: Participant addresses are visible in the envelope
- Session storage risk: Signature in sessionStorage could be accessed by XSS
- Single signature dependency: Compromised signature = compromised all markets for that wallet
Mitigations¶
| Risk | Mitigation |
|---|---|
| XSS stealing signature | CSP headers, input sanitization, sessionStorage isolation |
| Participant enumeration | Accept as design tradeoff; blockchain transparency |
| Removed participant access | Document limitation; recommend new market for true revocation |
| Wallet compromise | User responsibility; recommend hardware wallets |
IPFS Storage¶
Encrypted envelopes are stored on IPFS via Pinata, with only a CID reference stored on-chain.
On-Chain Reference Format¶
Storage Architecture¶
| Layer | Content | Size |
|---|---|---|
| Blockchain | encrypted:ipfs://CID |
~60 bytes |
| IPFS | Full encrypted envelope | 1-10 KB |
Benefits¶
- Gas efficiency: CID reference costs ~60 bytes regardless of envelope size
- Scalability: X-Wing's larger ciphertexts don't increase transaction costs
- Privacy: Encrypted data lives off-chain, reducing on-chain metadata
- Flexibility: Envelopes can be updated (adding participants) without on-chain transactions
IPFS Functions¶
// Upload encrypted envelope to IPFS
const { cid, uri } = await uploadEncryptedEnvelope(envelope, { marketType: 'oneVsOne' })
// Fetch encrypted envelope from IPFS
const envelope = await fetchEncryptedEnvelope(cid)
// Parse on-chain reference
const { isIpfs, cid } = parseEncryptedIpfsReference(description)
// Build on-chain reference
const reference = buildEncryptedIpfsReference(cid)
Backward Compatibility¶
The system auto-detects envelope storage format:
- IPFS reference (
encrypted:ipfs://...): Fetch from IPFS, then decrypt - Inline JSON: Parse directly from description field (legacy)
Both v1.0 (X25519) and v2.0 (X-Wing) envelopes work with either storage method.
File Locations¶
frontend/src/utils/crypto/envelopeEncryption.js # Core cryptographic functions
frontend/src/utils/ipfsService.js # IPFS upload/fetch functions
frontend/src/hooks/useEncryption.js # React hook with session management
Testing Considerations¶
When testing the encryption system:
- Key derivation consistency: Same wallet should always produce same keypair
- Cross-browser compatibility: Verify signature format is consistent
- Participant addition: New participants can decrypt after being added
- Invalid envelope handling: Graceful failures for malformed data
- Concurrent decryption: Multiple markets can be decrypted simultaneously
Version History¶
| Version | Changes |
|---|---|
| 2.0 | X-Wing hybrid KEM (X25519 + ML-KEM-768) for post-quantum security |
| 1.1 | IPFS storage for envelopes, CID reference on-chain |
| 1.0 | Initial implementation with X25519 + ChaCha20-Poly1305 |