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 org.bouncycastle.operator.DefaultSignatureAlgorithmIdentifierFinder; 041 042import net.jsign.DigestAlgorithm; 043 044/** 045 * SSL.com eSigner signing service. 046 * 047 * @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> 048 * @see <a href="https://www.ssl.com/guide/esigner-demo-credentials-and-certificates/">eSigner Demo Credentials and Certificates</a> 049 * @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> 050 * @since 4.1 051 */ 052public class ESignerSigningService implements SigningService { 053 054 /** Cache of certificates indexed by alias */ 055 private final Map<String, Map<String, ?>> certificates = new HashMap<>(); 056 057 private final RESTClient client; 058 059 public ESignerSigningService(String endpoint, String username, String password) throws IOException { 060 this(endpoint, getAccessToken(endpoint.contains("-try.ssl.com") ? "https://oauth-sandbox.ssl.com" : "https://login.ssl.com", 061 endpoint.contains("-try.ssl.com") ? "qOUeZCCzSqgA93acB3LYq6lBNjgZdiOxQc-KayC3UMw" : "kaXTRACNijSWsFdRKg_KAfD3fqrBlzMbWs6TwWHwAn8", 062 username, password)); 063 } 064 065 public ESignerSigningService(String endpoint, String accessToken) { 066 client = new RESTClient(endpoint) 067 .authentication(conn -> conn.setRequestProperty("Authorization", "Bearer " + accessToken)) 068 .errorHandler(response -> response.get("error") + ": " + response.get("error_description")); 069 } 070 071 private static String getAccessToken(String endpoint, String clientId, String username, String password) throws IOException { 072 Map<String, String> request = new LinkedHashMap<>(); 073 request.put("client_id", clientId); 074 request.put("grant_type", "password"); 075 request.put("username", username); 076 request.put("password", password); 077 078 RESTClient client = new RESTClient(endpoint).errorHandler(response -> response.get("error") + ": " + response.get("error_description")); 079 Map<String, ?> response = client.post("/oauth2/token", JsonWriter.format(request)); 080 return (String) response.get("access_token"); 081 } 082 083 @Override 084 public String getName() { 085 return "ESIGNER"; 086 } 087 088 @Override 089 public List<String> aliases() throws KeyStoreException { 090 try { 091 Map<String, String> request = new HashMap<>(); 092 request.put("clientData", "EVCS"); 093 Map<String, ?> response = client.post("/csc/v0/credentials/list", JsonWriter.format(request)); 094 Object[] credentials = (Object[]) response.get("credentialIDs"); 095 return Stream.of(credentials).map(Object::toString).collect(Collectors.toList()); 096 } catch (IOException e) { 097 throw new KeyStoreException("Unable to retrieve SSL.com certificate aliases", e); 098 } 099 } 100 101 /** 102 * Returns the certificate details 103 * 104 * @param alias the alias of the certificate 105 */ 106 private Map<String, ?> getCertificateInfo(String alias) throws IOException { 107 if (!certificates.containsKey(alias)) { 108 Map<String, String> request = new HashMap<>(); 109 request.put("credentialID", alias); 110 request.put("certificates", "chain"); 111 112 Map<String, ?> response = client.post("/csc/v0/credentials/info", JsonWriter.format(request)); 113 certificates.put(alias, (Map) response.get("cert")); 114 } 115 116 return certificates.get(alias); 117 } 118 119 @Override 120 public Certificate[] getCertificateChain(String alias) throws KeyStoreException { 121 try { 122 Map<String, ?> cert = getCertificateInfo(alias); 123 Object[] encodedChain = (Object[]) cert.get("certificates"); 124 125 List<Certificate> chain = new ArrayList<>(); 126 for (Object encodedCertificate : encodedChain) { 127 chain.add(CertificateFactory.getInstance("X.509").generateCertificate(new ByteArrayInputStream(Base64.getDecoder().decode(encodedCertificate.toString())))); 128 } 129 return chain.toArray(new Certificate[0]); 130 } catch (IOException | CertificateException e) { 131 throw new KeyStoreException("Unable to retrieve SSL.com certificate '" + alias + "'", e); 132 } 133 } 134 135 @Override 136 public SigningServicePrivateKey getPrivateKey(String alias, char[] password) throws UnrecoverableKeyException { 137 try { 138 Certificate[] chain = getCertificateChain(alias); 139 String algorithm = chain[0].getPublicKey().getAlgorithm(); 140 SigningServicePrivateKey key = new SigningServicePrivateKey(alias, algorithm, this); 141 if (password != null) { 142 key.getProperties().put("totpsecret", new String(password)); 143 } 144 return key; 145 } catch (KeyStoreException e) { 146 throw (UnrecoverableKeyException) new UnrecoverableKeyException().initCause(e); 147 } 148 } 149 150 private void scan(SigningServicePrivateKey privateKey, String hashToSign, String hashToScan) { 151 boolean malwareScanEnabled; 152 153 Map<String, Object> request = new LinkedHashMap<>(); 154 request.put("credential_id", privateKey.getId()); 155 try { 156 Map<String, ?> response = client.post("/scan/settings", JsonWriter.format(request)); 157 malwareScanEnabled = Boolean.TRUE.equals(response.get("malware_scan_enabled")); 158 } catch (IOException e) { 159 throw new RuntimeException(e); 160 } 161 162 if (malwareScanEnabled) { 163 request = new LinkedHashMap<>(); 164 request.put("credential_id", privateKey.getId()); 165 request.put("hash_to_scan", hashToScan); 166 request.put("hash_to_sign", hashToSign); 167 168 try { 169 client.post("/scan/hash", JsonWriter.format(request)); 170 } catch (IOException e) { 171 throw new RuntimeException(e); 172 } 173 } 174 } 175 176 @Override 177 public byte[] sign(SigningServicePrivateKey privateKey, String algorithm, byte[] data) throws GeneralSecurityException { 178 MessageDigest digest = DigestAlgorithm.of(algorithm.substring(0, algorithm.toLowerCase().indexOf("with"))).getMessageDigest(); 179 data = digest.digest(data); 180 String hash = Base64.getEncoder().encodeToString(data); 181 182 // Skip malware scanning. eSigner expects the SHA-256 hash of the full file, but scanning for malwares 183 // requires a little more than a mere hash controlled by the client. We just send a bogus hash instead. 184 scan(privateKey, hash, Base64.getEncoder().encodeToString(DigestAlgorithm.SHA256.getMessageDigest().digest(data))); 185 186 Map<String, Object> request = new LinkedHashMap<>(); 187 request.put("credentialID", privateKey.getId()); 188 request.put("SAD", getSignatureActivationData(privateKey, hash)); 189 request.put("hash", new String[] { hash }); 190 request.put("signAlgo", new DefaultSignatureAlgorithmIdentifierFinder().find(algorithm).getAlgorithm().getId()); 191 192 try { 193 Map<String, ?> response = client.post("/csc/v0/signatures/signHash", JsonWriter.format(request)); 194 Object[] signatures = (Object[]) response.get("signatures"); 195 196 return Base64.getDecoder().decode(signatures[0].toString()); 197 } catch (IOException e) { 198 throw new GeneralSecurityException(e); 199 } 200 } 201 202 private String getSignatureActivationData(SigningServicePrivateKey privateKey, String hash) throws GeneralSecurityException { 203 Map<String, Object> request = new LinkedHashMap<>(); 204 request.put("credentialID", privateKey.getId()); 205 request.put("numSignatures", 1); 206 request.put("hash", new String[] { hash }); 207 208 String totpsecret = (String) privateKey.getProperties().get("totpsecret"); 209 if (totpsecret != null) { 210 request.put("OTP", generateOTP(totpsecret)); 211 } 212 213 try { 214 Map<String, ?> response = client.post("/csc/v0/credentials/authorize", JsonWriter.format(request)); 215 return (String) response.get("SAD"); 216 } catch (IOException e) { 217 throw new GeneralSecurityException("Couldn't get signing authorization for SSL.com certificate " + privateKey.getId(), e); 218 } 219 } 220 221 private String generateOTP(String secret) throws GeneralSecurityException { 222 Mac mac = Mac.getInstance("HmacSHA1"); 223 224 byte[] counter = new byte[8]; 225 ByteBuffer.wrap(counter).putLong(System.currentTimeMillis() / 30000); 226 227 mac.init(new SecretKeySpec(Base64.getDecoder().decode(secret), "RAW")); 228 mac.update(counter); 229 ByteBuffer hash = ByteBuffer.wrap(mac.doFinal()); 230 231 int offset = hash.get(hash.capacity() - 1) & 0x0F; 232 long value = (hash.getInt(offset) & 0x7FFFFFFF) % 1000000; 233 234 return String.format("%06d", value); 235 } 236}