Skip to content

Commit 034c040

Browse files
committed
DAOS-19450 control: enforce per-pool node cert at connect
When a pool has a CA installed, the engine expects the client to present a node cert and proof-of-possession in the pool connect RPC. If the client does not present a valid cert and/or PoP, the connection is rejected. Pools without a CA accept clients as usual. Patch: 5/8 Features: pool Signed-off-by: Michael MacDonald <github@macdonald.cx>
1 parent aa1bab5 commit 034c040

16 files changed

Lines changed: 737 additions & 22 deletions

File tree

‎src/control/lib/daos/drpc.go‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
//
22
// (C) Copyright 2019-2024 Intel Corporation.
3-
// (C) Copyright 2025 Hewlett Packard Enterprise Development LP
3+
// (C) Copyright 2025-2026 Hewlett Packard Enterprise Development LP
44
//
55
// SPDX-License-Identifier: BSD-2-Clause-Patent
66
//
@@ -290,6 +290,7 @@ func (m securityMethod) ID() int32 {
290290
func (m securityMethod) String() string {
291291
if s, ok := map[securityMethod]string{
292292
MethodValidateCredentials: "validate credentials",
293+
MethodValidateNodeCert: "validate node cert",
293294
}[m]; ok {
294295
return s
295296
}
@@ -300,4 +301,6 @@ func (m securityMethod) String() string {
300301
const (
301302
// MethodValidateCredentials is a ModuleSecurity method
302303
MethodValidateCredentials securityMethod = C.DRPC_METHOD_SEC_VALIDATE_CREDS
304+
// MethodValidateNodeCert is a ModuleSecurity method for per-pool node cert validation
305+
MethodValidateNodeCert securityMethod = C.DRPC_METHOD_SEC_VALIDATE_NODE_CERT
303306
)

‎src/control/lib/daos/status.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,8 @@ const (
172172
ControlIncompatible Status = -C.DER_CONTROL_INCOMPAT
173173
// NoCert indicates that one or more configured certificates could not be accessed.
174174
NoCert Status = -C.DER_NO_CERT
175+
// NoNodeCert indicates that a pool requires a node certificate but none is configured.
176+
NoNodeCert Status = -C.DER_NO_NODE_CERT
175177
// BadCert indicates that an invalid certificate was detected.
176178
BadCert Status = -C.DER_BAD_CERT
177179
// RedundancyFactorExceeded indicates that the maximum number of failed components was exceeded.

‎src/control/server/config/server.go‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -782,6 +782,12 @@ func (cfg *Server) Validate(log logging.Logger) (err error) {
782782
}
783783
log.Debug(msg)
784784

785+
if cfg.TransportConfig != nil {
786+
if err = cfg.TransportConfig.Validate(); err != nil {
787+
return errors.Wrap(err, "transport config validation failed")
788+
}
789+
}
790+
785791
// Append the user-friendly message to any error.
786792
defer func() {
787793
if err != nil && !fault.HasResolution(err) {

‎src/control/server/config/server_test.go‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"path/filepath"
1818
"strings"
1919
"testing"
20+
"time"
2021

2122
"github.com/dustin/go-humanize"
2223
"github.com/google/go-cmp/cmp"
@@ -2391,3 +2392,48 @@ func TestConfig_HasPMem(t *testing.T) {
23912392
})
23922393
}
23932394
}
2395+
2396+
func TestServerConfig_TransportMaxClockSkew(t *testing.T) {
2397+
testDir, cleanup := test.CreateTestDir(t)
2398+
defer cleanup()
2399+
testFile := filepath.Join(testDir, sConfigUncomment)
2400+
uncommentServerConfig(t, testFile)
2401+
2402+
for name, tc := range map[string]struct {
2403+
skew time.Duration
2404+
expErr error
2405+
}{
2406+
"zero": {0, nil},
2407+
"positive": {time.Minute, nil},
2408+
"one second boundary": {time.Second, nil},
2409+
"negative": {-time.Second, errors.New("must not be negative")},
2410+
"sub-second (bare ns)": {300 * time.Nanosecond, errors.New("below 1s")},
2411+
} {
2412+
t.Run(name, func(t *testing.T) {
2413+
log, buf := logging.NewTestLogger(t.Name())
2414+
defer test.ShowBufferOnFailure(t, buf)
2415+
2416+
cfg := baseCfg(t, log, testFile)
2417+
cfg.TransportConfig.CertMaxClockSkew = tc.skew
2418+
test.CmpErr(t, tc.expErr, cfg.Validate(log))
2419+
})
2420+
}
2421+
}
2422+
2423+
// A transport_config block that omits cert_max_clock_skew keeps the default.
2424+
func TestServerConfig_TransportMaxClockSkewDefault(t *testing.T) {
2425+
log, buf := logging.NewTestLogger(t.Name())
2426+
defer test.ShowBufferOnFailure(t, buf)
2427+
testDir, cleanup := test.CreateTestDir(t)
2428+
defer cleanup()
2429+
2430+
path := filepath.Join(testDir, "daos_server.yml")
2431+
if err := os.WriteFile(path, []byte("transport_config:\n allow_insecure: true\n"), 0644); err != nil {
2432+
t.Fatal(err)
2433+
}
2434+
cfg, err := mockConfigFromFile(t, log, path)
2435+
if err != nil {
2436+
t.Fatal(err)
2437+
}
2438+
test.AssertEqual(t, security.DefaultCertMaxClockSkew, cfg.TransportConfig.CertMaxClockSkew, "cert_max_clock_skew")
2439+
}

‎src/control/server/security_rpc.go‎

Lines changed: 82 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
//
22
// (C) Copyright 2019-2022 Intel Corporation.
3-
// (C) Copyright 2025 Hewlett Packard Enterprise Development LP
3+
// (C) Copyright 2025-2026 Hewlett Packard Enterprise Development LP
44
//
55
// SPDX-License-Identifier: BSD-2-Clause-Patent
66
//
@@ -12,7 +12,10 @@ import (
1212
"crypto"
1313
"fmt"
1414
"path/filepath"
15+
"time"
1516

17+
"github.com/google/uuid"
18+
"github.com/pkg/errors"
1619
"google.golang.org/protobuf/proto"
1720

1821
"github.com/daos-stack/daos/src/control/drpc"
@@ -84,11 +87,14 @@ func (m *SecurityModule) validateRespWithStatus(status daos.Status) ([]byte, err
8487

8588
// HandleCall is the handler for calls to the SecurityModule
8689
func (m *SecurityModule) HandleCall(_ context.Context, session *drpc.Session, method drpc.Method, body []byte) ([]byte, error) {
87-
if method != daos.MethodValidateCredentials {
90+
switch method {
91+
case daos.MethodValidateCredentials:
92+
return m.processValidateCredentials(body)
93+
case daos.MethodValidateNodeCert:
94+
return m.processValidateNodeCert(body)
95+
default:
8896
return nil, drpc.UnknownMethodFailure()
8997
}
90-
91-
return m.processValidateCredentials(body)
9298
}
9399

94100
// ID will return Security module ID
@@ -105,7 +111,79 @@ func (m *SecurityModule) GetMethod(id int32) (drpc.Method, error) {
105111
switch id {
106112
case daos.MethodValidateCredentials.ID():
107113
return daos.MethodValidateCredentials, nil
114+
case daos.MethodValidateNodeCert.ID():
115+
return daos.MethodValidateNodeCert, nil
108116
default:
109117
return nil, fmt.Errorf("invalid method ID %d for module %s", id, m.String())
110118
}
111119
}
120+
121+
func (m *SecurityModule) processValidateNodeCert(body []byte) ([]byte, error) {
122+
req := &auth.ValidateNodeCertReq{}
123+
if err := proto.Unmarshal(body, req); err != nil {
124+
return nil, drpc.UnmarshalingPayloadFailure()
125+
}
126+
127+
poolUUID, err := uuid.FromBytes(req.GetPoolUuid())
128+
if err != nil {
129+
return m.rejectNodeCert(req, daos.InvalidInput,
130+
fmt.Sprintf("invalid pool UUID (%d bytes)", len(req.GetPoolUuid())))
131+
}
132+
133+
daosCA, err := m.config.CACert()
134+
if err != nil {
135+
return m.rejectNodeCert(req, daos.NoCert,
136+
fmt.Sprintf("failed to load DAOS CA: %v", err))
137+
}
138+
if daosCA == nil {
139+
return m.rejectNodeCert(req, daos.NoCert, "no DAOS CA without transport security")
140+
}
141+
142+
p := &security.NodeCertPresentation{
143+
Root: daosCA,
144+
PoolCA: req.PoolCa,
145+
Cert: req.NodeCert,
146+
PoPSig: req.PopSig,
147+
PoPPayload: req.PopPayload,
148+
PoolUUID: poolUUID,
149+
MachineName: req.MachineName,
150+
Watermarks: req.CertWatermarks,
151+
MaxSkew: m.config.CertMaxClockSkew,
152+
Now: time.Now(),
153+
}
154+
payload, err := p.Validate()
155+
if err != nil {
156+
return m.rejectNodeCert(req, validationStatus(err), err.Error())
157+
}
158+
159+
m.log.Debugf("node cert validated: pool=%s, handle=%s", poolUUID, payload.HandleID())
160+
161+
return drpc.Marshal(&auth.ValidateNodeCertResp{Status: 0})
162+
}
163+
164+
// validationStatus maps the security package's sentinel errors to wire statuses.
165+
func validationStatus(err error) daos.Status {
166+
switch {
167+
case errors.Is(err, security.ErrInvalidInput):
168+
return daos.InvalidInput
169+
case errors.Is(err, security.ErrCertInvalid), errors.Is(err, security.ErrCertNotYetValid),
170+
errors.Is(err, security.ErrCertRevoked):
171+
return daos.BadCert
172+
case errors.Is(err, security.ErrPoPInvalid), errors.Is(err, security.ErrPoPStale):
173+
return daos.NoPermission
174+
case errors.Is(err, security.ErrBadWatermarks):
175+
return daos.IOError
176+
default:
177+
return daos.MiscError
178+
}
179+
}
180+
181+
// rejectNodeCert logs and returns a ValidateNodeCertResp carrying status + detail.
182+
func (m *SecurityModule) rejectNodeCert(req *auth.ValidateNodeCertReq, status daos.Status, detail string) ([]byte, error) {
183+
m.log.Errorf("node cert rejected (pool=%x, status=%s): %s",
184+
req.GetPoolUuid(), status, detail)
185+
return drpc.Marshal(&auth.ValidateNodeCertResp{
186+
Status: int32(status),
187+
Detail: detail,
188+
})
189+
}

0 commit comments

Comments
 (0)