Skip to content

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/base peer 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 matters
sha256Hex("abc"); // "ba7816bf…" — also accepts Uint8Array; sha256Base64 for standard padded base64
const 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 (-00), 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.

import {
generateEd25519KeyPair,
importEd25519Key,
serializeEd25519Key_Raw,
signTextDataWithKeyEd25519,
verifyWithKeyEd25519,
} from "@nice-code/util";
import { base64 } from "@scure/base";
const keyPair = await generateEd25519KeyPair();
// Sign
const 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 + verify
const 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 key
const 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,
});
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 keys
await 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 call
const { 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.

import type { StringKeys } from "@nice-code/util";
type Keys = StringKeys<{ a: string; b: number; 0: boolean }>;
// → "a" | "b"