UNPKG

paseto-ts

Version:

PASETO v4 (encrypt, decrypt, sign & verify) in TypeScript

336 lines (335 loc) 14.3 kB
import { AUTH_BYTES, KEY_BYTES, KEY_LENGTHS, KEY_MAGIC_BYTES, KEY_MAGIC_STRINGS } from "./magic.js"; import { PasetoClaimInvalid, PasetoKeyInvalid, PasetoPayloadInvalid, PasetoPurposeInvalid, PasetoTokenInvalid } from "./errors.js"; import { concat, stringToUint8Array, uint8ArrayToString } from "./uint8array.js"; import { constantTimeEqual, isObject, validateFooterClaims, validateISODate } from "./validate.js"; import { parseTime } from "./time.js"; import { assertJsonStringSize } from "./json.js"; import { base64UrlDecode } from "./base64url.js"; import { hash } from "@stablelib/blake2b"; /** * Parses a key and ensures it is a valid key for the given type * @param {string} purpose Purpose of the key. Must be one of `local`, `secret` or `public`. * @param {string | Uint8Array} key Key to parse. **Must contain the magic string (or bytes) for the given purpose.** * @returns {Uint8Array} Parsed key * @see https://github.com/paseto-standard/paseto-spec/blob/master/docs/02-Implementation-Guide/03-Algorithm-Lucidity.md */ export function parseKeyData(purpose, key, { version = 'v4' } = { version: 'v4' }) { const magicString = KEY_MAGIC_STRINGS[version][purpose]; const magicBytes = KEY_MAGIC_BYTES[version][purpose]; // Assert that the key and purpose are valid if (!purpose || !key) throw new TypeError('Purpose and key are required.'); if (typeof key !== 'string' && key instanceof Uint8Array === false) { throw new PasetoKeyInvalid(`Invalid key data. Key data must be a string or Uint8Array and start with the UTF-8 string '${magicString}' for this purpose (${purpose}). Received: ${typeof key}.`); } if (purpose !== 'local' && purpose !== 'secret' && purpose !== 'public') { throw new PasetoPurposeInvalid(`Invalid purpose. Must be one of 'local', 'secret' or 'public'. Received: ${purpose}`); } if (typeof key === 'string') { if (key.startsWith(KEY_MAGIC_STRINGS[version][purpose]) === false) { throw new PasetoKeyInvalid(`Invalid key (string). Key must start with ${magicString} to ensure it is a valid key for the given purpose. Received: ${key}`); } // Decode the key key = base64UrlDecode(key.split('.')[2]); } else { if (!constantTimeEqual(key.subarray(0, 9), magicBytes)) { throw new PasetoKeyInvalid(`Invalid key. Key must start with the UTF-8 bytes ${magicBytes.toString()} to ensure it is a valid key for the given purpose.`); } // Remove the magic bytes key = key.subarray(9); } // Validate key length if (key.byteLength !== KEY_LENGTHS[version][purpose]) { throw new PasetoKeyInvalid(`Invalid key. Key must be ${KEY_LENGTHS[version][purpose]} bytes long.`); } return key; } /** * Splits a PASETO v4.public token into its parts * @param {string | Uint8Array} token Token to split * @returns {object} Object containing the token parts */ export function parsePublicToken(token) { if (token instanceof Uint8Array) { token = uint8ArrayToString(token); } const parts = token.split('.'); // v4.public.payload.[footer] if (parts.length > 4) { throw new PasetoTokenInvalid(`Invalid token format: must contain 3 or 4 parts (is ${parts.length})`); } const payload = base64UrlDecode(parts[2]); if (payload.length < 64) { throw new PasetoTokenInvalid(`Invalid token format: payload must be at least 64 bytes (is ${payload.length})`); } const footer = parts[3] ? base64UrlDecode(parts[3]) : new Uint8Array(0); const message = payload.subarray(0, -64); const signature = payload.subarray(-64); return { payload, message, signature, footer, }; } /** * Splits a PASETO v4.secret token into its parts * @param {string | Uint8Array} token Token to split * @returns {object} Object containing the token parts */ export function parseLocalToken(token) { if (token instanceof Uint8Array) { token = uint8ArrayToString(token); } const parts = token.split('.'); // v4.local.payload.[footer] if (parts.length > 4) { throw new PasetoTokenInvalid(`Invalid token format: must contain 3 or 4 parts (is ${parts.length})`); } const payload = base64UrlDecode(parts[2]); if (payload.length < 32) { throw new PasetoTokenInvalid(`Invalid token format: payload must be at least 32 bytes (is ${payload.length})`); } const footer = parts[3] ? base64UrlDecode(parts[3]) : new Uint8Array(0); // Decode the payload (m sans h, f, and the optional trailing period between m and f) from base64url to raw binary. Set: // n to the leftmost 32 bytes // t to the rightmost 32 bytes // c to the middle remainder of the payload, excluding n and t. const nonce = payload.slice(0, 32); const tag = payload.slice(-32); const ciphertext = payload.slice(32, -32); return { payload, nonce, tag, ciphertext, footer, }; } /** * Parse and validate the payload of a message * @param {string | object | Uint8Array} obj Payload to validate * @see https://github.com/paseto-standard/paseto-spec/blob/master/docs/02-Implementation-Guide/01-Payload-Processing.md#payload-processing */ export function parsePayload(payload, { addIat = true, // Add an iat claim if one is not provided addExp = true, // Add an exp claim if one is not provided: maxDepth = 32, // Maximum depth of the JSON object maxKeys = 128, // Maximum number of keys in the JSON object validate = true, // Validate the payload } = { addIat: true, addExp: true, maxDepth: 32, maxKeys: 128, validate: true, }) { let obj; // Bail out early if (!isObject(payload) && typeof payload !== "string" && !(payload instanceof Uint8Array)) { throw new PasetoPayloadInvalid("Payload must be valid JSON (is falsy)"); } // All PASETO payloads must be a JSON-encoded object represented as a UTF-8 encoded string. const possibleStringPayload = payload instanceof Uint8Array ? uint8ArrayToString(payload) : payload; if (typeof possibleStringPayload === "string") { // The topmost JSON object should be an object, not a flat array or list. // Bail out early if the payload is a list. if (possibleStringPayload.startsWith("[") || possibleStringPayload.startsWith("[")) { throw new PasetoPayloadInvalid("Payload must be valid JSON (is an array)"); } // Try to parse the payload as JSON. try { assertJsonStringSize(possibleStringPayload, { maxDepth, maxKeys, }); obj = JSON.parse(possibleStringPayload); } catch (e) { throw new PasetoPayloadInvalid("Payload must be valid JSON"); } } else if (isObject(payload)) { obj = JSON.parse(JSON.stringify(payload)); } // Validate the "iss" claim if (obj.hasOwnProperty("iss") && validate) { const iss = obj.iss; if (typeof iss !== "string") { throw new PasetoClaimInvalid("Payload must have a valid \"iss\" claim (is not a string)"); } } // Validate the "sub" claim if (obj.hasOwnProperty("sub") && validate) { const sub = obj.sub; if (typeof sub !== "string") { throw new PasetoClaimInvalid("Payload must have a valid \"sub\" claim (is not a string)"); } } // Validate the "aud" claim if (obj.hasOwnProperty("aud") && validate) { const aud = obj.aud; if (typeof aud !== "string") { throw new PasetoClaimInvalid("Payload must have a valid \"aud\" claim (is not a string)"); } } // Note the order here: iat comes first, then exp, then nbf. // This is because the exp claim is validated against the iat claim, // and the nbf claim is validated against the exp and iat claims. // Checking them in this order ensures that if any of the claims are // not valid (e.g. iat is in the future, or not a valid JSON string), // it will bail out early and not attempt to validate the other claims. const now = Date.now(); // Validate the "iat" claim if (obj.hasOwnProperty("iat") && validate) { // Validate the existing "iat" claim. // Don't allow passing in a relative time string (e.g. "1 hour") const iat = obj.iat; if (!validateISODate(iat)) { throw new PasetoClaimInvalid("Payload must have a valid \"iat\" claim (is not an ISO date)"); } const parsedDate = Date.parse(iat); // The "iat" claim must not be in the future if (parsedDate > now) { throw new PasetoClaimInvalid("Payload must have a valid \"iat\" claim (is in the future)"); } } else if (addIat) { // If the "iat" claim is not present, create it if requested obj.iat = new Date().toISOString(); } // Validate the "exp" claim if (obj.hasOwnProperty("exp") && validate) { let exp = obj.exp; try { exp = parseTime(exp); } catch (err) { throw new PasetoClaimInvalid("Payload must have a valid \"exp\" claim (is not an date or a valid relative time string (e.g. \"1 hour\"))"); } // The "exp" claim must be greater than the "iat" claim if (obj.hasOwnProperty("iat") && exp <= Date.parse(obj.iat)) { throw new PasetoClaimInvalid("Payload must have a valid \"exp\" claim (is not greater than \"iat\")"); } // The "exp" claim must not have expired if (exp <= now) { throw new PasetoClaimInvalid("Payload must have a valid \"exp\" claim (has expired)"); } // If the "exp" claim is not a valid ISO date, convert it to one if (!validateISODate(obj.exp)) { obj.exp = new Date(exp).toISOString(); } } else if (addExp) { // If the "exp" claim is not present, create it with one hour of leeway obj.exp = new Date(now + 3600000).toISOString(); } // Validate the "nbf" claim if (obj.hasOwnProperty("nbf") && validate) { let nbf = obj.nbf; try { nbf = parseTime(nbf); } catch (err) { throw new PasetoClaimInvalid("Payload must have a valid \"nbf\" claim (is not an date or a valid relative time string (e.g. \"1 hour\"))"); } // The "nbf" claim must be greater than the "iat" claim if (obj.hasOwnProperty("iat") && nbf < Date.parse(obj.iat)) { throw new PasetoClaimInvalid("Payload must have a valid \"nbf\" claim (is not greater than \"iat\")"); } // The "nbf" claim must not be in the future if (nbf > now) { throw new PasetoClaimInvalid("Payload must have a valid \"nbf\" claim (is in the future)"); } // If the "nbf" claim is not a valid ISO date, convert it to one if (!validateISODate(obj.nbf)) { obj.nbf = new Date(nbf).toISOString(); } } // Validate the "jti" claim if (obj.hasOwnProperty("jti") && validate) { const jti = obj.jti; if (typeof jti !== "string") { throw new PasetoClaimInvalid("Payload must have a valid \"jti\" claim (is not a string)"); } } return obj; } /** * Assert that the footer is a Uint8Array. Parses a string footer into a Uint8Array. * If the footer is JSON, the `kid` and `wpk` claims will be validated. * @param {Footer | string | Uint8Array} footer The footer to assert. * @returns {Uint8Array} footer as a Uint8Array. * @see https://github.com/paseto-standard/paseto-spec/blob/master/docs/02-Implementation-Guide/04-Claims.md#optional-footer-claims */ export function parseFooter(footer, { maxDepth = 32, maxKeys = 128, validate = true } = { maxDepth: 32, maxKeys: 128, validate: true }) { if (typeof footer === "string") { // Check if the footer is JSON if (footer.startsWith("{") && footer.endsWith("}")) { assertJsonStringSize(footer, { maxDepth, maxKeys, }); const obj = JSON.parse(footer); if (validate) validateFooterClaims(obj); } return stringToUint8Array(footer); } else if (isObject(footer)) { if (validate) validateFooterClaims(footer); return stringToUint8Array(JSON.stringify(footer)); } else if (footer instanceof Uint8Array) { const possibleObj = uint8ArrayToString(footer); if (possibleObj.startsWith("{") && possibleObj.endsWith("}") && validate) { assertJsonStringSize(possibleObj, { maxDepth, maxKeys, }); const obj = JSON.parse(possibleObj); validateFooterClaims(obj); } return footer; } throw new TypeError("Footer must be a string, Uint8Array, or object"); } /** * Assert that the assertion is a Uint8Array. Parses a string assertion into a Uint8Array. * @param {Assertion | string | Uint8Array} assertion The assertion to assert. * @returns {Uint8Array} assertion as a Uint8Array. */ export function parseAssertion(assertion) { if (typeof assertion === "string") { return stringToUint8Array(assertion); } else if (assertion instanceof Uint8Array) { return assertion; } else if (isObject(assertion)) { return stringToUint8Array(JSON.stringify(assertion)); } throw new TypeError("Assertion must be a string or Uint8Array"); } /** * Derives an encryption key, counter nonce, and auth key from a key and nonce. * @param {Uint8Array} key Local key. * @param {Uint8Array} nonce Local nonce. * @returns {object} Encryption and auth keys. */ export function deriveEncryptionAndAuthKeys(key, nonce) { const keyedHash = hash(concat(KEY_BYTES, nonce), 56, { key }); const encryptionKey = keyedHash.slice(0, 32); const counterNonce = keyedHash.slice(32); const authKey = hash(concat(AUTH_BYTES, nonce), 32, { key }); return { encryptionKey, counterNonce, authKey, }; }