@tacet-ink/journal-core 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -12
- package/package.json +5 -3
- package/scripts/verify-core-crypto.ts +628 -10
- package/src/client/argon2.ts +32 -37
- package/src/client/note-crypto.ts +137 -42
- package/src/client/pinlock.ts +13 -8
- package/src/index.ts +3 -0
- package/src/server/auth.ts +47 -19
- package/src/server/cors.ts +4 -0
- package/src/server/ratelimit.ts +50 -34
package/src/server/auth.ts
CHANGED
|
@@ -1,33 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* auth.ts — 零知識伺服器端 auth 核心邏輯(品牌/schema 參數化)。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 本檔實作的契約:
|
|
5
5
|
* - pass 明文永不過線:線上憑證送 PH1(雜湊形),伺服器存 PH2 = SHA-256(PH1)。
|
|
6
6
|
* PH1 派生方式由各產品自定義(快雜湊或 Argon2id 派生皆可)——本層只驗
|
|
7
7
|
* hex64 形,不驗 PH1 派生方式(伺服器零知識,無從也無需區分形別)。
|
|
8
|
-
* -
|
|
9
|
-
*
|
|
10
|
-
* -
|
|
11
|
-
*
|
|
8
|
+
* - 入庫前密文/包裹格式驗證(inboundCipher/isCipherFor/validWrappedKey/
|
|
9
|
+
* pickKeyPackage 等,前綴由 config 注入)。
|
|
10
|
+
* - /auth/login 核心動線(loginRouteCore):rate-limit 閘 → PH1 驗形 →
|
|
11
|
+
* PH2 → store 查詢/建號 → session token 簽發(30 天)。
|
|
12
|
+
*
|
|
13
|
+
* 呼叫端(fork)職責——本檔只定義介面,機制在呼叫端實作:
|
|
14
|
+
* - identity 生成:AuthStore.createUser 由各 fork 自決(隨機 account_id 或
|
|
15
|
+
* 客戶端帶入語意皆可);PH2 UNIQUE 衝突(兩個同時首登同密語)是 store
|
|
16
|
+
* 責任——tacet 參考實作:UNIQUE 失敗重查既有列回 userKey(非 5xx)。
|
|
17
|
+
* - hash-ladder 雙軌(舊式 PH1 直比 → 升級寫回 PH2)與密語重設後的 session
|
|
18
|
+
* 撤銷時機:fork 呼叫端接線(revokeAllSessions 介面已備)。
|
|
19
|
+
* - 常數時間比較 timingSafeEq:./hash.ts 匯出(login 動線經 PH2 查詢不直比)。
|
|
12
20
|
*
|
|
13
|
-
*
|
|
14
|
-
* -
|
|
15
|
-
*
|
|
21
|
+
* 不變量(勿破壞):
|
|
22
|
+
* - 金鑰包裹欄組(wrapped+salt)缺一整組放棄——pair 檢查本體在 pickKeyPackage。
|
|
23
|
+
* - recPkg 與 recHash 成對出現=呼叫端(fork store)職責:本層介面兩參各自可空、
|
|
24
|
+
* 不做 pair 檢查(r2 MINOR-4 照此收口;pickKeyPackage 是同職責的成對檢查先例)。
|
|
16
25
|
*/
|
|
17
26
|
|
|
18
27
|
import { corsResponse } from './cors.ts';
|
|
19
28
|
import { sha256Hex, generateSessionToken } from './hash.ts';
|
|
20
29
|
import { checkRate, type RateWindow } from './ratelimit.ts';
|
|
21
|
-
import type { Env } from './env';
|
|
30
|
+
import type { Env } from './env.ts';
|
|
22
31
|
|
|
23
32
|
// ── 格式驗證(自 noteCrypt.ts 抽出;前綴由 config 注入) ─────────────────────
|
|
24
33
|
|
|
25
34
|
export interface CipherFormats {
|
|
26
|
-
/**
|
|
35
|
+
/** 密文前綴家族字面(startsWith 語意,非 regex 來源),如 'jr1b.'。 */
|
|
27
36
|
cipherPrefixes: [string, string];
|
|
28
37
|
/** 金鑰包裹前綴,如 'jr1w.'。 */
|
|
29
38
|
wrapPrefix: string;
|
|
30
|
-
/**
|
|
39
|
+
/** 密文上限:UTF-16 code unit 計數(.length 語意;非 UTF-8 bytes)。 */
|
|
31
40
|
cipherMax: number;
|
|
32
41
|
}
|
|
33
42
|
|
|
@@ -36,17 +45,32 @@ const BINARY_RE = /[\x00-\x08\x0e-\x1f]/;
|
|
|
36
45
|
const HASH64_RE = /^[0-9a-f]{64}$/;
|
|
37
46
|
const SALT32_RE = /^[0-9a-f]{32}$/;
|
|
38
47
|
|
|
39
|
-
|
|
40
|
-
|
|
48
|
+
/** regex 用字面跳脫:全字元掃描(.replace('.',) 只跳第一個點——前綴含第二個 . 即壞)。 */
|
|
49
|
+
function escapeReLiteral(s: string): string {
|
|
50
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 前綴家族 → 密文形 regex(單一組裝點;cipherRe 死碼退場)。
|
|
54
|
+
* never-match 守衛(t_580f9c54 N-7,opt-in 鐵律面;run 574 r2 實測校正):空字首組態會
|
|
55
|
+
* 組裝出 accept-all 形——`[]` 與 `['']` 組裝出 byte-identical `^()[A-Za-z0-9+/]+={0,2}$`
|
|
56
|
+
* (join('|') 同為空串),無點純 b64 串(≤200)在舊碼 validWrappedKey/pickKeyPackage 放行=真洞;
|
|
57
|
+
* 回恆不匹配 regex(`/^(?=a)b/` 正 lookahead 錨死永假)=未配置形整面拒絕,與 opt-in 律同向。
|
|
58
|
+
* (probe 實證 /tmp/t580f9c54-r2/n7-corrected-probe.mjs:`[]` 對純 b64 樣本 NON-NULL——
|
|
59
|
+
* 「charset 恆假自帶防線」是錯誤敘事;帶點前綴向量舊碼本就 null=帳面無承載。) */
|
|
60
|
+
function cipherPrefixRe(cipherPrefixes: readonly string[]): RegExp {
|
|
61
|
+
const usable = cipherPrefixes.filter(p => typeof p === 'string' && p.length > 0);
|
|
62
|
+
if (usable.length === 0) return /^(?=a)b/; // never-match(lookahead 錨死矛盾面)——空字串/任意串皆不匹配
|
|
63
|
+
const escaped = usable.map(p => escapeReLiteral(p));
|
|
41
64
|
return new RegExp(`^(${escaped.join('|')})[A-Za-z0-9+/]+={0,2}$`);
|
|
42
65
|
}
|
|
43
66
|
|
|
44
67
|
/**
|
|
45
68
|
* 入庫前校正:前綴密文原樣入庫;超限密文/編碼丟棄(截斷必壞)、明文截到上限照收。
|
|
46
|
-
*
|
|
69
|
+
* cipherMax(UTF-16 單位)同為明文截斷帽:明文相容層無 opt-out——明文一律照收、
|
|
70
|
+
* 截到 cipherMax 入庫(r2 MINOR-5:JSDoc 幽靈參數 plainMax 摘除)。
|
|
47
71
|
*/
|
|
48
72
|
export function makeInboundCipher(c: CipherFormats) {
|
|
49
|
-
const RE =
|
|
73
|
+
const RE = cipherPrefixRe(c.cipherPrefixes);
|
|
50
74
|
return function inboundCipher(raw: unknown): string | null {
|
|
51
75
|
if (typeof raw !== 'string') return null;
|
|
52
76
|
const text = raw.trim();
|
|
@@ -65,13 +89,17 @@ export function makeInboundCipher(c: CipherFormats) {
|
|
|
65
89
|
|
|
66
90
|
export function isCipherFor(text: string | null | undefined, c: CipherFormats): boolean {
|
|
67
91
|
if (typeof text !== 'string') return false;
|
|
68
|
-
|
|
92
|
+
// 空字首組態同面守衛(t_580f9c54 N-7 範圍增補,run 574 r2):'' 態 startsWith('') 恆真=
|
|
93
|
+
// 整體分類面 accept-all(cipherPrefixRe never-match 守衛不覆蓋此直讀 tuple 面——本函式是
|
|
94
|
+
// 另一組裝點)。守衛形=跳過空字首的 some() 單點承載:空集恆 false(與 never-match 家族拒絕、
|
|
95
|
+
// opt-in 未配置即拒鐵律同向)、混合形只摘空槽(['','jr1b.']=jr1b. 家族面照活)——
|
|
96
|
+
// 「空集 if 檢+保留 startsWith(tuple) 原式」兩行形對混合形不設防(空槽仍經 startsWith('') 放行)。
|
|
97
|
+
return c.cipherPrefixes.some(p => typeof p === 'string' && p.length > 0 && text.startsWith(p));
|
|
69
98
|
}
|
|
70
99
|
|
|
71
100
|
export function validWrappedKey(v: unknown, wrapPrefix: string | string[]): string | null {
|
|
72
101
|
const prefixes = Array.isArray(wrapPrefix) ? wrapPrefix : [wrapPrefix];
|
|
73
|
-
const
|
|
74
|
-
const re = new RegExp(`^(${escaped.join('|')})[A-Za-z0-9+/]+={0,2}$`);
|
|
102
|
+
const re = cipherPrefixRe(prefixes);
|
|
75
103
|
return typeof v === 'string' && v.length <= 200 && re.test(v) ? v : null;
|
|
76
104
|
}
|
|
77
105
|
|
|
@@ -100,7 +128,7 @@ export interface KeyPackage {
|
|
|
100
128
|
export interface AuthStore {
|
|
101
129
|
/** 以 PH2 查身份(PH2 UNIQUE;tacet:隨機 account_id 在此建立)。 */
|
|
102
130
|
findByIdentityQuery(ph2: string): Promise<AuthRow | null>;
|
|
103
|
-
/** 建立(identity
|
|
131
|
+
/** 建立(identity 生成由各 fork 自決——見檔頭「呼叫端職責」;PH2 UNIQUE 衝突亦是 store 責任)。 */
|
|
104
132
|
createUser(ph2: string): Promise<string>;
|
|
105
133
|
getByUserKey(key: string): Promise<AuthRow | null>;
|
|
106
134
|
getByRecHash(recHash: string): Promise<AuthRow | null>;
|
package/src/server/cors.ts
CHANGED
|
@@ -5,6 +5,10 @@ export const CORS_HEADERS = {
|
|
|
5
5
|
'Access-Control-Allow-Origin': '*',
|
|
6
6
|
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
7
7
|
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
|
8
|
+
// preflight 快取(卡C t_7361b68c:缺 Max-Age=瀏覽器對每個跨域請求重跑 OPTIONS preflight)。
|
|
9
|
+
// 86400 落業界 600-86400 慣例上緣;瀏覽器端自行 clamp(Chromium 2h/WebKit 10m、Firefox
|
|
10
|
+
// 照走)——快取命中不影響伺服器語意;消費端 inline OPTIONS 分流照 corsResponse 放行。
|
|
11
|
+
'Access-Control-Max-Age': '86400',
|
|
8
12
|
};
|
|
9
13
|
|
|
10
14
|
export function corsResponse(res: Response): Response {
|
package/src/server/ratelimit.ts
CHANGED
|
@@ -1,17 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ratelimit.ts — per-IP
|
|
2
|
+
* ratelimit.ts — per-IP fixed-window 限流(D1 計數,跨 isolate 有效;單句 UPSERT…RETURNING)。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* -
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* 設計(2026-10-04 t_b91ed07f,外部審查 MAJOR:原 4 往返條件寫入鏈 1 往返收口):
|
|
5
|
+
* - 限流語意是 fixed window:以 window_start 為窗錨,窗過期整窗重置。
|
|
6
|
+
* (原檔頭「滑動視窗」措辭錯誤——c7c8701 只修 README 未修本檔;本卡一併收口。)
|
|
7
|
+
* - 單條 UPSERT…RETURNING 原子完成「計數+窗重置+讀回放行判準」:
|
|
8
|
+
* 同 IP 併發首發不再有 INSERT/輸家競態(同語句同鍵衝突時第二發原子吃到
|
|
9
|
+
* count+1;D1 實測 RETURNING 逐發回真值——同 IP 併發首發不 undercount)。
|
|
10
|
+
* - fail-open 裁定不變(2026-09-06):限流儲存故障時放行(保護面不可反噬主功能),
|
|
11
|
+
* 只擋明確超額。RETURNING 異形回應(results 空/alias 斷裂)同向 fail-open
|
|
12
|
+
* 並 console.error 照實回報(r2 MINOR-3:異形不反向成 deny)。
|
|
9
13
|
*
|
|
10
|
-
*
|
|
14
|
+
* 放行判準:RETURNING 的 count <= max(窗口內超額後的每一發都回實際 count,
|
|
15
|
+
* 由呼叫端以 count <= max 拒絕——計數不封頂,超額仍逐發累計)。
|
|
16
|
+
*
|
|
17
|
+
* 呼叫端須自建表(無 GC 機制:舊 IP 列不清,表隨活躍 IP 數緩慢增長——
|
|
18
|
+
* 每列 ~40 bytes,量級可忽略;如需回收見下方 GC 註記):
|
|
11
19
|
* CREATE TABLE IF NOT EXISTS {table} (ip TEXT PRIMARY KEY, window_start INTEGER, count INTEGER);
|
|
20
|
+
* 可選 GC(未內建):定期 DELETE WHERE window_start < {now} - windowMs。
|
|
12
21
|
*/
|
|
13
22
|
|
|
14
|
-
import type { Env } from './env';
|
|
23
|
+
import type { Env } from './env.ts';
|
|
15
24
|
|
|
16
25
|
export interface RateWindow {
|
|
17
26
|
table: string;
|
|
@@ -19,35 +28,42 @@ export interface RateWindow {
|
|
|
19
28
|
max: number;
|
|
20
29
|
}
|
|
21
30
|
|
|
31
|
+
/**
|
|
32
|
+
* 表名只允許 \w 字元(字母/數字/底線):表名直接拼進 SQL 字串,禁注入符號。
|
|
33
|
+
* 違例 throw——配置錯誤在開發期炸開,不靜默。
|
|
34
|
+
*/
|
|
35
|
+
function assertSafeTable(table: string): void {
|
|
36
|
+
if (!/^[\w]+$/.test(table)) throw new Error(`ERR_RATE_TABLE_NAME: ${table}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** @internal(0.1.6 公開面走 index.ts checkRate)。UPSERT…RETURNING 單句({table} 佔位由呼叫端替換;D1 實測 RETURNING 列經 results 回帶)。 */
|
|
40
|
+
export const SQL_RATE_BUMP = `INSERT INTO {table} (ip, window_start, count) VALUES (?1, ?2, 1)
|
|
41
|
+
ON CONFLICT(ip) DO UPDATE SET
|
|
42
|
+
count = CASE WHEN window_start <= ?3 THEN 1 ELSE count + 1 END,
|
|
43
|
+
window_start = CASE WHEN window_start <= ?3 THEN ?2 ELSE window_start END
|
|
44
|
+
RETURNING count AS n`;
|
|
45
|
+
|
|
46
|
+
/** @internal(0.1.6 公開面走 index.ts checkRate)。放行判準單一真相:窗口內超額後照實回實際 count,呼叫端以 count <= max 拒絕。 */
|
|
47
|
+
export function isRateAllowed(count: number, max: number): boolean {
|
|
48
|
+
return count <= max;
|
|
49
|
+
}
|
|
50
|
+
|
|
22
51
|
async function bumpRate(env: Env, w: RateWindow, ip: string): Promise<boolean> {
|
|
52
|
+
assertSafeTable(w.table);
|
|
23
53
|
const now = Date.now();
|
|
24
54
|
try {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if (
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
).bind(ip, now).run();
|
|
38
|
-
if ((ins.meta?.changes ?? 0) > 0) return true;
|
|
39
|
-
const loser = await env.DB.prepare(
|
|
40
|
-
`UPDATE ${w.table} SET count = count + 1
|
|
41
|
-
WHERE ip = ? AND window_start > ? AND count < ?`
|
|
42
|
-
).bind(ip, now - w.windowMs, w.max).run();
|
|
43
|
-
if ((loser.meta?.changes ?? 0) > 0) return true;
|
|
44
|
-
// 列已存在且未超額:窗口過期 → 重置;窗口內但剛才 count>=max 沒吃到 → 拒絕
|
|
45
|
-
const reset = await env.DB.prepare(
|
|
46
|
-
`UPDATE ${w.table} SET window_start = ?, count = 1
|
|
47
|
-
WHERE ip = ? AND window_start <= ?`
|
|
48
|
-
).bind(now, ip, now - w.windowMs).run();
|
|
49
|
-
if ((reset.meta?.changes ?? 0) > 0) return true;
|
|
50
|
-
return false;
|
|
55
|
+
const { results } = await env.DB.prepare(
|
|
56
|
+
SQL_RATE_BUMP.replaceAll('{table}', w.table)
|
|
57
|
+
).bind(ip, now, now - w.windowMs).run();
|
|
58
|
+
const first = results[0] as { n?: unknown } | undefined;
|
|
59
|
+
const n = first?.n;
|
|
60
|
+
if (typeof n !== 'number' || !Number.isFinite(n)) {
|
|
61
|
+
// 異形回應(results 空/alias 斷裂)與儲存故障同向 fail-open(2026-09-06 裁定:
|
|
62
|
+
// 缺陷偏向放行,不反向成 deny);真 D1 不產生(探針實證)——守衛照實回報。
|
|
63
|
+
console.error(`[ratelimit] ${w.table} unexpected RETURNING shape:`, first);
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
return isRateAllowed(n, w.max);
|
|
51
67
|
} catch (e) {
|
|
52
68
|
// fail-open 裁定(2026-09-06):儲存故障時放行,只擋明確超額
|
|
53
69
|
console.error(`[ratelimit] ${w.table} update failed:`, e);
|