npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@trustbaseai/account

v0.1.0

Published

TrustBase 账户与密钥库:tct1… 地址派生、口令加密 keystore(Argon2id + AES-256-GCM)、WebAuthn PRF 硬件解锁、口令强度与 BIP39 助记词

Downloads

224

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

验证方式(三层,缺一不可):

  1. 公开向量:BIP-173 的 bech32 测试向量(含 P2WPKH 示例 bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4)、 比特币"私钥 = 1"的 hash160 751e76e8199196d454941c45d1b3a323f1433bd6、BIP-39 官方英文向量。 出处逐条写在 test-vectors/address.golden.json 的 sources 里
  2. 独立复算:test/address.test.ts 用 node:crypto(OpenSSL)自己算一遍 sha256/ripemd160
  3. 交叉验证:与 @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 扩展的实际行为写)

  1. 注册:create() 时带 extensions.prf.eval.first = salt。很多平台在 create() 的返回值里不给 results(只给 enabled),所以 registerWebAuthnPrfCredential() 注册后立刻再 get() 一次断言去取 results.first
  2. 解锁:get() 时带 extensions.prf.eval.first = salt,allowCredentials 放凭据的 rawId
  3. salt 必须与注册时一致(PRF 的定义就是"同 salt 同输出"),所以它存在我们的包装 blob 里
  4. 换设备 / 重装 / 重置生物识别 → 秘密变了或凭据没了 → 认证失败 → 回退口令或助记词。 这是设计内行为,不是 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},不返回正确答案(否则抄写门就成了"帮你填回去"的工具) |

两处有意的不对称,都写进注释与测试:

  1. 生成严出、校验宽进:我们只造 12/24 词(少一种长度 = 少一类"抄错还没发现"), 但校验接受任何合法 BIP39 —— 用户拿来的旧纸密钥可能是 15/18/21 词的, 那是用户的钱,不因为我们不喜欢这个长度就拒收
  2. 空白归一化: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 的字典加载器(从文件/网络批量导入商户自定义黑名单):接口已留,加载器未做