Skip to content

Commit f7a1d73

Browse files
JWS: added basic support for HMAC-based signing
1 parent d489d4b commit f7a1d73

15 files changed

Lines changed: 556 additions & 3 deletions

File tree

‎src/main/java/module-info.java‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
exports org.cryptomator.jose;
66
exports org.cryptomator.jose.builder;
77
exports org.cryptomator.jose.parser;
8+
exports org.cryptomator.jose.jws;
89

910
exports org.cryptomator.jose.hpke to java.base;
1011
// provides Provider with org.cryptomator.jose.hpke.XwingProvider; // only required, if we want to find the provider by name
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package org.cryptomator.jose;
2+
3+
import org.cryptomator.jose.jws.JWSBuilder;
4+
import org.cryptomator.jose.jws.JWSParser;
5+
import org.cryptomator.jose.jws.ParsedJWS;
6+
7+
import java.util.Set;
8+
9+
/// Entry point for JSON Web Signatures ([RFC 7515](https://www.rfc-editor.org/rfc/rfc7515)) in compact serialization with MAC-based algorithms.
10+
///
11+
/// {@snippet :
12+
///var token = JWS.build("{\"sub\":\"alice\"}")
13+
/// .withProtectedHeader(header) // e.g. typ, kid
14+
/// .sign(MacAlg.hs256(key));
15+
///
16+
///var verified = JWS.parse(token).verify(MacAlg.hs256(key));
17+
///var payload = verified.payload();
18+
/// }
19+
public interface JWS {
20+
21+
/// @param payload The JWS Payload (UTF-8 text, e.g. a JSON document)
22+
/// @return A builder for signing the payload
23+
static JWSBuilder build(String payload) {
24+
return JWSBuilder.withPayload(payload);
25+
}
26+
27+
/// Parses a JWS in compact serialization without verifying it.
28+
///
29+
/// @param token The serialized JWS
30+
/// @param understoodCrit Extension header parameter names the caller understands and therefore accepts in `crit`
31+
/// ([RFC 7515, Section 4.1.11](https://www.rfc-editor.org/rfc/rfc7515#section-4.1.11))
32+
/// @return The parsed, not yet verified JWS
33+
/// @throws JoseParseException if the token is malformed, violates the JOSE Header rules, or lists a critical extension that is not understood
34+
static ParsedJWS parse(String token, String... understoodCrit) throws JoseParseException {
35+
return JWSParser.parse(token, Set.of(understoodCrit));
36+
}
37+
38+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package org.cryptomator.jose;
2+
3+
/// A JWS could not be verified: no provided algorithm matches, or the signature is invalid.
4+
public class JoseVerifyException extends JoseException {
5+
6+
public JoseVerifyException(String message) {
7+
super(message);
8+
}
9+
10+
public JoseVerifyException(String message, Throwable cause) {
11+
super(message, cause);
12+
}
13+
14+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package org.cryptomator.jose;
2+
3+
import org.cryptomator.jose.alg.HmacAlg;
4+
5+
import javax.crypto.SecretKey;
6+
7+
/// A JWS algorithm using a MAC, identified by its `alg` header value ([RFC 7518, Section 3.2](https://www.rfc-editor.org/rfc/rfc7518#section-3.2)).
8+
/// The same key signs and verifies, so one instance serves both directions. Independent of the JWE [Alg] hierarchy.
9+
public sealed interface MacAlg permits HmacAlg {
10+
11+
/// @return The `alg` (algorithm) header parameter value identifying this algorithm
12+
String name();
13+
14+
/// @param signingInput The JWS Signing Input, i.e. `ASCII(BASE64URL(UTF8(JWS Protected Header)) || '.' || BASE64URL(JWS Payload))`
15+
/// @return The JWS Signature
16+
byte[] sign(byte[] signingInput);
17+
18+
/// @param key The HMAC key, at least 256 bits long, which remains owned (and eventually destroyed) by the caller
19+
/// @return `HS256` (HMAC using SHA-256)
20+
/// @throws IllegalArgumentException if the key is shorter than the hash output
21+
static MacAlg hs256(SecretKey key) {
22+
return new HmacAlg(HmacAlg.Type.HS256, key);
23+
}
24+
25+
/// @param key The HMAC key, at least 384 bits long, which remains owned (and eventually destroyed) by the caller
26+
/// @return `HS384` (HMAC using SHA-384)
27+
/// @throws IllegalArgumentException if the key is shorter than the hash output
28+
static MacAlg hs384(SecretKey key) {
29+
return new HmacAlg(HmacAlg.Type.HS384, key);
30+
}
31+
32+
/// @param key The HMAC key, at least 512 bits long, which remains owned (and eventually destroyed) by the caller
33+
/// @return `HS512` (HMAC using SHA-512)
34+
/// @throws IllegalArgumentException if the key is shorter than the hash output
35+
static MacAlg hs512(SecretKey key) {
36+
return new HmacAlg(HmacAlg.Type.HS512, key);
37+
}
38+
39+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package org.cryptomator.jose.alg;
2+
3+
import org.cryptomator.jose.MacAlg;
4+
5+
import javax.crypto.Mac;
6+
import javax.crypto.SecretKey;
7+
import java.security.InvalidKeyException;
8+
import java.security.NoSuchAlgorithmException;
9+
import java.util.Arrays;
10+
11+
/// HMAC with SHA-2 for JWS (`HS256`, `HS384`, `HS512`), see [RFC 7518, Section 3.2](https://www.rfc-editor.org/rfc/rfc7518#section-3.2).
12+
public final class HmacAlg implements MacAlg {
13+
14+
public enum Type {
15+
HS256("HS256", "HmacSHA256", 32),
16+
HS384("HS384", "HmacSHA384", 48),
17+
HS512("HS512", "HmacSHA512", 64),
18+
;
19+
20+
private final String jwaAlgName;
21+
private final String jcaAlgName;
22+
private final int minKeyLength; // RFC 7518 §3.2: a key of the same size as the hash output or larger MUST be used
23+
24+
Type(String jwaAlgName, String jcaAlgName, int minKeyLength) {
25+
this.jwaAlgName = jwaAlgName;
26+
this.jcaAlgName = jcaAlgName;
27+
this.minKeyLength = minKeyLength;
28+
}
29+
}
30+
31+
private final Type type;
32+
private final SecretKey key;
33+
34+
/// @param type The HMAC variant
35+
/// @param key The HMAC key, which remains owned (and eventually destroyed) by the caller
36+
/// @throws IllegalArgumentException if the key is shorter than the hash output
37+
public HmacAlg(Type type, SecretKey key) {
38+
var encoded = key.getEncoded();
39+
if (encoded == null) {
40+
throw new IllegalArgumentException("Key must be extractable"); // e.g. hardware-backed keys are not supported
41+
}
42+
try {
43+
if (encoded.length < type.minKeyLength) {
44+
throw new IllegalArgumentException(type.jwaAlgName + " requires a key of at least " + type.minKeyLength * Byte.SIZE + " bits, but got " + encoded.length * Byte.SIZE);
45+
}
46+
} finally {
47+
Arrays.fill(encoded, (byte) 0x00);
48+
}
49+
this.type = type;
50+
this.key = key;
51+
}
52+
53+
@Override
54+
public String name() {
55+
return type.jwaAlgName;
56+
}
57+
58+
@Override
59+
public byte[] sign(byte[] signingInput) {
60+
try {
61+
var mac = Mac.getInstance(type.jcaAlgName);
62+
mac.init(key);
63+
return mac.doFinal(signingInput);
64+
} catch (NoSuchAlgorithmException e) {
65+
throw new UnsupportedOperationException("JVM does not support " + type.jcaAlgName, e);
66+
} catch (InvalidKeyException e) {
67+
throw new IllegalStateException("Unsuitable key for " + type.jcaAlgName, e);
68+
}
69+
}
70+
71+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package org.cryptomator.jose.jws;
2+
3+
import com.google.gson.JsonObject;
4+
import org.cryptomator.jose.MacAlg;
5+
import org.cryptomator.jose.util.Ascii;
6+
7+
import java.nio.charset.StandardCharsets;
8+
import java.util.Base64;
9+
import java.util.Objects;
10+
11+
/// Builds a JWS in compact serialization, see [RFC 7515, Section 5.1](https://www.rfc-editor.org/rfc/rfc7515#section-5.1).
12+
///
13+
/// @param payload The JWS Payload
14+
/// @param protectedHeader Caller-supplied JWS Protected Header parameters (e.g. `typ`, `kid`); `alg` is added when signing
15+
public record JWSBuilder(String payload, JsonObject protectedHeader) {
16+
17+
public JWSBuilder {
18+
Objects.requireNonNull(payload, "payload");
19+
protectedHeader = protectedHeader == null ? new JsonObject() : protectedHeader.deepCopy();
20+
}
21+
22+
public static JWSBuilder withPayload(String payload) {
23+
return new JWSBuilder(payload, new JsonObject());
24+
}
25+
26+
/// @param protectedHeader Header parameters replacing the current ones
27+
/// @return A copy of this builder with the given protected header
28+
public JWSBuilder withProtectedHeader(JsonObject protectedHeader) {
29+
return new JWSBuilder(payload, protectedHeader);
30+
}
31+
32+
/// Signs the payload, producing the compact serialization `BASE64URL(UTF8(JWS Protected Header)) || '.' || BASE64URL(JWS Payload) || '.' || BASE64URL(JWS Signature)`.
33+
///
34+
/// @param alg The MAC algorithm, whose name becomes the `alg` header parameter
35+
/// @return The JWS in compact serialization
36+
/// @throws IllegalArgumentException if the caller-supplied header contains `alg`, or the unsupported `b64` parameter ([RFC 7797](https://www.rfc-editor.org/rfc/rfc7797))
37+
public String sign(MacAlg alg) throws IllegalArgumentException {
38+
if (protectedHeader.has("alg")) {
39+
throw new IllegalArgumentException("Header parameter 'alg' is derived from the signing algorithm and must not be set by the caller");
40+
}
41+
if (protectedHeader.has("b64")) {
42+
throw new IllegalArgumentException("Unsupported header parameter: b64");
43+
}
44+
var base64url = Base64.getUrlEncoder().withoutPadding();
45+
var header = new JsonObject();
46+
header.addProperty("alg", alg.name());
47+
protectedHeader.entrySet().forEach(e -> header.add(e.getKey(), e.getValue()));
48+
var encodedHeader = base64url.encodeToString(header.toString().getBytes(StandardCharsets.UTF_8));
49+
var encodedPayload = base64url.encodeToString(payload.getBytes(StandardCharsets.UTF_8));
50+
var signature = alg.sign(Ascii.strictBytes(encodedHeader + "." + encodedPayload));
51+
return encodedHeader + "." + encodedPayload + "." + base64url.encodeToString(signature);
52+
}
53+
54+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package org.cryptomator.jose.jws;
2+
3+
import com.google.gson.JsonObject;
4+
import com.google.gson.JsonParseException;
5+
import com.google.gson.JsonParser;
6+
import org.cryptomator.jose.JoseParseException;
7+
import org.cryptomator.jose.util.CriticalHeaderPolicy;
8+
9+
import java.nio.charset.StandardCharsets;
10+
import java.util.Base64;
11+
12+
/// Parses a JWS in compact serialization ([RFC 7515, Section 7.1](https://www.rfc-editor.org/rfc/rfc7515#section-7.1)) into a [ParsedJWS].
13+
/// Structural checks happen here (segments, JOSE Header rules incl. `crit`); the signature is checked on verification.
14+
public final class JWSParser {
15+
16+
private JWSParser() {
17+
}
18+
19+
/// @param token The serialized JWS
20+
/// @param understoodCrit Extension header parameter names the caller understands and therefore accepts in `crit`
21+
/// @return The parsed, not yet verified JWS
22+
/// @throws JoseParseException if the token is malformed, violates the JOSE Header rules, or lists a critical extension that is not understood
23+
public static ParsedJWS parse(String token, java.util.Set<String> understoodCrit) throws JoseParseException {
24+
var parts = token.split("\\.", -1);
25+
if (parts.length != 3) {
26+
throw new JoseParseException("Invalid JWS compact serialization");
27+
}
28+
var header = decodeProtectedHeader(parts[0]);
29+
if (!header.has("alg") || !header.get("alg").isJsonPrimitive() || !header.getAsJsonPrimitive("alg").isString()) {
30+
throw new JoseParseException("Missing or non-string alg header parameter");
31+
}
32+
if (header.has("b64")) {
33+
throw new JoseParseException("Unsupported header parameter: b64"); // RFC 7797 unencoded payloads
34+
}
35+
try {
36+
var crit = CriticalHeaderPolicy.validate(header, understoodCrit);
37+
CriticalHeaderPolicy.ensurePresent(crit, header);
38+
Base64.getUrlDecoder().decode(parts[1]);
39+
Base64.getUrlDecoder().decode(parts[2]);
40+
} catch (IllegalArgumentException e) {
41+
throw new JoseParseException(e.getMessage(), e);
42+
}
43+
return new ParsedJWS(parts[0], parts[1], parts[2]);
44+
}
45+
46+
private static JsonObject decodeProtectedHeader(String protectedHeader) throws JoseParseException {
47+
try {
48+
var decoded = new String(Base64.getUrlDecoder().decode(protectedHeader), StandardCharsets.UTF_8);
49+
return JsonParser.parseString(decoded).getAsJsonObject();
50+
} catch (IllegalArgumentException | IllegalStateException | JsonParseException e) {
51+
// IllegalArgumentException: invalid base64url; IllegalStateException: valid JSON but not an object; JsonParseException: invalid JSON
52+
throw new JoseParseException("Invalid protected header", e);
53+
}
54+
}
55+
56+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package org.cryptomator.jose.jws;
2+
3+
import com.google.gson.JsonObject;
4+
import com.google.gson.JsonParser;
5+
import org.cryptomator.jose.JoseVerifyException;
6+
import org.cryptomator.jose.MacAlg;
7+
import org.cryptomator.jose.util.Ascii;
8+
9+
import java.nio.charset.StandardCharsets;
10+
import java.security.MessageDigest;
11+
import java.util.Base64;
12+
13+
/// A parsed, not yet verified JWS in compact serialization. Instances produced by [JWSParser] have passed the JOSE Header checks (incl. `crit`).
14+
///
15+
/// @param protectedHeader BASE64URL(UTF8(JWS Protected Header))
16+
/// @param payload BASE64URL(JWS Payload)
17+
/// @param signature BASE64URL(JWS Signature)
18+
public record ParsedJWS(String protectedHeader, String payload, String signature) {
19+
20+
public JsonObject parsedProtectedHeader() {
21+
var decoded = new String(Base64.getUrlDecoder().decode(protectedHeader), StandardCharsets.UTF_8);
22+
return JsonParser.parseString(decoded).getAsJsonObject();
23+
}
24+
25+
/// The payload before verification, for inspection only. Trust its content only after [#verify] succeeded.
26+
///
27+
/// @return The decoded, unverified payload
28+
public String unverifiedPayload() {
29+
return new String(Base64.getUrlDecoder().decode(payload), StandardCharsets.UTF_8);
30+
}
31+
32+
/// Verifies the signature with the first provided algorithm whose name matches the `alg` header.
33+
///
34+
/// @param algs The candidate MAC algorithms
35+
/// @return The verified JWS
36+
/// @throws JoseVerifyException if no algorithm matches the `alg` header or the signature is invalid
37+
public VerifiedJWS verify(MacAlg... algs) throws JoseVerifyException {
38+
var header = parsedProtectedHeader();
39+
var algValue = header.get("alg").getAsString();
40+
var signingInput = Ascii.strictBytes(protectedHeader + "." + payload);
41+
var expected = Base64.getUrlDecoder().decode(signature);
42+
for (var alg : algs) {
43+
if (!algValue.equals(alg.name())) {
44+
continue;
45+
}
46+
if (MessageDigest.isEqual(alg.sign(signingInput), expected)) {
47+
return new VerifiedJWS(unverifiedPayload(), header);
48+
}
49+
throw new JoseVerifyException("Invalid signature");
50+
}
51+
throw new JoseVerifyException("No provided algorithm matches alg '" + algValue + "'");
52+
}
53+
54+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package org.cryptomator.jose.jws;
2+
3+
import com.google.gson.JsonObject;
4+
5+
/// A JWS whose signature has been verified.
6+
///
7+
/// @param payload The JWS Payload
8+
/// @param protectedHeader The JWS Protected Header
9+
public record VerifiedJWS(String payload, JsonObject protectedHeader) {
10+
11+
}

‎src/test/java/org/cryptomator/jose/JWEIT.java‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import java.security.spec.ECGenParameterSpec;
3030
import java.util.stream.Stream;
3131

32+
@DisplayName("JWE: end-to-end round trips")
3233
class JWEIT {
3334

3435
@Nested

0 commit comments

Comments
 (0)