39
头图

lead

This is not the first time I have written this topic. Recently, a friend asked me with "Ensuring Password Transmission Security in Web Applications" 5 years ago: "Why can't the back-end be solved if you follow your instructions step by step?" Encryption and decryption are far from the truth. I think it's mostly because the parameters are not adjusted correctly. Just check and correct them carefully. Take a look at the code, dumbfounded... It's okay, why can't it be solved?

After a long time, the source code attached to the original text can no longer be downloaded. When looking through various reference links, I found a code from CodeProject, and changed the parameters for a try. There is nothing wrong with it! This is weird, so I went to the RSA.js document (there is no special document, just document comments) and found that RSA.js added the Padding parameter in January 2014, "Guarantee Password Transmission Security in Web Applications" Although it was written in February 2014, the old version may have been used by mistake.

Isn't it Padding? I don't bother to read the documentation. Both front and back end specify PKCS1Padding to try. failure!

That's a bit more violent, try all padding!

The front end uses RSAAPP , and the back end C# uses RSAEncryptionPadding , combining 20 cases, and experimenting one by one... Well, none of them are right!

With so many trees in the world, why bother to hang on this one, not to mention that it has not been published to npm... If we find enough reasons, let's change it!

After searching on the Internet, I chose the library JSEncrypt

Core knowledge

Before talking about JSEncrypt, let's return to the topic of "secure transmission". The key technology of this topic is encryption and decryption. Speaking of encryption and decryption, there are three types of algorithms: HASH (digest) algorithm, symmetric encryption algorithm, and asymmetric encryption algorithm. The basic secure transmission process can be shown with a picture:

image.png

However, this is only the most basic theory of secure transmission. In fact, there are still hidden security risks in certificate (public key) distribution, etc., so there will be CAs and trusted root certificates... But here is not an extension, just a conclusion: On the issue of web front-end and back-end transmission, HTTPS is the best practice and the preferred web transmission solution. Only when HTTPS cannot be used, can I take the second place and use my own implementation to raise a little security threshold.

JSEncrypt

JSEncrypt has a new version just a month ago and it is still active. However, the usage is different from RSA.js, it does not need to specify RSA parameters, but directly imports a key (certificate) in PEM format. Regarding the format of the certificate, I won’t be here for science popularization. In short, PEM is a text format, Base64 encoding.

Since JSEnrypt needs to import the key, here is mainly the need to import the public key. Let's take a look at what can be derived from RSACryptoServiceProvider Export... method. The main two related to the export convention are:

Because the original requirement is to use .NET, first study the cooperation of .NET and JSEncrypt, and then add NodeJS and Java later.
  • ExportRSAPublicKey() , export the public key part of the current key in PKCS#1 RSAPublicKey format.
  • ExportSubjectPublicKeyInfo() , export the public key part of the current key in X.509 SubjectPublicKeyInfo format.

There are also two Try... prefix 0607cfae2e1c7a that have similar effects and can be ignored. The difference between these two methods lies in the different export formats, one is PKCS#1 (Public-Key Cryptography Standards) and the other is SPKI (Subject Public Key Info).

Which format can JSEncrypt import? It is not clearly stated in the document, so try it.

C# Generate key and export

It is relatively simple to generate an RSA key pair in C#. Use RSACryptoServiceProvider For example, generate a pair of 1024-bit RSA keys and export them in XML format:

// C# Code

private RSACryptoServiceProvider GenerateRsaKeys(int keySize = 1024)
{
    var rsa = new RSACryptoServiceProvider(keySize);
    var xmlPrivateKey = rsa.ToXmlString(true);
    // 如果需要单独的公钥部分,将传入 `ToXmlString()` 改为 false 就好
    // var xmlPublicKey = rsa.ToXmlString(false);

    File.WriteAllText("RSA_KEY", xmlPrivateKey);
    return rsa;
}

In order to use the same key every time the process is restarted, the above example saves the generated xmlPrivateKey to a file, and you can try to load and import from the file when you restart the process. Note that since the private key contains the public key, you only need to save xmlPrivateKey . Then the loading process:

// C# Code

private RSACryptoServiceProvider LoadRsaKeys()
{
    if (!File.Exists("RSA_KEY")) { return null; }
    var xmlPrivateKey = File.ReadAllText("RSA_KEY");

    var rsa = new RSACryptoServiceProvider();
    rsa.FromXmlString(xmlPrivateKey);
    return rsa;
}

Try to import first, and if it fails, the process of generating a new one is just one sentence:

// C# Code

var rsa = LoadRsaKeys() ?? GenerateRsaKeys();

Exporting the XML Key is for persistence. What JSEncrypt needs is a certificate in PEM format, which is a Base64-encoded certificate. The return types of the two methods ExportRSAPublicKey and ExportSubjectPublicKeyInfo byte[] , so they need to be Base64 encoded. As used herein Viyi.Util provided Base64Encode() extension methods to achieve:

// C# Code

var pkcs1 = rsa.ExportRSAPublicKey().Base64Encode();
var spki = rsa.ExportSubjectPublicKeyInfo().Base64Encode();

-----BEGIN PUBLIC KEY----- and -----END PUBLIC KEY----- should be added to the PEM format. Base64 encoding should also be line-wrapped based on 64 characters per line. However, the actual test JSEncrypt will not be so strict when importing, which saves a lot of things.

All that remains is to pass pkcs1 and spki to the front end. The web application directly returns a JSON or TEXT through the API, which is determined according to the interface specification. Of course, it can also be transferred by copy/paste. Since it is an experiment here, use Console.WriteLine output to the console and pass it through the clipboard.

My PKCS#1 here exports Base64 with a length of 188 characters:

MIGJAoGB...tAgMBAAE=

SPKI exports Base64 with a length of 216 characters:

MIGfMA0GC...QIDAQAB

JSEncrypt import public key and encrypt

JSEncrypt provides setPublicKey() and setPrivateKey() to import keys. However, it is mentioned in the document that they are actually setKey() , which requires attention. To avoid ambiguity, I recommend using setKey() directly.

You can use also setPrivateKey and setPublicKey, they are both alias to setKey

from: http://travistidwell.com/jsencrypt/

Then the process of importing the public key and experimenting with encryption will look like this:

// JavaScript Code

const pkcs1 = "MIGJAoGB...tAgMBAAE=";   // 注意,这里的 KEY 值仅作示意,并不完整
const spki = "MIGfMA0GC...QIDAQAB";     // 注意,这里的 KEY 值仅作示意,并不完整

[pkcs1, spki].forEach((pKey, i) => {
    const jse = new JSEncrypt();
    jse.setKey(pKey);
    const eCodes = jse.encrypt("Hello World");
    console.log(`[${i} Result]: ${eCodes}`);
});

Get the output after running (the ciphertext is also omitted the long string in the middle):

[0 Result]: false
[1 Result]: ZkhFRnigoHt...wXQX4=

Seeing this result, there is no suspense. JSEncrypt only recognizes the SPKI format .

But you have to go to C# to verify that the ciphertext can be solved.

C# verifies that the ciphertext generated by JSEncrypt can be decrypted

ZkhFRnigoHt...wXQX4= generated above is copied into the C# code for verification and decryption. C# uses the RSACryptoServiceProvider.Decrypt() instance method to decrypt. The first parameter of this method is the ciphertext, type byte[] , which is provided in the form of binary data.

The second parameter can be boolean type, true means using OAEP filling method, false means using PKCS#1 v1.5 ; this parameter can also be RSAEncryptionPadding object, just select one of the predefined static objects directly. These are clearly stated in the documentation. Because the PKCS filling method is generally used, so this time, take a gamble and go directly to:

// C# Code

var eCodes = "ZkhFRnigoHt...wXQX4=";    // 示例代码这里省略了中间大部分内容
var rsa = LoadRsaKeys();   // rsa 肯定是使用之前生成的密钥对,要不然没法解密
byte[] data = rsa.Decrypt(eCodes.Base64Decode(), false);
Console.WriteLine(data.GetString());    // GetString 也是 Viyi.Util 中定义的扩展方法,默认用 UTF8 编码

The result is as expected:

Hello World

Technical summary

Now, through experiments, RSA encryption/decryption has been implemented between the Web front-end using JSEncrypt and the .NET back-end to complete secure data transmission. The practice is summarized as follows:

  1. The back-end generates an RSA key pair and saves it for later use. The storage method can be selected according to the actual situation: memory, file, database, cache service, etc.
  2. The back-end exports the public key in SPKI format (don’t forget the Base64 encoding), and passes it to the front-end through a certain business interface, or is actively requested by the front-end (such as calling a specific API)
  3. The front end uses JSEncrypt, imports the public key setKey() encrypt() encrypt the string. Before encryption, the string will be encoded into binary data according to UTF8.
  4. After the back-end obtains the front-end encrypted data (Base64 encoding), it decrypts it into binary data and uses UTF8 to decode it into text.

One thing to pay special attention to is: no matter what method (XML, PEM, etc.) the public key is sent to the front end, remember that should not give the private key to . This is especially likely to happen after using .ToXmlString(true) and then sending the result directly to the front end. Don't ask me why I have such a reminder, I have to ask because... I have seen it!

Close the door and put Node

It's not over yet, I said before to supplement the situation of the NodeJS backend. NodeJS SDK for encryption/decryption is in the crypto module,

  • Use generateKeyPair() or generateKeyPairSync() to generate a key pair
  • Use privateDecrypt() to decrypt the data
generateKeyPair() is an asynchronous operation. Asynchronous functions are very common in Node nowadays, especially when writing web servers, they are asynchronous everywhere. If you don't like the callback method, you can use util in the promisify() module to convert it.
// JavaScript Code, in Node environtment

import { promisify } from "util";
import crypto from "crypto";

const asyncGenerateKeyPair = promisify(crypto.generateKeyPair);

(async () => {
    const { publicKey, privateKey } = await asyncGenerateKeyPair(
        "rsa",
        {
            modulusLength: 1024,
            publicKeyEncoding: {
                type: "spki",
                format: "pem",
            },
            privateKeyEncoding: {
                type: "pkcs1",
                format: "pem"
            }
        }
    );

    console.log(publicKey)
    console.log(privateKey);
})();

generateKeyPair first parameter of 0607cfae2e214d is the algorithm, which is obvious. The second parameter is an option, and the intensity of 1024 is also obvious. Only publicKeyEncoding and privateKeyEncoding need to be explained a little bit-in fact, the document is also very clear: refer to keyObject.export() .

For the public key, type be "pkcs1" or "spki" . I have tried it before, but JSEncrypt only recognizes "spki" , so there is no choice.

For the private key, RSA can only choose "pkcs1" , so there is still no choice.

However, the PEM output of NodeJS is much more standardized, see (the middle part is also omitted):

-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCYur0zYBtqOqs98l4rh1J2olBb
... ... ...
8I8y4j9dZw05HD3u7QIDAQAB
-----END PUBLIC KEY-----
-----BEGIN RSA PRIVATE KEY-----
MIICXAIBAAKBgQCYur0zYBtqOqs98l4rh1J2olBbYpm5n6aNonWJ6y59smqipfj5
... ... ...
UJKGwVN8328z40R5w0iXqtYNvEhRtYGl0pTBP1FjJKg=
-----END RSA PRIVATE KEY-----

JSEncrypt recognizes it regardless of whether it contains headers/tails, and whether there are line breaks, so don't worry too much about these details. In short, after JSEncrypt gets the public key, it is still the same as before. It does the same thing without changing a word of the logic code.

Then go back to NodeJS decryption:

// JavaScript Code, in Node environtment

import crypto from "crypto";

const eCodes = "ZkhFRnigoHt...wXQX4=";    // 作为示例,偷个懒就用之前的那一段了
const buffer = crypto.privateDecrypt(
    {
        key: privateKey,
        padding: crypto.constants.RSA_PKCS1_PADDING
    },
    Buffer.from(eCodes, "base64")
);

console.log(buffer.toString());

privateDecrypt() the private key, which can be the private key PEM that was exported before, or the KeyObject object that was not exported. It should be noted that the filling method must be specified as RSA_PKCS1_PADDING , because the document says that RSA_PKCS1_OAEP_PADDING used by default.

One more thing to note is don’t forget Buffer.from(..., "base64") .

The result of the decryption is stored in the Buffer. It is good to directly toString() into a string, and display the specified UTF-8. Of course, it is also possible to toString("utf-8")

Wait, there's Java too

Java is also similar, but to be honest, the amount of code is much larger. In order to do these things, you probably need to import these classes:

// Java Code

import java.nio.charset.StandardCharsets;
import java.security.KeyFactory;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Base64;
import java.util.Base64.Decoder;
import java.util.Base64.Encoder;
import javax.crypto.Cipher;

Then generate the key pair

// Java Code

KeyPairGenerator gen = KeyPairGenerator.getInstance("RSA");
gen.initialize(1024);
KeyPair pair = gen.generateKeyPair();

Encoder base64Encoder = Base64.getEncoder();
String publicKey = base64Encoder.encodeToString(pair.getPublic().getEncoded());
String privateKey = base64Encoder.encodeToString(pair.getPrivate().getEncoded());

// 这里输出 PKCS#8,所以解密时需要用 PKCS8EncodedKeySpec
System.out.println(pair.getPrivate().getFormat());

The generated publicKey and privateKey are pure Base64, with no other content (no header/suffix, etc.).

Then the decryption process...

// Java Code

String eCode = "k7M0hD....qvdk=";  // 再次声明,这是仅为演示写的阉割版数据

Decoder base64Decoder = Base64.getDecoder();
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(base64Decoder.decode(privateKey));
KeyFactory keyFactory = KeyFactory.getInstance("RSA");

Cipher cipher = Cipher.getInstance(keyFactory.getAlgorithm());
cipher.init(Cipher.DECRYPT_MODE, keyFactory.generatePrivate(keySpec));
byte[] data = cipher.doFinal(base64Decoder.decode(eCode));

System.out.println(new String(data, StandardCharsets.UTF_8));

end

It's really tiring to finish writing Java, so I will use NodeJS in the future back-end examples-not a pot of Java, mainly because I don't want to cut the environment.

Highlights in the next section: "Registration" DEMO, secure transmission and storage of user passwords. 「Portal」


边城
59.8k 声望29.6k 粉丝

一路从后端走来,终于走在了前端!