Files
petrbalvin a9b8039ef7
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
feat: full NFSv4.2 server and client in pure Go
Assisted-by: GLM 5.3 Flash
2026-09-21 18:51:17 +02:00

426 lines
13 KiB
Go

// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// The RPCSEC_GSS server side: the context store keyed by handle, the
// RPCSEC_GSS_INIT and DESTROY procedures, and the credential evaluation
// of COMPOUND calls at the service levels none, integrity and privacy.
package nfs4server
import (
crand "crypto/rand"
"sync"
"sourcedock.dev/petrbalvin/nfs/internal/krb5"
"sourcedock.dev/petrbalvin/nfs/internal/rpc"
)
// gssMajorStatus values as RFC 2743 section 1.2.2 encodes them: the
// continue needed supplementary bit, and the routine errors shifted
// into bits sixteen and up.
const (
gssMajorOK = 0
gssMajorContinueNeeded = 1
gssMajorDefectiveToken = 9 << 16
gssMajorFailure = 16 << 16
)
// The GSS sequence window the server accepts, RFC 2203 section 5.2.2.
const gssWindow = 32
// A gssSession is one established security context and the sequence
// bookkeeping of its credential. The mutex guards the bookkeeping and
// the token operations together: one context handle presented on two
// connections must not interleave its anti-replay window or its
// sequence counters, RFC 2203 section 5.2.2.
type gssSession struct {
mu sync.Mutex
ctx *krb5.Context
service uint32
lastSeq uint32
seqSet bool
label *rpc.Label
privs []rpc.Privs
}
// gssStore keeps the established contexts by their handle. The handles
// are random, not a counter, so one cannot be guessed and destroyed by
// enumeration.
type gssStore struct {
mu sync.Mutex
byKey map[string]*gssSession
}
func newGSSStore() *gssStore {
return &gssStore{byKey: make(map[string]*gssSession)}
}
func (s *gssStore) put(sess *gssSession) []byte {
handle := make([]byte, 8)
if _, err := crand.Read(handle); err != nil {
panic("nfs4server: the random source failed: " + err.Error())
}
handle[0] = 'G'
handle[1] = 'S'
s.mu.Lock()
defer s.mu.Unlock()
s.byKey[string(handle)] = sess
return handle
}
func (s *gssStore) get(handle []byte) (*gssSession, bool) {
s.mu.Lock()
defer s.mu.Unlock()
sess, ok := s.byKey[string(handle)]
return sess, ok
}
func (s *gssStore) drop(handle []byte) {
s.mu.Lock()
delete(s.byKey, string(handle))
s.mu.Unlock()
}
// gssStore returns the context store, made once per handler. A handler
// without a ServerKey never establishes contexts.
func (h *Handler) gssSessions() *gssStore {
h.mu.Lock()
defer h.mu.Unlock()
if h.gssSt == nil {
h.gssSt = newGSSStore()
}
return h.gssSt
}
// gssInit serves RPCSEC_GSS_INIT: the call data carries the initiator
// context token, which is verified against the service key, stored
// under a fresh handle and answered with the RPCSEC_GSS_INIT result
// holding the handle and the AP-REP.
func (h *Handler) gssInit(token []byte) []byte {
var major, minor uint32
var handle, reply []byte
if h.ServerKey == nil {
major = gssMajorFailure // no key configured: refuse
} else {
ctx, rep, aerr := krb5.AcceptInit(token, h.ServerKey)
if aerr != nil {
major = gssMajorDefectiveToken
minor = 1
} else {
handle = h.gssSessions().put(&gssSession{ctx: ctx})
reply = rep
}
}
return rpc.AppendGSSInitRes(nil, handle, major, minor, gssWindow, reply)
}
// gssDestroy retires the context the credential names. RFC 2203
// section 5.2.3 requires the request to carry a valid verifier under
// the very context it destroys; a request without one, or for a context
// this server never established, is refused.
func (h *Handler) gssDestroy(call rpc.Call) bool {
cred, err := rpc.DecodeGSSCred(call.Cred.Body)
if err != nil || cred.Proc != rpc.GSSProcDestroy {
return false
}
sess, ok := h.gssSessions().get(cred.Handle)
if !ok {
return false
}
prefix, err := rpc.AppendCall(nil, rpc.Call{
XID: call.XID, Program: call.Program, Version: call.Version,
Procedure: call.Procedure, Cred: call.Cred,
})
if err != nil {
return false
}
if sess.ctx.VerifyMIC(prefix, call.Verifier.Body) != nil {
return false
}
h.gssSessions().drop(cred.Handle)
return true
}
// gssCompound evaluates a COMPOUND call under RPCSEC_GSS: it verifies
// the verifier MIC over the call header, unwraps or checksum-verifies
// the arguments per the service level, runs the compound and protects
// the results the same way. The reply verifier is generated under the
// same session lock as the protected body, so one session's tokens
// leave the server in the order a client verifies them. The last answer
// is false when the RPC layer must answer GARBAGE_ARGS.
func (h *Handler) gssCompound(call rpc.Call, args []byte) ([]byte, rpc.Auth, bool) {
var gcred rpc.GSSCred
if peekU32(call.Cred.Body) == rpc.GSSVersion3 {
// A version three credential carries the version in front.
v3, verr := rpc.DecodeGSSv3Cred(call.Cred.Body)
if verr != nil || v3.Proc != rpc.GSSProcData {
return nil, rpc.Auth{}, false
}
gcred = rpc.GSSCred{Proc: rpc.GSSProcData, Version: rpc.GSSVersion3,
Service: v3.Service, Handle: v3.Handle, Seq: v3.Seq}
} else {
var derr error
gcred, derr = rpc.DecodeGSSCred(call.Cred.Body)
if derr != nil || gcred.Proc != rpc.GSSProcData || gcred.Version != rpc.GSSVersion1 {
return nil, rpc.Auth{}, false
}
}
sess, ok := h.gssSessions().get(gcred.Handle)
if !ok {
return nil, rpc.Auth{}, false
}
sess.mu.Lock()
defer sess.mu.Unlock()
if sess.seqSet && gcred.Seq != sess.lastSeq+1 && !(gcred.Seq > sess.lastSeq) {
return nil, rpc.Auth{}, false
}
// The verifier is a MIC over the call header with an empty verifier
// field: re-encode that prefix and check the token against it.
prefix, err := rpc.AppendCall(nil, rpc.Call{
XID: call.XID, Program: call.Program, Version: call.Version,
Procedure: call.Procedure, Cred: call.Cred,
})
if err != nil {
return nil, rpc.Auth{}, false
}
if err := sess.ctx.VerifyMIC(prefix, call.Verifier.Body); err != nil {
return nil, rpc.Auth{}, false
}
sess.lastSeq = gcred.Seq
sess.service = gcred.Service
sess.seqSet = true
var compoundArgs []byte
switch sess.service {
case rpc.SvcPrivacy:
if compoundArgs, err = sess.ctx.Unwrap(args); err != nil {
return nil, rpc.Auth{}, false
}
case rpc.SvcIntegrity:
if len(args) < 28 {
return nil, rpc.Auth{}, false
}
compoundArgs = args[:len(args)-28]
if err := sess.ctx.VerifyMIC(compoundArgs, args[len(args)-28:]); err != nil {
return nil, rpc.Auth{}, false
}
default:
compoundArgs = args
}
body, ok := h.compoundCtx(compoundArgs, h.gssCred(sess), nil)
if !ok {
return nil, rpc.Auth{}, false
}
verf := rpc.Auth{}
if sess.service != rpc.SvcNone {
replyPrefix, perr := rpc.AppendAcceptedReply(nil, call.XID, rpc.Auth{}, rpc.AcceptSuccess, rpc.Mismatch{})
if perr != nil {
return nil, rpc.Auth{}, false
}
mic, merr := sess.ctx.GetMIC(replyPrefix)
if merr != nil {
return nil, rpc.Auth{}, false
}
verf = rpc.Auth{Flavor: rpc.FlavorGSS, Body: mic}
}
switch sess.service {
case rpc.SvcPrivacy:
if body, err = sess.ctx.Wrap(body); err != nil {
return nil, rpc.Auth{}, false
}
case rpc.SvcIntegrity:
mic, merr := sess.ctx.GetMIC(body)
if merr != nil {
return nil, rpc.Auth{}, false
}
body = append(append([]byte{}, body...), mic...)
}
return body, verf, true
}
// gssCred answers the identity the operations of one context run as.
// Every principal this server authenticates maps to the anonymous
// identity until a mapping table exists, so no principal silently
// becomes root over the export.
func (h *Handler) gssCred(sess *gssSession) cred {
return cred{uid: 65534, gid: 65534}
}
// gssCreate serves RPCSEC_GSS_CREATE over a version three credential:
// the parent context is looked up, the multi-principal assertion is
// verified against the inner handle, labels and privileges are accepted
// into the new child session and the child handle answers the request,
// RFC 7861 section 2.7.1.
func (h *Handler) gssCreate(v3 rpc.GSSv3Cred, headerPrefix []byte, callData []byte) ([]byte, bool) {
parent, ok := h.gssSessions().get(v3.Handle)
if !ok {
return nil, false
}
mpAuth, chanBind, assertions, err := rpc.DecodeCreateArgs(callData)
if err != nil {
return nil, false
}
// Multi-principal authentication rides only over privacy and binds
// the inner handle by its MIC over this call header.
var resMp *rpc.MpAuth
if mpAuth != nil {
if v3.Service != rpc.SvcPrivacy {
return nil, false
}
inner, ok := h.gssSessions().get(mpAuth.InnerHandle)
if !ok {
return nil, false
}
if err := inner.ctx.VerifyMIC(headerPrefix, mpAuth.HeaderMic); err != nil {
return nil, false
}
resMic, merr := inner.ctx.GetMIC(headerPrefix)
if merr != nil {
return nil, false
}
resMp = &rpc.MpAuth{InnerHandle: mpAuth.InnerHandle, HeaderMic: resMic}
}
_ = chanBind // channel binding: asserted, unverified in this build
var granted []rpc.Assertion
child := &gssSession{ctx: parent.ctx, service: v3.Service}
for _, a := range assertions {
switch a.Type {
case rpc.AssertionLabel:
child.label = &a.Label
granted = append(granted, a)
case rpc.AssertionPrivs:
child.privs = append(child.privs, a.Privs)
granted = append(granted, a)
default:
// Unsupported assertions are dropped, not granted.
}
}
childHandle := h.gssSessions().put(child)
return rpc.AppendCreateRes(nil, childHandle, resMp, nil, granted), true
}
// gssList serves RPCSEC_GSS_LIST: the assertion types this server
// grants.
func (h *Handler) gssList() []byte {
return rpc.AppendListRes(nil, []uint32{rpc.AssertionLabel, rpc.AssertionPrivs})
}
// labelOf answers the label assertion of a context, if any.
func (h *Handler) labelOf(handle []byte) *rpc.Label {
sess, ok := h.gssSessions().get(handle)
if !ok {
return nil
}
return sess.label
}
// peekU32 reads the first word of a credential body without consuming
// it: the version three credential starts with the version field while
// the version one form starts with the control procedure.
func peekU32(body []byte) uint32 {
if len(body) < 4 {
return 0
}
return uint32(body[0])<<24 | uint32(body[1])<<16 | uint32(body[2])<<8 | uint32(body[3])
}
// gssv3Control serves the RPCSEC_GSS_CREATE and LIST control messages,
// which ride on NULLPROC under a version three credential protected at
// the integrity or privacy level, RFC 7861 section 5.2. The reply
// verifier is generated under the same session lock as the protected
// result, so the session's tokens leave the server in the order a
// client verifies them.
func (h *Handler) gssv3Control(call rpc.Call, args []byte) ([]byte, rpc.Auth, bool) {
v3, err := rpc.DecodeGSSv3Cred(call.Cred.Body)
if err != nil {
return nil, rpc.Auth{}, false
}
sess, ok := h.gssSessions().get(v3.Handle)
if !ok {
return nil, rpc.Auth{}, false
}
sess.mu.Lock()
defer sess.mu.Unlock()
if sess.seqSet && v3.Seq != sess.lastSeq+1 && !(v3.Seq > sess.lastSeq) {
return nil, rpc.Auth{}, false
}
prefix, err := rpc.AppendCall(nil, rpc.Call{
XID: call.XID, Program: call.Program, Version: call.Version,
Procedure: call.Procedure, Cred: call.Cred,
})
if err != nil {
return nil, rpc.Auth{}, false
}
if err := sess.ctx.VerifyMIC(prefix, call.Verifier.Body); err != nil {
return nil, rpc.Auth{}, false
}
sess.lastSeq = v3.Seq
sess.seqSet = true
// The call data carries the control payload protected at the
// session's service level, for every control procedure: a client
// that checksummed or sealed its arguments must see them verified,
// or the shared sequence counters of the context drift apart.
var callData []byte
switch v3.Service {
case rpc.SvcPrivacy:
if callData, err = sess.ctx.Unwrap(args); err != nil {
return nil, rpc.Auth{}, false
}
case rpc.SvcIntegrity:
if len(args) < 28 {
return nil, rpc.Auth{}, false
}
callData = args[:len(args)-28]
if err := sess.ctx.VerifyMIC(callData, args[len(args)-28:]); err != nil {
return nil, rpc.Auth{}, false
}
default:
return nil, rpc.Auth{}, false
}
var res []byte
switch v3.Proc {
case rpc.GSSProcCreate:
if ok, cok := h.gssCreate(v3, prefix, callData); !cok {
return nil, rpc.Auth{}, false
} else {
res = ok
}
case rpc.GSSProcList:
res = h.gssList()
default:
return nil, rpc.Auth{}, false
}
replyPrefix, perr := rpc.AppendAcceptedReply(nil, call.XID, rpc.Auth{}, rpc.AcceptSuccess, rpc.Mismatch{})
if perr != nil {
return nil, rpc.Auth{}, false
}
mic, merr := sess.ctx.GetMIC(replyPrefix)
if merr != nil {
return nil, rpc.Auth{}, false
}
verf := rpc.Auth{Flavor: rpc.FlavorGSS, Body: mic}
switch v3.Service {
case rpc.SvcPrivacy:
sealed, serr := sess.ctx.Wrap(res)
if serr != nil {
return nil, rpc.Auth{}, false
}
return sealed, verf, true
case rpc.SvcIntegrity:
mic, merr := sess.ctx.GetMIC(res)
if merr != nil {
return nil, rpc.Auth{}, false
}
return append(append([]byte{}, res...), mic...), verf, true
default:
return nil, rpc.Auth{}, false
}
}
// LabelOf answers the label assertion bound to a context handle, or nil
// when the context carries none.
func (h *Handler) LabelOf(handle []byte) *rpc.Label {
return h.labelOf(handle)
}