mrkeyoor.com_
Thu 06 Aug 15:43 UTC
npmSecurityupdated 06 Aug 2026

tuf-js

tuf-js is a Node implementation of The Update Framework client workflow. TUF is a CNCF specification for software update systems that stay trustworthy even when the server hosting the updates, or one of its signing keys, is compromised. It does that with four signed metadata roles (root, timestamp, snapshot, targets), threshold signatures, expiry dates, and key rotation you can verify offline from a single root of trust you shipped with your app. The package gives you one class, Updater: point it at a local metadata cache directory, a metadata base URL, and a target base URL, call refresh() to walk the chain of trust, then getTargetInfo() and downloadTarget() to fetch a file whose hash and length were signed by a key you already trust. It is the client half only. It does not create repositories, sign metadata, or rotate keys.

Verdict

The correct choice when you genuinely need compromise-resilient updates in Node, and it is maintained by GitHub's Package Security team against the conformance suite. For everyone else, a signed checksum is the right amount of security and TUF is a repository operations project wearing a library's clothes.

API stability4/5The Updater surface (refresh, getTargetInfo, findCachedTarget, downloadTarget) has been stable since 1.x and the spec it implements is frozen at 1.0; majors mostly move the Node engines floor and swap internals, and 6.0.0 kept the same four methods
Docs2/5The published package README is three lines and the repo README is mostly links to the TUF website; there is a working examples/client directory, but understanding metadata layout, expiry, or delegations means reading the specification and the python-tuf documentation
Maintenance4/5Maintained by two named engineers on GitHub's Package Security team, pushed within days, 6.0.0 released June 2026, only 1 open issue (4 counting PRs), and CI runs the cross-implementation TUF conformance suite; the team is small and the project is a dependency of their day job rather than a standalone product
Ecosystem3/5About 11M weekly downloads, but essentially all transitive through npm and sigstore; only 83 stars, no plugin scene, and few Stack Overflow answers, so when something breaks you are reading source

Use it if

  • You ship software updates or model or plugin artifacts outside a package manager and need the download to stay verifiable even if your CDN or release server is compromised
  • You already run a TUF repository (python-tuf, RSTUF, or tough) and need a Node client that passes the same conformance suite as the reference implementation
  • You are building Sigstore verification in Node and need the trust root itself kept fresh through TUF rather than pinned in your source tree
  • You need spec-correct handling of key rotation, threshold signatures, metadata expiry, rollback attacks, and delegated targets, and would rather not reimplement those rules yourself
Skip it if

Setup reality

npm install tuf-js is the smallest part. Before the first run you have to ship a trusted root.json with your application and copy it into the metadata directory yourself, because the Updater constructor reads metadataDir/root.json synchronously and throws if it is missing. You also create metadataDir and targetDir up front; the client will not mkdir for you. Node 22.22.2, 24.15.0, or 26 and above is enforced by engines. Then come the operational parts nobody warns you about: timestamp metadata typically expires in days, so a client that has been offline past the expiry window fails refresh() rather than degrading, and your repository has to re-sign on a schedule forever. If the repository uses consistent snapshots, target URLs are hash-prefixed by default via config.prefixTargetsWithHash, so a target that 404s is usually a mismatch between that setting and how the repository actually lays out files. Finally, the thrown errors (DownloadHTTPError, ExpiredMetadataError, RepositoryError) are internal classes not re-exported from the package, so distinguishing them means matching on names or messages.

Patterns

Seed the metadata cache with a trusted rootbootstrap-trusted-root

import fs from 'node:fs';
import path from 'node:path';

const metadataDir = './tuf-metadata';
const targetDir = './tuf-targets';

fs.mkdirSync(metadataDir, { recursive: true });
fs.mkdirSync(targetDir, { recursive: true });

const rootPath = path.join(metadataDir, 'root.json');
if (!fs.existsSync(rootPath)) {
  // 1.root.json ships inside your application, not downloaded
  fs.copyFileSync(new URL('./1.root.json', import.meta.url), rootPath);
}

This is the whole trust anchor. The Updater constructor reads metadataDir/root.json with readFileSync and throws before you get a chance to call refresh() if it is absent. Never fetch this file over the network on first run.

Construct the Updatercreate-updater

import { Updater } from 'tuf-js';

const updater = new Updater({
  metadataDir,
  metadataBaseUrl: 'https://updates.example.com/metadata',
  targetDir,
  targetBaseUrl: 'https://updates.example.com/targets',
});

targetDir and targetBaseUrl are optional at construction, but downloadTarget throws ValueError('Target base URL not set') if neither the constructor nor the call supplies one.

Walk the chain of trust before anything elserefresh-metadata

await updater.refresh();

refresh() loads root, then timestamp, snapshot, and targets in that spec-mandated order, persisting each verified file into metadataDir. Call it once per process after construction; getTargetInfo() calls it implicitly if you forget, which hides latency in a surprising place.

Fetch a target, reusing the local copy when validdownload-target-with-cache

const targetInfo = await updater.getTargetInfo('bin/agent-2.4.1.tar.gz');

if (!targetInfo) {
  throw new Error('target is not listed in signed targets metadata');
}

const cached = await updater.findCachedTarget(targetInfo);
const filePath = cached ?? (await updater.downloadTarget(targetInfo));

console.log('verified artifact at', filePath);

getTargetInfo returning undefined means the path is not in signed metadata, which is a trust failure, not a 404. findCachedTarget re-verifies hashes against the file on disk, so it returns undefined for a tampered or truncated cache entry.

Control where the artifact landsdownload-to-explicit-path

const dest = path.join(targetDir, 'agent-latest.tar.gz');
const written = await updater.downloadTarget(
  targetInfo,
  dest,
  'https://mirror.example.net/targets'
);

The third argument overrides targetBaseUrl for this call, which is how you use a mirror while keeping metadata on the origin. Hashes still have to match the signed metadata, so a bad mirror fails closed.

Set timeouts, retries, and size capstune-config

const updater = new Updater({
  metadataDir,
  metadataBaseUrl,
  targetDir,
  targetBaseUrl,
  config: {
    fetchTimeout: 15000,      // default 100000 ms
    fetchRetry: 3,            // default 2; fetchRetries is the deprecated name
    targetsMaxLength: 20_000_000,
    prefixTargetsWithHash: true,
    userAgent: 'my-updater/1.0',
  },
});

Only 5xx responses and network errors are retried; 403 and 404 are treated as final. Set prefixTargetsWithHash to false when your repository uses consistent snapshots for metadata but plain filenames for targets, which is the usual cause of a 404 on an otherwise valid target.

Start from cached metadata instead of hitting the networkoffline-force-cache

const updater = new Updater({
  metadataDir,
  metadataBaseUrl,
  targetDir,
  targetBaseUrl,
  forceCache: true,
});

await updater.refresh();

With forceCache the client tries the local timestamp first and only falls back to the remote if loading it fails, which cuts startup requests for short-lived processes. It does not let you run past expiry: expired cached timestamp metadata triggers the remote path anyway.

Add auth headers or a proxy by subclassing BaseFetchercustom-fetcher

import { BaseFetcher, Updater } from 'tuf-js';

class AuthFetcher extends BaseFetcher {
  constructor(private token: string) { super(); }

  async fetch(url: string): Promise<ReadableStream<Uint8Array<ArrayBuffer>>> {
    const res = await fetch(url, {
      headers: { Authorization: `Bearer ${this.token}` },
      signal: AbortSignal.timeout(15000),
    });
    if (!res.ok || !res.body) {
      throw new Error(`download failed: ${res.status}`);
    }
    return res.body;
  }
}

const updater = new Updater({ metadataDir, metadataBaseUrl, fetcher: new AuthFetcher(token) });

Subclassing BaseFetcher gives you the length-checking and temp-file logic for free; you only implement fetch. Supplying a fetcher also discards the built-in retry and timeout config, so reimplement both if you needed them.

Tell an expired repository apart from a network failurehandle-errors

try {
  await updater.refresh();
} catch (err) {
  const name = err instanceof Error ? err.constructor.name : 'Unknown';
  if (name === 'ExpiredMetadataError') {
    // repository stopped re-signing; do not fall back to unverified downloads
    log.error('update metadata expired, refusing to update');
  } else if (name === 'DownloadHTTPError') {
    log.warn('update server unreachable', (err as { statusCode?: number }).statusCode);
  } else {
    throw err;
  }
}

The error classes are defined in the package but not exported from its entry point, so instanceof is not available to you and constructor name or message matching is the practical workaround. Never treat any of these as a reason to skip verification.

Check a file you already have against signed metadataverify-existing-file

import fs from 'node:fs';

const targetInfo = await updater.getTargetInfo('bin/agent-2.4.1.tar.gz');
if (!targetInfo) throw new Error('unknown target');

try {
  await targetInfo.verify(fs.createReadStream('/opt/agent/agent-2.4.1.tar.gz'));
  console.log('length and hashes match signed metadata');
} catch {
  console.error('artifact on disk does not match the repository');
}

TargetFile.verify takes a Readable and checks both length and every hash in the metadata. It rejects rather than returning false, so the try block is required.

See what the client is fetching and cachingdebug-logging

// shell
// DEBUG=tuf:* node ./update.js
// narrower:
// DEBUG=tuf:fetch node ./update.js

tuf-js logs through the debug package under the tuf:fetch and tuf:cache namespaces. This is the fastest way to find a hash-prefix or base-URL mismatch, because the failure otherwise surfaces only as a 404.

Re-check for a new version on a scheduleperiodic-update-check

async function checkForUpdate(target: string) {
  const updater = new Updater({ metadataDir, metadataBaseUrl, targetDir, targetBaseUrl });
  await updater.refresh();
  const info = await updater.getTargetInfo(target);
  if (!info) return null;
  const cached = await updater.findCachedTarget(info);
  return cached ?? (await updater.downloadTarget(info));
}

setInterval(() => { void checkForUpdate('bin/agent-latest.tar.gz'); }, 6 * 60 * 60 * 1000);

Build a fresh Updater per check. The instance holds a trusted metadata set captured at construction, so a long-lived one keeps serving stale roles and will eventually trip on expiry instead of picking up the rotation.

Alternatives

PackageRegistryPick it when
tufPyPIYou need the repository side too: python-tuf is the reference implementation and can generate, sign, and rotate metadata as well as consume it
sigstorenpmYour actual goal is verifying signed artifacts and provenance attestations, and you want the higher-level API that uses tuf-js underneath
@sigstore/tufnpmYou only need the Sigstore trust root kept up to date and do not want to configure an Updater yourself