
# Migrating to 0.3

> Everything that changed between 0.2.3 and 0.3.0, and the edit each change asks for.

0.3.0 is a breaking release. Most of the breakage is one change repeated across every module: the library now throws a single error class, and the codecs now refuse text they used to accept.

Work through the sections that name a function you call. Anything not listed here behaves as it did.

## 1. Every error is an `UnsecureError`

Before, failures came back as `Error`, `TypeError`, `RangeError`, `SyntaxError`, or an opaque `DOMException` from Web Crypto. Now every one of them is an [`UnsecureError`](/safety/errors) carrying a machine-readable `code`.

```ts
// 0.2
try {
  base64Parse(input);
} catch (error) {
  if (error instanceof SyntaxError) return respond(400);
}

// 0.3
import { UnsecureError } from "unsecure/errors";

try {
  base64Parse(input);
} catch (error) {
  if (error instanceof UnsecureError && error.code === "MALFORMED") return respond(400);
  throw error;
}
```

A Web Crypto refusal that used to escape as a `DOMException` is now `code: "PLATFORM"`, with the runtime's own error kept in `error.cause`. `safeJsonParse` likewise wraps the engine's `SyntaxError` as `MALFORMED` with the original as `cause`.

Most messages are byte-identical. These gained a prefix naming the function, so any test matching on message text must be updated:

- `uuidv7Timestamp: Not a valid UUIDv7: <value>`
- `safeJsonParse: <the engine's own text>`
- The OTP secret errors now read `hotp: …`, `totpVerify: …`, `otpauthURI: …` rather than `otp: …`.
- The generator's four `next` messages are prefixed with `SecureRandomGenerator.next: `.
- `hkdf`'s two length messages became one: `hkdf: length must be an integer between 1 and 8160, got 0.`
- Six Argon2 range messages now spell their bounds as numbers and name the value found.

::note
The code union is complete for this release but may grow in a later minor. Keep a `default` branch in any `switch` over `error.code`.
::

## 2. The codec wrappers are gone

`hexEncode`, `hexDecode`, `base64Encode`, `base64Decode`, `base64UrlEncode`, `base64UrlDecode`, `base32Encode` and `base32Decode` have been removed.

| 0.2                     | 0.3                                                         |
| ----------------------- | ----------------------------------------------------------- |
| `hexEncode(data)`       | `hexStringify(data)`                                        |
| `hexDecode(text, opts)` | `hexParse(text, { loose: true, ...opts })`                  |
| `base64Encode(data)`    | `base64Stringify(data)`                                     |
| `base64Decode(text)`    | `base64Parse(text, { loose: true })`                        |
| `base64UrlEncode(data)` | `base64Stringify(data, { alphabet: "base64url" })`          |
| `base64UrlDecode(text)` | `base64Parse(text, { alphabet: "base64url", loose: true })` |
| `base32Encode(data)`    | `base32Stringify(data)`                                     |
| `base32Decode(text)`    | `base32Parse(text, { loose: true })`                        |

The `{ loose: true }` in the right column is not decoration. The old wrappers decoded leniently; `parse` is [strict by default](/safety/codecs#strict-and-loose). Drop it only once you know the text is canonical.

## 3. Strict decoding refuses more

Strict `parse` now accepts exactly the canonical encoding of some byte string, and nothing else.

- **Whitespace is a character.** `base64Parse("Zm 9v")` throws where it used to decode.
- **Padding must be right or absent.** `"Zm9vYg="`, `"Zm9vYmFy="` and a `=` inside the body are all refused. Unpadded stays canonical, so anything `stringify` emits round-trips.
- **The length must encode whole bytes.** `"Zm9vY"` is refused.
- **No bits past the final byte.** `"Zh=="` and `"MZXW7==="` are refused.
- **Base32 strict is uppercase-only** for the `base32` and `base32hex` alphabets. Crockford is unchanged: case-insensitive, with `O`→0 and `I`/`L`→1 in both modes.
- **Bytes past 0x7F are invalid characters.** A `Uint8Array` input is the encoded text read one byte per character, so a UTF-8 BOM is three invalid characters rather than something to skip.
- **String output must be valid UTF-8.** Strict `parse` returning a string throws `MALFORMED` when the decoded bytes are not text. Pass `{ returnAs: "bytes" }` for binary payloads.

Loose decoding changed too: `hexParse` no longer stops at the first character it cannot read, it drops it and keeps going. `"zz666f6f"` used to decode to nothing and now decodes to `foo`; `"66 6f 6f"` used to decode to one byte and now decodes to three; `"0x66"` loses only the `x`, leaving the single byte `0x06`.

A custom base32 `alphabet` is now validated when it is resolved: 32 distinct ASCII characters, none of them `=` or whitespace. Anything else is `OUT_OF_RANGE`.

## 4. `secureCompare`

- `secureCompare(expected, null)` returns `false` instead of throwing.
- A plain array as `received` (say `[1, 2, 3]` out of a JSON body) is a mismatch instead of being compared element by element.
- An `expected` that is neither text, bytes nor `undefined` now throws `INVALID_TYPE`, where it used to return `false` or fail internally.
- Both arguments accept any `BytesSource`, not just `Uint8Array`.

The rule is unchanged: the trusted value goes first.

## 5. `hmac` and `hmacVerify`

- An empty `secret` throws `OUT_OF_RANGE` from both functions, before Web Crypto. A secret that failed to load is a deployment bug, not a wrong signature.
- `hmacVerify` no longer infers the signature format from the type of `data`. A string signature is decoded strictly with the codec named by `returnAs` — hex when `returnAs` names raw bytes or is omitted. A hex signature verified with `{ returnAs: "base64" }` still fails; an uppercase hex signature now verifies.
- `hash`, `hmac` and `hkdf` take `string | BytesSource` and throw `INVALID_TYPE` for anything else, where the platform used to report it.

## 6. One-time passwords

Every numeric option is checked at the boundary now, so several silent behaviours became errors.

- `hotpVerify(secret, code, undefined)` throws. It used to verify against counter 0, which meant a counter that never loaded accepted a stale code.
- `digits` outside 6 to 8 is refused (7 is still allowed).
- `generateOTPSecret(0)` throws.
- `period: null` and `time: null` throw. Only an omitted option takes its default.
- `otpauthURI({ issuer: "" })` throws. Omit the key, or pass `undefined`, to mean "no issuer".
- An empty secret throws `OUT_OF_RANGE`.
- `totpVerify` throws when the derived step plus the window would leave the safe integer range.

Two behavioural fixes need no edit but are worth knowing: verification now walks the whole window on every call, so timing says nothing about which step matched, and `delta` is the nearest match rather than the first one scanned. `otpauthURI` percent-encodes its values, so an issuer of `My App` is `My%20App` and not `My+App`.

## 7. `secureGenerate`

- `length` must be an integer of at least 1. `NaN` used to return `""`, `Infinity` used to hang, `5.5` quietly produced 5 characters.
- An invalid `Date` as `timestamp` throws instead of prefixing the literal text `NaN`.
- A character appearing in two selected sets throws `OUT_OF_RANGE`, because the repeat would skew the distribution.
- `length` counts code points, so a custom set holding astral characters yields fewer UTF-16 units than before for the same `length` and never emits half a surrogate pair.

## 8. Sanitizers

- `sanitizeObjectCopy` no longer turns a `Date`, `Map`, `Set`, typed array, `RegExp`, class instance or function into a plain object. Those values are now shared between the input and the copy. For a fully detached plain structure, use `safeJsonParse(JSON.stringify(obj))`.
- `sanitizeObjectCopy` copies own enumerable data properties only. Accessors are skipped rather than materialized, and an accessor at an array index leaves a hole.
- `sanitizeObject` throws `FROZEN` when a frozen or sealed object holds a dangerous key, where it used to report success.
- All three functions walk an explicit stack, so a deeply nested payload no longer overflows the call stack.

## 9. Random

- `randomJitter` throws `OUT_OF_RANGE` for a fractional bound, and for `null`. Only `undefined` takes a default, so `randomJitter(undefined, 50)` now means the range `[0, 50)` instead of failing.
- `secureRandomBytes` refuses a length above `2**31 - 1` rather than allocating for hours, and its message names the value and the range.

## 10. New in 0.3

- [`unsecure/argon2`](/crypto/password-hashing) — password hashing (RFC 9106) in plain JavaScript. `argon2Hash`, `argon2Verify`, `argon2NeedsRehash` and the raw `argon2` KDF.
- [`unsecure/errors`](/safety/errors) — `UnsecureError` and `UnsecureErrorCode`.
- Algorithm names are matched case-insensitively everywhere, so `"sha-256"` works.
- `importHmacKey` and `importHkdfKey`, and `CryptoKey` accepted in place of a secret by `hmac`, `hmacVerify`, `hkdf` and the four OTP functions. See [Hashing and MAC](/crypto/hashing/hmac#reusing-an-imported-key).
