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 carrying a machine-readable code.
// 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 thanotp: …. - The generator's four
nextmessages are prefixed withSecureRandomGenerator.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. 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 anythingstringifyemits 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
base32andbase32hexalphabets. Crockford is unchanged: case-insensitive, withO→0 andI/L→1 in both modes. - Bytes past 0x7F are invalid characters. A
Uint8Arrayinput 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
parsereturning a string throwsMALFORMEDwhen 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)returnsfalseinstead 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
expectedthat is neither text, bytes norundefinednow throwsINVALID_TYPE, where it used to returnfalseor fail internally. - Both arguments accept any
BytesSource, not justUint8Array.
The rule is unchanged: the trusted value goes first.
#5. hmac and hmacVerify
- An empty
secretthrowsOUT_OF_RANGEfrom both functions, before Web Crypto. A secret that failed to load is a deployment bug, not a wrong signature. hmacVerifyno longer infers the signature format from the type ofdata. A string signature is decoded strictly with the codec named byreturnAs— hex whenreturnAsnames raw bytes or is omitted. A hex signature verified with{ returnAs: "base64" }still fails; an uppercase hex signature now verifies.hash,hmacandhkdftakestring | BytesSourceand throwINVALID_TYPEfor 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.digitsoutside 6 to 8 is refused (7 is still allowed).generateOTPSecret(0)throws.period: nullandtime: nullthrow. Only an omitted option takes its default.otpauthURI({ issuer: "" })throws. Omit the key, or passundefined, to mean "no issuer".- An empty secret throws
OUT_OF_RANGE. totpVerifythrows 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
lengthmust be an integer of at least 1.NaNused to return"",Infinityused to hang,5.5quietly produced 5 characters.- An invalid
Dateastimestampthrows instead of prefixing the literal textNaN. - A character appearing in two selected sets throws
OUT_OF_RANGE, because the repeat would skew the distribution. lengthcounts code points, so a custom set holding astral characters yields fewer UTF-16 units than before for the samelengthand never emits half a surrogate pair.
#8. Sanitizers
sanitizeObjectCopyno longer turns aDate,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, usesafeJsonParse(JSON.stringify(obj)).sanitizeObjectCopycopies own enumerable data properties only. Accessors are skipped rather than materialized, and an accessor at an array index leaves a hole.sanitizeObjectthrowsFROZENwhen 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
randomJitterthrowsOUT_OF_RANGEfor a fractional bound, and fornull. Onlyundefinedtakes a default, sorandomJitter(undefined, 50)now means the range[0, 50)instead of failing.secureRandomBytesrefuses a length above2**31 - 1rather than allocating for hours, and its message names the value and the range.
#10. New in 0.3
unsecure/argon2— password hashing (RFC 9106) in plain JavaScript.argon2Hash,argon2Verify,argon2NeedsRehashand the rawargon2KDF.unsecure/errors—UnsecureErrorandUnsecureErrorCode.- Algorithm names are matched case-insensitively everywhere, so
"sha-256"works. importHmacKeyandimportHkdfKey, andCryptoKeyaccepted in place of a secret byhmac,hmacVerify,hkdfand the four OTP functions. See Hashing and MAC.