@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.
@@ -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
- if (cfg.wrapRec && cfg.recKekHkdf) {
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 {
@@ -8,14 +8,16 @@
8
8
  * - 入庫前密文/包裹格式驗證(inboundCipher/isCipherFor/validWrappedKey/
9
9
  * pickKeyPackage 等,前綴由 config 注入)。
10
10
  * - /auth/login 核心動線(loginRouteCore):rate-limit 閘 → PH1 驗形 →
11
- * PH2 → store 查詢/建號 → session token 簽發(30 天)。
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
- /** ladder 查表:以「舊 ph2 值」(v2/legacy 形)查 ladder 表,
140
- * 命中回帳戶 id(login route 的「不建幽靈、回舊帳+ph2Kind='legacy' 語意」守衛面);
141
- * 未配置(optional)=查表面退場(fork 離線/無表態,行為不變)。 */
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 天
@@ -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 → 建幽靈)在 fork route(tacet loginRoute,v2-first 裁定)。
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;
@@ -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