@byokit/seal

中文文档
0.3.2发布于 2026/10/25Apache-2.0TypeScriptES Module构建来源已验证近 30 天下载 9658

TypeScript kits that let people sign in to your app with the AI plans they already pay for, on web, phone and desktop.

原始使用文档
文档目录11 节
  1. Install
  2. Quickstart
  3. API at a glance
  4. Raw keys, authenticated boxes and notices
  5. Opening a notice in an iOS Notification Service Extension
  6. Formats
  7. Randomness and keys
  8. Dependencies
  9. Tests
  10. Links
  11. License

@byokit/seal

npm CI Apache 2.0 Node 22+ | browsers | React Native

Portable box, secretbox and Ed25519 signatures.
Box, secretbox and Ed25519 signatures for Node 22+, browsers/PWAs and React Native, with no native modules and no Node runtime imports. For apps that store or send small secrets and need byte-identical sealed boxes everywhere (see the API table and Tests for the compatibility vectors).

Install

npm install @byokit/seal

npm · Latest release · All releases

Quickstart

npm install @byokit/seal

On React Native, install a crypto.getRandomValues polyfill before using it (for example react-native-get-random-values).

Seal and open a box, keep a JSON value in a secretbox, and sign and verify a message:

import {
  boxKeyPair, boxPublicKey, boxKeyPairFromSeed, sealBox, openBox, openBoxFromSeed,
  box, openAuthBox, sealNotice, openNotice,
  sealJson, openJson,
  signingKeyPairFromSeed, signDetached, verifyDetached,
} from '@byokit/seal';

const text = new TextEncoder().encode('hello from the phone');

// Box: anyone with the recipient's public key can seal; only the secret opens.
const recipientSeed = crypto.getRandomValues(new Uint8Array(32));
const recipient = boxKeyPairFromSeed(recipientSeed);
const bundle = sealBox(text, recipient.publicKey);
console.log('box bytes:', bundle.length);
console.log('opened:', new TextDecoder().decode(openBox(bundle, recipient.secretKey)!));
console.log('wrong key:', openBox(bundle, crypto.getRandomValues(new Uint8Array(32))));

// Secretbox JSON: one 32-byte key seals and opens data at rest.
const key = crypto.getRandomValues(new Uint8Array(32));
const stored = sealJson({ machines: ['desk'] }, key);
console.log('json:', openJson(stored, key));
stored[stored.length - 1] ^= 1;
console.log('tampered json:', openJson(stored, key));

// Ed25519 detached signatures.
const signing = signingKeyPairFromSeed(crypto.getRandomValues(new Uint8Array(32)));
const signature = signDetached(text, signing.secretKey);
console.log('signature bytes:', signature.length, 'secret key bytes:', signing.secretKey.length);
console.log('verified:', verifyDetached(text, signature, signing.publicKey));
console.log('other message:', verifyDetached(new TextEncoder().encode('changed'), signature, signing.publicKey));

Output of running the TypeScript file with node (Node 22.18+):

box bytes: 92
opened: hello from the phone
wrong key: null
json: { machines: [ 'desk' ] }
tampered json: null
signature bytes: 64 secret key bytes: 64
verified: true
other message: false

The 92-byte box is 32 (ephemeral public key) + 24 (nonce) + 16 (MAC) + 20 (the message).

API at a glance

ExportWhat it does
boxKeyPair(rng?)Fresh raw 32-byte X25519 secret and its public key
boxPublicKey(secret)X25519 public key from a raw 32-byte secret
boxKeyPairFromSeed(seed)X25519 key pair from a 32-byte seed; same as libsodium crypto_box_seed_keypair
sealBox(bytes, recipientPublicKey, rng?)Seals bytes to a public key with a fresh ephemeral key
openBox(bundle, recipientSecret)Opens with a raw secret; Uint8Array or null
openBoxFromSeed(bundle, recipientSeed)Opens with a seed, as in the original API
box(bytes, theirPublic, mySecret, rng?)Known-sender authenticated box
openAuthBox(bundle, theirPublic, mySecret)Opens a known-sender box; Uint8Array or null
sealNotice(value, boxPublicKey, rng?)JSON in an anonymous box; { v: 1, sealed: string }
openNotice(data, secret)Opens a notice with a raw secret; unknown or null
sealSecretBox(bytes, key, rng?)Seals bytes with a 32-byte secret key
openSecretBox(bundle, key)Opens a secretbox; Uint8Array or null
sealJson(value, key, rng?)JSON.stringify, UTF-8, then secretbox
openJson(bundle, key)Opens and parses; the value or null
signingKeyPairFromSeed(seed)Ed25519 key pair from a 32-byte seed; tweetnacl-style 64-byte secret key
signDetached(bytes, secretKey)64-byte detached signature
verifyDetached(bytes, signature, publicKey)true or false
RandomBytes (type)(length: number) => Uint8Array, the optional RNG the seal functions take

Every call in one place:

import {
  boxKeyPair, boxPublicKey, boxKeyPairFromSeed, sealBox, openBox, openBoxFromSeed,
  box, openAuthBox, sealNotice, openNotice,
  sealSecretBox, openSecretBox, sealJson, openJson,
  signingKeyPairFromSeed, signDetached, verifyDetached,
} from '@byokit/seal';

const recipient = boxKeyPairFromSeed(recipientSeed); // 32-byte seed; same as libsodium crypto_box_seed_keypair
const bundle = sealBox(plaintext, recipient.publicKey);
const opened = openBox(bundle, recipient.secretKey); // Uint8Array | null
const sealed = sealSecretBox(plaintext, secretboxKey); // 32-byte key
const unsealed = openSecretBox(sealed, secretboxKey); // Uint8Array | null
const stored = sealJson({ machines: [] }, secretboxKey);
const catalog = openJson(stored, secretboxKey); // unknown | null
const signing = signingKeyPairFromSeed(signingSeed); // 32-byte seed
const signature = signDetached(plaintext, signing.secretKey); // 64-byte signature
verifyDetached(plaintext, signature, signing.publicKey); // boolean

Raw keys, authenticated boxes and notices

const sender = boxKeyPair();
const device = boxKeyPair();
const authenticated = box(text, device.publicKey, sender.secretKey);
const opened = openAuthBox(authenticated, sender.publicKey, device.secretKey);
const notice = sealNotice({ kind: 'completed', job: 'build' }, device.publicKey);
const event = openNotice(notice, device.secretKey);

openBox now takes a raw X25519 secret. For stored seeds, keep boxKeyPairFromSeed(seed) and pass its secretKey, or replace the old openBox(bundle, seed) call with openBoxFromSeed(bundle, seed). Stored ciphertext and seed derivation are unchanged. A raw secret and a seed are different inputs.

A known-sender box requires a trusted public key for the peer; both peers can produce its ciphertext, so it is not a signature. Anonymous boxes and notices do not identify the sender. Validate decrypted notice values in the app before acting on them. JSON null is indistinguishable from an opening failure; undefined values, cyclic objects and BigInts cannot be sealed as JSON.

Opening a notice in an iOS Notification Service Extension

No JavaScript runs in an iOS Notification Service Extension, so the package also ships a Swift opener, ios/Sources/ByokitSeal/ByokitSeal.swift (CryptoKit only, iOS 13+). Add that file to the extension target. With @byokit/relay's mutableContent: true, iOS runs the extension before the alert shows, even when the app is not running, and the extension can replace the generic text with the notice:

import UserNotifications

final class NotificationService: UNNotificationServiceExtension {
  override func didReceive(_ request: UNNotificationRequest, withContentHandler done: @escaping (UNNotificationContent) -> Void) {
    let content = request.content.mutableCopy() as! UNMutableNotificationContent
    if let secret = ByokitSeal.keychainSecret(service: "app:no-auth", account: "notice-secret", accessGroup: "TEAMID.com.example.shared"),
       let notice = ByokitSeal.openNotice(userInfo: request.content.userInfo, secret: secret) as? [String: Any],
       let title = notice["title"] as? String, let body = notice["body"] as? String {
      content.title = title // validate before showing; the notice's sender is not authenticated
      content.body = body
    }
    done(content) // on any failure the generic alert shows unchanged
  }
}

The app owns the notice key: it makes it with boxKeyPair(), gives the public key to its host, and saves only the 32-byte secret as an unpadded base64url string in a keychain access group shared with its extension, readable after first unlock (with expo-secure-store: setItemAsync('notice-secret', secret, { accessGroup, keychainAccessible: AFTER_FIRST_UNLOCK }), which stores service app:no-auth). keychainSecret reads exactly that one item; the extension never reads other keychain items and never logs the secret or the notice. openNotice(envelope, secret:) opens an envelope from anywhere else. Its byte-for-byte parity with the TypeScript opener is checked by ios/Tests/ByokitSealTests against vectors this package's tests make and libsodium opens (swift test in this directory). One difference cannot be avoided: a Swift string cannot hold an unpaired UTF-16 surrogate, so a notice whose JSON contains one (for example text cut with slice through an emoji) opens in TypeScript but is nil in Swift, and the generic alert shows. Seal well-formed text (String.prototype.toWellFormed).

Formats

  • sealBox emits ephemeral X25519 public key (32) | nonce (24) | crypto_box_easy ciphertext (16-byte MAC first).
  • box emits nonce (24) | crypto_box_easy ciphertext (16-byte MAC first).
  • sealNotice emits { v: 1, sealed }, where sealed is unpadded base64url of sealBox UTF-8 JSON bytes.
  • sealSecretBox emits nonce (24) | crypto_secretbox_easy ciphertext.
  • sealJson uses JSON.stringify then UTF-8; openJson parses UTF-8 and returns null on failure.
  • openBox, openAuthBox and openSecretBox return null for short, tampered, or wrong-key bundles.
  • Invalid key lengths passed to seal/sign throw.

Randomness and keys

Each seal accepts an optional final (length: number) => Uint8Array RNG for deterministic tests; in production the default is crypto.getRandomValues.

import { sealSecretBox, type RandomBytes } from '@byokit/seal';

// Tests only: a fixed RNG makes the output repeatable. Never in production.
const fixed: RandomBytes = (length) => new Uint8Array(length).fill(7);
const key = new Uint8Array(32).fill(1);
const a = sealSecretBox(new Uint8Array([1, 2, 3]), key, fixed);

Never reuse a secretbox nonce with the same key or substitute deterministic RNG in production. Keep secrets in a platform secure store; this library does not manage storage or keys.

Dependencies

Uses pinned @noble/curves, @noble/ciphers and @noble/hashes. No native modules or Node runtime imports.

Tests

The suite checks fixed libsodium/tweetnacl vectors and both directions of interoperability with sodium-native, including tamper and wrong-key cases. Run npm run build && npm run check && npm test at the repository root.

License

Apache-2.0. See LICENSE and NOTICE.

版本与发布记录

@byokit/seal 共发布 5 个版本,首次发布于 2026/09/26;过去 12 个月发布了 5 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.3.2latest2026/10/2514 天前
0.3.12026/10/1822 天前
0.3.02026/10/031 个月前
0.2.02026/09/301 个月前
0.1.02026/09/261 个月前

关于 @byokit/seal 的常见问题

@byokit/seal 是什么?

TypeScript kits that let people sign in to your app with the AI plans they already pay for, on web, phone and desktop.

如何安装 @byokit/seal?

在项目目录运行 npm install @byokit/seal,或使用 pnpm add / yarn add / bun add。

@byokit/seal 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@byokit/seal 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@byokit/seal 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=22.18。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@byokit/seal 可以商用吗?

@byokit/seal 使用 Apache-2.0 许可。宽松许可:允许商业使用、修改和再分发,并附带明确的专利授权;分发时需保留许可与 NOTICE 文件,修改过的文件要注明改动。以上为概括,不构成法律意见,请以许可原文为准。

@byokit/seal 的下载量有多少?

根据 npm 官方下载统计,@byokit/seal 在 2026/10/09 – 2026/11/07 这 30 天内被下载 9,658 次,日均约 322 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@byokit/seal 还在维护吗?

最新版本 0.3.2 发布于 2026/10/25(14 天前);至今共发布 5 个版本,其中过去 12 个月发布了 5 个;首次发布于 2026/09/26。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。