001/*
002 * Copyright 2025 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.charset.StandardCharsets;
022import java.security.GeneralSecurityException;
023import java.security.KeyStoreException;
024import java.security.UnrecoverableKeyException;
025import java.security.cert.Certificate;
026import java.security.cert.CertificateException;
027import java.security.cert.CertificateFactory;
028import java.util.ArrayList;
029import java.util.Base64;
030import java.util.HashMap;
031import java.util.LinkedHashMap;
032import java.util.List;
033import java.util.Map;
034
035import net.jsign.DigestAlgorithm;
036
037/**
038 * Signing service using the SignPath REST API.
039 *
040 * @since 7.1
041 * @see <a href="https://about.signpath.io/documentation/crypto-providers/rest-api">SignPath REST API</a>
042 */
043public class SignPathSigningService implements SigningService {
044
045    /** Cache of certificates indexed by alias */
046    private final Map<String, Map<String, ?>> certificates = new HashMap<>();
047
048    private final RESTClient client;
049
050    /**
051     * Create a new SignPath signing service.
052     *
053     * @param organizationId the organization ID
054     * @param token          the API access token
055     */
056    public SignPathSigningService(String organizationId, String token) {
057        this("https://app.signpath.io/API/v1", organizationId, token);
058    }
059
060    SignPathSigningService(String endpoint, String organizationId, String token) {
061        this.client = new RESTClient(endpoint + "/" + organizationId)
062                .authentication(conn -> conn.setRequestProperty("Authorization", "Bearer " + token))
063                .errorHandler(response -> response.get("status") + " - " + response.get("title") + " - " + JsonWriter.format(response.get("errors")));
064    }
065
066    @Override
067    public String getName() {
068        return "SignPath";
069    }
070
071    private void loadKeyStore() throws KeyStoreException {
072        if (certificates.isEmpty()) {
073            try {
074                Map<String, ?> response = client.get("/Cryptoki/MySigningPolicies");
075                Object[] policies = (Object[]) response.get("signingPolicies");
076                for (Object policy : policies) {
077                    String alias = ((Map) policy).get("projectSlug") + "/" + ((Map) policy).get("signingPolicySlug");
078                    certificates.put(alias, ((Map) policy));
079                }
080            } catch (IOException e) {
081                throw new KeyStoreException("Unable to retrieve the SignPath signing policies", e);
082            }
083        }
084    }
085
086    @Override
087    public List<String> aliases() throws KeyStoreException {
088        loadKeyStore();
089        return new ArrayList<>(certificates.keySet());
090    }
091
092    @Override
093    public Certificate[] getCertificateChain(String alias) throws KeyStoreException {
094        loadKeyStore();
095
096        Map<String, ?> policy = certificates.get(alias);
097        if (policy == null) {
098            throw new KeyStoreException("Unable to retrieve SignPath signing policy '" + alias + "'");
099        }
100
101        byte[] certificateBytes = Base64.getDecoder().decode((String) policy.get("certificateBytes"));
102        Certificate certificate;
103        try {
104            certificate = CertificateFactory.getInstance("X.509").generateCertificate(new ByteArrayInputStream(certificateBytes));
105        } catch (CertificateException e) {
106            throw new KeyStoreException(e);
107        }
108
109        return new Certificate[] { certificate };
110    }
111
112    private String getAlgorithm(String alias) throws KeyStoreException {
113        loadKeyStore();
114        Map<String, ?> policy = certificates.get(alias);
115        if (policy == null) {
116            return null;
117        }
118
119        String keyType = (String) policy.get("keyType");
120
121        return keyType != null ? keyType.toUpperCase() : null;
122    }
123
124    @Override
125    public SigningServicePrivateKey getPrivateKey(String alias, char[] password) throws UnrecoverableKeyException {
126        try {
127            String algorithm = getAlgorithm(alias);
128            if (algorithm == null) {
129                throw new UnrecoverableKeyException("Unable to initialize the SignPath private key for the certificate '" + alias + "'");
130            }
131            return new SigningServicePrivateKey(alias, algorithm, this);
132        } catch (KeyStoreException e) {
133            throw (UnrecoverableKeyException) new UnrecoverableKeyException(e.getMessage()).initCause(e);
134        }
135    }
136
137    @Override
138    public byte[] sign(SigningServicePrivateKey privateKey, String algorithm, byte[] data) throws GeneralSecurityException {
139        DigestAlgorithm digestAlgorithm = DigestAlgorithm.of(algorithm.substring(0, algorithm.toLowerCase().indexOf("with")));
140        data = digestAlgorithm.getMessageDigest().digest(data);
141
142        String[] slugs = privateKey.getId().split("/");
143        String project = slugs[0];
144        String signingPolicy = slugs[1];
145
146        Map<String, String> artifact = new LinkedHashMap<>();
147        artifact.put("SignatureAlgorithm", "RsaPkcs1");
148        artifact.put("RsaHashAlgorithm", digestAlgorithm.oid.toString());
149        artifact.put("Base64EncodedHash", Base64.getEncoder().encodeToString(data));
150
151        Map<String, Object> request = new LinkedHashMap<>();
152        request.put("ProjectSlug", project);
153        request.put("SigningPolicySlug", signingPolicy);
154        request.put("IsFastSigningRequest", "true");
155        request.put("Artifact", JsonWriter.format(artifact).getBytes(StandardCharsets.UTF_8));
156
157        try {
158            Map<String, ?> response = client.post("/SigningRequests", request, true);
159            String signature = (String) response.get("Signature");
160
161            return Base64.getDecoder().decode(signature);
162        } catch (IOException e) {
163            throw new GeneralSecurityException(e);
164        }
165    }
166}