使用 PHP 中的 openssl_decrypt 从 Subtle Crypto Javascript Payload 解密 AES-256-CBC

Decrypt AES-256-CBC using openssl_decrypt in PHP from Subtle Crypto Javascript Payload

我正在尝试使用 webcrypto/window.crypto 到 AES-256-CBC encrypt JS 中的某些内容并尝试 decrypt 它使用 PHP 的 openssl_decrypt 函数。

我的问题是解密功能只是 returns false 因此似乎不起作用。

const encoder = new TextEncoder();
const encoded = encoder.encode('Hello this is a test.');

const encryptionKey = await window.crypto.subtle.generateKey(
    {
        name: 'AES-CBC',
        length: 256,
    },
    true,
    ['encrypt', 'decrypt'],
);

const iv = window.crypto.getRandomValues(new Uint8Array(16));
const cipher = await window.crypto.subtle.encrypt(
    {
        name: 'AES-CBC',
        iv,
    },
    encryptionKey,
    encoded,
);


const exportedKey = await window.crypto.subtle.exportKey(
    'jwk',
    encryptionKey,
);

console.log(exportedKey.k); 

sendToBackend({
    cipher: btoa(new Uint8Array(cipher)), // "MTMsMjIzLDE5NSwxNzYsMjA0LDE5MSwxOTYsMjEyLDIwNCwyMzAsMjcsMSwxMjAsMTQzLDE2MSwxMTgsMTYwLDIzOSw4NywyMDksMjQ0LDIwNCwyMzgsODYsMTgzLDIyOCwxMzksMjIwLDcwLDY5LDI0OSwxODQ="
    iv: btoa(new Uint8Array(iv)), // "MTQ5LDE2Nyw4LDE2NywyMjAsMTA4LDEwMSw1Niw4Miw3MiwxMjAsMjM5LDE4NCw0OCwyNTIsMTE=",
    password: exportedKey.k, // "szq1aOg-F_72vWrdJatWyQp3iOXIus-cE19sO4bSOLs"
});

现在,当我尝试使用 PHP 在后端解密时,我得到 false:

$key = "szq1aOg-F_72vWrdJatWyQp3iOXIus-cE19sO4bSOLs";
$payload = "MTMsMjIzLDE5NSwxNzYsMjA0LDE5MSwxOTYsMjEyLDIwNCwyMzAsMjcsMSwxMjAsMTQzLDE2MSwxMTgsMTYwLDIzOSw4NywyMDksMjQ0LDIwNCwyMzgsODYsMTgzLDIyOCwxMzksMjIwLDcwLDY5LDI0OSwxODQ=";
$iv = "MTQ5LDE2Nyw4LDE2NywyMjAsMTA4LDEwMSw1Niw4Miw3MiwxMjAsMjM5LDE4NCw0OCwyNTIsMTE=";
$dec = openssl_decrypt($payload, 'AES-256-GCM', $key, false, $iv);
var_dump($dec); // false

有什么我遗漏的吗?

JavaScript这边,Base64编码失败,从结果的长度可以看出。在以下 JavaScript 代码中,函数 ab2b64() 用于此转换:

(async () => {

    const encoder = new TextEncoder();
    const encoded = encoder.encode('Hello this is a test.');

    const encryptionKey = await window.crypto.subtle.generateKey(
        {
            name: 'AES-CBC',
            length: 256,
        },
        true,
        ['encrypt', 'decrypt'],
    );

    const iv = window.crypto.getRandomValues(new Uint8Array(16));
    const cipher = await window.crypto.subtle.encrypt(
        {
            name: 'AES-CBC',
            iv,
        },
        encryptionKey,
        encoded,
    );

    const exportedKey = await window.crypto.subtle.exportKey(
        'jwk',
        encryptionKey,
    );

    console.log("key (Base64url): " + exportedKey.k)
    console.log("iv (Base64): " + ab2b64(iv))
    console.log("ciphertext (Base64): " + ab2b64(cipher))

    // 
    function ab2b64(arrayBuffer) {
        return window.btoa(String.fromCharCode.apply(null, new Uint8Array(arrayBuffer)));
    }

})();

可能的输出:

key (Base64url): 2TCb_J7EnsXgpYRhrJUG4ChgDNcnpcZ4sSCOK739U8A
iv (Base64): cWXBcXDyEcKTSRi2zPsqrg==
ciphertext (Base64): DRFokcfbdsfhNz/IeFUdmUQzxEAg09Y+gTE1DTfmzoA=

在 PHP 方面使用了错误的模式,即 GCM 必须替换为 CBC 以与 JavaScript 代码兼容(尽管 GCM 实际上是更安全的选择)。
此外,密钥必须是 Base64url(不是 Base64)解码,而 IV 和密文必须是 Base64 解码。对于密文,可以通过将 openssl_decrypt() 的第 4 个参数设置为 0:

来完成 implicit Base64 解码
<?php
$keyB64url = "2TCb_J7EnsXgpYRhrJUG4ChgDNcnpcZ4sSCOK739U8A";
$keyB64 = str_replace(['-','_'], ['+','/'], $keyB64url );
$key = base64_decode($keyB64);
$iv = base64_decode("cWXBcXDyEcKTSRi2zPsqrg==");
$payload = "DRFokcfbdsfhNz/IeFUdmUQzxEAg09Y+gTE1DTfmzoA=";
$dec = openssl_decrypt($payload, 'AES-256-CBC', $key, 0, $iv);
var_dump($dec); // string(21) "Hello this is a test."
?>

编辑:

虽然 CBC 仅提供机密性,但 GCM 提供机密性和 authenticity/integrity,使 GCM 更加安全。请注意,对于 CBC,可以使用消息验证码 (MAC),以便(除了机密性之外)还提供真实性;然而,GCM 的优势在于,这是 隐式 .

完成的

对于 JavaScript 端的 GCM,generateKey()encrypt() 中的算法必须从 AES-CBC 更改为 AES-GCM。 GCM 推荐的 nonce 长度为 12 字节(虽然也支持包括 16 字节在内的其他 nonce 长度),这需要在 getRandomValues():

中进行相应的更改

(async () => {

    const encoder = new TextEncoder();
    const encoded = encoder.encode('Hello this is a test.');

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

    const iv = window.crypto.getRandomValues(new Uint8Array(12));
    const cipher = await window.crypto.subtle.encrypt(
        {
            name: 'AES-GCM',
            iv,
        },
        encryptionKey,
        encoded,
    );

    const exportedKey = await window.crypto.subtle.exportKey(
        'jwk',
        encryptionKey,
    );

    console.log("key (Base64url): " + exportedKey.k)
    console.log("iv (Base64): " + ab2b64(iv))
    console.log("ciphertext (Base64): " + ab2b64(cipher))

    // 
    function ab2b64(arrayBuffer) {
        return window.btoa(String.fromCharCode.apply(null, new Uint8Array(arrayBuffer)));
    }

})();

可能的输出是:

key (Base64url): jno7Ydkris18yDtJ2nvPeWBrdiPqmqoZheYcc0qpjO8
iv (Base64): zETSdleg3nYdTbDv
ciphertext (Base64): HiPRcKl3MhzG+U4gKnpnK44hl9jqIzunMd15WnM9l4XkCjylXg==

如前所述,GCM是一种鉴权加密方式,使用tag进行鉴权。 WebCrypto 按此顺序隐式连接密文和标签(默认 16 字节长),而 PHP 分别处理两者。所以密文和tag必须在PHP这边分开:

<?php
$keyB64url = "jno7Ydkris18yDtJ2nvPeWBrdiPqmqoZheYcc0qpjO8";
$keyB64 = str_replace(['-','_'], ['+','/'], $keyB64url );
$key = base64_decode($keyB64);
$iv = base64_decode("zETSdleg3nYdTbDv");
$payload = base64_decode("HiPRcKl3MhzG+U4gKnpnK44hl9jqIzunMd15WnM9l4XkCjylXg==");
$payloadLen = strlen($payload);
$ciphertext = substr($payload, 0, $payloadLen - 16);
$tag = substr($payload, $payloadLen - 16, 16);
$dec = openssl_decrypt($ciphertext, 'AES-256-GCM', $key, OPENSSL_RAW_DATA, $iv, $tag);
var_dump($dec); // string(21) "Hello this is a test."
?>