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.
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.
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
- You just want to verify a download: a published SHA-256 plus a detached signature covers that with no metadata server, no expiry to keep fresh, and no key ceremony, and TUF only pays off when you must survive a compromised repository
- You are shipping to the browser: the client reads and writes metadata and targets through Node's fs module and streams files to a temp path, so there is no browser build and no in-memory-only mode
- You do not already have a TUF repository: the client is the easy 10 percent, and the work is generating and signing four roles, storing offline root keys, re-signing before every expiry, and running a rotation ceremony when a key leaks
- Your CI or runtime is on an older Node patch release: 6.0.0 declares engines ^22.22.2 || ^24.15.0 || >=26.0.0, which is deliberately restricted to security-patched lines and will fail installs that would have worked on 22.15
- You expect documentation: the published client README is three lines, the error classes are not even exported from the package entry point, and the real reference is the TUF specification plus the python-tuf docs
- You saw the download count and assumed popularity: nearly all of those 11M weekly installs are transitive, pulled in by npm and sigstore for package provenance, and almost nobody depends on it directly
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.jstuf-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
| Package | Registry | Pick it when |
|---|---|---|
| tuf | PyPI | You need the repository side too: python-tuf is the reference implementation and can generate, sign, and rotate metadata as well as consume it |
| sigstore | npm | Your actual goal is verifying signed artifacts and provenance attestations, and you want the higher-level API that uses tuf-js underneath |
| @sigstore/tuf | npm | You only need the Sigstore trust root kept up to date and do not want to configure an Updater yourself |