- 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
445 lines
12 KiB
ObjectPascal
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.
|