001/** 002 * Copyright 2021 Emmanuel Bourg 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016 017package net.jsign.jca; 018 019import java.io.ByteArrayInputStream; 020import java.io.IOException; 021import java.nio.ByteBuffer; 022import java.security.GeneralSecurityException; 023import java.security.KeyStoreException; 024import java.security.MessageDigest; 025import java.security.UnrecoverableKeyException; 026import java.security.cert.Certificate; 027import java.security.cert.CertificateException; 028import java.security.cert.CertificateFactory; 029import java.util.ArrayList; 030import java.util.Base64; 031import java.util.HashMap; 032import java.util.LinkedHashMap; 033import java.util.List; 034import java.util.Map; 035import java.util.stream.Collectors; 036import java.util.stream.Stream; 037import javax.crypto.Mac; 038import javax.crypto.spec.SecretKeySpec; 039 040import com.cedarsoftware.util.io.JsonWriter; 041import org.bouncycastle.operator.DefaultSignatureAlgorithmIdentifierFinder; 042 043import net.jsign.DigestAlgorithm; 044 045/** 046 * SSL.com eSigner signing service. 047 * 048 * @see <a href="https://www.ssl.com/guide/integration-guide-testing-remote-signing-with-esigner-csc-api/">Integration Guide to Testing Remote Signing with eSigner CSC API</a> 049 * @see <a href="https://www.ssl.com/guide/esigner-demo-credentials-and-certificates/">eSigner Demo Credentials and Certificates</a> 050 * @see <a href="https://cloudsignatureconsortium.org/wp-content/uploads/2020/05/CSC_API_V0_0.1.7.9.pdf">CSC API specifications (version 0.1.7.9)</a> 051 * @since 4.1 052 */ 053public class ESignerSigningService implements SigningService { 054 055 /** Cache of certificates indexed by alias */ 056 private final Map<String, Map<String, ?>> certificates = new HashMap<>(); 057 058 private final RESTClient client; 059 060 public ESignerSigningService(String endpoint, String username, String password) throws IOException { 061 this(endpoint, getAccessToken(endpoint.contains("-try.ssl.com") ? "https://oauth-sandbox.ssl.com" : "https://login.ssl.com", 062 endpoint.contains("-try.ssl.com") ? "qOUeZCCzSqgA93acB3LYq6lBNjgZdiOxQc-KayC3UMw" : "kaXTRACNijSWsFdRKg_KAfD3fqrBlzMbWs6TwWHwAn8", 063 username, password)); 064 } 065 066 public ESignerSigningService(String endpoint, String accessToken) { 067 client = new RESTClient(endpoint, conn -> conn.setRequestProperty("Authorization", "Bearer " + accessToken)); 068 } 069 070 private static String getAccessToken(String endpoint, String clientId, String username, String password) throws IOException { 071 Map<String, String> request = new LinkedHashMap<>(); 072 request.put("client_id", clientId); 073 request.put("grant_type", "password"); 074 request.put("username", username); 075 request.put("password", password); 076 077 RESTClient client = new RESTClient(endpoint); 078 Map<String, ?> response = client.post("/oauth2/token", JsonWriter.objectToJson(request)); 079 return (String) response.get("access_token"); 080 } 081 082 @Override 083 public String getName() { 084 return "ESIGNER"; 085 } 086 087 @Override 088 public List<String> aliases() throws KeyStoreException { 089 try { 090 Map<String, String> request = new HashMap<>(); 091 request.put("clientData", "EVCS"); 092 Map<String, ?> response = client.post("/csc/v0/credentials/list", JsonWriter.objectToJson(request)); 093 Object[] credentials = (Object[]) response.get("credentialIDs"); 094 return Stream.of(credentials).map(Object::toString).collect(Collectors.toList()); 095 } catch (IOException e) { 096 throw new KeyStoreException("Unable to retrieve SSL.com certificate aliases", e); 097 } 098 } 099 100 /** 101 * Returns the certificate details 102 * 103 * @param alias the alias of the certificate 104 */ 105 private Map<String, ?> getCertificateInfo(String alias) throws IOException { 106 if (!certificates.containsKey(alias)) { 107 Map<String, String> request = new HashMap<>(); 108 request.put("credentialID", alias); 109 request.put("certificates", "chain"); 110 Map<String, ?> response = client.post("/csc/v0/credentials/info", JsonWriter.objectToJson(request)); 111 certificates.put(alias, (Map) response.get("cert")); 112 } 113 114 return certificates.get(alias); 115 } 116 117 @Override 118 public Certificate[] getCertificateChain(String alias) throws KeyStoreException { 119 try { 120 Map<String, ?> cert = getCertificateInfo(alias); 121 Object[] encodedChain = (Object[]) cert.get("certificates"); 122 123 List<Certificate> chain = new ArrayList<>(); 124 for (Object encodedCertificate : encodedChain) { 125 chain.add(CertificateFactory.getInstance("X.509").generateCertificate(new ByteArrayInputStream(Base64.getDecoder().decode(encodedCertificate.toString())))); 126 } 127 return chain.toArray(new Certificate[0]); 128 } catch (IOException | CertificateException e) { 129 throw new KeyStoreException("Unable to retrieve SSL.com certificate '" + alias + "'", e); 130 } 131 } 132 133 @Override 134 public SigningServicePrivateKey getPrivateKey(String alias, char[] password) throws UnrecoverableKeyException { 135 try { 136 Certificate[] chain = getCertificateChain(alias); 137 String algorithm = chain[0].getPublicKey().getAlgorithm(); 138 SigningServicePrivateKey key = new SigningServicePrivateKey(alias, algorithm); 139 if (password != null) { 140 key.getProperties().put("totpsecret", new String(password)); 141 } 142 return key; 143 } catch (KeyStoreException e) { 144 throw (UnrecoverableKeyException) new UnrecoverableKeyException().initCause(e); 145 } 146 } 147 148 private void scan(SigningServicePrivateKey privateKey, String hashToSign, String hashToScan) { 149 boolean malwareScanEnabled; 150 151 Map<String, Object> args = new HashMap<>(); 152 args.put(JsonWriter.TYPE, "false"); 153 154 Map<String, Object> request = new LinkedHashMap<>(); 155 request.put("credential_id", privateKey.getId()); 156 try { 157 Map<String, ?> response = client.post("/scan/settings", JsonWriter.objectToJson(request, args)); 158 malwareScanEnabled = Boolean.TRUE.equals(response.get("malware_scan_enabled")); 159 } catch (IOException e) { 160 throw new RuntimeException(e); 161 } 162 163 if (malwareScanEnabled) { 164 request = new LinkedHashMap<>(); 165 request.put("credential_id", privateKey.getId()); 166 request.put("hash_to_scan", hashToScan); 167 request.put("hash_to_sign", hashToSign); 168 169 try { 170 client.post("/scan/hash", JsonWriter.objectToJson(request, args)); 171 } catch (IOException e) { 172 throw new RuntimeException(e); 173 } 174 } 175 } 176 177 @Override 178 public byte[] sign(SigningServicePrivateKey privateKey, String algorithm, byte[] data) throws GeneralSecurityException { 179 MessageDigest digest = DigestAlgorithm.of(algorithm.substring(0, algorithm.toLowerCase().indexOf("with"))).getMessageDigest(); 180 data = digest.digest(data); 181 String hash = Base64.getEncoder().encodeToString(data); 182 183 // Skip malware scanning. eSigner expects the SHA-256 hash of the full file, but scanning for malwares 184 // requires a little more than a mere hash controlled by the client. We just send a bogus hash instead. 185 scan(privateKey, hash, Base64.getEncoder().encodeToString(DigestAlgorithm.SHA256.getMessageDigest().digest(data))); 186 187 Map<String, Object> request = new LinkedHashMap<>(); 188 request.put("credentialID", privateKey.getId()); 189 request.put("SAD", getSignatureActivationData(privateKey, hash)); 190 request.put("hash", new String[] { hash }); 191 request.put("signAlgo", new DefaultSignatureAlgorithmIdentifierFinder().find(algorithm).getAlgorithm().getId()); 192 193 Map<String, Object> args = new HashMap<>(); 194 args.put(JsonWriter.TYPE, "false"); 195 try { 196 Map<String, ?> response = client.post("/csc/v0/signatures/signHash", JsonWriter.objectToJson(request, args)); 197 Object[] signatures = (Object[]) response.get("signatures"); 198 199 return Base64.getDecoder().decode(signatures[0].toString()); 200 } catch (IOException e) { 201 throw new GeneralSecurityException(e); 202 } 203 } 204 205 private String getSignatureActivationData(SigningServicePrivateKey privateKey, String hash) throws GeneralSecurityException { 206 Map<String, Object> request = new LinkedHashMap<>(); 207 request.put("credentialID", privateKey.getId()); 208 request.put("numSignatures", 1); 209 request.put("hash", new String[] { hash }); 210 211 String totpsecret = (String) privateKey.getProperties().get("totpsecret"); 212 if (totpsecret != null) { 213 request.put("OTP", generateOTP(totpsecret)); 214 } 215 216 try { 217 Map<String, Object> args = new HashMap<>(); 218 args.put(JsonWriter.TYPE, "false"); 219 Map<String, ?> response = client.post("/csc/v0/credentials/authorize", JsonWriter.objectToJson(request, args)); 220 return (String) response.get("SAD"); 221 } catch (IOException e) { 222 throw new GeneralSecurityException("Couldn't get signing authorization for SSL.com certificate " + privateKey.getId(), e); 223 } 224 } 225 226 private String generateOTP(String secret) throws GeneralSecurityException { 227 Mac mac = Mac.getInstance("HmacSHA1"); 228 229 byte[] counter = new byte[8]; 230 ByteBuffer.wrap(counter).putLong(System.currentTimeMillis() / 30000); 231 232 mac.init(new SecretKeySpec(Base64.getDecoder().decode(secret), "RAW")); 233 mac.update(counter); 234 ByteBuffer hash = ByteBuffer.wrap(mac.doFinal()); 235 236 int offset = hash.get(hash.capacity() - 1) & 0x0F; 237 long value = (hash.getInt(offset) & 0x7FFFFFFF) % 1000000; 238 239 return String.format("%06d", value); 240 } 241}