Skip to content

Authenticate Scout and DPU-agent with self-signed bearer JWTs - #4373

Closed
wminckler wants to merge 1 commit into
NVIDIA:mainfrom
wminckler:node-auth-token-broker
Closed

Authenticate Scout and DPU-agent with self-signed bearer JWTs#4373
wminckler wants to merge 1 commit into
NVIDIA:mainfrom
wminckler:node-auth-token-broker

Conversation

@wminckler

@wminckler wminckler commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Scout and the DPU-agent authenticate to the API with mTLS client certificates,
which means every process that needs to call the API must hold the machine's
private key. On a DPU that includes co-located DPF services such as fmds, so
the key gets mounted into more containers than strictly need it. Removing the
per-node key entirely is a longer road; this is the step that stops it from
spreading, and starts moving node auth off mTLS.

Nodes now sign short-lived (5 minute) ES256 JWTs with the private key of the
mTLS client certificate they already have, and carry the certificate chain in
the token's x5c header. The API verifies that chain against the same root CA
its TLS listener already trusts for client certs, verifies the signature with
the verified leaf's key, enforces exp/iat/aud plus a bounded lifetime,
and maps the leaf's SPIFFE URI SAN through the same SpiffeContext as mTLS
certs. A JWT and a client cert for the same machine therefore produce a
byte-identical principal, and RBAC is untouched.

This is the simple variant of the design: no server-side signing key, no key
storage, no issuance or refresh RPCs. Clients re-mint locally, and key rotation
rides the existing client-certificate renewal. An earlier server-issued design
is recorded in the design doc as the fallback if per-node keys are ever removed.

Because the token is a bearer credential rather than a channel credential, the
agent can also broker tokens to co-located services over a Unix socket. fmds
can then run without the machine key mounted at all, which is what closes the
key-spreading problem above.

Everything is off by default ([node_auth] enabled = false) and the two
mechanisms run side by side when it is enabled, so rollout order does not
matter: a server with node-auth disabled ignores the bearer header, and a node
that cannot mint yet simply sends no header.

Related issues

Fixes #355

Part of the Vault-elimination epic #195.

Type of Change

  • Add - New feature or capability
  • Change - Changes in existing functionality
  • Fix - Bug fixes
  • Remove - Removed features or deprecated functionality
  • Internal - Internal changes (refactoring, tests, docs, etc.)

Breaking Changes

  • This PR contains breaking changes

Testing

  • Unit tests added/updated
  • Integration tests added/updated
  • Manual testing performed
  • No testing required (docs, internal refactor, etc.)

Chart tests (helm unittest) assert the security property of token mode
directly: the credentials directory is absent from the pod's volumes and from
both containers, with the mount counts pinned so it cannot be reintroduced
unnoticed. Note that CI's helm-validate step runs lint and template only
-- it does not execute chart tests -- so these were run locally via the
helmunittest/helm-unittest image.

Unit tests cover the validator end to end against a test PKI: a client-minted
token round-tripping to its certificate's SPIFFE URI, rejection of garbage,
missing chains, untrusted CAs and over-long lifetimes, a configured audience
round-tripping while the default is refused, client-CA rotation being honored
after a refresh, and a corrupt bundle leaving the previous trust anchors in
place. The authn middleware has tests for bearer principals with and without an
authenticator configured and for mtls_enabled = false suppressing machine
cert principals while leaving service certs alone. There are also tests for the
agent's local API socket (a key-less consumer obtaining a token through it, and
the socket being root-only) and for TLS enforcement surviving a token-only
client config.

Not manually exercised on hardware. The DPF token-mode path in particular
(chart mounts, the agent socket inside a DPU) has only been validated by
rendering the charts and by unit tests.

Additional Notes

Suggested reading order: docs/design/machine-identity/node-auth-jwt.md first —
it covers the trust model, the new-DPU-to-first-authorized-call walkthrough, and
the JWT best-practice checklist — then crates/api-core/src/node_auth.rs for
validation and crates/rpc/src/node_jwt.rs for minting.

Operational notes for reviewers:

  • [node_auth] audience must be kept in sync between the API and its nodes. The
    API templates it onto DPF-deployed agents automatically; Scout and
    non-DPF agents take it from their own config (--node-auth-audience /
    [forge-system] node-auth-audience).
  • The API refuses to start with enabled = false and mtls_enabled = false,
    and refuses to accept bearer tokens on a non-TLS listener.
  • The JWT validator reloads its trust anchors on the TLS listener's existing
    five-minute tick, so a client-CA rotation does not require an API restart.

One known follow-up, deliberately out of scope here:

  • Validating a bearer token costs ~228 us (two P-256 verifications plus DER
    parsing) and runs on every request, where mTLS amortizes the equivalent over
    a long-lived connection. Tokens repeat for their whole 5-minute life, so
    caching successful validations would remove nearly all of it. Filed as Cache validated node-auth JWTs to cut per-request verification cost #4388
    with the measurement and the invalidation constraints.

Two issues raised in review during this PR are fixed rather than deferred: the
root CA is now published to a key-free directory and mounted as a directory (an
earlier subPath mount excluded the key but pinned the inode, so a rotation
never reached a running pod), and the minter now refuses to sign when the
certificate and key on disk disagree, which could otherwise happen mid-renewal
and cache an unusable token.

@wminckler
wminckler requested review from a team and polarweasel as code owners July 30, 2026 13:57
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: de9092db-9ccd-4e4b-a14b-9578f1c99409

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Summary by CodeRabbit

  • New Features
    • Added node-auth bearer JWT support with a configurable JWT audience across CLI, charts, and API.
    • Introduced an on-DPU local node token broker over a Unix socket for co-located services.
    • Added FMDS “node-token mode” to authenticate using JWTs fetched from the local agent socket.
  • Security
    • Added server-side node-auth JWT validation (audience enforcement, bounded lifetimes, cert-chain checks) with safe CA bundle refresh.
    • Hardened local socket and key permissions to owner-only access.
  • Documentation
    • Added node-auth configuration guidance, defaults, and a design overview.
  • Tests
    • Added Helm chart tests covering audience rendering and node-token mode manifest behavior.

Walkthrough

Adds configurable ES256 node-auth JWTs, API-side validation, bearer authentication middleware, a Unix-socket token broker for FMDS, and Helm wiring for DPU-agent and FMDS. Existing mTLS authentication remains configurable, with protected machine-key handling and tests/documentation.

Changes

Node-auth JWT migration

Layer / File(s) Summary
Authentication contracts and configuration
crates/api-core/..., crates/rpc/proto/*, crates/host-support/..., */Cargo.toml, */command_line.rs
Defines node-auth settings, token RPC messages, defaults, CLI options, dependencies, fixtures, and service audience propagation.
JWT minting and client authorization
crates/rpc/src/node_jwt.rs, crates/rpc/src/forge_tls_client.rs, crates/scout/..., crates/agent/src/lib.rs
Mints and caches ES256 JWTs, checks certificate-key alignment, injects bearer tokens, and configures Scout and DPU-agent clients.
Local token broker and socket consumer
crates/agent/src/local_api.rs, crates/rpc/src/node_token_socket.rs, crates/fmds/...
Serves tokens over a protected Unix socket and configures FMDS to refresh and use them without machine-key mounts.
API validation and dual-auth middleware
crates/api-core/src/node_auth.rs, crates/api-core/src/{setup,listener}.rs, crates/authn/src/middleware.rs
Validates JWT chains, signatures, claims, and SPIFFE identities, then combines bearer and mTLS principals.
Credential protection and deployment documentation
crates/host-support/src/registration.rs, bluefield/charts/*, docs/design/..., */config*.toml
Restricts machine-key permissions, adds Helm token-mode behavior and tests, and documents node-auth configuration and deployment behavior.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Scout
  participant ForgeClient
  participant CarbideAPI
  participant AuthMiddleware
  Scout->>ForgeClient: create request
  ForgeClient->>ForgeClient: mint or retrieve ES256 JWT
  ForgeClient->>CarbideAPI: send request with bearer token
  CarbideAPI->>AuthMiddleware: process Authorization header
  AuthMiddleware->>AuthMiddleware: validate x5c chain, claims, and SPIFFE identity
  AuthMiddleware-->>CarbideAPI: attach node principal
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: adding self-signed bearer JWT authentication for Scout and the DPU-agent.
Description check ✅ Passed The description matches the implemented JWT auth, RBAC integration, rollout model, and co-located token broker behavior.
Linked Issues check ✅ Passed The PR delivers JWT-based node auth with the existing RBAC path and optional rollout, aligning with issue #355's stated goal.
Out of Scope Changes check ✅ Passed The changes stay within node-auth, deployment, and test support needed for the JWT migration and do not show unrelated scope.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown

🔐 TruffleHog Secret Scan

No secrets or credentials found!

Your code has been scanned for 700+ types of secrets and credentials. All clear! 🎉

🔗 View scan details

🕐 Last updated: 2026-07-30 14:00:48 UTC | Commit: 58b7b30

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Caution

Failed to replace (edit) comment. This is likely due to insufficient permissions or the comment being deleted.

Error details
putComment timed out

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (5)
crates/api-core/src/node_auth.rs (2)

462-479: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Two security-critical rejection paths remain untested: algorithm confusion and sub mismatch.

RejectReason::Algorithm and RejectReason::SubjectMismatch guard the module's central invariants — that only ES256 is honoured, and that identity derives from the certificate rather than from an attacker-supplied claim. Neither has a test, so a regression that relaxed either would pass CI. Both are cheap to add alongside overlong_lifetime_is_rejected: mint a token whose sub names a different machine than the leaf's URI SAN, and one signed with a non-ES256 header.

As per path instructions for crates/**/*.rs, prefer "findings about behavior, concurrency, resource lifetimes, and missing tests over style-only comments".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/api-core/src/node_auth.rs` around lines 462 - 479, Add focused tests
alongside overlong_lifetime_is_rejected covering both rejection paths: a token
with a sub claim that differs from the leaf certificate’s URI SAN must be
rejected, and a token signed with a non-ES256 algorithm header must also be
rejected. Reuse the existing test_pki, mint_with, and NodeJwtValidator setup,
and assert spiffe_id_from_bearer returns None for each case.

Source: Path instructions


238-248: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider emitting a counted Event for bearer-token rejections, not just a DEBUG log.

Rejections here are the single signal that node auth is failing fleet-wide — precisely the CA-rotation scenario refresh_roots exists to prevent — yet they are observable only at DEBUG. RejectReason is already a bounded enum, making it a natural #[label], and this crate's mTLS path sets the precedent with ClientCertRejected / carbide_authn_client_cert_rejected_total. Keep the token and any error text in #[context].

As per coding guidelines, "declare and emit a carbide_instrument::Event when an event deserves a count, rate, or duration" and "use bounded values (usually enums)" for labels.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/api-core/src/node_auth.rs` around lines 238 - 248, Update
NodeJwtValidator::spiffe_id_from_bearer to declare and emit a counted
carbide_instrument::Event when validate returns Err(reason). Use the bounded
RejectReason as a label, and preserve the bearer token and error text as event
context while retaining the existing None return behavior and debug log.

Source: Coding guidelines

bluefield/charts/nico-fmds/templates/daemonset.yaml (1)

150-160: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Cross-chart certsDir consistency is implicit, not enforced.

nico-agent-run's hostPath ({{ .Values.certsDir }}/run) must resolve to the exact same host path the co-located nico-dpu-agent chart uses for its own certsDir, since the socket is handed off via the shared host filesystem, not a programmatically-threaded value (unlike node_auth.audience, which dpf_services.rs centrally propagates to both). If an operator overrides certsDir on only one of the two charts, the socket path silently diverges.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bluefield/charts/nico-fmds/templates/daemonset.yaml` around lines 150 - 160,
Enforce that the nico-fmds certsDir and co-located nico-dpu-agent certsDir
resolve identically before constructing the nico-agent-run hostPath in the
daemonset template. Reuse the established shared configuration or validation
mechanism to reject mismatched overrides, ensuring the socket is always mounted
from the same host path used by nico-dpu-agent.
crates/api-core/src/dpf_services.rs (1)

614-630: 🧹 Nitpick | 🔵 Trivial

Fleet-wide coupling between node_auth.enabled and fmds's credential mode.

fmds_service(&resolved.base.fmds, node_auth.enabled) ties every fmds DaemonSet's authentication mode directly to the API's [node_auth] enabled flag. The moment an operator flips node_auth.enabled = true on the API, all fmds instances fleet-wide immediately require a dpu-agent build that serves the local token-broker API (per the doc comment above), with no independent, per-rollout knob to stage this migration. If any node in the fleet is still running an older dpu-agent image without the local API, its fmds pod will be unable to fetch bearer tokens as soon as the API-side flag flips.

Worth confirming this is an acceptable trade-off for the planned rollout sequencing (e.g., dpu-agent images upgraded fleet-wide before node_auth.enabled is ever set), since there's no way to decouple the two once node_auth is enabled.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/api-core/src/dpf_services.rs` around lines 614 - 630, Decouple fmds
credential selection from the API-wide node_auth.enabled flag in
mandatory_services. Add or reuse an independent fmds authentication setting and
pass it to fmds_service, while retaining node_auth.enabled for
dpu-agent/node-auth behavior so fmds rollout can be staged independently across
the fleet.
crates/api-core/src/cfg/file.rs (1)

1893-1974: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Consider deny_unknown_fields on NodeAuthConfig.

CertificatesConfig and SecretsConfig in this same file enforce deny_unknown_fields precisely to catch operator typos (see the unknown field rejected.../rejects_misspelled_field tests). NodeAuthConfig gates node authentication entirely — a typo'd key (e.g. mtls_enable instead of mtls_enabled) would silently fall back to the default rather than error, potentially leaving nodes locked out or a site running with an unintended auth posture.

🛡️ Proposed fix
-#[derive(Clone, Debug, Serialize, Deserialize)]
+#[derive(Clone, Debug, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
 pub struct NodeAuthConfig {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/api-core/src/cfg/file.rs` around lines 1893 - 1974, Apply serde’s
deny-unknown-fields attribute to NodeAuthConfig so misspelled or unsupported
node-auth configuration keys fail deserialization instead of silently using
defaults. Preserve the existing fields, defaults, and validate behavior, and add
or update deserialization coverage for rejecting an unknown key such as
mtls_enable.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/agent/src/local_api.rs`:
- Around line 71-98: Update serve to set the socket parent directory permissions
to 0700 immediately after create_dir_all and before UnixListener::bind, while
preserving the existing socket-file permission restriction and error context.
Use the parent path already derived in serve and propagate any set_permissions
failure.

In `@crates/authn/src/middleware.rs`:
- Around line 627-634: Update both tracing::debug! calls in the bearer-token
SPIFFE parsing branches to keep stable message literals and record each error in
the structured error field using Display formatting. Preserve the existing
target and branch-specific messages while replacing direct {e} interpolation.
- Around line 264-268: Update bearer_token_from_headers to parse the
Authorization value with a case-insensitive Bearer scheme and tolerate optional
whitespace between the scheme and token. Preserve returning None for missing,
invalid, or tokenless headers, while continuing to trim surrounding token
whitespace.

In `@docs/design/machine-identity/node-auth-jwt.md`:
- Around line 206-216: Update the component map and “Open question” section to
describe the local token broker as implemented production functionality,
referencing agent `local_api.rs` with `GetNodeToken`, RPC
`node_token_socket.rs`, and FMDS wiring in `cfg.rs`/`main.rs`; remove stale
prototype, branch, and recommended-target wording. Correct the client opt-in row
to distinguish scout’s `with_node_jwt()` from agent’s direct shared
`NodeJwtMinter::with_audience(...)` and `.with_token_provider(...)` setup, and
retain only genuine open items such as the Kubernetes Secret alternative or
otelcol’s raw-key requirement.

In `@helm/charts/nico-api/files/carbide-api-config.toml`:
- Around line 44-45: Remove one of the consecutive [node_auth] table
declarations in helm/charts/nico-api/files/carbide-api-config.toml at lines
44-45 and apply the same removal in
deploy/nico-base/api/config-files/nico-api-config.toml at lines 46-47, leaving
exactly one [node_auth] header in each configuration source.

---

Nitpick comments:
In `@bluefield/charts/nico-fmds/templates/daemonset.yaml`:
- Around line 150-160: Enforce that the nico-fmds certsDir and co-located
nico-dpu-agent certsDir resolve identically before constructing the
nico-agent-run hostPath in the daemonset template. Reuse the established shared
configuration or validation mechanism to reject mismatched overrides, ensuring
the socket is always mounted from the same host path used by nico-dpu-agent.

In `@crates/api-core/src/cfg/file.rs`:
- Around line 1893-1974: Apply serde’s deny-unknown-fields attribute to
NodeAuthConfig so misspelled or unsupported node-auth configuration keys fail
deserialization instead of silently using defaults. Preserve the existing
fields, defaults, and validate behavior, and add or update deserialization
coverage for rejecting an unknown key such as mtls_enable.

In `@crates/api-core/src/dpf_services.rs`:
- Around line 614-630: Decouple fmds credential selection from the API-wide
node_auth.enabled flag in mandatory_services. Add or reuse an independent fmds
authentication setting and pass it to fmds_service, while retaining
node_auth.enabled for dpu-agent/node-auth behavior so fmds rollout can be staged
independently across the fleet.

In `@crates/api-core/src/node_auth.rs`:
- Around line 462-479: Add focused tests alongside overlong_lifetime_is_rejected
covering both rejection paths: a token with a sub claim that differs from the
leaf certificate’s URI SAN must be rejected, and a token signed with a non-ES256
algorithm header must also be rejected. Reuse the existing test_pki, mint_with,
and NodeJwtValidator setup, and assert spiffe_id_from_bearer returns None for
each case.
- Around line 238-248: Update NodeJwtValidator::spiffe_id_from_bearer to declare
and emit a counted carbide_instrument::Event when validate returns Err(reason).
Use the bounded RejectReason as a label, and preserve the bearer token and error
text as event context while retaining the existing None return behavior and
debug log.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 267fd218-4289-4c00-a1d7-fe330e0edc99

📥 Commits

Reviewing files that changed from the base of the PR and between 94c24d2 and 58b7b30.

⛔ Files ignored due to path filters (3)
  • Cargo.lock is excluded by !**/*.lock
  • rest-api/proto/core/gen/v1/agent_local_nico.pb.go is excluded by !**/*.pb.go, !**/gen/**, !rest-api/**/*.pb.go
  • rest-api/proto/core/gen/v1/agent_local_nico_grpc.pb.go is excluded by !**/*.pb.go, !**/gen/**, !rest-api/**/*.pb.go, !rest-api/**/*_grpc.pb.go
📒 Files selected for processing (40)
  • bluefield/charts/nico-dpu-agent/templates/daemonset.yaml
  • bluefield/charts/nico-dpu-agent/values.yaml
  • bluefield/charts/nico-fmds/templates/daemonset.yaml
  • bluefield/charts/nico-fmds/values.yaml
  • crates/agent/Cargo.toml
  • crates/agent/example_agent_config.toml
  • crates/agent/src/command_line.rs
  • crates/agent/src/lib.rs
  • crates/agent/src/local_api.rs
  • crates/agent/src/tests/common/mod.rs
  • crates/api-core/src/api.rs
  • crates/api-core/src/cfg/file.rs
  • crates/api-core/src/dpf_services.rs
  • crates/api-core/src/lib.rs
  • crates/api-core/src/listener.rs
  • crates/api-core/src/node_auth.rs
  • crates/api-core/src/setup.rs
  • crates/api-core/src/test_support/builder.rs
  • crates/api-core/src/test_support/default_config.rs
  • crates/authn/Cargo.toml
  • crates/authn/src/middleware.rs
  • crates/fmds/src/cfg.rs
  • crates/fmds/src/main.rs
  • crates/host-support/src/agent_config.rs
  • crates/host-support/src/registration.rs
  • crates/host-support/test/min_agent_config/output.toml
  • crates/rpc/Cargo.toml
  • crates/rpc/build.rs
  • crates/rpc/proto/agent_local.proto
  • crates/rpc/src/forge_tls_client.rs
  • crates/rpc/src/lib.rs
  • crates/rpc/src/node_jwt.rs
  • crates/rpc/src/node_token_socket.rs
  • crates/rpc/src/protos/mod.rs
  • crates/scout/src/cfg/command_line.rs
  • crates/scout/src/client.rs
  • deploy/nico-base/api/config-files/nico-api-config.toml
  • docs/design/machine-identity/node-auth-jwt.md
  • helm/charts/nico-api/files/carbide-api-config.toml
  • rest-api/proto/core/src/v1/agent_local_nico.proto

Comment thread crates/agent/src/local_api.rs
Comment on lines +264 to +268
/// Extracts the token from an `Authorization: Bearer <token>` header, if present.
fn bearer_token_from_headers(headers: &hyper::HeaderMap) -> Option<&str> {
let value = headers.get(hyper::header::AUTHORIZATION)?.to_str().ok()?;
value.strip_prefix("Bearer ").map(str::trim)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

RFC 6750 Bearer authentication scheme case-insensitive Authorization header

💡 Result:

The "Bearer" authentication scheme used in the HTTP Authorization header is case-insensitive [1][2]. Although RFC 6750 provides the ABNF definition credentials = "Bearer" 1*SP b64token [3][4], which uses a capitalized "Bearer," this does not mandate case sensitivity. According to ABNF specification (RFC 5234), literal strings defined in quotes are case-insensitive [2]. Furthermore, HTTP authentication schemes are fundamentally case-insensitive, as established by the HTTP authentication framework [1][2]. In practice, this has been a source of confusion and interoperability issues, leading some implementations to incorrectly enforce case sensitivity [5][6][1]. To resolve this ambiguity, the upcoming OAuth 2.1 specification explicitly clarifies that the "Bearer" scheme is case-insensitive, confirming that variations such as "bearer", "BEARER", and "bEaReR" are valid [1][7][2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== target file =="
cat -n crates/authn/src/middleware.rs | sed -n '250,285p'

echo
echo "== bearer token call sites =="
rg -n "bearer_token_from_headers|Authorization: Bearer|Bearer " crates -g '!target' -g '!**/node_modules/**'

echo
echo "== node jwt producer referenced in the original comment =="
cat -n crates/rpc/src/node_jwt.rs | sed -n '260,325p'

Repository: NVIDIA/infra-controller

Length of output: 7791


Accept the Bearer scheme case-insensitively

Authorization parsing here is stricter than the HTTP auth-scheme grammar: Bearer is case-insensitive, and the separator may include more than one space. Lowercase or slightly different clients will be dropped silently. Accepting eq_ignore_ascii_case("Bearer") and tolerating optional whitespace would make this interoperable with essentially no complexity.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/authn/src/middleware.rs` around lines 264 - 268, Update
bearer_token_from_headers to parse the Authorization value with a
case-insensitive Bearer scheme and tolerate optional whitespace between the
scheme and token. Preserve returning None for missing, invalid, or tokenless
headers, while continuing to trim surrounding token whitespace.

Comment on lines +627 to +634
Err(e) => {
tracing::debug!(target: "node_auth", "node-auth: bearer token SPIFFE id not recognized: {e}");
}
},
Err(e) => {
tracing::debug!(target: "node_auth", "node-auth: bearer token contained an unparsable SPIFFE URI: {e}");
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Interpolated error text in tracing messages breaks the structured-logging contract.

Both messages embed {e} directly, which destabilises the message literal and prevents the error from being filtered or aggregated as a field. node_auth.rs in this same feature already does it correctly with %reason.

♻️ Move the error into an `error` field
                     Err(e) => {
-                        tracing::debug!(target: "node_auth", "node-auth: bearer token SPIFFE id not recognized: {e}");
+                        tracing::debug!(target: "node_auth", error = %e, "node-auth: bearer token SPIFFE id not recognized");
                     }
                 },
                 Err(e) => {
-                    tracing::debug!(target: "node_auth", "node-auth: bearer token contained an unparsable SPIFFE URI: {e}");
+                    tracing::debug!(target: "node_auth", error = %e, "node-auth: bearer token contained an unparsable SPIFFE URI");
                 }

As per coding guidelines, "Tracing messages must be stable human-readable string literals; put dynamic values in structured fields instead of interpolating them into messages", using % for Display and the documented error field name.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Err(e) => {
tracing::debug!(target: "node_auth", "node-auth: bearer token SPIFFE id not recognized: {e}");
}
},
Err(e) => {
tracing::debug!(target: "node_auth", "node-auth: bearer token contained an unparsable SPIFFE URI: {e}");
}
}
Err(e) => {
tracing::debug!(target: "node_auth", error = %e, "node-auth: bearer token SPIFFE id not recognized");
}
},
Err(e) => {
tracing::debug!(target: "node_auth", error = %e, "node-auth: bearer token contained an unparsable SPIFFE URI");
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/authn/src/middleware.rs` around lines 627 - 634, Update both
tracing::debug! calls in the bearer-token SPIFFE parsing branches to keep stable
message literals and record each error in the structured error field using
Display formatting. Preserve the existing target and branch-specific messages
while replacing direct {e} interpolation.

Source: Coding guidelines

Comment thread docs/design/machine-identity/node-auth-jwt.md
Comment thread helm/charts/nico-api/files/carbide-api-config.toml
@wminckler
wminckler force-pushed the node-auth-token-broker branch from 58b7b30 to ba1791b Compare July 30, 2026 14:28

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
crates/fmds/src/main.rs (1)

83-126: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Silently drops a configured node_token_socket when root_ca is missing.

If an operator sets --node-token-socket / FMDS_NODE_TOKEN_SOCKET without --root-ca, the match falls to the generic _ arm and phone_home is disabled entirely with a generic "no TLS credentials" warning — masking the actual misconfiguration (node-auth was requested but can't work without root_ca to validate the server).

🐛 Proposed fix: surface the specific misconfiguration
     let forge_client_config = match &options.root_ca {
         Some(root_ca) if client_cert.is_some() || options.node_token_socket.is_some() => {
             let mut config = ForgeClientConfig::new(root_ca.clone(), client_cert);
             if let Some(socket) = &options.node_token_socket {
                 ...
             }
             Some(Arc::new(config))
         }
+        None if options.node_token_socket.is_some() => {
+            eyre::bail!(
+                "--node-token-socket was provided without --root-ca; node-auth bearer tokens still require a root CA to validate the API server"
+            )
+        }
         _ => {
             tracing::warn!(
                 "No TLS credentials provided; phone_home to carbide-api will be unavailable"
             );
             None
         }
     };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/fmds/src/main.rs` around lines 83 - 126, Update the
forge_client_config match around options.root_ca and options.node_token_socket
so configuring node-token authentication without root_ca is handled as an
explicit misconfiguration rather than the generic no-credentials path. Emit a
specific warning or error stating that node_token_socket requires root_ca, while
preserving the existing configuration flow when root_ca is present.
crates/rpc/src/node_jwt.rs (1)

142-199: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Blocking disk I/O on every request contradicts NodeTokenProvider's own "never wait on I/O" contract.

NodeTokenProvider::current is documented as needing to be non-blocking (Line 249: current runs on the request path, so implementations must be non-blocking: return a cached token or None, never wait on I/O"), yet NodeJwtMinter::current_with_expiry falls through to mint(), which performs synchronous std::fs::read/read_to_string calls. Since mint() failures aren't negatively cached, a missing/unreadable cert (e.g. before registration completes) causes every outgoing request through BearerAuthService to repeat the blocking read-and-fail cycle instead of backing off.

Consider negative-caching failed mint attempts (short TTL) to bound retry frequency, and/or moving the disk read off the synchronous request path (mirroring the background-refresh design already used by SocketTokenSource).

♻️ Illustrative negative-cache sketch
     pub fn current_with_expiry(&self) -> Option<(String, u64)> {
         let now = unix_now().ok()?;
         if let Ok(guard) = self.cached.read()
             && let Some(cached) = guard.as_ref()
             && cached.expires_at > now + REMINT_MARGIN_SECS
         {
             return Some((cached.token.clone(), cached.expires_at));
         }
+        // Avoid hammering disk on every request while cert/key are
+        // missing/unreadable: skip re-minting until a short backoff elapses.
+        if let Ok(guard) = self.cached.read()
+            && let Some(cached) = guard.as_ref()
+            && cached.last_mint_failure_at.is_some_and(|t| now < t + MINT_RETRY_BACKOFF_SECS)
+        {
+            return None;
+        }
         match self.mint(now) {

Also applies to: 244-255

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/rpc/src/node_jwt.rs` around lines 142 - 199, Update
NodeJwtMinter::current_with_expiry so the request path never performs
synchronous minting or disk I/O. Reuse the existing cached-token state to return
a valid token, otherwise return None while scheduling or relying on background
refresh; if immediate mint retries remain necessary, add a short-lived negative
cache to suppress repeated failures. Preserve the current error logging and
expiry behavior.
♻️ Duplicate comments (1)
crates/agent/src/local_api.rs (1)

71-90: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

TOCTOU window on the socket directory remains unaddressed.

UnixListener::bind() creates the socket entry before set_permissions(0o600) runs on it, and the parent directory itself is never restricted. Anything with traversal access to the shared /opt/forge/run parent can connect during that window. This was already flagged in a prior review pass and the ordering here is unchanged.

🔒 Harden the parent directory before bind
     if let Some(parent) = std::path::Path::new(socket_path).parent() {
         std::fs::create_dir_all(parent)
             .wrap_err(format!("creating socket directory {}", parent.display()))?;
+        {
+            use std::os::unix::fs::PermissionsExt;
+            std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))
+                .wrap_err(format!("restricting socket directory permissions on {}", parent.display()))?;
+        }
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/agent/src/local_api.rs` around lines 71 - 90, Update serve to restrict
the socket parent directory before UnixListener::bind creates the socket, using
restrictive directory permissions and preserving the existing error context.
Keep the stale-socket removal and post-bind socket permission handling, while
ensuring the parent-directory hardening occurs before binding.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/rpc/src/forge_tls_client.rs`:
- Around line 187-195: Update `with_token_provider` to enable TLS certificate
validation as part of setting any bearer-token provider, ensuring the resulting
client cannot retain `DummyTlsVerifier` when token authentication is configured.
Preserve the provider assignment and fluent return behavior, and avoid requiring
callers to separately invoke `require_tls_enforcement()`.

---

Outside diff comments:
In `@crates/fmds/src/main.rs`:
- Around line 83-126: Update the forge_client_config match around
options.root_ca and options.node_token_socket so configuring node-token
authentication without root_ca is handled as an explicit misconfiguration rather
than the generic no-credentials path. Emit a specific warning or error stating
that node_token_socket requires root_ca, while preserving the existing
configuration flow when root_ca is present.

In `@crates/rpc/src/node_jwt.rs`:
- Around line 142-199: Update NodeJwtMinter::current_with_expiry so the request
path never performs synchronous minting or disk I/O. Reuse the existing
cached-token state to return a valid token, otherwise return None while
scheduling or relying on background refresh; if immediate mint retries remain
necessary, add a short-lived negative cache to suppress repeated failures.
Preserve the current error logging and expiry behavior.

---

Duplicate comments:
In `@crates/agent/src/local_api.rs`:
- Around line 71-90: Update serve to restrict the socket parent directory before
UnixListener::bind creates the socket, using restrictive directory permissions
and preserving the existing error context. Keep the stale-socket removal and
post-bind socket permission handling, while ensuring the parent-directory
hardening occurs before binding.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 925618da-9731-40dc-85cf-d1ae4b385f27

📥 Commits

Reviewing files that changed from the base of the PR and between 58b7b30 and ba1791b.

⛔ Files ignored due to path filters (3)
  • Cargo.lock is excluded by !**/*.lock
  • rest-api/proto/core/gen/v1/agent_local_nico.pb.go is excluded by !**/*.pb.go, !**/gen/**, !rest-api/**/*.pb.go
  • rest-api/proto/core/gen/v1/agent_local_nico_grpc.pb.go is excluded by !**/*.pb.go, !**/gen/**, !rest-api/**/*.pb.go, !rest-api/**/*_grpc.pb.go
📒 Files selected for processing (29)
  • bluefield/charts/nico-dpu-agent/templates/daemonset.yaml
  • bluefield/charts/nico-dpu-agent/values.yaml
  • bluefield/charts/nico-fmds/templates/daemonset.yaml
  • bluefield/charts/nico-fmds/values.yaml
  • crates/agent/Cargo.toml
  • crates/agent/example_agent_config.toml
  • crates/agent/src/command_line.rs
  • crates/agent/src/lib.rs
  • crates/agent/src/local_api.rs
  • crates/agent/src/tests/common/mod.rs
  • crates/api-core/src/dpf_services.rs
  • crates/api-core/src/listener.rs
  • crates/api-core/src/node_auth.rs
  • crates/api-core/src/setup.rs
  • crates/authn/src/middleware.rs
  • crates/fmds/src/cfg.rs
  • crates/fmds/src/main.rs
  • crates/host-support/src/agent_config.rs
  • crates/host-support/test/min_agent_config/output.toml
  • crates/rpc/build.rs
  • crates/rpc/proto/agent_local.proto
  • crates/rpc/src/forge_tls_client.rs
  • crates/rpc/src/lib.rs
  • crates/rpc/src/node_jwt.rs
  • crates/rpc/src/node_token_socket.rs
  • crates/rpc/src/protos/mod.rs
  • crates/scout/src/cfg/command_line.rs
  • crates/scout/src/client.rs
  • rest-api/proto/core/src/v1/agent_local_nico.proto
🚧 Files skipped from review as they are similar to previous changes (4)
  • crates/agent/src/tests/common/mod.rs
  • crates/agent/example_agent_config.toml
  • crates/scout/src/cfg/command_line.rs
  • crates/authn/src/middleware.rs

Comment on lines +187 to +195
/// Attaches an explicit node-auth token provider — e.g. a pre-built
/// [`NodeJwtMinter`] the caller also serves through the agent's local
/// API, or a `SocketTokenSource` in a process that holds no key at all.
#[must_use]
pub fn with_token_provider(mut self, provider: Arc<dyn NodeTokenProvider>) -> Self {
self.node_token_provider = Some(provider);
self
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

with_token_provider should enforce TLS validation itself, not rely on callers to remember require_tls_enforcement().

Any bearer-token client implicitly trusts the token as its credential, so the server's certificate must always be validated — never left on DummyTlsVerifier. Today this only holds because crates/fmds/src/main.rs happens to chain .require_tls_enforcement() before .with_token_provider(...); a future caller that skips this pairing would silently get an unauthenticated TLS channel while believing bearer-token auth is protecting the connection. As per coding guidelines, "design APIs to be difficult to misuse and add abstractions only for real requirements."

🔒 Proposed fix: bake enforcement into the setter
     #[must_use]
     pub fn with_token_provider(mut self, provider: Arc<dyn NodeTokenProvider>) -> Self {
         self.node_token_provider = Some(provider);
-        self
+        self.require_tls_enforcement()
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// Attaches an explicit node-auth token provider — e.g. a pre-built
/// [`NodeJwtMinter`] the caller also serves through the agent's local
/// API, or a `SocketTokenSource` in a process that holds no key at all.
#[must_use]
pub fn with_token_provider(mut self, provider: Arc<dyn NodeTokenProvider>) -> Self {
self.node_token_provider = Some(provider);
self
}
/// Attaches an explicit node-auth token provider — e.g. a pre-built
/// [`NodeJwtMinter`] the caller also serves through the agent's local
/// API, or a `SocketTokenSource` in a process that holds no key at all.
#[must_use]
pub fn with_token_provider(mut self, provider: Arc<dyn NodeTokenProvider>) -> Self {
self.node_token_provider = Some(provider);
self.require_tls_enforcement()
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/rpc/src/forge_tls_client.rs` around lines 187 - 195, Update
`with_token_provider` to enable TLS certificate validation as part of setting
any bearer-token provider, ensuring the resulting client cannot retain
`DummyTlsVerifier` when token authentication is configured. Preserve the
provider assignment and fluent return behavior, and avoid requiring callers to
separately invoke `require_tls_enforcement()`.

Source: Coding guidelines

@wminckler
wminckler force-pushed the node-auth-token-broker branch from ba1791b to d911e7b Compare July 30, 2026 15:22

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/api-core/src/dpf_services.rs`:
- Around line 664-668: Update the test invoking dpu_agent_helm_values to pass a
non-default node-auth audience and assert that the rendered Helm values contain
the same value at nodeAuth.audience, ensuring the argument is emitted under the
correct key.

In `@crates/host-support/src/agent_config.rs`:
- Around line 127-131: Validate node_auth_audience during AgentConfig::load_from
so whitespace-only or blank TOML values are rejected, matching the existing
non-blank validation used for the CLI flag. Apply the check to the deserialized
configuration before it can mint JWTs, while preserving
default_node_auth_audience behavior for omitted values.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 7a4e1b90-3a41-49d3-ad05-1fafae16dcf1

📥 Commits

Reviewing files that changed from the base of the PR and between ba1791b and d911e7b.

📒 Files selected for processing (17)
  • bluefield/charts/nico-dpu-agent/templates/daemonset.yaml
  • bluefield/charts/nico-dpu-agent/values.yaml
  • bluefield/charts/nico-fmds/templates/daemonset.yaml
  • bluefield/charts/nico-fmds/values.yaml
  • crates/agent/src/command_line.rs
  • crates/agent/src/lib.rs
  • crates/agent/src/tests/common/mod.rs
  • crates/api-core/src/dpf_services.rs
  • crates/api-core/src/listener.rs
  • crates/api-core/src/node_auth.rs
  • crates/api-core/src/setup.rs
  • crates/host-support/src/agent_config.rs
  • crates/host-support/test/min_agent_config/output.toml
  • crates/rpc/src/forge_tls_client.rs
  • crates/rpc/src/node_jwt.rs
  • crates/scout/src/cfg/command_line.rs
  • crates/scout/src/client.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • bluefield/charts/nico-fmds/values.yaml

Comment on lines +664 to +668
dpu_agent_helm_values(
&default_dpu_agent_service(),
&policy,
::rpc::node_jwt::NODE_JWT_AUDIENCE,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert the rendered node-auth audience.

This test passes the new argument but never verifies nodeAuth.audience; a dropped or misspelled Helm key would still pass. Add an assertion using a non-default audience.

Proposed test
+#[test]
+fn dpu_agent_helm_values_include_node_auth_audience() {
+    let audience = "site-specific-api";
+    let values = dpu_agent_helm_values(
+        &default_dpu_agent_service(),
+        &DpfDpuAgentBootstrapCa::default(),
+        audience,
+    );
+
+    assert_eq!(values["nodeAuth"]["audience"], audience);
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
dpu_agent_helm_values(
&default_dpu_agent_service(),
&policy,
::rpc::node_jwt::NODE_JWT_AUDIENCE,
)
#[test]
fn dpu_agent_helm_values_include_node_auth_audience() {
let audience = "site-specific-api";
let values = dpu_agent_helm_values(
&default_dpu_agent_service(),
&DpfDpuAgentBootstrapCa::default(),
audience,
);
assert_eq!(values["nodeAuth"]["audience"], audience);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/api-core/src/dpf_services.rs` around lines 664 - 668, Update the test
invoking dpu_agent_helm_values to pass a non-default node-auth audience and
assert that the rendered Helm values contain the same value at
nodeAuth.audience, ensuring the argument is emitted under the correct key.

Comment on lines +127 to +131
/// `aud` stamped on node-auth bearer JWTs (issue #355). Must match the
/// API's `[node_auth] audience`; a site that changes one must change the
/// other, or the API rejects every token this node mints.
#[serde(default = "default_node_auth_audience")]
pub node_auth_audience: String,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -C3 'node_auth_audience|ForgeSystemConfig|AgentConfig' crates/host-support crates/agent

Repository: NVIDIA/infra-controller

Length of output: 19366


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,220p' crates/host-support/src/agent_config.rs
printf '\n---\n'
sed -n '1,220p' crates/agent/src/command_line.rs
printf '\n---\n'
sed -n '280,380p' crates/agent/src/lib.rs
printf '\n---\n'
rg -n 'non_blank_audience|trim\(\)\.is_empty|node_auth_audience|load_from\(' crates/host-support crates/agent

Repository: NVIDIA/infra-controller

Length of output: 21440


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '300,340p' crates/host-support/src/agent_config.rs
printf '\n---\n'
rg -n -C2 'struct NodeJwtMinter|with_audience|audience' crates -g '!target'

Repository: NVIDIA/infra-controller

Length of output: 50379


Validate node_auth_audience on config load. AgentConfig::load_from accepts whitespace-only TOML values here, so a malformed file can still mint JWTs the API rejects. Apply the same non-blank check used by the CLI flag, or make this a validated type.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/host-support/src/agent_config.rs` around lines 127 - 131, Validate
node_auth_audience during AgentConfig::load_from so whitespace-only or blank
TOML values are rejected, matching the existing non-blank validation used for
the CLI flag. Apply the check to the deserialized configuration before it can
mint JWTs, while preserving default_node_auth_audience behavior for omitted
values.

@wminckler
wminckler force-pushed the node-auth-token-broker branch from d911e7b to 11f4720 Compare July 30, 2026 16:16

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (3)
helm/charts/nico-api/files/carbide-api-config.toml (1)

44-61: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Duplicate [node_auth] table header still present.

Two consecutive [node_auth] headers remain at the top of this block. TOML rejects redefining a table, so this can prevent the API config from parsing at all.

🛠️ Proposed fix
 [node_auth]
-[node_auth]
 # Node (Scout / DPU-agent) authentication to the API.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@helm/charts/nico-api/files/carbide-api-config.toml` around lines 44 - 61,
Remove the duplicate [node_auth] table header in the node authentication
configuration, leaving exactly one header for the settings beginning with
enabled and mtls_enabled so the TOML remains valid.
crates/rpc/src/forge_tls_client.rs (1)

191-195: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

with_token_provider still doesn't enforce TLS validation itself.

Unchanged from the prior review: a bearer-token client implicitly trusts the token as its credential, so the server certificate must always be validated, never left on DummyTlsVerifier. Today this only holds because crates/fmds/src/main.rs happens to chain .require_tls_enforcement() before .with_token_provider(...); a future caller that skips this pairing silently gets an unauthenticated TLS channel while believing bearer-token auth protects it.

🔒 Proposed fix
     #[must_use]
     pub fn with_token_provider(mut self, provider: Arc<dyn NodeTokenProvider>) -> Self {
         self.node_token_provider = Some(provider);
-        self
+        self.require_tls_enforcement()
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/rpc/src/forge_tls_client.rs` around lines 191 - 195, Update
ForgeTlsClientBuilder::with_token_provider to enforce TLS certificate validation
whenever a token provider is configured, rather than relying on callers to chain
require_tls_enforcement separately. Preserve the existing provider assignment
and builder return behavior, and ensure the method cannot leave DummyTlsVerifier
active for bearer-token clients.
crates/host-support/src/agent_config.rs (1)

121-131: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Still missing non-blank validation for node_auth_audience.

node_auth_audience deserializes straight from TOML with no non-blank check, so a whitespace-only value in a config file mints tokens the API's [node_auth] audience will reject outright, rather than failing fast with a clear config error.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/host-support/src/agent_config.rs` around lines 121 - 131, The
AgentConfig deserialization path must reject whitespace-only values for
node_auth_audience instead of accepting them. Add non-blank validation to the
node_auth_audience field’s existing TOML/config parsing flow, trimming or
checking whitespace as appropriate while preserving valid audience values and
returning a clear configuration error.
🧹 Nitpick comments (11)
crates/host-support/src/registration.rs (1)

349-351: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Consider sync_all() instead of flush() for the persisted key. flush() on a tokio::fs::File does not push data to stable storage; a power loss right after registration could leave a truncated key file while the certificate is already written, requiring manual recovery.

♻️ Suggested change
     file.write_all(key).await?;
-    file.flush().await
+    file.sync_all().await
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/host-support/src/registration.rs` around lines 349 - 351, Update the
persisted-key write flow containing file.write_all and file.flush to call
sync_all instead of flush, ensuring the key data is committed to stable storage
before registration completes.
bluefield/charts/nico-fmds/tests/node_tokens_test.yaml (1)

133-156: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider asserting the mTLS arguments in the default case. The default-mode test verifies the absence of token plumbing but not that --client-cert/--client-key are still rendered, which is the regression that would actually break existing deployments.

♻️ Suggested additional assertions
       - notContains:
           path: spec.template.spec.containers[0].args
           content: --node-token-socket=/opt/forge/run/agent.sock
+      - contains:
+          path: spec.template.spec.containers[0].args
+          content: --client-cert=/opt/forge/machine_cert.pem
+      - contains:
+          path: spec.template.spec.containers[0].args
+          content: --client-key=/opt/forge/machine_cert.key
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bluefield/charts/nico-fmds/tests/node_tokens_test.yaml` around lines 133 -
156, Extend the default-mode test “should keep the credentials mount and no pub
volume by default” to assert that the rendered container arguments still include
the mTLS `--client-cert` and `--client-key` options, using their expected
credential paths. Preserve the existing assertions that token-mode plumbing is
absent.
crates/agent/src/local_api.rs (1)

74-76: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Consider a relative-path edge case in the parent-directory handling. For a bare socket name (e.g. agent.sock), Path::parent() yields Some("") and create_dir_all("") fails with ENOENT, so serve would fail permanently in the agent's retry loop. Filtering empty parents keeps a dev/relative configuration usable.

♻️ Suggested guard
-    if let Some(parent) = std::path::Path::new(socket_path).parent() {
+    if let Some(parent) = std::path::Path::new(socket_path)
+        .parent()
+        .filter(|p| !p.as_os_str().is_empty())
+    {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/agent/src/local_api.rs` around lines 74 - 76, Update the
parent-directory handling around Path::new(socket_path).parent() so an empty
parent for a bare relative socket name is skipped, while non-empty parents still
pass to create_dir_all and retain the existing error context. Preserve the serve
retry behavior and directory creation for paths that specify a parent.
crates/rpc/src/node_token_socket.rs (1)

159-168: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Persistent token-fetch failure is only visible at debug level. In token mode the bearer token is the sole client credential, so an unreachable agent socket silently degrades every request to unauthenticated. Consider escalating to warn after a few consecutive failures (or logging the first failure at warn) so a misconfigured deployment is diagnosable at default log levels.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/rpc/src/node_token_socket.rs` around lines 159 - 168, Escalate
persistent token-fetch failures in the retry handling of the token-fetch loop
around the Err(status) branch to warn-level logging, while retaining the
existing retry behavior and context fields. Log the first failure or failures
after a defined consecutive-failure threshold at warn so unreachable agent
sockets are visible at default log levels.
bluefield/charts/nico-dpu-agent/templates/daemonset.yaml (1)

134-139: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Nil-safety: .Values.nodeAuth.audience panics if nodeAuth is explicitly nulled. The template already guards bootstrapCa with default (dict) at line 1; applying the same treatment here keeps rendering robust against --set nodeAuth=null or a values file that omits the map.

♻️ Suggested guard
-            {{- with .Values.nodeAuth.audience }}
+            {{- with get (default (dict) .Values.nodeAuth) "audience" }}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bluefield/charts/nico-dpu-agent/templates/daemonset.yaml` around lines 134 -
139, Update the audience block in the DaemonSet template to access nodeAuth
through a default empty dictionary, matching the existing bootstrapCa nil-safety
pattern. Keep the current with guard and node-auth-audience argument behavior
unchanged when nodeAuth.audience is configured.
bluefield/charts/nico-dpu-agent/tests/node_auth_audience_test.yaml (1)

29-34: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

notContains is an exact-element match, so it only proves the empty-valued flag is absent. The intent — "no --node-auth-audience argument at all" — is currently carried by the lengthEqual: 5 assertion, which will drift the next time an unrelated argument is added. Consider asserting the rendered args explicitly (equal on the list) instead of relying on a count.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bluefield/charts/nico-dpu-agent/tests/node_auth_audience_test.yaml` around
lines 29 - 34, The no-audience test should assert the complete rendered
container argument list instead of relying on lengthEqual: 5, which can pass or
fail due to unrelated arguments. Update the assertion near the existing
notContains check in the node-auth audience test to use an explicit list
equality that contains no --node-auth-audience argument while preserving the
expected remaining arguments.
bluefield/charts/nico-fmds/templates/daemonset.yaml (1)

131-132: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Redundant variable re-declaration; $rootCa is unused here. $dir and $rootCa are already bound earlier in this template body, and the volumeMounts block only needs $dir.

♻️ Suggested cleanup
-            {{- $dir := .Values.certsDir | default "/opt/nico" }}
-            {{- $rootCa := .Values.rootCaFile | default "nico_root.pem" }}
             {{- if .Values.useNodeTokens }}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bluefield/charts/nico-fmds/templates/daemonset.yaml` around lines 131 - 132,
Remove the redundant `$rootCa` declaration from the volumeMounts block in the
daemonset template, since it is already defined earlier and unused there. Retain
the local `$dir` declaration because the block uses it.
crates/api-core/src/cfg/file.rs (2)

1893-1913: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Consider deny_unknown_fields on NodeAuthConfig.

Unlike CertificatesConfig/SecretsConfig in this same file, NodeAuthConfig doesn't reject unknown TOML keys. Since every field here has a default, a typo (e.g., enabld = true) silently falls back to the default rather than erroring — for a security-relevant auth toggle, that's a quiet downgrade rather than a loud failure.

🛡️ Proposed fix
 #[derive(Clone, Debug, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
 pub struct NodeAuthConfig {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/api-core/src/cfg/file.rs` around lines 1893 - 1913, Add serde’s
deny_unknown_fields attribute to NodeAuthConfig, matching the strict
deserialization behavior of CertificatesConfig and SecretsConfig, so misspelled
or unsupported authentication settings fail configuration parsing instead of
silently using defaults.

3573-3593: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add edge-case coverage for validate().

The current test only covers the enabled/mtls_enabled lockout combinations. validate() also branches on empty audience, max_token_ttl_sec == 0, and max_token_ttl_sec > NODE_AUTH_MAX_TOKEN_TTL_SEC — none of which are exercised.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/api-core/src/cfg/file.rs` around lines 3573 - 3593, Extend
node_auth_rejects_all_methods_disabled to cover validate() branches for an empty
audience, max_token_ttl_sec equal to zero, and max_token_ttl_sec above
NODE_AUTH_MAX_TOKEN_TTL_SEC. Assert each configuration’s expected validation
result while preserving the existing authentication-method combination checks.
crates/rpc/src/node_jwt.rs (1)

240-268: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Doc comment misattached — ec_encoding_key is now undocumented.

The block at lines 240-242 ("Builds an ES256 signing key from the client key PEM...") describes ec_encoding_key (line 268) but is attached to key_matches_certificate (line 249) instead, since there's no blank-line break. As written, key_matches_certificate's rustdoc opens with an unrelated sentence about signing keys, and ec_encoding_key ships with no doc at all.

📝 Proposed fix
-/// Builds an ES256 signing key from the client key PEM. Vault-issued machine
-/// keys are SEC1 (`BEGIN EC PRIVATE KEY`), which `jsonwebtoken` cannot load
-/// directly, so those are re-encoded to PKCS#8 first.
-/// Whether `key_pem`'s public half is the one certified by `leaf_der`.
+/// Whether `key_pem`'s public half is the one certified by `leaf_der`.
 ///
 /// Both encodings the node may hold are accepted: Vault issues SEC1 ("EC
 /// PRIVATE KEY") and renewal may leave PKCS#8. The comparison is on the
 /// uncompressed SEC1 point, which is exactly what an EC `SubjectPublicKeyInfo`
 /// carries.
 fn key_matches_certificate(key_pem: &str, leaf_der: &[u8]) -> Result<bool, NodeJwtError> {
     ...
 }

+/// Builds an ES256 signing key from the client key PEM. Vault-issued machine
+/// keys are SEC1 (`BEGIN EC PRIVATE KEY`), which `jsonwebtoken` cannot load
+/// directly, so those are re-encoded to PKCS#8 first.
 fn ec_encoding_key(key_pem: &str) -> Result<EncodingKey, NodeJwtError> {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/rpc/src/node_jwt.rs` around lines 240 - 268, Move the opening
signing-key Rustdoc from key_matches_certificate to ec_encoding_key, leaving
key_matches_certificate documented only by the text describing certificate-key
matching. Ensure ec_encoding_key has the full relevant documentation, including
the SEC1 and PKCS#8 encoding details.
crates/agent/src/lib.rs (1)

389-405: 🩺 Stability & Availability | 🔵 Trivial | 🏗️ Heavy lift

Own and cancel the local API task.

This detached, infinite token-broker loop has no cancellation or join path during agent shutdown. Keep its handle in the service lifecycle, cancel it explicitly, and await it before exit so the socket server is shut down deterministically.

As per coding guidelines, “Join spawned background tasks through JoinHandle or JoinSet” and “Use RAII cancellation for client-facing background services.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/agent/src/lib.rs` around lines 389 - 405, The local API server task
spawned in the agent startup lifecycle is detached and cannot be stopped during
shutdown. Store the tokio::JoinHandle returned by the spawn alongside the
service tasks, explicitly abort or cancel it during agent shutdown, and await
its completion before exiting; preserve the existing retry loop and
local_api::serve behavior.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@crates/host-support/src/agent_config.rs`:
- Around line 121-131: The AgentConfig deserialization path must reject
whitespace-only values for node_auth_audience instead of accepting them. Add
non-blank validation to the node_auth_audience field’s existing TOML/config
parsing flow, trimming or checking whitespace as appropriate while preserving
valid audience values and returning a clear configuration error.

In `@crates/rpc/src/forge_tls_client.rs`:
- Around line 191-195: Update ForgeTlsClientBuilder::with_token_provider to
enforce TLS certificate validation whenever a token provider is configured,
rather than relying on callers to chain require_tls_enforcement separately.
Preserve the existing provider assignment and builder return behavior, and
ensure the method cannot leave DummyTlsVerifier active for bearer-token clients.

In `@helm/charts/nico-api/files/carbide-api-config.toml`:
- Around line 44-61: Remove the duplicate [node_auth] table header in the node
authentication configuration, leaving exactly one header for the settings
beginning with enabled and mtls_enabled so the TOML remains valid.

---

Nitpick comments:
In `@bluefield/charts/nico-dpu-agent/templates/daemonset.yaml`:
- Around line 134-139: Update the audience block in the DaemonSet template to
access nodeAuth through a default empty dictionary, matching the existing
bootstrapCa nil-safety pattern. Keep the current with guard and
node-auth-audience argument behavior unchanged when nodeAuth.audience is
configured.

In `@bluefield/charts/nico-dpu-agent/tests/node_auth_audience_test.yaml`:
- Around line 29-34: The no-audience test should assert the complete rendered
container argument list instead of relying on lengthEqual: 5, which can pass or
fail due to unrelated arguments. Update the assertion near the existing
notContains check in the node-auth audience test to use an explicit list
equality that contains no --node-auth-audience argument while preserving the
expected remaining arguments.

In `@bluefield/charts/nico-fmds/templates/daemonset.yaml`:
- Around line 131-132: Remove the redundant `$rootCa` declaration from the
volumeMounts block in the daemonset template, since it is already defined
earlier and unused there. Retain the local `$dir` declaration because the block
uses it.

In `@bluefield/charts/nico-fmds/tests/node_tokens_test.yaml`:
- Around line 133-156: Extend the default-mode test “should keep the credentials
mount and no pub volume by default” to assert that the rendered container
arguments still include the mTLS `--client-cert` and `--client-key` options,
using their expected credential paths. Preserve the existing assertions that
token-mode plumbing is absent.

In `@crates/agent/src/lib.rs`:
- Around line 389-405: The local API server task spawned in the agent startup
lifecycle is detached and cannot be stopped during shutdown. Store the
tokio::JoinHandle returned by the spawn alongside the service tasks, explicitly
abort or cancel it during agent shutdown, and await its completion before
exiting; preserve the existing retry loop and local_api::serve behavior.

In `@crates/agent/src/local_api.rs`:
- Around line 74-76: Update the parent-directory handling around
Path::new(socket_path).parent() so an empty parent for a bare relative socket
name is skipped, while non-empty parents still pass to create_dir_all and retain
the existing error context. Preserve the serve retry behavior and directory
creation for paths that specify a parent.

In `@crates/api-core/src/cfg/file.rs`:
- Around line 1893-1913: Add serde’s deny_unknown_fields attribute to
NodeAuthConfig, matching the strict deserialization behavior of
CertificatesConfig and SecretsConfig, so misspelled or unsupported
authentication settings fail configuration parsing instead of silently using
defaults.
- Around line 3573-3593: Extend node_auth_rejects_all_methods_disabled to cover
validate() branches for an empty audience, max_token_ttl_sec equal to zero, and
max_token_ttl_sec above NODE_AUTH_MAX_TOKEN_TTL_SEC. Assert each configuration’s
expected validation result while preserving the existing authentication-method
combination checks.

In `@crates/host-support/src/registration.rs`:
- Around line 349-351: Update the persisted-key write flow containing
file.write_all and file.flush to call sync_all instead of flush, ensuring the
key data is committed to stable storage before registration completes.

In `@crates/rpc/src/node_jwt.rs`:
- Around line 240-268: Move the opening signing-key Rustdoc from
key_matches_certificate to ec_encoding_key, leaving key_matches_certificate
documented only by the text describing certificate-key matching. Ensure
ec_encoding_key has the full relevant documentation, including the SEC1 and
PKCS#8 encoding details.

In `@crates/rpc/src/node_token_socket.rs`:
- Around line 159-168: Escalate persistent token-fetch failures in the retry
handling of the token-fetch loop around the Err(status) branch to warn-level
logging, while retaining the existing retry behavior and context fields. Log the
first failure or failures after a defined consecutive-failure threshold at warn
so unreachable agent sockets are visible at default log levels.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 61e22f6b-38e5-4b84-b45b-69cd5a0868d9

📥 Commits

Reviewing files that changed from the base of the PR and between d911e7b and 11f4720.

⛔ Files ignored due to path filters (3)
  • Cargo.lock is excluded by !**/*.lock
  • rest-api/proto/core/gen/v1/agent_local_nico.pb.go is excluded by !**/*.pb.go, !**/gen/**, !rest-api/**/*.pb.go
  • rest-api/proto/core/gen/v1/agent_local_nico_grpc.pb.go is excluded by !**/*.pb.go, !**/gen/**, !rest-api/**/*.pb.go, !rest-api/**/*_grpc.pb.go
📒 Files selected for processing (42)
  • bluefield/charts/nico-dpu-agent/templates/daemonset.yaml
  • bluefield/charts/nico-dpu-agent/tests/node_auth_audience_test.yaml
  • bluefield/charts/nico-dpu-agent/values.yaml
  • bluefield/charts/nico-fmds/templates/daemonset.yaml
  • bluefield/charts/nico-fmds/tests/node_tokens_test.yaml
  • bluefield/charts/nico-fmds/values.yaml
  • crates/agent/Cargo.toml
  • crates/agent/example_agent_config.toml
  • crates/agent/src/command_line.rs
  • crates/agent/src/lib.rs
  • crates/agent/src/local_api.rs
  • crates/agent/src/tests/common/mod.rs
  • crates/api-core/src/api.rs
  • crates/api-core/src/cfg/file.rs
  • crates/api-core/src/dpf_services.rs
  • crates/api-core/src/lib.rs
  • crates/api-core/src/listener.rs
  • crates/api-core/src/node_auth.rs
  • crates/api-core/src/setup.rs
  • crates/api-core/src/test_support/builder.rs
  • crates/api-core/src/test_support/default_config.rs
  • crates/authn/Cargo.toml
  • crates/authn/src/middleware.rs
  • crates/fmds/src/cfg.rs
  • crates/fmds/src/main.rs
  • crates/host-support/src/agent_config.rs
  • crates/host-support/src/registration.rs
  • crates/host-support/test/min_agent_config/output.toml
  • crates/rpc/Cargo.toml
  • crates/rpc/build.rs
  • crates/rpc/proto/agent_local.proto
  • crates/rpc/src/forge_tls_client.rs
  • crates/rpc/src/lib.rs
  • crates/rpc/src/node_jwt.rs
  • crates/rpc/src/node_token_socket.rs
  • crates/rpc/src/protos/mod.rs
  • crates/scout/src/cfg/command_line.rs
  • crates/scout/src/client.rs
  • deploy/nico-base/api/config-files/nico-api-config.toml
  • docs/design/machine-identity/node-auth-jwt.md
  • helm/charts/nico-api/files/carbide-api-config.toml
  • rest-api/proto/core/src/v1/agent_local_nico.proto
🚧 Files skipped from review as they are similar to previous changes (6)
  • crates/host-support/test/min_agent_config/output.toml
  • deploy/nico-base/api/config-files/nico-api-config.toml
  • crates/agent/example_agent_config.toml
  • crates/api-core/src/lib.rs
  • crates/rpc/Cargo.toml
  • docs/design/machine-identity/node-auth-jwt.md

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (11)
crates/host-support/src/registration.rs (1)

339-351: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value

Consider sync_all() for the key.

The create-with-mode then tighten-then-write ordering is right: the truncation window exposes only an empty file, and the secret bytes land after the chmod. One optional hardening — flush() reaches the OS but not the platter, so an ungraceful reboot between the cert write and the key write can leave a valid certificate beside a truncated key. sync_all() before returning would make the pair recoverable without re-registration. Entirely optional, since the surrounding cert write makes the same trade-off.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/host-support/src/registration.rs` around lines 339 - 351, Optionally
harden the key-write path by calling sync_all() on the opened file after
write_all() and before returning from the surrounding function, while retaining
the existing flush and permission ordering.
bluefield/charts/nico-fmds/tests/node_tokens_test.yaml (2)

113-156: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Optional: hoist the repeated set block to suite level.

image.repository/image.tag are set identically in all six cases. helm-unittest supports a suite-level set:, which would leave each case setting only the values it actually varies (useNodeTokens, certsDir, rootCaFile) and make the intent of each case read at a glance.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bluefield/charts/nico-fmds/tests/node_tokens_test.yaml` around lines 113 -
156, Hoist the repeated image.repository and image.tag values from the six test
cases into a suite-level set block in the node token tests. Remove those
duplicate image settings from each case, leaving only scenario-specific values
such as useNodeTokens, certsDir, and rootCaFile.

60-66: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the nico-agent-run volume, not just its mount.

Lines 52-56 verify the mount and lines 60-66 verify the nico-certs-pub volume, but nothing asserts that nico-agent-run is actually defined in spec.template.spec.volumes. A template edit that dropped the volume while keeping the mount yields a Pod spec the API server rejects, and this suite would still pass.

💚 Close the gap
       - contains:
           path: spec.template.spec.volumes
           content:
             name: nico-certs-pub
             hostPath:
               path: /opt/forge/pub
               type: DirectoryOrCreate
+      - contains:
+          path: spec.template.spec.volumes
+          content:
+            name: nico-agent-run
+            hostPath:
+              path: /opt/forge/run
+              type: DirectoryOrCreate
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bluefield/charts/nico-fmds/tests/node_tokens_test.yaml` around lines 60 - 66,
Add a `contains` assertion in the node token test for
`spec.template.spec.volumes` that verifies the `nico-agent-run` volume
definition, alongside the existing `nico-certs-pub` assertion. Match the
expected volume fields from the chart template so the test detects a mount
without its corresponding volume.
bluefield/charts/nico-dpu-agent/templates/daemonset.yaml (1)

134-134: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Guard against a nil nodeAuth.

.Values.nodeAuth.audience errors outright if an operator sets nodeAuth: null (a common way to "unset" a block in an overlay). This chart already defends against exactly that for bootstrapCa on line 1 via default (dict); mirroring it here costs nothing.

♻️ Defensive dereference
-            {{- with .Values.nodeAuth.audience }}
+            {{- with (get (default (dict) .Values.nodeAuth) "audience") }}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bluefield/charts/nico-dpu-agent/templates/daemonset.yaml` at line 134, Guard
the nodeAuth lookup in the audience block by applying the chart’s existing
default-dictionary pattern before accessing audience, so nodeAuth: null safely
behaves as an unset block. Update the with expression around
.Values.nodeAuth.audience while preserving the current audience rendering for
configured values.
crates/rpc/src/node_token_socket.rs (1)

224-281: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider covering the refresh transition, not just the steady states.

The four cases assert first-fetch, absent-socket, redaction, and margin rejection, but the re-fetch arithmetic in refresh_loop (lines 149-157) — the part that decides when a cached token is replaced — has no coverage. A server whose expires_at advances between calls would exercise it directly and guard the saturating_sub / .max(RETRY_DELAY) boundary.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/rpc/src/node_token_socket.rs` around lines 224 - 281, The tests cover
initial fetch and expiration handling but not token replacement during refresh.
Add a test near the existing SocketTokenSource tests that serves a token with an
advancing expires_at, waits for the refresh loop to replace the cached value,
and asserts the newer token is returned; exercise the refresh_loop scheduling
path including the saturating_sub and RETRY_DELAY minimum boundary.
crates/api-core/src/node_auth.rs (2)

238-248: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Negative node-auth outcomes are DEBUG-only across both new decision points. The pre-existing mTLS path emits a counted ClientCertRejected Event, but the two paths this PR adds — a bearer token failing validation, and a machine cert being gated away by mtls_enabled = false — leave nothing but a debug line. During the mTLS→JWT cutover these are precisely the signals distinguishing "migration proceeding" from "fleet locked out", and both stem from one missing instrumentation decision.

  • crates/api-core/src/node_auth.rs#L238-L248: emit a carbide_instrument::Event alongside the debug log, with RejectReason as a bounded #[label] and the error text in #[context].
  • crates/authn/src/middleware.rs#L639-L666: count machine principals dropped by the gate, so a cutover with nodes still presenting certs is observable rather than inferred from DEBUG output.

As per coding guidelines, "declare and emit a carbide_instrument::Event when an event deserves a count, rate, or duration", keeping #[label] values bounded and high-cardinality detail in #[context].

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/api-core/src/node_auth.rs` around lines 238 - 248, In
crates/api-core/src/node_auth.rs lines 238-248, update
NodeJwtValidator::spiffe_id_from_bearer to emit a carbide_instrument::Event when
validation fails, using a bounded #[label] for RejectReason and placing the
error text in #[context] alongside the existing debug log. In
crates/authn/src/middleware.rs lines 639-666, add event instrumentation for
machine principals rejected because mtls_enabled is false, using the same
bounded-label and contextual-detail conventions.

Source: Coding guidelines


412-479: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

The sub-mismatch branch is untested.

SubjectMismatch (line 231-233) is what stops a node from claiming another machine's identity through an attacker-controlled claim — the single most consequential branch in validate, and currently unexercised. A token minted from cert A but carrying cert B's SPIFFE URI in sub would pin it. An expired-token case would likewise close out the exp path, which today is only covered indirectly through the TTL cap.

Given these cases share one operation with differing inputs, a table would fit: as per coding guidelines, "use tables whenever multiple tests call the same operation with different inputs".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/api-core/src/node_auth.rs` around lines 412 - 479, Extend the token
validation tests around spiffe_id_from_bearer to cover mismatched and expired
claims: mint a token with one certificate while setting sub to another
certificate’s SPIFFE URI and assert rejection, and create an otherwise valid
token with an expired exp claim and assert rejection. Consolidate these cases
with the existing same-operation tests using a table-driven test structure where
practical, while preserving the current trusted, untrusted-CA, malformed, and
lifetime-cap coverage.

Source: Coding guidelines

crates/agent/src/local_api.rs (1)

156-162: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider extracting the socket-readiness poll loop.

The same bounded wait appears three times with only the predicate differing. A small async fn wait_until(mut ready: impl FnMut() -> bool) (or a wait_for_socket(&Path) helper) would remove the repetition and keep the timing policy in one place.

Also applies to: 178-183, 213-218

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/agent/src/local_api.rs` around lines 156 - 162, Extract the repeated
bounded readiness polling loops around the current source and the corresponding
sections into one async helper, such as wait_until or wait_for_socket, that
accepts the differing readiness predicate. Centralize the 100-attempt loop and
20ms delay in that helper, then replace all three duplicated loops while
preserving their existing success and timeout behavior.
crates/authn/src/middleware.rs (1)

1102-1110: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider a table for the header-parsing cases.

Three inputs against one total function is the textbook value_scenarios! shape, and it would make adding the lowercase-scheme and extra-whitespace cases a one-line change each.

As per coding guidelines, use "value_scenarios! for total operations" from the carbide-test-support table-driven helpers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/authn/src/middleware.rs` around lines 1102 - 1110, The
bearer_token_from_headers_parses_scheme test currently repeats setup and
assertions for each input; convert it to a carbide-test-support value_scenarios!
table for this total operation. Define scenarios covering absent authorization,
valid Bearer, and non-Bearer headers, while preserving the expected outputs and
making future lowercase-scheme or whitespace cases easy to add.

Source: Coding guidelines

bluefield/charts/nico-fmds/templates/daemonset.yaml (1)

91-98: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Source the node-token socket path from one value. nico-fmds hardcodes {{ $dir }}/run/agent.sock, while the agent already exposes local_api_socket; wiring both to a shared value avoids drift if the bind path is overridden in a deployment.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@bluefield/charts/nico-fmds/templates/daemonset.yaml` around lines 91 - 98,
Update the useNodeTokens argument block in the daemonset template to derive
--node-token-socket from the agent’s existing local_api_socket value instead of
hardcoding {{ $dir }}/run/agent.sock. Reuse the shared value while preserving
the current argument behavior and other certificate paths.
crates/api-core/src/cfg/file.rs (1)

3573-3593: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider a scenario table here, and cover the remaining validate() branches.

Three invocations of the same operation with different inputs is exactly the table-driven case, and the audience-empty and max_token_ttl_sec bounds branches currently have no coverage at all.

♻️ Proposed table-driven form
-    /// Disabling both bearer tokens and machine mTLS would lock every node out
-    /// of the API; validation must refuse the combination, and each mechanism
-    /// alone must pass.
-    #[test]
-    fn node_auth_rejects_all_methods_disabled() {
-        let both_off = NodeAuthConfig {
-            enabled: false,
-            mtls_enabled: false,
-            ..NodeAuthConfig::default()
-        };
-        assert!(both_off.validate().is_err());
-
-        assert!(NodeAuthConfig::default().validate().is_ok());
-        let jwt_only = NodeAuthConfig {
-            enabled: true,
-            mtls_enabled: false,
-            ..NodeAuthConfig::default()
-        };
-        assert!(jwt_only.validate().is_ok());
-    }
+    /// Disabling both bearer tokens and machine mTLS would lock every node out
+    /// of the API; validation must refuse that combination, accept either
+    /// mechanism alone, and reject nonsensical token constraints.
+    #[test]
+    fn node_auth_validation_contract() {
+        value_scenarios!(
+            run = |cfg: NodeAuthConfig| cfg.validate().is_ok();
+            "accepted" {
+                NodeAuthConfig::default() => true,
+                NodeAuthConfig { enabled: true, mtls_enabled: false, ..NodeAuthConfig::default() } => true,
+            }
+
+            "rejected" {
+                NodeAuthConfig { enabled: false, mtls_enabled: false, ..NodeAuthConfig::default() } => false,
+                NodeAuthConfig { enabled: true, audience: "   ".to_string(), ..NodeAuthConfig::default() } => false,
+                NodeAuthConfig { enabled: true, max_token_ttl_sec: 0, ..NodeAuthConfig::default() } => false,
+                NodeAuthConfig {
+                    enabled: true,
+                    max_token_ttl_sec: NODE_AUTH_MAX_TOKEN_TTL_SEC + 1,
+                    ..NodeAuthConfig::default()
+                } => false,
+            }
+        );
+    }

As per coding guidelines, "Use tables whenever multiple tests call the same operation with different inputs".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/api-core/src/cfg/file.rs` around lines 3573 - 3593, Refactor
node_auth_rejects_all_methods_disabled into a table-driven test covering the
existing all-disabled, default, and JWT-only configurations, and add cases
exercising the validate() branches for empty audience and max_token_ttl_sec
boundary values. Invoke validate() through the scenario table and assert each
expected result.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/api-core/src/listener.rs`:
- Around line 485-498: Update the jwt_roots_reloaded branch in the listener
reload flow to replace tls_acceptor only when get_tls_acceptor returns Some;
retain the existing acceptor when the rebuild returns None. Leave the startup
path and existing task error propagation unchanged, preserving TLS for
subsequent connections after a failed reload.

In `@crates/fmds/src/main.rs`:
- Around line 102-118: Add a validation guard immediately before the
`forge_client_config` match: if `options.node_token_socket` is set while
`options.root_ca` is absent, fail loudly with an `eyre::bail!` error identifying
both flags. Keep the existing match behavior unchanged for valid credential
combinations.

In `@crates/rpc/src/node_jwt.rs`:
- Around line 281-292: Update the documentation for the
NodeTokenProvider::current contract to acknowledge that implementations may
perform the existing lightweight local token refresh/signing work, while
prohibiting blocking or network I/O on the request path. Keep the
cached-token/None behavior and the NodeJwtMinter and SocketTokenSource
references unchanged.
- Around line 240-249: Move the SEC1-to-PKCS#8 re-encoding documentation from
`key_matches_certificate` to `ec_encoding_key`, leaving only the certificate
public-key comparison documentation attached to `key_matches_certificate` and
ensuring `ec_encoding_key` has the appropriate encoding-related doc comment.

In `@crates/rpc/src/node_token_socket.rs`:
- Around line 110-118: Update NodeTokenSocket::current to recover from a
poisoned cached read lock using the repository’s into_inner() convention instead
of converting the error to None. Preserve the existing expiration filtering and
token-cloning behavior after obtaining the guard.

---

Nitpick comments:
In `@bluefield/charts/nico-dpu-agent/templates/daemonset.yaml`:
- Line 134: Guard the nodeAuth lookup in the audience block by applying the
chart’s existing default-dictionary pattern before accessing audience, so
nodeAuth: null safely behaves as an unset block. Update the with expression
around .Values.nodeAuth.audience while preserving the current audience rendering
for configured values.

In `@bluefield/charts/nico-fmds/templates/daemonset.yaml`:
- Around line 91-98: Update the useNodeTokens argument block in the daemonset
template to derive --node-token-socket from the agent’s existing
local_api_socket value instead of hardcoding {{ $dir }}/run/agent.sock. Reuse
the shared value while preserving the current argument behavior and other
certificate paths.

In `@bluefield/charts/nico-fmds/tests/node_tokens_test.yaml`:
- Around line 113-156: Hoist the repeated image.repository and image.tag values
from the six test cases into a suite-level set block in the node token tests.
Remove those duplicate image settings from each case, leaving only
scenario-specific values such as useNodeTokens, certsDir, and rootCaFile.
- Around line 60-66: Add a `contains` assertion in the node token test for
`spec.template.spec.volumes` that verifies the `nico-agent-run` volume
definition, alongside the existing `nico-certs-pub` assertion. Match the
expected volume fields from the chart template so the test detects a mount
without its corresponding volume.

In `@crates/agent/src/local_api.rs`:
- Around line 156-162: Extract the repeated bounded readiness polling loops
around the current source and the corresponding sections into one async helper,
such as wait_until or wait_for_socket, that accepts the differing readiness
predicate. Centralize the 100-attempt loop and 20ms delay in that helper, then
replace all three duplicated loops while preserving their existing success and
timeout behavior.

In `@crates/api-core/src/cfg/file.rs`:
- Around line 3573-3593: Refactor node_auth_rejects_all_methods_disabled into a
table-driven test covering the existing all-disabled, default, and JWT-only
configurations, and add cases exercising the validate() branches for empty
audience and max_token_ttl_sec boundary values. Invoke validate() through the
scenario table and assert each expected result.

In `@crates/api-core/src/node_auth.rs`:
- Around line 238-248: In crates/api-core/src/node_auth.rs lines 238-248, update
NodeJwtValidator::spiffe_id_from_bearer to emit a carbide_instrument::Event when
validation fails, using a bounded #[label] for RejectReason and placing the
error text in #[context] alongside the existing debug log. In
crates/authn/src/middleware.rs lines 639-666, add event instrumentation for
machine principals rejected because mtls_enabled is false, using the same
bounded-label and contextual-detail conventions.
- Around line 412-479: Extend the token validation tests around
spiffe_id_from_bearer to cover mismatched and expired claims: mint a token with
one certificate while setting sub to another certificate’s SPIFFE URI and assert
rejection, and create an otherwise valid token with an expired exp claim and
assert rejection. Consolidate these cases with the existing same-operation tests
using a table-driven test structure where practical, while preserving the
current trusted, untrusted-CA, malformed, and lifetime-cap coverage.

In `@crates/authn/src/middleware.rs`:
- Around line 1102-1110: The bearer_token_from_headers_parses_scheme test
currently repeats setup and assertions for each input; convert it to a
carbide-test-support value_scenarios! table for this total operation. Define
scenarios covering absent authorization, valid Bearer, and non-Bearer headers,
while preserving the expected outputs and making future lowercase-scheme or
whitespace cases easy to add.

In `@crates/host-support/src/registration.rs`:
- Around line 339-351: Optionally harden the key-write path by calling
sync_all() on the opened file after write_all() and before returning from the
surrounding function, while retaining the existing flush and permission
ordering.

In `@crates/rpc/src/node_token_socket.rs`:
- Around line 224-281: The tests cover initial fetch and expiration handling but
not token replacement during refresh. Add a test near the existing
SocketTokenSource tests that serves a token with an advancing expires_at, waits
for the refresh loop to replace the cached value, and asserts the newer token is
returned; exercise the refresh_loop scheduling path including the saturating_sub
and RETRY_DELAY minimum boundary.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: f7817634-3115-42ad-8fd7-31aa1c02b0f0

📥 Commits

Reviewing files that changed from the base of the PR and between 11f4720 and c487a0e.

⛔ Files ignored due to path filters (3)
  • Cargo.lock is excluded by !**/*.lock
  • rest-api/proto/core/gen/v1/agent_local_nico.pb.go is excluded by !**/*.pb.go, !**/gen/**, !rest-api/**/*.pb.go
  • rest-api/proto/core/gen/v1/agent_local_nico_grpc.pb.go is excluded by !**/*.pb.go, !**/gen/**, !rest-api/**/*.pb.go, !rest-api/**/*_grpc.pb.go
📒 Files selected for processing (43)
  • bluefield/charts/nico-dpu-agent/templates/daemonset.yaml
  • bluefield/charts/nico-dpu-agent/tests/node_auth_audience_test.yaml
  • bluefield/charts/nico-dpu-agent/values.yaml
  • bluefield/charts/nico-fmds/templates/daemonset.yaml
  • bluefield/charts/nico-fmds/tests/node_tokens_test.yaml
  • bluefield/charts/nico-fmds/values.yaml
  • crates/agent/Cargo.toml
  • crates/agent/example_agent_config.toml
  • crates/agent/src/command_line.rs
  • crates/agent/src/lib.rs
  • crates/agent/src/local_api.rs
  • crates/agent/src/tests/common/mod.rs
  • crates/api-core/src/api.rs
  • crates/api-core/src/cfg/README.md
  • crates/api-core/src/cfg/file.rs
  • crates/api-core/src/dpf_services.rs
  • crates/api-core/src/lib.rs
  • crates/api-core/src/listener.rs
  • crates/api-core/src/node_auth.rs
  • crates/api-core/src/setup.rs
  • crates/api-core/src/test_support/builder.rs
  • crates/api-core/src/test_support/default_config.rs
  • crates/authn/Cargo.toml
  • crates/authn/src/middleware.rs
  • crates/fmds/src/cfg.rs
  • crates/fmds/src/main.rs
  • crates/host-support/src/agent_config.rs
  • crates/host-support/src/registration.rs
  • crates/host-support/test/min_agent_config/output.toml
  • crates/rpc/Cargo.toml
  • crates/rpc/build.rs
  • crates/rpc/proto/agent_local.proto
  • crates/rpc/src/forge_tls_client.rs
  • crates/rpc/src/lib.rs
  • crates/rpc/src/node_jwt.rs
  • crates/rpc/src/node_token_socket.rs
  • crates/rpc/src/protos/mod.rs
  • crates/scout/src/cfg/command_line.rs
  • crates/scout/src/client.rs
  • deploy/nico-base/api/config-files/nico-api-config.toml
  • docs/design/machine-identity/node-auth-jwt.md
  • helm/charts/nico-api/files/carbide-api-config.toml
  • rest-api/proto/core/src/v1/agent_local_nico.proto
🚧 Files skipped from review as they are similar to previous changes (6)
  • crates/api-core/src/test_support/builder.rs
  • crates/authn/Cargo.toml
  • bluefield/charts/nico-dpu-agent/tests/node_auth_audience_test.yaml
  • bluefield/charts/nico-fmds/values.yaml
  • crates/agent/example_agent_config.toml
  • docs/design/machine-identity/node-auth-jwt.md

Comment on lines +485 to +498
if jwt_roots_reloaded {
tls_acceptor = tokio::task::Builder::new()
.name("get_tls_acceptor refresh")
.spawn_blocking({
let tls_config = tls_config.clone();
move || get_tls_acceptor(&tls_config)
})
// Safety: spawn_blocking only returns Error if run outside the tokio runtime
.expect("Failed to spawn blocking task")
.await
// Safety: Awaiting a JoinHandle only fails if the task panicked, and we want to
// propagate panics
.expect("task panicked");
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

A failed acceptor rebuild silently downgrades the TLS listener to plaintext — and node-auth makes that costly.

The reload ordering correctly protects the JWT-roots-fail case. The inverse is not protected: get_tls_acceptor returns Option, and on a mid-write identity file or unreadable key it yields None, which is assigned straight into tls_acceptor at line 486. The accept path then takes the else branch at line 555 and serves the connection in the clear.

With node-auth enabled this is no longer merely an availability problem. The (Some(_), false) guard at lines 345-349 is evaluated once at startup, so the middleware keeps its bearer authenticator installed and would validate tokens arriving over a plaintext connection — exactly the outcome that guard exists to prevent. The validator has also already advanced to the new roots, so the pair the surrounding comment works so hard to keep aligned ends up misaligned anyway.

Keeping the previous acceptor on a failed rebuild preserves the stated invariant in both directions.

🔒️ Preserve the previous acceptor when the rebuild fails
                     if jwt_roots_reloaded {
-                        tls_acceptor = tokio::task::Builder::new()
+                        let rebuilt = tokio::task::Builder::new()
                             .name("get_tls_acceptor refresh")
                             .spawn_blocking({
                                 let tls_config = tls_config.clone();
                                 move || get_tls_acceptor(&tls_config)
                             })
                             // Safety: spawn_blocking only returns Error if run outside the tokio runtime
                             .expect("Failed to spawn blocking task")
                             .await
                             // Safety: Awaiting a JoinHandle only fails if the task panicked, and we want to
                             // propagate panics
                             .expect("task panicked");
+                        match rebuilt {
+                            Some(acceptor) => tls_acceptor = Some(acceptor),
+                            // Never drop to plaintext on a transient read/parse
+                            // failure: keep serving on the previous identity.
+                            None => tracing::warn!(
+                                "could not rebuild the TLS acceptor; keeping the previous one"
+                            ),
+                        }
                     }

Note this leaves the startup path (line 291-295) unchanged; a None acceptor there for ApiListenMode::Tls deserves its own look, but it is outside this diff.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if jwt_roots_reloaded {
tls_acceptor = tokio::task::Builder::new()
.name("get_tls_acceptor refresh")
.spawn_blocking({
let tls_config = tls_config.clone();
move || get_tls_acceptor(&tls_config)
})
// Safety: spawn_blocking only returns Error if run outside the tokio runtime
.expect("Failed to spawn blocking task")
.await
// Safety: Awaiting a JoinHandle only fails if the task panicked, and we want to
// propagate panics
.expect("task panicked");
}
if jwt_roots_reloaded {
let rebuilt = tokio::task::Builder::new()
.name("get_tls_acceptor refresh")
.spawn_blocking({
let tls_config = tls_config.clone();
move || get_tls_acceptor(&tls_config)
})
// Safety: spawn_blocking only returns Error if run outside the tokio runtime
.expect("Failed to spawn blocking task")
.await
// Safety: Awaiting a JoinHandle only fails if the task panicked, and we want to
// propagate panics
.expect("task panicked");
match rebuilt {
Some(acceptor) => tls_acceptor = Some(acceptor),
// Never drop to plaintext on a transient read/parse
// failure: keep serving on the previous identity.
None => tracing::warn!(
"could not rebuild the TLS acceptor; keeping the previous one"
),
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/api-core/src/listener.rs` around lines 485 - 498, Update the
jwt_roots_reloaded branch in the listener reload flow to replace tls_acceptor
only when get_tls_acceptor returns Some; retain the existing acceptor when the
rebuild returns None. Leave the startup path and existing task error propagation
unchanged, preserving TLS for subsequent connections after a failed reload.

Comment thread crates/fmds/src/main.rs
Comment on lines +102 to +118
let forge_client_config = match &options.root_ca {
Some(root_ca) if client_cert.is_some() || options.node_token_socket.is_some() => {
let mut config = ForgeClientConfig::new(root_ca.clone(), client_cert);
if let Some(socket) = &options.node_token_socket {
tracing::info!(
socket = %socket,
"fetching node-auth bearer tokens from the dpu-agent local API"
);
// Token mode usually runs without a client cert, which would
// otherwise leave the channel on the dummy TLS verifier. The
// bearer token is the client credential; the server still has
// to prove itself against the root CA.
config = config
.require_tls_enforcement()
.with_token_provider(SocketTokenSource::spawn(socket.clone()));
}
Some(Arc::new(config))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

A --node-token-socket without --root-ca degrades silently.

Lines 87-101 rightly treat half a credential as a deployment bug and bail. The new guard on line 103, however, requires root_ca, so --node-token-socket supplied alone falls through to the generic "No TLS credentials provided" warning and phone_home is quietly disabled — the same class of misconfiguration, handled inconsistently. Failing loudly here would keep the two paths symmetric.

🛡️ Fail on a token socket configured without a trust anchor
     let forge_client_config = match &options.root_ca {
         Some(root_ca) if client_cert.is_some() || options.node_token_socket.is_some() => {

Add before the match:

if options.root_ca.is_none() && options.node_token_socket.is_some() {
    eyre::bail!("--node-token-socket was provided without --root-ca");
}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let forge_client_config = match &options.root_ca {
Some(root_ca) if client_cert.is_some() || options.node_token_socket.is_some() => {
let mut config = ForgeClientConfig::new(root_ca.clone(), client_cert);
if let Some(socket) = &options.node_token_socket {
tracing::info!(
socket = %socket,
"fetching node-auth bearer tokens from the dpu-agent local API"
);
// Token mode usually runs without a client cert, which would
// otherwise leave the channel on the dummy TLS verifier. The
// bearer token is the client credential; the server still has
// to prove itself against the root CA.
config = config
.require_tls_enforcement()
.with_token_provider(SocketTokenSource::spawn(socket.clone()));
}
Some(Arc::new(config))
if options.root_ca.is_none() && options.node_token_socket.is_some() {
eyre::bail!("--node-token-socket was provided without --root-ca");
}
let forge_client_config = match &options.root_ca {
Some(root_ca) if client_cert.is_some() || options.node_token_socket.is_some() => {
let mut config = ForgeClientConfig::new(root_ca.clone(), client_cert);
if let Some(socket) = &options.node_token_socket {
tracing::info!(
socket = %socket,
"fetching node-auth bearer tokens from the dpu-agent local API"
);
// Token mode usually runs without a client cert, which would
// otherwise leave the channel on the dummy TLS verifier. The
// bearer token is the client credential; the server still has
// to prove itself against the root CA.
config = config
.require_tls_enforcement()
.with_token_provider(SocketTokenSource::spawn(socket.clone()));
}
Some(Arc::new(config))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/fmds/src/main.rs` around lines 102 - 118, Add a validation guard
immediately before the `forge_client_config` match: if
`options.node_token_socket` is set while `options.root_ca` is absent, fail
loudly with an `eyre::bail!` error identifying both flags. Keep the existing
match behavior unchanged for valid credential combinations.

Comment on lines +240 to +249
/// Builds an ES256 signing key from the client key PEM. Vault-issued machine
/// keys are SEC1 (`BEGIN EC PRIVATE KEY`), which `jsonwebtoken` cannot load
/// directly, so those are re-encoded to PKCS#8 first.
/// Whether `key_pem`'s public half is the one certified by `leaf_der`.
///
/// Both encodings the node may hold are accepted: Vault issues SEC1 ("EC
/// PRIVATE KEY") and renewal may leave PKCS#8. The comparison is on the
/// uncompressed SEC1 point, which is exactly what an EC `SubjectPublicKeyInfo`
/// carries.
fn key_matches_certificate(key_pem: &str, leaf_der: &[u8]) -> Result<bool, NodeJwtError> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Doc comment is attached to the wrong function.

The paragraph describing SEC1-to-PKCS#8 re-encoding documents ec_encoding_key (Line 268), but sits on key_matches_certificate, which then carries two unrelated doc blocks while ec_encoding_key is left undocumented.

🧹 Proposed relocation
-/// Builds an ES256 signing key from the client key PEM. Vault-issued machine
-/// keys are SEC1 (`BEGIN EC PRIVATE KEY`), which `jsonwebtoken` cannot load
-/// directly, so those are re-encoded to PKCS#8 first.
 /// Whether `key_pem`'s public half is the one certified by `leaf_der`.
 ///
 /// Both encodings the node may hold are accepted: Vault issues SEC1 ("EC
 /// PRIVATE KEY") and renewal may leave PKCS#8. The comparison is on the
 /// uncompressed SEC1 point, which is exactly what an EC `SubjectPublicKeyInfo`
 /// carries.
 fn key_matches_certificate(key_pem: &str, leaf_der: &[u8]) -> Result<bool, NodeJwtError> {

Then, above Line 268:

+/// Builds an ES256 signing key from the client key PEM. Vault-issued machine
+/// keys are SEC1 (`BEGIN EC PRIVATE KEY`), which `jsonwebtoken` cannot load
+/// directly, so those are re-encoded to PKCS#8 first.
 fn ec_encoding_key(key_pem: &str) -> Result<EncodingKey, NodeJwtError> {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// Builds an ES256 signing key from the client key PEM. Vault-issued machine
/// keys are SEC1 (`BEGIN EC PRIVATE KEY`), which `jsonwebtoken` cannot load
/// directly, so those are re-encoded to PKCS#8 first.
/// Whether `key_pem`'s public half is the one certified by `leaf_der`.
///
/// Both encodings the node may hold are accepted: Vault issues SEC1 ("EC
/// PRIVATE KEY") and renewal may leave PKCS#8. The comparison is on the
/// uncompressed SEC1 point, which is exactly what an EC `SubjectPublicKeyInfo`
/// carries.
fn key_matches_certificate(key_pem: &str, leaf_der: &[u8]) -> Result<bool, NodeJwtError> {
/// Whether `key_pem`'s public half is the one certified by `leaf_der`.
///
/// Both encodings the node may hold are accepted: Vault issues SEC1 ("EC
/// PRIVATE KEY") and renewal may leave PKCS#8. The comparison is on the
/// uncompressed SEC1 point, which is exactly what an EC `SubjectPublicKeyInfo`
/// carries.
fn key_matches_certificate(key_pem: &str, leaf_der: &[u8]) -> Result<bool, NodeJwtError> {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/rpc/src/node_jwt.rs` around lines 240 - 249, Move the SEC1-to-PKCS#8
re-encoding documentation from `key_matches_certificate` to `ec_encoding_key`,
leaving only the certificate public-key comparison documentation attached to
`key_matches_certificate` and ensuring `ec_encoding_key` has the appropriate
encoding-related doc comment.

Comment on lines +281 to +292
/// A source of node-auth bearer tokens for outgoing requests. Implemented by
/// [`NodeJwtMinter`] (holds the key, signs locally) and
/// [`SocketTokenSource`](crate::node_token_socket::SocketTokenSource)
/// (fetches from the dpu-agent's local API — the caller never sees the key).
///
/// `current` runs on the request path, so implementations must be non-blocking:
/// return a cached token or `None`, never wait on I/O.
pub trait NodeTokenProvider: Send + Sync + std::fmt::Debug {
/// Returns a currently-valid token, or `None` if one isn't available
/// (the request then proceeds with whatever else the channel carries).
fn current(&self) -> Option<String>;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

The stated trait contract is stronger than the key-holding implementation delivers.

"never wait on I/O" is not what NodeJwtMinter::current does: on a cold or near-expiry cache it performs two synchronous std::fs::read calls plus an ECDSA signature, on the request thread. The cost is negligible (local files, roughly once per token lifetime), so the implementation is fine — the contract is what should be corrected, before someone writes a provider that genuinely blocks on a network call and believes they are within spec.

📝 Proposed wording
-/// `current` runs on the request path, so implementations must be non-blocking:
-/// return a cached token or `None`, never wait on I/O.
+/// `current` runs on the request path, so implementations must never block on
+/// network I/O or await a remote refresh: serve a cached token, or `None` and
+/// refresh out of band. Cheap local work on the re-mint path is acceptable —
+/// [`NodeJwtMinter`] re-reads its cert/key files and signs, roughly once per
+/// token lifetime.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// A source of node-auth bearer tokens for outgoing requests. Implemented by
/// [`NodeJwtMinter`] (holds the key, signs locally) and
/// [`SocketTokenSource`](crate::node_token_socket::SocketTokenSource)
/// (fetches from the dpu-agent's local API — the caller never sees the key).
///
/// `current` runs on the request path, so implementations must be non-blocking:
/// return a cached token or `None`, never wait on I/O.
pub trait NodeTokenProvider: Send + Sync + std::fmt::Debug {
/// Returns a currently-valid token, or `None` if one isn't available
/// (the request then proceeds with whatever else the channel carries).
fn current(&self) -> Option<String>;
}
/// A source of node-auth bearer tokens for outgoing requests. Implemented by
/// [`NodeJwtMinter`] (holds the key, signs locally) and
/// [`SocketTokenSource`](crate::node_token_socket::SocketTokenSource)
/// (fetches from the dpu-agent's local API — the caller never sees the key).
///
/// `current` runs on the request path, so implementations must never block on
/// network I/O or await a remote refresh: serve a cached token, or `None` and
/// refresh out of band. Cheap local work on the re-mint path is acceptable —
/// [`NodeJwtMinter`] re-reads its cert/key files and signs, roughly once per
/// token lifetime.
pub trait NodeTokenProvider: Send + Sync + std::fmt::Debug {
/// Returns a currently-valid token, or `None` if one isn't available
/// (the request then proceeds with whatever else the channel carries).
fn current(&self) -> Option<String>;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/rpc/src/node_jwt.rs` around lines 281 - 292, Update the documentation
for the NodeTokenProvider::current contract to acknowledge that implementations
may perform the existing lightweight local token refresh/signing work, while
prohibiting blocking or network I/O on the request path. Keep the
cached-token/None behavior and the NodeJwtMinter and SocketTokenSource
references unchanged.

Comment on lines +110 to +118
fn current(&self) -> Option<String> {
let now = unix_now()?;
self.cached
.read()
.ok()?
.as_ref()
.filter(|(_, expires_at)| *expires_at > now + REFRESH_MARGIN_SECS / 2)
.map(|(token, _)| token.clone())
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Recover from lock poisoning rather than degrading to “no token” permanently.

self.cached.read().ok()? converts a poisoned lock into an indefinite None, which silently strips the bearer header from every subsequent request. The rest of this feature (node_auth.rs, authn/src/middleware.rs) deliberately recovers with into_inner(); matching that keeps the failure mode consistent and non-terminal.

♻️ Align with the repository's poison-recovery convention
         self.cached
             .read()
-            .ok()?
+            .unwrap_or_else(|poisoned| poisoned.into_inner())
             .as_ref()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn current(&self) -> Option<String> {
let now = unix_now()?;
self.cached
.read()
.ok()?
.as_ref()
.filter(|(_, expires_at)| *expires_at > now + REFRESH_MARGIN_SECS / 2)
.map(|(token, _)| token.clone())
}
fn current(&self) -> Option<String> {
let now = unix_now()?;
self.cached
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.as_ref()
.filter(|(_, expires_at)| *expires_at > now + REFRESH_MARGIN_SECS / 2)
.map(|(token, _)| token.clone())
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/rpc/src/node_token_socket.rs` around lines 110 - 118, Update
NodeTokenSocket::current to recover from a poisoned cached read lock using the
repository’s into_inner() convention instead of converting the error to None.
Preserve the existing expiration filtering and token-cloning behavior after
obtaining the guard.

@wminckler
wminckler force-pushed the node-auth-token-broker branch from c487a0e to a18fe9d Compare August 5, 2026 18:24
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown

@wminckler
wminckler marked this pull request as draft August 5, 2026 18:31
@copy-pr-bot

copy-pr-bot Bot commented Aug 5, 2026

Copy link
Copy Markdown

Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually.

Contributors can view more details about this message here.

@wminckler
wminckler force-pushed the node-auth-token-broker branch from 8d886d3 to bac16c4 Compare August 5, 2026 20:07
NVIDIA#355)

Nodes authenticate to the API with short-lived ES256 JWTs signed by the
private key of their existing mTLS client certificate, carrying the cert
chain in the token's x5c header. The API verifies that chain against the same
root CA its TLS listener already trusts and maps the leaf's SPIFFE SAN
through the existing SpiffeContext, so the machine principal and RBAC are
unchanged. No new key material, no server-side signing key, no issuance or
refresh RPCs.

On a DPU only the dpu-agent holds the machine key. It serves
AgentLocal/GetNodeToken over a unix socket, so co-located services get tokens
rather than the key: token-mode fmds pods mount that socket plus a
trust-anchor directory the agent publishes, and never reference the
credentials volume. The socket's directory must be dedicated to it — created
0700, or refused if it holds anything else — since the directory is what
closes the window between bind and the socket's own chmod. fmds token mode
follows [node_auth] enabled, so with node-auth off the chart renders as
before.

Configuration is [node_auth]: enabled (accept bearer JWTs, requires a TLS
listener), mtls_enabled (machine client-cert authn, disableable once the
fleet presents tokens), audience and max_token_ttl_sec. Both switches off is
rejected at startup. The audience must agree between the API and each node,
and is validated on the node whether it arrives by flag or by config file.

Bearer tokens never travel in the clear. The API refuses to accept them on a
non-TLS listener and keeps its previous TLS acceptor if a rebuild fails,
rather than falling back to plaintext while the authenticator stays armed.
Clients enforce server-certificate validation whenever a token provider is
attached, and refuse a non-HTTPS endpoint outright.

Rotation is covered on both sides: the validator reloads its trust anchors on
the listener's client-CA refresh, and the minter checks its key against the
certified public key before signing, so neither CA rotation nor certificate
renewal can lock a node out. The machine key is written 0600.

Design doc: docs/design/machine-identity/node-auth-jwt.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Sinck

Sinck commented Aug 7, 2026

Copy link
Copy Markdown

Superseded by #4718, opened from the fork this work now lives on. Same change plus the review fixes from this thread — the with_token_provider TLS enforcement, the TLS acceptor downgrade, audience validation on the config path, and the socket-directory hardening are all addressed there.

Thanks to everyone who reviewed here; the findings carried over rather than being lost.

@Sinck Sinck closed this Aug 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Move scout and dpu-agent to use JWT tokens instead of mTLS

2 participants