@tacet-ink/journal-core 0.2.3 → 0.2.5
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 +69 -14
- package/migrations/0012-fix-ph2-ladder-index.sql +18 -0
- package/package.json +1 -1
- package/scripts/prepare-core-pkg.cjs +74 -0
- package/scripts/verify-core-crypto.ts +635 -74
- package/src/client/argon2.ts +4 -0
- package/src/client/note-crypto.ts +7 -1
- package/src/server/auth.ts +46 -6
- package/src/server/ladder.ts +8 -3
- package/scripts/open-card-pr.sh +0 -49
package/src/client/argon2.ts
CHANGED
|
@@ -249,12 +249,14 @@ export async function wrapNoteKey3(cfg: Argon3Config, noteKey: CryptoKey, passph
|
|
|
249
249
|
}
|
|
250
250
|
|
|
251
251
|
/** jr3w. 解包:任何不符(前綴/salt 形/長度/AAD)回 null 不拋;成功 → noteKey extractable=true(鐵律 4)。
|
|
252
|
+
* 快速前綴快檢在場(deriveKekArgon 前——t_87ef62dd;unwrapNoteKey4 母型):錯前綴 junk 免付 Argon。
|
|
252
253
|
* 收口 openNoteKey:嚴格 92B+rawHex 形+hex fail-closed 全在共用核心;本函式只做
|
|
253
254
|
* 家族守衛(wrap3 未配置回 null)、salt1 hex-形檢查與 KEK 派生——payload 形是鹽外置家族(iv‖ct),
|
|
254
255
|
* own 92B 檢已隨收口摘除(jr3s 同;鹽內嵌族 own 108B 保留)。 */
|
|
255
256
|
export async function unwrapNoteKey3(cfg: Argon3Config, wrapped: string, passphrase: string, saltHex: string): Promise<CryptoKey | null> {
|
|
256
257
|
try {
|
|
257
258
|
if (!cfg.wrap3) return null;
|
|
259
|
+
if (!wrapped.startsWith(cfg.wrap3)) return null; // 快速前綴快檢(t_87ef62dd):錯前綴 junk 免付 64 MiB Argon 成本
|
|
258
260
|
const salt = hexToBytes(saltHex);
|
|
259
261
|
if (salt.length !== SALT_LEN || !/^[0-9a-f]{32}$/.test(saltHex)) return null;
|
|
260
262
|
const kek = await deriveKekArgon(passphrase, salt);
|
|
@@ -478,11 +480,13 @@ export async function wrapNoteKeyShare3(cfg: Argon3Config, noteKey: CryptoKey, s
|
|
|
478
480
|
}
|
|
479
481
|
|
|
480
482
|
/** jr3s. 解包:salt 取自 GET /shares/:hash 回應(與 jr1w 同形);任何不符回 null,不拋。
|
|
483
|
+
* 快速前綴快檢在場(deriveKekArgon 前——t_87ef62dd;unwrapNoteKey4 母型):錯前綴 junk 免付 Argon。
|
|
481
484
|
* 收口 openNoteKey:鹽外置家族(payload 嚴格 92B+rawHex 形+hex fail-closed
|
|
482
485
|
* 全在共用核心);本函式只做家族守衛、salt hex-形檢查與 KEK 派生——own 92B 檢已隨收口摘除。 */
|
|
483
486
|
export async function unwrapNoteKeyShare3(cfg: Argon3Config, wrapped: string, sharePass: string, saltHex: string): Promise<CryptoKey | null> {
|
|
484
487
|
try {
|
|
485
488
|
if (!cfg.wrapShare3) return null;
|
|
489
|
+
if (!wrapped.startsWith(cfg.wrapShare3)) return null; // 快速前綴快檢(t_87ef62dd):錯前綴 junk 免付 64 MiB Argon 成本
|
|
486
490
|
const salt = hexToBytes(saltHex);
|
|
487
491
|
if (salt.length !== SALT_LEN || !/^[0-9a-f]{32}$/.test(saltHex)) return null;
|
|
488
492
|
const kek = await deriveKekArgon(sharePass, salt);
|
|
@@ -439,6 +439,7 @@ export async function wrapNoteKey(cfg: NoteCryptoConfig, noteKey: CryptoKey, pas
|
|
|
439
439
|
export async function unwrapNoteKey(cfg: NoteCryptoConfig, wrapped: string, passphrase: string, saltHex: string): Promise<CryptoKey | null> {
|
|
440
440
|
try {
|
|
441
441
|
if (!cfg.wrap) return null;
|
|
442
|
+
if (!wrapped.startsWith(cfg.wrap)) return null; // 快速前綴快檢(t_87ef62dd):錯前綴 junk 免付 600k PBKDF2 成本
|
|
442
443
|
const salt = hexToBytes(saltHex);
|
|
443
444
|
if (salt.length !== 16 || !HEX32_RE.test(saltHex)) return null;
|
|
444
445
|
const kek = await deriveKek(passphrase, salt);
|
|
@@ -468,6 +469,7 @@ export async function wrapNoteKeyShare(cfg: NoteCryptoConfig, noteKey: CryptoKey
|
|
|
468
469
|
export async function unwrapNoteKeyShare(cfg: NoteCryptoConfig, wrapped: string, sharePass: string, saltHex: string): Promise<CryptoKey | null> {
|
|
469
470
|
try {
|
|
470
471
|
if (!cfg.wrapShare) return null;
|
|
472
|
+
if (!wrapped.startsWith(cfg.wrapShare)) return null; // 快速前綴快檢(t_87ef62dd):錯前綴 junk 免付 600k PBKDF2 成本
|
|
471
473
|
const salt = hexToBytes(saltHex);
|
|
472
474
|
if (salt.length !== 16 || !HEX32_RE.test(saltHex)) return null;
|
|
473
475
|
const kek = await deriveKek(sharePass, salt);
|
|
@@ -525,7 +527,10 @@ export async function unwrapNoteKeyWithRecToken(cfg: NoteCryptoConfig, wrapped:
|
|
|
525
527
|
try {
|
|
526
528
|
// 次序契約:jr1r. 面只在兩欄齊備時試(HKDF 專責派生——cfg.wrap 面零 HKDF 腿,
|
|
527
529
|
// jr1w. 前綴只 carrying passphrase-PBKDF2 派生:「一個前綴一份契約」第二違例收口)。
|
|
528
|
-
|
|
530
|
+
// 快速前綴快檢(t_87ef62dd):jr1r. 腿接線=條件面(錯前綴跳過 HKDF 腿)——
|
|
531
|
+
// 禁 return null 形(會短路「新世代讀舊寫新」的舊面回落雙試=[20] 過渡保護契約);
|
|
532
|
+
// 舊面是終面(回落後無再退)=return null 快檢形恰當(錯前綴免付 600k PBKDF2)。
|
|
533
|
+
if (cfg.wrapRec && cfg.recKekHkdf && wrapped.startsWith(cfg.wrapRec)) {
|
|
529
534
|
const recKek = await deriveRecKek(cfg, recToken);
|
|
530
535
|
const fresh = await openNoteKey(wrapped, recKek, 'notekey-rec', cfg.wrapRec);
|
|
531
536
|
if (fresh) return fresh;
|
|
@@ -533,6 +538,7 @@ export async function unwrapNoteKeyWithRecToken(cfg: NoteCryptoConfig, wrapped:
|
|
|
533
538
|
// 舊面回落(過渡保護):cfg.wrap 在場即試(部分配置讀面照走——寫面已被配對門
|
|
534
539
|
// 拒,讀面寬容=舊 blob 永遠可解的承載面)。
|
|
535
540
|
if (!cfg.wrap) return null;
|
|
541
|
+
if (!wrapped.startsWith(cfg.wrap)) return null; // 快速前綴快檢:錯前綴 junk 免付 600k PBKDF2 成本
|
|
536
542
|
const legacyKek = await deriveRecLegacyKek(cfg, recToken, identity);
|
|
537
543
|
return await openNoteKey(wrapped, legacyKek, 'notekey', cfg.wrap);
|
|
538
544
|
} catch {
|
package/src/server/auth.ts
CHANGED
|
@@ -8,14 +8,16 @@
|
|
|
8
8
|
* - 入庫前密文/包裹格式驗證(inboundCipher/isCipherFor/validWrappedKey/
|
|
9
9
|
* pickKeyPackage 等,前綴由 config 注入)。
|
|
10
10
|
* - /auth/login 核心動線(loginRouteCore):rate-limit 閘 → PH1 驗形 →
|
|
11
|
-
* PH2 → store
|
|
11
|
+
* PH2 → store 查詢(現值 miss → ladder 查表守衛——0.2.4 內建,optional 未配置
|
|
12
|
+
* 即退場)/建號 → session token 簽發(30 天)。
|
|
12
13
|
*
|
|
13
14
|
* 呼叫端(fork)職責——本檔只定義介面,機制在呼叫端實作:
|
|
14
15
|
* - identity 生成:AuthStore.createUser 由各 fork 自決(隨機 account_id 或
|
|
15
16
|
* 客戶端帶入語意皆可);PH2 UNIQUE 衝突(兩個同時首登同密語)是 store
|
|
16
17
|
* 責任——tacet 參考實作:UNIQUE 失敗重查既有列回 userKey(非 5xx)。
|
|
17
18
|
* - hash-ladder 雙軌(舊式 PH1 直比 → 升級寫回 PH2)與密語重設後的 session
|
|
18
|
-
* 撤銷時機:fork 呼叫端接線(revokeAllSessions
|
|
19
|
+
* 撤銷時機:fork 呼叫端接線(revokeAllSessions 介面已備);ladder 查表守衛已
|
|
20
|
+
* 內建 loginRouteCore(optional——fork 只需配 ladderLookup+0011 migration,接線零寫)。
|
|
19
21
|
* - 常數時間比較 timingSafeEq:./hash.ts 匯出(login 動線經 PH2 查詢不直比)。
|
|
20
22
|
*
|
|
21
23
|
* 不變量(勿破壞):
|
|
@@ -136,9 +138,17 @@ export interface AuthStore {
|
|
|
136
138
|
/** v3 重鑰:舊值入 ladder 表+ph2/wrapped/salt/wrappedRec/rec_hash
|
|
137
139
|
* 五欄同列覆蓋(rec 欄在場才覆蓋——四欄形契約面不動;fork 實作三語句同批寫入)。 */
|
|
138
140
|
rekeyWithLadder?(userKey: string, oldPh2: string, oldKind: 'legacy' | 'v2', ph2: string, pkg: KeyPackage, recPkg: string | null, recHash: string | null): Promise<void>;
|
|
139
|
-
/**
|
|
140
|
-
*
|
|
141
|
-
*
|
|
141
|
+
/**
|
|
142
|
+
* ladder 查表:以「舊 ph2 值」(v2/legacy 形)查 ladder 表,命中回帳戶 id。
|
|
143
|
+
* loginRouteCore 內建接線(0.2.4 外審 #8 幽靈守衛上位):現值 miss → ladderLookup →
|
|
144
|
+
* 命中=回舊帳(不建幽靈;ph2Kind 語意照 ladder.ts 檔頭註解——core 端回帳序:
|
|
145
|
+
* ladder hit → getByUserKey → 可續 buildSession),兩面皆 miss 才 createUser。
|
|
146
|
+
* 未配置(optional undefined)=查表面退場(現行單查行為零變——向後相容:
|
|
147
|
+
* fork 未配 ladder(0011 未上)時行為與 0.2.3 恆等,sennight/vestige 未來接 core 也零炸)。
|
|
148
|
+
* 錯誤處理照 tacet store.ts 母型:lookup 拋任何錯(0011 未上的 ladder 表缺席、ladder store
|
|
149
|
+
* 停機故障)=fail-open 視同 miss,不炸登入(ladder store 停機中登入=建幽靈——fail-open
|
|
150
|
+
* 語意的已知代價;接線端 ladderGuard 統一 catch 只包 lookup)+非 lookup 錯誤照常外拋。
|
|
151
|
+
*/
|
|
142
152
|
ladderLookup?(oldPh2: string): Promise<string | null>;
|
|
143
153
|
/** ladder 入表(遷移線用;upsert冪等)。 */
|
|
144
154
|
ladderInsert?(accountId: string, oldPh2: string, oldKind: 'legacy' | 'v2'): Promise<void>;
|
|
@@ -167,12 +177,42 @@ export function errResponse(code: string, status: number): Response {
|
|
|
167
177
|
|
|
168
178
|
// ── /auth/login:PH1 進站 → PH2 查詢(UNIQUE)→ session ─────────────────────
|
|
169
179
|
|
|
180
|
+
/**
|
|
181
|
+
* loginRouteCore ladder 查表守衛(0.2.4 外審 #8 幽靈帳真洞收口——tacet fork 母型上位):
|
|
182
|
+
* 現值(PH2 UNIQUE 查表)miss 時,先查 ladder 表(ph2_ladder:遷移線落下的「舊 ph2 值」),
|
|
183
|
+
* 命中=回舊帳不建幽靈;ladder 也 miss(或未配置)=才 createUser。
|
|
184
|
+
* - ladder hit 語意照 ladder.ts 檔頭註解:表列只承載舊值,帳戶現行憑證面以
|
|
185
|
+
* `getByUserKey` 讀到的 users 現值為準(v3 帳的 salt/wrapped 已是 v3 形);
|
|
186
|
+
* ladder hit → getByUserKey 落查無列(帳戶已刪、行殘留)=誠實 miss 續走建幽靈。
|
|
187
|
+
* - lookup 未配置(optional undefined)=零呼叫,行為與 0.2.3 單查恆等(向後相容鐵律);
|
|
188
|
+
* insert 是遷移線(rekeyWithLadder)職責、delete 已綁 delete-account(fork 面)——
|
|
189
|
+
* 本守衛只動查表,零寫入面。
|
|
190
|
+
* - lookup 拋錯(表缺席/儲存故障)=fail-open 視同 miss,不炸登入(tacet store.ts 母型;
|
|
191
|
+
* 靜默面照 tacet 慣例只 console.error)。
|
|
192
|
+
* - 零知識照舊:ladder 只存 ph2 hash 與 account_id,本函式零新洩漏面。
|
|
193
|
+
*/
|
|
194
|
+
async function ladderGuard(
|
|
195
|
+
store: AuthStore,
|
|
196
|
+
ph2: string,
|
|
197
|
+
): Promise<AuthRow | null> {
|
|
198
|
+
if (!store.ladderLookup) return null;
|
|
199
|
+
let ladderAcc: string | null = null;
|
|
200
|
+
try {
|
|
201
|
+
ladderAcc = await store.ladderLookup(ph2);
|
|
202
|
+
} catch (e) {
|
|
203
|
+
console.error('[ph2-ladder] lookup failed (fail-open):', e);
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
if (!ladderAcc) return null;
|
|
207
|
+
return store.getByUserKey(ladderAcc);
|
|
208
|
+
}
|
|
209
|
+
|
|
170
210
|
export async function loginRouteCore(env: Env & Record<string, unknown>, store: AuthStore, ip: string, ph1In: unknown): Promise<Response> {
|
|
171
211
|
if (!(await checkRate(env, AUTH_RATE, ip))) return errResponse('ERR_RATE_LIMITED', 429);
|
|
172
212
|
const ph1 = validHash64(ph1In);
|
|
173
213
|
if (!ph1) return errResponse('ERR_BAD_REQUEST', 400);
|
|
174
214
|
const ph2 = await sha256Hex(ph1);
|
|
175
|
-
const user = await store.findByIdentityQuery(ph2);
|
|
215
|
+
const user = (await store.findByIdentityQuery(ph2)) ?? (await ladderGuard(store, ph2));
|
|
176
216
|
const userKey: string = user ? user.userKey : await store.createUser(ph2);
|
|
177
217
|
const token = generateSessionToken();
|
|
178
218
|
const expiresAt = Date.now() + 30 * 24 * 60 * 60 * 1000; // PWA:30 天
|
package/src/server/ladder.ts
CHANGED
|
@@ -19,8 +19,9 @@
|
|
|
19
19
|
* 零知識照舊:ladder 只存 ph2 hash 與 account_id,零 PII、零密語材料。
|
|
20
20
|
*
|
|
21
21
|
* 職責分工(fork 對位面):表名/migration 由 fork migrations 承載(0011-ph2-ladder.sql,
|
|
22
|
-
* PK=ph2 值主權威形);本層提供「以舊 ph2
|
|
23
|
-
* (現值 miss → ladder →
|
|
22
|
+
* PK=ph2 值主權威形);本層提供「以舊 ph2 查列」與「遷移時入表」原語。查表守衛自 0.2.4 起
|
|
23
|
+
* 內建 loginRouteCore(現值 miss → ladder → 兩面 miss 才建幽靈;lookup 未配置=零呼叫
|
|
24
|
+
* 行為零變)——自帶 login route 的 fork(tacet loginRoute,v2-first 裁定)續走自身接線。
|
|
24
25
|
*/
|
|
25
26
|
|
|
26
27
|
import type { Env } from './env.ts';
|
|
@@ -35,7 +36,11 @@ export type Ph2LadderKind = 'legacy' | 'v2';
|
|
|
35
36
|
export const PH2_LADDER_KIND_LEGACY = 'legacy';
|
|
36
37
|
export const PH2_LADDER_KIND_V2 = 'v2';
|
|
37
38
|
|
|
38
|
-
/** ladder 行形(fork schema 對位面)。
|
|
39
|
+
/** ladder 行形(fork schema 對位面)。
|
|
40
|
+
* 欄名映射註解(讀帳面——schema 欄名 vs Row 形名差異;t_87ef62dd #13 只補註解不改名,
|
|
41
|
+
* 禁 consumer breaking):schema 欄是 `upgraded_at`(D1/SQLite 欄面——「舊形被覆蓋的時刻」),
|
|
42
|
+
* Row 形名 `createdAt`(fork 消費面語意——「該舊值歷列建立時刻」);同一值、兩層命名——
|
|
43
|
+
* findByOldPh2 讀取面做 upgraded_at→createdAt 的逐欄映射,改任一層名都是 breaking(禁動)。 */
|
|
39
44
|
export interface Ph2LadderRow {
|
|
40
45
|
accountId: string;
|
|
41
46
|
ph2Kind: string;
|
package/scripts/open-card-pr.sh
DELETED
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# open-card-pr.sh — journal-core 同 repo card branch PR 產生器。
|
|
3
|
-
#
|
|
4
|
-
# Usage: open-card-pr.sh <branch> <title> <body-file>
|
|
5
|
-
# branch card branch(head),例:card/<id>
|
|
6
|
-
# title PR 標題
|
|
7
|
-
# body-file PR body 純文字檔(完整讀入)
|
|
8
|
-
#
|
|
9
|
-
# 行為:純 curl POST /repos/tacet-ink/journal-core/pulls
|
|
10
|
-
# (head=<branch>、base=main、Token 取自 /srv/dropbox/csoft/tacet/.env 的
|
|
11
|
-
# GITHUB_TOKEN)。成功時 print PR 編號+URL;僅 print,不做其他事
|
|
12
|
-
# (不 push branch、不合併、不關閉)。
|
|
13
|
-
set -euo pipefail
|
|
14
|
-
|
|
15
|
-
REPO_URL="https://api.github.com/repos/tacet-ink/journal-core/pulls"
|
|
16
|
-
# 位置面:預設為本機絕對路徑(host-specific);其他環境以 ENV_FILE 覆寫
|
|
17
|
-
: "${ENV_FILE:=/srv/dropbox/csoft/tacet/.env}"
|
|
18
|
-
|
|
19
|
-
BRANCH="${1:?usage: open-card-pr.sh <branch> <title> <body-file>}"
|
|
20
|
-
TITLE="${2:?missing PR title}"
|
|
21
|
-
BODY_FILE="${3:?missing body file}"
|
|
22
|
-
|
|
23
|
-
[ -f "$BODY_FILE" ] || { echo "body file not found: $BODY_FILE" >&2; exit 1; }
|
|
24
|
-
|
|
25
|
-
TOKEN="$(grep '^GITHUB_TOKEN=' "$ENV_FILE" | head -1 | cut -d= -f2-)"
|
|
26
|
-
[ -n "$TOKEN" ] || { echo "GITHUB_TOKEN not found in $ENV_FILE" >&2; exit 1; }
|
|
27
|
-
|
|
28
|
-
PAYLOAD="$(jq -n --arg title "$TITLE" --arg head "$BRANCH" --arg base main \
|
|
29
|
-
--rawfile body "$BODY_FILE" \
|
|
30
|
-
'{title: $title, head: $head, base: $base, body: $body}')"
|
|
31
|
-
|
|
32
|
-
RESP="$(curl -sS -X POST \
|
|
33
|
-
-H "Authorization: token ${TOKEN}" \
|
|
34
|
-
-H "Accept: application/vnd.github+json" \
|
|
35
|
-
-H "X-GitHub-Api-Version: 2022-11-28" \
|
|
36
|
-
-d "$PAYLOAD" \
|
|
37
|
-
"$REPO_URL")"
|
|
38
|
-
|
|
39
|
-
NUM="$(printf '%s' "$RESP" | jq -r '.number // empty')"
|
|
40
|
-
if [ -n "$NUM" ]; then
|
|
41
|
-
URL="$(printf '%s' "$RESP" | jq -r '.html_url')"
|
|
42
|
-
echo "PR number: $NUM"
|
|
43
|
-
echo "PR url: $URL"
|
|
44
|
-
else
|
|
45
|
-
# 失敗(422 常見=branch 已有 PR/無 diff):print API 訊息不做事
|
|
46
|
-
printf '%s' "$RESP" | jq -r 'if .message then .message
|
|
47
|
-
else . end' >&2
|
|
48
|
-
exit 1
|
|
49
|
-
fi
|