Files
comet/src/cometcrypt.pas
Ken Johnson 0d19afe3f1 Move source to src/, add cometcrypt + contrib, update build system
- Move all .pas source files from root to src/ directory
- Add cometcrypt.pas (X25519 key exchange + ChaCha20 encryption)
- Add test_crypt.pas and test_outbound.pas
- Add contrib/ (systemd service, install script)
- Add COMET.QA quality assurance checklist
- Update Makefile for src/ layout
- Update FSP-COMET.001 protocol spec
- Remove comet.exe binary from repo
2026-04-01 11:11:27 -07:00

445 lines
12 KiB
ObjectPascal

{
Comet - Direct TCP File Transfer for FidoNet
cometcrypt.pas - Session encryption: X25519 key exchange + ChaCha20
Provides full session encryption for the Comet native protocol using
keys already configured for ED25519 authentication. No certificates,
no external libraries, no CA infrastructure required.
Key exchange:
Both sides generate ephemeral X25519 keypairs and exchange public
keys. The shared secret is computed via Diffie-Hellman. Ephemeral
keys provide forward secrecy -- compromising long-term keys does
not reveal past session content.
Encryption:
ChaCha20 stream cipher (RFC 8439) with per-direction keys and
counters. Each frame body (TYPE+SEQ+PAYLOAD+CRC32) is encrypted
after CRC computation and decrypted before CRC verification.
Key derivation:
session_key = SHA-256(shared_secret || ephemeral_pub_A || ephemeral_pub_B)
send_key = SHA-256(session_key || "comet-send-" || direction_byte)
recv_key = SHA-256(session_key || "comet-recv-" || direction_byte)
Copyright (C) 2026 Ken Johnson
License: GPL-2.0
}
unit cometcrypt;
{$mode objfpc}{$H+}
{$R-}{$Q-} { Range and overflow checks OFF - crypto arithmetic }
interface
uses
SysUtils, cometed25519, cometsha;
type
{ X25519 key types }
TX25519Key = array[0..31] of Byte;
{ ChaCha20 cipher state }
TChacha20State = record
Key: array[0..31] of Byte; { 256-bit key }
Nonce: array[0..11] of Byte; { 96-bit nonce (zeroed, counter-based) }
Counter: QWord; { Block counter }
Block: array[0..63] of Byte; { Current keystream block }
Used: Integer; { Bytes used in current block }
end;
{ Session encryption context }
TCometCrypt = record
Active: Boolean; { Encryption is active }
TxState: TChacha20State; { Transmit cipher state }
RxState: TChacha20State; { Receive cipher state }
end;
{ ---- X25519 Key Exchange ---- }
{ Generate a random X25519 keypair.
PrivKey is the clamped 32-byte scalar.
PubKey is the 32-byte Montgomery u-coordinate. }
procedure X25519Keypair(out PrivKey, PubKey: TX25519Key);
{ Scalar multiplication on Curve25519 (Montgomery form).
R = K * U. Exported for testing. }
procedure X25519ScalarMult(const K, U: TX25519Key; out R: TX25519Key);
{ Compute shared secret = X25519(our_private, their_public).
Returns False if the result is all-zero (bad public key). }
function X25519SharedSecret(const OurPriv, TheirPub: TX25519Key;
out Secret: TX25519Key): Boolean;
{ ---- ChaCha20 Stream Cipher ---- }
{ Initialize a ChaCha20 cipher state with a 32-byte key. }
procedure ChaCha20Init(var State: TChacha20State;
const Key: array of Byte);
{ Encrypt/decrypt data in place using ChaCha20 (XOR with keystream). }
procedure ChaCha20Crypt(var State: TChacha20State;
Data: PByte; Len: LongWord);
{ ---- Session Encryption ---- }
{ Derive send/recv keys from shared secret and ephemeral public keys,
and initialize the encryption context. IsOriginator determines
key direction (originator's send key = answerer's recv key). }
procedure CometCryptInit(var Crypt: TCometCrypt;
const SharedSecret, EphPubLocal, EphPubRemote: TX25519Key;
IsOriginator: Boolean);
{ Encrypt a frame body in place (TYPE+SEQ+PAYLOAD+CRC32).
Call after CRC computation, before sending. }
procedure CometCryptEncrypt(var Crypt: TCometCrypt;
Data: PByte; Len: LongWord);
{ Decrypt a frame body in place (TYPE+SEQ+PAYLOAD+CRC32).
Call after receiving, before CRC verification. }
procedure CometCryptDecrypt(var Crypt: TCometCrypt;
Data: PByte; Len: LongWord);
implementation
uses
cometlog;
{ ---- X25519 Montgomery Ladder ---- }
{ Clamp a 32-byte scalar for X25519 per RFC 7748. }
procedure X25519Clamp(var K: TX25519Key);
begin
K[0] := K[0] and 248;
K[31] := (K[31] and 127) or 64;
end;
{ Montgomery ladder scalar multiplication on Curve25519.
Computes Result = k * u where u is a Montgomery u-coordinate.
Uses field element operations from cometed25519. }
procedure X25519ScalarMult(const K, U: TX25519Key; out R: TX25519Key);
{ Montgomery ladder per RFC 7748 Section 5.
Clamps scalar and decodes u-coordinate per spec.
All temporaries are distinct to avoid aliasing bugs. }
var
Scalar: TX25519Key;
UCopy: TX25519Key;
U_fe, X2, Z2, X3, Z3: TFieldElement;
tA, tAA, tB, tBB, tE: TFieldElement;
tC, tD, tDA, tCB: TFieldElement;
tDAp, tDAm, tT: TFieldElement;
Swap, KT: LongInt;
I: Integer;
begin
{ decodeScalar25519: clamp scalar per RFC 7748 }
Move(K, Scalar, 32);
X25519Clamp(Scalar);
{ decodeUCoordinate: clear high bit per RFC 7748 }
Move(U, UCopy, 32);
UCopy[31] := UCopy[31] and $7F;
FE_FromBytes(U_fe, @UCopy[0]);
FE_1(X2);
FE_0(Z2);
FE_Copy(X3, U_fe);
FE_1(Z3);
Swap := 0;
for I := 254 downto 0 do
begin
KT := (Scalar[I shr 3] shr (I and 7)) and 1;
Swap := Swap xor KT;
FE_CSwap(X2, X3, Swap);
FE_CSwap(Z2, Z3, Swap);
Swap := KT;
FE_Add(tA, X2, Z2);
FE_Sq(tAA, tA);
FE_Sub(tB, X2, Z2);
FE_Sq(tBB, tB);
FE_Sub(tE, tAA, tBB);
FE_Add(tC, X3, Z3);
FE_Sub(tD, X3, Z3);
FE_Mul(tDA, tD, tA);
FE_Mul(tCB, tC, tB);
FE_Add(tDAp, tDA, tCB);
FE_Sq(X3, tDAp);
FE_Sub(tDAm, tDA, tCB);
FE_Sq(tDAm, tDAm);
FE_Mul(Z3, U_fe, tDAm);
FE_Mul(X2, tAA, tBB);
FE_Mul121666(tT, tE); { 121666 * E }
FE_Sub(tT, tT, tE); { 121665 * E (a24 for Curve25519) }
FE_Add(tT, tAA, tT); { AA + a24 * E }
FE_Mul(Z2, tE, tT); { z_2 = E * (AA + a24 * E) }
end;
FE_CSwap(X2, X3, Swap);
FE_CSwap(Z2, Z3, Swap);
FE_Invert(Z2, Z2);
FE_Mul(X2, X2, Z2);
FE_ToBytes(TED25519PublicKey(R), X2);
end;
{ The X25519 base point (u=9). }
const
X25519_BASEPOINT: TX25519Key = (
9, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
);
procedure X25519Keypair(out PrivKey, PubKey: TX25519Key);
var
Seed: TED25519Seed;
begin
{ Generate 32 random bytes }
ED25519RandomSeed(Seed);
Move(Seed, PrivKey, 32);
X25519Clamp(PrivKey);
{ Public key = PrivKey * basepoint }
X25519ScalarMult(PrivKey, X25519_BASEPOINT, PubKey);
end;
function X25519SharedSecret(const OurPriv, TheirPub: TX25519Key;
out Secret: TX25519Key): Boolean;
var
I: Integer;
Check: Byte;
begin
X25519ScalarMult(OurPriv, TheirPub, Secret);
{ Check for all-zero result (invalid public key) }
Check := 0;
for I := 0 to 31 do
Check := Check or Secret[I];
Result := Check <> 0;
end;
{ ---- ChaCha20 Stream Cipher (RFC 8439) ---- }
type
TChaCha20Block = array[0..15] of LongWord;
function RotL32(X: LongWord; N: Integer): LongWord; inline;
begin
Result := (X shl N) or (X shr (32 - N));
end;
procedure QuarterRound(var A, B, C, D: LongWord); inline;
begin
A := A + B; D := D xor A; D := RotL32(D, 16);
C := C + D; B := B xor C; B := RotL32(B, 12);
A := A + B; D := D xor A; D := RotL32(D, 8);
C := C + D; B := B xor C; B := RotL32(B, 7);
end;
procedure ChaCha20Block(const Key: array of Byte;
Counter: LongWord; const Nonce: array of Byte;
out Output: array of Byte);
var
State, Working: TChaCha20Block;
I: Integer;
begin
{ Initialize state }
State[0] := $61707865; { "expa" }
State[1] := $3320646e; { "nd 3" }
State[2] := $79622d32; { "2-by" }
State[3] := $6b206574; { "te k" }
{ Key (little-endian LongWords) }
for I := 0 to 7 do
State[4 + I] := LongWord(Key[I*4]) or (LongWord(Key[I*4+1]) shl 8) or
(LongWord(Key[I*4+2]) shl 16) or (LongWord(Key[I*4+3]) shl 24);
{ Counter }
State[12] := Counter;
{ Nonce (little-endian LongWords) }
for I := 0 to 2 do
State[13 + I] := LongWord(Nonce[I*4]) or (LongWord(Nonce[I*4+1]) shl 8) or
(LongWord(Nonce[I*4+2]) shl 16) or (LongWord(Nonce[I*4+3]) shl 24);
{ Copy state to working }
Working := State;
{ 20 rounds (10 double-rounds) }
for I := 1 to 10 do
begin
{ Column rounds }
QuarterRound(Working[0], Working[4], Working[8], Working[12]);
QuarterRound(Working[1], Working[5], Working[9], Working[13]);
QuarterRound(Working[2], Working[6], Working[10], Working[14]);
QuarterRound(Working[3], Working[7], Working[11], Working[15]);
{ Diagonal rounds }
QuarterRound(Working[0], Working[5], Working[10], Working[15]);
QuarterRound(Working[1], Working[6], Working[11], Working[12]);
QuarterRound(Working[2], Working[7], Working[8], Working[13]);
QuarterRound(Working[3], Working[4], Working[9], Working[14]);
end;
{ Add original state }
for I := 0 to 15 do
Working[I] := Working[I] + State[I];
{ Serialize to bytes (little-endian) }
for I := 0 to 15 do
begin
Output[I*4] := Byte(Working[I]);
Output[I*4 + 1] := Byte(Working[I] shr 8);
Output[I*4 + 2] := Byte(Working[I] shr 16);
Output[I*4 + 3] := Byte(Working[I] shr 24);
end;
end;
procedure ChaCha20Init(var State: TChacha20State;
const Key: array of Byte);
begin
FillChar(State, SizeOf(State), 0);
Move(Key[0], State.Key[0], 32);
State.Counter := 0;
State.Used := 64; { Force new block generation on first use }
end;
procedure ChaCha20Crypt(var State: TChacha20State;
Data: PByte; Len: LongWord);
var
I: LongWord;
Avail: Integer;
begin
I := 0;
while I < Len do
begin
{ Generate new keystream block if needed }
if State.Used >= 64 then
begin
ChaCha20Block(State.Key, LongWord(State.Counter),
State.Nonce, State.Block);
Inc(State.Counter);
State.Used := 0;
end;
{ XOR data with keystream }
Avail := 64 - State.Used;
if Avail > Integer(Len - I) then
Avail := Integer(Len - I);
while Avail > 0 do
begin
Data[I] := Data[I] xor State.Block[State.Used];
Inc(I);
Inc(State.Used);
Dec(Avail);
end;
end;
end;
{ ---- Session Encryption Setup ---- }
procedure CometCryptInit(var Crypt: TCometCrypt;
const SharedSecret, EphPubLocal, EphPubRemote: TX25519Key;
IsOriginator: Boolean);
var
Ctx: TSHA256Context;
SessionKey: TSHA256Digest;
SendKey, RecvKey: TSHA256Digest;
DirByte: Byte;
Buf: array[0..31] of Byte;
begin
FillChar(Crypt, SizeOf(Crypt), 0);
{ Derive session key = SHA-256(secret || pubA || pubB)
Always hash originator's key first for consistency. }
SHA256Init(Ctx);
Move(SharedSecret, Buf, 32);
SHA256Update(Ctx, Buf, 32);
if IsOriginator then
begin
Move(EphPubLocal, Buf, 32);
SHA256Update(Ctx, Buf, 32);
Move(EphPubRemote, Buf, 32);
SHA256Update(Ctx, Buf, 32);
end
else
begin
Move(EphPubRemote, Buf, 32);
SHA256Update(Ctx, Buf, 32);
Move(EphPubLocal, Buf, 32);
SHA256Update(Ctx, Buf, 32);
end;
SHA256Final(Ctx, SessionKey);
{ Derive directional keys:
Key-A = SHA-256(session_key || "comet-keystream-A")
Key-B = SHA-256(session_key || "comet-keystream-B")
Originator sends with Key-A, receives with Key-B.
Answerer sends with Key-B, receives with Key-A. }
DirByte := Ord('A');
SHA256Init(Ctx);
SHA256Update(Ctx, SessionKey, 32);
SHA256Update(Ctx, DirByte, 1);
SHA256Final(Ctx, SendKey);
DirByte := Ord('B');
SHA256Init(Ctx);
SHA256Update(Ctx, SessionKey, 32);
SHA256Update(Ctx, DirByte, 1);
SHA256Final(Ctx, RecvKey);
if IsOriginator then
begin
ChaCha20Init(Crypt.TxState, SendKey);
ChaCha20Init(Crypt.RxState, RecvKey);
end
else
begin
ChaCha20Init(Crypt.TxState, RecvKey);
ChaCha20Init(Crypt.RxState, SendKey);
end;
{ Wipe intermediate keys }
FillChar(SessionKey, SizeOf(SessionKey), 0);
FillChar(SendKey, SizeOf(SendKey), 0);
FillChar(RecvKey, SizeOf(RecvKey), 0);
Crypt.Active := True;
LogInfo('Session encryption active (X25519 + ChaCha20)');
end;
procedure CometCryptEncrypt(var Crypt: TCometCrypt;
Data: PByte; Len: LongWord);
begin
if Crypt.Active then
ChaCha20Crypt(Crypt.TxState, Data, Len);
end;
procedure CometCryptDecrypt(var Crypt: TCometCrypt;
Data: PByte; Len: LongWord);
begin
if Crypt.Active then
ChaCha20Crypt(Crypt.RxState, Data, Len);
end;
end.