Skip to content

WebCrypto RSA-PSS signature verification fails with AWS KMS signatures #242

@gnarea

Description

@gnarea

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:

  1. 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 for PSSOptions, 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
  2. 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
  3. 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),
    )
  4. 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

  1. Test with actual AWS KMS to confirm the same behavior occurs with the real service
  2. Consider a longer-term solution that maintains better compliance with the WebCrypto specification

See also

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions