@trustbaseai/account
v0.1.0
Published
TrustBase 账户与密钥库:tct1… 地址派生、口令加密 keystore(Argon2id + AES-256-GCM)、WebAuthn PRF 硬件解锁、口令强度与 BIP39 助记词
Downloads
224
Maintainers
Readme
@trustbaseai/account
TrustBase 的账户与密钥库:地址派生、口令加密的私钥存储、WebAuthn PRF 硬件解锁、 口令强度估算与 BIP39 助记词(纸密钥)。
为什么这是第一个功能包:SDK 方案 §3.2 拍板「账户能力是 SDK 的默认组件,不是插件。 任何基于本 SDK 的 PWA 打开就带「我的账户」面板」。要做到这件事,先得有一块能登录、 能管理自己账户的地基,而且它必须是安全关键的那块 —— 本包就是这块地基。
一个硬约束贯穿全部代码:绝不托管(SDK 方案 §3.2.1)。本包不知道任何用户的明文, 没有"找回密码"能力,助记词是唯一退路。任何看起来像"帮用户保管密钥"的 API 都不属于这里。
1. 四块能力与模块对应
| 能力 | 模块 | 依据(设计文档) |
|---|---|---|
| tct1… 地址派生 | src/address.ts + src/keys.ts + src/bech32.ts | 入口文档 §8.3.2 ①②、SDK 方案 §3.2.3 |
| 口令加密 keystore | src/keystore.ts + src/kdf.ts + src/webcrypto.ts | SDK 方案 §3.3.1 / §3.3.2 |
| PRF 硬件解锁(双轨) | src/prf.ts + src/envelope.ts + src/prf-webauthn.ts | SDK 方案 §3.3.5 / §3.3.7 |
| 口令强度 + 助记词 | src/password.ts + src/mnemonic.ts + src/hd.ts | SDK 方案 §3.3.6 / §3.3.3 |
同构:src/ 零 Node 依赖(浏览器 PWA 与 Node 守护进程同一份代码),
这条由 test/isomorphic.test.ts 静态扫描守着:出现 node: 导入、process.、Buffer、
__dirname、Math.random、或在白名单外碰 navigator/localStorage,测试直接红。
2. 地址派生(口径必须与链一致)
address = bech32( hrp='tct', ripemd160( sha256( compressed_secp256k1_pubkey ) ) )
└──────────── 20 字节 payload ────────────┘三个"必须一致"的点,错一个都会在链上静默失败(例如退款无处可退):
| # | 要点 | 错了会怎样 |
|---|---|---|
| 1 | 公钥必须是压缩形态(33 字节) | 用非压缩公钥派生 → 得到另一个地址:收款地址与签名者地址不是同一个 |
| 2 | 顺序是 ripemd160(sha256(pubkey)) | 不是链上认的账户地址,AccAddressFromBech32 过不了 |
| 3 | 编码是 bech32(BIP-173),HRP = tct | 别的钱包解不开;bech32m 会被本实现明确拒绝(报"这是 bech32m"而不是含糊的校验和错误) |
import { createAccount, accountAddressFromPublicKey, isAccountAddress, parseAccountAddress } from '@trustbaseai/account';
const account = createAccount(); // { mnemonic, privateKey, publicKey, address, path }
account.address; // tct1…(12 词助记词,路径 m/44'/118'/0'/0/0)
isAccountAddress('tct1…'); // 不抛异常的判定(校验和 + HRP + 20 字节 payload)
parseAccountAddress('tct1…').bytes; // 20 字节 payload验证方式(三层,缺一不可):
- 公开向量:BIP-173 的 bech32 测试向量(含 P2WPKH 示例
bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4)、 比特币"私钥 = 1"的 hash160751e76e8199196d454941c45d1b3a323f1433bd6、BIP-39 官方英文向量。 出处逐条写在test-vectors/address.golden.json的sources里 - 独立复算:
test/address.test.ts用node:crypto(OpenSSL)自己算一遍 sha256/ripemd160 - 交叉验证:与
@cosmjs/proto-signing的DirectSecp256k1HdWallet比地址、与@cosmjs/crypto的Bip39 + Slip10比私钥 —— 官方向量 + 随机助记词 5 轮,逐字符一致
3. 加密 keystore(格式 v1)
{
"version": 1,
"kdf": "argon2id", // 或 "pbkdf2"
"kdfParams": { // argon2id:salt / memory / cost / parallelism / hashLength / version
"salt": "<base64, 16 字节>", // pbkdf2:salt / iterations / hashLength / hash
"memory": 19456, "cost": 2, "parallelism": 1, "hashLength": 32, "version": 19
},
"cipher": "aes-256-gcm",
"iv": "<base64, 12 字节>",
"ciphertext": "<base64>",
"tag": "<base64, 16 字节>"
}import { createKeystore, unlockKeystore, serializeKeystore, parseKeystore, wipe } from '@trustbaseai/account';
const blob = await createKeystore(privateKey, password, { minStrength: 3 });
const store = serializeKeystore(blob); // 只把这段密文写进 IndexedDB / 本机文件
const opened = await unlockKeystore(store, password); // 明文只在内存里
// …用完
wipe(opened);3.1 明文头参与认证(本实现相对设计文档的加固)
设计文档列的字段里,version / kdf / kdfParams / cipher / iv 都是明文头。
如果不认证它们,攻击者可以改掉 KDF 参数(甚至把 argon2id 改成 pbkdf2)而密文照样解得开
—— 那就等于"KDF 强度随人调"。本实现把整个明文头作为 GCM 的 AAD:
trustbase-keystore-v1|kdf=argon2id|salt=<b64>|memory=19456|cost=2|parallelism=1|hashLength=32|version=19|cipher=aes-256-gcm|iv=<b64>
trustbase-keystore-v1|kdf=pbkdf2|salt=<b64>|iterations=600000|hashLength=32|hash=sha256|cipher=aes-256-gcm|iv=<b64>这串文本被 test/keystore.test.ts 用字面量钉住(改它 = 改密钥库格式),
并且任何一位被改都会让解锁以 AUTH_FAILED 失败。
3.2 KDF 参数的依据与升级路径
| 项 | 默认值 | 依据 |
|---|---|---|
| Argon2id memory / cost / parallelism | 19456 KiB / 2 / 1 | OWASP Password Storage Cheat Sheet 的推荐下限(19 MiB, t=2, p=1);本机 WASM 实测 ~40 ms |
| Argon2id version | 19(v1.3) | hash-wasm 内置版本;别的版本号一律拒(UNSUPPORTED_KDF),不猜 |
| PBKDF2 iterations | 600000 | SDK 方案 §3.3.1 规定的下限;无 WASM 时兜底 |
| 盐 | 16 字节随机 | 每次创建新随机,绝不复用 |
| 派生密钥长度 | 32 字节 | AES-256 的密钥长度;格式 v1 固定,加长要换版本号 |
升级路径:参数随密文落盘,解锁只认密文里的参数。要换更强的参数:
用旧参数 unlockKeystore → 用新参数 createKeystore → 覆盖落盘。旧密文永远解得开,
不需要"重抄助记词"。
3.3 错误码(调用方按码分支,不要按 message)
| 情况 | 错误码 |
|---|---|
| 口令错 / 密文被改 / AAD 头被改 / PRF 秘密不匹配 | AUTH_FAILED(刻意含糊:不区分它们) |
| 不是密文 / 字段缺失 / 类型不对 / base64 坏 / 参数越界 | INVALID_BLOB |
| version 比本实现新 | UNSUPPORTED_VERSION |
| kdf 不认 / Argon2 版本不认 / 本环境跑不了 Argon2id | UNSUPPORTED_KDF |
| 环境没有 WebCrypto | CRYPTO_UNAVAILABLE |
| 口令超长(>128)/ secret 形状不对 / 参数低于下限 | INVALID_ARGUMENT |
| 口令强度门未过 | WEAK_PASSWORD(details 带 score 与 reasons) |
"口令错"与"密文被改"共用一个码、共用一句 message,这是有意的(SDK 方案 §3.3.1): 一旦能区分,攻击者就拿到了"这份密文是否被正确改写"的判定预言机。
3.4 无 WASM 环境的行为(唯一允许的退化)
| 场景 | 行为 |
|---|---|
| 创建时没有 WASM | 退到 PBKDF2-HMAC-SHA256(≥60 万次),并把 kdf: 'pbkdf2' 落盘 |
| 解锁 argon2id 密文时没有 WASM | 报 UNSUPPORTED_KDF,不回退 —— 回退会派生另一把密钥,把"环境不支持"伪装成"口令错" |
探测是真跑一次(8 KiB / 1 迭代)而不只是 typeof WebAssembly:CSP 缺
wasm-unsafe-eval 时 WebAssembly.instantiate 才抛错,只查存在性会朝"能创建、
将来打不开"的方向失败。test/keystore-no-wasm.test.ts 用删全局 + mock hash-wasm 两条路测。
4. 双轨解锁(生物识别优先、口令回退)
SDK 方案 §3.3.5 写的是「HKDF(PRF 秘密) 包住 keystore 的加密密钥」。要让它成立, "被包住的东西"必须独立于口令存在,否则两条路算不出同一个值。于是引入一把 不落盘的数据密钥:
DEK(32 字节随机,只存在于内存,用完即清)
├─ payload:账户密钥材料(私钥 / BIP39 种子)在 DEK 下加密
├─ passwordTrack:DEK 在口令下加密(就是上面的 keystore v1)
└─ prfTrack:DEK 在 HKDF(PRF 秘密) 派生的 KEK 下包装import { createUnlockEnvelope, attachPrfTrack, detachPrfTrack, unlockEnvelope } from '@trustbaseai/account';
// 建户:口令轨(§3.3.7 要求"强制用户设置口令",所以口令轨始终存在)
let envelope = await createUnlockEnvelope(privateKey, password, { minStrength: 3 });
// 之后开启生物识别:**不需要重新加密、不需要重新抄助记词**(要一次口令解出 DEK)
envelope = await attachPrfTrack(envelope, { password, prfSecret, credentialId });
// 两条路解出**同一份**密钥材料
const a = await unlockEnvelope(envelope, { strategy: 'password', password });
const b = await unlockEnvelope(envelope, { strategy: 'prf', prfSecret });
// a 与 b 逐字节相同(test/envelope.test.ts 用 toEqual 钉住)
// 撤销:去掉 PRF 轨,payload 与口令轨原封不动
const onlyPassword = await detachPrfTrack(envelope);三条直接好处:两条轨等价(可无缝切换)、PRF 轨可以后加(不必重新加密)、 PRF 轨可以撤销(PRF 不是根,助记词与口令才是)。
5. PRF 硬件解锁:能做什么、不能做什么
✅ 能做:把"解锁 keystore 的钥匙"绑到硬件 + 生物识别/PIN。秘密由 Secure Enclave / Android Keystore(StrongBox)持有、永不导出,取它要过生物识别。我们不存它、也导不出它。
❌ 不能做(别往这个方向设计):拿 SE/Keystore 直接签链上交易。浏览器只有 WebAuthn, 而 WebAuthn 断言签的是「认证器数据 + 客户端数据哈希」,不能对任意 payload 签名 —— 拿不到一把能签 Cosmos 交易的密钥(SDK 方案 §3.3.5 的 ⚠️ 段)。
流程要点(照 WebAuthn PRF 扩展的实际行为写)
- 注册:
create()时带extensions.prf.eval.first = salt。很多平台在create()的返回值里不给results(只给enabled),所以registerWebAuthnPrfCredential()注册后立刻再get()一次断言去取results.first - 解锁:
get()时带extensions.prf.eval.first = salt,allowCredentials放凭据的rawId - salt 必须与注册时一致(PRF 的定义就是"同 salt 同输出"),所以它存在我们的包装 blob 里
- 换设备 / 重装 / 重置生物识别 → 秘密变了或凭据没了 → 认证失败 → 回退口令或助记词。 这是设计内行为,不是 bug(SDK 方案 §3.3.7 第 2 条)
src/prf-webauthn.ts 是骨架:结构照规范写,但没有 Node 单测覆盖成功路径
(Node 里没有 navigator.credentials)。理由与真机验证清单见下面 §9。
6. 口令强度(估算,不是复杂度规则)
import { estimatePasswordStrength, isBlocklisted } from '@trustbaseai/account';
const strength = estimatePasswordStrength(password, { extraBlocklist: ['acme-store'] });
// { score: 0..4, reasons: ['这是最常见的十个口令之一', …], acceptable: boolean,
// guessesLog10, blocklisted, tooLong }| 项 | 口径 |
|---|---|
| 估算器 | zxcvbn-ts,评分 0..4 + 中文理由(库默认返回 key 如 topTen,本包自己映射文案,不引英文文案包) |
| 通过线 | score >= 3 且不在本地黑名单(acceptableScore 可调) |
| 黑名单 | 纯本地、不联网:zxcvbn 自带字典(passwords-common 49233 条 + diceware-common 7776 条)+ APP_BLOCKLIST(产品/域名/币种/示例域名/中文产品词) |
| 匹配规则 | 归一化(小写 + 去非字母数字 + NFC)后精确命中,或包含命中(拉丁词要求 ≥5 字符,CJK 词 ≥2 字符) |
| 长度 | 上限 128 字符(SDK 方案 §3.3.6 的防 DoS 条款);超长短路拒绝且不做估算 |
| 不做 | 大小写/数字/符号的复杂度规则(规则只会逼出 Password1! 这种"看着合规实际上榜"的口令);也不做 HIBP 之类的联网查询 |
已知局限(照实说):由多个常见词拼成的长短语(如 correct horse battery staple)
会被估成高分 —— 这是评分模型的性质,不是 bug。这类口令仍受 Argon2id 与硬件门控保护。
另外,score 的具体数值会随 zxcvbn 数据版本漂移,所以测试里断言的是性质
(弱口令必须拒绝、强口令必须通过、同分不同理由)而不是固定分值。
7. 助记词与抄写门
| API | 语义 |
|---|---|
| generateMnemonic(strength?) | 生成 128 / 256 bit(12 / 24 词);随机源走 crypto.getRandomValues,可注入测试替身 |
| validateMnemonic(mnemonic) | 校验 BIP39(词表 + 校验和 + 词数);宽进:12..24 词都收 |
| entropyToMnemonic / mnemonicToEntropy | 熵 ↔ 词(只收 16 / 32 字节),用来跑官方向量 |
| splitMnemonicWords / normalizeMnemonic | 空白归一化(多空格/换行折成单空格 + NFKD) |
| pickTranscriptionIndices(words, n, rng?) | 抄写门抽词:0 基下标、升序去重、拒绝采样(无模偏差) |
| checkTranscription(words, indices, answers) | 校验回填:给 {ok, failed, missing},不返回正确答案(否则抄写门就成了"帮你填回去"的工具) |
两处有意的不对称,都写进注释与测试:
- 生成严出、校验宽进:我们只造 12/24 词(少一种长度 = 少一类"抄错还没发现"), 但校验接受任何合法 BIP39 —— 用户拿来的旧纸密钥可能是 15/18/21 词的, 那是用户的钱,不因为我们不喜欢这个长度就拒收
- 空白归一化:BIP39 的种子是对助记词字符串做 PBKDF2,多一个空格就是另一份种子。 用户从手机备忘录粘贴几乎一定带多余空白,不归一会造成"抄对了却打不开"这种最伤人的失败
8. 内存卫生(说清楚能做到什么)
import { wipe, wipeRandom } from '@trustbaseai/account';能做到:本包自己分配的 Uint8Array(派生密钥、KDF 输入字节、DEK)在 finally 里清零;
WebCrypto 的密钥句柄以 extractable: false 导入(少一份可导出的副本);
不提供任何把明文写进 localStorage / sessionStorage 的路径(同构守卫也会拦这类 API)。
做不到(不夸大):
- JS 字符串不可清零:口令与助记词都是不可变字符串,只能等 GC
- 引擎/运行时内部副本不可达:WASM 线性内存里 Argon2id 的输入、WebCrypto 内部持有的 密钥副本、V8 的 rope/cons 字符串中间态 —— 拿不到指针,清不了
- 调用方自己
slice()/ 展开 / 转 string 产生的副本,也不在本函数的射程内
所以 wipe() 的定位是降低窗口,不是保证抹除。真正的防线是短生命周期 + 不落明文 +
严格 CSP 且不引第三方脚本(SDK 方案 §3.3.3 第 2 条)。
9. 未验证清单(别当已实现)
| 项 | 状态 |
|---|---|
| WebAuthn PRF 真机路径(src/prf-webauthn.ts) | ❌ 未在真机验证。只覆盖了"没有 WebAuthn 时优雅降级"这一支;create()/get() 的成功路径必须在真机浏览器(iOS Safari 18+ / Android Chrome / 桌面 Chrome 116+)逐个验证:① 注册是否返回 prf.enabled ② 断言是否给 results.first ③ allowCredentials 是否要配 evalByCredential ④ 生物识别被拒/取消时的错误形态 |
| navigator.credentials 之外的平台差异 | ❌ 未做 iOS/Android/桌面三端矩阵测试(无真机环境) |
| 浏览器里的 Argon2id 实测耗时 | ⚠️ 只在 Node 的 hash-wasm 上测过(~40 ms @ 19 MiB/t2)。手机浏览器(尤其低端机)会慢数倍,上生产前应实测并考虑调参 |
| 空闲锁定 / 二次解锁(§3.3.2 的 15 分钟) | ❌ 不属本包:设备账户的"空闲即锁"在 pwa-kit 的会话层,商户侧的 15 分钟在守护进程 —— 本包只提供原语与 wipe() |
| IndexedDB / 文件持久化 | ❌ 不属本包(只定义密文格式):pwa-kit 与守护进程各自落盘 |
| 配对(异机登录商户账户,§3.2.2) | ❌ planned(需要 P2P 与守护进程,见 SDK 方案 §3.2.2) |
| OS 凭据库集成(systemd-creds / DPAPI / Keychain,§3.3.5) | ❌ planned(属守护进程 trustbased) |
| ESM 产物 | ❌ 当前只有 CJS(与 @trustbase/protocol 一致,浏览器经打包器可用;动态 import('hash-wasm') 在 CJS 产物里会被打包器转成 require,仅延迟执行、不减少体积) |
| 账户面板 UI(§3.2.4) | ❌ planned(@trustbase/shop-ui 的 AccountPanel) |
关于交叉验证用的 cosmjs 版本
devDependency 用的是 @cosmjs/*@0.36.x(全 CJS),而不是仓库别处出现的 0.38.1:
0.38 的 CJS 产物依赖 ESM-only 的 @scure/base@2,jest 在 Node 20/22 上无法
require 它(需要 Node ≥ 24.9 且开 --experimental-vm-modules),会让本包测试在本仓库
声明的 engines: node >= 20 上直接红。0.36 的地址派生逻辑与 0.38 完全一致
(BIP39 → BIP32/SLIP-10 → secp256k1 → ripemd160(sha256) → bech32),交叉验证的意义不变。
另外也用 0.38.1 在纯 Node 下复算过一遍,结论相同(记在 test-vectors/address.golden.json
的 verifiedAgainst 字段里)。
10. 构建与测试
npm run build # tsc → dist/(CommonJS + .d.ts)
npm test # jest(10 个套件 / 334 个用例)
npm run typecheck # tsc --noEmit测试的分工(为什么这么分):
| 套件 | 守什么 |
|---|---|
| test/address.test.ts | 口径正确性:公开向量 + node:crypto 独立复算 + cosmjs 交叉验证 + HRP/大小写/长度边界 |
| test/hd.test.ts | 助记词→地址全链路与 cosmjs 一致(含随机助记词 5 轮) |
| test/keystore.test.ts | 往返、四类失败(口令错/密文改/头改/参数改)、OpenSSL 双向对照、参数下限、强度门 |
| test/keystore-no-wasm.test.ts | 无 WASM 时"创建退 PBKDF2、解锁拒绝"两条语义 |
| test/prf.test.ts | HKDF(RFC 5869 向量 + OpenSSL 对照)、包装/拆包、AAD 覆盖、假 provider |
| test/envelope.test.ts | 双轨解出同一份材料、可后加/可撤销、失败路径 |
| test/password.test.ts | 弱口令必拒/强口令必过、同分不同理由、黑名单、长度上限 |
| test/mnemonic.test.ts | BIP39 官方向量(双向)、改一个词必失败、抄写门抽位与校验 |
| test/isomorphic.test.ts | 同构纪律(Node-only 与浏览器-only API 都拦)、依赖清单、禁用 Math.random |
| test/constants-drift.test.ts | 与 @trustbase/protocol 的 BECH32_PREFIX / HD_PATH 不漂移 |
金标准向量在 test-vectors/(三个文件,每个都标了来源与"是外部权威还是本实现冻结")。
11. 相对设计文档的加固与取舍(供评审)
设计文档是口径来源,下面这些是实现层的补充,写在这里免得评审时以为是偷偷改方案:
| # | 改动 | 为什么 | 是否影响兼容 |
|---|---|---|---|
| 1 | 明文头作为 GCM 的 AAD | 不认证参数 = KDF 参数可被改(§3.1) | 是格式的一部分,改它要 bump KEYSTORE_VERSION |
| 2 | 引入 DEK 信封(envelope.ts) | §3.3.5 的"PRF 包住 keystore 密钥"需要一份独立于口令的材料才成立 | 新增文件格式,不动 keystore v1 |
| 3 | 口令做 NFC 归一化 | 输入法差异会造成"输对了打不开";NFKC 才是真改口令,所以只用 NFC | 影响跨实现互通,必须一致 |
| 4 | 助记词空白归一化 | 同上(种子是对字符串做 PBKDF2) | 必须一致 |
| 5 | createKeystore 的 minStrength 是可选的 | 它是密码学原语,不该替调用方做产品策略;但 PWA 建户应当传 3(§3.3.6 的"低于则拒绝") | 否 |
| 6 | 黑名单短词(<5 拉丁字符)只做精确匹配,CJK 放宽到 2 字符 | 避免 mytct123 这类误伤;评分仍是主防线 | 否 |
| 7 | 解析时拒绝未知字段 | v1 形状冻结;多出来的字段意味着这不是我们的密文 | 是(v1 内不允许加字段) |
| 8 | 不可信 KDF 参数有上限(内存 ≤1 GiB、迭代 ≤1000 万…) | 防"一个 blob 就是一次 DoS" | 否(远高于任何合理参数) |
| 9 | 密码学原语的错误一律可分支(见 §3.3 表) | 调用方要能区分"环境不支持"与"口令错",否则只能给用户含糊提示 | 否 |
12. 尚未做(本包内的 planned)
- 多凭据 PRF:当前
prfTrack只存一条(一个凭据)。多设备/多凭据要扩成数组(格式 v2) - 流式/大文件 keystore:本包定位是"密钥库",密钥材料上限 256 字节(不当时通用加密工具)
- 口令强度实时输入反馈的 UI 组件:本包只给数据(
score/reasons),组件在shop-ui extraBlocklist的字典加载器(从文件/网络批量导入商户自定义黑名单):接口已留,加载器未做
