Skip to content

Commit d89d0e9

Browse files
authored
Add identity to SDP response (#12)
1 parent 2c9e7e5 commit d89d0e9

6 files changed

Lines changed: 205 additions & 5 deletions

File tree

‎gradle.properties‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
# Only update version on publishing to Maven Central
2-
version=1.7.2
2+
version=1.7.3

‎gradle/libs.versions.toml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ junit = "5.14.1"
44
webrtc-java = "1.0.3"
55
gson = "2.13.2"
66
nmcp = "1.4.0"
7+
jose4j = "0.9.6"
78

89
[libraries]
910
expiringmap = { group = "net.jodah", name = "expiringmap", version = "0.5.10" }
@@ -15,6 +16,7 @@ netty-codec-http = { group = "io.netty", name = "netty-codec-http", version.ref
1516
netty-transport = { group = "io.netty", name = "netty-transport", version.ref = "netty" }
1617
netty-transport-native-unix-common = { group = "io.netty", name = "netty-transport-native-unix-common", version.ref = "netty" }
1718
webrtc-java = { group = "dev.kastle.webrtc", name = "webrtc-java", version.ref = "webrtc-java" }
19+
jose4j = { group = "org.bitbucket.b_c", name = "jose4j", version.ref = "jose4j" }
1820

1921
# Test dependencies
2022
junit-jupiter-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junit" }

‎transport-nethernet/build.gradle.kts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ dependencies {
1111
api(libs.webrtc.java)
1212

1313
implementation(libs.gson)
14+
implementation(libs.jose4j)
1415

1516
testImplementation(libs.bundles.junit)
1617
testRuntimeOnly(libs.junit.platform.launcher)

‎transport-nethernet/src/main/java/dev/kastle/netty/channel/nethernet/NetherNetServerChannel.java‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import dev.kastle.netty.channel.nethernet.config.NetherChannelOption;
55
import dev.kastle.netty.channel.nethernet.signaling.NetherNetServerSignaling;
66
import dev.kastle.netty.channel.nethernet.signaling.NetherNetSignaling.IceServerInfo;
7+
import dev.kastle.netty.util.nethernet.ServerIdentity;
78
import dev.kastle.webrtc.CreateSessionDescriptionObserver;
89
import dev.kastle.webrtc.PeerConnectionFactory;
910
import dev.kastle.webrtc.PeerConnectionObserver;
@@ -25,6 +26,7 @@
2526
import io.netty.util.concurrent.ScheduledFuture;
2627
import io.netty.util.internal.logging.InternalLogger;
2728
import io.netty.util.internal.logging.InternalLoggerFactory;
29+
import org.jose4j.lang.JoseException;
2830

2931
import java.net.InetSocketAddress;
3032
import java.net.SocketAddress;
@@ -42,6 +44,8 @@ public class NetherNetServerChannel extends AbstractServerChannel {
4244
private InetSocketAddress localAddress;
4345
private volatile boolean open = true;
4446

47+
private ServerIdentity serverIdentity;
48+
4549
/**
4650
* Creates a NetherNetServerChannel with a new PeerConnectionFactory.
4751
*
@@ -61,6 +65,11 @@ public NetherNetServerChannel(PeerConnectionFactory factory, NetherNetServerSign
6165
this.factory = factory;
6266
this.signaling = signaling;
6367
this.config = new DefaultNetherServerChannelConfig(this);
68+
try {
69+
this.serverIdentity = ServerIdentity.generate("self");
70+
} catch (Exception e) {
71+
throw new RuntimeException(e);
72+
}
6473
}
6574

6675
@Override
@@ -146,10 +155,14 @@ public void onSuccess(RTCSessionDescription description) {
146155
@Override
147156
public void onSuccess() {
148157
log.trace("Sending Answer SDP for {}", Long.toUnsignedString(connectionId));
149-
signaling.sendSignal(
150-
remoteNetworkId,
151-
NetherNetConstants.buildSignalConnectResponse(connectionId, description.sdp)
152-
);
158+
try {
159+
signaling.sendSignal(
160+
remoteNetworkId,
161+
NetherNetConstants.buildSignalConnectResponse(connectionId, serverIdentity.augmentAnswer(description.sdp))
162+
);
163+
} catch (JoseException e) {
164+
throw new RuntimeException(e);
165+
}
153166
pipeline().fireChannelRead(child);
154167
}
155168
@Override public void onFailure(String error) { log.error("SetLocalDesc failed: {}", error); }
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package dev.kastle.netty.util.nethernet;
2+
3+
import com.google.gson.Gson;
4+
5+
import java.nio.charset.StandardCharsets;
6+
import java.util.Base64;
7+
8+
public record Identity(Idp idp, Assertion assertion) {
9+
private static Gson gson = new Gson();
10+
11+
private record Raw(Idp idp, String assertion) {}
12+
13+
public record Idp(String domain, String protocol) {}
14+
15+
public record Assertion(String token, String fingerprints) {}
16+
17+
public String toJson() {
18+
return gson.toJson(new Raw(idp, gson.toJson(assertion)));
19+
}
20+
21+
public String toBase64() {
22+
return Base64.getEncoder().encodeToString(toJson().getBytes(StandardCharsets.UTF_8));
23+
}
24+
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
package dev.kastle.netty.util.nethernet;
2+
3+
import org.jose4j.jwk.EcJwkGenerator;
4+
import org.jose4j.jwk.EllipticCurveJsonWebKey;
5+
import org.jose4j.jws.AlgorithmIdentifiers;
6+
import org.jose4j.jws.JsonWebSignature;
7+
import org.jose4j.jwt.JwtClaims;
8+
import org.jose4j.jwt.NumericDate;
9+
import org.jose4j.keys.EllipticCurves;
10+
import org.jose4j.lang.JoseException;
11+
12+
import java.security.PrivateKey;
13+
import java.security.PublicKey;
14+
import java.time.Instant;
15+
import java.util.ArrayList;
16+
import java.util.Arrays;
17+
import java.util.Base64;
18+
import java.util.List;
19+
import java.util.stream.Collectors;
20+
21+
/**
22+
* Produces the server-side identity assertion for each SDP answer
23+
*
24+
* @see <a href="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/Mojang/bedrock-protocol-docs/blob/7330880ab78ef001cad0b9cdfedb3aa3eaa6d4af/NetherNetOnboardingGuide.md#52-producing-the-server-assertion-in-the-answer">NetherNet onboarding guide, section 5.2</a>
25+
*/
26+
public class ServerIdentity {
27+
private static final String ALG = AlgorithmIdentifiers.ECDSA_USING_P384_CURVE_AND_SHA384; // ES384 / P-384
28+
29+
private final PrivateKey privateKey;
30+
private final String domain;
31+
private final String token;
32+
33+
public ServerIdentity(PrivateKey privateKey, PublicKey publicKey, Instant expiry, String domain) throws JoseException {
34+
this.privateKey = privateKey;
35+
this.domain = domain;
36+
this.token = buildToken(publicKey, expiry);
37+
}
38+
39+
/**
40+
* Generate a brand-new server identity that is not stored
41+
*
42+
* @param domain The domain name for the server identity
43+
* @return A new ServerIdentity instance
44+
* @throws JoseException If there is an error creating the JWT
45+
*/
46+
public static ServerIdentity generate(String domain) throws JoseException {
47+
EllipticCurveJsonWebKey jwk = EcJwkGenerator.generateJwk(EllipticCurves.P384);
48+
return new ServerIdentity(jwk.getPrivateKey(), jwk.getPublicKey(), null, domain);
49+
}
50+
51+
/**
52+
* Build a JWT token with the given public key and expiry.
53+
*
54+
* @param publicKey The public key to include in the token
55+
* @param expiry The expiration time of the token
56+
* @return The signed JWT token
57+
* @throws JoseException If there is an error signing the token
58+
*/
59+
private String buildToken(PublicKey publicKey, Instant expiry) throws JoseException {
60+
JwtClaims claims = new JwtClaims();
61+
claims.setClaim("cpk", Base64.getEncoder().encodeToString(publicKey.getEncoded())); // Custom claim required by the NetherNet spec
62+
claims.setIssuedAtToNow();
63+
64+
// If we have a domain set it as the isser as it could be shown to the user
65+
if (domain != null && !domain.isBlank()) {
66+
claims.setIssuer(domain);
67+
}
68+
69+
// Mirror the certificate expiry if set
70+
if (expiry != null) {
71+
claims.setExpirationTime(NumericDate.fromMilliseconds(expiry.toEpochMilli()));
72+
}
73+
74+
return sign(claims.toJson());
75+
}
76+
77+
/**
78+
* Sign the payload with the private key and return the compact JWS serialization.
79+
*
80+
* @param payload The payload to sign
81+
* @return The compact JWS serialization
82+
* @throws JoseException If there is an error signing the payload
83+
*/
84+
private String sign(String payload) throws JoseException {
85+
JsonWebSignature jws = new JsonWebSignature();
86+
jws.setPayload(payload);
87+
jws.setKey(privateKey);
88+
jws.setAlgorithmHeaderValue(ALG);
89+
return jws.getCompactSerialization();
90+
}
91+
92+
/**
93+
* Generate the identity value as base64 for this answer SDP
94+
*
95+
* @param answerSdp The SDP to generate the identity value for
96+
* @return The base64 identity value
97+
* @throws JoseException If there is an error signing the identity value
98+
*/
99+
public String identityValue(String answerSdp) throws JoseException {
100+
// Generate and sign the fingerprint
101+
String[] fingerprintParts = sign(getCanonicalFingerprintJson(answerSdp)).split("\\.");
102+
String fingerprints = fingerprintParts[0] + ".." + fingerprintParts[2];
103+
104+
Identity.Assertion assertion = new Identity.Assertion(token, fingerprints);
105+
Identity.Idp idp = new Identity.Idp(domain, "default");
106+
return new Identity(idp, assertion).toBase64();
107+
}
108+
109+
/**
110+
* Insert the identity into the answer SDP
111+
* The specific placement is a strange requirement for the spec but we will follow it
112+
*
113+
* @param answerSdp The SDP to insert the identity into
114+
* @return The SDP with the identity inserted
115+
* @throws JoseException If there is an error signing the identity value
116+
*/
117+
public String augmentAnswer(String answerSdp) throws JoseException {
118+
String line = "a=identity:" + identityValue(answerSdp);
119+
String eol = answerSdp.contains("\r\n") ? "\r\n" : "\n";
120+
121+
String[] lines = answerSdp.split("\r\n|\n", -1);
122+
List<String> out = new ArrayList<>(lines.length + 1);
123+
124+
boolean inserted = false;
125+
for (String current : lines) {
126+
if (!inserted && current.startsWith("m=")) {
127+
out.add(line);
128+
inserted = true;
129+
}
130+
out.add(current);
131+
}
132+
133+
if (!inserted) {
134+
out.add(line);
135+
}
136+
137+
return String.join(eol, out);
138+
}
139+
140+
/**
141+
* Get the canonical fingerprint JSON from the SDP offer
142+
*
143+
* @param sdpOffer The SDP offer to extract fingerprints from
144+
* @return The canonical fingerprint JSON
145+
*/
146+
private static String getCanonicalFingerprintJson(String sdpOffer) {
147+
String prefix = "a=fingerprint:";
148+
return Arrays.stream(sdpOffer.split("\n"))
149+
.filter(line -> line.startsWith(prefix))
150+
.map(line -> line.substring(prefix.length()).trim())
151+
.map(line -> {
152+
String[] parts = line.split(" ");
153+
if (parts.length != 2) {
154+
throw new IllegalArgumentException("Invalid fingerprint line: " + line);
155+
}
156+
return "{\"algorithm\":\"" + parts[0] + "\",\"digest\":\"" + parts[1] + "\"}";
157+
})
158+
.collect(Collectors.joining(",", "{\"fingerprint\":[", "]}"));
159+
}
160+
}

0 commit comments

Comments
 (0)