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,425 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user