Crypto
WebCrypto helpers: Ed25519 signing, X25519 exchange, AES-GCM, and a client link.
Helpers for signing, verifying and encrypting, built on WebCrypto, so they run the same in browsers, Workers / Durable Objects, Bun, and Node. A serialized key carries its own description (<algo>::<format>::<data>), so wherever you store or send it, it can always be loaded back in.
Crypto helpers require the
@scure/basepeer dependency.
Canonical JSON + SHA-256 — deterministic bytes for hashing and signing
Section titled “Canonical JSON + SHA-256 — deterministic bytes for hashing and signing”stringifyCanonicalJson turns equal data into equal bytes, whatever order the keys were inserted
in. Content hashes, idempotency keys, dedup and signed challenges all depend on that. sha256Hex /
sha256Base64 are synchronous SHA-256, so they work at module-evaluation time, where
crypto.subtle.digest can’t go. The hashCanonicalJsonSha256* helpers combine the two:
import { hashCanonicalJsonSha256Hex, sha256Hex, stringifyCanonicalJson,} from "@nice-code/util";
stringifyCanonicalJson({ b: 1, a: 2 }); // '{"a":2,"b":1}' — insertion order never matterssha256Hex("abc"); // "ba7816bf…" — also accepts Uint8Array; sha256Base64 for standard padded base64const idempotencyKey = hashCanonicalJsonSha256Hex({ op: "transfer", amount: 5 });The rules are an exactly-documented project contract. They are not RFC 8785/JCS, and no claim
of JCS number-formatting conformance is made. Scalars use native JSON.stringify spelling
(-0 → 0), own enumerable string keys are sorted by UTF-16 code unit, undefined object entries
are dropped, toJSON is never invoked, and array order is kept with sparse holes as null.
Non-finite numbers, bigints, functions/symbols, explicit undefined array elements, and cycles all
throw. The full rule set lives on the function’s JSDoc, and frozen byte-level vectors in the
library’s tests pin every one of these behaviors. Any change to them is a breaking change,
because consumers sign these bytes. The strict TCanonicalJsonValue type expresses the safe input
shape for signing APIs.
Two cautions. First, there is no built-in byte or depth ceiling, so bound and validate untrusted input before canonicalizing it. Second, if you already ship your own canonical serializer as part of a wire contract, keep yours authoritative until you have cross-checked vectors byte-for-byte. “Canonical JSON” implementations differ in exactly the edge cases that break signatures.
Canonical challenges — unambiguous signing
Section titled “Canonical challenges — unambiguous signing”Joining challenge parts with a separator (signChallenge(["nonce", ciphertext]) → "nonce::…")
is a known source of ambiguity. The parts have no escaping, so ["a::b"] and ["a","b"] sign
identical bytes, and two operations’ challenges can collide unless every call site remembers its
own fixed tag. The canonical challenge form removes the whole class of problem. A domain tag
namespaces the operation, a version namespaces the format, and canonical-JSON fields give
exact boundaries:
import { buildCanonicalChallenge, ClientCryptoKeyLink, type ICanonicalChallenge } from "@nice-code/util";
const challenge: ICanonicalChallenge = { domainTag: "my-app-action-submit", // one per operation version: 1, // bump when this challenge's field contract changes fields: { nonce, payloadHash, seq }, // pre-hash large members; strict JSON values only};
const link = new ClientCryptoKeyLink();const { signatureBase64 } = await link.signChallengeCanonical(challenge);
// The verifier passes the same STRUCTURED challenge — both ends build the bytes one way:const isValid = await peerLink.verifyChallengeCanonicalFromLinkedClient({ linkedClientId, challenge, signatureBase64,});buildCanonicalChallenge, the shared byte-construction both methods call, validates its input
strictly. Empty tags, non-positive/unsafe versions, undefined entries, sparse arrays, and
non-plain objects are rejected rather than normalized, because a signing API must not guess.
The older signChallenge / signCombinedTextDataWithKeyEd25519 API keeps working byte-for-byte,
because deployed contracts depend on it. nice-wire’s handshake challenge is likewise unchanged.
Prefer the canonical form for every new signing API.
Ed25519 — sign & verify
Section titled “Ed25519 — sign & verify”import { generateEd25519KeyPair, importEd25519Key, serializeEd25519Key_Raw, signTextDataWithKeyEd25519, verifyWithKeyEd25519,} from "@nice-code/util";import { base64 } from "@scure/base";
const keyPair = await generateEd25519KeyPair();
// Signconst signature = await signTextDataWithKeyEd25519("challenge-text", keyPair.privateKey);const signatureBase64 = base64.encode(signature);
// Serialize the public key for transport — "ed25519::raw_base64::<data>"const { prefixed } = await serializeEd25519Key_Raw(keyPair.publicKey);
// Other side: import + verifyconst publicKey = await importEd25519Key.public.fromFormattedString.extractable(prefixed);const isValid = await verifyWithKeyEd25519({ challenge: "challenge-text", signatureBase64, publicKey,});X25519 + AES-GCM — shared-key encryption
Section titled “X25519 + AES-GCM — shared-key encryption”Derive a shared AES-GCM key from two X25519 key pairs (ECDH + HKDF), then encrypt and decrypt with it:
import { generateX25519KeyPair, createAesGcmKeyFromX25519Keys, encryptTextDataWithAesGcmKey, decryptTextDataWithAesGcmKey,} from "@nice-code/util";
const alice = await generateX25519KeyPair();const bob = await generateX25519KeyPair();
// Each side derives the same key from its own private key and the other's public key.// The salt and info strings are optional, but both sides must pass the same ones.const keyContext = { saltString: "optional-session-salt", infoString: "optional-context" };
const aliceKey = await createAesGcmKeyFromX25519Keys({ internalX25519PrivateKey: alice.privateKey, externalX25519PublicKey: bob.publicKey, ...keyContext,});const bobKey = await createAesGcmKeyFromX25519Keys({ internalX25519PrivateKey: bob.privateKey, externalX25519PublicKey: alice.publicKey, ...keyContext,});
const payload = await encryptTextDataWithAesGcmKey({ aesGcmKey: aliceKey, dataToEncrypt: "secret message",}); // { nonce, ciphertext } — both base64
const plaintext = await decryptTextDataWithAesGcmKey({ aesGcmKey: bobKey, dataToDecrypt: payload,});ClientCryptoKeyLink — full client-to-client crypto
Section titled “ClientCryptoKeyLink — full client-to-client crypto”A high-level class that holds your local identity (an Ed25519 sign/verify pair plus an X25519 exchange pair) and the links to other clients. It can optionally save all of this through any StorageAdapter.
import { ClientCryptoKeyLink, createMemoryStorageAdapter_json } from "@nice-code/util";
const link = new ClientCryptoKeyLink({ storageAdapter: createMemoryStorageAdapter_json(), // optional — omit for in-memory only});await link.initialize();
// Share these with the other side (serialized prefixed strings)const { verifyPublicKey, exchangePublicKey } = await link.getLocalPublicKeys();
// Register the other side's keysawait link.linkClient({ linkedClientId: "client::partner-1", verifyPublicKey: theirVerifyKey, exchangePublicKey: theirExchangeKey, bindVerifyKeysIntoDerivation: true, // a tampered relayed key makes the first decryption fail});
// Sign + encrypt for the linked client (shared key derived & cached automatically)const { encryptedData, signatureBase64 } = await link.signAndEncryptDataForLinkedClient({ linkedClientId: "client::partner-1", dataToEncrypt: "hello",});
// Other side: decrypt + verify in one callconst { data, isValid } = await otherLink.decryptAndVerifyDataFromLinkedClient({ linkedClientId: "client::me", dataToDecrypt: encryptedData, signatureBase64,});The same ClientCryptoKeyLink backs the crypto identity of secure connections across the stack. That includes the identityMode: "required" provisioning described under Security Levels.
TypeScript utilities
Section titled “TypeScript utilities”import type { StringKeys } from "@nice-code/util";
type Keys = StringKeys<{ a: string; b: number; 0: boolean }>;// → "a" | "b"