使用 AWS KMS 返回的数字签名对 PdfDocument 进行签名

Gra*_*ood 2 java pdf-generation amazon-web-services amazon-kms itext7

我正在尝试使用通过使用 AWS KMS 签署我的 PdfDocument 的 SHA256 摘要获得的签名在 PDF 本身上应用签名。我什至不确定我是否朝着正确的方向前进。

一切运行正常,但生成的文件的签名会引发错误:

Error during signature verification. ASN.1 parsing error:  Error encountered while BER decoding:
Run Code Online (Sandbox Code Playgroud)

如果这很重要,我可以从 AWS 检索公钥,但私钥保留在他们身边。我在网上看到的大多数文档都假定您可以访问私钥。此外,由于 AWS 处理签名,我不确定如何或从何处获取证书链。我发现的所有文档也需要该证书链。

代码

首先,我创建了一个空的签名字段,因为大多数文档都指示您这样做。我认为可能存在问题,PdfName.Adbe_pkcs7_detached但如果这是错误的,我不知道还有什么可以代替它。

public void addEmptySignatureField(File src, File destination, String fieldName) throws IOException, GeneralSecurityException {
    try (
            var reader = new PdfReader(src);
            var output = new FileOutputStream(destination)
    ) {
        var signer = new PdfSigner(reader, output, new StampingProperties());

        signer.getSignatureAppearance()
                .setPageRect(new Rectangle(36, 748, 200, 100))
                .setPageNumber(1)
                .setLocation("whee")
                .setSignatureCreator("Mario")
                .setReason("because")
                .setLayer2FontSize(14f);
        signer.setFieldName(fieldName);

        IExternalSignatureContainer blankSignatureContainer = new ExternalBlankSignatureContainer(PdfName.Adobe_PPKLite,
                PdfName.Adbe_pkcs7_detached);

        // Sign the document using an blankSignatureContainer container.
        // 8192 is the size of the empty signature placeholder.
        signer.signExternalContainer(blankSignatureContainer, 8192);
    }
}
Run Code Online (Sandbox Code Playgroud)

然后我尝试签署文件:

public void completeSignature(File src, File destination, String fieldName) throws IOException, GeneralSecurityException {
    try (
            var reader = new PdfReader(src);
            var pdfDocument = new PdfDocument(reader);
            var writer = new PdfWriter(destination)
    ) {
        // Signs a PDF where space was already reserved. The field must cover the whole document.
        PdfSigner.signDeferred(pdfDocument, fieldName, writer, kmsBackedSignatureContainer);
    }
}
Run Code Online (Sandbox Code Playgroud)

作为参考,kmsBackedSignatureContainer 如下。 fileSigner.signbyte[]他们的文档中定义的AWS KMS a 返回:

此值是 ANS X9.62–2005 和 RFC 3279 第 2.2.3 节定义的 DER 编码对象。

public class KmsBackedSignatureContainer implements IExternalSignatureContainer
{
    @Override
    public byte[] sign(InputStream data) throws GeneralSecurityException {
        try {
            var bytes = DigestAlgorithms.digest(data, new BouncyCastleDigest().getMessageDigest(DigestAlgorithms.SHA256));
            var derEncodedBytes = fileSigner.sign(bytes);

            return derEncodedBytes;
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    @Override
    public void modifySigningDictionary(PdfDictionary signDic)
    {
    }
}
Run Code Online (Sandbox Code Playgroud)

mkl*_*mkl 6

In the context of this answer it is assumed that you have stored your credentials in the default section of your ~/.aws/credentials file and your region in the default section of your ~/.aws/config file. Otherwise you'll have to adapt the KmsClient instantiation or initialization in the following code.

Generating a Certificate for an AWS KMS Key Pair

First of all, AWS KMS signs using a plain asymmetric key pair, it does not provide a X.509 certificate for the public key. Interoperable PDF signatures require a X.509 certificate for the public key, though, to establish trust in the signature. Thus, the first step to take for interoperable AWS KMS PDF signing is to generate a X.509 certificate for the public key of your AWS KMS signing key pair.

For testing purposes you can create a self signed certificate using this helper method which is based on code from this stack overflow answer:

public static Certificate generateSelfSignedCertificate(String keyId, String subjectDN) throws IOException, GeneralSecurityException {
    long now = System.currentTimeMillis();
    Date startDate = new Date(now);

    X500Name dnName = new X500Name(subjectDN);
    BigInteger certSerialNumber = new BigInteger(Long.toString(now));

    Calendar calendar = Calendar.getInstance();
    calendar.setTime(startDate);
    calendar.add(Calendar.YEAR, 1);

    Date endDate = calendar.getTime();

    PublicKey publicKey = null;
    SigningAlgorithmSpec signingAlgorithmSpec = null;
    try (   KmsClient kmsClient = KmsClient.create() ) {
        GetPublicKeyResponse response = kmsClient.getPublicKey(GetPublicKeyRequest.builder().keyId(keyId).build());
        SubjectPublicKeyInfo spki = SubjectPublicKeyInfo.getInstance(response.publicKey().asByteArray());
        JcaPEMKeyConverter converter = new JcaPEMKeyConverter();
        publicKey = converter.getPublicKey(spki);
        List<SigningAlgorithmSpec> signingAlgorithms = response.signingAlgorithms();
        if (signingAlgorithms != null && !signingAlgorithms.isEmpty())
            signingAlgorithmSpec = signingAlgorithms.get(0);
    }
    JcaX509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder(dnName, certSerialNumber, startDate, endDate, dnName, publicKey);

    ContentSigner contentSigner = new AwsKmsContentSigner(keyId, signingAlgorithmSpec);

    BasicConstraints basicConstraints = new BasicConstraints(true);
    certBuilder.addExtension(new ASN1ObjectIdentifier("2.5.29.19"), true, basicConstraints);

    return new JcaX509CertificateConverter().setProvider("BC").getCertificate(certBuilder.build(contentSigner));
}
Run Code Online (Sandbox Code Playgroud)

(CertificateUtils helper method)

The AwsKmsContentSigner class used in the code above is this implementation of the BouncyCastle interface ContentSigner:

public class AwsKmsContentSigner implements ContentSigner {
    final ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
    final String keyId;
    final SigningAlgorithmSpec signingAlgorithmSpec;
    final AlgorithmIdentifier signatureAlgorithm;

    public AwsKmsContentSigner(String keyId, SigningAlgorithmSpec signingAlgorithmSpec) {
        this.keyId = keyId;
        this.signingAlgorithmSpec = signingAlgorithmSpec;
        String signatureAlgorithmName = signingAlgorithmNameBySpec.get(signingAlgorithmSpec);
        if (signatureAlgorithmName == null)
            throw new IllegalArgumentException("Unknown signature algorithm " + signingAlgorithmSpec);
        this.signatureAlgorithm = new DefaultSignatureAlgorithmIdentifierFinder().find(signatureAlgorithmName);
    }

    @Override
    public byte[] getSignature() {
        try (   KmsClient kmsClient = KmsClient.create() ) {
            SignRequest signRequest = SignRequest.builder()
                    .signingAlgorithm(signingAlgorithmSpec)
                    .keyId(keyId)
                    .messageType(MessageType.RAW)
                    .message(SdkBytes.fromByteArray(outputStream.toByteArray()))
                    .build();
            SignResponse signResponse = kmsClient.sign(signRequest);
            SdkBytes signatureSdkBytes = signResponse.signature();
            return signatureSdkBytes.asByteArray();
        } finally {
            outputStream.reset();
        }
    }

    @Override
    public OutputStream getOutputStream() {
        return outputStream;
    }

    @Override
    public AlgorithmIdentifier getAlgorithmIdentifier() {
        return signatureAlgorithm;
    }

    final static Map<SigningAlgorithmSpec, String> signingAlgorithmNameBySpec;

    static {
        signingAlgorithmNameBySpec = new HashMap<>();
        signingAlgorithmNameBySpec.put(SigningAlgorithmSpec.ECDSA_SHA_256, "SHA256withECDSA");
        signingAlgorithmNameBySpec.put(SigningAlgorithmSpec.ECDSA_SHA_384, "SHA384withECDSA");
        signingAlgorithmNameBySpec.put(SigningAlgorithmSpec.ECDSA_SHA_512, "SHA512withECDSA");
        signingAlgorithmNameBySpec.put(SigningAlgorithmSpec.RSASSA_PKCS1_V1_5_SHA_256, "SHA256withRSA");
        signingAlgorithmNameBySpec.put(SigningAlgorithmSpec.RSASSA_PKCS1_V1_5_SHA_384, "SHA384withRSA");
        signingAlgorithmNameBySpec.put(SigningAlgorithmSpec.RSASSA_PKCS1_V1_5_SHA_512, "SHA512withRSA");
        signingAlgorithmNameBySpec.put(SigningAlgorithmSpec.RSASSA_PSS_SHA_256, "SHA256withRSAandMGF1");
        signingAlgorithmNameBySpec.put(SigningAlgorithmSpec.RSASSA_PSS_SHA_384, "SHA384withRSAandMGF1");
        signingAlgorithmNameBySpec.put(SigningAlgorithmSpec.RSASSA_PSS_SHA_512, "SHA512withRSAandMGF1");
    }
}
Run Code Online (Sandbox Code Playgroud)

(AwsKmsContentSigner)

For production purposes you'll usually want to use a certificate signed by a trusted CA. Similarly to the above you can create and sign a certificate request for your AWS KMS public key, send it to your CA of choice, and get back the certificate to use from them.

Signing a PDF Using an AWS KMS Key Pair

To sign a PDF with iText you need an implementation of the iText IExternalSignature or IExternalSignatureContainer interface. Here we use the former:

public class AwsKmsSignature implements IExternalSignature {
    public AwsKmsSignature(String keyId) {
        this.keyId = keyId;

        try (   KmsClient kmsClient = KmsClient.create() ) {
            GetPublicKeyRequest getPublicKeyRequest = GetPublicKeyRequest.builder()
                    .keyId(keyId)
                    .build();
            GetPublicKeyResponse getPublicKeyResponse = kmsClient.getPublicKey(getPublicKeyRequest);
            signingAlgorithmSpec = getPublicKeyResponse.signingAlgorithms().get(0);
            switch(signingAlgorithmSpec) {
            case ECDSA_SHA_256:
            case ECDSA_SHA_384:
            case ECDSA_SHA_512:
            case RSASSA_PKCS1_V1_5_SHA_256:
            case RSASSA_PKCS1_V1_5_SHA_384:
            case RSASSA_PKCS1_V1_5_SHA_512:
                break;
            case RSASSA_PSS_SHA_256:
            case RSASSA_PSS_SHA_384:
            case RSASSA_PSS_SHA_512:
                throw new IllegalArgumentException(String.format("Signing algorithm %s not supported directly by iText", signingAlgorithmSpec));
            default:
                throw new IllegalArgumentException(String.format("Unknown signing algorithm: %s", signingAlgorithmSpec));
            }
        }
    }

    @Override
    public String getHashAlgorithm() {
        switch(signingAlgorithmSpec) {
        case ECDSA_SHA_256:
        case RSASSA_PKCS1_V1_5_SHA_256:
            return "SHA-256";
        case ECDSA_SHA_384:
        case RSASSA_PKCS1_V1_5_SHA_384:
            return "SHA-384";
        case ECDSA_SHA_512:
        case RSASSA_PKCS1_V1_5_SHA_512:
            return "SHA-512";
        default:
            return null;
        }
    }

    @Override
    public String getEncryptionAlgorithm() {
        switch(signingAlgorithmSpec) {
        case ECDSA_SHA_256:
        case ECDSA_SHA_384:
        case ECDSA_SHA_512:
            return "ECDSA";
        case RSASSA_PKCS1_V1_5_SHA_256:
        case RSASSA_PKCS1_V1_5_SHA_384:
        case RSASSA_PKCS1_V1_5_SHA_512:
            return "RSA";
        default:
            return null;
        }
    }

    @Override
    public byte[] sign(byte[] message) throws GeneralSecurityException {
        try (   KmsClient kmsClient = KmsClient.create() ) {
            SignRequest signRequest = SignRequest.builder()
                    .signingAlgorithm(signingAlgorithmSpec)
                    .keyId(keyId)
                    .messageType(MessageType.RAW)
                    .message(SdkBytes.fromByteArray(message))
                    .build();
            SignResponse signResponse = kmsClient.sign(signRequest);
            return signResponse.signature().asByteArray();
        }
    }

    final String keyId;
    final SigningAlgorithmSpec signingAlgorithmSpec;
}
Run Code Online (Sandbox Code Playgroud)

(AwsKmsSignature)

In the constructor we select a signing algorithm available for the key in question. This actually is done quite haphazardly here, instead of simply taking the first algorithm you may want to enforce use of a specific hashing algorithm.

getHashAlgorithm and getEncryptionAlgorithm return the name of the respective part of the signature algorithm and sign simply creates a signature.

Putting It Into Action

Assuming your AWS KMS signing key pair has the alias SigningExamples-ECC_NIST_P256 you can use the code above like this to sign a PDF:

BouncyCastleProvider provider = new BouncyCastleProvider();
Security.addProvider(provider);

String keyId = "alias/SigningExamples-ECC_NIST_P256";
AwsKmsSignature signature = new AwsKmsSignature(keyId);
Certificate certificate = CertificateUtils.generateSelfSignedCertificate(keyId, "CN=AWS KMS PDF Signing Test,OU=mkl tests,O=mkl");

try (   PdfReader pdfReader = new PdfReader(PDF_TO_SIGN);
        OutputStream result = new FileOutputStream(SIGNED_PDF)) {
    PdfSigner pdfSigner = new PdfSigner(pdfReader, result, new StampingProperties().useAppendMode());

    IExternalDigest externalDigest = new BouncyCastleDigest();
    pdfSigner.signDetached(externalDigest , signature, new Certificate[] {certificate}, null, null, null, 0, CryptoStandard.CMS);
}
Run Code Online (Sandbox Code Playgroud)

(TestSignSimple test testSignSimpleEcdsa)

Signing a PDF Using an AWS KMS Key Pair Revisited

Above we used an implementation of IExternalSignature for signing. While that is the easiest way, it has some drawbacks: The class PdfPKCS7 used in this case does not support RSASSA-PSS usage, and for ECDSA signatures it uses the wrong OID as signature algorithm OID.

To not be subject to these issues, we here use an implementation of IExternalSignatureContainer instead in which we build the complete CMS signature container ourselves using only BouncyCastle functionality.

public class AwsKmsSignatureContainer implements IExternalSignatureContainer {
    public AwsKmsSignatureContainer(X509Certificate x509Certificate, String keyId) {
        this(x509Certificate, keyId, a -> a != null && a.size() > 0 ? a.get(0) : null);
    }

    public AwsKmsSignatureContainer(X509Certificate x509Certificate, String keyId, Function<List<SigningAlgorithmSpec>, SigningAlgorithmSpec> selector) {
        this.x509Certificate = x509Certificate;
        this.keyId = keyId;

        try (   KmsClient kmsClient = KmsClient.create() ) {
            GetPublicKeyRequest getPublicKeyRequest = GetPublicKeyRequest.builder()
                    .keyId(keyId)
                    .build();
            GetPublicKeyResponse getPublicKeyResponse = kmsClient.getPublicKey(getPublicKeyRequest);
            signingAlgorithmSpec = selector.apply(getPublicKeyResponse.signingAlgorithms());
            if (signingAlgorithmSpec == null)
                throw new IllegalArgumentException("KMS key has no signing algorithms");
            contentSigner = new AwsKmsContentSigner(keyId, signingAlgorithmSpec);
        }
    }

    @Override
    public byte[] sign(InputStream data) throws GeneralSecurityException {
        try {
            CMSTypedData msg = new CMSTypedDataInputStream(data);

            X509CertificateHolder signCert = new X509CertificateHolder(x509Certificate.getEncoded());

            CMSSignedDataGenerator gen = new CMSSignedDataGenerator();

            gen.addSignerInfoGenerator(
                    new JcaSignerInfoGeneratorBuilder(new JcaDigestCalculatorProviderBuilder().setProvider("BC").build())
                            .build(contentSigner, signCert));

            gen.addCertificates(new JcaCertStore(Collections.singleton(signCert)));

            CMSSignedData sigData = gen.generate(msg, false);
            return sigData.getEncoded();
        } catch (IOException | OperatorCreationException | CMSException e) {
            throw new GeneralSecurityException(e);
        }
    }

    @Override
    public void modifySigningDictionary(PdfDictionary signDic) {
        signDic.put(PdfName.Filter, new PdfName("MKLx_AWS_KMS_SIGNER"));
        signDic.put(PdfName.SubFilter, PdfName.Adbe_pkcs7_detached);
    }

    final X509Certificate x509Certificate;
    final String keyId;
    final SigningAlgorithmSpec signingAlgorithmSpec;
    final ContentSigner contentSigner;

    class CMSTypedDataInputStream implements CMSTypedData {
        InputStream in;

        public CMSTypedDataInputStream(InputStream is) {
            in = is;
        }

        @Override
        public ASN1ObjectIdentifier getContentType() {
            return PKCSObjectIdentifiers.data;
        }

        @Override
        public Object getContent() {
            return in;
        }

        @Override
        public void write(OutputStream out) throws IOException,
                CMSException {
            byte[] buffer = new byte[8 * 1024];
            int read;
            while ((read = in.read(buffer)) != -1) {
                out.write(buffer, 0, read);
            }
            in.close();
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

(AwsKmsSignatureContainer)

In the constructor we also select a signing algorithm available for the key in question. Here, though, we allow a function parameter that permits the caller to choose among the available signature algorithms. This is necessary in particular for RSASSA-PSS use.

Putting It Into Action Revisited

Assuming you have an AWS KMS signing RSA_2048 key pair which has the alias SigningExamples-RSA_2048 you can use the code above like this to sign a PDF using RSASSA-PSS:

BouncyCastleProvider provider = new BouncyCastleProvider();
Security.addProvider(provider);

String keyId = "alias/SigningExamples-RSA_2048";
X509Certificate certificate = CertificateUtils.generateSelfSignedCertificate(keyId, "CN=AWS KMS PDF Signing Test,OU=mkl tests,O=mkl");
AwsKmsSignatureContainer signatureContainer = new AwsKmsSignatureContainer(certificate, keyId, TestSignSimple::selectRsaSsaPss);

try (   PdfReader pdfReader = new PdfReader(PDF_TO_SIGN);
        OutputStream result = new FileOutputStream(SIGNED_PDF)) {
    PdfSigner pdfSigner = new PdfSigner(pdfReader, result, new StampingProperties().useAppendMode());

    pdfSigner.signExternalContainer(signatureContainer, 8192);
}
Run Code Online (Sandbox Code Playgroud)

(TestSignSimple test testSignSimpleRsaSsaPss)

with this selector function

static SigningAlgorithmSpec selectRsaSsaPss (List<SigningAlgorithmSpec> specs) {
    if (specs != null)
        return specs.stream().filter(spec -> spec.toString().startsWith("RSASSA_PSS")).findFirst().orElse(null);
    else
        return null;
}
Run Code Online (Sandbox Code Playgroud)

(TestSignSimple helper method)

Mass-Signing Considerations

If you plan to do mass-signing using AWS KMS, please be aware of the request quotas established by AWS KMS for some of its operations:

Quota Name Default value (per second)
Cryptographic operations (RSA) request rate 500 (shared) for RSA CMKs
Cryptographic operations (ECC) request rate 300 (shared) for elliptic curve (ECC) CMKs
GetPublicKey request rate 5

(excerpt from "AWS Key Management Service Developer Guide" / "Quotas" / "Request Quotas" / "Request quotas for each AWS KMS API operation" viewed 2020-12-15)

The RSA and ECC cryptographic operations request rates likely are not a problem. Or more to the point, if they are a problem, AWS KMS most likely is not the right signing product for your needs; instead you should look for actual HSMs, be they physical or as-a-service, e.g. AWS CloudHSM.

The GetPublicKey request rate on the other hand may well be a problem: Both AwsKmsSignature and AwsKmsSignatureContainer in their respective constructors call that method. Naive mass signing code based on them, therefore, would be limited to 5 signatures per second.

Depending on your use case there are different strategies to tackle this problem.

If only very few instances of your signing code are running concurrently and they are using a very few different keys only, you can simply re-use your AwsKmsSignature and AwsKmsSignatureContainer objects, either creating them at start-up or on-demand and then caching them.

Otherwise, though, you should refactor the use of the GetPublicKey method out of the AwsKmsSignature and AwsKmsSignatureContainer constructors. It is used in there only to determine which AWS KMS signing algorithm identifier to use when signing with the key in question. Obvio