Crypto
WebCrypto helpers — Ed25519 signing, X25519 exchange, AES-GCM, and a client link.
Crypto helpers built on WebCrypto, so they run the same in browsers, Workers / Durable Objects, Bun, and Node. When a key is turned into a string, that string carries its own description (<algo>::<format>::<data>) — so wherever you store or send a key, it always knows how to load itself 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 regardless of key insertion order — the
primitive under content hashes, idempotency keys, dedup, and signed challenges. sha256Hex /
sha256Base64 are synchronous SHA-256 (usable at module-evaluation time, where
crypto.subtle.digest can’t go), and 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 (not RFC 8785/JCS — no claim of JCS
number-formatting conformance is made): native JSON.stringify spelling for scalars (-0 → 0),
own enumerable string keys sorted by UTF-16 code unit, undefined object entries dropped, toJSON
never invoked, array order kept with sparse holes as null — while 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: there is no built-in byte or depth ceiling — bound and validate untrusted input before canonicalizing it; and 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 ambiguity class: 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 — 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: a signing API must not guess.
The older signChallenge / signCombinedTextDataWithKeyEd25519 surface keeps working
byte-for-byte (deployed contracts depend on it), and nice-wire’s handshake challenge is likewise
unchanged — prefer the canonical form for every new signing surface.
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/decrypt:
import { generateX25519KeyPair, createAesGcmKeyFromX25519Keys, encryptTextDataWithAesGcmKey, decryptTextDataWithAesGcmKey,} from "@nice-code/util";
const alice = await generateX25519KeyPair();const bob = await generateX25519KeyPair();
// Both sides derive the same key from their private + the other's public keyconst aliceKey = await createAesGcmKeyFromX25519Keys({ internalX25519PrivateKey: alice.privateKey, externalX25519PublicKey: bob.publicKey, saltString: "optional-session-salt", infoString: "optional-context",});
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,});This is the same ClientCryptoKeyLink used across the stack to back the crypto identity of secure connections — including 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"