Skip to content

Commit 25db897

Browse files
committed
phase 2: messages, session handshake, capability negotiation (gate 2)
1 parent 2f742bd commit 25db897

28 files changed

Lines changed: 1202 additions & 17 deletions
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package dev.arcp.auth;
2+
3+
/**
4+
* Pluggable validator for credentials presented during a session handshake (RFC
5+
* §8). Returns an authenticated {@link Principal} or throws.
6+
*
7+
* <p>
8+
* The SDK provides reference implementations: {@link StaticBearerValidator} and
9+
* {@link JwtValidator}. Production users supply their own.
10+
*/
11+
@FunctionalInterface
12+
public interface CredentialValidator {
13+
14+
/**
15+
* @param credentials
16+
* caller-presented credentials.
17+
* @return the authenticated principal on success.
18+
* @throws dev.arcp.error.ARCPException
19+
* with {@code UNAUTHENTICATED} when the credentials are
20+
* unacceptable.
21+
*/
22+
Principal validate(Credentials credentials);
23+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package dev.arcp.auth;
2+
3+
import com.fasterxml.jackson.annotation.JsonInclude;
4+
import com.fasterxml.jackson.annotation.JsonProperty;
5+
import com.fasterxml.jackson.annotation.JsonSubTypes;
6+
import com.fasterxml.jackson.annotation.JsonTypeInfo;
7+
import org.jspecify.annotations.Nullable;
8+
9+
/**
10+
* Sealed root of credential payloads carried in {@code session.open} and
11+
* {@code session.authenticate} (RFC §8.2). v0.1 ships
12+
* {@link BearerCredentials}, {@link JwtCredentials}, and
13+
* {@link NoneCredentials}.
14+
*/
15+
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "scheme", visible = true)
16+
@JsonSubTypes({@JsonSubTypes.Type(value = Credentials.BearerCredentials.class, name = "bearer"),
17+
@JsonSubTypes.Type(value = Credentials.JwtCredentials.class, name = "signed_jwt"),
18+
@JsonSubTypes.Type(value = Credentials.NoneCredentials.class, name = "none")})
19+
public sealed interface Credentials {
20+
21+
/** @return the canonical scheme name (RFC §8.2). */
22+
String scheme();
23+
24+
/** Cleartext bearer token (§8.2). */
25+
@JsonInclude(JsonInclude.Include.NON_NULL)
26+
record BearerCredentials(@JsonProperty("scheme") String scheme,
27+
@JsonProperty("token") String token) implements Credentials {
28+
public BearerCredentials(String token) {
29+
this("bearer", token);
30+
}
31+
}
32+
33+
/** Signed JWT (§8.2). */
34+
@JsonInclude(JsonInclude.Include.NON_NULL)
35+
record JwtCredentials(@JsonProperty("scheme") String scheme,
36+
@JsonProperty("jwt") String jwt) implements Credentials {
37+
public JwtCredentials(String jwt) {
38+
this("signed_jwt", jwt);
39+
}
40+
}
41+
42+
/**
43+
* Anonymous (§8.2). Only honored when the {@code anonymous} capability is true.
44+
*/
45+
@JsonInclude(JsonInclude.Include.NON_NULL)
46+
record NoneCredentials(@JsonProperty("scheme") String scheme,
47+
@JsonProperty("subject") @Nullable String subject) implements Credentials {
48+
public NoneCredentials() {
49+
this("none", null);
50+
}
51+
}
52+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package dev.arcp.auth;
2+
3+
import com.fasterxml.jackson.annotation.JsonInclude;
4+
import com.fasterxml.jackson.annotation.JsonProperty;
5+
import org.jspecify.annotations.Nullable;
6+
7+
/**
8+
* Identity advertised in {@code session.accepted} (RFC §8.3): the runtime's
9+
* {@code kind}/{@code version}/{@code fingerprint} and the authenticated
10+
* principal's {@code trust_level}.
11+
*/
12+
@JsonInclude(JsonInclude.Include.NON_NULL)
13+
public record Identity(@JsonProperty("kind") String kind, @JsonProperty("version") String version,
14+
@JsonProperty("fingerprint") @Nullable String fingerprint, @JsonProperty("trust_level") String trustLevel) {
15+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package dev.arcp.auth;
2+
3+
import com.nimbusds.jose.JOSEException;
4+
import com.nimbusds.jose.JWSAlgorithm;
5+
import com.nimbusds.jose.JWSVerifier;
6+
import com.nimbusds.jose.crypto.MACVerifier;
7+
import com.nimbusds.jwt.JWTClaimsSet;
8+
import com.nimbusds.jwt.SignedJWT;
9+
import dev.arcp.error.ARCPException;
10+
import dev.arcp.error.ErrorCode;
11+
import java.text.ParseException;
12+
import java.time.Clock;
13+
import java.time.Instant;
14+
import java.util.Objects;
15+
16+
/**
17+
* HMAC-SHA256 JWT validator (RFC §8.2 {@code signed_jwt}). Verifies signature,
18+
* expiry, and issuer; returns a {@link Principal} keyed on the {@code sub}
19+
* claim.
20+
*
21+
* <p>
22+
* Production deployments substitute a richer validator for RSA/EC keys and
23+
* JWKS-backed key rotation; this implementation covers the v0.1 reference
24+
* surface.
25+
*/
26+
public final class JwtValidator implements CredentialValidator {
27+
28+
private final JWSVerifier verifier;
29+
private final String expectedIssuer;
30+
private final Clock clock;
31+
32+
public JwtValidator(byte[] hmacKey, String expectedIssuer) {
33+
this(hmacKey, expectedIssuer, Clock.systemUTC());
34+
}
35+
36+
public JwtValidator(byte[] hmacKey, String expectedIssuer, Clock clock) {
37+
Objects.requireNonNull(hmacKey, "hmacKey");
38+
this.expectedIssuer = Objects.requireNonNull(expectedIssuer, "expectedIssuer");
39+
this.clock = Objects.requireNonNull(clock, "clock");
40+
try {
41+
this.verifier = new MACVerifier(hmacKey);
42+
} catch (JOSEException e) {
43+
throw new ARCPException(ErrorCode.INTERNAL, "could not build JWT verifier", e);
44+
}
45+
}
46+
47+
@Override
48+
public Principal validate(Credentials credentials) {
49+
if (!(credentials instanceof Credentials.JwtCredentials jwt)) {
50+
throw new ARCPException(ErrorCode.UNAUTHENTICATED, "signed_jwt scheme required");
51+
}
52+
SignedJWT parsed;
53+
try {
54+
parsed = SignedJWT.parse(jwt.jwt());
55+
} catch (ParseException e) {
56+
throw new ARCPException(ErrorCode.UNAUTHENTICATED, "malformed JWT", e);
57+
}
58+
if (!JWSAlgorithm.HS256.equals(parsed.getHeader().getAlgorithm())) {
59+
throw new ARCPException(ErrorCode.UNAUTHENTICATED, "expected HS256 JWT");
60+
}
61+
try {
62+
if (!parsed.verify(verifier)) {
63+
throw new ARCPException(ErrorCode.UNAUTHENTICATED, "bad signature");
64+
}
65+
} catch (JOSEException e) {
66+
throw new ARCPException(ErrorCode.UNAUTHENTICATED, "verifier error", e);
67+
}
68+
JWTClaimsSet claims;
69+
try {
70+
claims = parsed.getJWTClaimsSet();
71+
} catch (ParseException e) {
72+
throw new ARCPException(ErrorCode.UNAUTHENTICATED, "malformed claims", e);
73+
}
74+
if (!expectedIssuer.equals(claims.getIssuer())) {
75+
throw new ARCPException(ErrorCode.UNAUTHENTICATED, "bad issuer");
76+
}
77+
if (claims.getExpirationTime() == null || claims.getExpirationTime().toInstant().isBefore(Instant.now(clock))) {
78+
throw new ARCPException(ErrorCode.UNAUTHENTICATED, "expired or missing exp");
79+
}
80+
String subject = claims.getSubject();
81+
if (subject == null || subject.isBlank()) {
82+
throw new ARCPException(ErrorCode.UNAUTHENTICATED, "missing subject");
83+
}
84+
String trust = claims.getClaim("trust_level") instanceof String s ? s : "trusted";
85+
return new Principal(subject, trust);
86+
}
87+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package dev.arcp.auth;
2+
3+
/**
4+
* An authenticated session principal. {@link #subject} is the logical identity
5+
* (e.g. user id, service account); {@link #trustLevel} maps to one of
6+
* {@code untrusted | constrained | trusted | privileged} (RFC §15.3).
7+
*/
8+
public record Principal(String subject, String trustLevel) {
9+
10+
/**
11+
* Anonymous principal used when the {@code anonymous} capability is in force.
12+
*/
13+
public static Principal anonymous() {
14+
return new Principal("anonymous", "untrusted");
15+
}
16+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package dev.arcp.auth;
2+
3+
import dev.arcp.error.ARCPException;
4+
import dev.arcp.error.ErrorCode;
5+
import java.util.Map;
6+
import java.util.Objects;
7+
8+
/**
9+
* In-memory bearer-token validator. Maps each accepted token to a
10+
* {@link Principal}; rejects everything else with
11+
* {@link ErrorCode#UNAUTHENTICATED}. Suitable for tests and reference runtimes;
12+
* not a substitute for a real identity provider.
13+
*/
14+
public final class StaticBearerValidator implements CredentialValidator {
15+
16+
private final Map<String, Principal> tokens;
17+
18+
public StaticBearerValidator(Map<String, Principal> tokens) {
19+
this.tokens = Map.copyOf(Objects.requireNonNull(tokens, "tokens"));
20+
}
21+
22+
@Override
23+
public Principal validate(Credentials credentials) {
24+
if (!(credentials instanceof Credentials.BearerCredentials bearer)) {
25+
throw new ARCPException(ErrorCode.UNAUTHENTICATED, "bearer scheme required");
26+
}
27+
Principal p = tokens.get(bearer.token());
28+
if (p == null) {
29+
throw new ARCPException(ErrorCode.UNAUTHENTICATED, "unknown bearer token");
30+
}
31+
return p;
32+
}
33+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/**
2+
* Authentication schemes (RFC §8.2). v0.1 implements {@code bearer},
3+
* {@code signed_jwt}, and {@code none} (only when capability is negotiated).
4+
*/
5+
@org.jspecify.annotations.NullMarked
6+
package dev.arcp.auth;
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package dev.arcp.capability;
2+
3+
import com.fasterxml.jackson.annotation.JsonInclude;
4+
import com.fasterxml.jackson.annotation.JsonProperty;
5+
import java.util.List;
6+
import java.util.Set;
7+
import org.jspecify.annotations.Nullable;
8+
9+
/**
10+
* Negotiated capability set (RFC §7). Absent boolean capabilities are treated
11+
* as {@code false}; the explicit-{@code false} encoding here matches that
12+
* default. Extension namespaces are listed in {@link #extensions}.
13+
*/
14+
@JsonInclude(JsonInclude.Include.NON_NULL)
15+
public record Capabilities(@JsonProperty("anonymous") boolean anonymous, @JsonProperty("streaming") boolean streaming,
16+
@JsonProperty("human_input") boolean humanInput, @JsonProperty("permissions") boolean permissions,
17+
@JsonProperty("artifacts") boolean artifacts, @JsonProperty("subscriptions") boolean subscriptions,
18+
@JsonProperty("interrupt") boolean interrupt,
19+
@JsonProperty("heartbeat_recovery") @Nullable String heartbeatRecovery,
20+
@JsonProperty("heartbeat_interval_seconds") int heartbeatIntervalSeconds,
21+
@JsonProperty("binary_encoding") @Nullable List<String> binaryEncoding,
22+
@JsonProperty("extensions") @Nullable Set<String> extensions) {
23+
24+
/** Default empty capability set; every flag is false. */
25+
public static final Capabilities NONE = new Capabilities(false, false, false, false, false, false, false, null, 0,
26+
null, null);
27+
28+
/** Capability set advertised by the v0.1 reference runtime. */
29+
public static Capabilities reference() {
30+
return new Capabilities(true, true, true, true, true, true, true, "block", 30, List.of("base64"), Set.of());
31+
}
32+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package dev.arcp.capability;
2+
3+
import java.util.HashSet;
4+
import java.util.List;
5+
import java.util.Set;
6+
import org.jspecify.annotations.Nullable;
7+
8+
/**
9+
* Computes the negotiated intersection of two {@link Capabilities} sets (RFC
10+
* §7). Required-but-unsupported features yield a {@code session.rejected}
11+
* higher up; this class only computes the intersection — call sites enforce the
12+
* {@code required} contract.
13+
*/
14+
public final class CapabilityNegotiator {
15+
16+
private CapabilityNegotiator() {
17+
}
18+
19+
/** AND-intersection of the two sets, used during session.accepted. */
20+
public static Capabilities intersect(Capabilities a, Capabilities b) {
21+
Set<String> exts = new HashSet<>();
22+
if (a.extensions() != null) {
23+
exts.addAll(a.extensions());
24+
}
25+
if (b.extensions() != null) {
26+
exts.retainAll(b.extensions());
27+
} else {
28+
exts.clear();
29+
}
30+
List<String> encoding = intersectList(a.binaryEncoding(), b.binaryEncoding());
31+
String hbRecovery = a.heartbeatRecovery() != null && a.heartbeatRecovery().equals(b.heartbeatRecovery())
32+
? a.heartbeatRecovery()
33+
: null;
34+
int hbInterval = Math.min(positive(a.heartbeatIntervalSeconds(), 30),
35+
positive(b.heartbeatIntervalSeconds(), 30));
36+
return new Capabilities(a.anonymous() && b.anonymous(), a.streaming() && b.streaming(),
37+
a.humanInput() && b.humanInput(), a.permissions() && b.permissions(), a.artifacts() && b.artifacts(),
38+
a.subscriptions() && b.subscriptions(), a.interrupt() && b.interrupt(), hbRecovery, hbInterval,
39+
encoding, exts);
40+
}
41+
42+
private static int positive(int v, int fallback) {
43+
return v <= 0 ? fallback : v;
44+
}
45+
46+
private static List<String> intersectList(@Nullable List<String> a, @Nullable List<String> b) {
47+
if (a == null || b == null) {
48+
return List.of();
49+
}
50+
return a.stream().filter(b::contains).toList();
51+
}
52+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/**
2+
* Capability negotiation primitives (RFC §7). Absent boolean capabilities are
3+
* treated as {@code false}; required-but-unsupported features yield
4+
* {@code session.rejected/UNIMPLEMENTED}.
5+
*/
6+
@org.jspecify.annotations.NullMarked
7+
package dev.arcp.capability;

0 commit comments

Comments
 (0)