/*
 * Copyright 2015 Red Hat, Inc.
 *
 *  All rights reserved. This program and the accompanying materials
 *  are made available under the terms of the Eclipse Public License v1.0
 *  and Apache License v2.0 which accompanies this distribution.
 *
 *  The Eclipse Public License is available at
 *  http://www.eclipse.org/legal/epl-v10.html
 *
 *  The Apache License v2.0 is available at
 *  http://www.opensource.org/licenses/apache2.0.php
 *
 *  You may elect to redistribute this code under either of these licenses.
 */
package io.vertx.ext.auth.impl.jose;

import io.vertx.core.buffer.Buffer;
import io.vertx.core.impl.logging.Logger;
import io.vertx.core.impl.logging.LoggerFactory;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;
import io.vertx.ext.auth.PubSecKeyOptions;
import io.vertx.ext.auth.impl.CertificateHelper;

import javax.crypto.*;
import javax.crypto.spec.SecretKeySpec;
import java.io.ByteArrayInputStream;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.security.*;
import java.security.cert.*;
import java.security.spec.*;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

JWK https://tools.ietf.org/html/rfc7517

In a nutshell a JWK is a Key(Pair) encoded as JSON. This implementation follows the spec with some limitations:

* Supported algorithms are: "PS256", "PS384", "PS512", "RS256", "RS384", "RS512", "ES256", "ES256K", "ES384", "ES512", "HS256", "HS384", "HS512"

When working with COSE, then "RS1" is also a valid algorithm.

The rationale for this choice is to support the required algorithms for JWT.

The constructor takes a single JWK (the the KeySet) or a PEM encoded pair (used by Google and useful for importing standard PEM files from OpenSSL).

Certificate chains (x5c) are allowed and verified, certificate urls and fingerprints are not considered.

Author:Paulo Lopes
/** * JWK https://tools.ietf.org/html/rfc7517 * <p> * In a nutshell a JWK is a Key(Pair) encoded as JSON. This implementation follows the spec with some limitations: * <p> * * Supported algorithms are: "PS256", "PS384", "PS512", "RS256", "RS384", "RS512", "ES256", "ES256K", "ES384", "ES512", "HS256", "HS384", "HS512" * <p> * When working with COSE, then "RS1" is also a valid algorithm. * <p> * The rationale for this choice is to support the required algorithms for JWT. * <p> * The constructor takes a single JWK (the the KeySet) or a PEM encoded pair (used by Google and useful for importing * standard PEM files from OpenSSL). * <p> * Certificate chains (x5c) are allowed and verified, certificate urls and fingerprints are not considered. * * @author Paulo Lopes */
public final class JWK implements Crypto { public static final int USE_SIG = 1; public static final int USE_ENC = 2; private static final Logger LOG = LoggerFactory.getLogger(JWK.class); // JSON JWK properties private final String kid; private final String alg; private final String kty; private final int use; // the label is a synthetic id that allows comparing 2 keys // that are expected to replace each other but are not necessarely // the same key cryptographically speaking. // In most cases it should be the same as kid, or synthetically generated // when there's no kid. private final String label; // the length of the signature. This is derived from the algorithm name // this will help to cope with signatures that are longer (yet valid) than // the expected result private final int len; // if a key is marked as symmetric it can be used interchangeably private final boolean symmetric; // the cryptography objects, not all will be initialized private PrivateKey privateKey; private PublicKey publicKey; private Signature signature; private Mac mac; public static List<JWK> load(KeyStore keyStore, String keyStorePassword, Map<String, String> passwordProtection) { Map<String, String> aliases = new HashMap<String, String>() {{ put("HS256", "HMacSHA256"); put("HS384", "HMacSHA384"); put("HS512", "HMacSHA512"); put("RS256", "SHA256withRSA"); put("RS384", "SHA384withRSA"); put("RS512", "SHA512withRSA"); put("ES256K", "SHA256withECDSA"); put("ES256", "SHA256withECDSA"); put("ES384", "SHA384withECDSA"); put("ES512", "SHA512withECDSA"); }}; final List<JWK> keys = new ArrayList<>(); // load MACs for (String alias : Arrays.asList("HS256", "HS384", "HS512")) { try { final Key secretKey = keyStore.getKey(alias, keyStorePassword.toCharArray()); // key store does not have the requested algorithm if (secretKey == null) { continue; } // test the algorithm String alg = secretKey.getAlgorithm(); // the algorithm cannot be null and it cannot be different from // the alias list final String expected = aliases.get(alias); if (alg == null || !alg.equalsIgnoreCase(expected)) { LOG.warn("The key algorithm does not match " + expected); continue; } // algorithm is valid Mac mac = Mac.getInstance(alg); mac.init(secretKey); keys.add(new JWK(alias, mac)); } catch (KeyStoreException | NoSuchAlgorithmException | UnrecoverableKeyException | InvalidKeyException e) { LOG.warn("Failed to load key for algorithm: " + alias, e); } } for (String alias : Arrays.asList("RS256", "RS384", "RS512", "ES256K", "ES256", "ES384", "ES512")) { try { // Key pairs on keystores are stored with a certificate, so we use it to load a key pair X509Certificate certificate = (X509Certificate) keyStore.getCertificate(alias); // not found if (certificate == null) { continue; } // start validation certificate.checkValidity(); // verify that the algorithms match String alg = certificate.getSigAlgName(); // the algorithm cannot be null and it cannot be different from // the alias list final String expected = aliases.get(alias); if (alg == null || !alg.equalsIgnoreCase(expected)) { LOG.warn("The key algorithm does not match " + expected); continue; } // algorithm is valid PrivateKey privateKey = (PrivateKey) keyStore.getKey(alias, passwordProtection == null ? keyStorePassword.toCharArray() : passwordProtection.get(alias).toCharArray()); keys.add(new JWK(alias, certificate, privateKey)); } catch (ClassCastException | KeyStoreException | CertificateExpiredException | CertificateNotYetValidException | NoSuchAlgorithmException | UnrecoverableKeyException | InvalidAlgorithmParameterException e) { LOG.warn("Failed to load key for algorithm: " + alias, e); } } return keys; }
Creates a Key(Pair) from pem formatted strings.
Params:
  • options – PEM pub sec key options.
/** * Creates a Key(Pair) from pem formatted strings. * * @param options PEM pub sec key options. */
public JWK(PubSecKeyOptions options) { alg = options.getAlgorithm(); kid = options.getId(); final String pem = Objects.requireNonNull(options.getBuffer()); label = kid == null ? alg + "#" + pem.hashCode() : kid; // Handle Mac keys switch (alg) { case "HS256": try { mac = Mac.getInstance("HMacSHA256"); mac.init(new SecretKeySpec(pem.getBytes(StandardCharsets.US_ASCII), "HMacSHA256")); } catch (NoSuchAlgorithmException | InvalidKeyException e) { throw new RuntimeException(e); } kty = "oct"; len = 256; // this is a symmetric key symmetric = true; use = USE_SIG + USE_ENC; return; case "HS384": try { mac = Mac.getInstance("HMacSHA384"); mac.init(new SecretKeySpec(pem.getBytes(StandardCharsets.US_ASCII), "HMacSHA384")); } catch (NoSuchAlgorithmException | InvalidKeyException e) { throw new RuntimeException(e); } kty = "oct"; len = 384; // this is a symmetric key symmetric = true; use = USE_SIG + USE_ENC; return; case "HS512": try { mac = Mac.getInstance("HMacSHA512"); mac.init(new SecretKeySpec(pem.getBytes(StandardCharsets.US_ASCII), "HMacSHA512")); } catch (NoSuchAlgorithmException | InvalidKeyException e) { throw new RuntimeException(e); } kty = "oct"; len = 512; // this is a symmetric key symmetric = true; use = USE_SIG + USE_ENC; return; } // Handle Pub-Sec Keys symmetric = false; try { switch (alg) { case "RS256": case "RS384": case "RS512": kty = "RSA"; use = parsePEM(KeyFactory.getInstance("RSA"), pem); signature = JWS.getSignature(alg); len = JWS.getSignatureLength(alg, publicKey); break; case "PS256": case "PS384": case "PS512": kty = "RSASSA"; use = parsePEM(KeyFactory.getInstance("RSA"), pem); signature = JWS.getSignature(alg); len = JWS.getSignatureLength(alg, publicKey); break; case "ES256": case "ES384": case "ES512": case "ES256K": kty = "EC"; len = JWS.getSignatureLength(alg, publicKey); use = parsePEM(KeyFactory.getInstance("EC"), pem); signature = JWS.getSignature(alg); break; default: throw new IllegalArgumentException("Unknown algorithm: " + alg); } } catch (InvalidKeySpecException | CertificateException | NoSuchAlgorithmException | InvalidAlgorithmParameterException e) { // error throw new RuntimeException(e); } } private int parsePEM(KeyFactory kf, String pem) throws CertificateException, InvalidKeySpecException { // extract the information from the pem String[] lines = pem.split("\r?\n"); // A PEM PKCS#8 formatted string shall contain on the first line the kind of content if (lines.length <= 2) { throw new IllegalArgumentException("PEM contains not enough lines"); } // there must be more than 2 lines Pattern begin = Pattern.compile("-----BEGIN (.+?)-----"); Pattern end = Pattern.compile("-----END (.+?)-----"); Matcher beginMatcher = begin.matcher(lines[0]); if (!beginMatcher.matches()) { throw new IllegalArgumentException("PEM first line does not match a BEGIN line"); } String kind = beginMatcher.group(1); Buffer buffer = Buffer.buffer(); boolean endSeen = false; for (int i = 1; i < lines.length; i++) { if ("".equals(lines[i])) { continue; } Matcher endMatcher = end.matcher(lines[i]); if (endMatcher.matches()) { endSeen = true; if (!kind.equals(endMatcher.group(1))) { throw new IllegalArgumentException("PEM END line does not match start"); } break; } buffer.appendString(lines[i]); } if (!endSeen) { throw new IllegalArgumentException("PEM END line not found"); } switch (kind) { case "CERTIFICATE": final CertificateFactory cf = CertificateFactory.getInstance("X.509"); publicKey = cf.generateCertificate(new ByteArrayInputStream(pem.getBytes(StandardCharsets.US_ASCII))).getPublicKey(); return USE_ENC; case "PUBLIC KEY": case "PUBLIC RSA KEY": publicKey = kf.generatePublic(new X509EncodedKeySpec(Base64.getMimeDecoder().decode(buffer.getBytes()))); return USE_ENC; case "PRIVATE KEY": case "PRIVATE RSA KEY": privateKey = kf.generatePrivate(new PKCS8EncodedKeySpec(Base64.getMimeDecoder().decode(buffer.getBytes()))); return USE_SIG; default: throw new IllegalStateException("Invalid PEM content: " + kind); } } private JWK(String algorithm, Mac mac) throws NoSuchAlgorithmException { alg = algorithm; kid = null; label = alg + "#" + mac.hashCode(); // this is a symmetric key symmetric = true; use = USE_SIG + USE_ENC; // test the algorithm String macAlg = mac.getAlgorithm(); switch (alg) { case "HS256": kty = "oct"; len = 256; if (!"HMacSHA256".equalsIgnoreCase(macAlg)) { throw new IllegalArgumentException("The key algorithm does not match, expected: HMacSHA256"); } this.mac = mac; break; case "HS384": kty = "oct"; len = 384; if (!"HMacSHA384".equalsIgnoreCase(macAlg)) { throw new IllegalArgumentException("The key algorithm does not match, expected: HMacSHA384"); } this.mac = mac; break; case "HS512": kty = "oct"; len = 512; if (!"HMacSHA512".equalsIgnoreCase(macAlg)) { throw new IllegalArgumentException("The key algorithm does not match, expected: HMacSHA512"); } this.mac = mac; break; default: throw new NoSuchAlgorithmException("Unknown algorithm: " + algorithm); } } private JWK(String algorithm, X509Certificate certificate, PrivateKey privateKey) throws NoSuchAlgorithmException, InvalidAlgorithmParameterException { alg = algorithm; kid = null; label = privateKey != null ? algorithm + '#' + certificate.hashCode() + "-" + privateKey.hashCode() : algorithm + '#' + certificate.hashCode(); symmetric = false; this.publicKey = certificate.getPublicKey(); this.privateKey = privateKey; // this key does both because we have a certificate (public) + private key ? if (privateKey != null) { use = USE_ENC + USE_SIG; } else { use = USE_ENC; } switch (algorithm) { case "RS256": case "RS384": case "RS512": kty = "RSA"; signature = JWS.getSignature(alg); len = JWS.getSignatureLength(alg, publicKey); break; case "PS256": case "PS384": case "PS512": kty = "RSASSA"; signature = JWS.getSignature(alg); len = JWS.getSignatureLength(alg, publicKey); break; case "ES256": case "ES384": case "ES512": case "ES256K": kty = "EC"; len = JWS.getSignatureLength(alg, publicKey); signature = JWS.getSignature(alg); break; default: throw new NoSuchAlgorithmException("Unknown algorithm: " + algorithm); } } public JWK(JsonObject json) { kid = json.getString("kid"); try { switch (json.getString("kty")) { case "RSA": case "RSASSA": kty = json.getString("kty"); // get the alias for the algorithm alg = json.getString("alg", "RS256"); symmetric = false; switch (alg) { case "RS1": // special case for COSE case "RS256": case "RS384": case "RS512": case "PS256": case "PS384": case "PS512": use = createRSA(json); len = JWS.getSignatureLength(alg, publicKey); break; default: throw new NoSuchAlgorithmException(alg); } break; case "EC": kty = json.getString("kty"); // get the alias for the algorithm alg = json.getString("alg", "ES256"); symmetric = false; switch (alg) { case "ES256": case "ES256K": case "ES512": case "ES384": len = JWS.getSignatureLength(alg, publicKey); use = createEC(json); break; default: throw new NoSuchAlgorithmException(alg); } break; // case "OKP": // kty = json.getString("kty"); // // get the alias for the algorithm // alg = json.getString("alg", "EdDSA"); // symmetric = false; // break; case "oct": kty = json.getString("kty"); // get the alias for the algorithm alg = json.getString("alg", "HS256"); symmetric = true; switch (alg) { case "HS256": len = 256; use = createOCT("HMacSHA256", json); break; case "HS384": len = 384; use = createOCT("HMacSHA384", json); break; case "HS512": len = 512; use = createOCT("HMacSHA512", json); break; default: throw new NoSuchAlgorithmException(alg); } break; default: throw new RuntimeException("Unsupported key type: " + json.getString("kty")); } label = kid != null ? kid : alg + "#" + json.hashCode(); } catch (NoSuchAlgorithmException | InvalidKeyException | InvalidKeySpecException | InvalidParameterSpecException | CertificateException | NoSuchPaddingException | NoSuchProviderException | SignatureException | InvalidAlgorithmParameterException e) { throw new RuntimeException(e); } } private int createRSA(JsonObject json) throws NoSuchAlgorithmException, InvalidKeySpecException, CertificateException, InvalidKeyException, NoSuchProviderException, SignatureException, InvalidAlgorithmParameterException { int use = 0; // public key if (jsonHasProperties(json, "n", "e")) { final BigInteger n = new BigInteger(1, json.getBinary("n")); final BigInteger e = new BigInteger(1, json.getBinary("e")); publicKey = KeyFactory.getInstance("RSA").generatePublic(new RSAPublicKeySpec(n, e)); if ((use & USE_ENC) == 0) { use += USE_ENC; } // private key if (jsonHasProperties(json, "d", "p", "q", "dp", "dq", "qi")) { final BigInteger d = new BigInteger(1, json.getBinary("d")); final BigInteger p = new BigInteger(1, json.getBinary("p")); final BigInteger q = new BigInteger(1, json.getBinary("q")); final BigInteger dp = new BigInteger(1, json.getBinary("dp")); final BigInteger dq = new BigInteger(1, json.getBinary("dq")); final BigInteger qi = new BigInteger(1, json.getBinary("qi")); privateKey = KeyFactory.getInstance("RSA").generatePrivate(new RSAPrivateCrtKeySpec(n, e, d, p, q, dp, dq, qi)); if ((use & USE_SIG) == 0) { use += USE_SIG; } } } // certificate chain if (json.containsKey("x5c")) { JsonArray x5c = json.getJsonArray("x5c"); List<X509Certificate> certChain = new ArrayList<>(); for (int i = 0; i < x5c.size(); i++) { certChain.add(JWS.parseX5c(x5c.getString(i))); } // validate the chain (don't assume the chain includes the root CA certificate CertificateHelper.checkValidity(certChain, false, null); final X509Certificate certificate = certChain.get(0); // extract the public key publicKey = certificate.getPublicKey(); if ((use & USE_ENC) == 0) { use += USE_ENC; } } switch (json.getString("use", "sig")) { case "sig": // use default signature = JWS.getSignature(alg); if ((use & USE_SIG) == 0) { use += USE_SIG; } break; case "enc": if ((use & USE_ENC) == 0) { use += USE_ENC; } } return use; } private int createEC(JsonObject json) throws NoSuchAlgorithmException, InvalidKeySpecException, InvalidParameterSpecException, NoSuchPaddingException, InvalidAlgorithmParameterException { int use = 0; AlgorithmParameters parameters = AlgorithmParameters.getInstance("EC"); parameters.init(new ECGenParameterSpec(translateECCrv(json.getString("crv")))); // public key if (jsonHasProperties(json, "x", "y")) { final BigInteger x = new BigInteger(1, json.getBinary("x")); final BigInteger y = new BigInteger(1, json.getBinary("y")); publicKey = KeyFactory.getInstance("EC").generatePublic(new ECPublicKeySpec(new ECPoint(x, y), parameters.getParameterSpec(ECParameterSpec.class))); if ((use & USE_ENC) == 0) { use += USE_ENC; } // private key if (jsonHasProperties(json, "d")) { final BigInteger d = new BigInteger(1, json.getBinary("d")); privateKey = KeyFactory.getInstance("EC").generatePrivate(new ECPrivateKeySpec(d, parameters.getParameterSpec(ECParameterSpec.class))); if ((use & USE_SIG) == 0) { use += USE_SIG; } } } switch (json.getString("use", "sig")) { case "sig": signature = JWS.getSignature(alg); if ((use & USE_SIG) == 0) { use += USE_SIG; } break; case "enc": if ((use & USE_ENC) == 0) { use += USE_ENC; } break; } return use; } private int createOCT(String alias, JsonObject json) throws NoSuchAlgorithmException, InvalidKeyException { mac = Mac.getInstance(alias); mac.init(new SecretKeySpec(json.getBinary("k"), alias)); return USE_SIG + USE_ENC; } public String getAlgorithm() { return alg; } public String getId() { return kid; } @Override public synchronized byte[] sign(byte[] payload) { if (!isFor(USE_SIG)) { throw new IllegalStateException("Key use is not 'sig'"); } if (symmetric) { return mac.doFinal(payload); } else { try { signature.initSign(privateKey); signature.update(payload); byte[] sig = signature.sign(); switch (kty) { case "EC": return JWS.toJWS(sig, len); default: return sig; } } catch (SignatureException | InvalidKeyException e) { throw new RuntimeException(e); } } } @Override public synchronized boolean verify(byte[] expected, byte[] payload) { if (!isFor(USE_ENC)) { throw new IllegalStateException("Key use is not 'enc'"); } try { if (expected == null) { throw new SignatureException("signature is missing"); } if (symmetric) { return MessageDigest.isEqual(expected, sign(payload)); } else { signature.initVerify(publicKey); signature.update(payload); switch (kty) { case "EC": // JCA EC signatures expect ASN1 formatted signatures // while JWS uses it's own format, while this will be true // for all JWS, it may not be true for COSE keys if (!JWS.isASN1(expected)) { expected = JWS.toASN1(expected); } break; } if (expected.length < len) { // need to adapt the expectation to make the RSA? engine happy byte[] normalized = new byte[len]; System.arraycopy(expected, 0, normalized, 0, expected.length); return signature.verify(normalized); } else { return signature.verify(expected); } } } catch (SignatureException | InvalidKeyException e) { throw new RuntimeException(e); } } private static String translateECCrv(String crv) { switch (crv) { case "P-256": return "secp256r1"; case "P-384": return "secp384r1"; case "P-521": return "secp521r1"; case "secp256k1": return "secp256k1"; default: throw new IllegalArgumentException("Unsupported {crv}: " + crv); } } private static boolean jsonHasProperties(JsonObject json, String... properties) { for (String property : properties) { if (!json.containsKey(property) || json.getValue(property) == null) { return false; } } return true; } public boolean isFor(int use) { return (this.use & use) != 0; } public int getUse() { return use; } @Override public String getLabel() { return label; } public String getType() { return kty; } public Signature getSignature() { return signature; } public Mac getMac() { return mac; } public PublicKey getPublicKey() { return publicKey; } public PrivateKey getPrivateKey() { return privateKey; } }