基于 WebAssembly 的 Linera 链上 Web 客户端:`@linera/client` 集成指南与 Signer 签名体系实战

基于 WebAssembly 的 Linera 链上 Web 客户端:@linera/client 集成指南与 Signer 签名体系实战

【免费下载链接】linera-protocol Main repository for the Linera protocol 【免费下载链接】linera-protocol 项目地址: https://gitcode.com/GitHub_Trending/li/linera-protocol

@linera/client 是 Linera 协议官方发布到 NPM 的浏览器端 Web 客户端包(对应仓库中的 web/@linera/client 目录,Rust crate 名为 linera-web)。它把 Linera 客户端的 Rust 核心编译为 WebAssembly,并辅以 TypeScript 类型的 JavaScript 封装,让前端应用能够直接创建链、查询余额、提交区块,并通过统一的 Signer 接口完成交易签名。读完本文,你将掌握该包的整体架构、构建发布流程、initialize 初始化细节,以及两套开箱即用的签名器(PrivateKeyWebCryptoEd25519)的选型、用法与底层实现原理。

一、定位与架构:Rust 核心如何跑进浏览器

web/@linera/client/Cargo.toml 可以看到,该包对应的 Rust crate 名为 linera-web,版本与 @linera/clientweb/@linera/client/package.json 中为 0.16.0)保持一致,crate-type 声明为 ["cdylib", "rlib"],其中 cdylib 就是为编译成 wasm32 目标准备的。核心依赖直连 Linera 的各层库:

  • linera-base(启用 web feature):基础类型与密码学原语,如 AccountOwnerEd25519PublicKey
  • linera-clientweb + wasmer + indexed-db):客户端上下文,负责与验证节点交互;
  • linera-corelinera-executionlinera-rpclinera-storagelinera-views(均启用 web/wasmer/indexeddb 相关 feature):核心共识、执行、RPC 网络与浏览器存储后端;
  • linera-faucet-client:与测试网水龙头交互的客户端。

web/@linera/client/src/lib.rs 通过 #![cfg(target_arch = "wasm32")] 显式声明“本 crate 仅面向 Web 编译”,并导出 ClientChainListenerFormatsStorageWalletSignerError 等模块,同时将类型别名 Environment 组装为 linera_core::environment::Impl<Storage, Network, Signer, Wallet>——也就是说,浏览器端的存储、网络、签名、钱包四件套全部由 Web 适配层注入。

在 TypeScript 侧,web/@linera/client/src/index.ts 是包的统一入口:

export * from './wasm/index.js';
export * from './error/index.js';
export * as signer from './signer/index.js';
export type { Signer } from './signer/Signer.js';

其中 wasm/index.js 即 Rust 编译产物对应的 JS 封装(wasm-bindgen 生成),signer 命名空间则来自 web/@linera/client/src/signer/index.ts,导出 CompositePrivateKeyWebCryptoEd25519 三个实现类与 Signer 类型。

二、构建与发布

文档明确指出构建的前提:确保 cargo 调用的是 nightly 版 rustc(仓库根目录的 rust-toolchain.tomlweb/@linera/client 目录下的工具链配置共同约束了这一点),然后执行:

pnpm build     # 构建
pnpm publish   # 发布到 NPM

web/@linera/client/package.json 的脚本可以还原完整的工程化流水线:

  • buildbash build.bash --release——以 release 模式编译 Rust 为 Wasm 并产出 dist 目录(exports 字段指向 ./dist/index.js./dist/index.d.ts);
  • preparepnpm build,保证安装依赖后自动产出构建产物;
  • lintpnpm build && cargo fmt --check && cargo clippy——同时校验 Rust 与 TS 侧;
  • testpnpm exec vitest --run——运行 web/@linera/client/tests 下的浏览器测试套件(Chain.test.tsClient.test.tsFormats.test.tsPrivateKeySigner.test.tsWebCryptoEd25519.test.ts);
  • docpnpm exec typedoc——生成 API 文档(对应 docs/ 目录下的 interfaces/Signer.html 等页面)。

包对外只发布 dist 目录("files": ["dist"]),运行时依赖仅 ethers(EIP-191 签名)与 bowser(浏览器 UA 检测)。

三、初始化客户端:linera.initialize()

在使用任何需要 Wasm 的功能前,必须先调用 linera.initialize()。其完整签名位于 web/@linera/client/src/lib.rs

#[derive(serde::Deserialize, Default, tsify::Tsify)]
#[tsify(from_wasm_abi)]
#[serde(default, rename_all = "camelCase")]
pub struct InitializeOptions {
    pub log: String,       // tracing_subscriber::EnvFilter 格式的日志过滤串,默认 INFO
    pub profiling: bool,   // 是否通过 Performance API 上报性能数据
}

对应 JS 侧(web/@linera/client/src/index.ts)的行为可以总结为:

  • 优先从当前 URL 的查询参数读取 LINERA_LOG(覆盖日志过滤串)与 LINERA_PROFILING(开启性能上报),方便开发期通过地址栏直接调试;
  • 调用 Wasm 模块的 default() 初始化并执行 wasm.initialize(options)
  • Rust 侧使用 std::sync::OnceLock 保证全局只初始化一次,重复调用会输出 already initialized 警告;
  • 默认日志过滤串为 user_application_log=off,linera_web=info,即默认关闭应用层日志、只保留客户端库自身的信息日志,避免刷屏;传入自定义 log 串时则完全使用调用者提供的过滤规则;
  • 日志输出到浏览器 Console(tracing_web::MakeWebConsoleWriter),开启 profiling 后会额外挂载 performance_layer 上报到 Performance API。

另外 initialize() 内置了一个针对 Safari 26.2 的已知崩溃(WebKit #303387,共享内存 + 多线程下 memory.grow 导致崩溃)的规避逻辑:检测到该版本时,先单线程预分配并释放 768 MiB 内存块,避免 Wasm 工作线程启动后再触发内存增长。

一个完整的最小启动代码:

import * as linera from "@linera/client";

await linera.initialize();
// 可选:传入初始化选项(也可用 URL 参数 LINERA_LOG / LINERA_PROFILING 覆盖)
// await linera.initialize({ log: "linera_web=debug", profiling: true });

四、Signer 接口:跨语言签名桥

Signer 是连接 JS 签名实现与 Rust 验证逻辑的抽象层,定义于 web/@linera/client/src/signer/Signer.ts,包含三个方法:

方法作用返回格式要求
sign(owner, value)owner 对应的私钥对 value 签名Address20:EIP-191 签名,0x 前缀、65 字节 r||s||vAddress32:原生 64 字节 Ed25519 签名 r||s,均以 0x 前缀十六进制字符串返回
getPublicKey(owner)返回 owner 对应的公钥Address20:未压缩 secp256k1 公钥(65 字节);Address32:原始 32 字节 Ed25519 公钥
containsKey(owner)判断签名器是否持有该 owner 的私钥布尔值

接口注释揭示了 Linera 账户所有者的两种地址方案:

  • Address20:EVM 系 secp256k1 账户,使用 EIP-191 签名(即 MetaMask 等钱包的签名协议);
  • Address32:Ed25519 账户,对 32 字节 CryptoHash 预哈希直接做 Ed25519 签名。

在 Rust 侧(web/@linera/client/src/signer/mod.rs),这个 JS 对象通过 wasm_bindgenextern "C" 声明被 linera_base::crypto::Signer trait 适配。关键逻辑是:Rust 把待签名的 CryptoHash 原始字节(而非 BCS 序列化结果)传给 JS,收到签名字符串后按 owner 类型分派:

  • Address20:解析 r||s||v 格式,构造 AccountSignature::EvmSecp256k1 { signature, address }
  • Address32:额外调用 getPublicKey 取得公钥,若 AccountOwner::from(public_key) != owner提前报错(防止签名器返回“有效签名但公钥不匹配”的异常状态),随后构造 AccountSignature::Ed25519 { signature, public_key }
  • Reserved:直接返回 InvalidAccountOwnerType 错误。

同时该文件定义了 SignerError 枚举(MissingKeySigningErrorPublicKeyParseJsConversionUnexpectedSignatureFormatInvalidAccountOwnerTypeUnknown),所有 JS 抛出的错误都会被映射为这些可辨识的错误码。

五、签名器实现一:PrivateKey(仅限测试/本地开发)

PrivateKey 实现位于 web/@linera/client/src/signer/PrivateKey.ts,内部直接持有一个内存中的 ethers.Wallet,按 EIP-191 方案签名。文档与源码都给出了醒目的安全警告:私钥以明文形式留在 JS 内存中,一旦页面暴露给不可信代码(XSS、恶意依赖)即等于泄露,只允许用于测试与本地开发,严禁用于生产环境

import * as linera from "@linera/client";
import { PrivateKey } from "@linera/client/signer";

// 方式一:随机生成(内部经由助记词创建)
const signer = PrivateKey.createRandom();

// 方式二:从助记词恢复
const signer2 = PrivateKey.fromMnemonic("your mnemonic phrase ...");

// 方式三:从原始私钥构造
const signer3 = new PrivateKey("0x..."); // ethers.Wallet 兼容的私钥 hex

const owner = signer.address(); // 以太坊风格地址,即 Address20 owner
await signer.sign(owner, new Uint8Array(32)); // EIP-191 签名,返回 0x 前缀 65 字节 r||s||v

实现要点(均可在源码中验证):

  • sign()getPublicKey()containsKey() 三个方法都会先用 ethers.isAddress 校验 owner 格式并比对 wallet.address,不匹配即抛出 "Invalid owner address"
  • createRandom() 经由 ethers.Wallet.createRandom() 的助记词间接创建;
  • 返回的公钥是 wallet.signingKey.publicKey,即未压缩的 65 字节 secp256k1 公钥,符合 Signer 接口对 Address20 的要求。

对应的测试见 web/@linera/client/tests/PrivateKeySigner.test.ts

六、签名器实现二:WebCryptoEd25519(浏览器生产级会话密钥)

WebCryptoEd25519web/@linera/client/src/signer/WebCryptoEd25519.ts)是文档推荐的面向生产环境的签名器,其核心安全模型是:原始私钥字节永不进入 JavaScript

具体机制:

  1. 通过 Web Crypto API 的 crypto.subtle.generateKey({ name: "Ed25519" }, false, ["sign", "verify"]) 生成密钥对,extractable: false 意味着私钥永远无法被导出;
  2. 私钥以 CryptoKey 句柄形式持久化到 IndexedDB(数据库名 linera-signer,object store 名 keys,版本 1),刷新页面后依然可用;
  3. 页面内的攻击者(如 XSS 或恶意依赖)只能在当前标签页存活期间请求签名,无法把密钥本身拷走——这就是“会话密钥”级别的安全边界。

6.1 常用 API 一览

方法说明
generate()生成新的密钥对并返回签名器(不落盘,需手动 persist;适合先注册链上再落盘的流程)
persist(recordKey)把密钥对写入 IndexedDB(覆盖旧记录)
load(recordKey)读取已有记录,不存在则返回 null
loadOrCreate(recordKey)便利方法:有则加载,无则生成并持久化
delete(recordKey)删除本地记录(例如链上撤销授权或迁移密钥形态后)
address()返回 Address32 账户所有者地址,格式 0x + 64 位小写十六进制
sign(owner, value) / getPublicKey(owner) / containsKey(owner)实现 Signer 接口

6.2 owner 地址的派生

WebCryptoEd25519 的 owner 地址不是公钥哈希直取,而是 AccountOwner::Address32(Keccak256(BCS(public_key)))——由 Rust 侧导出给 Wasm 的函数 accountOwnerFromEd25519PublicKey 派生(web/@linera/client/src/crypto.rs):

#[wasm_bindgen(js_name = "accountOwnerFromEd25519PublicKey")]
pub fn account_owner_from_ed25519_public_key(public_key: &[u8]) -> Result<String, JsError> {
    let pubkey = Ed25519PublicKey::from_slice(public_key).map_err(|e| JsError::new(&e.to_string()))?;
    Ok(AccountOwner::from(pubkey).to_string())
}

该派生路径的正确性由双向测试钉死:浏览器测试 web/@linera/client/tests/WebCryptoEd25519.test.ts 中,用固定的公钥字节 0x01..0x20 断言 JS 侧派生结果必须等于 0xeacee5344cbec9569e836f95029d476c700f4f5bc007c71c0752c73fba149043,而该期望值来自 linera-base/src/identifiers.rs 中的 Rust 已知向量测试,两侧互锁,防止派生逻辑漂移。

6.3 持久化的工程细节

源码中值得注意的两处实现细节:

  • 写入必须等待 transaction.oncompletetxWrite 只有在 IndexedDB 写事务提交完成后才 resolve,确保 persist() 返回时密钥已经落到磁盘日志,避免“返回后立刻关标签页导致密钥丢失”的竞态;读取(txRead)则只等 req.onsuccess 即可,因为此时值已在内存中;
  • 多标签页阻塞处理openDb() 监听 onblocked(另一个旧版本连接阻塞升级时直接报错,并提示关闭其他标签页)与 onversionchange(检测到其他标签页触发升级时主动关闭本连接放行)。

6.4 签名流程与防御性拷贝

sign() 内部先校验 owner 匹配(只规范化调用方一侧的大小写,因为 record.owner 恒为 Rust hex::encode 产出的小写形式),随后做一次防御性拷贝new Uint8Array(value).buffer——因为部分浏览器会拒绝为 SharedArrayBuffer 支持的视图调用 crypto.subtle.sign,无论调用方如何取得 value,都能保证签名成功。

6.5 使用示例

文档给出的最小用法(也是标准姿势):

import * as linera from "@linera/client";

await linera.initialize();
const signer = await linera.signer.WebCryptoEd25519.loadOrCreate("my-app-key");
const owner = signer.address(); // "0x" + 64 hex chars (AccountOwner::Address32)

更完整的生命周期:

// 生成新密钥(不落盘)→ 先在链上注册 → 再持久化
const signer = await linera.signer.WebCryptoEd25519.generate();
const owner = signer.address();
// ... 把 owner 注册为链的所有者/自动签名者 ...
await signer.persist("my-app-key");

// 下次打开页面直接恢复同一个账户
const restored = await linera.signer.WebCryptoEd25519.loadOrCreate("my-app-key");
console.log(restored.address() === owner); // true

// 签名(value 为 32 字节预哈希或其他数据)
const sig = await restored.sign(owner, new Uint8Array(32));

// 本地撤销(如链上 forgoDelegation 成功后清理)
await linera.signer.WebCryptoEd25519.delete("my-app-key");

浏览器兼容性(Web Crypto Ed25519 目前已在所有现代稳定浏览器可用):Chrome 137+、Firefox 129+、Safari 17+

七、签名器实现三:Composite——多签名者编排

当应用同时支持多种签名来源(例如既内置 WebCryptoEd25519 会话密钥,又允许接入 MetaMask 等 EIP-1193 钱包)时,可以使用 web/@linera/client/src/signer/Composite.ts 提供的 Composite 实现,它按构造顺序依次询问每个子签名器:

import { signer } from "@linera/client";

const session = await signer.WebCryptoEd25519.loadOrCreate("session-key");
const composite = new signer.Composite(session, metamaskSigner /* 任意实现了 Signer 的对象 */);

await composite.sign(owner, value); // 自动路由到持有该 owner 私钥的那个签名器

其语义为:对 sign / getPublicKey,遍历子签名器,遇到第一个 containsKey(owner)true 的即委派给它;containsKey 则为“任一子签名器持有即返回 true”。若没有任何签名器持有该 owner,则抛出 no signer found for owner ...

八、签名器接入链路与测试闭环

把前面几节串起来,一次浏览器内交易签名在 JS 与 Rust 之间的完整数据流是:

  1. 应用代码调用 signer.sign(owner, value)(JS 侧,ethers / Web Crypto 完成原始签名,返回 0x 前缀 hex 字符串);
  2. Wasm 桥(web/@linera/client/src/signer/mod.rs)把 value 作为 CryptoHash 预哈希原始字节传入,按 owner 类型解析签名与公钥;
  3. Rust 侧构造 AccountSignature::EvmSecp256k1AccountSignature::Ed25519,交给 linera-clientClientContext 打包进区块/证书;
  4. 验证节点按标准密码学路径校验签名。

测试闭环体现在 web/@linera/client/tests/WebCryptoEd25519.test.ts 中:

  • 已知向量测试:固定的 Ed25519 公钥必须派生为固定的 Address32 owner(与 Rust 侧测试互锁);
  • IndexedDB 往返测试loadOrCreateload 出的签名器地址不变,且重新加载的 CryptoKey 仍可正常签名——测试里先用 crypto.subtle.importKey 导入公钥、再用 crypto.subtle.verify 独立验证签名,证明签名确实由原始私钥产生;
  • 幂等性测试:同一 recordKey 多次 loadOrCreate 返回同一个地址。

这些测试都以真实浏览器环境运行(vitest + @vitest/browser + playwright),而非 mocks。

九、安全模型小结

结合文档与源码,可以对两套签名器的安全边界给出精确结论:

维度PrivateKeyWebCryptoEd25519
适用场景测试、本地开发、CLI 类工具生产浏览器应用(会话密钥)
私钥存放JS 内存明文(ethers.Wallet浏览器密码学子系统(extractable: falseCryptoKey),永不进入 JS
持久化无(需调用方自行保管助记词/私钥)IndexedDB(linera-signer 库,CryptoKey 句柄)
签名方案secp256k1 + EIP-191(Address20Ed25519(Address32
威胁模型页面被攻陷即密钥泄露攻击者仅能在标签页存活期间请求签名,无法导出密钥;标签页关闭后攻击面消失

一句话选型建议:测试用 PrivateKey,生产环境的自动会话密钥用 WebCryptoEd25519;如果还需要接入 MetaMask 等外部钱包,将其封装为实现了 Signer 接口的对象后,用 Composite 统一编排。

十、相关资源

【免费下载链接】linera-protocol Main repository for the Linera protocol 【免费下载链接】linera-protocol 项目地址: https://gitcode.com/GitHub_Trending/li/linera-protocol

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值