-
Notifications
You must be signed in to change notification settings - Fork 0
Description
Issue Description
RSA-PSS signatures generated via our AWS KMS provider (backed by the local-kms
mock server) fail validation when verified using the standard Node.js WebCrypto API, but succeed when verified using either the legacy Node.js crypto API or the AWS SDK's own verification method.
This discrepancy is caused by incompatibilities between how WebCrypto implementations handle salt length during verification versus how AWS KMS generates signatures.
Failing Tests
As demonstrated in #241, the following test case fails when using WebCrypto verification but passes with other methods:
// This test fails
test('Node.js WebCrypto verification', async () => {
const { publicKey, privateKey } = keyPair;
const signature = await provider.sign(RSA_PSS_SIGN_ALGORITHM, privateKey, PLAINTEXT);
const publicKeySpki = await NODEJS_CRYPTO.subtle.exportKey('spki', publicKey);
const nodePublicKey = await webcrypto.subtle.importKey(
'spki',
publicKeySpki,
RSA_PSS_IMPORT_ALGORITHM,
true, // extractable
['verify'], // key usages for public key
);
await expect(
webcrypto.subtle.verify(
RSA_PSS_SIGN_ALGORITHM,
nodePublicKey, // Use the re-imported key
signature,
PLAINTEXT,
),
).resolves.toBeTrue();
});
Whilst these tests pass:
// This test passes (using Node.js legacy crypto)
test('Node.js verification', async () => {
const { publicKey, privateKey } = keyPair;
const signature = await provider.sign(RSA_PSS_SIGN_ALGORITHM, privateKey, PLAINTEXT);
const verify = createVerify('sha256');
verify.update(PLAINTEXT);
verify.end();
const publicKeyDer = await derSerializePublicKey(publicKey);
const publicKeyPem = derPublicKeyToPem(publicKeyDer);
expect(
verify.verify(
{ key: publicKeyPem, padding: constants.RSA_PKCS1_PSS_PADDING },
new Uint8Array(signature),
),
).toBeTrue();
});
// This test passes (using AWS SDK)
test('AWS SDK verification', async () => {
const { privateKey } = keyPair;
const signature = await provider.sign(RSA_PSS_SIGN_ALGORITHM, privateKey, PLAINTEXT);
const arnBuffer = (await provider.exportKey('raw', privateKey)) as ArrayBuffer;
const keyArn = Buffer.from(arnBuffer).toString();
const verifyCommand = new VerifyCommand({
KeyId: keyArn,
Message: PLAINTEXT,
MessageType: MessageType.RAW,
Signature: new Uint8Array(signature),
SigningAlgorithm: SigningAlgorithmSpec.RSASSA_PSS_SHA_256,
});
const client = new KMSClient({
endpoint: process.env.AWS_KMS_ENDPOINT,
region: process.env.AWS_KMS_REGION,
});
const response = await client.send(verifyCommand);
expect(response.SignatureValid).toBe(true);
});
Our algorithm parameters:
export const RSA_PSS_SIGN_ALGORITHM: RsaPssParams = { name: 'RSA-PSS', saltLength: 32 };
Investigation Findings
Root Cause: Salt Length Handling During Verification
I discovered that the issue is in how the salt length parameter is handled during verification:
-
local-kms Implementation:
- Uses Go's standard library with default options for RSA-PSS signatures:
// Core signing call in local-kms return rsa.SignPSS(rand.Reader, &key, hash, digest, nil)
- When
nil
is passed forPSSOptions
, Go uses default options with salt length equal to hash output size (32 bytes for SHA-256) - Critically, it generates a random salt for each signature and embeds this in the signature
-
Peculiar WebCrypto Implementation:
- I looked at
src/mechs/rsa/crypto.ts
and found:
protected static verifySSA(algorithm: Algorithm, key: RsaPublicKey, data: Uint8Array, signature: Uint8Array) { // ... if (algorithm.name.toUpperCase() === "RSA-PSS") { options.padding = crypto.constants.RSA_PKCS1_PSS_PADDING; options.saltLength = (algorithm as RsaPssParams).saltLength; } // ... }
- It explicitly passes the saltLength parameter to the Node.js crypto verification
- I looked at
-
Key Discovery: When I modify the Node.js legacy crypto verification test to include the
saltLength
parameter, it also fails:verify.verify( { key: publicKeyPem, padding: constants.RSA_PKCS1_PSS_PADDING, saltLength: 32 }, new Uint8Array(signature), )
-
WebCrypto API Requirements:
- The W3C WebCrypto specification requires saltLength as a mandatory parameter for RSA-PSS operations
- This strict adherence to the parameter causes verification to fail with AWS KMS signatures
OpenSSL vs. WebCrypto Approach
- OpenSSL (Node.js legacy crypto): When saltLength is omitted, it can extract and adapt to the salt information embedded in the signature
- WebCrypto: Strictly requires the exact saltLength parameter and doesn't adapt to embedded salt information
Workaround
I can extend the RSA-PSS provider from Peculiar WebCrypto to override the verifySSA()
method, omitting the saltLength parameter when verifying AWS KMS signatures:
protected static verifySSA(algorithm: Algorithm, key: RsaPublicKey, data: Uint8Array, signature: Uint8Array) {
// ...
if (algorithm.name.toUpperCase() === "RSA-PSS") {
options.padding = crypto.constants.RSA_PKCS1_PSS_PADDING;
// Omit saltLength for AWS KMS compatibility
// options.saltLength = (algorithm as RsaPssParams).saltLength;
}
// ...
}
This technically deviates from the WebCrypto spec but provides compatibility with AWS KMS signatures.
Next Steps
- Test with actual AWS KMS to confirm the same behavior occurs with the real service
- Consider a longer-term solution that maintains better compliance with the WebCrypto specification