All files / src validator.js

100% Statements 463/463
100% Branches 129/129
100% Functions 13/13
100% Lines 463/463

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 4641x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 11067x 11067x 668x 668x 668x 668x 11067x 11067x 11067x 11067x 11067x 11067x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 4575x 11x 11x 4564x 4575x 18x 18x 4546x 4575x 17x 17x 4529x 4529x 4575x 28x 28x 4501x 4575x 15x 15x 4486x 4575x 50x 50x 4436x 4436x 4436x 4436x 4436x 4436x 4436x 4575x 4379x 4379x 4575x 4343x 4343x 4343x 4575x 108x 108x 4436x 4436x 4436x 4575x 59x 59x 4436x 4436x 4575x 1x 1x 1x 1x 1x 1x 1x 1x 1x 762x 762x 762x 762x 762x 762x 52x 52x 762x 762x 762x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 46x 46x 46x 46x 46x 32x 46x 15x 15x 46x 46x 46x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 287x 287x 287x 287x 287x 276x 287x 12x 12x 275x 275x 275x 287x 1x 1x 274x 287x 212x 212x 212x 212x 205x 205x 205x 199x 205x 212x 22x 22x 212x 190x 190x 190x 190x 190x 190x 190x 190x 8x 8x 190x 190x 212x 274x 274x 287x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1100x 1100x 1100x 1100x 1100x 1100x 1100x 1068x 1100x 37x 37x 1100x 1100x 1100x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 809x 809x 809x 809x 809x 809x 809x 759x 809x 50x 50x 809x 809x 809x 1x 1x 1x 1x 1x 1x 1x 1x 1x 396x 396x 396x 25x 25x 396x 396x 396x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 258x 258x 258x 258x 258x 258x 252x 99x 99x 99x 99x 87x 252x 258x 20x 20x 258x 258x 258x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 159x 128x 128x 31x 31x 159x 159x 159x 159x 159x 25x 159x 10x 10x 21x 21x 159x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 327x 327x 327x 327x 319x 327x 21x 21x 306x 306x 306x 306x 306x 327x 327x 327x 284x 327x 26x 26x 306x 306x 327x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 2348x 2348x 2348x 2x 2x 2346x 2348x 6x 6x 2346x 2348x 5x 5x 2341x 2341x 2348x 7x 7x 2341x 2348x 5x 5x 2341x 2341x 2341x 2348x 38x 38x 2341x 2341x 2348x 1x  
/*!
 * Copyright (c) 2026 The Triauth Authors (https://www.triauth.org/)
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 *
 * SPDX-License-Identifier: Apache-2.0
 */
 
import { Helpers } from './helpers.js';
import { LIMITS, DELIMITER } from './protocol.js';
 
/**
 * A collection of helper methods dedicated for data validation.
 *
 * These validation methods are not meant to be bullet-proof, and API method calls should return correct results even
 * if invalid or dangerous data for some reason passes the validation.
 *
 * @class
 * @memberof Triauth
 */
export class Validator {
 
  static #ERRORS = {
    210: 'Identifier is invalid',
    211: 'Identifier must not be empty',
    212: 'Identifier is too long',
    213: 'Identifier must be provided in lowercase',
    214: 'Identifier must include one @ sign',
    215: 'Identifier contains invalid username',
    216: 'Identifier contains invalid domain name',
    221: 'Invalid callbackUrl',
    222: 'Invalid ext',
    223: 'Invalid challenge',
    224: 'Invalid response',
    225: 'Invalid signature',
    226: 'Invalid token',
    227: 'Invalid message',
    228: 'Invalid attachments',
    229: 'Invalid attestations',
    260: 'Device name is invalid',
    261: 'Device name must not be empty',
    262: 'Device name is too long',
    263: 'Device name must be provided in lowercase',
    264: 'Device name must include only letters, numbers, and hyphens'
  };
 
  static #expandResult(errors) {
    // expand errors
    errors = errors.map((errno) => {
      return {
        code: errno,
        message: this.#ERRORS[errno]
      };
    });
 
    return {
      valid: errors.length === 0,
      errors
    };
  };
 
  /**
   * Validates the provided personal identifier's format, and returns human-friendly error messages if problems are found.
   *
   * @param identifier {String} - Identifier that should be validated.
   *
   * @returns {{valid: boolean, errors: Array<{code: number, message: string}>}}
   */
  static validateIdentifier(identifier) {
    if (typeof identifier !== 'string') {
      return this.#expandResult([210]); // 'Identifier is invalid'
    }
 
    if (identifier.length <= 0) {
      return this.#expandResult([211]); // 'Identifier must not be empty'
    }
 
    if (identifier.length > LIMITS.identifierBytesize || Helpers.byteSize(identifier) > LIMITS.identifierBytesize) {
      return this.#expandResult([212]); // 'Identifier is too long'
    }
 
    // identifiers must not contain any whitespace characters and/or NULL
    if (identifier.match(/[\s\0]/g)) {
      return this.#expandResult([210]); // 'Identifier is invalid'
    }
 
    if (identifier !== identifier.toLowerCase()) {
      return this.#expandResult([213]); // Identifier must be provided in lowercase
    }
 
    if ((identifier.match(/@/g) || []).length !== 1) {
      return this.#expandResult([214]); // 'Identifier must include one @ sign'
    }
 
    const [username, domain] = identifier.split('@');
    const errors = [];
 
    if (
      // Username may be composed only of ASCII letters, numbers, dot and hyphen signs, must start with a letter or a digit,
      // and must be no longer than 63 chars (DNS label limit),
      !username.match(/^[a-z0-9][a-z0-9.-]{0,62}$/) ||
 
      // consecutive dots and/or hyphens are not allowed,
      username.match(/[.-][.-]/i) ||
 
      // and it must not end with a dot or hyphen.
      username.match(/[.-]$/i)
    ) {
      errors.push(215); // 'Invalid username'
    }
 
    if (
      !Helpers.isDomainName(domain)
    ) {
      errors.push(216); // 'Invalid domain name',
    }
 
    return this.#expandResult(errors);
  }
 
  /**
   * Validates the provided callback URL's format, and returns human-friendly error messages if problems are found.
   *
   * @param callbackUrl {String} - Callback URL that should be validated.
   *
   * @returns {{valid: boolean, errors: Array<{code: number, message: string}>}}
   */
  static validateCallbackUrl(callbackUrl) {
    const errors = [];
 
    // A callback URL is any canonical URL — http or https, named or IPv4-literal host — whose
    // base URL is free of the signature-envelope delimiter ';'
    const base = Helpers.getBaseUrl(callbackUrl);
    if (base === false || base.includes(DELIMITER)) {
      errors.push(221);
    }
 
    return this.#expandResult(errors);
  }
 
  /**
   * Validates the optional `ext` property for {@link Triauth.authenticate} — both as supplied in stage 1 and as received back in stage 3.
   *
   * `ext` must be a plain object whose values are strings, numbers, booleans, or plain objects one level deep (no arrays, no deeper nesting).
   * Keys must additionally be ASCII-only — `Helpers.safeParseJson` rejects non-ASCII property names when the ext round-trips back in stage 3 —
   * and never one of the prototype-poisoning names (`__proto__`, `constructor`, `prototype`)
   *
   * @param ext {object} - Ext object that should be validated.
   * @param [depth=0] {number} - Nesting depth of the object being validated: 0 for the `ext` object itself,
   *                             1 for the single level of nested plain objects that `ext` values may contain.
   *
   * @returns {{valid: boolean, errors: Array<{code: number, message: string}>}}
   */
  static validateExt(ext, depth=0) {
    const errors = [];
 
    if (
      !ext ||
      !(typeof ext === 'object' && !Array.isArray(ext) && Object.keys(ext).every((k) => Helpers.isNormalString(k, LIMITS.jsonMaxKeyLength) && !/[^\x20-\x7E]/.test(k) && ['__proto__', 'constructor', 'prototype'].indexOf(k) < 0) && Object.values(ext).every((e) => typeof e === 'string' || typeof e === 'number' || typeof e === 'boolean' || (depth === 0 && this.validateExt(e, depth + 1).errors.length === 0))) ||
      Helpers.byteSize(JSON.stringify(ext)) > LIMITS.extBytesize
    ) {
      errors.push(222);
    }
 
    return this.#expandResult(errors);
  }
 
  /**
   * Validates the `attestations` option for {@link Triauth.attest}, applied both when building the challenge (stage 1) and verifying the response (stage 3).
   *
   * `attestations` must be a plain object keyed by attestation name, where each value is `{label: string, providers: string[]}`.
   * An empty object `{}` is valid; passing it will produce `{attested: true, attestations: {}}` from stage 3.
   * An attestation name is never one of the prototype-poisoning keys (`__proto__`, `constructor`, `prototype`).
   *
   * @param attestations {Object} - Attestations object that should be validated.
   *
   * @returns {{valid: boolean, errors: Array<{code: number, message: string}>}}
   */
  static validateAttestations(attestations) {
    const errors = [];
 
    if (
      !attestations ||
      !(typeof attestations === 'object' && !Array.isArray(attestations)) ||
      !(Object.keys(attestations).every((k) => Helpers.isNormalString(k) && ['__proto__', 'constructor', 'prototype'].indexOf(k) < 0))
      ) {
      return this.#expandResult([229]);
    }
 
    // The multi-signature envelope holds at most maxMultiSignatures entries (1 user + N attesters),
    // so requesting more than (maxMultiSignatures - 1) attestations creates a request that can never be satisfied.
    if (Object.keys(attestations).length > LIMITS.maxMultiSignatures - 1) {
      return this.#expandResult([229]);
    }
 
    for (const entry of Object.values(attestations)) {
      if(
        !(typeof entry === 'object' && !Array.isArray(entry)) ||
        !(Object.keys(entry).every((k) => ['label', 'providers'].indexOf(k) >= 0)) ||
        !(Helpers.isNormalString(entry.label)) ||
        !(
          Array.isArray(entry.providers) &&
          entry.providers.length > 0 && entry.providers.length <= LIMITS.maxMultiSignatures - 1 &&
          entry.providers.every((pUrl) => Helpers.isSecureUrl(pUrl))
        )
      ) {
        errors.push(229);
 
      } else {
        for (const providerUrl of entry.providers) {
          // Beyond being canonical on a secure origin, a provider URL is displayed verbatim in
          // the authenticator UI and embedded whole as the `via` field of the attestation
          // segment, so it must carry no query, no fragment, no percent-escapes, no envelope
          // delimiter ';', and no apostrophe. In a canonical URL, '?' and '#' can appear only
          // as the query/fragment introducers and '%' only in an escape — one charset test
          // covers every excluded character.
          if (/[?#%;']/.test(providerUrl)) {
            errors.push(229);
          }
        }
      }
    }
 
    return this.#expandResult(errors);
  }
 
  /**
   * Validates the challenge string returned from stage 1 of an authentication flow, before passing it to stage 3.
   *
   * The challenge must be a non-empty base64url-encoded string within the allowed byte size.
   *
   * @param challenge {String} - Challenge string that should be validated.
   *
   * @returns {{valid: boolean, errors: Array<{code: number, message: string}>}}
   */
  static validateChallenge(challenge) {
    const errors = [];
 
    if (
      typeof challenge !== 'string' ||
      challenge.length <= 0 ||
      challenge.length > LIMITS.challengeBytesize ||
      Helpers.byteSize(challenge) > LIMITS.challengeBytesize ||
      !Helpers.isBase64UrlString(challenge)
    ) {
      errors.push(223);
    }
 
    return this.#expandResult(errors);
  }
 
  /**
   * Validates the response string returned by the authenticator in stage 3 of an authentication flow.
   *
   * The response must be a pipe-delimited string of the form `|...|`, within the allowed byte size.
   *
   * @param response {String} - Response string that should be validated.
   *
   * @returns {{valid: boolean, errors: Array<{code: number, message: string}>}}
   */
  static validateResponse(response) {
    const errors = [];
 
    if (
      typeof response !== 'string' ||
      response.length <= 3 ||
      (response[0] !== '|' || response[response.length - 1] !== '|') ||
      response.length > LIMITS.signatureBytesize ||
      Helpers.byteSize(response) > LIMITS.signatureBytesize
    ) {
      errors.push(224);
    }
 
    return this.#expandResult(errors);
  }
 
  /**
   * Validates the message string to be signed or stamped in a {@link Triauth.sign} or {@link Triauth.stamp} flow.
   *
   * @param message {String} - Message that should be validated.
   *
   * @returns {{valid: boolean, errors: Array<{code: number, message: string}>}}
   */
  static validateMessage(message) {
    const errors = [];
 
    if (!Helpers.isNormalString(message, LIMITS.messageBytesize)) {
      errors.push(227);
    }
 
    return this.#expandResult(errors);
  }
 
  /**
   * Validates the `attachments` array for a {@link Triauth.sign} flow.
   *
   * Each attachment must be a plain object with `name` (string) and `sha256` (64-character lowercase hex string),
   * plus a `sourceUrl` (a canonical URL). Names must be unique within the array.
   *
   * `sourceUrl` is only needed when building the challenge (stage 1), so the authenticator knows where to fetch
   * the file to hash. It is intentionally NOT required on the signed response (stage 3): the durable proof binds
   * only `name`+`sha256`, and keeping `sourceUrl` out of the signed payload avoids embedding fetch URLs (which may
   * carry presigned credentials) in a portable, third-party-verifiable signature. Pass `requireSourceUrl = false`
   * to validate a response's attachments, where `sourceUrl` may be omitted (but is held to the same rule if present).
   *
   * @param attachments {Array<{name: string, sourceUrl?: string, sha256: string}>} - Attachments array that should be validated.
   * @param [requireSourceUrl=true] {boolean} - Whether a valid `sourceUrl` is mandatory on every entry.
   *
   * @returns {{valid: boolean, errors: Array<{code: number, message: string}>}}
   */
  static validateAttachments(attachments, requireSourceUrl = true) {
    const errors = [];
    const seenNames = Object.create(null);
 
    if (
      !Array.isArray(attachments) ||
      attachments.length > LIMITS.attachmentsCount ||
      !attachments.every((f) =>
        Helpers.hasOnlyKnownProperties(f, ['name', 'sourceUrl', 'sha256']) &&
        Helpers.isNormalString(f.name, 255) &&
        ((!requireSourceUrl && f.sourceUrl === undefined) || Helpers.isCanonicalUrl(f.sourceUrl)) &&
        (typeof f.sha256 === 'string' && /^[0-9a-f]{64}$/.test(f.sha256)) &&
        (!seenNames[f.name] && (seenNames[f.name] = true))
      )
    ) {
      errors.push(228);
    }
 
    return this.#expandResult(errors);
  }
 
  /**
   * The binder names a signature may state inside its signed-metadata `bind` object: the verified
   * fields the signature is bound to. Each names a field of the verified user signature and must
   * equal it (see {@link Triauth.attest}).
   *
   * @type {Array<string>}
   */
  static BINDER_NAMES = ['identifier', 'via', 'deviceTag'];
 
  /**
   * Validates a signature segment's signed metadata against the binder grammar.
   *
   * A signature states what it is bound to in the `bind` object, and **every member of `bind` is
   * critical**: a verifier must recognize it and it must match, so a constraint the producer signed
   * can never be dropped without a trace. Members outside `bind` stay auxiliary and tolerant
   * (an unrecognized one is ignored), so a compatible revision may still add signed metadata.
   *
   * @param signedMetadata {object} - The signed metadata of a signature segment.
   *
   * @returns {{valid: boolean, errors: Array<{code: number, message: string}>}}
   *          Invalid (225) when `bind` is present but is not an object of at least one recognized
   *          binder with a string value.
   */
  static validateBinders(signedMetadata) {
    if (Object.keys(signedMetadata).indexOf('bind') < 0) {
      return this.#expandResult([]); // a signature that binds nothing is valid - attesters attest anonymously
    }
 
    const bind = signedMetadata.bind;
    const binders = (bind && typeof bind === 'object' && !Array.isArray(bind)) ? Object.keys(bind) : null;
 
    if (
      !binders ||
      binders.length < 1 ||
      !binders.every((binder) => this.BINDER_NAMES.indexOf(binder) >= 0 && typeof bind[binder] === 'string')
    ) {
      return this.#expandResult([225]);
    }
 
    return this.#expandResult([]);
  }
 
  /**
   * Validates the `token` that gates stage 1 of {@link Triauth.ping}, {@link Triauth.sign}, {@link Triauth.stamp}, and {@link Triauth.attest}.
   *
   * A token has the form `issuer:secret` — an issuer prefix that is either empty (the identifier's own domain) or the
   * lowercase domain name whose endpoint record names the authenticator that minted the token, a single `:`, and a
   * secret of at least 16 characters. The whole token keys the HMAC that binds the challenge to the token holder,
   * and must be a normal string within the allowed byte size.
   *
   * @param token {String} - Token that should be validated.
   *
   * @returns {{valid: boolean, errors: Array<{code: number, message: string}>}}
   */
  static validateToken(token) {
    const errors = [];
 
    if (
      typeof token !== 'string' ||
      !Helpers.isNormalString(token)
    ) {
      return this.#expandResult([226]);
    }
 
    // Validate token structure
    const [tokenDomainName, tokenSecret, rest] = token.split(':');
 
    if (
      (tokenDomainName && !Helpers.isDomainName(tokenDomainName)) ||
      tokenDomainName.toLowerCase() !== tokenDomainName ||
      (tokenSecret || '').length < 16 ||
      rest !== undefined
    ) {
      errors.push(226);
    }
 
    return this.#expandResult(errors);
  }
 
  /**
   * Validates the provided device name's format, and returns human-friendly error messages if problems are found.
   *
   * Device names must be lowercase and composed of letters, numbers, and single hyphens between alphanumerics
   * (no leading, trailing, or consecutive hyphens), within the allowed byte size.
   *
   * @param deviceName {String} - Device name that should be validated.
   *
   * @returns {{valid: boolean, errors: Array<{code: number, message: string}>}}
   */
  static validateDeviceName(deviceName) {
    const errors = [];
 
    if (typeof deviceName !== 'string') {
      return this.#expandResult([260]); // 'Device name is invalid'
    }
 
    if (deviceName.length <= 0) {
      errors.push(261); // 'Device name must not be empty'
    }
 
    if (deviceName.length > LIMITS.deviceNameBytesize || Helpers.byteSize(deviceName) > LIMITS.deviceNameBytesize) {
      return this.#expandResult([262]); // 'Device name is too long'
    }
 
    // deviceName must not contain any whitespace characters and/or NULL
    if (deviceName.match(/[\s\0]/g)) {
      errors.push(260); // 'Device name is invalid'
    }
 
    if (deviceName !== deviceName.toLowerCase()) {
      errors.push(263); // Device name must be provided in lowercase
    }
 
    // Lowercase alphanumerics joined by single hyphens: the name must start and end on an alphanumeric
    // and carry no consecutive hyphens.
    if (!deviceName.match(/^[a-z0-9]+(-[a-z0-9]+)*$/)) {
      errors.push(264);
    }
 
    return this.#expandResult(errors);
  }
}