Security Baked Into the JVM: two Subjects, one call

The constraint system stops a bad call before it leaves the JVM. The Safe Codebase Audit Pipeline stops bad code before a client ever loads it. What remains is identity: who is calling, and can you verify it? Most frameworks answer with a token check at the door. A filter validates a bearer token, sets a thread-local variable, and hopes that nothing downstream forgets to look at it. DirtyChai answers differently.

The constraint system stops a bad call before it leaves the JVM. The Safe Codebase Audit Pipeline stops bad code before a client ever loads it. What remains is identity: who is calling, and can you verify it?

Most frameworks answer with a token check at the door. A filter validates a bearer token, sets a thread-local variable, and hopes that nothing downstream forgets to look at it. DirtyChai answers differently. Identity lives inside the JVM’s own security machinery: in Subject, in ProtectionDomain, in every permission check. This part covers the two kinds of identity a call carries, how SPIFFE manages process credentials without keystores, and how three identity layers coexist on a single dispatch thread.

Two Subjects, Two Lifetimes

DirtyChai introduces a sealed Subject hierarchy with two distinct identity types, each with its own carrier, lifetime, and routing rule.

Sealed Subject hierarchy: WorkerSubject and UserSubject under the vanilla Subject
WorkerSubject

The JVM process itself. Sealed, permits SpiffeSubject and RemoteSubject only. It carries the process workload identity, a SPIFFE SVID, and is baked into every ProtectionDomain at class load time. It is ambient: present at every checkPermission regardless of doPrivileged nesting, so the server never needs to reinstall it per request. Passing it to callAs() or doAs() is illegal.

UserSubject

A human user. Final. User identity is established via JWT/OIDC using JwtLoginModule from the jgdms-security-jwt module. The resulting UserSubject carries JwtPrincipal instances, e.g., "sub:alice@example.org" and "group:admins", travels in a ScopedValue<Subject[]>, and is installed per-request via Subject.callAs(). Kerberos is supported for legacy deployments.

This separation is also why DirtyChai is retiring Subject.doAs() and doAsPrivileged() in favour of Subject.callAs() plus an explicit doPrivileged where a privilege boundary is actually wanted. Two things classic JAAS fused are orthogonal: who you are and what privileges the code runs with. callAs binds identity on a ScopedValue, and that identity survives doPrivileged. doPrivileged truncates the stack and thereby sheds the remote caller’s stack-borne domains. doAs couples them: used merely to run privileged, it also drops the user, because it rebinds the subject and suppresses the enclosing one. Dropping the user should be deliberate, not a side effect of a code boundary. doAs and doAsPrivileged survive only for legacy JAAS and Kerberos GSS interop.

Identity Without Keystores

Part 1 declared ClientAuthentication.YES as a constraint. TLSv1.3 with X.509 certificates is what satisfies it, and in a fleet of services, long-lived keystores are a management and security liability. JGDMS and DirtyChai integrate SPIFFE workload identity via SPIRE. Each host process and client JVM receives a short-lived X.509 SVID from a local SPIRE agent; the default lifetime is about one hour.

The SpiffeCredentialManager component:

  • Opens the SPIRE Workload API socket on startup
  • Populates an in-memory Subject with the current X.509 certificate and private key; no filesystem keystore
  • Rotates credentials automatically when the SVID nears expiry
  • Triggers policy refresh on each rotation

No keytool, no PKCS#12 files, no manual certificate renewal. Each service’s identity is managed by the SPIRE control plane; revocation and rotation happen without JVM restarts.

The managed SpiffeSubject contains a SpiffePrincipal derived from the SVID URI SAN, e.g., spiffe://jgdms.example.org/host/bae/engine-2, an X500Principal derived from the certificate Subject DN, and the short-lived credential itself, never written to disk. Every server-side dispatch thread automatically carries the SPIFFE worker Subject of the calling client for the lifetime of the remote method invocation. Service implementations write no authentication boilerplate.

Three Layers on One Dispatch Thread

At any checkPermission during a dispatched call, up to three identity layers are in play.

Layer 1, process worker

The local WorkerSubject. JGDMS’s RFC3986URLClassLoader and PreferredClassLoader carry the server’s Principal[] as a final field and inject it into each ProtectionDomain at defineClass time. DirtyChai’s SecureClassLoader simultaneously injects the client’s process Principal[] and the DigestCodeSource, the codebase’s SHA-256 hash. A proxy’s ProtectionDomain therefore carries both sides of the call plus the content hash: a policy grant can scope on the server’s SPIFFE workload, the client’s SPIFFE workload, and the exact JAR content. No single axis alone is sufficient.

Layer 2, remote process

The remote client’s WorkerSubject principals travel inside a serialized AccessControlContext over the JERI wire. AccessControlContextSerializer encodes verifiable domains, those with an httpmd: URL or a DigestCodeSource, by identity; the receiving JVM checks the SHA-256 digest before accepting each one. Unverifiable domains are counted as anonCount, and the receiver reconstructs them as anonymous placeholder ProtectionDomains: their permission ceilings are preserved without asserting a specific identity. All remote domains are shed at doPrivileged boundaries.

Earlier versions stripped unverifiable domains before transport. Version 23 of the serializer removed the practice: unverifiable domains act as permission ceilings, and removing them is an implicit privilege escalation.

Layer 3, user

The per-request UserSubject. The JERI dispatcher binds it via Subject.callAs(userSubject, () → invoke(…​)). Unlike layer 2, it survives doPrivileged.

Service code retrieves the received user identities from the server context:

ClientUserSubject cus = (ClientUserSubject)
    ServerContext.getServerContextElement(ClientUserSubject.class); (1)
Subject[] users = cus.getUserSubjects();                            (2)
Subject primary = cus.getUserSubject();                             (3)
1 Inside a dispatched service method, the server context element holds the wire-transferred user Subjects
2 All user Subjects, outermost first
3 Convenience accessor for single-user callers, equivalent to users[0]

A single RPC can carry both an end-user’s JWT identity and a delegation-chain Subject without any out-of-band negotiation. The wire protocol supports up to 16 user Subjects per call; I’ll detail the format in the next part.

Before the Registry Is Online

Part 2’s Verdict Registry gates codebase loading, but the registry is itself a service. During the boot window, before it is reachable, BootstrapPermission in net.jini.loader.pref, target "loadCodebase", takes its place. Only callers whose ProtectionDomain holds this permission, that is, code already trusted by the bootstrap policy, can trigger a SPIFFE-principal-based codebase load before the registry is online.

The second bootstrap piece is LocalPrincipalProvider, an SPI in org.apache.river.api.security that bridges jgdms-platform and jgdms-jeri. SpiffeCredentialManager.start() registers the managed Subject via Security.registerLocalPrincipalProvider(). When Security.currentPrincipals() is called outside a callAs scope, during bootstrapping or on a plain thread, it falls back to the registered provider rather than returning an empty set.

Conclusion

Identity in this platform is not a filter at the door. The WorkerSubject is in every ProtectionDomain before the first request arrives, the UserSubject is bound per call, and the remote process context travels with the call and is shed at every privilege boundary. No session state, no thread-local leakage between calls, no boilerplate in service code.

A single call can carry up to 16 user Subjects. I’ll cover in the series' next part how they travel on the JERI wire, and what service code does with them on arrival.