A URL is a name, and names get reused.
Replace the bytes behind https://repo.example.org/order-processor.jar and every permission granted to that URL still applies, now to code nobody audited.
So far in this series, Part 1 declared constraints on a remote call, Part 2 vetted code before a client loaded it, Part 3 established who is calling, and Part 4 carried that chain of identities across the wire.
This part is about what the caller is then allowed to do:
- how incoming arguments are validated before they become objects,
- why permissions are granted to what the code is, rather than where it came from,
- how three policy layers combine so that no single party can widen its own authority.
Nothing Exists Before It Is Checked
Standard Java deserialization creates an object first and validates it afterwards.
By the time readObject() runs, the instance already exists, and a crafted stream has already had its chance:
a back-reference elsewhere in the stream can capture the half-built object,
classes you never intended to create have already been instantiated,
and oversized arrays or collections can exhaust the heap before any check runs.
JEP 290 serialization filters help by limiting which classes may appear in the stream, but they treat the symptom.
As the AtomicSerial javadoc puts it, filters "don’t address the underlying cause; the creation of objects prior to their validation".
JGDMS’s @AtomicSerial reverses the order.
An annotated class declares the named, typed values it expects on the wire, and receives them through a constructor.
The constructor validates each value before assigning it.
If validation fails, the constructor throws, and no object is ever created.
SpiffePrincipal, which represents a workload identity (see Part 3), is the smallest complete example:
@AtomicSerial
public final class SpiffePrincipal implements Principal {
private final String spiffeId;
public static SerialForm[] serialForm() { (1)
return new SerialForm[]{
new SerialForm("spiffeId", String.class)
};
}
public SpiffePrincipal(GetArg arg) (2)
throws IOException, ClassNotFoundException {
this.spiffeId = checkCanonical(arg.get("spiffeId", null, (3)
String.class));
}
}
| 1 | The wire format is declared separately from the class’s fields, so the fields can be renamed or refactored without breaking compatibility. |
| 2 | GetArg provides the values read from the stream, looked up by name and type. |
| 3 | checkCanonical() throws InvalidObjectException if the SPIFFE ID is malformed. The field is never assigned and the constructor never completes. |
In a class hierarchy, each subclass validates in its super(…) call, as in super(check(arg)), so every level checks its invariants before anything is assigned.
"Atomic" here refers to failure: either every level of the object is valid, or no object exists, and there is no partially built reference for an attacker to grab.
This only helps if the server actually reads arguments this way.
In JGDMS that is a per-method constraint, AtomicInputValidation.YES, declared and negotiated like the other constraints from Part 1 rather than switched on globally.
When it applies, method arguments are read with the atomic protocol instead of a plain ObjectInputStream.
The URL Is Not the Identity
A classic Java policy grants permissions to a CodeSource: essentially a URL, plus optional signer certificates.
That ties authority to where code came from, not what it is.
DirtyChai adds DigestCodeSource, a CodeSource that also records a cryptographic digest of the JAR the classes were loaded from.
JGDMS adds a matching grant type, DigestGrant, with policy-file syntax to express it:
grant digest "SHA-256:6e340b9cffb37a989ca544e6bb780a2c78901d3fb33738768511a30617afa01d"
principal net.jini.security.jwt.JwtPrincipal "sub:alice@example.org"
principal au.net.zeus.jgdms.spiffe.SpiffePrincipal "spiffe://example.org/svc/order-processor" {
permission net.jini.security.AccessPermission "submitOrder";
};
In plain terms: code whose JAR hashes to this value, running on behalf of both Alice and the order-processor workload, may call submitOrder.
DigestGrant doesn’t look at the URL at all.
The source comment is short:
Ignore URL, it doesn’t define identity.
Every edge case answers "no":
- A plain
CodeSourcenever matches, even one with exactly the same URL. - If the policy is asked about a class loader rather than a code source, the grant doesn’t apply, because the digest of code that is already loaded can’t be established.
- On a stock JDK, which has no
DigestCodeSource, JGDMS still runs, but digest grants never match. They don’t fall back to matching on the URL. They stop applying.
Three Policy Layers
A node’s policy is assembled from three nested providers, each answering to a different authority:
| Provider | Controlled by | Supplies |
|---|---|---|
|
The operator, through the infrastructure’s policy server |
Low-level bootstrap grants for the node itself, fetched over HTTPS at startup and authenticated with the node’s own workload certificate (its X.509 SVID). |
|
The djinn administrator |
Policy for the whole djinn, the group of nodes and services that make up a JGDMS deployment. Its main job is deciding which users may delegate which permissions, by granting them `GrantPermission`s. |
|
The client user and the service proxy |
Grants to a downloaded proxy once it has been verified (see Part 2): the permissions the proxy declares it needs, limited to what the user is allowed to delegate. |
SpiffePolicyFile is the foundation.
If a node can’t fetch and parse its policy at startup, it refuses to start rather than running with an empty or default policy.
It fetches the policy again each time the workload certificate is rotated, so grants stay current.
If that refresh fails, the node keeps its last good policy.
RemotePolicyProvider accepts policy only from callers holding PolicyPermission("Remote").
Each grant it receives is checked too: the sender must hold a GrantPermission for every permission in it.
It receives grants as plain text in ordinary policy-file syntax, which each node parses locally.
Earlier versions shipped serialized Permission objects; since JGDMS 4.0, no permission is ever deserialized off the wire, which removes a whole class of attack.
When the policy changes, nodes are notified and fetch the current grants.
DynamicPolicyProvider is where delegation happens.
A service proxy declares the permissions it needs, typically to download its code and connect back to its service.
When the client prepares the proxy, those permissions are granted on the user’s authority, and never beyond what the user holds a GrantPermission for.
Each grant is tied to the proxy’s class loader.
When that loader is garbage-collected, its grants become void and are cleaned up in the background.
Every Check Has to Pass
The outcome works like an intersection. In the code, it comes from independent checks, each of which has to pass. Two of them apply each time a permission is used:
- Code. For a static grant, the JAR’s digest must match the value in the grant. For a dynamic grant, the code must come from the prepared proxy’s class loader.
- Identity. Every principal named in the grant must be present on the call.
The
submitOrderexample above needs both Alice and the order-processor workload.
The third applies when a dynamic grant is made, as a proxy is prepared:
- Ceiling. The proxy never gets more than it declared, nor more than the user is allowed to delegate.
The administrator decides who holds which
GrantPermission, so a user can only hand out authority they have been trusted to hand out.
Compromising any single input gets an attacker nothing:
- Swap the JAR at the same URL, and the digest no longer matches.
- Steal Alice’s token, and this grant still needs the order-processor workload on the call.
- Take over the order processor, and it can only call
submitOrderwhile Alice is on the call. - Serve a proxy that asks for more than it should, and it still gets no more than the user was allowed to delegate.
The administrator sets what each user may delegate. It can also grant to a digest directly, but only what the bootstrap policy allows it to grant. The user can delegate only within that ceiling. The proxy receives no more than it declared. And none of it applies unless the code and the caller’s identities match. Beneath all of this, the bootstrap policy is the node’s own root of trust.
Conclusion
Arguments are validated before objects exist.
A grant applies only when both the code and the identity pass their checks, and authority delegated to downloaded code never exceeds what the delegating user was trusted with.
I keep coming back to how the system behaves when something is missing.
A stock JDK, a policy server unreachable at startup, a plain CodeSource at exactly the right URL: each one answers no.
Every part so far has been about correctness. I’ll cover in the series' next part what all this costs under load, and why, on this platform, security and performance pull in the same direction.