mirror of
https://github.com/soypat/lneto.git
synced 2026-09-10 08:39:30 +00:00
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>
This commit is contained in:
@@ -98,6 +98,7 @@ ok github.com/soypat/lneto/x/xnet 2.926s
|
|||||||
| DNS | RFC 1035 | ✅ | `dns` | — | Client (A/AAAA query) |
|
| DNS | RFC 1035 | ✅ | `dns` | — | Client (A/AAAA query) |
|
||||||
| DHCPv4 | RFC 2131 | ✅ | `dhcp/dhcpv4` | — | Client + Server |
|
| DHCPv4 | RFC 2131 | ✅ | `dhcp/dhcpv4` | — | Client + Server |
|
||||||
| NTP | RFC 5905 | ✅ | `ntp` | — | Client |
|
| NTP | RFC 5905 | ✅ | `ntp` | — | Client |
|
||||||
|
| NTS | RFC 8915 | ✅ | `x/nts` | — | Client + Server; key exchange over TLS 1.3, authenticated NTP. Requires caller-supplied AEAD_AES_SIV_CMAC_256 |
|
||||||
| mDNS | RFC 6762 | ✅ | `dns/mdns` | — | Client (service announcement + query) |
|
| mDNS | RFC 6762 | ✅ | `dns/mdns` | — | Client (service announcement + query) |
|
||||||
| HTTP/1.1 headers | RFC 9110, RFC 9112 | ✅ | `http/httpraw` | 2 ⁴ | Header parse/format; no field normalization |
|
| HTTP/1.1 headers | RFC 9110, RFC 9112 | ✅ | `http/httpraw` | 2 ⁴ | Header parse/format; no field normalization |
|
||||||
| Ethernet PHY/MDIO | IEEE 802.3 cl.22/45 | ✅ | `phy` | — | Bare-metal PHY management via MDIO |
|
| Ethernet PHY/MDIO | IEEE 802.3 cl.22/45 | ✅ | `phy` | — | Bare-metal PHY management via MDIO |
|
||||||
@@ -124,6 +125,7 @@ ok github.com/soypat/lneto/x/xnet 2.926s
|
|||||||
- [`lneto/internal`](./internal): Lightweight and flexible ring buffer implementation and debugging primitives.
|
- [`lneto/internal`](./internal): Lightweight and flexible ring buffer implementation and debugging primitives.
|
||||||
- [`lneto/x`](./x): Experimental packages.
|
- [`lneto/x`](./x): Experimental packages.
|
||||||
- [`lneto/x/xnet`](./x/xnet/): `net` package like abstractions of stack implementations for ease of reuse. Still in testing phase and likely subject to breaking API change.
|
- [`lneto/x/xnet`](./x/xnet/): `net` package like abstractions of stack implementations for ease of reuse. Still in testing phase and likely subject to breaking API change.
|
||||||
|
- [`lneto/x/nts`](./x/nts/): Network Time Security (RFC 8915). NTS-KE key exchange over TLS 1.3 and AEAD-authenticated NTP client/server. Ships no cryptography itself; the caller supplies the mandated `AEAD_AES_SIV_CMAC_256` `cipher.AEAD` implementation.
|
||||||
|
|
||||||
### Abstractions
|
### Abstractions
|
||||||
The following interface is implemented by networking stack nodes and the stack themselves.
|
The following interface is implemented by networking stack nodes and the stack themselves.
|
||||||
|
|||||||
+296
@@ -0,0 +1,296 @@
|
|||||||
|
package nts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/binary"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/soypat/lneto"
|
||||||
|
"github.com/soypat/lneto/internal"
|
||||||
|
"github.com/soypat/lneto/ntp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxAuthBody is the maximum auth field body size:
|
||||||
|
// 4-byte header (NonceLen + CtLen) + maxNonceLen + 32-byte max AEAD overhead.
|
||||||
|
const maxAuthBody = 4 + maxNonceLen + 32
|
||||||
|
|
||||||
|
// minCarrierRoom is the minimum extra bytes beyond NTP header that
|
||||||
|
// carrierData must have for an NTS request. The cookie can be at most
|
||||||
|
// MaxCookieLen bytes; the auth body at most maxAuthBody bytes, each wrapped
|
||||||
|
// in a 4-byte ext-field header.
|
||||||
|
const minCarrierRoom = ntp.SizeHeader + (4 + 32) + (4 + MaxCookieLen) + (4 + maxAuthBody + 3)
|
||||||
|
|
||||||
|
// ClientConfig configures an NTS [Client].
|
||||||
|
type ClientConfig struct {
|
||||||
|
C2S, S2C cipher.AEAD
|
||||||
|
ChosenAlg AEADAlgorithmID
|
||||||
|
Rand io.Reader
|
||||||
|
Now func() time.Time
|
||||||
|
Log *slog.Logger
|
||||||
|
Sysprec int8
|
||||||
|
Cookies [MaxCookies][MaxCookieLen]byte
|
||||||
|
CookieLens [MaxCookies]int
|
||||||
|
NumCookies int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client is a stateful NTS-capable NTP client implementing [lneto.StackNode].
|
||||||
|
// It wraps an [ntp.Client] and injects/validates NTS extension fields.
|
||||||
|
//
|
||||||
|
// Client is not safe for concurrent use.
|
||||||
|
type Client struct {
|
||||||
|
connID uint64
|
||||||
|
cfg ClientConfig
|
||||||
|
ntpState ntp.Client
|
||||||
|
uniqueID [32]byte
|
||||||
|
nonce [maxNonceLen]byte
|
||||||
|
cookies [MaxCookies][MaxCookieLen]byte
|
||||||
|
cookieLens [MaxCookies]int
|
||||||
|
numCookies int
|
||||||
|
exchange int // counts completed Encapsulate calls
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset re-initialises the client with cfg. May be called again after
|
||||||
|
// a fresh [PerformKE] to refresh cookies without losing connID.
|
||||||
|
func (c *Client) Reset(cfg ClientConfig) error {
|
||||||
|
if cfg.C2S == nil || cfg.S2C == nil {
|
||||||
|
return lneto.ErrInvalidConfig
|
||||||
|
}
|
||||||
|
if cfg.C2S.NonceSize() > maxNonceLen || cfg.S2C.NonceSize() > maxNonceLen {
|
||||||
|
return lneto.ErrInvalidConfig
|
||||||
|
}
|
||||||
|
if cfg.NumCookies <= 0 {
|
||||||
|
return lneto.ErrInvalidConfig
|
||||||
|
}
|
||||||
|
if cfg.Rand == nil {
|
||||||
|
cfg.Rand = rand.Reader
|
||||||
|
}
|
||||||
|
if cfg.Now == nil {
|
||||||
|
cfg.Now = time.Now
|
||||||
|
}
|
||||||
|
if cfg.ChosenAlg == 0 {
|
||||||
|
cfg.ChosenAlg = AlgAESSIVCMAC256
|
||||||
|
}
|
||||||
|
*c = Client{
|
||||||
|
connID: c.connID + 1,
|
||||||
|
cfg: cfg,
|
||||||
|
cookies: cfg.Cookies,
|
||||||
|
cookieLens: cfg.CookieLens,
|
||||||
|
numCookies: cfg.NumCookies,
|
||||||
|
}
|
||||||
|
c.ntpState.Reset(cfg.Sysprec, cfg.Now)
|
||||||
|
c.ntpState.SetLogger(cfg.Log)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnectionID implements [lneto.StackNode].
|
||||||
|
func (c *Client) ConnectionID() *uint64 { return &c.connID }
|
||||||
|
|
||||||
|
// Protocol implements [lneto.StackNode].
|
||||||
|
func (c *Client) Protocol() uint64 { return uint64(ntp.ServerPort) }
|
||||||
|
|
||||||
|
// LocalPort implements [lneto.StackNode].
|
||||||
|
func (c *Client) LocalPort() uint16 { return c.ntpState.LocalPort() }
|
||||||
|
|
||||||
|
// IsDone reports whether both NTP exchanges completed.
|
||||||
|
func (c *Client) IsDone() bool { return c.ntpState.IsDone() }
|
||||||
|
|
||||||
|
// Offset returns the averaged clock offset after both exchanges (zero before).
|
||||||
|
func (c *Client) Offset() time.Duration { return c.ntpState.Offset() }
|
||||||
|
|
||||||
|
// RoundTripDelay returns the averaged RTD (-1 before done).
|
||||||
|
func (c *Client) RoundTripDelay() time.Duration { return c.ntpState.RoundTripDelay() }
|
||||||
|
|
||||||
|
// Now returns the NTS-corrected current time (local time before done).
|
||||||
|
func (c *Client) Now() time.Time { return c.ntpState.Now() }
|
||||||
|
|
||||||
|
// Encapsulate implements [lneto.StackNode].
|
||||||
|
//
|
||||||
|
// carrierData must have at least [minCarrierRoom] bytes available starting at
|
||||||
|
// offsetToFrame; otherwise [lneto.ErrShortBuffer] is returned.
|
||||||
|
func (c *Client) Encapsulate(carrierData []byte, offsetToIP, offsetToFrame int) (int, error) {
|
||||||
|
if c.numCookies == 0 {
|
||||||
|
return 0, lneto.ErrExhausted
|
||||||
|
}
|
||||||
|
if len(carrierData)-offsetToFrame < minCarrierRoom {
|
||||||
|
return 0, lneto.ErrShortBuffer
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := c.ntpState.Encapsulate(carrierData, offsetToIP, offsetToFrame)
|
||||||
|
if err != nil || n == 0 {
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// buf is a view into carrierData starting at offsetToFrame.
|
||||||
|
// Appending to buf writes into carrierData's backing array because we
|
||||||
|
// verified capacity above; no reallocation will occur.
|
||||||
|
buf := carrierData[offsetToFrame : offsetToFrame+n]
|
||||||
|
|
||||||
|
// UniqueID: 32 random bytes.
|
||||||
|
if _, err = io.ReadFull(c.cfg.Rand, c.uniqueID[:]); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
buf = ntp.AppendExtField(buf, ntp.ExtNTSUniqueID, c.uniqueID[:])
|
||||||
|
|
||||||
|
// Cookie: pop one from the pool.
|
||||||
|
ci := c.numCookies - 1
|
||||||
|
cookieLen := c.cookieLens[ci]
|
||||||
|
buf = ntp.AppendExtField(buf, ntp.ExtNTSCookie, c.cookies[ci][:cookieLen])
|
||||||
|
c.numCookies--
|
||||||
|
c.exchange++
|
||||||
|
internal.LogAttrs(c.cfg.Log, slog.LevelDebug, "nts.Client:encapsulate",
|
||||||
|
slog.Int("exchange", c.exchange),
|
||||||
|
slog.Int("cookieLen", cookieLen),
|
||||||
|
slog.Int("cookiesRemaining", c.numCookies))
|
||||||
|
|
||||||
|
// NTS-Authenticator-and-EEF (RFC 8915 §5.6).
|
||||||
|
// Body = [nonceLen(2)] [ctLen(2)] [nonce(N)] [ciphertext(M)]
|
||||||
|
// For a client request there is no EEF, so plaintext is empty and M = AEAD overhead.
|
||||||
|
nonceLen := c.cfg.C2S.NonceSize()
|
||||||
|
overhead := c.cfg.C2S.Overhead()
|
||||||
|
if _, err = io.ReadFull(c.cfg.Rand, c.nonce[:nonceLen]); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// aad = all packet bytes written so far (header + UniqueID + Cookie).
|
||||||
|
aad := buf
|
||||||
|
|
||||||
|
// Build auth body in a stack-allocated buffer to avoid heap allocation.
|
||||||
|
var authBody [maxAuthBody]byte
|
||||||
|
binary.BigEndian.PutUint16(authBody[0:2], uint16(nonceLen))
|
||||||
|
binary.BigEndian.PutUint16(authBody[2:4], uint16(overhead))
|
||||||
|
copy(authBody[4:4+nonceLen], c.nonce[:nonceLen])
|
||||||
|
|
||||||
|
// Seal computes the authentication tag for empty plaintext.
|
||||||
|
// The tag is appended into authBody[4+nonceLen:].
|
||||||
|
tag := c.cfg.C2S.Seal(authBody[4+nonceLen:4+nonceLen], c.nonce[:nonceLen], nil, aad)
|
||||||
|
if len(tag) != overhead {
|
||||||
|
return 0, lneto.ErrBug
|
||||||
|
}
|
||||||
|
|
||||||
|
buf = ntp.AppendExtField(buf, ntp.ExtNTSAuthAndEEF, authBody[:4+nonceLen+overhead])
|
||||||
|
return len(buf), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Demux implements [lneto.StackNode].
|
||||||
|
func (c *Client) Demux(carrierData []byte, frameOffset int) error {
|
||||||
|
frame, err := ntp.NewFrame(carrierData[frameOffset:])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
payload := frame.ExtensionFields()
|
||||||
|
if len(payload) == 0 {
|
||||||
|
return lneto.ErrMismatch
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC 8915 §5.7: verify the response carries the same UniqueID we sent.
|
||||||
|
if err = c.verifyUniqueID(payload); err != nil {
|
||||||
|
internal.LogAttrs(c.cfg.Log, slog.LevelDebug, "nts.Client:demux:uniqueID-fail",
|
||||||
|
slog.String("err", err.Error()))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
internal.LogAttrs(c.cfg.Log, slog.LevelDebug, "nts.Client:demux:uniqueID-ok")
|
||||||
|
|
||||||
|
authOffset, authField, err := findAuthField(payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := authField.Value()
|
||||||
|
if len(body) < 4 {
|
||||||
|
return lneto.ErrTruncatedFrame
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse auth body header (RFC 8915 §5.6).
|
||||||
|
nonceLen := int(binary.BigEndian.Uint16(body[0:2]))
|
||||||
|
ctLen := int(binary.BigEndian.Uint16(body[2:4]))
|
||||||
|
if len(body) < 4+nonceLen+ctLen {
|
||||||
|
return lneto.ErrTruncatedFrame
|
||||||
|
}
|
||||||
|
nonce := body[4 : 4+nonceLen]
|
||||||
|
ciphertext := body[4+nonceLen : 4+nonceLen+ctLen]
|
||||||
|
|
||||||
|
// aad = everything from the NTP header start up to (not including) the auth field.
|
||||||
|
aadEnd := frameOffset + ntp.SizeHeader + authOffset
|
||||||
|
aad := carrierData[frameOffset:aadEnd]
|
||||||
|
|
||||||
|
plaintext, openErr := c.cfg.S2C.Open(nil, nonce, ciphertext, aad)
|
||||||
|
if openErr != nil {
|
||||||
|
internal.LogAttrs(c.cfg.Log, slog.LevelDebug, "nts.Client:demux:auth-fail",
|
||||||
|
slog.String("err", openErr.Error()))
|
||||||
|
return lneto.ErrBadCRC
|
||||||
|
}
|
||||||
|
|
||||||
|
prevCookies := c.numCookies
|
||||||
|
c.ingestAuthPayload(plaintext)
|
||||||
|
internal.LogAttrs(c.cfg.Log, slog.LevelDebug, "nts.Client:demux:auth-ok",
|
||||||
|
slog.Int("newCookies", c.numCookies-prevCookies),
|
||||||
|
slog.Int("totalCookies", c.numCookies))
|
||||||
|
return c.ntpState.Demux(carrierData, frameOffset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyUniqueID scans extension fields in payload for the NTS UniqueID
|
||||||
|
// field and verifies it matches the one sent in the request (RFC 8915 §5.7).
|
||||||
|
func (c *Client) verifyUniqueID(payload []byte) error {
|
||||||
|
for off := 0; off < len(payload); {
|
||||||
|
field, n, err := ntp.NextExtField(payload[off:])
|
||||||
|
if err != nil || len(field.RawData()) == 0 {
|
||||||
|
return lneto.ErrMismatch // UniqueID not found
|
||||||
|
}
|
||||||
|
if field.Type() == ntp.ExtNTSUniqueID {
|
||||||
|
v := field.Value()
|
||||||
|
if len(v) != len(c.uniqueID) {
|
||||||
|
return lneto.ErrMismatchLen
|
||||||
|
}
|
||||||
|
for i := range c.uniqueID {
|
||||||
|
if v[i] != c.uniqueID[i] {
|
||||||
|
return lneto.ErrMismatch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
off += n
|
||||||
|
}
|
||||||
|
return lneto.ErrMismatch
|
||||||
|
}
|
||||||
|
|
||||||
|
// findAuthField iterates extension fields in payload and returns the byte
|
||||||
|
// offset of the NTSAuthAndEEF field within payload, plus the field itself.
|
||||||
|
func findAuthField(payload []byte) (offsetInPayload int, auth ntp.ExtField, err error) {
|
||||||
|
for off := 0; off < len(payload); {
|
||||||
|
field, n, e := ntp.NextExtField(payload[off:])
|
||||||
|
if e != nil {
|
||||||
|
return 0, ntp.ExtField{}, e
|
||||||
|
}
|
||||||
|
if len(field.RawData()) == 0 {
|
||||||
|
return 0, ntp.ExtField{}, lneto.ErrMismatch
|
||||||
|
}
|
||||||
|
if field.Type() == ntp.ExtNTSAuthAndEEF {
|
||||||
|
return off, field, nil
|
||||||
|
}
|
||||||
|
off += n
|
||||||
|
}
|
||||||
|
return 0, ntp.ExtField{}, lneto.ErrMismatch
|
||||||
|
}
|
||||||
|
|
||||||
|
// ingestAuthPayload extracts NTS-Cookie fields from the authenticated EEF
|
||||||
|
// payload and adds them to the cookie pool (up to MaxCookies).
|
||||||
|
func (c *Client) ingestAuthPayload(payload []byte) {
|
||||||
|
for off := 0; off < len(payload); {
|
||||||
|
field, n, err := ntp.NextExtField(payload[off:])
|
||||||
|
if err != nil || len(field.RawData()) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if field.Type() == ntp.ExtNTSCookie && c.numCookies < MaxCookies {
|
||||||
|
v := field.Value()
|
||||||
|
if len(v) <= MaxCookieLen {
|
||||||
|
i := c.numCookies
|
||||||
|
copy(c.cookies[i][:], v)
|
||||||
|
c.cookieLens[i] = len(v)
|
||||||
|
c.numCookies++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
off += n
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
// Package nts implements the Network Time Security (NTS) key exchange and
|
||||||
|
// authenticated NTP packet construction as specified in RFC 8915.
|
||||||
|
//
|
||||||
|
// # Protocol overview
|
||||||
|
//
|
||||||
|
// NTS adds an authentication layer on top of NTPv4. It has two phases:
|
||||||
|
//
|
||||||
|
// 1. Key Exchange (NTS-KE, TCP port 4460): a TLS 1.3 handshake followed by
|
||||||
|
// a small application-layer record exchange that negotiates the AEAD
|
||||||
|
// algorithm and distributes opaque cookies. Use [PerformKE] to run this
|
||||||
|
// phase over a caller-owned *tls.Conn.
|
||||||
|
//
|
||||||
|
// 2. Authenticated NTP: each request carries a Unique-ID extension field, a
|
||||||
|
// Cookie extension field (from the pool obtained in phase 1), and an
|
||||||
|
// NTS-Authenticator-and-EEF field sealed with the C2S AEAD key. Use
|
||||||
|
// [Client] as the lneto [StackNode] for this phase.
|
||||||
|
//
|
||||||
|
// # Cipher note
|
||||||
|
//
|
||||||
|
// RFC 8915 §5.1 mandates AEAD_AES_SIV_CMAC_256 ([AlgAESSIVCMAC256]); it is the
|
||||||
|
// only algorithm a compliant implementation must support and the sole one
|
||||||
|
// registered at the time of writing. This algorithm is not part of the Go
|
||||||
|
// standard library, and lneto deliberately does not ship cryptographic
|
||||||
|
// primitives. Callers must therefore supply their own [cipher.AEAD]
|
||||||
|
// implementation, keyed with the C2S and S2C material in [KESecrets], via
|
||||||
|
// [ClientConfig] and [ServerConfig]. Any external or standard-library
|
||||||
|
// [cipher.AEAD] may be plugged in, but only AEAD_AES_SIV_CMAC_256 is
|
||||||
|
// guaranteed to interoperate with other RFC 8915 peers.
|
||||||
|
//
|
||||||
|
//go:generate stringer -type=KERecordType,AEADAlgorithmID -linecomment -output stringers.go
|
||||||
|
package nts
|
||||||
|
|
||||||
|
// KEPort is the IANA-assigned TCP port for the NTS Key Exchange protocol.
|
||||||
|
const KEPort = 4460
|
||||||
|
|
||||||
|
// MaxCookies is the maximum number of cookies the client stores at one time.
|
||||||
|
// RFC 8915 §5.7 says servers SHOULD send eight cookies.
|
||||||
|
const MaxCookies = 8
|
||||||
|
|
||||||
|
// MaxCookieLen is the maximum byte length of a single NTS cookie.
|
||||||
|
// Real-world servers typically use 100–200 bytes; 256 provides headroom.
|
||||||
|
const MaxCookieLen = 256
|
||||||
|
|
||||||
|
// maxNonceLen is the largest nonce we pre-allocate space for.
|
||||||
|
// AES-SIV uses 16 bytes; GCM uses 12 bytes.
|
||||||
|
const maxNonceLen = 16
|
||||||
|
|
||||||
|
// KERecordType identifies NTS-KE record types (RFC 8915 §4.1.2).
|
||||||
|
type KERecordType uint16
|
||||||
|
|
||||||
|
const (
|
||||||
|
RecordEndOfMessage KERecordType = 0 // end of message
|
||||||
|
RecordNextProtoNeg KERecordType = 1 // next protocol negotiation
|
||||||
|
RecordError KERecordType = 2 // error
|
||||||
|
RecordWarning KERecordType = 3 // warning
|
||||||
|
RecordAEADAlgNeg KERecordType = 4 // AEAD algorithm negotiation
|
||||||
|
RecordNewCookie KERecordType = 5 // new cookie for NTPv4
|
||||||
|
RecordNTPv4Server KERecordType = 6 // NTPv4 server negotiation
|
||||||
|
RecordNTPv4Port KERecordType = 7 // NTPv4 port negotiation
|
||||||
|
)
|
||||||
|
|
||||||
|
// AEADAlgorithmID identifies AEAD algorithms used in NTS (RFC 8915 §5.1).
|
||||||
|
type AEADAlgorithmID uint16
|
||||||
|
|
||||||
|
const (
|
||||||
|
// AlgAESSIVCMAC256 is AEAD_AES_SIV_CMAC_256 (algorithm number 15).
|
||||||
|
// This is the only algorithm mandated by RFC 8915 §5.1 and the sole
|
||||||
|
// registered algorithm at time of writing.
|
||||||
|
AlgAESSIVCMAC256 AEADAlgorithmID = 15 // AEAD_AES_SIV_CMAC_256
|
||||||
|
)
|
||||||
|
|
||||||
|
// ntpv4ProtocolID is the NTS-KE protocol identifier for NTPv4 (RFC 8915 §4).
|
||||||
|
const ntpv4ProtocolID uint16 = 0
|
||||||
+257
@@ -0,0 +1,257 @@
|
|||||||
|
package nts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"encoding/binary"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/soypat/lneto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// KERecord provides zero-copy access to a single NTS-KE record within an
|
||||||
|
// existing buffer. The wire format is (RFC 8915 §4.1.2):
|
||||||
|
//
|
||||||
|
// [C(1b) | RecordType(15b) BE uint16] [BodyLen BE uint16] [Body…]
|
||||||
|
type KERecord struct {
|
||||||
|
buf []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewKERecord wraps buf as a KERecord. Returns [lneto.ErrTruncatedFrame] if
|
||||||
|
// buf is shorter than the 4-byte header plus the declared body length.
|
||||||
|
func NewKERecord(buf []byte) (KERecord, error) {
|
||||||
|
if len(buf) < 4 {
|
||||||
|
return KERecord{}, lneto.ErrTruncatedFrame
|
||||||
|
}
|
||||||
|
bodyLen := int(binary.BigEndian.Uint16(buf[2:4]))
|
||||||
|
if 4+bodyLen > len(buf) {
|
||||||
|
return KERecord{}, lneto.ErrTruncatedFrame
|
||||||
|
}
|
||||||
|
return KERecord{buf: buf[:4+bodyLen]}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordType returns the record type (lower 15 bits of the first two bytes).
|
||||||
|
func (r KERecord) RecordType() KERecordType {
|
||||||
|
return KERecordType(binary.BigEndian.Uint16(r.buf[0:2]) & 0x7FFF)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsCritical reports whether the Critical bit (bit 15 of the type field) is set.
|
||||||
|
func (r KERecord) IsCritical() bool {
|
||||||
|
return r.buf[0]&0x80 != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// BodyLen returns the declared body length in bytes.
|
||||||
|
func (r KERecord) BodyLen() uint16 {
|
||||||
|
return binary.BigEndian.Uint16(r.buf[2:4])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Body returns the record body bytes (excludes the 4-byte header).
|
||||||
|
func (r KERecord) Body() []byte {
|
||||||
|
return r.buf[4 : 4+r.BodyLen()]
|
||||||
|
}
|
||||||
|
|
||||||
|
// RawData returns the complete record bytes including the 4-byte header.
|
||||||
|
func (r KERecord) RawData() []byte { return r.buf }
|
||||||
|
|
||||||
|
// ValidateSize adds an error to v if the record is structurally invalid.
|
||||||
|
func (r KERecord) ValidateSize(v *lneto.Validator) {
|
||||||
|
if len(r.buf) < 4 {
|
||||||
|
v.AddError(lneto.ErrTruncatedFrame)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if 4+int(r.BodyLen()) > len(r.buf) {
|
||||||
|
v.AddError(lneto.ErrMismatchLen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppendKERecord appends a single NTS-KE record to dst and returns the result.
|
||||||
|
func AppendKERecord(dst []byte, critical bool, typ KERecordType, body []byte) []byte {
|
||||||
|
var hdr [4]byte
|
||||||
|
typeField := uint16(typ)
|
||||||
|
if critical {
|
||||||
|
typeField |= 0x8000
|
||||||
|
}
|
||||||
|
binary.BigEndian.PutUint16(hdr[0:2], typeField)
|
||||||
|
binary.BigEndian.PutUint16(hdr[2:4], uint16(len(body)))
|
||||||
|
dst = append(dst, hdr[:]...)
|
||||||
|
dst = append(dst, body...)
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
// KEConfig configures a [PerformKE] call.
|
||||||
|
type KEConfig struct {
|
||||||
|
OfferedAlgorithms [4]AEADAlgorithmID
|
||||||
|
NumAlgorithms int
|
||||||
|
Scratch []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// KESecrets holds all material produced by a successful NTS-KE exchange.
|
||||||
|
// All fields are fixed-size arrays to avoid heap allocation.
|
||||||
|
type KESecrets struct {
|
||||||
|
C2SKey [32]byte
|
||||||
|
S2CKey [32]byte
|
||||||
|
Cookies [MaxCookies][MaxCookieLen]byte
|
||||||
|
CookieLens [MaxCookies]int
|
||||||
|
NumCookies int
|
||||||
|
ChosenAlg AEADAlgorithmID
|
||||||
|
NTPAddr [64]byte
|
||||||
|
NTPAddrLen int
|
||||||
|
NTPPort uint16
|
||||||
|
}
|
||||||
|
|
||||||
|
// PerformKE runs the NTS Key Exchange protocol over an already-established
|
||||||
|
// TLS 1.3 connection. The connection MUST be configured with:
|
||||||
|
// - MinVersion: tls.VersionTLS13
|
||||||
|
// - ALPN: "ntske/1"
|
||||||
|
//
|
||||||
|
// On success the returned [KESecrets] contains the C2S/S2C keys, cookies,
|
||||||
|
// and optional NTP server address. The caller should close the connection
|
||||||
|
// after PerformKE returns; it is not used for NTP traffic.
|
||||||
|
func PerformKE(conn *tls.Conn, cfg KEConfig) (KESecrets, error) {
|
||||||
|
if conn.ConnectionState().Version != tls.VersionTLS13 {
|
||||||
|
return KESecrets{}, lneto.ErrInvalidConfig
|
||||||
|
}
|
||||||
|
if err := sendKERequest(conn, cfg); err != nil {
|
||||||
|
return KESecrets{}, err
|
||||||
|
}
|
||||||
|
secrets, err := readKEResponse(conn, cfg.Scratch)
|
||||||
|
if err != nil {
|
||||||
|
return secrets, err
|
||||||
|
}
|
||||||
|
if err := DeriveKeys(conn, &secrets); err != nil {
|
||||||
|
return secrets, err
|
||||||
|
}
|
||||||
|
return secrets, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendKERequest(w io.Writer, cfg KEConfig) error {
|
||||||
|
numAlg := cfg.NumAlgorithms
|
||||||
|
if numAlg == 0 {
|
||||||
|
numAlg = 1
|
||||||
|
cfg.OfferedAlgorithms[0] = AlgAESSIVCMAC256
|
||||||
|
}
|
||||||
|
|
||||||
|
var proto [2]byte
|
||||||
|
binary.BigEndian.PutUint16(proto[:], ntpv4ProtocolID)
|
||||||
|
var buf []byte
|
||||||
|
buf = AppendKERecord(buf, true, RecordNextProtoNeg, proto[:])
|
||||||
|
|
||||||
|
algBody := make([]byte, numAlg*2)
|
||||||
|
for i := 0; i < numAlg; i++ {
|
||||||
|
binary.BigEndian.PutUint16(algBody[i*2:], uint16(cfg.OfferedAlgorithms[i]))
|
||||||
|
}
|
||||||
|
buf = AppendKERecord(buf, true, RecordAEADAlgNeg, algBody)
|
||||||
|
buf = AppendKERecord(buf, true, RecordEndOfMessage, nil)
|
||||||
|
_, err := w.Write(buf)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func readKEResponse(r io.Reader, scratch []byte) (KESecrets, error) {
|
||||||
|
if cap(scratch) < 4096 {
|
||||||
|
scratch = make([]byte, 4096)
|
||||||
|
}
|
||||||
|
scratch = scratch[:cap(scratch)]
|
||||||
|
|
||||||
|
var secrets KESecrets
|
||||||
|
var hdr [4]byte
|
||||||
|
for {
|
||||||
|
if _, err := io.ReadFull(r, hdr[:]); err != nil {
|
||||||
|
return secrets, err
|
||||||
|
}
|
||||||
|
bodyLen := int(binary.BigEndian.Uint16(hdr[2:4]))
|
||||||
|
recType := KERecordType(binary.BigEndian.Uint16(hdr[0:2]) & 0x7FFF)
|
||||||
|
critical := hdr[0]&0x80 != 0
|
||||||
|
|
||||||
|
var body []byte
|
||||||
|
if bodyLen > 0 {
|
||||||
|
if bodyLen > len(scratch) {
|
||||||
|
scratch = make([]byte, bodyLen)
|
||||||
|
}
|
||||||
|
body = scratch[:bodyLen]
|
||||||
|
if _, err := io.ReadFull(r, body); err != nil {
|
||||||
|
return secrets, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch recType {
|
||||||
|
case RecordEndOfMessage:
|
||||||
|
if secrets.ChosenAlg == 0 {
|
||||||
|
return secrets, lneto.ErrInvalidConfig
|
||||||
|
}
|
||||||
|
return secrets, nil
|
||||||
|
|
||||||
|
case RecordError:
|
||||||
|
return secrets, lneto.ErrInvalidField
|
||||||
|
|
||||||
|
case RecordNextProtoNeg:
|
||||||
|
// Mandatory in every response (RFC 8915 §4.1.2). Verify it
|
||||||
|
// indicates NTPv4 (protocol ID 0).
|
||||||
|
if len(body) >= 2 {
|
||||||
|
proto := binary.BigEndian.Uint16(body[:2])
|
||||||
|
if proto != ntpv4ProtocolID {
|
||||||
|
return secrets, lneto.ErrUnsupported
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case RecordAEADAlgNeg:
|
||||||
|
if len(body) >= 2 {
|
||||||
|
secrets.ChosenAlg = AEADAlgorithmID(binary.BigEndian.Uint16(body[:2]))
|
||||||
|
}
|
||||||
|
|
||||||
|
case RecordNewCookie:
|
||||||
|
if secrets.NumCookies < MaxCookies && len(body) <= MaxCookieLen {
|
||||||
|
i := secrets.NumCookies
|
||||||
|
copy(secrets.Cookies[i][:], body)
|
||||||
|
secrets.CookieLens[i] = len(body)
|
||||||
|
secrets.NumCookies++
|
||||||
|
}
|
||||||
|
|
||||||
|
case RecordNTPv4Server:
|
||||||
|
n := min(len(body), len(secrets.NTPAddr))
|
||||||
|
copy(secrets.NTPAddr[:], body[:n])
|
||||||
|
secrets.NTPAddrLen = n
|
||||||
|
|
||||||
|
case RecordNTPv4Port:
|
||||||
|
if len(body) >= 2 {
|
||||||
|
secrets.NTPPort = binary.BigEndian.Uint16(body[:2])
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
if critical {
|
||||||
|
return secrets, lneto.ErrUnsupported
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeriveKeys fills secrets.C2SKey and secrets.S2CKey by exporting keying
|
||||||
|
// material from the TLS connection per RFC 8915 §4.2. Two separate
|
||||||
|
// ExportKeyingMaterial calls are made with 5-byte contexts:
|
||||||
|
//
|
||||||
|
// C2S context: [0x00, 0x00, algID_hi, algID_lo, 0x00]
|
||||||
|
// S2C context: [0x00, 0x00, algID_hi, algID_lo, 0x01]
|
||||||
|
//
|
||||||
|
// This is called automatically by [PerformKE]; expose it for callers
|
||||||
|
// that manage the TLS handshake themselves.
|
||||||
|
func DeriveKeys(conn *tls.Conn, secrets *KESecrets) error {
|
||||||
|
cs := conn.ConnectionState()
|
||||||
|
const label = "EXPORTER-network-time-security"
|
||||||
|
var ctx [5]byte
|
||||||
|
binary.BigEndian.PutUint16(ctx[2:4], uint16(secrets.ChosenAlg))
|
||||||
|
|
||||||
|
// C2S key: context byte 4 = 0x00.
|
||||||
|
ctx[4] = 0x00
|
||||||
|
c2s, err := cs.ExportKeyingMaterial(label, ctx[:], 32)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
copy(secrets.C2SKey[:], c2s)
|
||||||
|
|
||||||
|
// S2C key: context byte 4 = 0x01.
|
||||||
|
ctx[4] = 0x01
|
||||||
|
s2c, err := cs.ExportKeyingMaterial(label, ctx[:], 32)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
copy(secrets.S2CKey[:], s2c)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,745 @@
|
|||||||
|
package nts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/binary"
|
||||||
|
"math/big"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/soypat/lneto"
|
||||||
|
"github.com/soypat/lneto/ntp"
|
||||||
|
// NOTE: tests exercising AES-SIV-CMAC-256 are skipped pending
|
||||||
|
// due to missing stdlib support.
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestKERecord_RoundTrip(t *testing.T) {
|
||||||
|
body := []byte("hello NTS-KE")
|
||||||
|
buf := AppendKERecord(nil, true, RecordNewCookie, body)
|
||||||
|
|
||||||
|
rec, err := NewKERecord(buf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rec.RecordType() != RecordNewCookie {
|
||||||
|
t.Errorf("RecordType = %v; want %v", rec.RecordType(), RecordNewCookie)
|
||||||
|
}
|
||||||
|
if !rec.IsCritical() {
|
||||||
|
t.Error("IsCritical = false; want true")
|
||||||
|
}
|
||||||
|
if !bytes.Equal(rec.Body(), body) {
|
||||||
|
t.Errorf("Body mismatch: got %x want %x", rec.Body(), body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKERecord_NonCritical(t *testing.T) {
|
||||||
|
buf := AppendKERecord(nil, false, RecordWarning, []byte{0, 1})
|
||||||
|
rec, err := NewKERecord(buf)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rec.IsCritical() {
|
||||||
|
t.Error("IsCritical = true; want false")
|
||||||
|
}
|
||||||
|
if rec.RecordType() != RecordWarning {
|
||||||
|
t.Errorf("RecordType = %v; want %v", rec.RecordType(), RecordWarning)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKERecord_TruncatedBuffer(t *testing.T) {
|
||||||
|
buf := AppendKERecord(nil, true, RecordEndOfMessage, nil)
|
||||||
|
for i := range buf {
|
||||||
|
if _, err := NewKERecord(buf[:i]); err == nil {
|
||||||
|
t.Errorf("NewKERecord(buf[:%d]): expected error", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKERecord_ValidateSize(t *testing.T) {
|
||||||
|
buf := AppendKERecord(nil, false, RecordAEADAlgNeg, []byte{0, 15})
|
||||||
|
rec, _ := NewKERecord(buf)
|
||||||
|
var v lneto.Validator
|
||||||
|
rec.ValidateSize(&v)
|
||||||
|
if v.HasError() {
|
||||||
|
t.Errorf("ValidateSize: unexpected error: %v", v.ErrPop())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateSelfSignedCert returns a TLS certificate for localhost, suitable
|
||||||
|
// for in-process testing.
|
||||||
|
func generateSelfSignedCert(t *testing.T) tls.Certificate {
|
||||||
|
t.Helper()
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tmpl := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(1),
|
||||||
|
Subject: pkix.Name{CommonName: "localhost"},
|
||||||
|
NotBefore: time.Now().Add(-time.Hour),
|
||||||
|
NotAfter: time.Now().Add(time.Hour),
|
||||||
|
DNSNames: []string{"localhost"},
|
||||||
|
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||||
|
}
|
||||||
|
certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(certDER)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pool := x509.NewCertPool()
|
||||||
|
pool.AddCert(cert)
|
||||||
|
return tls.Certificate{Certificate: [][]byte{certDER}, PrivateKey: key, Leaf: cert}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runMockKEServer runs a minimal NTS-KE server over conn that responds with
|
||||||
|
// one cookie and the chosen algorithm.
|
||||||
|
func runMockKEServer(t *testing.T, conn net.Conn, tlsCfg *tls.Config, cookie []byte) {
|
||||||
|
t.Helper()
|
||||||
|
tc := tls.Server(conn, tlsCfg)
|
||||||
|
if err := tc.Handshake(); err != nil {
|
||||||
|
t.Errorf("server TLS handshake: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tc.Close()
|
||||||
|
|
||||||
|
// Read client records until EndOfMessage.
|
||||||
|
hdr := make([]byte, 4)
|
||||||
|
for {
|
||||||
|
if _, err := tc.Read(hdr); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bodyLen := int(binary.BigEndian.Uint16(hdr[2:4]))
|
||||||
|
recType := KERecordType(binary.BigEndian.Uint16(hdr[0:2]) & 0x7FFF)
|
||||||
|
if bodyLen > 0 {
|
||||||
|
body := make([]byte, bodyLen)
|
||||||
|
tc.Read(body)
|
||||||
|
}
|
||||||
|
if recType == RecordEndOfMessage {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send response per RFC 8915 §4.1: NextProtoNeg + AEAD + Cookie + EndOfMessage.
|
||||||
|
var resp []byte
|
||||||
|
var protoBody [2]byte
|
||||||
|
binary.BigEndian.PutUint16(protoBody[:], ntpv4ProtocolID)
|
||||||
|
resp = AppendKERecord(resp, true, RecordNextProtoNeg, protoBody[:])
|
||||||
|
var algBody [2]byte
|
||||||
|
binary.BigEndian.PutUint16(algBody[:], uint16(AlgAESSIVCMAC256))
|
||||||
|
resp = AppendKERecord(resp, true, RecordAEADAlgNeg, algBody[:])
|
||||||
|
resp = AppendKERecord(resp, false, RecordNewCookie, cookie)
|
||||||
|
resp = AppendKERecord(resp, true, RecordEndOfMessage, nil)
|
||||||
|
tc.Write(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPerformKE_E2E(t *testing.T) {
|
||||||
|
cert := generateSelfSignedCert(t)
|
||||||
|
pool := x509.NewCertPool()
|
||||||
|
leaf, _ := x509.ParseCertificate(cert.Certificate[0])
|
||||||
|
pool.AddCert(leaf)
|
||||||
|
|
||||||
|
serverCfg := &tls.Config{
|
||||||
|
Certificates: []tls.Certificate{cert},
|
||||||
|
MinVersion: tls.VersionTLS13,
|
||||||
|
NextProtos: []string{"ntske/1"},
|
||||||
|
}
|
||||||
|
clientCfg := &tls.Config{
|
||||||
|
RootCAs: pool,
|
||||||
|
ServerName: "localhost",
|
||||||
|
MinVersion: tls.VersionTLS13,
|
||||||
|
NextProtos: []string{"ntske/1"},
|
||||||
|
}
|
||||||
|
|
||||||
|
wantCookie := []byte("test-cookie-data-1234")
|
||||||
|
|
||||||
|
serverConn, clientConn := net.Pipe()
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
runMockKEServer(t, serverConn, serverCfg, wantCookie)
|
||||||
|
}()
|
||||||
|
|
||||||
|
tc := tls.Client(clientConn, clientCfg)
|
||||||
|
if err := tc.Handshake(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
secrets, err := PerformKE(tc, KEConfig{})
|
||||||
|
tc.Close()
|
||||||
|
<-done
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("PerformKE: %v", err)
|
||||||
|
}
|
||||||
|
if secrets.NumCookies != 1 {
|
||||||
|
t.Errorf("NumCookies = %d; want 1", secrets.NumCookies)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(secrets.Cookies[0][:secrets.CookieLens[0]], wantCookie) {
|
||||||
|
t.Errorf("cookie mismatch: got %q want %q",
|
||||||
|
secrets.Cookies[0][:secrets.CookieLens[0]], wantCookie)
|
||||||
|
}
|
||||||
|
if secrets.ChosenAlg != AlgAESSIVCMAC256 {
|
||||||
|
t.Errorf("ChosenAlg = %v; want %v", secrets.ChosenAlg, AlgAESSIVCMAC256)
|
||||||
|
}
|
||||||
|
// Keys must be non-zero.
|
||||||
|
var zeroKey [32]byte
|
||||||
|
if secrets.C2SKey == zeroKey || secrets.S2CKey == zeroKey {
|
||||||
|
t.Error("derived keys are all-zero")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
DISABLED: missing AES-SIV-CMAC-256 in stdlib
|
||||||
|
|
||||||
|
// TestClient_E2E runs a full NTS Encapsulate→Demux cycle using
|
||||||
|
// AES-SIV-CMAC-256 as the AEAD.
|
||||||
|
func TestClient_E2E(t *testing.T) {
|
||||||
|
c2sKey := make([]byte, 32)
|
||||||
|
s2cKey := make([]byte, 32)
|
||||||
|
rand.Read(c2sKey)
|
||||||
|
rand.Read(s2cKey)
|
||||||
|
c2s, _ := siv.NewAESSIVCMAC256(c2sKey)
|
||||||
|
s2c, _ := siv.NewAESSIVCMAC256(s2cKey)
|
||||||
|
|
||||||
|
cookie := []byte("nts-cookie-12345678901234")
|
||||||
|
var cfg ClientConfig
|
||||||
|
cfg.C2S = c2s
|
||||||
|
cfg.S2C = s2c
|
||||||
|
cfg.ChosenAlg = AlgAESSIVCMAC256
|
||||||
|
for i := range 2 {
|
||||||
|
copy(cfg.Cookies[i][:], cookie)
|
||||||
|
cfg.CookieLens[i] = len(cookie)
|
||||||
|
}
|
||||||
|
cfg.NumCookies = 2
|
||||||
|
|
||||||
|
baseTime := ntp.BaseTime()
|
||||||
|
clockTime := baseTime.Add(10 * time.Second)
|
||||||
|
serverOffset := 200 * time.Millisecond
|
||||||
|
cfg.Now = func() time.Time { return clockTime }
|
||||||
|
cfg.Sysprec = -20
|
||||||
|
|
||||||
|
var client Client
|
||||||
|
if err := client.Reset(cfg); err != nil {
|
||||||
|
t.Fatalf("Reset: %v", err)
|
||||||
|
}
|
||||||
|
if client.IsDone() {
|
||||||
|
t.Fatal("should not be done before exchange")
|
||||||
|
}
|
||||||
|
|
||||||
|
carrier := make([]byte, 1500)
|
||||||
|
|
||||||
|
// --- First exchange ---
|
||||||
|
n, err := client.Encapsulate(carrier, 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encapsulate 1: %v", err)
|
||||||
|
}
|
||||||
|
if n < ntp.SizeHeader {
|
||||||
|
t.Fatalf("Encapsulate 1: n=%d too small", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate server response: build an NTP response and re-encrypt.
|
||||||
|
resp1 := buildTestResponse(t, carrier[:n], clockTime.Add(serverOffset), clockTime.Add(serverOffset+5*time.Millisecond), s2cKey)
|
||||||
|
clockTime = baseTime.Add(10*time.Second + 110*time.Millisecond)
|
||||||
|
if err = client.Demux(resp1, 0); err != nil {
|
||||||
|
t.Fatalf("Demux 1: %v", err)
|
||||||
|
}
|
||||||
|
if client.IsDone() {
|
||||||
|
t.Fatal("should not be done after first exchange only")
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Second exchange ---
|
||||||
|
carrier2 := make([]byte, 1500)
|
||||||
|
clockTime = baseTime.Add(10*time.Second + 200*time.Millisecond)
|
||||||
|
n2, err := client.Encapsulate(carrier2, 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Encapsulate 2: %v", err)
|
||||||
|
}
|
||||||
|
if n2 == 0 {
|
||||||
|
t.Fatal("Encapsulate 2 returned 0 bytes")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp2 := buildTestResponse(t, carrier2[:n2], clockTime.Add(serverOffset), clockTime.Add(serverOffset+5*time.Millisecond), s2cKey)
|
||||||
|
clockTime = baseTime.Add(10*time.Second + 310*time.Millisecond)
|
||||||
|
if err = client.Demux(resp2, 0); err != nil {
|
||||||
|
t.Fatalf("Demux 2: %v", err)
|
||||||
|
}
|
||||||
|
if !client.IsDone() {
|
||||||
|
t.Fatal("should be done after second exchange")
|
||||||
|
}
|
||||||
|
if client.RoundTripDelay() < 0 {
|
||||||
|
t.Errorf("RoundTripDelay = %v; want >= 0", client.RoundTripDelay())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_Reset_Validation(t *testing.T) {
|
||||||
|
key := make([]byte, 32)
|
||||||
|
aead, _ := siv.NewAESSIVCMAC256(key)
|
||||||
|
cookie := [MaxCookieLen]byte{}
|
||||||
|
cfg := ClientConfig{
|
||||||
|
C2S: aead, S2C: aead, NumCookies: 1,
|
||||||
|
Cookies: [MaxCookies][MaxCookieLen]byte{cookie},
|
||||||
|
CookieLens: [MaxCookies]int{8},
|
||||||
|
}
|
||||||
|
var c Client
|
||||||
|
if err := c.Reset(cfg); err != nil {
|
||||||
|
t.Fatalf("valid Reset: %v", err)
|
||||||
|
}
|
||||||
|
prevID := *c.ConnectionID()
|
||||||
|
|
||||||
|
cfg2 := cfg
|
||||||
|
cfg2.C2S = nil
|
||||||
|
if err := c.Reset(cfg2); err == nil {
|
||||||
|
t.Error("nil C2S: expected error")
|
||||||
|
}
|
||||||
|
if *c.ConnectionID() != prevID {
|
||||||
|
t.Error("connID should not increment on failed Reset")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg3 := cfg
|
||||||
|
cfg3.NumCookies = 0
|
||||||
|
if err := c.Reset(cfg3); err == nil {
|
||||||
|
t.Error("zero cookies: expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_ExhaustedCookies(t *testing.T) {
|
||||||
|
key := make([]byte, 32)
|
||||||
|
aead, _ := siv.NewAESSIVCMAC256(key)
|
||||||
|
cfg := ClientConfig{
|
||||||
|
C2S: aead, S2C: aead, NumCookies: 1,
|
||||||
|
Cookies: [MaxCookies][MaxCookieLen]byte{[MaxCookieLen]byte{}},
|
||||||
|
CookieLens: [MaxCookies]int{8},
|
||||||
|
}
|
||||||
|
var c Client
|
||||||
|
c.Reset(cfg)
|
||||||
|
|
||||||
|
carrier := make([]byte, 1500)
|
||||||
|
if _, err := c.Encapsulate(carrier, 0, 0); err != nil {
|
||||||
|
t.Fatalf("first Encapsulate: %v", err)
|
||||||
|
}
|
||||||
|
if c.numCookies != 0 {
|
||||||
|
t.Fatalf("numCookies = %d; want 0", c.numCookies)
|
||||||
|
}
|
||||||
|
if _, err := c.Encapsulate(carrier, 0, 0); err != lneto.ErrExhausted {
|
||||||
|
t.Errorf("second Encapsulate: got %v; want ErrExhausted", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClient_DemuxBadTag(t *testing.T) {
|
||||||
|
key := make([]byte, 32)
|
||||||
|
aead, _ := siv.NewAESSIVCMAC256(key)
|
||||||
|
cookie := make([]byte, 16)
|
||||||
|
var cookies [MaxCookies][MaxCookieLen]byte
|
||||||
|
copy(cookies[0][:], cookie)
|
||||||
|
cfg := ClientConfig{
|
||||||
|
C2S: aead, S2C: aead, NumCookies: 1,
|
||||||
|
Cookies: cookies,
|
||||||
|
CookieLens: [MaxCookies]int{16},
|
||||||
|
}
|
||||||
|
var c Client
|
||||||
|
c.Reset(cfg)
|
||||||
|
|
||||||
|
carrier := make([]byte, 1500)
|
||||||
|
n, _ := c.Encapsulate(carrier, 0, 0)
|
||||||
|
|
||||||
|
resp := buildTestResponse(t, carrier[:n],
|
||||||
|
time.Now(), time.Now().Add(time.Millisecond), key)
|
||||||
|
// Tamper a byte in the NTP header (part of the AAD); this must cause
|
||||||
|
// the authentication tag to be rejected.
|
||||||
|
resp[ntp.SizeHeader-1] ^= 0xff
|
||||||
|
if err := c.Demux(resp, 0); err != lneto.ErrBadCRC {
|
||||||
|
t.Errorf("tampered Demux: got %v; want ErrBadCRC", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildTestResponse constructs a minimal NTS-authenticated NTP server response
|
||||||
|
// by echoing the client's UniqueID and re-sealing with s2cKey.
|
||||||
|
// The returned slice contains exactly the response bytes (no trailing zeros).
|
||||||
|
func buildTestResponse(t *testing.T, request []byte, serverRecv, serverXmt time.Time, s2cKey []byte) []byte {
|
||||||
|
t.Helper()
|
||||||
|
reqFrm, err := ntp.NewFrame(request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the Unique-ID from the request so the response can echo it.
|
||||||
|
var uniqueID []byte
|
||||||
|
extBuf := reqFrm.ExtensionFields()
|
||||||
|
for off := 0; off < len(extBuf); {
|
||||||
|
field, n, e := ntp.NextExtField(extBuf[off:])
|
||||||
|
if e != nil || len(field.RawData()) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if field.Type() == ntp.ExtNTSUniqueID {
|
||||||
|
uniqueID = field.Value()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
off += n
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build response in a new buffer.
|
||||||
|
resp := make([]byte, 1500)
|
||||||
|
respFrm, _ := ntp.NewFrame(resp)
|
||||||
|
respFrm.SetFlags(ntp.ModeServer, ntp.Version4, ntp.LeapNoWarning)
|
||||||
|
respFrm.SetStratum(ntp.StratumPrimary)
|
||||||
|
respFrm.SetPrecision(-20)
|
||||||
|
respFrm.SetOriginTime(reqFrm.TransmitTime())
|
||||||
|
recvTS, _ := ntp.TimestampFromTime(serverRecv)
|
||||||
|
xmtTS, _ := ntp.TimestampFromTime(serverXmt)
|
||||||
|
respFrm.SetReceiveTime(recvTS)
|
||||||
|
respFrm.SetTransmitTime(xmtTS)
|
||||||
|
|
||||||
|
// Append UniqueID extension field (echo).
|
||||||
|
respBuf := resp[:ntp.SizeHeader]
|
||||||
|
if len(uniqueID) > 0 {
|
||||||
|
respBuf = ntp.AppendExtField(respBuf, ntp.ExtNTSUniqueID, uniqueID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build NTS-Auth field sealed with S2C key.
|
||||||
|
s2c, err := siv.NewAESSIVCMAC256(s2cKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
nonceLen := s2c.NonceSize()
|
||||||
|
overhead := s2c.Overhead()
|
||||||
|
|
||||||
|
var nonce [maxNonceLen]byte
|
||||||
|
rand.Read(nonce[:nonceLen])
|
||||||
|
|
||||||
|
aad := respBuf
|
||||||
|
|
||||||
|
var authBody [maxAuthBody]byte
|
||||||
|
binary.BigEndian.PutUint16(authBody[0:2], uint16(nonceLen))
|
||||||
|
binary.BigEndian.PutUint16(authBody[2:4], uint16(overhead))
|
||||||
|
copy(authBody[4:4+nonceLen], nonce[:nonceLen])
|
||||||
|
s2c.Seal(authBody[4+nonceLen:4+nonceLen], nonce[:nonceLen], nil, aad)
|
||||||
|
|
||||||
|
respBuf = ntp.AppendExtField(respBuf, ntp.ExtNTSAuthAndEEF, authBody[:4+nonceLen+overhead])
|
||||||
|
result := make([]byte, len(respBuf))
|
||||||
|
copy(result, respBuf)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
func FuzzKERecord(f *testing.F) {
|
||||||
|
// Seed with valid records.
|
||||||
|
f.Add(AppendKERecord(nil, true, RecordEndOfMessage, nil))
|
||||||
|
f.Add(AppendKERecord(nil, false, RecordNewCookie, []byte("cookie")))
|
||||||
|
f.Add(AppendKERecord(nil, true, RecordAEADAlgNeg, []byte{0, 15}))
|
||||||
|
// Seed with short inputs.
|
||||||
|
f.Add([]byte{})
|
||||||
|
f.Add([]byte{0x80, 0x01})
|
||||||
|
f.Fuzz(func(t *testing.T, data []byte) {
|
||||||
|
rec, err := NewKERecord(data)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var v lneto.Validator
|
||||||
|
rec.ValidateSize(&v)
|
||||||
|
_ = rec.RecordType()
|
||||||
|
_ = rec.IsCritical()
|
||||||
|
_ = rec.Body()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func FuzzNextExtField(f *testing.F) {
|
||||||
|
f.Add(ntp.AppendExtField(nil, ntp.ExtNTSUniqueID, make([]byte, 32)))
|
||||||
|
f.Add(ntp.AppendExtField(nil, ntp.ExtNTSCookie, make([]byte, 64)))
|
||||||
|
f.Add([]byte{})
|
||||||
|
f.Add([]byte{0, 1, 0, 0})
|
||||||
|
f.Fuzz(func(t *testing.T, data []byte) {
|
||||||
|
field, n, _ := ntp.NextExtField(data)
|
||||||
|
_ = field.RawData()
|
||||||
|
_ = n
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
DISABLED: depends on github.com/soypat/lneto/x/siv (see note above).
|
||||||
|
|
||||||
|
func TestServer_Reset_Validation(t *testing.T) {
|
||||||
|
key := make([]byte, 32)
|
||||||
|
aead, _ := siv.NewAESSIVCMAC256(key)
|
||||||
|
var s Server
|
||||||
|
if err := s.Reset(ServerConfig{C2S: aead, S2C: aead, Stratum: ntp.StratumPrimary}); err != nil {
|
||||||
|
t.Fatalf("valid Reset: %v", err)
|
||||||
|
}
|
||||||
|
prevID := *s.ConnectionID()
|
||||||
|
if err := s.Reset(ServerConfig{C2S: nil, S2C: aead}); err == nil {
|
||||||
|
t.Error("nil C2S: expected error")
|
||||||
|
}
|
||||||
|
if *s.ConnectionID() != prevID {
|
||||||
|
t.Error("connID should not increment on failed Reset")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServer_NoPendingReturnsZero(t *testing.T) {
|
||||||
|
key := make([]byte, 32)
|
||||||
|
aead, _ := siv.NewAESSIVCMAC256(key)
|
||||||
|
var s Server
|
||||||
|
s.Reset(ServerConfig{C2S: aead, S2C: aead, Stratum: ntp.StratumPrimary})
|
||||||
|
carrier := make([]byte, 1500)
|
||||||
|
n, err := s.Encapsulate(carrier, 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if n != 0 {
|
||||||
|
t.Fatalf("expected 0, got %d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientServer_E2E(t *testing.T) {
|
||||||
|
c2sKey := make([]byte, 32)
|
||||||
|
s2cKey := make([]byte, 32)
|
||||||
|
rand.Read(c2sKey)
|
||||||
|
rand.Read(s2cKey)
|
||||||
|
c2s, _ := siv.NewAESSIVCMAC256(c2sKey)
|
||||||
|
s2c, _ := siv.NewAESSIVCMAC256(s2cKey)
|
||||||
|
|
||||||
|
cookie := []byte("nts-cookie-round-trip-test")
|
||||||
|
|
||||||
|
baseTime := ntp.BaseTime()
|
||||||
|
clientTime := baseTime.Add(10 * time.Second)
|
||||||
|
serverTime := clientTime.Add(200 * time.Millisecond)
|
||||||
|
|
||||||
|
var clientCfg ClientConfig
|
||||||
|
clientCfg.C2S = c2s
|
||||||
|
clientCfg.S2C = s2c
|
||||||
|
clientCfg.ChosenAlg = AlgAESSIVCMAC256
|
||||||
|
clientCfg.Now = func() time.Time { return clientTime }
|
||||||
|
clientCfg.Sysprec = -20
|
||||||
|
for i := range 2 {
|
||||||
|
copy(clientCfg.Cookies[i][:], cookie)
|
||||||
|
clientCfg.CookieLens[i] = len(cookie)
|
||||||
|
}
|
||||||
|
clientCfg.NumCookies = 2
|
||||||
|
|
||||||
|
var client Client
|
||||||
|
if err := client.Reset(clientCfg); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server uses same keys (C2S to verify client, S2C to seal responses).
|
||||||
|
var server Server
|
||||||
|
if err := server.Reset(ServerConfig{
|
||||||
|
C2S: c2s,
|
||||||
|
S2C: s2c,
|
||||||
|
Now: func() time.Time { return serverTime },
|
||||||
|
Stratum: ntp.StratumPrimary,
|
||||||
|
Prec: -20,
|
||||||
|
RefID: [4]byte{'G', 'P', 'S', 0},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for exchange := range 2 {
|
||||||
|
carrier := make([]byte, 1500)
|
||||||
|
n, err := client.Encapsulate(carrier, 0, 0)
|
||||||
|
if err != nil || n == 0 {
|
||||||
|
t.Fatalf("exchange %d: client Encapsulate: n=%d err=%v", exchange, n, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = server.Demux(carrier[:n], 0); err != nil {
|
||||||
|
t.Fatalf("exchange %d: server Demux: %v", exchange, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := make([]byte, 1500)
|
||||||
|
rn, err := server.Encapsulate(resp, 0, 0)
|
||||||
|
if err != nil || rn == 0 {
|
||||||
|
t.Fatalf("exchange %d: server Encapsulate: rn=%d err=%v", exchange, rn, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
clientTime = clientTime.Add(100 * time.Millisecond)
|
||||||
|
serverTime = serverTime.Add(100 * time.Millisecond)
|
||||||
|
|
||||||
|
if err = client.Demux(resp[:rn], 0); err != nil {
|
||||||
|
t.Fatalf("exchange %d: client Demux: %v", exchange, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !client.IsDone() {
|
||||||
|
t.Fatal("client should be done after two exchanges")
|
||||||
|
}
|
||||||
|
if client.RoundTripDelay() < 0 {
|
||||||
|
t.Errorf("RTD = %v; want >= 0", client.RoundTripDelay())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientServer_TamperedAADRejected(t *testing.T) {
|
||||||
|
c2sKey := make([]byte, 32)
|
||||||
|
s2cKey := make([]byte, 32)
|
||||||
|
rand.Read(c2sKey)
|
||||||
|
rand.Read(s2cKey)
|
||||||
|
c2s, _ := siv.NewAESSIVCMAC256(c2sKey)
|
||||||
|
s2c, _ := siv.NewAESSIVCMAC256(s2cKey)
|
||||||
|
|
||||||
|
cookie := []byte("cookie-tamper-test")
|
||||||
|
var cfg ClientConfig
|
||||||
|
cfg.C2S = c2s
|
||||||
|
cfg.S2C = s2c
|
||||||
|
cfg.NumCookies = 1
|
||||||
|
copy(cfg.Cookies[0][:], cookie)
|
||||||
|
cfg.CookieLens[0] = len(cookie)
|
||||||
|
|
||||||
|
var client Client
|
||||||
|
client.Reset(cfg)
|
||||||
|
|
||||||
|
carrier := make([]byte, 1500)
|
||||||
|
n, _ := client.Encapsulate(carrier, 0, 0)
|
||||||
|
|
||||||
|
// Tamper with NTP header (part of AAD).
|
||||||
|
carrier[ntp.SizeHeader-1] ^= 0xff
|
||||||
|
|
||||||
|
var server Server
|
||||||
|
server.Reset(ServerConfig{C2S: c2s, S2C: s2c, Stratum: ntp.StratumPrimary})
|
||||||
|
|
||||||
|
if err := server.Demux(carrier[:n], 0); err != lneto.ErrBadCRC {
|
||||||
|
t.Errorf("tampered Demux: got %v; want ErrBadCRC", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
func TestHandleKE_E2E(t *testing.T) {
|
||||||
|
cert := generateSelfSignedCert(t)
|
||||||
|
pool := x509.NewCertPool()
|
||||||
|
leaf, _ := x509.ParseCertificate(cert.Certificate[0])
|
||||||
|
pool.AddCert(leaf)
|
||||||
|
|
||||||
|
serverTLSCfg := &tls.Config{
|
||||||
|
Certificates: []tls.Certificate{cert},
|
||||||
|
MinVersion: tls.VersionTLS13,
|
||||||
|
NextProtos: []string{"ntske/1"},
|
||||||
|
}
|
||||||
|
clientTLSCfg := &tls.Config{
|
||||||
|
RootCAs: pool,
|
||||||
|
ServerName: "localhost",
|
||||||
|
MinVersion: tls.VersionTLS13,
|
||||||
|
NextProtos: []string{"ntske/1"},
|
||||||
|
}
|
||||||
|
|
||||||
|
wantCookie := []byte("ke-server-cookie-data")
|
||||||
|
|
||||||
|
serverConn, clientConn := net.Pipe()
|
||||||
|
done := make(chan KESecrets, 1)
|
||||||
|
errc := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
tc := tls.Server(serverConn, serverTLSCfg)
|
||||||
|
if err := tc.Handshake(); err != nil {
|
||||||
|
errc <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer tc.Close()
|
||||||
|
secrets, err := HandleKE(tc, KEServerConfig{
|
||||||
|
Cookies: [][]byte{wantCookie},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
errc <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
done <- secrets
|
||||||
|
}()
|
||||||
|
|
||||||
|
tc := tls.Client(clientConn, clientTLSCfg)
|
||||||
|
if err := tc.Handshake(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
clientSecrets, err := PerformKE(tc, KEConfig{})
|
||||||
|
tc.Close()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-errc:
|
||||||
|
t.Fatalf("server KE: %v", err)
|
||||||
|
case serverSecrets := <-done:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("client KE: %v", err)
|
||||||
|
}
|
||||||
|
if clientSecrets.ChosenAlg != serverSecrets.ChosenAlg {
|
||||||
|
t.Errorf("ChosenAlg: client=%v server=%v; want equal", clientSecrets.ChosenAlg, serverSecrets.ChosenAlg)
|
||||||
|
}
|
||||||
|
if clientSecrets.C2SKey != serverSecrets.C2SKey {
|
||||||
|
t.Errorf("C2SKey mismatch: client and server derived different keys")
|
||||||
|
}
|
||||||
|
if clientSecrets.S2CKey != serverSecrets.S2CKey {
|
||||||
|
t.Errorf("S2CKey mismatch: client and server derived different keys")
|
||||||
|
}
|
||||||
|
if clientSecrets.NumCookies != 1 {
|
||||||
|
t.Errorf("NumCookies = %d; want 1", clientSecrets.NumCookies)
|
||||||
|
}
|
||||||
|
gotCookie := clientSecrets.Cookies[0][:clientSecrets.CookieLens[0]]
|
||||||
|
if !bytes.Equal(gotCookie, wantCookie) {
|
||||||
|
t.Errorf("cookie = %q; want %q", gotCookie, wantCookie)
|
||||||
|
}
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
t.Fatal("HandleKE server goroutine timed out")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
DISABLED: missing AES-SIV-CMAC-256 in stdlib
|
||||||
|
|
||||||
|
func FuzzServerDemux(f *testing.F) {
|
||||||
|
key := make([]byte, 32)
|
||||||
|
aead, _ := siv.NewAESSIVCMAC256(key)
|
||||||
|
|
||||||
|
// Seed with a valid NTS request built by the client.
|
||||||
|
var c Client
|
||||||
|
c.Reset(ClientConfig{
|
||||||
|
C2S: aead, S2C: aead, NumCookies: 1,
|
||||||
|
Cookies: [MaxCookies][MaxCookieLen]byte{},
|
||||||
|
CookieLens: [MaxCookies]int{16},
|
||||||
|
})
|
||||||
|
carrier := make([]byte, 1500)
|
||||||
|
n, _ := c.Encapsulate(carrier, 0, 0)
|
||||||
|
if n > 0 {
|
||||||
|
f.Add(carrier[:n])
|
||||||
|
}
|
||||||
|
f.Add(make([]byte, ntp.SizeHeader))
|
||||||
|
f.Add([]byte{})
|
||||||
|
f.Add(make([]byte, 10))
|
||||||
|
f.Fuzz(func(t *testing.T, data []byte) {
|
||||||
|
fuzzKey := make([]byte, 32)
|
||||||
|
fuzzAEAD, _ := siv.NewAESSIVCMAC256(fuzzKey)
|
||||||
|
var s Server
|
||||||
|
s.Reset(ServerConfig{C2S: fuzzAEAD, S2C: fuzzAEAD, Stratum: ntp.StratumPrimary})
|
||||||
|
_ = s.Demux(data, 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkClient_Encapsulate(b *testing.B) {
|
||||||
|
key := make([]byte, 32)
|
||||||
|
aead, _ := siv.NewAESSIVCMAC256(key)
|
||||||
|
carrier := make([]byte, 1500)
|
||||||
|
|
||||||
|
var c Client
|
||||||
|
newCfg := func() ClientConfig {
|
||||||
|
var cookies [MaxCookies][MaxCookieLen]byte
|
||||||
|
var lens [MaxCookies]int
|
||||||
|
for i := range cookies {
|
||||||
|
copy(cookies[i][:], make([]byte, 32))
|
||||||
|
lens[i] = 32
|
||||||
|
}
|
||||||
|
return ClientConfig{
|
||||||
|
C2S: aead, S2C: aead, ChosenAlg: AlgAESSIVCMAC256,
|
||||||
|
NumCookies: MaxCookies, Cookies: cookies, CookieLens: lens,
|
||||||
|
Now: time.Now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.Reset(newCfg())
|
||||||
|
b.ResetTimer()
|
||||||
|
for b.Loop() {
|
||||||
|
if c.numCookies == 0 {
|
||||||
|
c.Reset(newCfg())
|
||||||
|
}
|
||||||
|
c.Encapsulate(carrier, 0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*/
|
||||||
+203
@@ -0,0 +1,203 @@
|
|||||||
|
package nts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/binary"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/soypat/lneto"
|
||||||
|
"github.com/soypat/lneto/ntp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ServerConfig configures an NTS [Server].
|
||||||
|
type ServerConfig struct {
|
||||||
|
C2S, S2C cipher.AEAD
|
||||||
|
Rand io.Reader
|
||||||
|
Now func() time.Time
|
||||||
|
Stratum ntp.Stratum
|
||||||
|
Prec int8
|
||||||
|
RefID [4]byte
|
||||||
|
// MakeCookie is called during Encapsulate to generate a fresh cookie for
|
||||||
|
// the response's encrypted extension fields. If nil, no new cookie is sent.
|
||||||
|
MakeCookie func() []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server is a stateful NTS-capable NTP server implementing [lneto.StackNode].
|
||||||
|
// It wraps an [ntp.Server] and validates/builds NTS extension fields.
|
||||||
|
//
|
||||||
|
// Server is NOT safe for concurrent use.
|
||||||
|
type Server struct {
|
||||||
|
connID uint64
|
||||||
|
cfg ServerConfig
|
||||||
|
ntpState ntp.Server
|
||||||
|
nonce [maxNonceLen]byte
|
||||||
|
// pending stores extension data from the last Demux needed for Encapsulate.
|
||||||
|
pending [1]pendingNTS
|
||||||
|
hasPending bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type pendingNTS struct {
|
||||||
|
uniqueID [32]byte
|
||||||
|
uidLen int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset re-initialises the server with cfg.
|
||||||
|
func (s *Server) Reset(cfg ServerConfig) error {
|
||||||
|
if cfg.C2S == nil || cfg.S2C == nil {
|
||||||
|
return lneto.ErrInvalidConfig
|
||||||
|
}
|
||||||
|
if cfg.C2S.NonceSize() > maxNonceLen || cfg.S2C.NonceSize() > maxNonceLen {
|
||||||
|
return lneto.ErrInvalidConfig
|
||||||
|
}
|
||||||
|
if cfg.Rand == nil {
|
||||||
|
cfg.Rand = rand.Reader
|
||||||
|
}
|
||||||
|
if cfg.Now == nil {
|
||||||
|
cfg.Now = time.Now
|
||||||
|
}
|
||||||
|
prevConnID := s.connID
|
||||||
|
*s = Server{
|
||||||
|
connID: prevConnID,
|
||||||
|
cfg: cfg,
|
||||||
|
}
|
||||||
|
if err := s.ntpState.Reset(ntp.ServerConfig{
|
||||||
|
Now: cfg.Now,
|
||||||
|
Stratum: cfg.Stratum,
|
||||||
|
Precision: cfg.Prec,
|
||||||
|
RefID: cfg.RefID,
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.connID = prevConnID + 1
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnectionID implements [lneto.StackNode].
|
||||||
|
func (s *Server) ConnectionID() *uint64 { return &s.connID }
|
||||||
|
|
||||||
|
// Protocol implements [lneto.StackNode].
|
||||||
|
func (s *Server) Protocol() uint64 { return uint64(ntp.ServerPort) }
|
||||||
|
|
||||||
|
// LocalPort implements [lneto.StackNode].
|
||||||
|
func (s *Server) LocalPort() uint16 { return ntp.ServerPort }
|
||||||
|
|
||||||
|
// Demux implements [lneto.StackNode]. It verifies the NTS authentication on
|
||||||
|
// an incoming client request and queues the underlying NTP request.
|
||||||
|
func (s *Server) Demux(carrierData []byte, frameOffset int) error {
|
||||||
|
frame, err := ntp.NewFrame(carrierData[frameOffset:])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
payload := frame.ExtensionFields()
|
||||||
|
if len(payload) == 0 {
|
||||||
|
return lneto.ErrMismatch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract UniqueID for echoing in the response.
|
||||||
|
var p pendingNTS
|
||||||
|
for off := 0; off < len(payload); {
|
||||||
|
field, n, e := ntp.NextExtField(payload[off:])
|
||||||
|
if e != nil || len(field.RawData()) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if field.Type() == ntp.ExtNTSUniqueID {
|
||||||
|
v := field.Value()
|
||||||
|
vn := min(len(v), len(p.uniqueID))
|
||||||
|
copy(p.uniqueID[:], v[:vn])
|
||||||
|
p.uidLen = vn
|
||||||
|
}
|
||||||
|
off += n
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find and verify the NTS authenticator.
|
||||||
|
authOffset, authField, err := findAuthField(payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := authField.Value()
|
||||||
|
if len(body) < 4 {
|
||||||
|
return lneto.ErrTruncatedFrame
|
||||||
|
}
|
||||||
|
nonceLen := int(binary.BigEndian.Uint16(body[0:2]))
|
||||||
|
ctLen := int(binary.BigEndian.Uint16(body[2:4]))
|
||||||
|
if len(body) < 4+nonceLen+ctLen {
|
||||||
|
return lneto.ErrTruncatedFrame
|
||||||
|
}
|
||||||
|
nonce := body[4 : 4+nonceLen]
|
||||||
|
ciphertext := body[4+nonceLen : 4+nonceLen+ctLen]
|
||||||
|
|
||||||
|
aadEnd := frameOffset + ntp.SizeHeader + authOffset
|
||||||
|
aad := carrierData[frameOffset:aadEnd]
|
||||||
|
|
||||||
|
if _, err := s.cfg.C2S.Open(nil, nonce, ciphertext, aad); err != nil {
|
||||||
|
return lneto.ErrBadCRC
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authentication passed; queue the NTP request.
|
||||||
|
if err := s.ntpState.Demux(carrierData, frameOffset); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.pending[0] = p
|
||||||
|
s.hasPending = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// minServerCarrierRoom is the minimum extra bytes beyond NTP header that
|
||||||
|
// carrierData must have for an NTS server response.
|
||||||
|
const minServerCarrierRoom = ntp.SizeHeader + (4 + 32) + (4 + maxAuthBody + 3)
|
||||||
|
|
||||||
|
// Encapsulate implements [lneto.StackNode]. It builds an NTS-authenticated
|
||||||
|
// NTP server response.
|
||||||
|
func (s *Server) Encapsulate(carrierData []byte, offsetToIP, offsetToFrame int) (int, error) {
|
||||||
|
if !s.hasPending {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
if len(carrierData)-offsetToFrame < minServerCarrierRoom {
|
||||||
|
return 0, lneto.ErrShortBuffer
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := s.ntpState.Encapsulate(carrierData, offsetToIP, offsetToFrame)
|
||||||
|
if err != nil || n == 0 {
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := carrierData[offsetToFrame : offsetToFrame+n : len(carrierData)]
|
||||||
|
|
||||||
|
// Echo UniqueID.
|
||||||
|
p := s.pending[0]
|
||||||
|
if p.uidLen > 0 {
|
||||||
|
buf = ntp.AppendExtField(buf, ntp.ExtNTSUniqueID, p.uniqueID[:p.uidLen])
|
||||||
|
}
|
||||||
|
|
||||||
|
// AAD is everything up to (not including) the auth field we're about to write.
|
||||||
|
aad := buf
|
||||||
|
|
||||||
|
// Build auth body with optional encrypted cookie.
|
||||||
|
nonceLen := s.cfg.S2C.NonceSize()
|
||||||
|
overhead := s.cfg.S2C.Overhead()
|
||||||
|
if _, err = io.ReadFull(s.cfg.Rand, s.nonce[:nonceLen]); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var plaintext []byte
|
||||||
|
if s.cfg.MakeCookie != nil {
|
||||||
|
cookie := s.cfg.MakeCookie()
|
||||||
|
if len(cookie) > 0 {
|
||||||
|
plaintext = ntp.AppendExtField(nil, ntp.ExtNTSCookie, cookie)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var authBody [maxAuthBody + 512]byte
|
||||||
|
binary.BigEndian.PutUint16(authBody[0:2], uint16(nonceLen))
|
||||||
|
binary.BigEndian.PutUint16(authBody[2:4], uint16(len(plaintext)+overhead))
|
||||||
|
copy(authBody[4:4+nonceLen], s.nonce[:nonceLen])
|
||||||
|
|
||||||
|
sealed := s.cfg.S2C.Seal(authBody[4+nonceLen:4+nonceLen], s.nonce[:nonceLen], plaintext, aad)
|
||||||
|
totalAuth := 4 + nonceLen + len(sealed)
|
||||||
|
|
||||||
|
buf = ntp.AppendExtField(buf, ntp.ExtNTSAuthAndEEF, authBody[:totalAuth])
|
||||||
|
s.hasPending = false
|
||||||
|
return len(buf), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Code generated by "stringer -type=KERecordType,AEADAlgorithmID -linecomment -output stringers.go"; DO NOT EDIT.
|
||||||
|
|
||||||
|
package nts
|
||||||
|
|
||||||
|
import "strconv"
|
||||||
|
|
||||||
|
func _() {
|
||||||
|
// An "invalid array index" compiler error signifies that the constant values have changed.
|
||||||
|
// Re-run the stringer command to generate them again.
|
||||||
|
var x [1]struct{}
|
||||||
|
_ = x[RecordEndOfMessage-0]
|
||||||
|
_ = x[RecordNextProtoNeg-1]
|
||||||
|
_ = x[RecordError-2]
|
||||||
|
_ = x[RecordWarning-3]
|
||||||
|
_ = x[RecordAEADAlgNeg-4]
|
||||||
|
_ = x[RecordNewCookie-5]
|
||||||
|
_ = x[RecordNTPv4Server-6]
|
||||||
|
_ = x[RecordNTPv4Port-7]
|
||||||
|
}
|
||||||
|
|
||||||
|
const _KERecordType_name = "end of messagenext protocol negotiationerrorwarningAEAD algorithm negotiationnew cookie for NTPv4NTPv4 server negotiationNTPv4 port negotiation"
|
||||||
|
|
||||||
|
var _KERecordType_index = [...]uint8{0, 14, 39, 44, 51, 77, 97, 121, 143}
|
||||||
|
|
||||||
|
func (i KERecordType) String() string {
|
||||||
|
idx := int(i) - 0
|
||||||
|
if i < 0 || idx >= len(_KERecordType_index)-1 {
|
||||||
|
return "KERecordType(" + strconv.FormatInt(int64(i), 10) + ")"
|
||||||
|
}
|
||||||
|
return _KERecordType_name[_KERecordType_index[idx]:_KERecordType_index[idx+1]]
|
||||||
|
}
|
||||||
|
func _() {
|
||||||
|
// An "invalid array index" compiler error signifies that the constant values have changed.
|
||||||
|
// Re-run the stringer command to generate them again.
|
||||||
|
var x [1]struct{}
|
||||||
|
_ = x[AlgAESSIVCMAC256-15]
|
||||||
|
}
|
||||||
|
|
||||||
|
const _AEADAlgorithmID_name = "AEAD_AES_SIV_CMAC_256"
|
||||||
|
|
||||||
|
var _AEADAlgorithmID_index = [...]uint8{0, 21}
|
||||||
|
|
||||||
|
func (i AEADAlgorithmID) String() string {
|
||||||
|
idx := int(i) - 15
|
||||||
|
if i < 15 || idx >= len(_AEADAlgorithmID_index)-1 {
|
||||||
|
return "AEADAlgorithmID(" + strconv.FormatInt(int64(i), 10) + ")"
|
||||||
|
}
|
||||||
|
return _AEADAlgorithmID_name[_AEADAlgorithmID_index[idx]:_AEADAlgorithmID_index[idx+1]]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user