Skip to content

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/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, 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 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. 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.

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 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,
});
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,
});

The same ClientCryptoKeyLink backs the crypto identity of secure connections across the stack. That includes 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"