Skip to content

Commit

Permalink
Implement advanced verification of SD-JWT in Keycloak (keycloak#30966)
Browse files Browse the repository at this point in the history
closes keycloak#30907

Signed-off-by: Ingrid Kamga <[email protected]>
  • Loading branch information
IngridPuppet authored Aug 5, 2024
1 parent 4080ee2 commit 36a1410
Show file tree
Hide file tree
Showing 44 changed files with 2,620 additions and 44 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@
package org.keycloak.crypto;

import org.keycloak.common.VerificationException;
import org.keycloak.common.crypto.CryptoIntegration;

import java.security.NoSuchAlgorithmException;
import java.security.NoSuchProviderException;
import java.security.PublicKey;
import java.security.Signature;

Expand All @@ -42,7 +45,7 @@ public String getAlgorithm() {
@Override
public boolean verify(byte[] data, byte[] signature) throws VerificationException {
try {
Signature verifier = Signature.getInstance(JavaAlgorithm.getJavaAlgorithm(key.getAlgorithmOrDefault(), key.getCurve()));
Signature verifier = getSignature();
verifier.initVerify((PublicKey) key.getPublicKey());
verifier.update(data);
return verifier.verify(signature);
Expand All @@ -51,4 +54,13 @@ public boolean verify(byte[] data, byte[] signature) throws VerificationExceptio
}
}

private Signature getSignature()
throws NoSuchAlgorithmException, NoSuchProviderException {
try {
return Signature.getInstance(JavaAlgorithm.getJavaAlgorithm(key.getAlgorithmOrDefault(), key.getCurve()));
} catch (NoSuchAlgorithmException e) {
// Retry using the current crypto provider's override implementation
return CryptoIntegration.getProvider().getSignature(key.getAlgorithmOrDefault());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public class DisclosureRedList {
private final Set<SdJwtClaimName> redListClaimNames;
public static final DisclosureRedList defaultList = defaultList();

public DisclosureRedList of(Set<SdJwtClaimName> redListClaimNames) {
public static DisclosureRedList of(Set<SdJwtClaimName> redListClaimNames) {
return new DisclosureRedList(redListClaimNames);
}

Expand Down
49 changes: 44 additions & 5 deletions core/src/main/java/org/keycloak/sdjwt/IssuerSignedJWT.java
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,11 @@
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.stream.Collectors;

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

Expand All @@ -39,13 +42,12 @@
*/
public class IssuerSignedJWT extends SdJws {

public static IssuerSignedJWT fromJws(String jwsString) {
return new IssuerSignedJWT(jwsString);
public IssuerSignedJWT(JsonNode payload, SignatureSignerContext signer, String jwsType) {
super(payload, signer, 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 Down Expand Up @@ -134,6 +136,43 @@ private static JsonNode generatePayloadString(List<SdJwtClaim> claims, List<Deco
return payload;
}

/**
* Returns `cnf` claim (establishing key binding)
*/
public Optional<JsonNode> getCnfClaim() {
var cnf = getPayload().get("cnf");
return Optional.ofNullable(cnf);
}

/**
* Returns declared hash algorithm from SD hash claim.
*/
public String getSdHashAlg() {
var hashAlgNode = getPayload().get(CLAIM_NAME_SD_HASH_ALGORITHM);
return hashAlgNode == null ? "sha-256" : hashAlgNode.asText();
}

/**
* Verifies that the SD hash algorithm is understood and deemed secure.
*
* @throws VerificationException if not
*/
public void verifySdHashAlgorithm() throws VerificationException {
// Known secure algorithms
final Set<String> secureAlgorithms = Set.of(
"sha-256", "sha-384", "sha-512",
"sha3-256", "sha3-384", "sha3-512"
);

// Read SD hash claim
String hashAlg = getSdHashAlg();

// Safeguard algorithm
if (!secureAlgorithms.contains(hashAlg)) {
throw new VerificationException("Unexpected or insecure hash algorithm: " + hashAlg);
}
}

// SD-JWT Claims
public static final String CLAIM_NAME_SELECTIVE_DISCLOSURE = "_sd";
public static final String CLAIM_NAME_SD_HASH_ALGORITHM = "_sd_alg";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/*
* Copyright 2024 Red Hat, Inc. and/or its affiliates
* and other contributors as indicated by the @author tags.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package org.keycloak.sdjwt;

import org.keycloak.crypto.SignatureVerifierContext;

/**
* Options for Issuer-signed JWT verification.
*
* @author <a href="mailto:[email protected]">Ingrid Kamga</a>
*/
public class IssuerSignedJwtVerificationOpts {
private final SignatureVerifierContext verifier;
private final boolean validateIssuedAtClaim;
private final boolean validateExpirationClaim;
private final boolean validateNotBeforeClaim;

public IssuerSignedJwtVerificationOpts(
SignatureVerifierContext verifier,
boolean validateIssuedAtClaim,
boolean validateExpirationClaim,
boolean validateNotBeforeClaim) {
this.verifier = verifier;
this.validateIssuedAtClaim = validateIssuedAtClaim;
this.validateExpirationClaim = validateExpirationClaim;
this.validateNotBeforeClaim = validateNotBeforeClaim;
}

public SignatureVerifierContext getVerifier() {
return verifier;
}

public boolean mustValidateIssuedAtClaim() {
return validateIssuedAtClaim;
}

public boolean mustValidateExpirationClaim() {
return validateExpirationClaim;
}

public boolean mustValidateNotBeforeClaim() {
return validateNotBeforeClaim;
}

public static IssuerSignedJwtVerificationOpts.Builder builder() {
return new IssuerSignedJwtVerificationOpts.Builder();
}

public static class Builder {
private SignatureVerifierContext verifier;
private boolean validateIssuedAtClaim;
private boolean validateExpirationClaim = true;
private boolean validateNotBeforeClaim = true;

public Builder withVerifier(SignatureVerifierContext verifier) {
this.verifier = verifier;
return this;
}

public Builder withValidateIssuedAtClaim(boolean validateIssuedAtClaim) {
this.validateIssuedAtClaim = validateIssuedAtClaim;
return this;
}

public Builder withValidateExpirationClaim(boolean validateExpirationClaim) {
this.validateExpirationClaim = validateExpirationClaim;
return this;
}

public Builder withValidateNotBeforeClaim(boolean validateNotBeforeClaim) {
this.validateNotBeforeClaim = validateNotBeforeClaim;
return this;
}

public IssuerSignedJwtVerificationOpts build() {
return new IssuerSignedJwtVerificationOpts(
verifier,
validateIssuedAtClaim,
validateExpirationClaim,
validateNotBeforeClaim
);
}
}
}
84 changes: 77 additions & 7 deletions core/src/main/java/org/keycloak/sdjwt/SdJws.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,27 @@
package org.keycloak.sdjwt;

import java.io.IOException;
import java.time.Instant;
import java.util.List;
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.JWSHeader;
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 {
public abstract class SdJws {
private final JWSInput jwsInput;
private final JsonNode payload;

Expand All @@ -49,10 +52,6 @@ public JsonNode getPayload() {
return payload;
}

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

// Constructor for unsigned JWS
protected SdJws(JsonNode payload) {
this.payload = payload;
Expand Down Expand Up @@ -107,4 +106,75 @@ private static final JsonNode readPayload(JWSInput jwsInput) {
throw new RuntimeException(e);
}
}

public JWSHeader getHeader() {
return this.jwsInput.getHeader();
}

public void verifyIssuedAtClaim() throws VerificationException {
long now = Instant.now().getEpochSecond();
long iat = SdJwtUtils.readTimeClaim(payload, "iat");

if (now < iat) {
throw new VerificationException("jwt issued in the future");
}
}

public void verifyExpClaim() throws VerificationException {
long now = Instant.now().getEpochSecond();
long exp = SdJwtUtils.readTimeClaim(payload, "exp");

if (now >= exp) {
throw new VerificationException("jwt has expired");
}
}

public void verifyNotBeforeClaim() throws VerificationException {
long now = Instant.now().getEpochSecond();
long nbf = SdJwtUtils.readTimeClaim(payload, "nbf");

if (now < nbf) {
throw new VerificationException("jwt not valid yet");
}
}

/**
* Verifies that the JWS is not too old.
*
* @param maxAge Maximum age in seconds
* @throws VerificationException if too old
*/
public void verifyAge(int maxAge) throws VerificationException {
long now = Instant.now().getEpochSecond();
long iat = SdJwtUtils.readTimeClaim(getPayload(), "iat");

if (now - iat > maxAge) {
throw new VerificationException("jwt is too old");
}
}

/**
* Verifies that SD-JWT was issued by one of the provided issuers.
* @param issuers List of trusted issuers
*/
public void verifyIssClaim(List<String> issuers) throws VerificationException {
verifyClaimAgainstTrustedValues(issuers, "iss");
}

/**
* Verifies that SD-JWT vct claim matches the expected one.
* @param vcts list of supported verifiable credential types
*/
public void verifyVctClaim(List<String> vcts) throws VerificationException {
verifyClaimAgainstTrustedValues(vcts, "vct");
}

private void verifyClaimAgainstTrustedValues(List<String> trustedValues, String claimName)
throws VerificationException {
String claimValue = SdJwtUtils.readClaim(payload, claimName);

if (!trustedValues.contains(claimValue)) {
throw new VerificationException(String.format("Unknown '%s' claim value: %s", claimName, claimValue));
}
}
}
14 changes: 14 additions & 0 deletions core/src/main/java/org/keycloak/sdjwt/SdJwt.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import java.util.stream.Collectors;
import java.util.stream.IntStream;

import org.keycloak.common.VerificationException;
import org.keycloak.crypto.SignatureSignerContext;
import org.keycloak.sdjwt.vp.KeyBindingJWT;

Expand Down Expand Up @@ -194,6 +195,19 @@ public List<String> getDisclosures() {
return disclosures;
}

/**
* Verifies SD-JWT as to whether the Issuer-signed JWT's signature and disclosures are valid.
*
* @param verificationOpts Options to parameterize the Issuer-Signed JWT verification. A verifier
* must be specified for validating the Issuer-signed JWT. The caller
* is responsible for establishing trust in that associated public keys
* belong to the intended issuer.
* @throws VerificationException if verification failed
*/
public void verify(IssuerSignedJwtVerificationOpts verificationOpts) throws VerificationException {
new SdJwtVerificationContext(issuerSignedJWT, disclosures).verifyIssuance(verificationOpts);
}

// builder for SdJwt
public static class Builder {
private DisclosureSpec disclosureSpec;
Expand Down
Loading

0 comments on commit 36a1410

Please sign in to comment.