I have recently adopted some third-party security software, and I am trying to understand how to process the signed messages we are receiving from them. In the process, I am using the Node Crypto library to solidify my understanding of the concepts.
As I understand the RSA-SHA256 signing algorithm, in the process of signing:
And then the client will send us the original message, the public key, and the signature. The process of verifying the signature is as follows:
If this is the case, then in the following code snippet:
const {
generateKeyPairSync,
publicDecrypt, privateEncrypt,
createSign, createVerify,
createHash,
} = require('crypto');
const { privateKey, publicKey } = generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: {
type: 'spki',
format: 'pem',
},
privateKeyEncoding: {
type: 'pkcs8',
format: 'pem',
},
});
const messageToSign = "Yma o hyd";
const signer = createSign('RSA-SHA256');
signer.update(messageToSign);
const signature = signer.sign(privateKey);
console.log('Signed message', signature.toString('hex'));
const verifier = createVerify('RSA-SHA256');
verifier.update(messageToSign);
const isVerified = verifier.verify(publicKey, signature);
console.log('isVerified:', isVerified);
const hash = createHash('SHA256').update(messageToSign).digest();
console.log('hash in hex', hash.toString('hex'));
const hashEncryptedWithPrivateKey = privateEncrypt(
privateKey,
hash,
);
console.log('encrypted hash', hashEncryptedWithPrivateKey.toString('hex'));
const signatureDecryptedWithPublicKey = publicDecrypt(
publicKey,
signature
);
console.log('Signature decrypted with public key', signatureDecryptedWithPublicKey.toString('hex'));
I would expect signatureDecryptedWithPublicKey.toString('hex')
to match hash.toString('hex')
, but it does not.
Does the signer do more than just a straightforward combination of RSA and SHA256?
You haven't taken padding into account in your considerations, s. RFC8017, RSASSA-PKCS1-v1_5.
In order for hashEncryptedWithPrivateKey
to be equal to signature
, not hash
must be passed to privateEncrypt()
, but the DER encoding of the DigestInfo
value T
, which is for SHA256: 0x3031300d060960864801650304020105000420 || hash
, thus:
const hashEncryptedWithPrivateKey = privateEncrypt(
privateKey,
Buffer.concat([Buffer.from('3031300d060960864801650304020105000420', 'hex'), hash])
);
Likewise,
const signatureDecryptedWithPublicKey = publicDecrypt(
publicKey,
signature
);
does not return hash
but T
.
A bit more background:
RSASSA-PKCS1-v1_5 applies EMSA-PKCS1-v1_5. According to this, the message is padded before exponentiation with the private key as follows:
0x00 || 0x01 || PS || 0x00 || T
Here, T
is the DER encoding of the DigestInfo
value, which contains the digest OID and the hash. PS
consists of so many 0xFF values that the length of the padded message is equal to the key size.
privateEncrypt()
is intended for a low level signing that expects T
instead of the message, i.e. the passed data is neither hashed nor is the digest OID added.
One use case is signing when the message itself is not known, but only the hash. The hash can then be completed to T
, from which privateEncrypt()
then creates an RSASSA-PKCS1-v1_5 compliant signature. Correspondingly, publicDecrypt()
returns T
.
In contrast, when signing/verifying with createSign()
/createVerify()
, hashing and creating T
is done under the hood.