@tacet-ink/journal-core 0.2.0 → 0.2.1
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 +60 -10
- package/package.json +16 -2
- package/scripts/open-card-pr.sh +49 -0
- package/scripts/verify-core-crypto.ts +463 -157
- package/src/client/argon2.ts +23 -18
- package/src/client/bip39.ts +1 -1
- package/src/client/note-crypto.ts +128 -42
- package/src/client/pinlock.ts +3 -3
- package/src/server/auth.ts +8 -8
- package/src/server/cors.ts +1 -1
- package/src/server/ladder.ts +5 -5
- package/src/server/ratelimit.ts +3 -3
package/README.md
CHANGED
|
@@ -13,6 +13,42 @@
|
|
|
13
13
|
這是 [默·Tacet(tacet.ink)](https://tacet.ink) 的客戶端/伺服端核心原語層,
|
|
14
14
|
從多個姊妹產品共用的密碼學本體抽取而成,以品牌前綴參數化(前綴契約見下表)。
|
|
15
15
|
|
|
16
|
+
## Quickstart
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
git clone https://github.com/tacet-ink/journal-core.git && cd journal-core
|
|
20
|
+
npm ci
|
|
21
|
+
npm run verify # 429 assertions, all green, run against the real modules (no mocks)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
// example.ts
|
|
26
|
+
import { makeHeldKey, generateNoteKey, encryptNote, decryptNote } from './src/index.ts';
|
|
27
|
+
import { makeKeyStore } from './src/client/keys.ts';
|
|
28
|
+
|
|
29
|
+
const cfg = {
|
|
30
|
+
guestKdfPrefix: 'myapp-note-u1',
|
|
31
|
+
recSaltPrefix: 'myapp-note-rec1:',
|
|
32
|
+
cipherGuest: 'jr1g.', // your own brand prefix family (guest era, opt-in)
|
|
33
|
+
cipherBound: 'jr1b.',
|
|
34
|
+
wrap: 'jr1w.',
|
|
35
|
+
store: makeKeyStore({ brand: 'myapp' }),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const held = makeHeldKey();
|
|
39
|
+
held.set(await generateNoteKey()); // bound era: random 256-bit noteKey
|
|
40
|
+
const identity = { current: () => 'acct-xxxxxxxxxxxxxxxx' };
|
|
41
|
+
|
|
42
|
+
const cipher = await encryptNote(cfg, held, '今天寫了一點東西。', 'noteId:n1', identity);
|
|
43
|
+
console.log(cipher.slice(0, 5)); // 'jr1b.' — ciphertext; nobody reads it without the key
|
|
44
|
+
console.log(await decryptNote(cfg, held, cipher, 'noteId:n1', identity)); // '今天寫了一點東西。' — roundtrip
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
run: `node --experimental-strip-types example.ts`
|
|
48
|
+
|
|
49
|
+
完整 API 見[使用(Usage)](#使用usage)節。Live product: [https://tacet.ink](https://tacet.ink)
|
|
50
|
+
(驗證頁:[https://tacet.ink/verify](https://tacet.ink/verify))。
|
|
51
|
+
|
|
16
52
|
## 設計(Design)
|
|
17
53
|
|
|
18
54
|
- **兩時代金鑰模型**:未綁定(guest)時代以 `K_u = SHA-256(guestKdfPrefix ‖ identity)`
|
|
@@ -24,8 +60,10 @@
|
|
|
24
60
|
- **extractable 鐵律**:要被 exportKey/wrap 的 key(noteKey 全部產生路徑),
|
|
25
61
|
import 當下就必須 `extractable=true`;KEK/guest key 恆 nonextractable。
|
|
26
62
|
- **opt-in 原語**:選配契約(cipherGuest/wrapDual/wrapShare/pinLock/cipherAttach/cipherLocal/
|
|
27
|
-
wrap4/wrapDual4/wrapLocal
|
|
28
|
-
解密面 guest 家族整面拒絕、不明字串與畸形空字串配置不當明文顯示)。
|
|
63
|
+
wrap4/wrapDual4/wrapLocal/wrapRec+recKekHkdf(兩欄一體——同缺同在,部分配置拒寫)/recKekSalt)未配置=回落語意(wrapRec 系照走舊契約面;其他面拒絕或退場),各 fork 未選用的原語行為不受影響(cipherGuest 未配置時 bound 路徑不受牽連,
|
|
64
|
+
解密面 guest 家族整面拒絕、不明字串與畸形空字串配置不當明文顯示)。wrapLocal 為選配:
|
|
65
|
+
未配置=寫面功能退場——v1 可用者(借用期部署)升級後需配置 wrapLocal 才保留本機包裹寫入
|
|
66
|
+
(讀取面照走舊形回落,零資料損失面)。
|
|
29
67
|
|
|
30
68
|
## 模組(Modules)
|
|
31
69
|
|
|
@@ -37,7 +75,7 @@
|
|
|
37
75
|
| `src/client/bip39.ts` | 復原套件 24 詞 ⇄ hex64 轉寫層(BIP39,零依賴自製) |
|
|
38
76
|
| `src/client/keys.ts` | 品牌前綴 localStorage 命名空間 |
|
|
39
77
|
| `src/server/auth.ts` | 零知識 auth 核心:PH1→PH2 login 動線、入庫格式驗證、包裹欄組成對(pair 檢查 pickKeyPackage);PH2 UNIQUE 衝突與 session 撤銷=呼叫端職責 |
|
|
40
|
-
| `src/server/ladder.ts` | 密語正規化 v3 世代 ladder 表原語(ph2_ladder:以舊 ph2 查列/遷移入表 upsert
|
|
78
|
+
| `src/server/ladder.ts` | 密語正規化 v3 世代 ladder 表原語(ph2_ladder:以舊 ph2 查列/遷移入表 upsert 值主權威)——幽靈守衛的資料承載位(查表接線在 fork route) |
|
|
41
79
|
| `src/server/ratelimit.ts` | per-IP fixed-window 限流(單句 UPSERT…RETURNING;D1 計數,跨 isolate 有效) |
|
|
42
80
|
| `src/server/cors.ts`/`hash.ts` | 共用 CORS/雜湊工具(`src/server/env.ts` 為內部 Env 介面,不入 exports) |
|
|
43
81
|
|
|
@@ -53,7 +91,8 @@ Tacet 部署實例(config 傳入):
|
|
|
53
91
|
| `jr1c.` | 附件密文(image attachments;選配) | 同筆記金鑰(noteKey/guest key 由呼叫端決定) | AES-GCM,AAD `jr1a:<noteId>:<attachId>` |
|
|
54
92
|
| `jr1d.` | 本機 IDB stored 密文(notes store;選配) | 同筆記金鑰(呼叫端注入) | AES-GCM,AAD 綁 note_id,payload 自帶 `v` 欄 |
|
|
55
93
|
| `jr1l.` | 本機包裹(PWA session 期免重打密語;選配) | deriveGuestKey=K_u 同 guest 面(identity 派生、passphrase-free) | 同 jr1w. 形,AAD `notekey-local` |
|
|
56
|
-
| `
|
|
94
|
+
| `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` 永不變——新世代讀舊寫新雙試) |
|
|
95
|
+
| `jr1w.` | passphrase 包裹+復原套件包裹(未配置世代) | PBKDF2-SHA256 600k(復原套件 KEK=PBKDF2(recToken, recSaltPrefix‖identity)) | b64(iv[12] ‖ GCM(hex(noteKey))),AAD `notekey`(復原套件與 passphrase 同 AAD 慣例=未配置世代借形) |
|
|
57
96
|
| `jr2w.` | PIN 第二因子合鑰(PBKDF2 版) | PBKDF2 600k(pass)+2M(pin) → HKDF-SHA256 | pinSalt[16] ‖ iv[12] ‖ GCM,108B,AAD `notekey2` |
|
|
58
97
|
| `jr3w.` | passphrase 包裹(Argon2id 版) | Argon2id m=64MiB t=3 p=1 tag=32B | 同 jr1w. 形,AAD `notekey` |
|
|
59
98
|
| `jr3d.` | PIN 第二因子合鑰(Argon2id 版) | Argon2id(pass)+Argon2id(pin) → HKDF-SHA256 | 同 jr2w. 形,108B,AAD `notekey2` |
|
|
@@ -78,11 +117,12 @@ jr3d/jr4d 兩入參數同輸入,域分離由 info 承載;凍結 KAT 兩 blob
|
|
|
78
117
|
## 驗證(Verification)
|
|
79
118
|
|
|
80
119
|
```sh
|
|
81
|
-
npm run verify #
|
|
120
|
+
npm run verify # 429 斷言對真模組(禁鏡像;限流單句 UPSERT…RETURNING 直載真 SQLite):roundtrip/AAD 防搬移/
|
|
82
121
|
# extractable/時代隔離/跨前綴家族隔離/payload 竄改/RFC 9106 KAT/BIP39 @scure 對照 200 組/
|
|
83
122
|
# server-side(inboundCipher/validWrappedKey/pickKeyPackage/checkRate/timingSafeEq)+ pinlock 全函式
|
|
84
123
|
# + 密語正規化 v3 世代(normalizePassphrase/jr4w./jr4d./PH1 v3 鹽域分離+帶內版本化舊契約向量)
|
|
85
124
|
# + 本機包裹專用前綴(wrapLocal=jr1l. opt-in+讀舊寫新自癒)
|
|
125
|
+
# +復原套件專用前綴與 HKDF 世代 [20](wrapRec=jr1r.+recKekHkdf opt-in+讀舊寫新雙試+KAT)
|
|
86
126
|
# +ladder 表與重取鹽面 [19](ph2_ladder 原語/upsert 值主權威行為面/unwrapNoteKeyDual4WithSalt)
|
|
87
127
|
# +常駐毒化矩陣 [16][18](/tmp 拷貝樹突變重跑=毒化證據隨每執行重建)
|
|
88
128
|
# +KAT 凍結向量 [17](HKDF info 域世代分離+v3/raw 入口契約四 blob)
|
|
@@ -116,6 +156,10 @@ const cfg = {
|
|
|
116
156
|
cipherBound: 'jr1b.',
|
|
117
157
|
wrap: 'jr1w.',
|
|
118
158
|
wrapLocal: 'jr1l.', // 選配:本機包裹專用前綴(未配置 = 讀舊形回落、寫面退場)
|
|
159
|
+
// 復原套件 v0.2.0 世代(選配):wrapRec+jr1r. 專用前綴+recKekHkdf HKDF 派生
|
|
160
|
+
// (info='journal-kek-rec-v1:'+recKekHkdf;recKekSalt 16B hex 鹽域)。兩欄一體:
|
|
161
|
+
// 要開就兩欄一起配(部分配置=ERR_REC_CFG_PARTIAL 拒寫)。兩欄皆缺席 =
|
|
162
|
+
// 行為不變態(jr1w.+PBKDF2+AAD 'notekey' 舊契約面照走,舊 blob 永遠可解)。
|
|
119
163
|
store: makeKeyStore({ brand: 'myapp' }),
|
|
120
164
|
// cipherGuest / wrapDual / wrapShare / pinLock / cipherAttach / cipherLocal:選配,未配置即拒絕
|
|
121
165
|
};
|
|
@@ -164,7 +208,9 @@ hash-wasm 並以 `setArgonLoader()` 注入(詳 `src/client/argon2.ts` 檔頭
|
|
|
164
208
|
|
|
165
209
|
## 授權
|
|
166
210
|
|
|
167
|
-
|
|
211
|
+
版本歷史見 [CHANGELOG.md](./CHANGELOG.md)。
|
|
212
|
+
|
|
213
|
+
MIT。安全問題聯絡 tacetink.csd@gmail.com(安全回報流程見 [SECURITY.md](./SECURITY.md))。
|
|
168
214
|
|
|
169
215
|
## English Summary
|
|
170
216
|
|
|
@@ -194,9 +240,13 @@ and salted hashes, and never learns your passphrase or the content of any note.
|
|
|
194
240
|
KEK and guest keys are permanently non-extractable.
|
|
195
241
|
|
|
196
242
|
**Opt-in primitives.** Optional contracts (`cipherGuest` / `wrapDual` / `wrapShare` / `pinLock` /
|
|
197
|
-
`cipherAttach` / `cipherLocal` / `wrap4` / `wrapDual4` / `wrapLocal`
|
|
198
|
-
configured
|
|
199
|
-
|
|
243
|
+
`cipherAttach` / `cipherLocal` / `wrap4` / `wrapDual4` / `wrapLocal` / `wrapRec` (paired with
|
|
244
|
+
`recKekHkdf`: configured together or not at all; partial configuration is rejected on write) /
|
|
245
|
+
`recKekHkdf` / `recKekSalt`): the wrapRec family falls back to the legacy face when unconfigured,
|
|
246
|
+
so forks that don't use a primitive are unaffected. Unknown or malformed strings never
|
|
247
|
+
decrypt to plaintext. `wrapLocal` is opt-in: unconfigured means the write face retires —
|
|
248
|
+
v1-era deployments (borrow-period) must configure `wrapLocal` after upgrading to keep local
|
|
249
|
+
key-wrap writes (the read face still falls back to the legacy blob; no data is lost).
|
|
200
250
|
|
|
201
251
|
**Modules.** Client: two-era crypto (`note-crypto.ts`), Argon2id wrapping (`argon2.ts`, dual
|
|
202
252
|
carrier: `node:crypto` ≥ Node 24.7 / hash-wasm in browsers, RFC 9106 test vectors), local PIN lock
|
|
@@ -206,7 +256,7 @@ PH1→PH2 login flow, inbound cipher/package validation and paired key-package s
|
|
|
206
256
|
(`auth.ts`; PH2-UNIQUE conflict and session revocation are caller-owned wiring), per-IP
|
|
207
257
|
fixed-window rate limiting on D1 (`ratelimit.ts`), shared CORS/hash utilities.
|
|
208
258
|
|
|
209
|
-
**Verification.** `npm run verify` runs
|
|
259
|
+
**Verification.** `npm run verify` runs 429 assertions against the real modules (no mocks):
|
|
210
260
|
roundtrips, AAD tamper-evidence, extractability rules, era isolation, cross-prefix family
|
|
211
261
|
isolation, payload tampering, RFC 9106 KAT, and a 200-vector BIP39 cross-check.
|
|
212
262
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tacet-ink/journal-core",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Zero-knowledge journal core — e2e crypto, passphrase-only auth, transport, ratelimit. The server never sees plaintext.",
|
|
5
5
|
"homepage": "https://tacet.ink",
|
|
6
6
|
"repository": {
|
|
@@ -11,6 +11,20 @@
|
|
|
11
11
|
"url": "https://github.com/tacet-ink/journal-core/issues"
|
|
12
12
|
},
|
|
13
13
|
"type": "module",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"journal",
|
|
16
|
+
"diary",
|
|
17
|
+
"encryption",
|
|
18
|
+
"e2ee",
|
|
19
|
+
"end-to-end-encryption",
|
|
20
|
+
"argon2id",
|
|
21
|
+
"bip39",
|
|
22
|
+
"passphrase",
|
|
23
|
+
"zero-knowledge",
|
|
24
|
+
"webcrypto",
|
|
25
|
+
"privacy",
|
|
26
|
+
"tacet"
|
|
27
|
+
],
|
|
14
28
|
"main": "src/index.ts",
|
|
15
29
|
"types": "src/index.ts",
|
|
16
30
|
"sideEffects": false,
|
|
@@ -45,6 +59,6 @@
|
|
|
45
59
|
"devDependencies": {
|
|
46
60
|
"@cloudflare/workers-types": "^5.20260831.1",
|
|
47
61
|
"@scure/bip39": "^2.4.0",
|
|
48
|
-
"typescript": "~
|
|
62
|
+
"typescript": "~7.0.2"
|
|
49
63
|
}
|
|
50
64
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
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
|