Files
lneto/x/nts/ke_server.go
T
Marvin Drees 5f8ca45859 feat(x/nts): add NTS support (#88)
Implement NTS (RFC 8915) with KERecord zero-copy frame, PerformKE
(TLS 1.3 + ExportKeyingMaterial), DeriveKeys, and Client state
machine implementing lneto.StackNode. Client handles cookie pool
management, auth body codec with nonce/ciphertext, and two-exchange
NTP flow with UniqueID verification and AEAD authentication.

Add NTS Server wrapping ntp.Server with AEAD verification/sealing,
and HandleKE for server-side NTS Key Exchange over TLS 1.3.

Use internal.LogAttrs for non-allocating structured logging
throughout the NTS client, matching existing conventions.

Generated with LLM assistance.

Signed-off-by: Marvin Drees <marvin.drees@9elements.com>
2026-06-15 11:42:10 -03:00

154 lines
4.2 KiB
Go

package nts
import (
"crypto/tls"
"encoding/binary"
"io"
"slices"
"github.com/soypat/lneto"
)
// KEServerConfig configures [HandleKE].
type KEServerConfig struct {
SupportedAlgorithms [4]AEADAlgorithmID
NumAlgorithms int
Cookies [][]byte
// NTPPort, when non-zero, is advertised to the client as the port to use
// for NTS-authenticated NTP (via RecordNTPv4Port). Zero means the client
// should use the standard NTP port (123) and no record is sent.
NTPPort uint16
}
// HandleKE runs the server side of the NTS Key Exchange protocol over an
// already-established TLS 1.3 connection. It reads the client's request,
// negotiates algorithm and protocol, sends cookies, and derives keys.
//
// The returned [KESecrets] contains the negotiated algorithm and derived
// C2S/S2C keys matching the client's view.
func HandleKE(conn *tls.Conn, cfg KEServerConfig) (KESecrets, error) {
if conn.ConnectionState().Version != tls.VersionTLS13 {
return KESecrets{}, lneto.ErrInvalidConfig
}
numAlg := cfg.NumAlgorithms
if numAlg == 0 {
numAlg = 1
cfg.SupportedAlgorithms[0] = AlgAESSIVCMAC256
}
clientAlg, err := readKERequest(conn)
if err != nil {
return KESecrets{}, err
}
chosen := negotiateAlg(clientAlg, cfg.SupportedAlgorithms[:numAlg])
if chosen == 0 {
errBody := make([]byte, 2)
binary.BigEndian.PutUint16(errBody, 0) // unrecognized critical record
var resp []byte
resp = AppendKERecord(resp, true, RecordError, errBody)
resp = AppendKERecord(resp, true, RecordEndOfMessage, nil)
conn.Write(resp)
return KESecrets{}, lneto.ErrUnsupported
}
var secrets KESecrets
secrets.ChosenAlg = chosen
if err := sendKEResponse(conn, chosen, cfg.Cookies, cfg.NTPPort); err != nil {
return secrets, err
}
for i, c := range cfg.Cookies {
if i >= MaxCookies {
break
}
n := min(len(c), MaxCookieLen)
copy(secrets.Cookies[i][:], c[:n])
secrets.CookieLens[i] = n
secrets.NumCookies++
}
if err := DeriveKeys(conn, &secrets); err != nil {
return secrets, err
}
return secrets, nil
}
// maxKERecordBody is the maximum body length accepted for a single NTS-KE
// record. Real records are tiny; this guards against malicious inputs.
const maxKERecordBody = 1024
// readKERequest reads the client NTS-KE request and returns the offered AEAD
// algorithms. It consumes records until EndOfMessage.
func readKERequest(r io.Reader) (offered []AEADAlgorithmID, err error) {
var hdr [4]byte
for {
if _, err := io.ReadFull(r, hdr[:]); err != nil {
return nil, err
}
bodyLen := int(binary.BigEndian.Uint16(hdr[2:4]))
recType := KERecordType(binary.BigEndian.Uint16(hdr[0:2]) & 0x7FFF)
if bodyLen > maxKERecordBody {
return nil, lneto.ErrInvalidLengthField
}
var body []byte
if bodyLen > 0 {
body = make([]byte, bodyLen)
if _, err := io.ReadFull(r, body); err != nil {
return nil, err
}
}
switch recType {
case RecordEndOfMessage:
return offered, nil
case RecordAEADAlgNeg:
for i := 0; i+1 < len(body); i += 2 {
offered = append(offered, AEADAlgorithmID(binary.BigEndian.Uint16(body[i:i+2])))
}
case RecordNextProtoNeg:
if len(body) >= 2 {
proto := binary.BigEndian.Uint16(body[:2])
if proto != ntpv4ProtocolID {
return nil, lneto.ErrUnsupported
}
}
}
}
}
func negotiateAlg(clientOffered []AEADAlgorithmID, serverSupported []AEADAlgorithmID) AEADAlgorithmID {
for _, c := range clientOffered {
if slices.Contains(serverSupported, c) {
return c
}
}
return 0
}
func sendKEResponse(w io.Writer, chosen AEADAlgorithmID, cookies [][]byte, ntpPort uint16) error {
var proto [2]byte
binary.BigEndian.PutUint16(proto[:], ntpv4ProtocolID)
var buf []byte
buf = AppendKERecord(buf, true, RecordNextProtoNeg, proto[:])
var algBody [2]byte
binary.BigEndian.PutUint16(algBody[:], uint16(chosen))
buf = AppendKERecord(buf, true, RecordAEADAlgNeg, algBody[:])
for _, c := range cookies {
buf = AppendKERecord(buf, false, RecordNewCookie, c)
}
if ntpPort != 0 {
var portBody [2]byte
binary.BigEndian.PutUint16(portBody[:], ntpPort)
buf = AppendKERecord(buf, false, RecordNTPv4Port, portBody[:])
}
buf = AppendKERecord(buf, true, RecordEndOfMessage, nil)
_, err := w.Write(buf)
return err
}