From 445c7bd0e41a37aa2943f5da0f07d05bee2127ca Mon Sep 17 00:00:00 2001 From: Stefan Wiedemann Date: Thu, 14 Dec 2023 12:54:24 +0100 Subject: [PATCH] add sd --- services/src/api/oidc4vci-api.yaml | 5 +- .../oidc4vp/OIDC4VPIssuerEndpoint.java | 66 +++++-- .../oidc4vp/OIDC4VPLoginProtocolFactory.java | 9 +- .../protocol/oidc4vp/model/DIDCreate.java | 13 -- .../protocol/oidc4vp/model/DIDKey.java | 17 -- .../protocol/oidc4vp/model/JwtProofToken.java | 6 - .../protocol/oidc4vp/model/KeyId.java | 12 -- .../oidc4vp/model/VerifiableCredential.java | 2 +- .../oidc4vp/model/sdjwt/ArrayDigest.java | 24 +++ .../model/sdjwt/ArrayDisclosureClaim.java | 39 ++++ .../oidc4vp/model/sdjwt/ArrayElement.java | 52 ++++++ .../oidc4vp/model/sdjwt/DisclosureClaim.java | 54 ++++++ .../protocol/oidc4vp/model/sdjwt/SdClaim.java | 50 ++++++ .../oidc4vp/model/sdjwt/SdCredential.java | 24 +++ .../oidc4vp/model/{ => vcdm}/LdProof.java | 2 +- ...ingService.java => JwtSigningService.java} | 12 +- .../oidc4vp/signing/LDSigningService.java | 2 +- .../oidc4vp/signing/SdJwtSigningService.java | 168 ++++++++++++++++++ .../oidc4vp/signing/SigningService.java | 2 - .../oidc4vp/OIDC4VPIssuerEndpointTest.java | 7 +- .../signing/JWTSigningServiceTest.java | 89 ---------- .../signing/JwtSigningServiceTest.java | 48 +++++ .../oidc4vp/signing/LDSigningServiceTest.java | 8 +- .../signing/SdJwtSigningServiceTest.java | 56 ++++++ .../oidc4vp/signing/SigningServiceTest.java | 42 +++++ 25 files changed, 629 insertions(+), 180 deletions(-) delete mode 100644 services/src/main/java/org/keycloak/protocol/oidc4vp/model/DIDCreate.java delete mode 100644 services/src/main/java/org/keycloak/protocol/oidc4vp/model/DIDKey.java delete mode 100644 services/src/main/java/org/keycloak/protocol/oidc4vp/model/JwtProofToken.java delete mode 100644 services/src/main/java/org/keycloak/protocol/oidc4vp/model/KeyId.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc4vp/model/sdjwt/ArrayDigest.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc4vp/model/sdjwt/ArrayDisclosureClaim.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc4vp/model/sdjwt/ArrayElement.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc4vp/model/sdjwt/DisclosureClaim.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc4vp/model/sdjwt/SdClaim.java create mode 100644 services/src/main/java/org/keycloak/protocol/oidc4vp/model/sdjwt/SdCredential.java rename services/src/main/java/org/keycloak/protocol/oidc4vp/model/{ => vcdm}/LdProof.java (97%) rename services/src/main/java/org/keycloak/protocol/oidc4vp/signing/{JWTSigningService.java => JwtSigningService.java} (93%) create mode 100644 services/src/main/java/org/keycloak/protocol/oidc4vp/signing/SdJwtSigningService.java delete mode 100644 services/src/test/java/org/keycloak/protocol/oidc4vp/signing/JWTSigningServiceTest.java create mode 100644 services/src/test/java/org/keycloak/protocol/oidc4vp/signing/JwtSigningServiceTest.java create mode 100644 services/src/test/java/org/keycloak/protocol/oidc4vp/signing/SdJwtSigningServiceTest.java diff --git a/services/src/api/oidc4vci-api.yaml b/services/src/api/oidc4vci-api.yaml index d3c65331bd7e..fa9284ee09f0 100644 --- a/services/src/api/oidc4vci-api.yaml +++ b/services/src/api/oidc4vci-api.yaml @@ -246,11 +246,10 @@ components: Format: type: string enum: - - "jwt_vc_json" - - "jwt_vc_json-ld" - "ldp_vc" - "jwt_vc" - example: "jwt_vc_json-ld" + - "sd-jwt_vc" + example: "sd-jwt_vc" CredentialRequest: type: object properties: diff --git a/services/src/main/java/org/keycloak/protocol/oidc4vp/OIDC4VPIssuerEndpoint.java b/services/src/main/java/org/keycloak/protocol/oidc4vp/OIDC4VPIssuerEndpoint.java index 465d4f315f93..fc15a58ad037 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc4vp/OIDC4VPIssuerEndpoint.java +++ b/services/src/main/java/org/keycloak/protocol/oidc4vp/OIDC4VPIssuerEndpoint.java @@ -28,10 +28,8 @@ import org.keycloak.protocol.oidc4vp.model.PreAuthorized; import org.keycloak.protocol.oidc4vp.model.PreAuthorizedGrant; import org.keycloak.protocol.oidc4vp.model.SupportedCredential; -import org.keycloak.protocol.oidc4vp.signing.FileBasedKeyLoader; -import org.keycloak.protocol.oidc4vp.signing.JWTSigningService; -import org.keycloak.protocol.oidc4vp.signing.LDSigningService; -import org.keycloak.protocol.oidc4vp.signing.SigningServiceException; +import org.keycloak.protocol.oidc4vp.model.vcdm.LdProof; +import org.keycloak.protocol.oidc4vp.signing.*; import org.keycloak.representations.JsonWebToken; import org.keycloak.services.managers.AppAuthManager; import org.keycloak.services.managers.AuthenticationManager; @@ -66,16 +64,19 @@ public class OIDC4VPIssuerEndpoint { private final boolean ldSigningEnabled; private final boolean jwtSigningEnabled; + private final boolean sdJwtSigningEnabled; private LDSigningService ldSigningService; - private JWTSigningService jwtSigningService; + private JwtSigningService jwtSigningService; + private SdJwtSigningService sdJwtSigningService; public OIDC4VPIssuerEndpoint(KeycloakSession session, String issuerDid, String keyPath, Optional jwtType, Optional ldpType, + Optional sdJwtType, AppAuthManager.BearerTokenAuthenticator authenticator, - ObjectMapper objectMapper, Clock clock) { + ObjectMapper objectMapper, Clock clock, Integer decoys, Optional keyId) { this.session = session; this.bearerTokenAuthenticator = authenticator; this.objectMapper = objectMapper; @@ -84,7 +85,11 @@ public OIDC4VPIssuerEndpoint(KeycloakSession session, var tempJwtSigningEnabled = false; if (jwtType.isPresent()) { try { - this.jwtSigningService = new JWTSigningService(new FileBasedKeyLoader(keyPath), Optional.empty(), clock, jwtType.get()); + this.jwtSigningService = new JwtSigningService( + new FileBasedKeyLoader(keyPath), + keyId, + clock, + jwtType.get()); tempJwtSigningEnabled = true; } catch (SigningServiceException e) { LOGGER.warn("Was not able to initialize JWT SigningService, jwt credentials are not supported.", e); @@ -96,7 +101,12 @@ public OIDC4VPIssuerEndpoint(KeycloakSession session, var tempLdSigningEnabled = false; if (ldpType.isPresent()) { try { - this.ldSigningService = new LDSigningService(new FileBasedKeyLoader(keyPath), Optional.empty(), clock, ldpType.get(), objectMapper); + this.ldSigningService = new LDSigningService( + new FileBasedKeyLoader(keyPath), + keyId, + clock, + ldpType.get(), + objectMapper); tempLdSigningEnabled = true; } catch (SigningServiceException e) { LOGGER.warn("Was not able to initialize LD SigningService, ld credentials are not supported.", e); @@ -105,6 +115,25 @@ public OIDC4VPIssuerEndpoint(KeycloakSession session, } } this.ldSigningEnabled = tempLdSigningEnabled; + + var tempSdJWTSigningEnabled = false; + if (ldpType.isPresent()) { + try { + this.sdJwtSigningService = new SdJwtSigningService( + new FileBasedKeyLoader(keyPath), + keyId, + clock, + sdJwtType.get(), + objectMapper, + decoys); + tempSdJWTSigningEnabled = true; + } catch (SigningServiceException e) { + LOGGER.warn("Was not able to initialize SD-JWT SigningService, sd-jwt credentials are not supported.", e); + throw new IllegalArgumentException("No valid sd-jwt signing configured.", e); + + } + } + this.sdJwtSigningEnabled = tempSdJWTSigningEnabled; } /** @@ -264,10 +293,7 @@ public Response requestCredential( // Optional.ofNullable(credentialRequestVO.getProof()).ifPresent(this::verifyProof); Format requestedFormat = credentialRequestVO.getFormat(); - // workaround to support implementations not differentiating json & json-ld - if (requestedFormat == JWT_VC) { - requestedFormat = JWT_VC_JSON; - } + // TODO: check if there can be more String vcType = types.get(0); @@ -277,8 +303,7 @@ public Response requestCredential( Object theCredential = getCredential(vcType, credentialRequestVO.getFormat()); switch (requestedFormat) { - case LDP_VC -> responseVO.setCredential(theCredential); - case JWT_VC_JSON -> responseVO.setCredential(theCredential); + case LDP_VC, JWT_VC, SD_JWT_VC -> responseVO.setCredential(theCredential); default -> throw new BadRequestException( getErrorResponse(ErrorResponse.ErrorEnum.UNSUPPORTED_CREDENTIAL_TYPE)); } @@ -324,13 +349,20 @@ protected Object getCredential(String vcType, Format format) { throw new IllegalArgumentException( String.format("Requested format %s is not supported.", format)); } - case JWT_VC, JWT_VC_JSON_LD, JWT_VC_JSON -> { + case JWT_VC -> { if (jwtSigningEnabled) { yield jwtSigningService.signCredential(credentialToSign); } throw new IllegalArgumentException( String.format("Requested format %s is not supported.", format)); } + case SD_JWT_VC -> { + if (sdJwtSigningEnabled) { + yield sdJwtSigningService.signCredential(credentialToSign); + } + throw new IllegalArgumentException( + String.format("Requested format %s is not supported.", format)); + } }; } @@ -384,8 +416,8 @@ private List getClientsOfType(String vcType, Format format) { List formatStrings = switch (format) { case LDP_VC -> List.of(LDP_VC.toString()); - case JWT_VC, JWT_VC_JSON -> List.of(JWT_VC.toString(), JWT_VC_JSON.toString()); - case JWT_VC_JSON_LD -> List.of(JWT_VC.toString(), JWT_VC_JSON_LD.toString()); + case JWT_VC -> List.of(JWT_VC.toString()); + case SD_JWT_VC -> List.of(SD_JWT_VC.toString()); }; diff --git a/services/src/main/java/org/keycloak/protocol/oidc4vp/OIDC4VPLoginProtocolFactory.java b/services/src/main/java/org/keycloak/protocol/oidc4vp/OIDC4VPLoginProtocolFactory.java index 74de718782d2..8d2e6a21d292 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc4vp/OIDC4VPLoginProtocolFactory.java +++ b/services/src/main/java/org/keycloak/protocol/oidc4vp/OIDC4VPLoginProtocolFactory.java @@ -90,15 +90,18 @@ public Object createProtocolEndpoint(KeycloakSession keycloakSession, EventBuild .orElseThrow(() -> new VCIssuerException("No keyPath configured.")); Optional lpdType = Optional.ofNullable(keycloakSession.getContext().getRealm().getAttribute("ldpType")); Optional jwtType = Optional.ofNullable(keycloakSession.getContext().getRealm().getAttribute("jwtType")); + Optional sdJwtType = Optional.ofNullable(keycloakSession.getContext().getRealm().getAttribute("sdJwtType")); + Integer decoys = Optional.ofNullable(keycloakSession.getContext().getRealm().getAttribute("decoys")).map(Integer::valueOf).orElse(0); + Optional keyId = Optional.ofNullable(keycloakSession.getContext().getRealm().getAttribute("keyId")); return new OIDC4VPIssuerEndpoint( keycloakSession, issuerDid, keyPath, - jwtType, lpdType, + jwtType, sdJwtType, lpdType, new AppAuthManager.BearerTokenAuthenticator( keycloakSession), - OBJECT_MAPPER, - clock + OBJECT_MAPPER, clock, + decoys, keyId ); } diff --git a/services/src/main/java/org/keycloak/protocol/oidc4vp/model/DIDCreate.java b/services/src/main/java/org/keycloak/protocol/oidc4vp/model/DIDCreate.java deleted file mode 100644 index adbe238bf4e2..000000000000 --- a/services/src/main/java/org/keycloak/protocol/oidc4vp/model/DIDCreate.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.keycloak.protocol.oidc4vp.model; - -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -@AllArgsConstructor -@NoArgsConstructor -@Data -public class DIDCreate { - - private String method = "key"; -} diff --git a/services/src/main/java/org/keycloak/protocol/oidc4vp/model/DIDKey.java b/services/src/main/java/org/keycloak/protocol/oidc4vp/model/DIDKey.java deleted file mode 100644 index d8067ad53dd9..000000000000 --- a/services/src/main/java/org/keycloak/protocol/oidc4vp/model/DIDKey.java +++ /dev/null @@ -1,17 +0,0 @@ -package org.keycloak.protocol.oidc4vp.model; - -import lombok.Data; -import lombok.NoArgsConstructor; - -@NoArgsConstructor -@Data -public class DIDKey { - - private String kty; - private String d; - private String use; - private String crv; - private String kid; - private String x; - private String alg; -} diff --git a/services/src/main/java/org/keycloak/protocol/oidc4vp/model/JwtProofToken.java b/services/src/main/java/org/keycloak/protocol/oidc4vp/model/JwtProofToken.java deleted file mode 100644 index b7c1f2409a0b..000000000000 --- a/services/src/main/java/org/keycloak/protocol/oidc4vp/model/JwtProofToken.java +++ /dev/null @@ -1,6 +0,0 @@ -package org.keycloak.protocol.oidc4vp.model; - -import org.keycloak.representations.JsonWebToken; - -public class JwtProofToken extends JsonWebToken { -} diff --git a/services/src/main/java/org/keycloak/protocol/oidc4vp/model/KeyId.java b/services/src/main/java/org/keycloak/protocol/oidc4vp/model/KeyId.java deleted file mode 100644 index 024630b2a89a..000000000000 --- a/services/src/main/java/org/keycloak/protocol/oidc4vp/model/KeyId.java +++ /dev/null @@ -1,12 +0,0 @@ -package org.keycloak.protocol.oidc4vp.model; - -import lombok.Data; -import lombok.NoArgsConstructor; - -@NoArgsConstructor -@Data -public class KeyId { - - private String id; - -} diff --git a/services/src/main/java/org/keycloak/protocol/oidc4vp/model/VerifiableCredential.java b/services/src/main/java/org/keycloak/protocol/oidc4vp/model/VerifiableCredential.java index f66492612ca7..4d090c868375 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc4vp/model/VerifiableCredential.java +++ b/services/src/main/java/org/keycloak/protocol/oidc4vp/model/VerifiableCredential.java @@ -1,7 +1,7 @@ package org.keycloak.protocol.oidc4vp.model; import com.fasterxml.jackson.annotation.*; -import com.fasterxml.jackson.databind.DatabindException; +import org.keycloak.protocol.oidc4vp.model.vcdm.LdProof; import java.net.URI; import java.util.Date; diff --git a/services/src/main/java/org/keycloak/protocol/oidc4vp/model/sdjwt/ArrayDigest.java b/services/src/main/java/org/keycloak/protocol/oidc4vp/model/sdjwt/ArrayDigest.java new file mode 100644 index 000000000000..4affaa5de29b --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc4vp/model/sdjwt/ArrayDigest.java @@ -0,0 +1,24 @@ +package org.keycloak.protocol.oidc4vp.model.sdjwt; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public class ArrayDigest { + + @JsonProperty("...") + private String digest; + + public ArrayDigest() { + } + + public ArrayDigest(String digest) { + this.digest = digest; + } + + public String getDigest() { + return digest; + } + + public void setDigest(String digest) { + this.digest = digest; + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc4vp/model/sdjwt/ArrayDisclosureClaim.java b/services/src/main/java/org/keycloak/protocol/oidc4vp/model/sdjwt/ArrayDisclosureClaim.java new file mode 100644 index 000000000000..016c3fe05d71 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc4vp/model/sdjwt/ArrayDisclosureClaim.java @@ -0,0 +1,39 @@ +package org.keycloak.protocol.oidc4vp.model.sdjwt; + +import com.fasterxml.jackson.databind.annotation.JsonSerialize; + +import java.util.ArrayList; +import java.util.List; + +public class ArrayDisclosureClaim { + + private String key; + private List values = new ArrayList<>(); + + public ArrayDisclosureClaim() { + } + + public ArrayDisclosureClaim(String key) { + this.key = key; + } + + public String getKey() { + return key; + } + + public void setKey(String key) { + this.key = key; + } + + public List getValues() { + return values; + } + + public void setValues(List values) { + this.values = values; + } + + public void addValue(ArrayElement value) { + this.values.add(value); + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc4vp/model/sdjwt/ArrayElement.java b/services/src/main/java/org/keycloak/protocol/oidc4vp/model/sdjwt/ArrayElement.java new file mode 100644 index 000000000000..700e365706f7 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc4vp/model/sdjwt/ArrayElement.java @@ -0,0 +1,52 @@ +package org.keycloak.protocol.oidc4vp.model.sdjwt; + +import java.util.Map; + +public class ArrayElement { + + private String disclosure; + private String digest; + private String salt; + private Object value; + + public ArrayElement(String salt, Object value) { + this.salt = salt; + this.value = value; + } + + public String getSalt() { + return salt; + } + + public void setSalt(String salt) { + this.salt = salt; + } + + public Object getValue() { + return value; + } + + public void setValue(Object value) { + this.value = value; + } + + public String getDisclosure() { + return disclosure; + } + + public void setDisclosure(String disclosure) { + this.disclosure = disclosure; + } + + public String getDigest() { + return digest; + } + + public void setDigest(String digest) { + this.digest = digest; + } + + public ArrayDigest asDigest() { + return new ArrayDigest(this.digest); + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc4vp/model/sdjwt/DisclosureClaim.java b/services/src/main/java/org/keycloak/protocol/oidc4vp/model/sdjwt/DisclosureClaim.java new file mode 100644 index 000000000000..5159d90a7702 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc4vp/model/sdjwt/DisclosureClaim.java @@ -0,0 +1,54 @@ +package org.keycloak.protocol.oidc4vp.model.sdjwt; + +import com.fasterxml.jackson.databind.annotation.JsonSerialize; + +public class DisclosureClaim { + + private String digest; + private String disclosure; + private String salt; + private String key; + + public DisclosureClaim() { + } + + public DisclosureClaim(String digest, String disclosure, String salt, String key) { + this.digest = digest; + this.disclosure = disclosure; + this.salt = salt; + this.key = key; + } + + public String getDigest() { + return digest; + } + + public void setDigest(String digest) { + this.digest = digest; + } + + public String getDisclosure() { + return disclosure; + } + + public void setDisclosure(String disclosure) { + this.disclosure = disclosure; + } + + public String getSalt() { + return salt; + } + + public void setSalt(String salt) { + this.salt = salt; + } + + public String getKey() { + return key; + } + + public void setKey(String key) { + this.key = key; + } + +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc4vp/model/sdjwt/SdClaim.java b/services/src/main/java/org/keycloak/protocol/oidc4vp/model/sdjwt/SdClaim.java new file mode 100644 index 000000000000..6333241d8d73 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc4vp/model/sdjwt/SdClaim.java @@ -0,0 +1,50 @@ +package org.keycloak.protocol.oidc4vp.model.sdjwt; + +import org.keycloak.common.util.Base64; +import org.keycloak.protocol.oidc4vp.signing.SigningServiceException; + +import java.io.IOException; +import java.security.SecureRandom; + +import static org.keycloak.protocol.oidc4vp.signing.SdJwtSigningService.generateSalt; + +public class SdClaim { + + private String salt; + private String key; + private Object value; + + public SdClaim(String key, Object value) { + + this.key = key; + this.value = value; + this.salt = generateSalt(); + } + + + public String getSalt() { + return salt; + } + + public void setSalt(String salt) { + this.salt = salt; + } + + public String getKey() { + return key; + } + + public void setKey(String key) { + this.key = key; + } + + public Object getValue() { + return value; + } + + public void setValue(Object value) { + this.value = value; + } + + +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc4vp/model/sdjwt/SdCredential.java b/services/src/main/java/org/keycloak/protocol/oidc4vp/model/sdjwt/SdCredential.java new file mode 100644 index 000000000000..088b64bf1d1a --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc4vp/model/sdjwt/SdCredential.java @@ -0,0 +1,24 @@ +package org.keycloak.protocol.oidc4vp.model.sdjwt; + +import com.fasterxml.jackson.annotation.JsonIgnore; + +import java.util.ArrayList; +import java.util.List; + +public class SdCredential { + + @JsonIgnore + private List sdClaims = new ArrayList<>(); + + public List getSdClaims() { + return sdClaims; + } + + public void setSdClaims(List sdClaims) { + this.sdClaims = sdClaims; + } + + public void addSdClaim(SdClaim sdClaim) { + sdClaims.add(sdClaim); + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc4vp/model/LdProof.java b/services/src/main/java/org/keycloak/protocol/oidc4vp/model/vcdm/LdProof.java similarity index 97% rename from services/src/main/java/org/keycloak/protocol/oidc4vp/model/LdProof.java rename to services/src/main/java/org/keycloak/protocol/oidc4vp/model/vcdm/LdProof.java index e3b1201c2f33..4dc0dc6c309a 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc4vp/model/LdProof.java +++ b/services/src/main/java/org/keycloak/protocol/oidc4vp/model/vcdm/LdProof.java @@ -1,4 +1,4 @@ -package org.keycloak.protocol.oidc4vp.model; +package org.keycloak.protocol.oidc4vp.model.vcdm; import com.fasterxml.jackson.annotation.JsonAnyGetter; import com.fasterxml.jackson.annotation.JsonAnySetter; diff --git a/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/JWTSigningService.java b/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/JwtSigningService.java similarity index 93% rename from services/src/main/java/org/keycloak/protocol/oidc4vp/signing/JWTSigningService.java rename to services/src/main/java/org/keycloak/protocol/oidc4vp/signing/JwtSigningService.java index 18d8dc05f47d..401171e12696 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/JWTSigningService.java +++ b/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/JwtSigningService.java @@ -3,7 +3,6 @@ import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo; -import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.openssl.PEMKeyPair; import org.bouncycastle.openssl.PEMParser; import org.keycloak.common.util.KeyUtils; @@ -29,13 +28,13 @@ import static org.keycloak.protocol.oidc4vp.signing.signatures.EdDSASignatureSignerContext.ED_25519; -public class JWTSigningService extends SigningService { +public class JwtSigningService extends SigningService { private static final String ID_TEMPLATE = "urn:uuid:%s"; private SignatureSignerContext signatureSignerContext; - public JWTSigningService(KeyLoader keyLoader, Optional optionalKeyId, Clock clock, String algorithmType) { + public JwtSigningService(KeyLoader keyLoader, Optional optionalKeyId, Clock clock, String algorithmType) { super(keyLoader, optionalKeyId, clock, algorithmType); var signingKey = getKeyWrapper(algorithmType); @@ -53,7 +52,7 @@ public JWTSigningService(KeyLoader keyLoader, Optional optionalKeyId, Cl public String signCredential(VerifiableCredential verifiableCredential) { JsonWebToken jsonWebToken = new JsonWebToken(); - jsonWebToken.exp(clock.instant().plus(1, ChronoUnit.DAYS).getEpochSecond()); + Optional.ofNullable(verifiableCredential.getExpirationDate()).ifPresent(d -> jsonWebToken.exp(d.getTime())); jsonWebToken.issuer(verifiableCredential.getIssuer().toString()); jsonWebToken.nbf(clock.instant().getEpochSecond()); jsonWebToken.iat(clock.instant().getEpochSecond()); @@ -67,10 +66,13 @@ public String signCredential(VerifiableCredential verifiableCredential) { } jsonWebToken.subject(verifiableCredential.getCredentialSubject().getId()); jsonWebToken.setOtherClaims("vc", verifiableCredential); + return signToken(jsonWebToken, type); + } + protected String signToken(JsonWebToken jsonWebToken, String type) { JWSBuilder jwsBuilder = new JWSBuilder(); optionalKeyId.ifPresent(jwsBuilder::kid); - jwsBuilder.type("JWT"); + jwsBuilder.type(type); return jwsBuilder.jsonContent(jsonWebToken).sign(signatureSignerContext); } diff --git a/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/LDSigningService.java b/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/LDSigningService.java index c85a46b140a7..755cf71e05fc 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/LDSigningService.java +++ b/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/LDSigningService.java @@ -4,7 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.jboss.logging.Logger; import org.keycloak.common.util.Base64; -import org.keycloak.protocol.oidc4vp.model.LdProof; +import org.keycloak.protocol.oidc4vp.model.vcdm.LdProof; import org.keycloak.protocol.oidc4vp.model.VerifiableCredential; import org.keycloak.protocol.oidc4vp.signing.signatures.Ed255192018Suite; import org.keycloak.protocol.oidc4vp.signing.signatures.RsaSignature2018Suite; diff --git a/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/SdJwtSigningService.java b/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/SdJwtSigningService.java new file mode 100644 index 000000000000..66de1838a550 --- /dev/null +++ b/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/SdJwtSigningService.java @@ -0,0 +1,168 @@ +package org.keycloak.protocol.oidc4vp.signing; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.keycloak.crypto.HashProvider; +import org.keycloak.crypto.JavaAlgorithm; +import org.keycloak.crypto.JavaAlgorithmHashProvider; +import org.keycloak.protocol.oidc4vp.model.CredentialSubject; +import org.keycloak.protocol.oidc4vp.model.VerifiableCredential; +import org.keycloak.protocol.oidc4vp.model.sdjwt.*; +import org.keycloak.representations.JsonWebToken; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.time.Clock; +import java.util.*; + +public class SdJwtSigningService extends JwtSigningService { + private final ObjectMapper objectMapper; + private final HashProvider hashProvider; + private final int decoys; + + /** + * According to {@see https://datatracker.ietf.org/doc/html/draft-ietf-oauth-selective-disclosure-jwt-05#name-conventions-and-terminology} + * every base64 encoding is URL-Safe without padding. + */ + private static final Base64.Encoder BASE_64_ENCODER = Base64.getUrlEncoder().withoutPadding(); + + // TODO: cryptographic key binding is not yet implemented(@see https://www.ietf.org/archive/id/draft-terbu-oauth-sd-jwt-vc-00.html#section-4.2.2.2-3.5.1}. + // should be added + + public SdJwtSigningService(KeyLoader keyLoader, Optional optionalKeyId, Clock clock, String algorithmType, ObjectMapper objectMapper, int decoys) { + super(keyLoader, optionalKeyId, clock, algorithmType); + this.objectMapper = objectMapper; + // make configurable + this.hashProvider = new JavaAlgorithmHashProvider(JavaAlgorithm.SHA256); + this.decoys = decoys; + } + + @Override + public String signCredential(VerifiableCredential verifiableCredential) { + + SdCredential sdCredential = toSdCredential(verifiableCredential); + + List arrayClaims = new ArrayList<>(); + List nonArrayClaims = new ArrayList<>(); + + sdCredential.getSdClaims().forEach(sdc -> { + if (sdc.getValue() instanceof List) { + arrayClaims.add(sdc); + } else { + nonArrayClaims.add(sdc); + } + }); + + List disclosureClaims = nonArrayClaims.stream() + .map(this::createNonArrayDisclosure) + .toList(); + List arrayDisclosureClaims = arrayClaims.stream() + .map(this::createArrayDisclosure) + .toList(); + + // create a mutable list + List digestList = new ArrayList<>(disclosureClaims.stream().map(DisclosureClaim::getDigest).toList()); + + for (int i = 0; i < decoys; i++) { + digestList.add(generateDecoy()); + } + + JsonWebToken jsonWebToken = new JsonWebToken(); + Optional.ofNullable(verifiableCredential.getExpirationDate()).ifPresent(d -> jsonWebToken.exp(d.getTime())); + jsonWebToken.issuer(verifiableCredential.getIssuer().toString()); + jsonWebToken.nbf(clock.instant().getEpochSecond()); + jsonWebToken.iat(clock.instant().getEpochSecond()); + if (verifiableCredential.getType().size() != 1) { + throw new SigningServiceException("SD-JWT only supports single type credentials."); + } + jsonWebToken.setOtherClaims("type", verifiableCredential.getType().get(0)); + jsonWebToken.setOtherClaims("_sd_alg", JavaAlgorithm.SHA256.toLowerCase()); + jsonWebToken.setOtherClaims("_sd", digestList); + arrayDisclosureClaims.forEach(adc -> { + jsonWebToken.setOtherClaims( + adc.getKey(), + adc.getValues().stream() + .map(ArrayElement::asDigest) + .toList()); + }); + + StringJoiner tokenJoiner = new StringJoiner("."); + tokenJoiner.add(signToken(jsonWebToken, "vc+sd-jwt")); + disclosureClaims.forEach(dc -> tokenJoiner.add(dc.getDisclosure())); + arrayDisclosureClaims.stream().flatMap(adc -> adc.getValues().stream()).forEach(ae -> tokenJoiner.add(ae.getDisclosure())); + return tokenJoiner.toString(); + } + + + private SdCredential toSdCredential(VerifiableCredential verifiableCredential) { + SdCredential sdCredential = new SdCredential(); + // first the known properties + if (verifiableCredential.getContext() != null) { + sdCredential.addSdClaim(new SdClaim("@context", verifiableCredential.getContext())); + } + if (verifiableCredential.getId() != null) { + sdCredential.addSdClaim(new SdClaim("id", verifiableCredential.getId())); + } + verifiableCredential.getAdditionalProperties() + .forEach((key, value) -> sdCredential.addSdClaim(new SdClaim(key, value))); + CredentialSubject subject = verifiableCredential.getCredentialSubject(); + subject.getClaims().forEach((key, value) -> sdCredential.addSdClaim(new SdClaim(key, value))); + return sdCredential; + } + + private DisclosureClaim createNonArrayDisclosure(SdClaim claim) { + try { + String encodedArray = objectMapper.writeValueAsString(List.of(claim.getSalt(), claim.getKey(), claim.getValue())); + String disclosure = createDisclosureString(encodedArray); + return new DisclosureClaim(createDigest(disclosure), disclosure, claim.getSalt(), claim.getKey()); + + } catch (JsonProcessingException e) { + throw new SigningServiceException("Was not able to serialize the SD-Claim.", e); + } + } + + private ArrayDisclosureClaim createArrayDisclosure(SdClaim claim) { + if (claim.getValue() instanceof List listClaim) { + ArrayDisclosureClaim arrayDisclosureClaim = new ArrayDisclosureClaim(claim.getKey()); + for (Object listEntry : listClaim) { + try { + ArrayElement arrayElement = new ArrayElement(generateSalt(), listEntry); + String encodedElement = objectMapper.writeValueAsString(List.of(arrayElement.getSalt(), arrayElement.getValue())); + String elementDisclosure = createDisclosureString(encodedElement); + String digest = createDigest(elementDisclosure); + arrayElement.setDisclosure(elementDisclosure); + arrayElement.setDigest(digest); + arrayDisclosureClaim.addValue(arrayElement); + } catch (JsonProcessingException e) { + throw new SigningServiceException("Was not able to serialize the list entry.", e); + } + } + return arrayDisclosureClaim; + } else { + throw new SigningServiceException("Array-disclosures can only be built for list values."); + } + } + + private String createDisclosureString(String encodedEntry) { + return BASE_64_ENCODER + .encodeToString(encodedEntry.getBytes(StandardCharsets.UTF_8)); + } + + private String createDigest(String toDigest) { + return BASE_64_ENCODER + .encodeToString( + hashProvider.hash(toDigest.getBytes(StandardCharsets.UTF_8))); + } + + private String generateDecoy() { + return createDigest(generateSalt()); + } + + public static String generateSalt() { + SecureRandom secureRandom = new SecureRandom(); + byte[] randomBytes = new byte[16]; // 128 bits are converted to 16 bytes; + secureRandom.nextBytes(randomBytes); + return BASE_64_ENCODER.encodeToString(randomBytes); + } +} diff --git a/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/SigningService.java b/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/SigningService.java index e47dcd6503d0..c366c7655873 100644 --- a/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/SigningService.java +++ b/services/src/main/java/org/keycloak/protocol/oidc4vp/signing/SigningService.java @@ -7,8 +7,6 @@ public abstract class SigningService implements VCSigningService { - private static final Logger LOGGER = Logger.getLogger(SigningService.class); - protected final KeyLoader keyLoader; protected final Optional optionalKeyId; protected final Clock clock; diff --git a/services/src/test/java/org/keycloak/protocol/oidc4vp/OIDC4VPIssuerEndpointTest.java b/services/src/test/java/org/keycloak/protocol/oidc4vp/OIDC4VPIssuerEndpointTest.java index e4e8dce4f294..1f2270257ac5 100644 --- a/services/src/test/java/org/keycloak/protocol/oidc4vp/OIDC4VPIssuerEndpointTest.java +++ b/services/src/test/java/org/keycloak/protocol/oidc4vp/OIDC4VPIssuerEndpointTest.java @@ -67,9 +67,10 @@ public void setUp() throws NoSuchFieldException { this.keycloakSession = mock(KeycloakSession.class); this.bearerTokenAuthenticator = mock(AppAuthManager.BearerTokenAuthenticator.class); this.testEndpoint = new OIDC4VPIssuerEndpoint(keycloakSession, ISSUER_DID, url.getPath(), - Optional.of("Ed25519"), + Optional.of("RS256"), Optional.of("Ed25519Signature2018"), - bearerTokenAuthenticator, new ObjectMapper(), fixedClock); + Optional.of("RS256"), + bearerTokenAuthenticator, new ObjectMapper(), fixedClock, 3, Optional.empty()); } @Test @@ -159,7 +160,7 @@ public void testGetCredential(UserModel userModel, Stream clientMod VerifiableCredential verifiableCredential = OBJECT_MAPPER.convertValue(credential, VerifiableCredential.class); verifyLDCredential(expectedResult, verifiableCredential); } - case JWT_VC_JSON_LD, JWT_VC, JWT_VC_JSON -> verifyJWTCredential(expectedResult, (String) credential); + case JWT_VC -> verifyJWTCredential(expectedResult, (String) credential); } } diff --git a/services/src/test/java/org/keycloak/protocol/oidc4vp/signing/JWTSigningServiceTest.java b/services/src/test/java/org/keycloak/protocol/oidc4vp/signing/JWTSigningServiceTest.java deleted file mode 100644 index 26b679b4a30a..000000000000 --- a/services/src/test/java/org/keycloak/protocol/oidc4vp/signing/JWTSigningServiceTest.java +++ /dev/null @@ -1,89 +0,0 @@ -package org.keycloak.protocol.oidc4vp.signing; - -import org.bouncycastle.crypto.util.PrivateKeyInfoFactory; -import org.bouncycastle.openssl.jcajce.JcaPEMWriter; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.BeforeAll; -import org.keycloak.TokenVerifier; -import org.keycloak.common.VerificationException; -import org.keycloak.crypto.Algorithm; -import org.keycloak.representations.JsonWebToken; -import org.keycloak.util.TokenUtil; - -import java.io.IOException; -import java.io.StringWriter; -import java.security.KeyPair; -import java.security.KeyPairGenerator; -import java.security.NoSuchAlgorithmException; -import java.time.Clock; -import java.time.Instant; -import java.time.ZoneId; -import java.util.Optional; - -import static org.junit.jupiter.api.Assertions.*; - -class JWTSigningServiceTest extends SigningServiceTest { - private static final String CONTEXT_URL = "https://www.w3.org/2018/credentials/v1"; - - @BeforeAll - public static void setup() { - - } - - @Test - public void test() { - RSAKeyLoader keyLoader = new RSAKeyLoader(); - JWTSigningService jwtSigningService = new JWTSigningService( - keyLoader, - Optional.of("my-key-id"), - Clock.fixed(Instant.ofEpochSecond(1000), ZoneId.of("UTC")), - Algorithm.RS256); - - var testCredential = getTestCredential(); - - String jwtCredential = jwtSigningService.signCredential(testCredential); - var verifier = TokenVerifier.create(jwtCredential, JsonWebToken.class); - verifier.publicKey(keyLoader.getKeyPair().getPublic()); - try { - verifier.verify(); - } catch (VerificationException e) { - fail("The credential should successfully be verified.", e); - } - } - - - class RSAKeyLoader implements KeyLoader { - - private KeyPair keyPair; - - public KeyPair getKeyPair() { - return keyPair; - } - - public RSAKeyLoader() { - try { - KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); - kpg.initialize(2048); - keyPair = kpg.generateKeyPair(); - } catch (NoSuchAlgorithmException e) { - throw new RuntimeException(e); - } - } - - @Override - public String loadKey() { - - StringWriter stringWriter = new StringWriter(); - JcaPEMWriter pemWriter = new JcaPEMWriter(stringWriter); - try { - pemWriter.writeObject(keyPair); - pemWriter.flush(); - pemWriter.close(); - return stringWriter.toString(); - } catch (IOException e) { - throw new RuntimeException(e); - } - - } - } -} \ No newline at end of file diff --git a/services/src/test/java/org/keycloak/protocol/oidc4vp/signing/JwtSigningServiceTest.java b/services/src/test/java/org/keycloak/protocol/oidc4vp/signing/JwtSigningServiceTest.java new file mode 100644 index 000000000000..e808ff8f81dd --- /dev/null +++ b/services/src/test/java/org/keycloak/protocol/oidc4vp/signing/JwtSigningServiceTest.java @@ -0,0 +1,48 @@ +package org.keycloak.protocol.oidc4vp.signing; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.keycloak.TokenVerifier; +import org.keycloak.common.VerificationException; +import org.keycloak.crypto.Algorithm; +import org.keycloak.representations.JsonWebToken; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.fail; + +class JwtSigningServiceTest extends SigningServiceTest { + private static final String CONTEXT_URL = "https://www.w3.org/2018/credentials/v1"; + + @BeforeAll + public static void setup() { + + } + + @Test + public void test() { + RSAKeyLoader keyLoader = new RSAKeyLoader(); + JwtSigningService jwtSigningService = new JwtSigningService( + keyLoader, + Optional.of("my-key-id"), + Clock.fixed(Instant.ofEpochSecond(1000), ZoneId.of("UTC")), + Algorithm.RS256); + + var testCredential = getTestCredential(); + + String jwtCredential = jwtSigningService.signCredential(testCredential); + var verifier = TokenVerifier.create(jwtCredential, JsonWebToken.class); + verifier.publicKey(keyLoader.getKeyPair().getPublic()); + try { + verifier.verify(); + } catch (VerificationException e) { + fail("The credential should successfully be verified.", e); + } + } + + + +} \ No newline at end of file diff --git a/services/src/test/java/org/keycloak/protocol/oidc4vp/signing/LDSigningServiceTest.java b/services/src/test/java/org/keycloak/protocol/oidc4vp/signing/LDSigningServiceTest.java index 22d944e52e58..48bfc127e3b8 100644 --- a/services/src/test/java/org/keycloak/protocol/oidc4vp/signing/LDSigningServiceTest.java +++ b/services/src/test/java/org/keycloak/protocol/oidc4vp/signing/LDSigningServiceTest.java @@ -27,15 +27,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue; class LDSigningServiceTest extends SigningServiceTest { - private ObjectMapper objectMapper; + private ObjectMapper objectMapper = new ObjectMapper(); - @BeforeAll - public static void setup() { - ObjectMapper objectMapper = new ObjectMapper(); - objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); - objectMapper.setDateFormat(new StdDateFormat().withColonInTimeZone(true)); - } @Test public void testEd25519Signature() throws IOException { diff --git a/services/src/test/java/org/keycloak/protocol/oidc4vp/signing/SdJwtSigningServiceTest.java b/services/src/test/java/org/keycloak/protocol/oidc4vp/signing/SdJwtSigningServiceTest.java new file mode 100644 index 000000000000..8df852b8a9fa --- /dev/null +++ b/services/src/test/java/org/keycloak/protocol/oidc4vp/signing/SdJwtSigningServiceTest.java @@ -0,0 +1,56 @@ +package org.keycloak.protocol.oidc4vp.signing; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.keycloak.TokenVerifier; +import org.keycloak.common.VerificationException; +import org.keycloak.crypto.Algorithm; +import org.keycloak.crypto.JavaAlgorithm; +import org.keycloak.crypto.JavaAlgorithmHashProvider; +import org.keycloak.representations.JsonWebToken; + +import java.io.IOException; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; +import java.util.Base64; +import java.util.Optional; +import java.util.StringJoiner; + +import static org.junit.jupiter.api.Assertions.fail; + +class SdJwtSigningServiceTest extends SigningServiceTest { + + @Test + public void test() throws IOException { + + RSAKeyLoader keyLoader = new RSAKeyLoader(); + SdJwtSigningService sdJwtSigningService = new SdJwtSigningService( + keyLoader, + Optional.of("my-key-id"), + Clock.fixed(Instant.ofEpochSecond(1000), ZoneId.of("UTC")), + Algorithm.RS256, + new ObjectMapper(), + 3); + String sdJwt = sdJwtSigningService.signCredential(getTestCredential()); + + // the sd-jwt is dot-concatenated header.payload.signature.disclosure1.___.disclosureN + String[] splittedToken = sdJwt.split("\\."); + + String jwt = new StringJoiner(".") + // header + .add(splittedToken[0]) + // payload + .add(splittedToken[1]) + // signature + .add(splittedToken[2]) + .toString(); + var tokenVerifier = TokenVerifier.create(jwt, JsonWebToken.class); + tokenVerifier.publicKey(keyLoader.getKeyPair().getPublic()); + try { + tokenVerifier.verify(); + } catch (VerificationException e) { + fail("The credential should successfully be verified.", e); + } + } +} \ No newline at end of file diff --git a/services/src/test/java/org/keycloak/protocol/oidc4vp/signing/SigningServiceTest.java b/services/src/test/java/org/keycloak/protocol/oidc4vp/signing/SigningServiceTest.java index 9b0215306ba0..256c916c820b 100644 --- a/services/src/test/java/org/keycloak/protocol/oidc4vp/signing/SigningServiceTest.java +++ b/services/src/test/java/org/keycloak/protocol/oidc4vp/signing/SigningServiceTest.java @@ -1,9 +1,15 @@ package org.keycloak.protocol.oidc4vp.signing; +import org.bouncycastle.openssl.jcajce.JcaPEMWriter; import org.keycloak.protocol.oidc4vp.model.CredentialSubject; import org.keycloak.protocol.oidc4vp.model.VerifiableCredential; +import java.io.IOException; +import java.io.StringWriter; import java.net.URI; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; import java.time.Instant; import java.util.Date; import java.util.List; @@ -18,6 +24,7 @@ protected VerifiableCredential getTestCredential() { CredentialSubject credentialSubject = new CredentialSubject(); credentialSubject.setClaims("id", String.format("uri:uuid:%s", UUID.randomUUID())); credentialSubject.setClaims("test", "test"); + credentialSubject.setClaims("arrayClaim", List.of("a", "b", "c")); VerifiableCredential testCredential = new VerifiableCredential(); testCredential.setContext(List.of(CONTEXT_URL)); testCredential.setType(List.of("VerifiableCredential")); @@ -27,4 +34,39 @@ protected VerifiableCredential getTestCredential() { testCredential.setCredentialSubject(credentialSubject); return testCredential; } + + class RSAKeyLoader implements KeyLoader { + + private KeyPair keyPair; + + public KeyPair getKeyPair() { + return keyPair; + } + + public RSAKeyLoader() { + try { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + keyPair = kpg.generateKeyPair(); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } + + @Override + public String loadKey() { + + StringWriter stringWriter = new StringWriter(); + JcaPEMWriter pemWriter = new JcaPEMWriter(stringWriter); + try { + pemWriter.writeObject(keyPair); + pemWriter.flush(); + pemWriter.close(); + return stringWriter.toString(); + } catch (IOException e) { + throw new RuntimeException(e); + } + + } + } }