Web Crypto API

1,453 阅读10分钟

Web Crypto API 是现代浏览器中提供的一套接口,用于实现标准化的加密操作,如生成随机数、密钥生成、加密、解密和签名等。它的一个主要特点是可以提供更高的安全性和更好的性能,因为它使用底层的操作系统原生加密库。下面是Web Crypto API的一些基本用法示例:

1. 生成随机数据

生成一个32位16进制随机数

const generateRandomBytes = (length) => {
    const array = new Uint8Array(length);
    window.crypto.getRandomValues(array);
    return array;
};

const randomBytes = generateRandomBytes(32);
console.log("Random 32 Bytes:", randomBytes);

const bytesToHex = (bytes) => {
    return Array.from(bytes)
        .map(byte => byte.toString(16).padStart(2, '0'))
        .join('');
};

console.log("Random 32 Bytes (Hex):", bytesToHex(randomBytes));

执行后的打印结果:

image.png

解释

  1. Uint8Array是一个 JavaScript 数组类型,其中的每一个元素表示 8 位无符号整数值的数组。它的取值范围是介于 0 到 255 之间的十进制整数值。
  2. getRandomValues是 crypto 对象上的一个方法。它接受一个 Uint8Array、Uint16Array 或 Uint32Array 类型的数组作为参数,利用操作系统或硬件提供的真正随机数源,高效地将随机数填充到用户提供的数组中,相比较Math.random() 使用伪随机数算法生成的随机数,为应用程序提供了一种安全可靠的随机数生成方式。
  3. byte.toString(16).padStart(2, '0')将 byte 值转换为一个16进制字符串,如果原字符串长度不足2个字符,则在字符串开头添加字符'0'来达到2个字符的长度。

如果我想生成一个每位是十进制的32位随机数,应该如何做呢?

由于一个Unit8是一个数值范围在0-255之间的数,因此还需要把这个范围的随机数转化到0-9之间。

const generateRandomBytesInRange = (length, min, max) => {
    if (min > max) throw new Error("Min must be less than or equal to Max");
    
    const range = max - min + 1;
    const byteArray = new Uint8Array(length);
    window.crypto.getRandomValues(byteArray);

    const randomValuesInRange = new Uint8Array(length);
    for (let i = 0; i < byteArray.length; i++) {
        // 将随机字节值转换到指定范围内的随机值
        randomValuesInRange[i] = min + (byteArray[i] % range);
    }
    return randomValuesInRange;
};

console.log("Random 32 Bytes (Hex):", bytesToHex(randomBytes));

const randomDigits = generateRandomBytesInRange(32, 0, 9);
console.log("Random 32 Digits:", randomDigits);
console.log("Random 32 Digits(String):", randomDigits.join(''));

执行后的打印结果:

image.png

2. 生成加密密钥

2.1 生成对称密钥(例如用于 AES 加密):

const generateKey = async () => {
    // 生成 AES-GCM 对称密钥
    const key = await window.crypto.subtle.generateKey({
            name: "AES-GCM",
            length: 256
        },
        true, // 是否允许导出密钥
        ["encrypt", "decrypt"]
    );
    console.log('key', key);

    // 导出生成的密钥并将其编码为 Base64
    const exportedKey = await window.crypto.subtle.exportKey(
        "raw", // 导出为原始格式
        key
    );

    // 将 ArrayBuffer 转换为 Base64 字符串
    const base64Key = btoa(String.fromCharCode(...new Uint8Array(exportedKey)));

    console.log("Generated AES-GCM Key:", base64Key);
};

generateKey();

解释

  1. 密钥生成: 使用 window.crypto.subtle.generateKey 生成一个 AES-GCM 对称密钥,密钥长度为 256 位。
  2. 密钥导出: 使用 window.crypto.subtle.exportKey 将生成的对称密钥导出为原始格式(raw),返回一个 ArrayBuffer。
  3. Base64 编码: 将 ArrayBuffer 转换为一个 Uint8Array 并使用 btoa 函数将其编码为 Base64 字符串。
  4. 打印密钥: 最后,将编码后的 Base64 字符串打印出来。

执行后的打印结果:

image.png

2.2 生成非对称密钥对(例如用于 RSA 加密/解签或签名/验证):

const generateKeyPair = async () => {
    const keyPair = await window.crypto.subtle.generateKey({
            name: "RSA-OAEP",
            modulusLength: 2048,
            publicExponent: new Uint8Array([1, 0, 1]),
            hash: "SHA-256"
        },
        true,
        ["encrypt", "decrypt"]
    );

    const publicKey = await window.crypto.subtle.exportKey("spki", keyPair.publicKey);
    const privateKey = await window.crypto.subtle.exportKey("pkcs8", keyPair.privateKey);

    console.log("Public Key:", btoa(String.fromCharCode(...new Uint8Array(publicKey))));
    console.log("Private Key:", btoa(String.fromCharCode(...new Uint8Array(privateKey))));
};

generateKeyPair();

解释

  1. publicExponent是在使用RSA算法生成密钥对时的一个参数,指定RSA公钥的指数。publicExponent被设置为new Uint8Array([1, 0, 1]),这表示RSA公钥的指数为65537(0x010001)。在RSA算法中,通常选择65537作为公钥的指数,因为它是一个较小的素数且具有良好的安全性和效率。指定publicExponent为指定的值有助于确保生成的RSA密钥对的安全性和正确性。

执行打印后的结果:

image.png

3. 对称加密和解密

3.1 使用 AES-GCM 密钥进行加密:

const encrypt = async (key, data) => {
    const iv = window.crypto.getRandomValues(new Uint8Array(12));
    const encrypted = await window.crypto.subtle.encrypt({
            name: "AES-GCM",
            iv: iv
        },
        key,
        new TextEncoder().encode(data)
    );
    console.log("Encrypted Data:", btoa(String.fromCharCode(...new Uint8Array(encrypted))));
    console.log("IV:", btoa(String.fromCharCode(...iv)));
};

const keyPromise = window.crypto.subtle.generateKey({
        name: "AES-GCM",
        length: 256
    },
    true,
    ["encrypt", "decrypt"]
);

keyPromise.then(key => {
    encrypt(key, "Hello World!");
});

执行打印后的结果:

image.png

3.2 使用 AES-GCM 密钥进行解密:

const decrypt = async (key, iv, encryptedData) => {
    const decrypted = await window.crypto.subtle.decrypt({
            name: "AES-GCM",
            iv: iv
        },
        key,
        encryptedData
    );
    console.log("Decrypted Data:", new TextDecoder().decode(decrypted));
};

const encryptedData = Uint8Array.from(atob("加密后的数据"), c => c.charCodeAt(0));
const iv = Uint8Array.from(atob("加密用的IV"), c => c.charCodeAt(0));

keyPromise.then(key => {
    decrypt(key, iv, encryptedData);
});

注意

解密的时候需要拿到加密时生成的key,如果加密和解密不在同一台机器怎么办呢,我们需要将key转化为base64进行传输,接收方再将base64转化为key,下面列举一下完整加解密过程:

//导出key对象转化为base64Key 
const exportKeyAsBase64 = async (key) => {
    const exportedKey = await crypto.subtle.exportKey("raw", key);
    return btoa(String.fromCharCode(...new Uint8Array(exportedKey)));
};
//导入base64Key转化为key对象
const importKeyFromBase64 = async (base64Key) => {
    const rawKey = Uint8Array.from(atob(base64Key), c => c.charCodeAt(0));
    return await crypto.subtle.importKey(
        "raw",
        rawKey, {
            name: "AES-GCM"
        },
        true,
        ["encrypt", "decrypt"]
    );
};

下面列出含key导出与导入的简要demo示例:

const testExportImportKey = async () => {
    // 生成一个新的密钥
    const generatedKey = await generateKey();

    // 导出密钥为 Base64
    const base64Key = await exportKeyAsBase64(generatedKey);
    console.log("Exported Base64 Key:", base64Key);

    // 从 Base64 导入密钥
    const importedKey = await importKeyFromBase64(base64Key);

    // 测试加密和解密
    const dataToEncrypt = "Hello World!";
    const encryptedResult = await encrypt(importedKey, dataToEncrypt);
    console.log("Encrypted Data:", encryptedResult.data);

    const decryptedData = await decrypt(importedKey, encryptedResult.iv, encryptedResult.data);
    console.log("Decrypted Data:", decryptedData);
};

testExportImportKey();

解释

  1. 导出密钥: 使用 exportKeyAsBase64 方法将密钥转换为 Base64 编码字符串。
  2. 导入密钥: 使用 importKeyFromBase64 方法将 Base64 编码字符串转换回一个 AES-GCM 密钥对象。
  3. 测试导出与导入:
    • 首先生成一个新的密钥。
    • 将密钥导出为 Base64 编码字符串并打印出来。
    • 将 Base64 编码字符串导入为密钥对象。
    • 使用导入的密钥对象进行加密操作,再用同一密钥对象解密,最后打印解密后的结果以验证整个流程的正确性。

4. 非对称加密和解密

image.png

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>RSA Encryption Example</title>
</head>

<body>
    <div>
        <h1>RSA Encryption Example</h1>
        <button onclick="generateKeyPair()">Generate Key Pair</button>
        <h2>Public Key (Base64)</h2>
        <pre id="public-key"></pre>
        <h2>Private Key (Base64)</h2>
        <pre id="private-key"></pre>
        <textarea id="plaintext" placeholder="Enter text to encrypt"></textarea><br>
        <button onclick="encryptText()">Encrypt</button>
        <button onclick="decryptText()">Decrypt</button>
        <h2>Encrypted Text</h2>
        <pre id="encrypted-text"></pre>
        <h2>Decrypted Text</h2>
        <pre id="decrypted-text"></pre>
    </div>
    <script>
    let publicKeyBase64;
    let privateKeyBase64;

    async function generateKeyPair() {
        const keyPair = await crypto.subtle.generateKey({
            name: "RSA-OAEP",
            modulusLength: 2048,
            publicExponent: new Uint8Array([1, 0, 1]),
            hash: { name: "SHA-256" },
        }, true, ["encrypt", "decrypt"]);

        const publicKeyArrayBuffer = await crypto.subtle.exportKey('spki', keyPair.publicKey);
        const privateKeyArrayBuffer = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey);

        publicKeyBase64 = arrayBufferToBase64(publicKeyArrayBuffer);
        privateKeyBase64 = arrayBufferToBase64(privateKeyArrayBuffer);

        document.getElementById('public-key').textContent = publicKeyBase64;
        document.getElementById('private-key').textContent = privateKeyBase64;

        console.log('publicKeyBase64:', publicKeyBase64);
        console.log('privateKeyBase64:', privateKeyBase64);
    }

    function arrayBufferToBase64(buffer) {
        const binary = String.fromCharCode.apply(null, new Uint8Array(buffer));
        return btoa(binary);
    }

    function base64ToArrayBuffer(base64) {
        const binary = atob(base64);
        const len = binary.length;
        const bytes = new Uint8Array(len);
        for (let i = 0; i < len; i++) {
            bytes[i] = binary.charCodeAt(i);
        }
        return bytes.buffer;
    }

    async function encryptText() {
        if (!publicKeyBase64 || !privateKeyBase64) {
            alert('Please generate the key pair first.');
            return;
        }

        const publicKeyArrayBuffer = base64ToArrayBuffer(publicKeyBase64);
        const publicKey = await crypto.subtle.importKey(
            'spki',
            publicKeyArrayBuffer, {
                name: 'RSA-OAEP',
                hash: { name: 'SHA-256' },
            },
            true,
            ['encrypt']
        );

        const text = document.getElementById('plaintext').value;
        const encodedText = new TextEncoder().encode(text);

        const encryptedData = await crypto.subtle.encrypt({
            name: "RSA-OAEP",
        }, publicKey, encodedText);

        const encryptedBase64 = btoa(String.fromCharCode.apply(null, new Uint8Array(encryptedData)));
        document.getElementById('encrypted-text').textContent = encryptedBase64;
    }

    async function decryptText() {
        if (!publicKeyBase64 || !privateKeyBase64) {
            alert('Please generate the key pair first.');
            return;
        }

        const privateKeyArrayBuffer = base64ToArrayBuffer(privateKeyBase64);
        const privateKey = await crypto.subtle.importKey(
            'pkcs8',
            privateKeyArrayBuffer, {
                name: 'RSA-OAEP',
                hash: { name: 'SHA-256' },
            },
            true,
            ['decrypt']
        );

        const encryptedBase64 = document.getElementById('encrypted-text').textContent;
        const encryptedData = Uint8Array.from(atob(encryptedBase64), c => c.charCodeAt(0));

        const decryptedData = await crypto.subtle.decrypt({
            name: "RSA-OAEP",
        }, privateKey, encryptedData);

        const decryptedText = new TextDecoder().decode(decryptedData);
        document.getElementById('decrypted-text').textContent = decryptedText;
    }
    </script>
</body>

</html>

5. 签名和验证

在web安全中,消息认证码(HMAC, Hash-based Message Authentication Code)是一种广泛使用的技术,用于验证消息的完整性和真实性。使用HMAC可以确保数据在传输过程中未被篡改,并且数据确实来源于预期的发信方。其具体用途包括但不限于以下几种:

  • 数据完整性验证

    • HMAC 可以确保数据在传输过程中未被篡改。发送方使用共享密钥生成消息的 HMAC,接收方使用相同的密钥重新计算 HMAC,并与接收到的 HMAC 进行比较。
  • 消息认证

    • HMAC 确保消息的来源是可信的。由于 HMAC 基于共享的密钥,只有握有密钥的双方才能生成有效的 HMAC,从而认证消息的真实性。
  • 防止重播攻击

    • 在网络通信中,攻击者可能截获并重播有效的数据包。HMAC 可以与时间戳等其他机制结合使用,以防止这种情况的发生。
  • 数字签名

    • 在一些场景下,HMAC 也用于替代数字签名,特别是在资源受限或需要较高计算效率的环境中。虽然 HMAC 不同于使用非对称加密的数字签名,但在特定的情况下HMAC也能提供充分高效的验证手段。

5.1 使用 HMAC 生成签名:

const generateKey = async () => {
    return await window.crypto.subtle.generateKey({
            name: "HMAC",
            hash: { name: "SHA-256" }
        },
        true,
        ["sign", "verify"]
    );
};

const sign = async (key, data) => {
    const signature = await window.crypto.subtle.sign({
            name: "HMAC"
        },
        key,
        new TextEncoder().encode(data)
    );
    console.log("Signature:", btoa(String.fromCharCode(...new Uint8Array(signature))));
};

generateKey().then(key => {
    sign(key, "Hello World!");
});

执行后的打印结果:

image.png

5.2 使用 HMAC 验证签名:

const verify = async (key, signature, data) => {
    const isValid = await window.crypto.subtle.verify({
            name: "HMAC"
        },
        key,
        Uint8Array.from(atob(signature), c => c.charCodeAt(0)),
        new TextEncoder().encode(data)
    );
    console.log("Is valid:", isValid);
};

generateKey().then(key => {
    const signature = "之前生成的签名";
    verify(key, signature, "Hello World!");
});

下面列出完整示例:

const generateHMACKey = async () => {
    return await crypto.subtle.generateKey(
        { name: "HMAC", hash: "SHA-256" },
        true, // 是否可导出密钥
        ["sign", "verify"]
    );
};

const exportHMACKeyAsBase64 = async (key) => {
    const exportedKey = await crypto.subtle.exportKey("raw", key);
    return btoa(String.fromCharCode(...new Uint8Array(exportedKey)));
};

const importHMACKeyFromBase64 = async (base64Key) => {
    const rawKey = Uint8Array.from(atob(base64Key), c => c.charCodeAt(0));
    return await crypto.subtle.importKey(
        "raw",
        rawKey,
        { name: "HMAC", hash: "SHA-256" },
        true,
        ["sign", "verify"]
    );
};

const generateHMAC = async (key, message) => {
    const encoder = new TextEncoder();
    const data = encoder.encode(message);
    const signature = await crypto.subtle.sign("HMAC", key, data);
    return btoa(String.fromCharCode(...new Uint8Array(signature)));
};

const verifyHMAC = async (key, message, signatureBase64) => {
    const encoder = new TextEncoder();
    const data = encoder.encode(message);
    const signature = Uint8Array.from(atob(signatureBase64), c => c.charCodeAt(0));
    return await crypto.subtle.verify("HMAC", key, signature, data);
};

const testHMAC = async () => {
    const message = "Hello World!";
    
    // 生成HMAC密钥
    const hmacKey = await generateHMACKey();
    
    // 导出HMAC密钥作为Base64
    const base64Key = await exportHMACKeyAsBase64(hmacKey);
    console.log("HMAC Key (Base64):", base64Key);
    
    // 使用HMAC密钥生成消息签名
    const signature = await generateHMAC(hmacKey, message);
    console.log("HMAC Signature (Base64):", signature);
    
    // 导入HMAC密钥
    const importedKey = await importHMACKeyFromBase64(base64Key);
    
    // 验证HMAC签名
    const isValid = await verifyHMAC(importedKey, message, signature);
    console.log("Is the signature valid?", isValid);
};

testHMAC();

解释

  1. TextEncoder是一个内置的全局对象,它在Web Crypto API中用于将字符串转换为字节数组。它被用来将文本消息编码为字节以便进行HMAC签名。
  2. crypto.subtle.sign是 Web Crypto API 中的一个方法,用于对给定的数据使用指定的密钥进行签名操作。签名操作是对数据进行加密处理,生成一个特定的签名值,用于验证数据的完整性和真实性。
  3. crypto.subtle.verify 是 Web Crypto API 中的一个方法,用于验证给定数据的数字签名。

执行后的打印结果:

image.png

6. 散列 (Hashing)

计算 SHA-256 哈希:

const hash = async (data) => {
    const digest = await window.crypto.subtle.digest(
        "SHA-256",
        new TextEncoder().encode(data)
    );
    console.log("SHA-256:", btoa(String.fromCharCode(...new Uint8Array(digest))));
};

hash("Hello World!");

执行后的打印结果:

image.png

总结 Web Crypto API 提供了一组强大且灵活的工具,用于在网页应用中实现各种加密和安全相关的操作。其主要优势在于它可以直接使用浏览器提供的底层加密库提供更高效和更安全的加密操作。

关于加密和解密,签名和验签都逃不开密钥Key,如何在客户端和服务端安全的共享Key是我们值得研究的问题。参考文章《Web客户端和服务器如何安全共享加密密钥Key》