Skip to content

Commit

Permalink
Merge pull request #3 from adorsys/sd-jwt-no-keycloak
Browse files Browse the repository at this point in the history
Removed all keycloak dependencies, replaced with nimbus jose
  • Loading branch information
francis-pouatcha authored May 6, 2024
2 parents 23d7202 + 3a6a88a commit 33d2bbe
Show file tree
Hide file tree
Showing 26 changed files with 377 additions and 515 deletions.
9 changes: 4 additions & 5 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,12 @@
<artifactId>jackson-databind</artifactId>
<version>2.16.1</version>
</dependency>
<!-- https://mvnrepository.com/artifact/org.keycloak/keycloak-core -->
<!-- https://mvnrepository.com/artifact/com.nimbusds/nimbus-jose-jwt -->
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-core</artifactId>
<version>24.0.2</version>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
<version>9.38-rc4</version>
</dependency>

<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
Expand Down
7 changes: 2 additions & 5 deletions src/main/java/com/adorsys/ssi/sdjwt/DecoyEntry.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,10 @@

import java.util.Objects;

import org.keycloak.jose.jws.crypto.HashUtils;

/**
* Handles hash production for a decoy entry from the given salt.
*
*
* @author <a href="mailto:[email protected]">Francis Pouatcha</a>
*
*/
public abstract class DecoyEntry {
private final SdJwtSalt salt;
Expand All @@ -23,6 +20,6 @@ public SdJwtSalt getSalt() {
}

public String getDisclosureDigest(String hashAlg) {
return SdJwtUtils.encodeNoPad(HashUtils.hash(hashAlg, salt.toString().getBytes()));
return SdJwtUtils.encodeNoPad(SdJwtUtils.hash(salt.toString().getBytes(), hashAlg));
}
}
13 changes: 5 additions & 8 deletions src/main/java/com/adorsys/ssi/sdjwt/Disclosable.java
Original file line number Diff line number Diff line change
@@ -1,21 +1,18 @@

package com.adorsys.ssi.sdjwt;

import java.util.Objects;

import org.keycloak.jose.jws.crypto.HashUtils;

import com.fasterxml.jackson.core.JsonProcessingException;

import java.util.Objects;

/**
* Handles undisclosed claims and array elements, providing functionality
* to generate disclosure digests from Base64Url encoded strings.
*
* <p>
* Hiding claims and array elements occurs by including their digests
* instead of plaintext in the signed verifiable credential.
*
*
* @author <a href="mailto:[email protected]">Francis Pouatcha</a>
*
*/
public abstract class Disclosable {
private final SdJwtSalt salt;
Expand Down Expand Up @@ -52,7 +49,7 @@ public String getDisclosureString() {
}

public String getDisclosureDigest(String hashAlg) {
return SdJwtUtils.encodeNoPad(HashUtils.hash(hashAlg, getDisclosureString().getBytes()));
return SdJwtUtils.encodeNoPad(SdJwtUtils.hash(getDisclosureString().getBytes(), hashAlg));
}

@Override
Expand Down
52 changes: 30 additions & 22 deletions src/main/java/com/adorsys/ssi/sdjwt/IssuerSignedJWT.java
Original file line number Diff line number Diff line change
@@ -1,36 +1,39 @@

package com.adorsys.ssi.sdjwt;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSObject;
import com.nimbusds.jose.JWSSigner;
import com.nimbusds.jose.util.Base64URL;

import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;

import org.keycloak.crypto.SignatureSignerContext;
import org.keycloak.jose.jws.JWSInput;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;

/**
* Handle verifiable credentials (SD-JWT VC), enabling the parsing
* of existing VCs as well as the creation and signing of new ones.
* It integrates with Keycloak's SignatureSignerContext to facilitate
* It integrates with JOSE's JWSSigner to facilitate
* the generation of issuer signature.
*
* @author <a href="mailto:[email protected]">Francis Pouatcha</a>
*/
public class IssuerSignedJWT extends SdJws {

public static IssuerSignedJWT fromJws(String jwsString) {
return new IssuerSignedJWT(jwsString);
public IssuerSignedJWT(JsonNode payload, JWSSigner signer, String keyId, JWSAlgorithm jwsAlgorithm, String jwsType) {
super(payload, signer, keyId, jwsAlgorithm, jwsType);
}
public IssuerSignedJWT(Base64URL payloadBase64URL, JWSSigner signer, String keyId, JWSAlgorithm jwsAlgorithm, String jwsType) {
super(payloadBase64URL, signer, keyId, jwsAlgorithm, jwsType);
}

public IssuerSignedJWT toSignedJWT(SignatureSignerContext signer, String jwsType) {
JWSInput jwsInput = sign(getPayload(), signer, jwsType);
return new IssuerSignedJWT(getPayload(), jwsInput);
public static IssuerSignedJWT fromJws(String jwsString) {
return new IssuerSignedJWT(jwsString);
}

private IssuerSignedJWT(String jwsString) {
Expand All @@ -42,13 +45,13 @@ private IssuerSignedJWT(List<SdJwtClaim> claims, List<DecoyClaim> decoyClaims, S
super(generatePayloadString(claims, decoyClaims, hashAlg, nestedDisclosures));
}

private IssuerSignedJWT(JsonNode payload, JWSInput jwsInput) {
private IssuerSignedJWT(JsonNode payload, JWSObject jwsInput) {
super(payload, jwsInput);
}

private IssuerSignedJWT(List<SdJwtClaim> claims, List<DecoyClaim> decoyClaims, String hashAlg,
boolean nestedDisclosures, SignatureSignerContext signer, String jwsType) {
super(generatePayloadString(claims, decoyClaims, hashAlg, nestedDisclosures), signer, jwsType);
boolean nestedDisclosures, JWSSigner signer, String keyId, JWSAlgorithm jwsAlgorithm, String jwsType) {
super(generatePayloadString(claims, decoyClaims, hashAlg, nestedDisclosures), signer, keyId, jwsAlgorithm, jwsType);
}

/*
Expand All @@ -63,7 +66,6 @@ private static JsonNode generatePayloadString(List<SdJwtClaim> claims, List<Deco
: Collections.unmodifiableList(claims);
final List<DecoyClaim> decoyClaimsInternal = decoyClaims == null ? Collections.emptyList()
: Collections.unmodifiableList(decoyClaims);

try {
// Check no dupplicate claim names
claimsInternal.stream()
Expand Down Expand Up @@ -96,11 +98,11 @@ private static JsonNode generatePayloadString(List<SdJwtClaim> claims, List<Deco

ObjectNode payload = SdJwtUtils.mapper.createObjectNode();

if (sdArray.size() > 0) {
if (!sdArray.isEmpty()) {
// drop _sd claim if empty
payload.set(CLAIM_NAME_SELECTIVE_DISCLOSURE, sdArray);
}
if (sdArray.size() > 0 || nestedDisclosures) {
if (!sdArray.isEmpty() || nestedDisclosures) {
// add sd alg only if ay disclosure.
payload.put(CLAIM_NAME_SD_HASH_ALGORITHM, hashAlg);
}
Expand Down Expand Up @@ -131,7 +133,8 @@ public static Builder builder() {
public static class Builder {
private List<SdJwtClaim> claims;
private String hashAlg;
private SignatureSignerContext signer;
private JWSSigner signer;
private String keyId;
private List<DecoyClaim> decoyClaims;
private boolean nestedDisclosures;
private String jwsType;
Expand All @@ -151,11 +154,16 @@ public Builder withHashAlg(String hashAlg) {
return this;
}

public Builder withSigner(SignatureSignerContext signer) {
public Builder withSigner(JWSSigner signer) {
this.signer = signer;
return this;
}

public Builder withKeyId(String keyId){
this.keyId = keyId;
return this;
}

public Builder withNestedDisclosures(boolean nestedDisclosures) {
this.nestedDisclosures = nestedDisclosures;
return this;
Expand All @@ -174,7 +182,7 @@ public IssuerSignedJWT build() {
claims = claims == null ? Collections.emptyList() : claims;
decoyClaims = decoyClaims == null ? Collections.emptyList() : decoyClaims;
if (signer != null) {
return new IssuerSignedJWT(claims, decoyClaims, hashAlg, nestedDisclosures, signer, jwsType);
return new IssuerSignedJWT(claims, decoyClaims, hashAlg, nestedDisclosures, signer, keyId, JWSAlgorithm.ES256, jwsType);
} else {
return new IssuerSignedJWT(claims, decoyClaims, hashAlg, nestedDisclosures);
}
Expand Down
119 changes: 59 additions & 60 deletions src/main/java/com/adorsys/ssi/sdjwt/SdJws.java
Original file line number Diff line number Diff line change
@@ -1,122 +1,121 @@

package com.adorsys.ssi.sdjwt;

import com.fasterxml.jackson.databind.JsonNode;
import com.nimbusds.jose.*;
import com.nimbusds.jose.util.Base64URL;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.text.ParseException;
import java.time.Instant;
import java.util.Objects;

import org.keycloak.common.VerificationException;
import org.keycloak.crypto.SignatureSignerContext;
import org.keycloak.crypto.SignatureVerifierContext;
import org.keycloak.jose.jws.JWSBuilder;
import org.keycloak.jose.jws.JWSInput;
import org.keycloak.jose.jws.JWSInputException;

import com.fasterxml.jackson.databind.JsonNode;

/**
* Handle jws, either the issuer jwt or the holder key binding jwt.
*
*
* @author <a href="mailto:[email protected]">Francis Pouatcha</a>
*
*/
public class SdJws {
private final JWSInput jwsInput;
public abstract class SdJws {
private final JWSObject signedJwt;

private final String jwsString;
private final JsonNode payload;

public String toJws() {
if (jwsInput == null) {
if (jwsString == null) {
throw new IllegalStateException("JWS not yet signed");
}
return jwsInput.getWireString();
return jwsString;
}

public JsonNode getPayload() {
return payload;
}

public JWSInput getJwsInput() {
return jwsInput;
}

public String getJwsString() {
return jwsInput.getWireString();
}

// Constructor for unsigned JWS
protected SdJws(JsonNode payload) {
this.payload = payload;
this.jwsInput = null;
this.signedJwt = null;
this.jwsString = null;
}

// Constructor from jws string with all parts
protected SdJws(String jwsString) {
this.jwsInput = parse(jwsString);
this.payload = readPayload(jwsInput);
try {
this.jwsString = jwsString;
this.signedJwt = parse(jwsString);
this.payload = readPayload(signedJwt);
} catch (ParseException | IOException e) {
throw new RuntimeException(e);
}
}

// Constructor for signed JWS
protected SdJws(JsonNode payload, JWSInput jwsInput) {
protected SdJws(JsonNode payload, JWSObject signedJwt) {
this.payload = payload;
this.jwsInput = jwsInput;
this.signedJwt = signedJwt;
this.jwsString = signedJwt.serialize();
}

protected SdJws(JsonNode payload, SignatureSignerContext signer, String jwsType) {
protected SdJws(JsonNode payload, JWSSigner signer, String keyId, JWSAlgorithm jwsAlgorithm, String jwsType) {
this.payload = payload;
this.jwsInput = sign(payload, signer, jwsType);
JWSHeader header = new JWSHeader.Builder(jwsAlgorithm).type(new JOSEObjectType(jwsType)).keyID(keyId).build();
this.signedJwt = new JWSObject(header, new Payload(Base64URL.encode(payload.toString())));
try {
this.signedJwt.sign(signer);
} catch (JOSEException e) {
throw new RuntimeException(e);
}
this.jwsString= signedJwt.serialize();
}

protected static JWSInput sign(JsonNode payload, SignatureSignerContext signer, String jwsType) {
String jwsString = new JWSBuilder().type(jwsType).jsonContent(payload).sign(signer);
return parse(jwsString);
protected SdJws(Base64URL payloadBase64Url, JWSSigner signer, String keyId, JWSAlgorithm jwsAlgorithm, String jwsType) {
try {
this.payload = SdJwtUtils.mapper.readTree(payloadBase64Url.decode());
} catch (IOException e) {
throw new RuntimeException(e);
}
JWSHeader header = new JWSHeader.Builder(jwsAlgorithm).type(new JOSEObjectType(jwsType)).keyID(keyId).build();
this.signedJwt = new JWSObject(header, new Payload(payloadBase64Url));
try {
this.signedJwt.sign(signer);
} catch (JOSEException e) {
throw new RuntimeException(e);
}
this.jwsString= signedJwt.serialize();
}

public void verifySignature(SignatureVerifierContext verifier) throws VerificationException {
Objects.requireNonNull(verifier, "verifier must not be null");
try {
if (!verifier.verify(jwsInput.getEncodedSignatureInput().getBytes(StandardCharsets.UTF_8), jwsInput.getSignature())) {
throw new VerificationException("Invalid jws signature");
}
} catch (Exception e) {
throw new VerificationException(e);
public void verifySignature(JWSVerifier verifier) throws JOSEException {
if (!this.signedJwt.verify(verifier)) {
throw new JOSEException("Invalid JWS signature");
}
}

public void verifyExpClaim() throws VerificationException {
public void verifyExpClaim() throws JOSEException {
verifyTimeClaim("exp", "jwt has expired");
}

public void verifyNotBeforeClaim() throws VerificationException {
public void verifyNotBeforeClaim() throws JOSEException {
verifyTimeClaim("nbf", "jwt not valid yet");
}

private void verifyTimeClaim(String claimName, String errorMessage) throws VerificationException {
private void verifyTimeClaim(String claimName, String errorMessage) throws JOSEException {
JsonNode claim = payload.get(claimName);
if (claim == null || !claim.isNumber()) {
throw new VerificationException("Missing or invalid '" + claimName + "' claim");
throw new JOSEException("Missing or invalid '" + claimName + "' claim");
}

long claimTime = claim.asLong();
long currentTime = Instant.now().getEpochSecond();
if (("exp".equals(claimName) && currentTime >= claimTime) || ("nbf".equals(claimName) && currentTime < claimTime)) {
throw new VerificationException(errorMessage);
throw new JOSEException(errorMessage);
}
}

private static JWSInput parse(String jwsString) {
try {
return new JWSInput(Objects.requireNonNull(jwsString, "jwsString must not be null"));
} catch (JWSInputException e) {
throw new RuntimeException(e);
}
private static JWSObject parse(String jwsString) throws ParseException {
return JWSObject.parse(Objects.requireNonNull(jwsString, "jwsString must not be null"));
}

private static JsonNode readPayload(JWSInput jwsInput) {
try {
return SdJwtUtils.mapper.readTree(jwsInput.getContent());
} catch (IOException e) {
throw new RuntimeException(e);
}
private static JsonNode readPayload(JWSObject jwsInput) throws ParseException, IOException {
return SdJwtUtils.mapper.readValue(jwsInput.getParsedParts()[1].decode(), JsonNode.class);
}
}
Loading

0 comments on commit 33d2bbe

Please sign in to comment.