Errors

One class, one code set, one subpath. Branch on error.code, never on the message.

Note

ELI5. When something goes wrong, this library throws one kind of error, and that error carries a short label saying what kind of wrong it was. Read the label, not the sentence: the sentence is for a human reading a log and may be reworded, while the label is a promise. And note what does not throw: a bad signature, a wrong code, a missing header. Those come back as false, because a request being wrong is not an exceptional event on a server.

import { UnsecureError } from "unsecure/errors";

Everything the library throws is an UnsecureError. A native TypeError, RangeError or SyntaxError escaping from unsecure is a bug. Report it.

#Signature

type UnsecureErrorCode =
  "INVALID_TYPE" | "OUT_OF_RANGE" | "MALFORMED" | "UNSUPPORTED" | "FROZEN" | "PLATFORM";

class UnsecureError extends Error {
  readonly name: "UnsecureError";
  readonly code: UnsecureErrorCode;
  readonly cause?: unknown;
  constructor(code: UnsecureErrorCode, message: string, options?: { cause?: unknown });
}

message names the function, the value it judged and what it expected, as in "hkdf: length must be an integer between 1 and 8160, got 0.". code is that same judgement, machine-readable. cause is set only when the failure came from outside the library.

#The codes

CodeMeaningWhere it comes from
INVALID_TYPEA value of the wrong JavaScript type, or nothing where a value is required.Anything taking a BytesSource; secureCompare's expected; a non-string algorithm; otpauthURI's type, account and issuer; uuidv7's timestamp; every codec stringify and parse.
OUT_OF_RANGEThe right type, outside its documented domain.An empty secret; every bounded number in hkdf, otp, generate and random; a CryptoKey of the wrong kind; a base32 alphabet that is not 32 distinct ASCII characters; overlapping generator character sets; secureCompare under strict.
MALFORMEDText that is not what it claims to be.A codec parse on anything but a canonical encoding, or decoded bytes that are not valid UTF-8; safeJsonParse on JSON that does not parse; uuidv7Timestamp on a non-v7 string; argon2Verify on a string that is not a PHC string.
UNSUPPORTEDA name outside the set the library accepts.Any algorithm; any returnAs; otpauthURI for a type other than "hotp" or "totp"; an unknown Argon2 variant or a PHC version other than 0x13.
FROZENA dangerous key cannot be removed because the object holding it is frozen or sealed.sanitizeObject, and safeJsonParse through it.
PLATFORMThe runtime's Web Crypto refused an operation the library had already validated.hash, hmac, hmacVerify, hkdf and the OTP functions built on them. cause carries the platform error.

The union is complete for this release. A later minor may add a code, so keep a default branch in any switch over it.

#What throws and what does not

Verification functions never throw for untrusted input. secureCompare, hmacVerify, hotpVerify, totpVerify and argon2Verify return false — or { valid: false } — for a missing, malformed or wrong-typed value off the wire: a null header, a signature that is not canonical hex, a code that is not a string.

They throw only for something you control: an empty secret, an unsupported algorithm, an out-of-range window, a stored hash in a format nobody wrote.

So a catch around a verify is about your own configuration, never about the request.

#Catching

import { UnsecureError } from "unsecure/errors";
import { hmacVerify } from "unsecure/hmac";

try {
  const valid = await hmacVerify(secret, body, request.headers.get("x-signature"));
  return valid ? handle(body) : respond(403);
} catch (error) {
  if (!(error instanceof UnsecureError)) throw error;
  switch (error.code) {
    case "OUT_OF_RANGE": {
      // An empty secret: the deployment is misconfigured, the request is fine
      return respond(500);
    }
    case "PLATFORM": {
      // error.cause is the runtime's own failure
      return respond(503);
    }
    default: {
      return respond(400, { reason: error.code });
    }
  }
}

The codecs report exactly where the text stopped being canonical, which makes their message safe and useful to return:

import { UnsecureError, base64Parse } from "unsecure";

try {
  base64Parse(untrusted);
} catch (error) {
  if (error instanceof UnsecureError && error.code === "MALFORMED") {
    return respond(400, { reason: error.message });
  }
  throw error;
}

#Mapping codes onto HTTP

A reasonable default, to adapt:

CodeStatusWhy
MALFORMED400The client sent text that is not what it claimed to be.
INVALID_TYPE400 or 500400 if the value came from the request body; 500 if it came from your own code.
OUT_OF_RANGE500Usually a configuration value that did not load.
UNSUPPORTED500You named an algorithm or a returnAs the library does not have.
FROZEN500A frozen object reached a sanitizer. Use sanitizeObjectCopy.
PLATFORM503The runtime refused an operation it should have performed.

#Pitfall: matching on message text

// Breaks the moment the wording improves
if (error.message.includes("must not be empty")) {}

// Codes are the contract
if (error instanceof UnsecureError && error.code === "OUT_OF_RANGE") {}

Several messages gained a function-name prefix in 0.3. See Migrating to 0.3.

#Pitfall: an exhaustive switch with no default

The code union is documented as growable. A switch with no default compiles today and silently does nothing the day a code is added.

switch (error.code) {
  case "MALFORMED":
    return respond(400);
  default:
    return respond(500);
}

#Pitfall: losing cause

PLATFORM errors, and safeJsonParse's MALFORMED, carry the original failure in cause. Log it. The library's message says which operation was refused; cause says why.