|
| 1 | +/** |
| 2 | + * 加密系统端到端测试 |
| 3 | + * 运行方式: node tests/crypto.test.mjs |
| 4 | + * 无需任何测试框架依赖 |
| 5 | + */ |
| 6 | +import { createCipheriv, createHmac, pbkdf2Sync } from "node:crypto"; |
| 7 | + |
| 8 | +// 从源文件复制的常量(必须与 crypto-utils.ts 保持同步) |
| 9 | +const CRYPTO_CONSTANTS = { |
| 10 | + PBKDF2_ITERATIONS: 100000, |
| 11 | + SALT_LENGTH: 16, |
| 12 | + IV_LENGTH: 12, |
| 13 | + AUTH_TAG_LENGTH: 16, |
| 14 | + KEY_LENGTH: 32, |
| 15 | + VERIFY_PREFIX: "MIZUKI-VERIFY:", |
| 16 | +}; |
| 17 | + |
| 18 | +// === 服务端加密(复刻 crypto-utils.ts) === |
| 19 | +function deriveBytes(key, context, length) { |
| 20 | + return createHmac("sha256", key).update(context).digest().subarray(0, length); |
| 21 | +} |
| 22 | + |
| 23 | +function encryptContent(html, password, slug) { |
| 24 | + const { PBKDF2_ITERATIONS, SALT_LENGTH, IV_LENGTH, KEY_LENGTH, VERIFY_PREFIX } = CRYPTO_CONSTANTS; |
| 25 | + const plaintext = VERIFY_PREFIX + html; |
| 26 | + const salt = deriveBytes(password, `salt:${slug}`, SALT_LENGTH); |
| 27 | + const iv = deriveBytes(password, `iv:${slug}`, IV_LENGTH); |
| 28 | + const key = pbkdf2Sync(password, salt, PBKDF2_ITERATIONS, KEY_LENGTH, "sha256"); |
| 29 | + const cipher = createCipheriv("aes-256-gcm", key, iv); |
| 30 | + const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); |
| 31 | + const authTag = cipher.getAuthTag(); |
| 32 | + return Buffer.concat([salt, iv, authTag, encrypted]).toString("base64"); |
| 33 | +} |
| 34 | + |
| 35 | +// === 客户端解密(复刻 PasswordProtection.astro inline script) === |
| 36 | +async function clientDecrypt(encData, password) { |
| 37 | + const { PBKDF2_ITERATIONS, SALT_LENGTH, IV_LENGTH, AUTH_TAG_LENGTH, VERIFY_PREFIX } = CRYPTO_CONSTANTS; |
| 38 | + const raw = Buffer.from(encData, "base64"); |
| 39 | + const salt = raw.subarray(0, SALT_LENGTH); |
| 40 | + const iv = raw.subarray(SALT_LENGTH, SALT_LENGTH + IV_LENGTH); |
| 41 | + const authTag = raw.subarray(SALT_LENGTH + IV_LENGTH, SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH); |
| 42 | + const ciphertext = raw.subarray(SALT_LENGTH + IV_LENGTH + AUTH_TAG_LENGTH); |
| 43 | + |
| 44 | + const combined = Buffer.concat([ciphertext, authTag]); |
| 45 | + |
| 46 | + const enc = new TextEncoder(); |
| 47 | + const keyMaterial = await crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveKey"]); |
| 48 | + const aesKey = await crypto.subtle.deriveKey( |
| 49 | + { name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: "SHA-256" }, |
| 50 | + keyMaterial, { name: "AES-GCM", length: 256 }, false, ["decrypt"], |
| 51 | + ); |
| 52 | + const decrypted = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, aesKey, combined); |
| 53 | + const decoded = new TextDecoder().decode(decrypted); |
| 54 | + |
| 55 | + if (!decoded.startsWith(VERIFY_PREFIX)) { |
| 56 | + throw new Error("Verification prefix mismatch"); |
| 57 | + } |
| 58 | + return decoded.substring(VERIFY_PREFIX.length); |
| 59 | +} |
| 60 | + |
| 61 | +// === 测试用例 === |
| 62 | +const testHtml = "<h1>Hello World</h1><p>这是一篇加密文章的内容</p>"; |
| 63 | +const testPassword = "test-password-123"; |
| 64 | +const testSlug = "encrypted-test-post"; |
| 65 | + |
| 66 | +let passed = 0; |
| 67 | +let failed = 0; |
| 68 | + |
| 69 | +function assert(condition, message) { |
| 70 | + if (!condition) throw new Error(`Assertion failed: ${message}`); |
| 71 | +} |
| 72 | + |
| 73 | +async function test(name, fn) { |
| 74 | + try { |
| 75 | + await fn(); |
| 76 | + console.log(` ✓ ${name}`); |
| 77 | + passed++; |
| 78 | + } catch (err) { |
| 79 | + console.log(` ✗ ${name}`); |
| 80 | + console.log(` ${err.message}`); |
| 81 | + failed++; |
| 82 | + } |
| 83 | +} |
| 84 | + |
| 85 | +console.log("Encryption System Tests\n"); |
| 86 | + |
| 87 | +await test("encrypt and decrypt with correct password", async () => { |
| 88 | + const encrypted = encryptContent(testHtml, testPassword, testSlug); |
| 89 | + assert(encrypted.length > 0, "ciphertext should not be empty"); |
| 90 | + const decrypted = await clientDecrypt(encrypted, testPassword); |
| 91 | + assert(decrypted === testHtml, `decrypted content mismatch: got "${decrypted.slice(0, 30)}..."`); |
| 92 | +}); |
| 93 | + |
| 94 | +await test("deterministic ciphertext for same inputs", () => { |
| 95 | + const a = encryptContent(testHtml, testPassword, testSlug); |
| 96 | + const b = encryptContent(testHtml, testPassword, testSlug); |
| 97 | + assert(a === b, "same inputs should produce same ciphertext"); |
| 98 | +}); |
| 99 | + |
| 100 | +await test("reject wrong password", async () => { |
| 101 | + const encrypted = encryptContent(testHtml, testPassword, testSlug); |
| 102 | + let threw = false; |
| 103 | + try { |
| 104 | + await clientDecrypt(encrypted, "wrong-password"); |
| 105 | + } catch { |
| 106 | + threw = true; |
| 107 | + } |
| 108 | + assert(threw, "wrong password should throw"); |
| 109 | +}); |
| 110 | + |
| 111 | +await test("different slugs produce different ciphertext", () => { |
| 112 | + const a = encryptContent(testHtml, testPassword, "slug-a"); |
| 113 | + const b = encryptContent(testHtml, testPassword, "slug-b"); |
| 114 | + assert(a !== b, "different slugs should produce different ciphertext"); |
| 115 | +}); |
| 116 | + |
| 117 | +await test("CJK content round-trip", async () => { |
| 118 | + const cjk = "<p>日本語テスト 中文测试 한국어 테스트</p>"; |
| 119 | + const encrypted = encryptContent(cjk, testPassword, testSlug); |
| 120 | + const decrypted = await clientDecrypt(encrypted, testPassword); |
| 121 | + assert(decrypted === cjk, "CJK content mismatch"); |
| 122 | +}); |
| 123 | + |
| 124 | +await test("empty content round-trip", async () => { |
| 125 | + const encrypted = encryptContent("", testPassword, testSlug); |
| 126 | + const decrypted = await clientDecrypt(encrypted, testPassword); |
| 127 | + assert(decrypted === "", "empty content mismatch"); |
| 128 | +}); |
| 129 | + |
| 130 | +await test("special HTML characters round-trip", async () => { |
| 131 | + const special = '<div class="test">& < > "quotes" \'single\'</div>'; |
| 132 | + const encrypted = encryptContent(special, testPassword, testSlug); |
| 133 | + const decrypted = await clientDecrypt(encrypted, testPassword); |
| 134 | + assert(decrypted === special, "special HTML mismatch"); |
| 135 | +}); |
| 136 | + |
| 137 | +await test("CRYPTO_CONSTANTS have required fields", () => { |
| 138 | + assert(CRYPTO_CONSTANTS.PBKDF2_ITERATIONS === 100000, "PBKDF2_ITERATIONS"); |
| 139 | + assert(CRYPTO_CONSTANTS.SALT_LENGTH === 16, "SALT_LENGTH"); |
| 140 | + assert(CRYPTO_CONSTANTS.IV_LENGTH === 12, "IV_LENGTH"); |
| 141 | + assert(CRYPTO_CONSTANTS.AUTH_TAG_LENGTH === 16, "AUTH_TAG_LENGTH"); |
| 142 | + assert(CRYPTO_CONSTANTS.KEY_LENGTH === 32, "KEY_LENGTH"); |
| 143 | + assert(CRYPTO_CONSTANTS.VERIFY_PREFIX === "MIZUKI-VERIFY:", "VERIFY_PREFIX"); |
| 144 | +}); |
| 145 | + |
| 146 | +console.log(`\nResults: ${passed} passed, ${failed} failed`); |
| 147 | +process.exit(failed > 0 ? 1 : 0); |
0 commit comments