Guide

Custom algorithms

Extend FixedHash or BlockHash or Kdf and register the class. The CLI and the tools see it too

Four base classes

Every built-in is a class with a static key, and yours is the same shape. Pick the base by what you need:

  • FixedHash for a fixed-length digest. You write digest(bytes), it does the encodings, the result object, the error wrapping, and refuses an HMAC key.
  • BlockHash for a fixed-length digest built on an incremental Hasher. You write hasher(), and HMAC over its blocks comes for free.
  • Kdf for a key derivation function. You write derive(bytes), it does what FixedHash does and refuses rounds too.
  • Hash for anything else. You write info() and hash() yourself, lists included.

A fixed digest

FNV-1a in 32 bits, plain TypeScript, so it runs anywhere the library does:

tsfnv1a-32.ts
import { FixedHash, create, register } from "@agntn/hashes";

class Fnv1a32 extends FixedHash {
  static readonly key = "fnv1a-32";
  protected readonly about = {
    label: "FNV-1a (32-bit)",
    description: "FNV-1a 32-bit, for hash tables",
    family: "FNV",
    category: "non-cryptographic",
    digestLength: 4,
  } as const;

  protected digest(bytes: Uint8Array): Uint8Array {
    let hash = 0x811c9dc5;
    for (const byte of bytes) hash = Math.imul(hash ^ byte, 0x01000193);
    const out = new Uint8Array(4);
    new DataView(out.buffer).setUint32(0, hash >>> 0);
    return out;
  }
}

register(Fnv1a32);
create("fnv1a-32").hash("a").digest; // "e40c292c"
create("fnv1a-32").hash("a", { key: "k" }); // throws, fnv1a-32 has no HMAC mode

about is everything info() reports besides the name, the options and HMAC support. Add securityNote if there's something a user should know before trusting it.

Borrowing from node:crypto

On Node you can wrap what node:crypto already has. The library itself doesn't import node:*, your file can:

tssha3-384.ts
import { createHash } from "node:crypto";
import { FixedHash, register } from "@agntn/hashes";

class Sha3_384 extends FixedHash {
  static readonly key = "sha3-384";
  protected readonly about = {
    label: "SHA3-384",
    description: "SHA-3 family 384-bit hash",
    family: "SHA",
    category: "cryptographic",
    digestLength: 48,
  } as const;

  protected digest(bytes: Uint8Array): Uint8Array {
    return createHash("sha3-384").update(bytes).digest();
  }
}

register(Sha3_384);

That one won't run in a browser, of course. The built-ins do, which is how this site works.

Your own KDF

Need a KDF the registry doesn't have? WPA2 turns a Wi-Fi passphrase into its key with PBKDF2-HMAC-SHA1. That's 4096 iterations, with the network name as salt. The built-in pbkdf2 refuses SHA-1 on purpose. Yours doesn't have to:

tswpa-psk.ts
import { Kdf, MissingOptionError, Sha1Hasher, create, pbkdf2, register } from "@agntn/hashes";
import type { Derivation, HashOptions } from "@agntn/hashes";

interface WpaPskOptions extends HashOptions {
  ssid?: string;
}

class WpaPsk extends Kdf<WpaPskOptions> {
  static readonly key = "wpa-psk";
  protected readonly about = {
    label: "WPA-PSK",
    description: "WPA2 pre-shared key from a passphrase and the network name",
    family: "PBKDF",
    category: "password",
    digestLength: 32,
  } as const;
  protected override readonly options = [
    { name: "ssid", type: "string", required: true, description: "Network name" },
  ] as const;

  protected derive(passphrase: Uint8Array, options?: Readonly<WpaPskOptions>): Derivation {
    if (options?.ssid === undefined) throw new MissingOptionError("ssid");
    const salt = new TextEncoder().encode(options.ssid);
    return {
      digest: pbkdf2(() => new Sha1Hasher(), passphrase, salt, 4096, 32),
      reported: { ssid: options.ssid },
    };
  }
}

register(WpaPsk);
create("wpa-psk").hash("password", { ssid: "IEEE" } as WpaPskOptions).digest;
// "f42c6fc52df0ebef9ebb4b90b38a5f902e83fe1b135a70e23aed762e9710a12e"

derive gets the passphrase as bytes. It hands back the key and whatever a caller needs to derive it again. Lists, encodings, the result object and the error wrapping are on Kdf. So is saying no to an HMAC key or rounds. Your own parameters set the cost. Rename the network and the key changes with it. That's the salt doing its job.

Got a keyLength and a cost that hurts? Set floorCosts too, the options at which derive is nearly free and the key keeps its length. PBKDF2's are { iterations: 1 }. When an expected digest has the wrong length, verify derives once at those costs to check the keyLength it's about to suggest. Leave them out and that check pays your caller's costs.

Registering

register(Class) puts the class under its key, replacing any algorithm with the same key and dropping its cached instance. After that create, has, algorithms, resolveAlgorithm and every tool executor see it. Importing the package registers nothing by itself. The built-ins seed the registry on first use, so importing mutates no shared state.

Options of your own

An option besides encoding and key goes in info().options, like xxHash's seed. That one declaration is what the tool executors check parameters against, so parameters: { seed } reaches your digest and a name you didn't declare is refused. The built-ins' options become CLI flags the same way. FixedHash takes them as a protected options array and passes the values into digest(bytes, options). Kdf hands them to derive. If your algorithm draws an option at random when it's missing, the way a KDF draws its salt, mark it random: true. Verify then asks for the value the digest was made with, since a fresh one can't match.