Skip to content

Commit 30a4d92

Browse files
feat: upgrade content encryption with verification prefix and shared constants
- Add MIZUKI-VERIFY: prefix before encryption for fast wrong-password detection - Centralize crypto constants in exported CRYPTO_CONSTANTS object - Add verification prefix check in client-side decrypt - Remove broken import from non-existent types/auth module - Add pure Node.js encryption round-trip tests (8 cases)
1 parent 8ffce3d commit 30a4d92

4 files changed

Lines changed: 187 additions & 19 deletions

File tree

src/components/features/auth/PasswordProtection.astro

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ const i18nPasswordDecryptRetry = i18n(I18nKey.passwordDecryptRetry);
9797
var SALT_LENGTH = 16;
9898
var IV_LENGTH = 12;
9999
var AUTH_TAG_LENGTH = 16;
100+
var VERIFY_PREFIX = "MIZUKI-VERIFY:";
100101

101102
function base64ToUint8Array(b64) {
102103
var bin = atob(b64);
@@ -136,7 +137,11 @@ const i18nPasswordDecryptRetry = i18n(I18nKey.passwordDecryptRetry);
136137
var decrypted = await crypto.subtle.decrypt(
137138
{ name: "AES-GCM", iv: iv }, aesKey, combined
138139
);
139-
return new TextDecoder().decode(decrypted);
140+
var decoded = new TextDecoder().decode(decrypted);
141+
if (!decoded.startsWith(VERIFY_PREFIX)) {
142+
throw new Error("Verification prefix mismatch");
143+
}
144+
return decoded.substring(VERIFY_PREFIX.length);
140145
}
141146

142147
async function attemptUnlock(inputPassword) {

src/components/features/auth/index.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,3 @@
44
export { default as Encryptor } from "./Encryptor.astro";
55
export { default as PasswordProtection } from "./PasswordProtection.astro";
66
export type { EncryptorProps, PasswordProtectionProps } from "./types";
7-
export type {
8-
CopyCodeOptions,
9-
DecryptResult,
10-
UnlockCallbacks,
11-
ValidationMessages,
12-
} from "./types/auth";

src/utils/crypto-utils.ts

Lines changed: 34 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,49 @@
11
import { createCipheriv, createHmac, pbkdf2Sync } from "node:crypto";
22

3-
const PBKDF2_ITERATIONS = 100000;
4-
const SALT_LENGTH = 16;
5-
const IV_LENGTH = 12;
6-
const KEY_LENGTH = 32;
3+
// 共享加密常量 — 客户端 PasswordProtection.astro 中的内联脚本必须保持同步
4+
export const CRYPTO_CONSTANTS = {
5+
PBKDF2_ITERATIONS: 100000,
6+
SALT_LENGTH: 16,
7+
IV_LENGTH: 12,
8+
AUTH_TAG_LENGTH: 16,
9+
KEY_LENGTH: 32,
10+
VERIFY_PREFIX: "MIZUKI-VERIFY:", // 验证前缀:正确解密后内容以此开头
11+
} as const;
712

13+
/**
14+
* 使用 HMAC-SHA256 派生确定性字节
15+
*/
816
function deriveBytes(key: string, context: string, length: number): Buffer {
9-
return createHmac("sha256", key).update(context).digest().subarray(0, length);
17+
return createHmac("sha256", key)
18+
.update(context)
19+
.digest()
20+
.subarray(0, length);
1021
}
1122

1223
/**
13-
* Encrypt HTML content with AES-256-GCM using PBKDF2-derived key.
14-
* Salt and IV are deterministic (derived from password + slug) so the same
15-
* inputs always produce the same ciphertext, enabling sessionStorage caching.
24+
* 加密 HTML 内容
1625
*
17-
* Output format: base64(salt[16] + iv[12] + authTag[16] + ciphertext)
26+
* 协议 v2:在明文前添加验证前缀,使客户端可以快速验证密码是否正确,
27+
* 无需等待完整 AES-GCM 解密失败。
28+
*
29+
* 输出格式:base64(salt[16] + iv[12] + authTag[16] + ciphertext)
30+
* 其中 ciphertext = AES-256-GCM-encrypt("MIZUKI-VERIFY:" + html)
1831
*/
1932
export function encryptContent(
2033
html: string,
2134
password: string,
2235
slug: string,
2336
): string {
37+
const {
38+
PBKDF2_ITERATIONS,
39+
SALT_LENGTH,
40+
IV_LENGTH,
41+
KEY_LENGTH,
42+
VERIFY_PREFIX,
43+
} = CRYPTO_CONSTANTS;
44+
45+
const plaintext = VERIFY_PREFIX + html;
46+
2447
const salt = deriveBytes(password, `salt:${slug}`, SALT_LENGTH);
2548
const iv = deriveBytes(password, `iv:${slug}`, IV_LENGTH);
2649
const key = pbkdf2Sync(
@@ -33,11 +56,10 @@ export function encryptContent(
3356

3457
const cipher = createCipheriv("aes-256-gcm", key, iv);
3558
const encrypted = Buffer.concat([
36-
cipher.update(html, "utf8"),
59+
cipher.update(plaintext, "utf8"),
3760
cipher.final(),
3861
]);
3962
const authTag = cipher.getAuthTag();
4063

41-
const result = Buffer.concat([salt, iv, authTag, encrypted]);
42-
return result.toString("base64");
64+
return Buffer.concat([salt, iv, authTag, encrypted]).toString("base64");
4365
}

tests/crypto.test.mjs

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
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">&amp; &lt; &gt; "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

Comments
 (0)