feat: full NFSv4.2 server and client in pure Go
Test / test (push) Successful in 2m4s
Release / gates (push) Successful in 2m5s
Release / build (amd64, freebsd) (push) Successful in 1m27s
Release / build (amd64, linux) (push) Successful in 1m22s
Release / build (amd64, netbsd) (push) Successful in 1m19s
Release / build (amd64, openbsd) (push) Successful in 1m20s
Release / build (arm64, darwin) (push) Successful in 1m21s
Release / build (arm64, freebsd) (push) Successful in 1m26s
Release / build (arm64, linux) (push) Successful in 1m25s
Release / build (arm64, netbsd) (push) Successful in 1m31s
Release / build (arm64, openbsd) (push) Successful in 1m27s
Release / build (loong64, linux) (push) Successful in 1m37s
Release / build (riscv64, linux) (push) Successful in 1m21s
Release / release (push) Successful in 40s
Test / test (push) Successful in 2m4s
Release / gates (push) Successful in 2m5s
Release / build (amd64, freebsd) (push) Successful in 1m27s
Release / build (amd64, linux) (push) Successful in 1m22s
Release / build (amd64, netbsd) (push) Successful in 1m19s
Release / build (amd64, openbsd) (push) Successful in 1m20s
Release / build (arm64, darwin) (push) Successful in 1m21s
Release / build (arm64, freebsd) (push) Successful in 1m26s
Release / build (arm64, linux) (push) Successful in 1m25s
Release / build (arm64, netbsd) (push) Successful in 1m31s
Release / build (arm64, openbsd) (push) Successful in 1m27s
Release / build (loong64, linux) (push) Successful in 1m37s
Release / build (riscv64, linux) (push) Successful in 1m21s
Release / release (push) Successful in 40s
Assisted-by: GLM 5.3 Flash
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package krb5
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"encoding/binary"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testKeys() []byte {
|
||||
serviceKey := make([]byte, 32)
|
||||
for i := range serviceKey {
|
||||
serviceKey[i] = byte(i)
|
||||
}
|
||||
return serviceKey
|
||||
}
|
||||
|
||||
func TestGSSExchange(t *testing.T) {
|
||||
serviceKey := testKeys()
|
||||
initiator, token, err := ClientInit(EtypeAES256, serviceKey,
|
||||
"EXAMPLE.ORG", "nfs", "petr@EXAMPLE.ORG")
|
||||
if err != nil {
|
||||
t.Fatalf("client init: %v", err)
|
||||
}
|
||||
if len(token) == 0 || token[0] != 0x60 {
|
||||
t.Fatalf("context token % x", token[:8])
|
||||
}
|
||||
acceptor, reply, err := AcceptInit(token, serviceKey)
|
||||
if err != nil {
|
||||
t.Fatalf("accept init: %v", err)
|
||||
}
|
||||
if !bytes.Equal(acceptor.Key, initiator.Key) {
|
||||
t.Fatal("the session keys differ")
|
||||
}
|
||||
if len(reply) == 0 {
|
||||
t.Fatal("no AP-REP")
|
||||
}
|
||||
|
||||
// krb5i: a MIC over the message binds it to the header and the
|
||||
// sequence counters move in lock step.
|
||||
message := []byte("the rpc call header bytes")
|
||||
mic, err := initiator.GetMIC(message)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mic[0] != 0x04 || mic[1] != 0x04 {
|
||||
t.Fatalf("mic token id % x", mic[:2])
|
||||
}
|
||||
if err := acceptor.VerifyMIC(message, mic); err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
// The mirrored direction: the acceptor's own MIC verifies on the
|
||||
// initiator, and the sequence counters advanced.
|
||||
mic2, err := acceptor.GetMIC(message)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := initiator.VerifyMIC(message, mic2); err != nil {
|
||||
t.Fatalf("verify reverse: %v", err)
|
||||
}
|
||||
// A replay of the first token is refused.
|
||||
if err := acceptor.VerifyMIC(message, mic); err != ErrContext {
|
||||
t.Fatalf("replay accepted: %v", err)
|
||||
}
|
||||
// Tampering with a fresh token is refused on the checksum.
|
||||
tampered := append([]byte{}, message...)
|
||||
tampered[0] ^= 0x80
|
||||
tamperedMIC, err := initiator.GetMIC(tampered)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tamperedMIC[20] ^= 0xff
|
||||
if err := acceptor.VerifyMIC(tampered, tamperedMIC); err != ErrIntegrity {
|
||||
t.Fatalf("tampering accepted: %v", err)
|
||||
}
|
||||
|
||||
// krb5p: the Wrap token hides and binds the payload.
|
||||
secret := []byte("the compound arguments")
|
||||
wrapped, err := initiator.Wrap(secret)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if wrapped[0] != 0x05 || wrapped[1] != 0x04 {
|
||||
t.Fatalf("wrap token id % x", wrapped[:2])
|
||||
}
|
||||
if bytes.Contains(wrapped, secret) {
|
||||
t.Fatal("the plaintext leaked into the wrap token")
|
||||
}
|
||||
opened, err := acceptor.Unwrap(wrapped)
|
||||
if err != nil {
|
||||
t.Fatalf("unwrap: %v", err)
|
||||
}
|
||||
if !bytes.Equal(opened, secret) {
|
||||
t.Fatalf("unwrapped %q", opened)
|
||||
}
|
||||
wrapped[20] ^= 0xff
|
||||
if _, err := acceptor.Unwrap(wrapped); err == nil {
|
||||
t.Fatal("tampering accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// craftAPREQ builds an establishment token the way ClientInit does, with
|
||||
// the ticket endtime and the authenticator time under the caller's
|
||||
// control, so the acceptor's checks can be driven from the tests.
|
||||
func craftAPREQ(t *testing.T, endtime, ctime time.Time) []byte {
|
||||
t.Helper()
|
||||
serviceKey := testKeys()
|
||||
session := make([]byte, 32)
|
||||
for i := range session {
|
||||
session[i] = byte(255 - i)
|
||||
}
|
||||
ticket, err := buildTicket("EXAMPLE.ORG", "nfs", session, serviceKey, EtypeAES256, endtime)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
auth, err := buildAuthenticator("EXAMPLE.ORG", "petr@EXAMPLE.ORG", session, EtypeAES256, 0, ctime)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
apreq := appTag(14, derTLV(tagSequence, append(append(
|
||||
derInt(5), derInt(14)...),
|
||||
append(bitFlags(0), append(ticket, auth...)...)...)))
|
||||
return appTag(0, append(append([]byte{}, derOID...), apreq...))
|
||||
}
|
||||
|
||||
func TestAcceptInitReplay(t *testing.T) {
|
||||
serviceKey := testKeys()
|
||||
token := craftAPREQ(t, time.Now().Add(time.Hour), time.Now())
|
||||
if _, _, err := AcceptInit(token, serviceKey); err != nil {
|
||||
t.Fatalf("first accept: %v", err)
|
||||
}
|
||||
if _, _, err := AcceptInit(token, serviceKey); err != ErrReplay {
|
||||
t.Fatalf("replayed accept: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptInitSkew(t *testing.T) {
|
||||
serviceKey := testKeys()
|
||||
old := craftAPREQ(t, time.Now().Add(time.Hour), time.Now().Add(-2*clockSkew))
|
||||
if _, _, err := AcceptInit(old, serviceKey); err != ErrSkew {
|
||||
t.Fatalf("an authenticator outside the window: %v", err)
|
||||
}
|
||||
future := craftAPREQ(t, time.Now().Add(time.Hour), time.Now().Add(2*clockSkew))
|
||||
if _, _, err := AcceptInit(future, serviceKey); err != ErrSkew {
|
||||
t.Fatalf("an authenticator from the future: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAcceptInitExpiredTicket(t *testing.T) {
|
||||
serviceKey := testKeys()
|
||||
token := craftAPREQ(t, time.Now().Add(-time.Hour), time.Now())
|
||||
if _, _, err := AcceptInit(token, serviceKey); err != ErrExpired {
|
||||
t.Fatalf("an expired ticket: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The per-message token operations of one context serve concurrent
|
||||
// callers without losing sequence numbers, which the race detector
|
||||
// watches over.
|
||||
func TestContextConcurrentTokens(t *testing.T) {
|
||||
serviceKey := testKeys()
|
||||
initiator, token, err := ClientInit(EtypeAES256, serviceKey,
|
||||
"EXAMPLE.ORG", "nfs", "petr@EXAMPLE.ORG")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
acceptor, _, err := AcceptInit(token, serviceKey)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const workers, rounds = 4, 25
|
||||
mics := make([][]byte, workers*rounds)
|
||||
wrapped := make([][]byte, workers*rounds)
|
||||
var wg sync.WaitGroup
|
||||
for w := range workers {
|
||||
wg.Go(func() {
|
||||
for i := range rounds {
|
||||
mic, err := initiator.GetMIC([]byte("concurrent"))
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
mics[w*rounds+i] = mic
|
||||
box, err := initiator.Wrap([]byte("secret"))
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
wrapped[w*rounds+i] = box
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Every sequence number was handed out exactly once, and the tokens
|
||||
// verify in order on the other side. The MIC and Wrap tokens share the
|
||||
// send counter, so the two families interleave in one space.
|
||||
type sent struct {
|
||||
seq uint64
|
||||
mic []byte
|
||||
wrap []byte
|
||||
}
|
||||
total := make([]sent, 0, 2*workers*rounds)
|
||||
for _, mic := range mics {
|
||||
total = append(total, sent{seq: binary.BigEndian.Uint64(mic[8:]), mic: mic})
|
||||
}
|
||||
for _, box := range wrapped {
|
||||
total = append(total, sent{seq: binary.BigEndian.Uint64(box[8:]), wrap: box})
|
||||
}
|
||||
slices.SortFunc(total, func(a, b sent) int { return cmp.Compare(a.seq, b.seq) })
|
||||
if len(total) != 2*workers*rounds {
|
||||
t.Fatalf("%d tokens, want %d", len(total), 2*workers*rounds)
|
||||
}
|
||||
for i, tk := range total {
|
||||
if tk.seq != uint64(i) {
|
||||
t.Fatalf("token %d carries sequence %d", i, tk.seq)
|
||||
}
|
||||
if tk.mic != nil {
|
||||
if err := acceptor.VerifyMIC([]byte("concurrent"), tk.mic); err != nil {
|
||||
t.Fatalf("verify: %v", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
opened, err := acceptor.Unwrap(tk.wrap)
|
||||
if err != nil {
|
||||
t.Fatalf("unwrap: %v", err)
|
||||
}
|
||||
if string(opened) != "secret" {
|
||||
t.Fatalf("unwrapped %q", opened)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user