Skip to content

per-job certificates for job processes - #5263

Merged
pcnudde merged 14 commits into
NVIDIA:mainfrom
pcnudde:feature/per-job-certs
Sep 14, 2026
Merged

pcnudde merged 14 commits into
NVIDIA:mainfrom
pcnudde:feature/per-job-certs

Conversation

@pcnudde

@pcnudde pcnudde commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

Closes #5249 (parent overview: #5246).

Job processes (SJ/CJ) run on the site's long-lived certificate and private key, so any job-supplied code can read the site key and impersonate the site indefinitely. This PR gives every job process its own short-lived credential, makes it the only credential a secure job can run on, and keeps the site keys out of job processes.

  • Provisioning: CertBuilder generates a job-signing intermediate CA (job_ca.crt/job_ca.key, CA:TRUE pathlen:0, root-signed marker URI SAN) into the server startup kit. On by default; enable_job_ca: false opts out. Persisted in cert.json, regenerated if expired. A customer-supplied root passed to nvflare provision is covered. The distributed nvflare cert / nvflare package flow is not (no root key at packaging time, so no job CA, so no secure jobs); it is addressed separately.
  • Issuance: at job deploy the SP issues one leaf per participating site (CN=<site>, URI SANs naming the job and the cells it may claim, notBefore backdated for clock skew, validity job_cert_valid_days from the server's fed_server.json or resources.json, --set, or NVFLARE_JOB_CERT_VALID_DAYS, default 30 days, clamped to the CA). The SJ credential is written to the run dir after app deployment; each CJ credential is pushed in its site's deploy message over the existing authenticated CP-SP channel.
  • No fallback in secure mode: a server kit without a job CA, or with a job CA about to expire, fails the job deploy with the reason in job_deploy_detail; the CP rejects a deploy request without a valid job credential; a job cell refuses to start without one; launchers refuse to launch a secure job that has no credential. Non-secure mode and the simulator use no certificates and are unaffected.
  • Job processes: ssl_cert/ssl_private_key point at the job credential, so nothing in SJ/CJ refers to the site key. Job cells (and the Kubernetes bootstrap cell) use it for TLS and message-level crypto in both TLS roles, so cellnet's directory-based credential back-fill can never present a site certificate. The startup content-integrity check no longer requires the site key in job processes.
  • Site-scope rejection: IdentityVerifier.verify_common_name() rejects any certificate carrying the job URI and any chain containing the job-CA marker URI, so neither a leaked job leaf key nor a stolen job CA key can register a site, log in as admin, or pass as the server.
  • Child links must be mTLS: job-cert binding exists only on mTLS connections, so in secure mode the Docker and Slurm launchers refuse a clear-text parent connection (configure the client's internal listener with stcp / mtls; the shared-file transport is exempt). Kubernetes job pods already use the parent's external stcp listener with mTLS. A live test connects real job cells to a site parent's mTLS internal listener and verifies another job's certificate is rejected.
  • Credential lifetime: the SP destroys the SJ credential before the run directory is archived to the job store, so download_job never contains it; the CP destroys the CJ credential as soon as the CJ process exits. The credential is dead at that point (no renewal, no reuse).
  • Certificate URIs, no private OIDs: certificate attributes are https URI Subject Alternative Names under https://nvidia.com/nvflare/v1/ (job/<job id> and cell/<fqcn> on job leaves, ca/job on the job CA; nvflare/fuel/sec/cert_uri.py). A private extension would need an OID under NVIDIA's enterprise arc, which cannot be allocated, and 2.25 OIDs break Go parsers; a domain-owned URI needs no registry. Readers match the root exactly, ignore other hosts, and fail closed on malformed URIs.
  • Cellnet stays job-free: TLS drivers expose the authenticated peer certificate (PEER_CERT); CellIdentityResolver enforces one generic rule, a certificate carrying cell-scope URIs may only claim an FQCN equal to or under one of those cells, at the connection handshake and on the certificate cached for message-level crypto. Which cells a job credential lists is decided where it is issued: the job cell and the workspace-transfer bootstrap cell under the owning CP or server FQCN (which covers clients behind relays). site-1.<job B>.ws_transfer_<job A> is job B's cell and is rejected for job A's certificate. Live cross-process mTLS tests cover a server root, a site parent's internal listener, and the bootstrap cell.
  • Launchers never ship *.key (including job_ca.key): Docker binds the startup kit file by file; Kubernetes drops keys from the startup Secret and delivers the job credential through the per-pod credential Secret (NVFLARE_JOB_CERT/NVFLARE_JOB_KEY), installed by the job process before its bootstrap cell starts, with job_cert/ excluded from workspace bundles and result uploads; Slurm (apptainer/pyxis) binds a keyless staged copy of the kit. The in-process launcher and Slurm sandbox: none share the host filesystem and cannot isolate.
  • Docs: design docs/design/per_job_certs_design.md; user guide docs/user_guide/admin_guide/security/per_job_certificates.rst (incl. openssl recipe for an externally issued job CA).

Compatibility

Existing secure-mode projects must be re-provisioned (the root CA is reused; only job_ca.* is added to the server kit) before jobs will deploy. Server and clients must run the same release: an older client would run its job process on site certificates, which secure mode no longer allows. Non-secure mode and the simulator are unchanged.

@pcnudde pcnudde changed the title feature(security): per-job certificates for job processes per-job certificates for job processes Sep 3, 2026
@greptile-apps

greptile-apps Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

RetriggerConfidence Score: 5/5

The PR appears safe to merge.

Summary

This PR introduces per-job certificates so secure job processes no longer receive or use long-lived site private keys.

  • Provisions and persists a root-signed job-signing CA.
  • Issues job- and cell-scoped credentials during deployment.
  • Enforces certificate scope in CellNet and rejects job credentials at site-identity boundaries.
  • Distributes and removes credentials across process, Docker, Kubernetes, and Slurm launch paths.
  • Adds documentation and extensive unit and live integration coverage.

Diagram

sequenceDiagram
    participant SP as Server Parent
    participant CA as Job CA
    participant CP as Client Parent
    participant JP as Job Process
    participant CN as CellNet
    SP->>CA: Issue job- and cell-scoped credential
    CA-->>SP: Leaf certificate, chain, and key
    SP->>CP: Deploy app with site-specific credential
    CP->>JP: Install credential in job run directory
    JP->>CN: Establish mTLS using job credential
    CN->>CN: Verify claimed FQCN against certificate cell scopes
    CN-->>JP: Accept only authorized job cells
    JP-->>CP: Exit and return results
    CP->>CP: Destroy job credential
Loading

Reviews (14) · Last reviewed commit: "refactor(f3): derive peer identity once ..."

Comment thread examples/advanced/collab/pt_async_cifar10/trainer.py Outdated
Job processes (SJ/CJ) get a short-lived credential issued at deploy time by
a constrained job CA (CA:TRUE pathlen:0, marker extension) provisioned into
the server kit by default. Leaf certs keep CN=<site> and carry the job ID;
site-scope identity checks reject anything the job CA signed, and cellnet
binds a job cert to its job's FQCNs. In secure mode jobs never run on site
certificates: missing/expiring job CA fails the deploy, CP rejects a deploy
without a credential, job cells refuse to start without one. Docker, K8s
and Slurm launchers never ship *.key files to job processes.

Closes NVIDIA#5249
@pcnudde
pcnudde force-pushed the feature/per-job-certs branch from cccfbf9 to e7e382e Compare September 3, 2026 20:08
@codecov-commenter

codecov-commenter commented Sep 3, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.29064% with 11 lines in your changes missing coverage. Please review.
✅ Project coverage is 67.71%. Comparing base (030a5d5) to head (1dbde5a).

Files with missing lines Patch % Lines
nvflare/lighter/impl/cert.py 92.68% 3 Missing ⚠️
nvflare/fuel/f3/drivers/aio_conn.py 0.00% 2 Missing ⚠️
nvflare/private/fed/utils/job_cert_utils.py 98.41% 2 Missing ⚠️
nvflare/app_opt/job_launcher/docker_launcher.py 88.88% 1 Missing ⚠️
nvflare/fuel/sec/cert_uri.py 96.87% 1 Missing ⚠️
nvflare/lighter/utils.py 93.75% 1 Missing ⚠️
nvflare/private/fed/server/job_runner.py 96.42% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #5263      +/-   ##
==========================================
+ Coverage   67.40%   67.71%   +0.30%     
==========================================
  Files        1021     1023       +2     
  Lines      106127   106439     +312     
==========================================
+ Hits        71539    72072     +533     
+ Misses      34588    34367     -221     
Flag Coverage Δ
unit-tests 67.71% <97.29%> (+0.30%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

… cells

The K8s workspace-transfer bootstrap cell is named <owner>.ws_transfer_<job_id>,
so the job id is embedded in a segment instead of being one and the job-cert
binding rejected the job pod's own credential at the parent
("claimed endpoint ... is not part of that job"), failing every secure K8s job.

FQCN.belongs_to_job now defines a job's cells as the job cell and its
descendants plus auxiliary cells named <name>_<job_id>; the binding check and
the bootstrap FQCN builder both use it. Adds a cross-process mTLS regression
test that connects a real bootstrap cell to a real parent with a job-bound
cert and verifies another job's cert is still rejected.
The server archived the whole SJ run directory into the job store when a
job finished, so download_job returned job_cert/job.crt and job_cert/job.key
(found by the Docker e2e validation). Clients kept the CJ credential on disk
after the CJ exited.

The SP now destroys the SJ credential before the run directory is archived,
and the CP destroys the CJ credential as soon as the CJ process has exited.
The credential is dead at that point: no renewal, no reuse. Verified with a
full POC job: the stored workspace contains no job_cert entries or key files
and client run dirs keep the app but no credential.
…_valid_days from server config

QA on Slurm found a "cross-job replay" accepted at the site parent and the
documented fed_server.json job_cert_valid_days ignored.

- Docker and Slurm launchers refuse a clear-text parent connection in secure
  mode: job-cert binding exists only on mTLS links, and a clear internal
  listener lets any process that reaches it claim any job FQCN. The
  shared-file transport is exempt (filesystem permissions). A live test now
  connects real job cells to a site parent's mTLS internal listener and
  verifies another job's certificate is rejected there.
- SJ and the K8s bootstrap cell pin both TLS roles to the job credential so
  cellnet's directory-based back-fill can never present a site certificate.
- JobRunner reads job_cert_valid_days from the server's fed_server.json /
  resources.json sections; the SP never loads the application section.
…cess fails

The client removed the job credential only after job_handle.wait() returned
normally. It is now removed in a finally block, and also when no job handle
was registered, so an exceptional wait or a launch that never produced a
handle cannot leave the credential on disk.
…ger hooks

- _deploy_job in secure mode with a real job CA: SJ credential written to the
  run dir (CN of the server cert, job id, leaf + job CA chain, key 0600) and
  the CJ credential carried in the per-site deploy message.
- FLServerStarterConfiger / FLClientStarterConfiger point the job process at
  the job credential when one exists, and leave site paths alone otherwise.
…owner-relative job segment

Review follow-ups on NVIDIA#5263:

- grpc_driver.py / aio_grpc_driver.py called add_grpc_peer_job_id without
  importing it, so every authenticated gRPC stream failed with NameError.
  Both servicers now import it; new tests drive their authenticated stream
  paths with site and job certificates. setup.cfg ignores F821, which is
  why flake8 never reported the undefined name.
- FQCN.belongs_to_job matched the job id in any segment, so job A's cert was
  accepted on site-1.<job-B>.ws_transfer_<job-A>. The job segment must now
  be the one right after the owner's prefix, which the identity resolver
  reports alongside the expected identity (resolve_owner). Covered in the
  resolver, handshake, certificate-cache and live mTLS tests.
- The live tests asserted the child's transient connected flag, which can
  read True before the parent rejects the handshake. They now assert that
  an application request fails and that the parent logged the rejection.
# Conflicts:
#	nvflare/app_opt/job_launcher/slurm/batch.py
@nvidianz

nvidianz commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

This PR adds job_id handling in F3, which is the wrong layer. Here's how the network is currently structured:

SFM (including the transport drivers) is the bottom layer. It sends messages from endpoint to endpoint and has no concept of cells — it cannot forward messages between them.

Cellnet sits above SFM. It introduces FQCNs and knows how to relay messages between non-directly-connected cells using the FQCN hierarchy. It understands dot-separated FQCN components, but it has no concept of jobs. That's intentional — the client/server layer above Cellnet is responsible for incorporating job_id and constructing the correct FQCN so messages are properly relayed.

job_id belongs at that higher layer, not in Cellnet or below. Keeping this separation is important: Cellnet is a general-purpose communication layer that other applications can reuse. Leaking job concepts into it breaks that abstraction.

The live tests generated job ids with uuid4() at import time, so each
pytest-xdist worker collected different parametrized test ids and CI
failed with "Different tests were collected". Use fixed ids.
@pcnudde

pcnudde commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

Agreed: job_id does not belong in F3. Proposal to fix the layering while keeping the check at the handshake, where it has to happen.

What stays in F3, job-free

  • Drivers expose the peer certificate as a connection property next to PEER_CN (PEER_CERT, DER). No extension parsing in drivers; PEER_JOB_ID and the *_peer_job_id helpers in net_utils go away.
  • Cellnet gets one generic rule, in its own vocabulary: a peer certificate may carry an "FQCN scope" extension listing the FQCN prefixes it is allowed to claim. CellIdentityResolver.require_match rejects a handshake, and CredentialManager a cached certificate, whose claimed FQCN is not equal to or under one of those prefixes. Implemented with the existing FQCN.is_ancestor; belongs_to_job, job_aux_name and resolve_owner are removed.

What moves up to nvflare/private/fed

  • The issuer sets the scope when it mints a job credential: [<cp_fqcn>.<job_id>, <cp_fqcn>.ws_transfer_<job_id>] for a client, [server.<job_id>, server.ws_transfer_<job_id>] for the server. Using the registered CP FQCN covers clients behind relays without any owner-prefix logic in cellnet.
  • The job-id extension (...300.1) stays an FL-layer concept, read only by identity_utils (site-scope rejection) and for log messages.
  • The bootstrap cell name becomes a shared constant in the FL layer so the issuer and the K8s launcher agree on it.

Extension: new non-critical OID 1.3.6.1.4.1.5703.300.3, UTF-8, newline-separated FQCN prefixes. Cellnet documents it as its own ("certificate FQCN scope"), with no reference to jobs.

Tests: the cellnet unit and live tests become scope tests without job vocabulary; the job-layer tests assert the issuer emits the right scope. Runtime behavior is unchanged, so the K8s/Docker/Slurm validation results stay valid.

Net effect: F3 knows that a certificate can be restricted to FQCN subtrees, nothing about jobs. The alternative is an authorizer callback that the FL layer installs per cell, which keeps cellnet free of any extension parsing but relies on every cell creator (SP, CP, relay, bootstrap) remembering to install it; I think that is the worse trade. If this works for you I will rework the PR accordingly.

@pcnudde

pcnudde commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

@nvidianz See my proposal above. I also want to remove the custom OID that is used in the certificate. It should be fine, but I do not want to go through the registration steps if I can avoid it.

@pcnudde
pcnudde marked this pull request as draft September 9, 2026 22:15
@pcnudde

pcnudde commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

Amendment to the proposal above: no new OID. In fact, no private OIDs at all.

This PR will do the same and drop all three custom extensions:

Purpose Today URI SAN Read by
Job binding on the leaf OID 300.1 https://nvidia.com/nvflare/v1/job/<job-id> FL layer (site-scope rejection, log messages)
Job-CA marker on the intermediate OID 300.2 https://nvidia.com/nvflare/v1/ca/job FL layer (reject anything the job CA signed at site scope)
Cellnet FQCN scope proposed 300.3 https://nvidia.com/nvflare/v1/cell/<fqcn-prefix>, one per allowed prefix cellnet (handshake and certificate cache)

Reader rules, same as the SSO grants: exact scheme/host/prefix match, URIs on other hosts ignored, malformed URIs under the root rejected. FQCN characters are all URL-unreserved, so prefixes need no encoding, but they are percent-encoded anyway for a uniform fail-closed parser. The https://nvidia.com/nvflare/ root becomes one shared constant for both branches.

The rest of the proposal stands: cellnet only learns that a certificate may restrict which FQCN subtrees it can claim, the FL layer decides the prefixes when it issues the credential, and F3 carries no job vocabulary. The external-CA recipe gets simpler too: subjectAltName = URI:https://nvidia.com/nvflare/v1/ca/job in an openssl config instead of a custom ASN.1 line.

…s as URI SANs

Review follow-up on NVIDIA#5263: job_id belongs above cellnet, and private OIDs
under NVIDIA's enterprise arc cannot be allocated.

F3 now knows only that a certificate may restrict the cells it can claim:
drivers expose the authenticated peer certificate (PEER_CERT), and
CellIdentityResolver rejects a claimed FQCN outside the certificate's
cell-scope URIs (FQCN.is_ancestor). PEER_JOB_ID, the job-id helpers in
net_utils, FQCN.belongs_to_job/job_aux_name and resolve_owner are gone.

Certificate attributes are https URI Subject Alternative Names under
https://nvidia.com/nvflare/v1/ (nvflare/fuel/sec/cert_uri.py): job/<id>
and cell/<fqcn> on job leaves, ca/job on the job CA. The three private
OIDs are removed. The FL layer decides the cells when it issues a
credential: the job cell and the bootstrap cell under the owning CP or
server FQCN, which also covers clients behind relays.
@nvidianz

Copy link
Copy Markdown
Collaborator

Thanks. I agree that we should avoid private OIDs. I recommend using a standard field in the certificate subject DN instead of introducing NVFlare URI SAN semantics.

Of the standard DN fields, OU (2.5.4.11, organizationalUnitName) is the best fit because it is already supported by X.509 tooling, may occur more than once, and does not displace the existing CN identity. For example:

  • OU=nvflare-job:<job-id>
  • OU=nvflare-job-ca
  • OU=nvflare-cell-scope:<fqcn-prefix> - repeated once for each allowed prefix

The reader should collect all OU attributes, recognize only these exact prefixes, reject malformed or conflicting values, and apply the existing FQCN ancestor check. The values are protected by the certificate signature; this does not rely on sender-controlled message headers. I would keep CN exclusively for the current site/cell identity.

For completeness, the common DN fields and their ASN.1 OIDs are:

DN field OID Meaning
CN 2.5.4.3 Common Name
C 2.5.4.6 Country
L 2.5.4.7 Locality
ST 2.5.4.8 State or Province
STREET 2.5.4.9 Street Address
O 2.5.4.10 Organization
OU 2.5.4.11 Organizational Unit
serialNumber 2.5.4.5 Serial Number
surname 2.5.4.4 Surname
givenName 2.5.4.42 Given Name
title 2.5.4.12 Title
postalCode 2.5.4.17 Postal Code
emailAddress 1.2.840.113549.1.9.1 Email Address
DC 0.9.2342.19200300.100.1.25 Domain Component
UID 0.9.2342.19200300.100.1.1 User ID

serialNumber or UID could represent one job identifier, but neither cleanly carries repeated FQCN scopes and the CA marker. OU gives us one consistent, standard, repeatable field for all three purposes without registering a private OID.

@pcnudde
pcnudde marked this pull request as ready for review September 10, 2026 21:39
@pcnudde

pcnudde commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks, agreed on dropping the private OIDs; that is what the current head does. I looked at moving the three values into repeated OU attributes and found two problems, one small and one structural.

1. Issuer-name coupling. A marker in the job CA's subject DN becomes part of every leaf's issuer name. The issuer today rebuilds the issuer name from the CA's CN; with OU=nvflare-job-ca on the CA, verify_cert_chain rejects the chain (candidates exhausted). Fixable by copying ca_cert.subject into each leaf, but it is coupling a SAN marker does not have, because extensions are not part of the name.

2. The 64-character bound on OU. X.520 caps organizationalUnitName at 64 (RFC 5280 Appendix A, ub-organizational-unit-name). Job ids are UUIDs, so the cell scopes do not fit:

Value Length
nvflare-job:<uuid> 48
nvflare-cell-scope:site-1.ws_transfer_<uuid> 74
nvflare-cell-scope:relay-1.site-1.ws_transfer_<uuid> 82

cryptography does not enforce the bound on creation or validation and Go does not enforce it on parse, so it would work inside NVFlare today, but it violates the spec, and OpenSSL enforces the bound when building names, which is exactly the tooling the external-CA recipe relies on. The only way to stay under 64 is an owner-relative encoding (OU=nvflare-owner:<cp_fqcn> plus OU=nvflare-cell:<child>), which puts FQCN structure back into the reader; the flat list of allowed FQCNs is what keeps cellnet's check to "equal to or under one of these".

URI SANs have neither issue: no length cap, no effect on the issuer name, and SAN is the extension X.509 defines for carrying additional identities of the subject. Both encodings are protected by the signature, and a prefix convention inside OU is as much an NVFlare convention as a URI under our own domain; the reader rules you describe (exact prefixes only, reject malformed or conflicting values, existing FQCN ancestor check, CN untouched) are exactly what nvflare/fuel/sec/cert_uri.py and cell_scopes implement today.

I propose to keep the URI SANs.

@pcnudde
pcnudde enabled auto-merge September 14, 2026 20:22
nvidianz
nvidianz previously approved these changes Sep 14, 2026
Comment thread nvflare/fuel/f3/drivers/aio_conn.py Outdated
Comment thread nvflare/fuel/f3/cellnet/fqcn.py Outdated
Comment thread nvflare/fuel/f3/drivers/driver_params.py Outdated
Comment thread nvflare/fuel/f3/drivers/grpc_driver.py Outdated
Comment thread nvflare/fuel/f3/drivers/net_utils.py Outdated
Comment thread nvflare/fuel/f3/drivers/socket_conn.py Outdated
Comment thread nvflare/fuel/f3/drivers/aio_conn.py Outdated
@pcnudde
pcnudde disabled auto-merge September 14, 2026 20:44
Review follow-up on NVIDIA#5263: the per-driver steps that derive PEER_CN from the
peer certificate and record PEER_CERT were duplicated across the socket,
asyncio, websocket and both gRPC transports, each with its own fallback
convention, and pulled nvflare.fuel.hci.security into the transport layer.

Connection.record_peer (F3 base class) now takes the raw peer certificate,
DER or PEM as the transport provides it, and sets PEER_CERT and PEER_CN once;
a secure connection with no peer certificate still reports PEER_CN "N/A".
Each driver hands over the certificate in one line. Consumers of PEER_CN and
PEER_CERT are unchanged. Driver files are now smaller than upstream.
@pcnudde
pcnudde added this pull request to the merge queue Sep 14, 2026
Merged via the queue into NVIDIA:main with commit 8af5e0c Sep 14, 2026
23 checks passed
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.

Per-job credentials

3 participants