@tacet-ink/journal-core 0.3.1 → 0.3.2
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 +6 -6
- package/dist/client/argon2.d.ts +13 -2
- package/dist/client/argon2.js +9 -3
- package/dist/client/note-crypto.d.ts +2 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/server/env.d.ts +6 -0
- package/dist/server/ratelimit.d.ts +14 -0
- package/dist/server/ratelimit.js +54 -1
- package/docs/format-spec.md +5 -5
- package/docs/vectors/bip39.json +1 -1
- package/docs/vectors/families.json +77 -77
- package/docs/vectors/kat.json +1 -1
- package/package.json +3 -2
- package/scripts/verify-core-crypto.ts +147 -12
- package/src/client/argon2.ts +10 -3
- package/src/client/note-crypto.ts +3 -1
- package/src/index.ts +1 -1
- package/src/server/env.ts +7 -0
- package/src/server/ratelimit.ts +48 -1
|
@@ -90,7 +90,7 @@ import {
|
|
|
90
90
|
AUTH_RATE,
|
|
91
91
|
type CipherFormats,
|
|
92
92
|
} from '../src/server/auth.ts';
|
|
93
|
-
import { checkRate, isRateAllowed, SQL_RATE_BUMP, type RateWindow } from '../src/server/ratelimit.ts';
|
|
93
|
+
import { checkRate, isRateAllowed, normalizeRateIp, SQL_RATE_BUMP, type RateWindow } from '../src/server/ratelimit.ts';
|
|
94
94
|
import { generateSessionToken, timingSafeEq } from '../src/server/hash.ts';
|
|
95
95
|
import { CORS_HEADERS, corsResponse } from '../src/server/cors.ts';
|
|
96
96
|
import {
|
|
@@ -157,7 +157,7 @@ async function A(name: string, cond: boolean | Promise<boolean>, detail = ''): P
|
|
|
157
157
|
// 帳面契約:--only 態總帳顯形「本輪 X/選段帳 N(N 案非本輪載)」+OK 標記帶 (--ONLY …) 尾碼;skip 段帳面恆列「未跑非通過」;[24]/[25] runner 自證段隨每輪在場。
|
|
158
158
|
// 段標記 helper(secOpen):全跑態輸出位元組恆等原 console.log('\n[N] …');--only 態僅選中段輸出段頭。
|
|
159
159
|
// 毒化矩陣內層 rerun(execFileSync+POISON_GATE_INNER=1)不傳遞 --only=內層恆全帳(POISON_GATE_INNER 語意不變)。
|
|
160
|
-
// 段帳=執行帳(healthy-repo 全跑 runtime 真值,2026-10-
|
|
160
|
+
// 段帳=執行帳(healthy-repo 全跑 runtime 真值,2026-10-11 實測 521——[12] 限流鍵 /64 正規化 +14+IPv4-mapped 面 +1);帳面語意契約(逐段「案 X/帳」
|
|
161
161
|
// 對 secRan 執行值顯形):執行 < 帳 的段=本環境條件態未行使(非 skip 態),--only 期望算術對齊本帳。
|
|
162
162
|
// [23] 執行 18 帳 21=[23]④ lookup 未配置(0.2.3 單查恆等)/[23]⑤ fail-open(表缺席)兩情境
|
|
163
163
|
// 在本機樹由 [8](登入憑證 Argon2id 派生)內登入 lane 的訊號面真行使(try/catch 異常面)——車道分流非帳面虛報。
|
|
@@ -166,7 +166,7 @@ async function A(name: string, cond: boolean | Promise<boolean>, detail = ''): P
|
|
|
166
166
|
// for (const [k] of secRan) if (!secCases[k] && k !== 22) FAIL(負向斷言本體在 [25] 段
|
|
167
167
|
//——secCases 全摘即 RED);反向缺席段=帳面誠實「案 0/N · skip」。
|
|
168
168
|
// secCases 載體三群:[24]/[25] self-account+--only 段存在面+teeth(段帳由 secRan 執行自記)。
|
|
169
|
-
const secCases: Record<number, number> = { 1: 3, 2: 7, 3: 9, 4: 4, 5: 3, 6: 20, 7: 27, 8: 8, 9: 22, 10: 5, 11: 41, 12:
|
|
169
|
+
const secCases: Record<number, number> = { 1: 3, 2: 7, 3: 9, 4: 4, 5: 3, 6: 20, 7: 27, 8: 8, 9: 22, 10: 5, 11: 41, 12: 65, 13: 39, 14: 18, 15: 59, 16: 15, 17: 14, 18: 39, 19: 17, 20: 47, 21: 10, 23: 18, 24: 5, 25: 4, 26: 8, 27: 14 };
|
|
170
170
|
function parseOnly(spec: string): { err: string | null; set: number[] } {
|
|
171
171
|
const set = new Set<number>();
|
|
172
172
|
const specStr = spec.trim();
|
|
@@ -773,7 +773,7 @@ secOpen(12, '[12] 伺服器端原語(auth/ratelimit/hash)+ 本機鎖定(
|
|
|
773
773
|
// UPSERT…RETURNING 於 wrangler 4.129.0 workerd 逐發回真值;併發首發兩發計數 1→2)。
|
|
774
774
|
type RateRow = { ip: string; window_start: number; count: number };
|
|
775
775
|
const RATE: RateWindow = { table: 'login_rate', windowMs: 60_000, max: 3 };
|
|
776
|
-
const makeRateDb = (): {
|
|
776
|
+
const makeRateDb = (cr: typeof checkRate = checkRate): {
|
|
777
777
|
db: {
|
|
778
778
|
exec: (s: string) => void;
|
|
779
779
|
prepare: (s: string) => { all: (...p: unknown[]) => Array<Record<string, unknown>>; get: (...p: unknown[]) => Record<string, unknown> | undefined };
|
|
@@ -798,7 +798,7 @@ const makeRateDb = (): {
|
|
|
798
798
|
runRaw: (sql) => db.exec(sql),
|
|
799
799
|
// 真 checkRate 直驅:?1/?2/?3 有名參數語意在 node:sqlite 與 workerd 一致(端對端探針實證,見卡 comments)。
|
|
800
800
|
check: (ip) =>
|
|
801
|
-
|
|
801
|
+
cr(
|
|
802
802
|
{
|
|
803
803
|
DB: {
|
|
804
804
|
prepare: (sql: string) => ({
|
|
@@ -871,6 +871,120 @@ await A('rate:RETURNING 異形回應守衛 fail-open(results 空/alias 斷
|
|
|
871
871
|
})());
|
|
872
872
|
await A('rate:SQL_RATE_BUMP 常數形(UPSERT…RETURNING…count AS n;{table} 佔位)',
|
|
873
873
|
/^INSERT INTO \{table\} \(ip, window_start, count\) VALUES \(\?1, \?2, 1\)\n\s+ON CONFLICT\(ip\) DO UPDATE SET\n\s+count\s+= CASE WHEN window_start <= \?3 THEN 1 ELSE count \+ 1 END,\n\s+window_start = CASE WHEN window_start <= \?3 THEN \?2 ELSE window_start END\n\s+RETURNING count AS n$/.test(SQL_RATE_BUMP));
|
|
874
|
+
// 限流鍵正規化(t_b30cbc5f):checkRate 入口 normalizeRateIp——IPv6 聚合至 /64(尾段漂移不再每發新鍵)。
|
|
875
|
+
// v6 面謂詞以模組參數化:同一謂詞本體先對真模組(綠)、再對拷貝樹毒化模組(IPv6 聚合分支摘除)須逐案真翻紅。
|
|
876
|
+
type RateMod12 = { checkRate: typeof checkRate; normalizeRateIp: typeof normalizeRateIp };
|
|
877
|
+
const rateReal12: RateMod12 = { checkRate, normalizeRateIp };
|
|
878
|
+
const v6Cases12: Array<[string, (m: RateMod12) => Promise<boolean>]> = [
|
|
879
|
+
['rate:IPv6 同 /64 尾段漂移兩地址(2001:db8:5678::1/2001:db8:5678:0:ffff:ffff:ffff:9)經真 checkRate 共用鍵 2001:db8:5678::/64 計數 2(裸地址不入表)', async (m) => {
|
|
880
|
+
const r = makeRateDb(m.checkRate);
|
|
881
|
+
await r.check('2001:db8:5678::1'); await r.check('2001:db8:5678:0:ffff:ffff:ffff:9');
|
|
882
|
+
return r.row('2001:db8:5678::/64')?.count === 2 && r.row('2001:db8:5678::1') === undefined;
|
|
883
|
+
}],
|
|
884
|
+
['rate:IPv6 同 /64 輪換四個介面 ID → 三發 pass、第四發 reject(尾段漂移不再繞過 max)', async (m) => {
|
|
885
|
+
const r = makeRateDb(m.checkRate);
|
|
886
|
+
const seq: boolean[] = [];
|
|
887
|
+
for (const ip of ['2001:db8:77::1', '2001:db8:77::2', '2001:db8:77:0:a:b:c:d', '2001:db8:77::ffff']) seq.push(await r.check(ip));
|
|
888
|
+
return seq.join() === 'true,true,true,false';
|
|
889
|
+
}],
|
|
890
|
+
['rate:IPv6 跨 /64 分鍵(2001:db8:5678::1/2001:db8:9999::1 → 兩 /64 鍵各計 1)', async (m) => {
|
|
891
|
+
const r = makeRateDb(m.checkRate);
|
|
892
|
+
await r.check('2001:db8:5678::1'); await r.check('2001:db8:9999::1');
|
|
893
|
+
return r.row('2001:db8:5678::/64')?.count === 1 && r.row('2001:db8:9999::/64')?.count === 1;
|
|
894
|
+
}],
|
|
895
|
+
['rate:IPv6 同 /48 異 /64 分鍵(第 4 hextet 屬前綴:2001:db8:5678::1 → 2001:db8:5678::/64、2001:db8:5678:ffff::9 → 2001:db8:5678:ffff::/64 各計 1——聚合粒度恰 /64 非 /48)', async (m) => {
|
|
896
|
+
const r = makeRateDb(m.checkRate);
|
|
897
|
+
await r.check('2001:db8:5678::1'); await r.check('2001:db8:5678:ffff::9');
|
|
898
|
+
return r.row('2001:db8:5678::/64')?.count === 1 && r.row('2001:db8:5678:ffff::/64')?.count === 1;
|
|
899
|
+
}],
|
|
900
|
+
['normalizeRateIp RFC 5952 形:2001:0DB8:0000:000A::1 → 2001:db8:0:a::/64(前導零摘、小寫、內部單零組不壓縮、尾零串 ::);2001:db8:a::1 → 2001:db8:a::/64 異鍵(零組不吞=前綴不碰撞)', async (m) =>
|
|
901
|
+
m.normalizeRateIp('2001:0DB8:0000:000A::1') === '2001:db8:0:a::/64' && m.normalizeRateIp('2001:db8:a::1') === '2001:db8:a::/64'],
|
|
902
|
+
['normalizeRateIp 邊界:::1/0000:0000:0000:0000:1:2:3:4(全零前綴)/:: → 恆 ::/64(IPv4-mapped 位址另行回點分 IPv4,見下案)', async (m) =>
|
|
903
|
+
['::1', '0000:0000:0000:0000:1:2:3:4', '::'].every((ip) => m.normalizeRateIp(ip) === '::/64')],
|
|
904
|
+
['rate:normalizeRateIp IPv4-mapped 回點分 IPv4 鍵(/32 契約):::ffff:192.0.2.1 → 192.0.2.1;hex 尾組同主機 twin ::ffff:c000:201 同鍵 192.0.2.1(mapped 裸形絕不入表);兩台相異 mapped 主機經真 checkRate 各自鍵各計 1(不共 ::/64 桶)', async (m) => {
|
|
905
|
+
const r = makeRateDb(m.checkRate);
|
|
906
|
+
await r.check('::ffff:192.0.2.1'); await r.check('::ffff:c000:201'); await r.check('::ffff:192.0.2.2');
|
|
907
|
+
return m.normalizeRateIp('::ffff:192.0.2.1') === '192.0.2.1'
|
|
908
|
+
&& m.normalizeRateIp('::ffff:c000:201') === '192.0.2.1'
|
|
909
|
+
&& r.row('192.0.2.1')?.count === 2
|
|
910
|
+
&& r.row('192.0.2.2')?.count === 1
|
|
911
|
+
&& r.row('::ffff:192.0.2.1') === undefined && r.row('::ffff:c000:201') === undefined
|
|
912
|
+
&& r.row('::/64') === undefined;
|
|
913
|
+
}],
|
|
914
|
+
['normalizeRateIp zone id 剝除+輸出小寫+嵌入 IPv4 末組:fe80::1%eth0 → fe80::/64;FE80::ABCD → fe80::/64;1:2:3:4:5:6:1.2.3.4 → 1:2:3:4::/64', async (m) =>
|
|
915
|
+
m.normalizeRateIp('fe80::1%eth0') === 'fe80::/64' && m.normalizeRateIp('FE80::ABCD') === 'fe80::/64'
|
|
916
|
+
&& m.normalizeRateIp('1:2:3:4:5:6:1.2.3.4') === '1:2:3:4::/64'],
|
|
917
|
+
];
|
|
918
|
+
const plainCases12: Array<[string, (m: RateMod12) => Promise<boolean>]> = [
|
|
919
|
+
['rate:IPv4 原樣鍵(192.0.2.1 經真 checkRate 鍵面=self、計數 1)', async (m) => {
|
|
920
|
+
const r = makeRateDb(m.checkRate);
|
|
921
|
+
await r.check('192.0.2.1');
|
|
922
|
+
return m.normalizeRateIp('192.0.2.1') === '192.0.2.1' && r.row('192.0.2.1')?.count === 1;
|
|
923
|
+
}],
|
|
924
|
+
["rate:'unknown'/空串原樣鍵(經真 checkRate 照樣進表=可見面,不靜默丟鍵)", async (m) => {
|
|
925
|
+
const r = makeRateDb(m.checkRate);
|
|
926
|
+
await r.check('unknown'); await r.check('');
|
|
927
|
+
return m.normalizeRateIp('unknown') === 'unknown' && m.normalizeRateIp('') === ''
|
|
928
|
+
&& r.row('unknown')?.count === 1 && r.row('')?.count === 1;
|
|
929
|
+
}],
|
|
930
|
+
['normalizeRateIp 無法解析形原樣且零 throw(IPv4 五段/IPv6 單段 2001/缺組/雙 ::/:::/非 hex/9 組/:: 外 8 組/嵌入 IPv4 越界/方括號/前導空白/非字串)', async (m) => {
|
|
931
|
+
const bad = ['1.2.3.4.5', '2001', '2001:db8', '1::2::3', ':::', '2001:db8::g', '1:2:3:4:5:6:7:8:9', '1:2:3:4:5:6:7:8::', '::ffff:999.0.0.1', '[2001:db8::1]', ' 2001:db8::1'];
|
|
932
|
+
try {
|
|
933
|
+
return bad.every((ip) => m.normalizeRateIp(ip) === ip)
|
|
934
|
+
&& (m.normalizeRateIp(undefined as unknown as string) as unknown) === undefined;
|
|
935
|
+
} catch { return false; }
|
|
936
|
+
}],
|
|
937
|
+
];
|
|
938
|
+
for (const [name, pred] of [...v6Cases12, ...plainCases12]) await A(name, await pred(rateReal12));
|
|
939
|
+
await A('index barrel 匯出 normalizeRateIp(=ratelimit 子路徑同一函式——單一真相)',
|
|
940
|
+
(await import('../src/index.ts')).normalizeRateIp === normalizeRateIp);
|
|
941
|
+
// 毒化(拷貝樹 in-gate,[23] 母型):IPv6 聚合分支入口 `if (!g) return ip;` → `return ip;`(IPv6 一律原樣成鍵)
|
|
942
|
+
// → v6 面謂詞逐案真翻紅(false 非 crash)、非 v6 面照綠(毒面恰此面翻)。毒化跑在 /tmp 拷貝=本 repo 樹零接觸、樹出生即棄。
|
|
943
|
+
{
|
|
944
|
+
const gb12 = (id: string): unknown =>
|
|
945
|
+
(globalThis as unknown as { process?: { getBuiltinModule?: (i: string) => unknown } }).process?.getBuiltinModule?.(id);
|
|
946
|
+
const fs12 = gb12('node:fs') as {
|
|
947
|
+
mkdtempSync: (p: string) => string;
|
|
948
|
+
writeFileSync: (p: string, s: string, e: string) => void;
|
|
949
|
+
rmSync: (p: string, o?: unknown) => void;
|
|
950
|
+
existsSync: (p: string) => boolean;
|
|
951
|
+
} | undefined;
|
|
952
|
+
const os12 = gb12('node:os') as { tmpdir: () => string } | undefined;
|
|
953
|
+
const path12 = gb12('node:path') as { join: (...p: string[]) => string } | undefined;
|
|
954
|
+
const url12 = gb12('node:url') as { pathToFileURL: (p: string) => { href: string } } | undefined;
|
|
955
|
+
const needle12 = ' if (!g) return ip;\n';
|
|
956
|
+
const src12 = await srcOf('../src/server/ratelimit.ts');
|
|
957
|
+
const cnt12 = src12.split(needle12).length - 1;
|
|
958
|
+
await A('rate 毒化錨恰 1(IPv6 聚合分支入口單點承載)', cnt12 === 1, 'cnt=' + cnt12);
|
|
959
|
+
if (!fs12 || !os12 || !path12 || !url12) {
|
|
960
|
+
await A('rate 毒化面(原語缺席環境)=顯性 RED 行,毒化證據缺席顯形非 silent true', false, 'node fs/os/path/url 原語缺席');
|
|
961
|
+
} else if (cnt12 === 1) {
|
|
962
|
+
const tree12 = fs12.mkdtempSync(path12.join(os12.tmpdir(), 't12-ratekey-'));
|
|
963
|
+
const flipped12: string[] = [];
|
|
964
|
+
const held12: string[] = [];
|
|
965
|
+
let plainOk12 = false;
|
|
966
|
+
try {
|
|
967
|
+
fs12.writeFileSync(path12.join(tree12, 'ratelimit.ts'), src12.replace(needle12, ' return ip;\n'), 'utf8');
|
|
968
|
+
fs12.writeFileSync(path12.join(tree12, 'env.ts'), await srcOf('../src/server/env.ts'), 'utf8');
|
|
969
|
+
const modP12 = (await import(url12.pathToFileURL(path12.join(tree12, 'ratelimit.ts')).href)) as RateMod12;
|
|
970
|
+
for (const [name, pred] of v6Cases12) {
|
|
971
|
+
let res: string;
|
|
972
|
+
try { res = String(await pred(modP12)); } catch (e) { res = 'crash: ' + String((e as Error).message).slice(0, 80); }
|
|
973
|
+
(res === 'false' ? flipped12 : held12).push(name.slice(0, 40) + ' → ' + res);
|
|
974
|
+
}
|
|
975
|
+
plainOk12 = (await Promise.all(plainCases12.map(([, pred]) => pred(modP12)))).every((x) => x);
|
|
976
|
+
} catch (e) {
|
|
977
|
+
held12.push('poison import crash: ' + String((e as Error).message).slice(0, 120));
|
|
978
|
+
} finally {
|
|
979
|
+
fs12.rmSync(tree12, { recursive: true, force: true });
|
|
980
|
+
}
|
|
981
|
+
await A('rate 毒化(IPv6 聚合分支摘除)→ v6 面謂詞逐案真翻紅(' + v6Cases12.length + ' 案全 false、零 crash)',
|
|
982
|
+
held12.length === 0 && flipped12.length === v6Cases12.length,
|
|
983
|
+
'flipped=' + flipped12.length + '/' + v6Cases12.length + (held12.length ? ';未翻:' + held12.join('|') : ''));
|
|
984
|
+
await A('rate 毒化面精準:非 v6 面照綠(IPv4/unknown/無效形——毒面恰 IPv6 聚合面翻)+毒化樹出生即棄(本 repo 樹零接觸)',
|
|
985
|
+
plainOk12 && !fs12.existsSync(tree12));
|
|
986
|
+
}
|
|
987
|
+
}
|
|
874
988
|
await A('AUTH_RATE 契約(60s 窗 max 10)', AUTH_RATE.windowMs === 60_000 && AUTH_RATE.max === 10);
|
|
875
989
|
const sessA = generateSessionToken();
|
|
876
990
|
const sessB = generateSessionToken();
|
|
@@ -2810,7 +2924,7 @@ secOpen(23, '[23] loginRouteCore ladder 查表守衛(外審 #8 幽靈帳——
|
|
|
2810
2924
|
// 單向契約:源碼 → scripts/generate-vectors.ts → JSON(凍結證據,commit 入 repo)→ 本段只檢存在+shape。
|
|
2811
2925
|
// 本段不依賴產生器、不重算 KDF(向量含隨機 iv/鹽=重算即漂移);BIP39 是確定性轉寫,逐組對照現行原語。
|
|
2812
2926
|
// teeth:payload 位元組長度=b64 解碼真值、fields 拼接=payload 逐位元組、布局長度=規格表列、
|
|
2813
|
-
//
|
|
2927
|
+
// 欄位名限定形逐一在規格對應節、凍結 blob 逐字=本閘 KAT 字面([14]/[17]/[20])、RFC 9106 期望值+參數=argon2.ts 常數。
|
|
2814
2928
|
secOpen(26, '[26] 格式規格+測試向量(docs/format-spec.md+docs/vectors 存在性+shape)'); if (secEnter(26)) {
|
|
2815
2929
|
{
|
|
2816
2930
|
const readJson26 = async (rel: string): Promise<Record<string, unknown> | null> => {
|
|
@@ -2851,16 +2965,37 @@ secOpen(26, '[26] 格式規格+測試向量(docs/format-spec.md+docs/vecto
|
|
|
2851
2965
|
const head = v.layout === 'salt-embedded' ? v.fields[0]?.name === 'pinSalt' && v.fields[0]?.len === 16 : v.fields[0]?.name === 'iv';
|
|
2852
2966
|
return want === v.payload_len && head && specRows26.some((l) => l.startsWith('| `' + v.prefix + '`') && l.includes('| ' + v.layout + ' | ' + lenCol + ' |'));
|
|
2853
2967
|
}));
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
|
|
2857
|
-
|
|
2968
|
+
// 欄位名限定形(通用字 name/len/hex/source… 在規格檔隨處可見=裸字檢查無咬力):頂層欄=§7 頭表/§7.1/§7.2 對應節
|
|
2969
|
+
// 的表列 `| \`欄\` |`;巢狀欄=限定形 `父[].欄`/`rfc9106.欄` 逐字在規格檔。欄名集取自向量檔實際鍵(新增欄未入規格=紅)。
|
|
2970
|
+
const sec26 = (from: string, to: string): string => { const i = spec26.indexOf(from); const j = to ? spec26.indexOf(to, i + 1) : spec26.length; return i < 0 || j < 0 ? '' : spec26.slice(i, j); };
|
|
2971
|
+
const s7h26 = sec26('## 7. ', '### 7.1 '), s71_26 = sec26('### 7.1 ', '### 7.2 '), s72_26 = sec26('### 7.2 ', '### 7.3 '), s73_26 = sec26('### 7.3 ', '');
|
|
2972
|
+
const row26 = (sec: string, k: string): boolean => sec.includes('\n| `' + k + '` |');
|
|
2973
|
+
const keysOf26 = (xs: unknown): string[] => [...new Set((Array.isArray(xs) ? xs : []).flatMap((x) => (x && typeof x === 'object' ? Object.keys(x) : [])))];
|
|
2974
|
+
const fieldKeys26 = keysOf26(vecs26.flatMap((v) => (Array.isArray(v.fields) ? v.fields : [])));
|
|
2975
|
+
const rfcKeys26 = Object.keys((kat26?.rfc9106 ?? {}) as object);
|
|
2976
|
+
const frozenKeys26 = keysOf26(kat26?.frozen_blobs), credKeys26 = keysOf26(kat26?.credentials), sampleKeys26 = keysOf26(bip26?.samples);
|
|
2977
|
+
// 反空轉下限:實際鍵集須涵蓋閘面讀取的欄(鍵集取自資料=資料缺欄時下限擋住空集合真值)
|
|
2978
|
+
const floor26 = (ks: string[], need: string[]): boolean => need.every((k) => ks.includes(k));
|
|
2979
|
+
await A('[26] 欄位名對規格(限定形):頂層欄=§7 頭表/§7.1 vectors 表/§7.2 kat 表逐列;巢狀欄逐一以 `fields[].欄`/`rfc9106.欄`/`frozen_blobs[].欄`/`credentials[].欄`/`samples[].欄` 出現在對應節(欄名集取自向量檔實鍵+閘讀欄下限)',
|
|
2980
|
+
spec26.length > 0 && !!kat26 && !!bip26
|
|
2981
|
+
&& HEAD26.every((k) => row26(s7h26, k)) && keysOf26(vecs26).every((k) => row26(s71_26, k)) && VKEYS26.every((k) => row26(s71_26, k))
|
|
2982
|
+
&& ['rfc9106', 'frozen_blobs', 'credentials'].every((k) => k in kat26! && row26(s72_26, k)) && 'samples' in bip26! && s73_26.includes('`samples`')
|
|
2983
|
+
&& floor26(fieldKeys26, ['name', 'offset', 'len', 'hex']) && fieldKeys26.every((k) => s71_26.includes('`fields[].' + k + '`'))
|
|
2984
|
+
&& floor26(rfcKeys26, ['memory_kib', 'iterations', 'parallelism', 'tag_len', 'password_hex', 'salt_hex', 'expected_tag_hex']) && rfcKeys26.every((k) => s72_26.includes('`rfc9106.' + k + '`'))
|
|
2985
|
+
&& floor26(frozenKeys26, ['prefix', 'wire', 'payload_len', 'expected_note_key_hex', 'source']) && frozenKeys26.every((k) => s72_26.includes('`frozen_blobs[].' + k + '`'))
|
|
2986
|
+
&& floor26(credKeys26, ['kind', 'expected_hex']) && credKeys26.every((k) => s72_26.includes('`credentials[].' + k + '`'))
|
|
2987
|
+
&& floor26(sampleKeys26, ['entropy_hex', 'checksum_hex', 'words', 'source']) && sampleKeys26.every((k) => s73_26.includes('`samples[].' + k + '`')));
|
|
2858
2988
|
const gate26 = await srcOf('./verify-core-crypto.ts');
|
|
2859
2989
|
const frozen26 = (Array.isArray(kat26?.frozen_blobs) ? kat26!.frozen_blobs : []) as { prefix: string; wire: string; payload_len: number; expected_note_key_hex: string; source: string }[];
|
|
2860
2990
|
const rfc26 = (kat26?.rfc9106 ?? {}) as Record<string, unknown>;
|
|
2861
2991
|
const creds26 = (Array.isArray(kat26?.credentials) ? kat26!.credentials : []) as { kind: string; expected_hex: string }[];
|
|
2862
|
-
await
|
|
2863
|
-
|
|
2992
|
+
const argon26 = await import('../src/client/argon2.ts');
|
|
2993
|
+
const kp26 = argon26.ARGON_RFC9106_PARAMS;
|
|
2994
|
+
await A('[26] kat 對帳:RFC 9106 expected_tag_hex=ARGON_RFC9106_EXPECTED、參數 m/t/p/tag/pwd/salt=ARGON_RFC9106_PARAMS(argon2.ts 常數——verifyArgonKat/產生器同源);凍結 blob 7 筆逐字=本閘 KAT 字面([14]/[17]/[20])且 payload_len=解碼真長;憑證樣本 5 筆 hex64',
|
|
2995
|
+
argon26.ARGON_RFC9106_EXPECTED === rfc26.expected_tag_hex
|
|
2996
|
+
&& rfc26.memory_kib === kp26.memoryKib && rfc26.iterations === kp26.iterations && rfc26.parallelism === kp26.parallelism
|
|
2997
|
+
&& rfc26.tag_len === kp26.tagLen && kp26.tagLen === argon26.ARGON_TAG_LEN
|
|
2998
|
+
&& rfc26.password_hex === kp26.passwordByte.toString(16).padStart(2, '0').repeat(kp26.passwordLen) && rfc26.salt_hex === '00'.repeat(kp26.saltLen)
|
|
2864
2999
|
&& frozen26.length === 7 && frozen26.every((b) => gate26.includes("'" + b.wire + "'") && b.wire.startsWith(b.prefix)
|
|
2865
3000
|
&& unb64Mod(b.wire.slice(b.prefix.length)).length === b.payload_len && /^[0-9a-f]{64}$/.test(b.expected_note_key_hex))
|
|
2866
3001
|
&& creds26.length === 5 && creds26.every((c) => /^[0-9a-f]{64}$/.test(c.expected_hex)));
|
package/src/client/argon2.ts
CHANGED
|
@@ -52,6 +52,12 @@ function deriveInput(passphrase: string): string {
|
|
|
52
52
|
export const ARGON_RFC9106_EXPECTED =
|
|
53
53
|
'72d2a36fd5c266bcc96121b24937bc253338cdfcbd273713655748c54b4dd503';
|
|
54
54
|
|
|
55
|
+
/** RFC 9106 KAT 參數單一真相(verifyArgonKat/scripts/generate-vectors.ts 寫 kat.json/閘 [26] 對帳共用)。
|
|
56
|
+
* tagLen=派生本體固定輸出長(ARGON_TAG_LEN);pwd=passwordLen 位元組 passwordByte、salt=saltLen 位元組 0x00。 */
|
|
57
|
+
export const ARGON_RFC9106_PARAMS = Object.freeze({
|
|
58
|
+
memoryKib: 32, iterations: 3, parallelism: 4, tagLen: 32, passwordByte: 0x01, passwordLen: 32, saltLen: 16,
|
|
59
|
+
} as const);
|
|
60
|
+
|
|
55
61
|
/**
|
|
56
62
|
* PH1 v2 固定域鹽(登入憑證 Argon2id 派生,2026-09-10 安全路線第 4 步)。
|
|
57
63
|
*
|
|
@@ -229,7 +235,8 @@ type NodeArgonFn = (
|
|
|
229
235
|
|
|
230
236
|
/** 標準向量 KAT:驗證當前載體的 Argon2id 實作正確性(驗證閘起手式)。 */
|
|
231
237
|
export async function verifyArgonKat(): Promise<boolean> {
|
|
232
|
-
const
|
|
238
|
+
const k = ARGON_RFC9106_PARAMS;
|
|
239
|
+
const raw = await deriveArgon2id(new Uint8Array(k.passwordLen).fill(k.passwordByte), new Uint8Array(k.saltLen), k.memoryKib, k.iterations, k.parallelism);
|
|
233
240
|
return toHex(raw) === ARGON_RFC9106_EXPECTED;
|
|
234
241
|
}
|
|
235
242
|
|
|
@@ -244,7 +251,7 @@ export async function deriveKekArgon(password: string, salt: Uint8Array): Promis
|
|
|
244
251
|
return argonKekFromRaw(await deriveArgon2id(password, salt, ARGON_MEMORY_KIB, ARGON_ITERATIONS, ARGON_PARALLELISM));
|
|
245
252
|
}
|
|
246
253
|
|
|
247
|
-
/** jr3w. 包裹:payload =
|
|
254
|
+
/** jr3w. 包裹:payload = iv[12] ‖ AES-GCM(KEK, hex(noteKey), aad='notekey')(鹽外置家族,嚴格 92B);salt 不入 payload,由呼叫端存 users.salt。 */
|
|
248
255
|
export async function wrapNoteKey3(cfg: Argon3Config, noteKey: CryptoKey, passphrase: string): Promise<{ wrapped: string; salt: string }> {
|
|
249
256
|
if (!cfg.wrap3) throw new Error('ERR_JR3W_NOT_CONFIGURED');
|
|
250
257
|
const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));
|
|
@@ -279,7 +286,7 @@ export async function unwrapNoteKey3(cfg: Argon3Config, wrapped: string, passphr
|
|
|
279
286
|
// 與 cfg.wrap4 前綴。舊 payload(jr1w./jr3w.)永遠可解=raw 派生;新 payload 只由 jr4w. 產生。
|
|
280
287
|
// pinSaltPrefix 沿用 cfg 既有欄(Argon3 家族 PIN 鹽域,非 jr3d. 專屬)。
|
|
281
288
|
|
|
282
|
-
/** jr4w. 包裹(v3 密語):payload =
|
|
289
|
+
/** jr4w. 包裹(v3 密語):payload = iv[12] ‖ GCM(KEK, hex(noteKey), aad='notekey')(鹽外置家族,嚴格 92B);salt 不入 payload,呼叫端存 users.salt。 */
|
|
283
290
|
export async function wrapNoteKey4(cfg: Argon3Config, noteKey: CryptoKey, passphrase: string): Promise<{ wrapped: string; salt: string }> {
|
|
284
291
|
if (!cfg.wrap4) throw new Error('ERR_JR4W_NOT_CONFIGURED');
|
|
285
292
|
const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));
|
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
|
|
28
28
|
// ── 配置 ────────────────────────────────────────────────────────────────────
|
|
29
29
|
|
|
30
|
+
import type { KeyStore } from './keys.js';
|
|
31
|
+
|
|
30
32
|
export interface NoteCryptoConfig {
|
|
31
33
|
/** guest 時代 KDF 前綴,如 'journal-note-u1'。 */
|
|
32
34
|
guestKdfPrefix: string;
|
|
@@ -75,7 +77,7 @@ export interface NoteCryptoConfig {
|
|
|
75
77
|
/** 本機 IDB 密文前綴(jr1d.,notes store stored 形)。未配置 = local API 拒絕(未配置行為不變)。 */
|
|
76
78
|
cipherLocal?: string;
|
|
77
79
|
/** localStorage key store(品牌前綴由 keys.ts 管理)。 */
|
|
78
|
-
store:
|
|
80
|
+
store: KeyStore;
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
export const PBKDF2_ITERATIONS = 600_000; // OWASP 2023 建議值
|
package/src/index.ts
CHANGED
|
@@ -86,7 +86,7 @@ export {
|
|
|
86
86
|
type CipherFormats,
|
|
87
87
|
type KeyPackage,
|
|
88
88
|
} from './server/auth.ts';
|
|
89
|
-
export { checkRate, type RateWindow } from './server/ratelimit.ts';
|
|
89
|
+
export { checkRate, normalizeRateIp, type RateWindow } from './server/ratelimit.ts';
|
|
90
90
|
export { corsResponse, CORS_HEADERS } from './server/cors.ts';
|
|
91
91
|
export {
|
|
92
92
|
sha256Hex,
|
package/src/server/env.ts
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* env.ts — core 對 Worker Env 的最小介面。各 fork 的 Env 以此為基底擴充。
|
|
3
|
+
*
|
|
4
|
+
* D1Database 型別以模組 import 承載(非全域 triple-slash reference):
|
|
5
|
+
* 消費者未帶 @cloudflare/workers-types 時,吃 client 面 .d.ts 不會被 env.d.ts 的
|
|
6
|
+
* 全域型別缺席炸掉(types= reference 在 types 套件缺席=TS2688 全檔紅;import type
|
|
7
|
+
* 缺席=該欄型別 any 化+TS2304 只在吃 env.d.ts 的檔案現形=影響面收斂)。
|
|
3
8
|
*/
|
|
9
|
+
import type { D1Database } from '@cloudflare/workers-types';
|
|
10
|
+
|
|
4
11
|
export interface Env {
|
|
5
12
|
DB: D1Database;
|
|
6
13
|
}
|
package/src/server/ratelimit.ts
CHANGED
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
* 放行判準:RETURNING 的 count <= max(窗口內超額後的每一發都回實際 count,
|
|
15
15
|
* 由呼叫端以 count <= max 拒絕——計數不封頂,超額仍逐發累計)。
|
|
16
16
|
*
|
|
17
|
+
* 限流鍵(2026-10-11 定案):checkRate 入口經 normalizeRateIp 正規化——IPv6 聚合至 /64
|
|
18
|
+
* (單一終端可自由輪換 /64 內尾段=完整地址當鍵即每發新鍵、限流失效);IPv4-mapped
|
|
19
|
+
* 位址回點分 IPv4(=原生 IPv4 同 /32 鍵,review r1 MAJOR-1 收口);IPv4 與非 IPv6 形原樣。
|
|
20
|
+
*
|
|
17
21
|
* 呼叫端須自建表(無 GC 機制:舊 IP 列不清,表隨活躍 IP 數緩慢增長——
|
|
18
22
|
* 每列 ~40 bytes,量級可忽略;如需回收見下方 GC 註記):
|
|
19
23
|
* CREATE TABLE IF NOT EXISTS {table} (ip TEXT PRIMARY KEY, window_start INTEGER, count INTEGER);
|
|
@@ -71,6 +75,49 @@ async function bumpRate(env: Env, w: RateWindow, ip: string): Promise<boolean> {
|
|
|
71
75
|
}
|
|
72
76
|
}
|
|
73
77
|
|
|
78
|
+
const HEXTET = /^[0-9a-f]{1,4}$/i;
|
|
79
|
+
const IPV4_OCTET = /^(0|[1-9]\d{0,2})$/;
|
|
80
|
+
|
|
81
|
+
/** IPv6 文字形 → 8 組 hextet 數值(RFC 4291 §2.2:`::` 至多一處、嵌入 IPv4 僅居末);非法形回 null。 */
|
|
82
|
+
function parseIpv6(s: string): number[] | null {
|
|
83
|
+
const halves = s.split('::');
|
|
84
|
+
if (halves.length > 2) return null;
|
|
85
|
+
const parts = halves.map((h) => (h === '' ? [] : h.split(':')));
|
|
86
|
+
const last = parts[parts.length - 1];
|
|
87
|
+
const v4: number[] = [];
|
|
88
|
+
if (last.length > 0 && last[last.length - 1].includes('.')) {
|
|
89
|
+
const q = last.pop()!.split('.');
|
|
90
|
+
if (q.length !== 4 || !q.every((o) => IPV4_OCTET.test(o) && Number(o) <= 255)) return null;
|
|
91
|
+
v4.push(Number(q[0]) * 256 + Number(q[1]), Number(q[2]) * 256 + Number(q[3]));
|
|
92
|
+
}
|
|
93
|
+
if (!parts.every((p) => p.every((g) => HEXTET.test(g)))) return null;
|
|
94
|
+
const n = parts.reduce((a, p) => a + p.length, 0) + v4.length;
|
|
95
|
+
const hex = (p: string[]): number[] => p.map((g) => parseInt(g, 16));
|
|
96
|
+
if (halves.length === 1) return n === 8 ? [...hex(parts[0]), ...v4] : null;
|
|
97
|
+
if (n > 7) return null;
|
|
98
|
+
return [...hex(parts[0]), ...new Array<number>(8 - n).fill(0), ...hex(parts[1]), ...v4];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 限流鍵正規化(單一真相;純函式、零 throw):
|
|
103
|
+
* - IPv6 → /64 前綴+`/64` 標記(RFC 5952 規範形:hextet 前導零摘除、小寫、零串壓縮),
|
|
104
|
+
* 例 `2001:0DB8:0000:000A::1` → `2001:db8:0:a::/64`、`::1` → `::/64`;zone id(`%…`)先剝除。
|
|
105
|
+
* 介面 ID 歸零後末 4 組恆零=最長零串恆為尾串(前綴內零串至多 3 組),故壓縮形=前綴摘尾零組+`::`。
|
|
106
|
+
* - IPv4-mapped(::/96 前綴+映射尾組,RFC 4291 §2.5.5.2)→ 點分 IPv4 原鍵——mapped 位址就是 IPv4
|
|
107
|
+
* 節點的表示法,回 /32 與原生同鍵(hex 尾組 twin `::ffff:c000:201` 同鍵 192.0.2.1;禁併入 ::/64 全域桶)。
|
|
108
|
+
* - IPv4/`'unknown'`/空串/無法解析形:原樣返回(鍵照樣進表=可見面,不靜默丟鍵)。
|
|
109
|
+
*/
|
|
110
|
+
export function normalizeRateIp(ip: string): string {
|
|
111
|
+
if (typeof ip !== 'string' || !ip.includes(':')) return ip;
|
|
112
|
+
const pct = ip.indexOf('%');
|
|
113
|
+
const g = parseIpv6(pct >= 0 ? ip.slice(0, pct) : ip);
|
|
114
|
+
if (!g) return ip;
|
|
115
|
+
if (g[0] === 0 && g[1] === 0 && g[2] === 0 && g[3] === 0 && g[4] === 0 && g[5] === 0xffff) return [g[6] >> 8, g[6] & 255, g[7] >> 8, g[7] & 255].join('.');
|
|
116
|
+
const net = g.slice(0, 4);
|
|
117
|
+
while (net.length > 0 && net[net.length - 1] === 0) net.pop();
|
|
118
|
+
return net.map((h) => h.toString(16)).join(':') + '::/64';
|
|
119
|
+
}
|
|
120
|
+
|
|
74
121
|
export function checkRate(env: Env, w: RateWindow, ip: string): Promise<boolean> {
|
|
75
|
-
return bumpRate(env, w, ip);
|
|
122
|
+
return bumpRate(env, w, normalizeRateIp(ip));
|
|
76
123
|
}
|