feat(chat): seed-derived identity + secretstream crypto core

Phase 1 crypto foundation (gated by DRAGONX_ENABLE_CHAT; the pure primitives
are always compiled + unit-tested, the feature gate lives at the future
service layer).

- chat_identity: derive the DragonX-native X25519 (crypto_kx) identity from a
  stable per-wallet secret via a domain-separated keyed BLAKE2b KDF
  (crypto_generichash, context "DragonX-HushChat-Identity-v1") into a clean
  32-byte crypto_kx seed. Deterministic; skips SDXL's UTF-8-hex-seed quirk
  (that's the Phase-4 import path). Per §5.6.
- chat_crypto: encryptOutgoing (server_tx) / decryptIncoming (client_rx) via
  crypto_secretstream_xchacha20poly1305, byte-exact per Appendix A.3/A.4 so
  DragonX interoperates with SilentDragonXLite. Single chunk, TAG_FINAL;
  decrypt enforces both the Poly1305 tag and TAG_FINAL (stricter than SDXL).
  Every session key / seed / stream state / plaintext scratch is sodium_memzero'd
  on all paths; no secret is ever logged.
- Tests: encrypt->decrypt round-trip (incl. empty + UTF-8), identity
  determinism, feature-gate + empty-secret handling, and malformed/tampered/
  wrong-key inputs all fail safely.

Adversarially security-reviewed (3 lenses: secret hygiene, crypto correctness,
SDXL wire-interop) — confirmed byte-compatible with the SDXL reference in both
directions. Fixes from review: check the secretstream_push return before
reporting Ok; allow the empty-plaintext ciphertext (== ABYTES) so encrypt/decrypt
round-trip symmetrically; document the caller-owns-and-wipes secret contract.

Interop caveat: round-trip proves self-consistency; a real captured SDXL
message is still needed to prove wire-compat end to end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-05 19:54:47 -05:00
parent cec330ee47
commit 2502487e44
6 changed files with 437 additions and 0 deletions

View File

@@ -1,3 +1,5 @@
#include "chat/chat_crypto.h"
#include "chat/chat_identity.h"
#include "daemon/daemon_controller.h"
#include "data/transaction_history_cache.h"
#include "daemon/lifecycle_adapters.h"
@@ -5552,6 +5554,69 @@ void testPoolWeightedSelection()
EXPECT_TRUE(stay > 1000); // >50% thanks to kIncumbentStayBias
}
// HushChat crypto core: seed-derived identity + secretstream encrypt/decrypt round-trip.
// Pass featureEnabled=true so the crypto is exercised even in the default (chat-OFF) build.
void testHushChatCrypto()
{
using namespace dragonx::chat;
ChatKeyPair alice, bob;
ChatIdentityResult ra = deriveChatIdentityFromSecret("alice-wallet-secret", alice, /*featureEnabled=*/true);
ChatIdentityResult rb = deriveChatIdentityFromSecret("bob-wallet-secret", bob, /*featureEnabled=*/true);
EXPECT_TRUE(ra.status == ChatIdentityStatus::Ready);
EXPECT_TRUE(rb.status == ChatIdentityStatus::Ready);
EXPECT_EQ((int)ra.public_key_hex.size(), 64);
EXPECT_TRUE(ra.public_key_hex != rb.public_key_hex);
// Deterministic: the same secret always yields the same identity.
ChatKeyPair alice2;
ChatIdentityResult ra2 = deriveChatIdentityFromSecret("alice-wallet-secret", alice2, true);
EXPECT_EQ(ra.public_key_hex, ra2.public_key_hex);
// Feature gate: featureEnabled=false yields FeatureDisabled (and error_name matches).
ChatKeyPair off;
ChatIdentityResult roff = deriveChatIdentityFromSecret("x", off, false);
EXPECT_TRUE(roff.status == ChatIdentityStatus::FeatureDisabled);
EXPECT_EQ(std::string(roff.error_name), std::string("FeatureDisabled"));
// Empty secret is rejected.
ChatKeyPair none;
EXPECT_TRUE(deriveChatIdentityFromSecret("", none, true).status == ChatIdentityStatus::SecretUnavailable);
// Round-trip: Alice (server role) encrypts to Bob; Bob (client role) decrypts from Alice.
std::string headerHex, cipherHex;
ChatCryptoStatus es = encryptOutgoing(alice, rb.public_key_hex, "hello bob \xF0\x9F\x90\x89", headerHex, cipherHex);
EXPECT_TRUE(es == ChatCryptoStatus::Ok);
EXPECT_EQ((int)headerHex.size(), 48); // 24-byte secretstream header as hex
std::string plain;
ChatCryptoStatus ds = decryptIncoming(bob, ra.public_key_hex, headerHex, cipherHex, plain);
EXPECT_TRUE(ds == ChatCryptoStatus::Ok);
EXPECT_EQ(plain, std::string("hello bob \xF0\x9F\x90\x89"));
// Empty plaintext round-trips symmetrically (the crypto layer imposes no content policy).
std::string emptyHeader, emptyCipher, emptyPlain;
EXPECT_TRUE(encryptOutgoing(alice, rb.public_key_hex, "", emptyHeader, emptyCipher) == ChatCryptoStatus::Ok);
EXPECT_TRUE(decryptIncoming(bob, ra.public_key_hex, emptyHeader, emptyCipher, emptyPlain) == ChatCryptoStatus::Ok);
EXPECT_EQ(emptyPlain, std::string(""));
// Tampered ciphertext fails the auth tag (neutral DecryptFailed, not a crash).
std::string tampered = cipherHex;
tampered.back() = (tampered.back() == '0') ? '1' : '0';
std::string plain2;
EXPECT_TRUE(decryptIncoming(bob, ra.public_key_hex, headerHex, tampered, plain2) != ChatCryptoStatus::Ok);
// Wrong recipient key can't derive the session and/or fails auth.
std::string plain3;
ChatKeyPair mallory;
deriveChatIdentityFromSecret("mallory-secret", mallory, true);
EXPECT_TRUE(decryptIncoming(mallory, ra.public_key_hex, headerHex, cipherHex, plain3) != ChatCryptoStatus::Ok);
// Malformed inputs are rejected without touching the tag path.
std::string plain4;
EXPECT_TRUE(decryptIncoming(bob, "not-hex", headerHex, cipherHex, plain4) == ChatCryptoStatus::BadPeerKey);
EXPECT_TRUE(decryptIncoming(bob, ra.public_key_hex, headerHex, "00", plain4) == ChatCryptoStatus::CiphertextTooShort);
}
} // namespace
int main()
@@ -5642,6 +5707,7 @@ int main()
testPoolHashrateParsing();
testPoolWeightedSelection();
testAtomicFileWrite();
testHushChatCrypto();
testAddressChecksumValidation();
testLiteServerProbeLive();
testXmrigLiveInstall();