Technology9 min read

Security Baked Into the JVM: DirtyChai scales vertically, JGDMS scales horizontally

By · Published by Everything Blog

In short

The article uncovers that SecurityManager is not set in production environments, a practice observed in OpenJDK 23 but not in later versions. This oversight allows DirtyChai to scale vertically within a single JVM and JGDMS to scale horizontally across a fleet of JVMs. The article notes that the cost of setting SecurityManager is independent of throughput benefits, emphasizing a design choice that simplifies management.

Key points

  • JGDMS enables horizontal scaling across a fleet of JVMs.: JGDMS enables horizontal scaling across a fleet of JVMs.
  • SecurityManager is not set in production environments, a practice observed in OpenJDK 23…: SecurityManager is not set in production environments, a practice observed in OpenJDK 23 but not in later versions.

Sources checked by our analysis pipeline: 1

Nobody runs a SecurityManager

in production.

The usual reason given is that it does not survive a load test, and every guarantee in this series was bought with a check: a constraint on each call, an audit before each load, a principal set on each thread, a digest on each grant.

Part 1 declared constraints on a remote call. Part 2 vetted code before a client loaded it. Part 3 and Part 4 established who is calling and carried that chain across the wire. Part 5 decided what the combination is allowed to do. This last part is about what all of it costs.

Part 1 stated the thesis in two lines, and this part is the evidence for them. DirtyChai scales vertically, inside one JVM. JGDMS scales horizontally, across a fleet of them.

A Virtual Thread With No Permissions

JEP 444 states the starting point in one sentence: "Virtual threads have no permissions when running with a SecurityManager

set."

On a stock OpenJDK up to 23, that is the end of the story.

From 24 onward, there is no SecurityManager

to set.

That empty inherited context is a design choice, and DirtyChai’s fix for it is one line: a virtual thread is built with the context of the thread that created it.

The cost question is separate.

Every getContext

and every permission check recombines the context, and ProtectionDomain

compares by object identity, so two domains that describe the same code under the same principals count as two different domains.

Without value equality, each recombination is a new object that nothing can recognise, and that is where DirtyChai spends its effort.

DomainIdentity

-

A

ProtectionDomain

subclass whoseequals

andhashCode

are computed from its fields: code source, permissions, class loader, and principals. Its javadoc is explicit about the scope: it exists "to limit duplication ofProtectionDomain

s stored inAccessControlContext

's cache", and it is meant for the temporary domains built during a privileged action, never for the domain a class loader assigns to a class. - Cached contexts

-

AccessControlContext

is immutable, and its factory consults a non-blocking cache once the VM has finished booting, so a check made from the same code path under the same Subjects finds its context already there. Two contexts are equal when their domains are, which is why `DomainIdentity’s value equality matters.

SubjectDomainCombiner

is where the two meet.

On each combine, it walks the current domains and replaces every ordinary one with a DomainIdentity

carrying the merged principals of every Subject

in scope:

if (pd.staticPermissionsOnly() || pd.implies(ALL_PERMISSION)) { (1)

subjectPd = pd;

} else {

Principal[] pals = merge(pd.getPrincipals(), mergedPrincipals); (2)

subjectPd = new DomainIdentity(pd.getCodeSource(),

pd.getPermissions(),

pd.getClassLoader(),

pals); (3)

}

domainSet.add(subjectPd); (4)

1 | Static and fully privileged domains are left as they are. Adding principals to a domain that already holds AllPermission changes nothing. |

2 | The principals of every Subject on the thread, worker and user alike, are appended to the domain’s own. |

3 | The replacement is a DomainIdentity , so a second combine with the same inputs produces an equal domain. |

4 | The set deduplicates on that equality, and so does the context built from it: two combines from the same inputs yield equal contexts, which is what lets the cache hand back the first one. |

The security consequence matters more to me than the throughput one.

Each virtual thread runs under the immutable context captured from the thread that created it, never from the carrier it happens to run on.

Two requests that share a carrier thread, the platform thread that runs virtual threads in turn, share nothing else: one cannot inherit the principals of the other, and every checkPermission

is evaluated against the identities of the request that made it.

The remaining risk is pinning: a blocking class initializer holds the carrier, and every virtual thread queued behind it waits.

That is the case the ClinitBlockingVisitor

from Part 2 flags before the JAR is ever loaded.

Checks That Do Not Queue

The standard Java policy blocked inside the check itself: CodeSource.implies

and URL.equals

went to disk and to DNS.

With thousands of virtual threads each making dozens of checks per request, that made it unusable in production.

DirtyChai’s ConcurrentPolicyFile

removes the lock and, more surprisingly, the cache.

The class comment gives the reasoning in one line:

Caching limits scalability and consumes shared memory, so no cache exists.

Grants are published once and never mutated, so implies

can run concurrently against them.

Matching a code source against a grant is a structural comparison of RFC 3986 URIs.

There is no DNS lookup and no reverse lookup.

A grant made to an IP address never implies the same host by name, nor the reverse.

The same evaluation path serves all three providers from Part 5, so the stacked policy costs what one flat file would.

On my current measurements, a permission check on DirtyChai under a least-privilege policy with no AllPermission

costs about 1.9 µs on a platform thread and about 4.7 µs on a virtual thread; what share of a request that represents depends entirely on what the request does.

Where caching does pay, it sits one level up.

CombinerSecurityManager

caches the result of a check per context, non-blocking, and prunes stale entries.

When a stack carries four or more domains, it fans the check out across virtual threads and waits on a latch; shorter stacks are checked inline, because they are usually privileged and return at once.

Class loading follows the same rule.

RFC3986URLClassLoader

treats a codebase URL as an identifier to compare, not a name to resolve, which is where most of the time in URLClassLoader

goes.

Adding a Node Changes Nothing Else

The other half of the thesis is that a fleet grows without reconfiguring what is already running.

Part 2’s audit pipeline has three moving parts: a codebase downloader that fetches each new JAR, a pool of BAEs that inspect its bytecode, and a verdict registry that publishes SAFE

or DANGEROUS

once enough engines agree.

- Stateless engines

-

An

AnalysisRequest

carries the JAR bytes and their SHA-256 hash. The bytes are normalised to a byte-reproducible form, so the same JAR always hashes the same. The downloader fetches, normalises and hashes; the engine needs no outbound network access at all. During deserialization,check(GetArg)

recomputes the hash from the bytes and rejects the request if it differs, so an engine can never analyse one artifact while reporting under another’s hash. Any engine can take any request. Capacity is one more process that registers itself with the lookup service. - Quorum

-

The registry publishes

SAFE

only oncequorumMinimum

engines, a registry setting, have independently agreed. Adding an engine therefore raises throughput and confidence in the same move. - Content-addressed verdicts

-

The registry’s stores are

ConcurrentHashMap

s keyed by the hex digest, with lock-free reads. A JAR is analysed once, however many services serve it, and a CDN migration invalidates nothing. - Leases

-

Every registration and every event subscription expires unless renewed; a verdict listener’s lease can be extended to one day at most. A crashed service cleans itself out of the registry by doing nothing.

- Replicated lookup

-

A service registers with several lookup services at once, and a client asks several of them through

LookupDiscoveryManager

, which merges the answers. No single lookup service is a dependency.

When the Registry Is Away

Scale cuts both ways: the more nodes, the more often one of them is unreachable.

PreferredProxyCodebaseProvider

keeps an in-memory cache of signed verdicts, keyed by digest.

It is consulted only after every registry replica has failed all its retries, and only within a TTL:

Property | Default | Effect |

---|---|---|

|

|

Age limit of a cached verdict; |

|

|

Connect and read timeout per JAR |

|

|

Per-JAR byte limit, the fetch is aborted past it |

|

|

Maximum JARs per codebase annotation |

The cache never invents a verdict.

It stores whatever the registry signed, and a cached verdict is signature-checked again before use, so a cached DANGEROUS

still refuses the JAR.

An outage can delay a revocation by up to the TTL, but it can never upgrade a verdict.

The three download limits bound what a hostile codebase can make a client fetch.

And because a codebase annotation ships its digests as one flat byte array plus an offset table, validateDigestOffsets()

checks that every offset is in order and inside the array before a single digest is read.

One Address to Join the Fleet

Discovery is where a fleet starts.

A client needs one address, lookup.example.org:4160

, which a LookupLocator

turns into a lookup service and, from there, into every service registered with it; 4160

is the default port of the jini:

scheme.

Each unicast discovery exchange is hash-verified at both ends before any response is sent, with SHA-224 through SHA-512 available by configuration.

DownloadPermission

is granted dynamically to a lookup service that has authenticated itself, so a rogue one cannot talk a client into loading code.

DeSerializationPermission

is granted the same way, but it only matters while a client still accepts the Java serialization wire protocol, which is on its way out.

And SafeServiceRegistrar.lookUp

hands back a bootstrap proxy, a minimal stub that can only authenticate the service and fetch its verdict, rather than the full proxy: trust is established, and the registry’s SAFE

verdict obtained, before the client unmarshals anything.

Conclusion

At the start of this series I asked why anyone would fork both Apache River and OpenJDK.

The first five parts answered with guarantees; this one answers with the bill.

DomainIdentity

makes a recombined context equal to the last one, so the cache can hand it back.

ConcurrentPolicyFile

keeps a permission check off any lock.

An engine pool or a lookup tier grows by starting one more process.

The digest that makes a verdict trustworthy also makes it cacheable.

The immutable context that isolates two requests also makes it safe to share.

Without that, nothing in the previous five parts would survive a load test, which was the objection I started from.

Original source: blog.frankel.ch

Technology