@tacet-ink/journal-core
v0.3.1
Published
Zero-knowledge journal core — e2e crypto, passphrase-only auth, transport, ratelimit. The server never sees plaintext.
Maintainers
Readme
@tacet-ink/journal-core
零知識日記核心:端對端加密、密語即身份的 auth、傳輸與限流原語。 伺服器看不到任何一個字:passphrase 永不過線,密鑰包裹與密文都在客戶端完成。
English: Zero-knowledge journaling core — E2E-encrypted notes, passphrase-as-identity auth, transport & rate-limiting primitives. The server never sees a single word: the passphrase never crosses the wire, and all key wrapping and encryption happen client-side. See the English summary below.
這是 默·Tacet(tacet.ink) 的客戶端/伺服端核心原語層, 從多個姊妹產品共用的密碼學本體抽取而成,以品牌前綴參數化(前綴契約見下表)。
Quickstart
git clone https://github.com/tacet-ink/journal-core.git && cd journal-core
npm ci --ignore-scripts
npm run verify # 506 assertions, all green, run against the real modules (no mocks)// example.ts
import { makeHeldKey, generateNoteKey, encryptNote, decryptNote } from './src/index.ts';
import { makeKeyStore } from './src/client/keys.ts';
const cfg = {
guestKdfPrefix: 'myapp-note-u1',
recSaltPrefix: 'myapp-note-rec1:',
cipherGuest: 'jr1g.', // your own brand prefix family (guest era, opt-in)
cipherBound: 'jr1b.',
wrap: 'jr1w.',
store: makeKeyStore({ brand: 'myapp' }),
};
const held = makeHeldKey();
held.set(await generateNoteKey()); // bound era: random 256-bit noteKey
const identity = { current: () => 'acct-xxxxxxxxxxxxxxxx' };
const cipher = await encryptNote(cfg, held, '今天寫了一點東西。', 'noteId:n1', identity);
console.log(cipher.slice(0, 5)); // 'jr1b.' — ciphertext; nobody reads it without the key
console.log(await decryptNote(cfg, held, cipher, 'noteId:n1', identity)); // '今天寫了一點東西。' — roundtriprun: node --experimental-strip-types example.ts
完整 API 見使用(Usage)節。Live product: https://tacet.ink (驗證頁:https://tacet.ink/verify)。 安全回報流程見 SECURITY.md、版本歷史見 CHANGELOG.md。
設計(Design)
- 兩時代金鑰模型:未綁定(guest)時代以
K_u = SHA-256(guestKdfPrefix ‖ identity)純客戶端派生(混淆級);綁定後改用隨機 256-bit noteKey 加密, noteKey 再以 passphrase(KEK)與復原套件(KEK_rec)雙重包裹上傳。 此後 passphrase 不再過線:線上只送 PH1(雜湊形),伺服器存 PH2 = SHA-256(PH1)。 - 帶內版本化:升級 KDF 參數 = 換新前綴(舊前綴照解、原地改語意是禁手)。 前綴全部可配置,AAD(防搬移綁結)由呼叫端傳入。
- extractable 鐵律:要被 exportKey/wrap 的 key(noteKey 全部產生路徑),
import 當下就必須
extractable=true;KEK/guest key 恆 nonextractable。 - opt-in 原語:選配契約(cipherGuest/wrapDual/wrapShare/pinLock/cipherAttach/cipherLocal/ wrap4/wrapDual4/wrapLocal/wrapRec+recKekHkdf(兩欄一體——同缺同在,部分配置拒寫)/recKekSalt)未配置=回落語意(wrapRec 系照走舊契約面;其他面拒絕或退場),各 fork 未選用的原語行為不受影響(cipherGuest 未配置時 bound 路徑不受牽連, 解密面 guest 家族整面拒絕、不明字串與畸形空字串配置不當明文顯示)。wrapLocal 為選配: 未配置=寫面功能退場——v1 可用者(借用期部署)升級後需配置 wrapLocal 才保留本機包裹寫入 (讀取面照走舊形回落,零資料損失面)。
模組(Modules)
| 模組 | 內容 |
| --- | --- |
| src/client/note-crypto.ts | 兩時代加解密、passphrase/復原套件/本機三路包裹、PIN 第二因子合鑰(jr2w.) |
| src/client/argon2.ts | Argon2id 包裹原語(jr3w./jr3d./jr3s.+密語正規化 v3 世代 jr4w./jr4d.)、PH1 v2/v3 派生、RFC 9106 KAT、雙載體(node:crypto+hash-wasm) |
| src/client/pinlock.ts | 本機 PIN 鎖定包裹(jr1p.,開啟時鎖定) |
| src/client/bip39.ts | 復原套件 24 詞 ⇄ hex64 轉寫層(BIP39,零依賴自製) |
| src/client/keys.ts | 品牌前綴 localStorage 命名空間 |
| src/server/auth.ts | 零知識 auth 核心:PH1→PH2 login 動線、入庫格式驗證、包裹欄組成對(pair 檢查 pickKeyPackage);PH2 UNIQUE 衝突與 session 撤銷=呼叫端職責 |
| src/server/ladder.ts | 密語正規化 v3 世代 ladder 表原語(ph2_ladder:以舊 ph2 查列/遷移入表 upsert 值主權威)+幽靈守衛查表接線已內建 loginRouteCore——fork 只需配 ladderLookup store+0011 migration(未配置=單查行為零變) |
| src/server/ratelimit.ts | per-IP fixed-window 限流(單句 UPSERT…RETURNING;D1 計數,跨 isolate 有效) |
| src/server/cors.ts/hash.ts | 共用 CORS/雜湊工具(src/server/env.ts 為內部 Env 介面,不入 exports) |
發行形(0.3.0 起):dist/=tsc 原生 emit 的 ESM .js+.d.ts,exports 全部子路徑指向 dist;
src/ 同包隨附(稽核/閱讀用,import 恆走 dist)。
前綴契約(家族表:13 個資料前綴+閘對照組 jr1g.)
前綴是版本契約:payload 佈局與 KDF 由前綴界定,升級 = 新前綴。 逐位元組布局、KDF 參數、AAD 與世代契約的完整規格見 docs/format-spec.md, 測試向量見 docs/vectors/(真原語產生的凍結證據;閘 [26] 檢存在性與 shape)。 Tacet 部署實例(config 傳入):
| 前綴 | 語意 | KDF | payload |
| --- | --- | --- | --- |
| jr1u. | guest 時代密文 | K_u = SHA-256(guestKdfPrefix ‖ identity) | AES-GCM |
| jr1b. | 綁定時代密文 | 隨機 256-bit noteKey | AES-GCM,AAD 綁 noteId |
| jr1c. | 附件密文(image attachments;選配) | 同筆記金鑰(noteKey/guest key 由呼叫端決定) | AES-GCM,AAD jr1a:<noteId>:<attachId> |
| jr1d. | 本機 IDB stored 密文(notes store;選配) | 同筆記金鑰(呼叫端注入) | AES-GCM,AAD 綁 note_id,payload 自帶 v 欄 |
| jr1l. | 本機包裹(PWA session 期免重打密語;選配) | deriveGuestKey=K_u 同 guest 面(identity 派生、passphrase-free) | 同 jr1w. 形,AAD notekey-local |
| jr1r. | 復原套件包裹(wrappedRec 專用;v0.2.0 世代,選配,與 recKekHkdf 兩欄一體) | HKDF-SHA256(ikm=recToken, salt=recKekSalt?, info=journal-kek-rec-v1:+recKekHkdf)——兩欄齊備才寫 jr1r. 面 | 同 jr1w. 形,AAD notekey-rec(部分配置拒寫;舊契約面 jr1w.+PBKDF2+AAD notekey 永不變——新世代讀舊寫新雙試) |
| jr1w. | passphrase 包裹+復原套件包裹(未配置世代) | PBKDF2-SHA256 600k(復原套件 KEK=PBKDF2(recToken, recSaltPrefix‖identity)) | b64(iv[12] ‖ GCM(hex(noteKey))),AAD notekey(復原套件與 passphrase 同 AAD 慣例=未配置世代借形) |
| jr2w. | PIN 第二因子合鑰(PBKDF2 版) | PBKDF2 600k(pass)+2M(pin) → HKDF-SHA256 | pinSalt[16] ‖ iv[12] ‖ GCM,108B,AAD notekey2 |
| jr3w. | passphrase 包裹(Argon2id 版) | Argon2id m=64MiB t=3 p=1 tag=32B | 同 jr1w. 形,AAD notekey |
| jr3d. | PIN 第二因子合鑰(Argon2id 版) | Argon2id(pass)+Argon2id(pin) → HKDF-SHA256 | 同 jr2w. 形,108B,AAD notekey2 |
| jr4w. | passphrase 包裹(密語正規化 v3 世代) | Argon2id(normalizePassphrase(pass),NFKC-only) 同上參數 | 同 jr1w./jr3w. 形,AAD notekey |
| jr4d. | PIN 第二因子合鑰(密語正規化 v3 世代) | Argon2id(normalizePassphrase(pass))+Argon2id(pin) → HKDF-SHA256 | 同 jr2w./jr3d. 形,108B,AAD notekey2 |
| jr3s. | 單篇分享連結包裹 | Argon2id(同上參數) | 同 jr1w. 形,AAD notekey-share |
| jr1p. | 本機 PIN 鎖定(開啟時鎖定) | PBKDF2-SHA256 600k(刻意不用 Argon2id:解鎖要即時) | 同 jr2w. 形,AAD notekey-pinlock |
guest 密文前綴在閘對照組另驗 jr1g.(閘自造前綴,驗證品牌參數化本身;tacet 部署實例 guest 用 jr1u.)。
跨前綴呼叫一律回 null(前綴守衛+AAD+長度把關),不拋、不降級。
世代命名兩軸(jr4w./jr4d. 起):前綴數字軸=KDF 世代代(jr1w→jr2w→jr3w→jr4w,
KEK 輸入契約改變即換世代);「v3 家族」文案=PH1 規格代(PH1 v3 用 derivePh1ArgonV3)。
密語正規化 v3 世代=KEK 吃 normalizePassphrase(NFKC-only:不 trim、不分大小寫——
大小寫摺疊禁絕=大小寫差恆不同 KEK/ph2)——
舊前綴家族(jr1w./jr3w./jr2w./jr3d.)契約面永不變(raw 密語派生,帶內版本化禁原地改語意);
跨前綴呼叫恆 null(家族隔離)。同密語寫法差(全形/NFD/NFC)在 v3 世代收容為同一 KEK/PH2,
首尾空白與大小寫仍刻意分流(v3 契約只收容寫法差)。
jr4d. 的 KEK2 HKDF info 域='journal-kek2-v1:' + wrapDual4(自有域——NFKC-effective 密語下
jr3d/jr4d 兩入參數同輸入,域分離由 info 承載;凍結 KAT 兩 blob 互解 null 為證)。
驗證(Verification)
npm ci --ignore-scripts
npm run verify # 506 斷言對真模組(禁鏡像;限流單句 UPSERT…RETURNING 直載真 SQLite):roundtrip/AAD 防搬移/
# extractable/時代隔離/跨前綴家族隔離/payload 竄改/RFC 9106 KAT/BIP39 @scure 對照 200 組/
# server-side(inboundCipher/validWrappedKey/pickKeyPackage/checkRate/timingSafeEq)+ pinlock 全函式
# + 密語正規化 v3 世代(normalizePassphrase/jr4w./jr4d./PH1 v3 鹽域分離+帶內版本化舊契約向量)
# + 本機包裹專用前綴(wrapLocal=jr1l. opt-in+讀舊寫新自癒)
# +復原套件專用前綴與 HKDF 世代 [20](wrapRec=jr1r.+recKekHkdf opt-in+讀舊寫新雙試+KAT)
# +loginRouteCore ladder 查表守衛 [23](外審 #8 幽靈帳:現值 miss → ladder → 兩面 miss 才建幽靈;真 node:sqlite 直驅七情境+拷貝樹毒化接線摘除案)
# +ladder 表與重取鹽面 [19](ph2_ladder 原語/upsert 值主權威行為面/unwrapNoteKeyDual4WithSalt)
# +常駐毒化矩陣 [16][18](/tmp 拷貝樹突變重跑=毒化證據隨每執行重建)
# +KAT 凍結向量 [17](HKDF info 域世代分離+v3/raw 入口契約四 blob)
# +包裹前綴快檢 [13-2](錯前綴試探免付 KDF:五入口行為+計時帽+計數錨)
# +CI 供應鏈面 [21](npm ci --ignore-scripts+dependabot runner fromJSON 分流+腳本摘除+NIT-1 錨+r2 MINOR-1 URL 直傳;
# 506 帳=repo 樹——發行包內無 .github=[21] 四條 workflow 錨收斂為 1 顯性 SKIP 行→503,SKIP 顯形非靜默)
# +發行 tarball 治理面 [22](pack 白名單零 open-card-pr.sh)
# +格式規格+測試向量 [26](docs/format-spec.md+docs/vectors 存在性/位元組 shape/欄位名對規格/凍結 KAT 逐字對閘)
# +高階 Vault API [27](roundtrip 三形/新舊前綴解鎖/upgrade/換密語/復原/VaultError 毒化/載體覆寫+Worker 回退鏈/vendor 孿生)- 產品碼零執行時依賴(WebCrypto 原語);devDependencies 僅閘用(typescript、@scure/bip39 對照、workers types)。
- node_modules 出廠態說明:本 repo 的
@scure/bip39/@noble/hashes由閘的 vendor 修復 腿(scripts/prepare-core-pkg.cjs,plainnpm ci後執行)補齊 symlink 與 vendored 副本—— 與 devDependencies 安裝態等價(provenance 註記:現場樹的非 pristine 安裝形態屬此機制, 非手工污染;reproduce 走 plainnpm ci後跑 prepare 腿即收斂)。 - Argon2id 雙載體:node 走
node:crypto(Node 24.7+ 原生)、瀏覽器走 hash-wasm(wasm 內嵌), RFC 9106 標準向量逐位元一致;無載體即 throw(禁 fallback 鐵律)。 - BIP39 原語自製零依賴,與 @scure/bip39 參照 200 組雙向對照(僅 entropy↔words 轉寫層,禁用其 seed 派生)。
使用(Usage)
0.3.0 起發行 dist/(ESM .js+.d.ts):npm 安裝後瀏覽器、Node、Deno、Bun、Workers
皆可直接 import,不需打包器、不需 strip-types(0.2.x 的「npm 安裝必走打包器」限制已退役)。
源碼 clone 仍可直接 node --experimental-strip-types 跑 src/。
import { makeKeyStore, makeHeldKey, generateNoteKey, encryptNote, decryptNote } from '@tacet-ink/journal-core';
// 或子路徑:'@tacet-ink/journal-core/client/note-crypto'import { generateNoteKey, encryptNote, decryptNote, makeHeldKey,
wrapNoteKey, unwrapNoteKey } from '@tacet-ink/journal-core/client/note-crypto';
import { makeKeyStore } from '@tacet-ink/journal-core/client/keys';
import { derivePh1Argon } from '@tacet-ink/journal-core/client/argon2';
const cfg = {
guestKdfPrefix: 'myapp-note-u1',
recSaltPrefix: 'myapp-note-rec1:',
cipherGuest: 'jr1g.', // 換成你自己的品牌前綴家族
cipherBound: 'jr1b.',
wrap: 'jr1w.',
wrapLocal: 'jr1l.', // 選配:本機包裹專用前綴(未配置 = 讀舊形回落、寫面退場)
// 復原套件 v0.2.0 世代(選配):wrapRec+jr1r. 專用前綴+recKekHkdf HKDF 派生
// (info='journal-kek-rec-v1:'+recKekHkdf;recKekSalt 16B hex 鹽域)。兩欄一體:
// 要開就兩欄一起配(部分配置=ERR_REC_CFG_PARTIAL 拒寫)。兩欄皆缺席 =
// 行為不變態(jr1w.+PBKDF2+AAD 'notekey' 舊契約面照走,舊 blob 永遠可解)。
store: makeKeyStore({ brand: 'myapp' }),
// cipherGuest / wrapDual / wrapShare / pinLock / cipherAttach / cipherLocal:選配,未配置即拒絕
};
const held = makeHeldKey();
const identity = { current: () => 'acct-xxxxxxxxxxxxxxxx' };
const cipher = await encryptNote(cfg, held, '今天寫了一點東西。', 'noteId:n1', identity);PH1 派生(登入憑證):derivePh1Argon(pass, saltArg?) 預設鹽 PH1_V2_SALT
('tacet-ph1-v1',Tacet 實例,raw pass)。其他產品可自選固定域鹽傳入 saltArg——
鹽一經選定即 per-product 恆固定(PH2 UNIQUE 約束:同一密語必須恆生同一 PH2;
per-user 鹽會摧毀幽靈帳號機制)。改鹽=帶內版本化換鹽尾碼重遷移,禁原地改語意。
PH1 v3(密語正規化世代):derivePh1ArgonV3(pass, saltArg?) 預設鹽 PH1_V3_SALT
('tacet-ph1-v2',新鹽域=世代 ph2 命名空間分離——同鹽域會讓 v3 'PASS' 與 v2 世代
raw 'PASS' 撞同一 ph2)。派生輸入吃 normalizePassphrase(NFKC-only;大小寫摺疊禁絕=
大小寫差恆不同 ph2);v2/legacy 派生面零變更(舊帳戶憑證 raw 契約)。兩代 ph2 可並存查表=
tacet 遷移層職責(v0.2.0 世代收口)。
server 登入動線(fork 端):loginRouteCore(request-ish, store, ip, ph1) 內建
ladder 查表守衛——現值 miss 時經 AuthStore.ladderLookup(optional)查 ph2_ladder,
命中回舊帳不建幽靈、兩面 miss 才 createUser;未配置=行為與 0.2.3 單查恆等。
接法:store 實作 ladderLookup(tacet 參考形=SQL_FIND_PH2_LADDER_BY_OLD+fail-open
catch)+套用 migrations/0011-ph2-ladder.sql(+0012 索引修正);查表零寫入面
(insert 在 rekeyWithLadder 遷移線、delete 在 fork 的 delete-account 批)。
⚠️ 鹽撞位警告:PH1_V3_SALT 禁當 derivePh1Argon 的 saltArg 餵入(反之亦然)——
把 v3 鹽域值餵 v2 派生(或 fork 把同一 saltArg 帶到兩面)=NFKC-effective 密語與 v2 raw 密語
同值撞 ph2,跨世代帳戶空間混合。v3 的 saltArg 必須異於本 product 的 v2 鹽域值。
例外契約(錯誤碼語意)
客戶端(client/)
| 錯誤碼 | 拋出面 |
| --- | --- |
| ERR_NO_IDENTITY | guest 加密面空 identity(解密面同面回 null) |
| ERR_GUEST_NOT_CONFIGURED | encryptNote 未配置 cipherGuest |
| ERR_BAD_HEX | hexToBytes 非法 hex(奇數長度/非 hex 字元/空字串) |
| ERR_WRAP_NOT_CONFIGURED | 包裹未配置(sealNoteKey 本體+wrapNoteKey 入口雙防線) |
| ERR_WRAPTAMPER_LEN32 | sealNoteKey noteKey 非 32B(內部契約面) |
| ERR_DUAL_NOT_CONFIGURED | wrapNoteKeyDual 未配置 wrapDual/pinSaltPrefix |
| ERR_SHARE_NOT_CONFIGURED | wrapNoteKeyShare 未配置 wrapShare |
| ERR_LOCAL_NOT_CONFIGURED | encryptLocal 未配置 cipherLocal |
| ERR_ATTACH_NOT_CONFIGURED | encryptAttach 未配置 cipherAttach |
| ERR_PIN_EMPTY | wrapNoteKeyDual/3/4 空 PIN(≠ 未配置——語意分離) |
| ERR_PINLOCK_NOT_CONFIGURED | pinlock 未配置三欄任一 |
| ERR_PINLOCK_EMPTY | pinlock 空 PIN |
| ERR_REC_CFG_PARTIAL | wrapRec/recKekHkdf 部分配置(配對門拒寫) |
| ERR_REC_KEK_SALT | recKekSalt 非 16B hex(HKDF 世代鹽域 fail-closed) |
| ERR_JR3W_NOT_CONFIGURED | wrapNoteKey3/wrapNoteKeyDual3(+share 面)未配置 |
| ERR_JR3S_NOT_CONFIGURED | wrapNoteKeyShare3 未配置 wrapShare3 |
| ERR_JR4W_NOT_CONFIGURED | wrapNoteKey4 未配置 wrap4 |
| ERR_JR4D_NOT_CONFIGURED | wrapNoteKeyDual4 未配置 wrapDual4 |
| ERR_ARGON2_TAGLEN | Argon2id 載體 tag 非 32B(載體契約面) |
| ERR_ARGON2_UNAVAILABLE | 無 Argon2id 載體(node<24.7 且無 hash-wasm 注入——禁 fallback) |
伺服端(server/)+閘
| 錯誤碼 | 拋出面 |
| --- | --- |
| ERR_BAD_REQUEST | auth 動線缺 PH1 等必填(400 面回應) |
| ERR_RATE_LIMITED | 限流命中(429 面回應) |
| ERR_RATE_TABLE_NAME | 限流表名非法字元(SQL 面 fail-closed) |
| ERR_SQLITE_UNAVAILABLE | 驗證閘環境 node:sqlite 缺席 |
| ERR_FS_UNAVAILABLE | 驗證閘環境 node:fs 缺席 |
PinLockConfig:解包輸出的 noteKey 恆extractable=true(鐵律,非可調選項)—— config 只帶三個前綴欄(pinLock/pinLockSaltPrefix/pinLockAad),缺任一即拒。
Argon2id 瀏覽器載體需另裝 hash-wasm 並以 setArgonLoader() 注入(詳 src/client/argon2.ts 檔頭);
Node ≥ 24.7 走內建 node:crypto 免注入。
界限(誠實面)
- guest 時代是混淆級不是 e2e(K_u 由 identity 可派生);e2e 承諾從綁定(隨機 noteKey)起。
- PH1 派生方式由各產品自定義(快雜湊或 Argon2id 派生皆可),本層只驗 hex64 形。
- 本 repo 不含產品層:路由/配額/訂閱/GC/webhook 在各產品 fork。
- 未經第三方正式稽核;密語遺失設計上無救援(伺服器零知識的代價)。
授權
版本歷史見 CHANGELOG.md。
MIT。安全問題聯絡 [email protected](安全回報流程見 SECURITY.md)。
English Summary
What this is. @tacet-ink/journal-core is the cryptographic and server-primitive layer powering
默·Tacet (tacet.ink), a zero-knowledge journal: the server stores only ciphertext
and salted hashes, and never learns your passphrase or the content of any note.
Security model.
- Two key eras. Before binding an account, notes are encrypted with a client-derived key
K_u = SHA-256(guestKdfPrefix ‖ identity)(obfuscation-grade, not true E2E). After binding, notes are encrypted with a random 256-bit noteKey. The noteKey is wrapped twice — with the passphrase (KEK) and with a recovery package (KEK_rec) — before upload. From that point the passphrase never crosses the wire: only PH1 (a client-side hash form) is transmitted, and the server stores onlyPH2 = SHA-256(PH1). - In-band versioning. Payload layout and KDF are defined by configurable cipher prefixes
(e.g.
jr1b.for bound-era ciphertext,jr3w.for Argon2id-wrapped note keys). Upgrading KDF parameters always means a new prefix; old prefixes keep decrypting forever (no in-place semantic changes). Cross-prefix calls always returnnull— prefix guard + AAD + length checks; they never throw or fall back. The passphrase-normalization generation follows the same rule:jr4w./jr4d.wrap withnormalizePassphrase(NFKC-only — no trim, no case-folding); older prefixes keep deriving from the raw passphrase, so every legacy payload stays decryptable as-is. - Tamper-evidence. Every AES-GCM operation binds additional authenticated data (AAD) — e.g. ciphertext is bound to its note id, so records cannot be shuffled between notes.
- Key hygiene. Any key that must be exported/wrapped is imported with
extractable=true; KEK and guest keys are permanently non-extractable.
Server login wiring. loginRouteCore(store, ip, ph1) ships with the ladder guard built in
(v0.2.4, external-review #8 ghost-account fix): on a current-value (PH2) miss it consults the
ph2_ladder table via the optional AuthStore.ladderLookup — a ladder hit returns the
existing account (no ghost creation); a ladder miss, an unconfigured lookup, or a
ladder-store error (fail-open, treated as a miss) all fall through to the legacy
single-lookup behavior of ≤0.2.3. Forks adopt the guard by configuring ladderLookup
on their store (the tacet reference: d1AuthStore) and applying migrations/0011-ph2-ladder.sql
— the lookup itself never writes (inserts stay on the rekeyWithLadder migration path,
deletes on the fork's delete-account flow).
Opt-in primitives. Optional contracts (cipherGuest / wrapDual / wrapShare / pinLock /
cipherAttach / cipherLocal / wrap4 / wrapDual4 / wrapLocal / wrapRec (paired with
recKekHkdf: configured together or not at all; partial configuration is rejected on write) /
recKekHkdf / recKekSalt): the wrapRec family falls back to the legacy face when unconfigured,
so forks that don't use a primitive are unaffected. Unknown or malformed strings never
decrypt to plaintext. wrapLocal is opt-in: unconfigured means the write face retires —
v1-era deployments (borrow-period) must configure wrapLocal after upgrading to keep local
key-wrap writes (the read face still falls back to the legacy blob; no data is lost).
Modules. Client: two-era crypto (note-crypto.ts), Argon2id wrapping (argon2.ts, dual
carrier: node:crypto ≥ Node 24.7 / hash-wasm in browsers, RFC 9106 test vectors), local PIN lock
(pinlock.ts), recovery-package BIP39 codec (bip39.ts, zero-dependency, cross-checked against
@scure/bip39), brand-namespaced key storage (keys.ts). Server: zero-knowledge auth core with a
PH1→PH2 login flow, inbound cipher/package validation and paired key-package selection
(auth.ts; PH2-UNIQUE conflict and session revocation are caller-owned wiring), per-IP
fixed-window rate limiting on D1 (ratelimit.ts), shared CORS/hash utilities. Since 0.3.0 the
package ships dist/ (tsc-emitted ESM .js + .d.ts); every exports subpath resolves to dist,
and src/ is included for reading and auditing only.
Format spec & test vectors. The byte-level wire format of every prefix family — payload layout
and field lengths, KDF parameters, AAD strings, and the in-band versioning contract — is specified
in docs/format-spec.md (Chinese; transcribed from the source, not a new
contract). JSON test vectors live in docs/vectors/: one wrap/encrypt sample per
prefix family (families.json), known-answer vectors (RFC 9106 Argon2id, the gate's frozen
jr2w./jr3w./jr3d./jr4w./jr4d./jr1r./jr1w. blobs, and PH1/PH2 credential samples in
kat.json), and 34 BIP39 samples (bip39.json). They were generated once by
scripts/generate-vectors.ts against the real primitives and committed as frozen evidence; the
verify gate checks their presence and shape (section [26]) and does not regenerate them.
Verification. npm run verify runs 506 assertions against the real modules (no mocks):
roundtrips, AAD tamper-evidence, extractability rules, era isolation, cross-prefix family
isolation, payload tampering, RFC 9106 KAT, a 200-vector BIP39 cross-check, and the
login ladder-guard behavior suite (external audit #8), and presence/shape checks for the format
spec and test vectors.
Usage. Since 0.3.0 the npm package ships compiled ESM in dist/, so browsers, Node, Deno, Bun
and Workers can import it directly — no bundler and no strip-types needed (the 0.2.x "must bundle"
requirement is retired). A source checkout can still run src/ with node --experimental-strip-types.
In browsers, inject the hash-wasm Argon2id carrier via setArgonLoader(); Node ≥ 24.7 uses the
built-in node:crypto. See the code sample in the 使用 section above.
Honest limits. The guest era is obfuscation-grade, not E2E. PH1 derivation is defined by each product (fast hash or Argon2id); this layer only validates its hex64 form. Product concerns (routing, quotas, subscriptions, GC, webhooks) live in the product forks. No third-party audit yet. A lost passphrase is unrecoverable by design — that is the price of server-side zero knowledge.
License
MIT. Security contact: [email protected]. Version history: CHANGELOG.md. Security reporting process: SECURITY.md.
