How Vouchsafe Could Have Prevented the npm Debug/Chalk Compromise in 200 lines of Javascript
By Jay Kuri
2025-09-10
Yesterday, the npm ecosystem was rocked by news that the debug
,
chalk
(and several other) packages were compromised and shipped with
malicious code. Developers who pulled those versions into their projects
unknowingly imported malware into their applications.
The attack was caught relatively quickly and corrected, but not before countless applications pulled in the bad code.
This kind of attack, where a trusted package suddenly ships bad code, isn’t new and isn’t unique to npm. It happens because the security model is effectively ‘if it’s on npm, it’s safe to use.’
This makes package registries like npm act as middlemen of trust. They assure us that you can trust the package because you trust them. And most of the time that’s true. But yesterday we saw what happens when that model breaks down.
Put another way, when running npm install
you can trust that the package
came from npm, but that doesn’t mean it got there legitimately.
There’s no guarantee that this package version really came from the maintainer you trust because there’s no portable, verifiable way to bind an author’s identity to the actual code being shipped.
Vouchsafe changes that.
Today I’m going to explain how and outline a way this trust issue could be fixed.
The Problem With Today’s Publishing
When you run npm publish
, the registry verifies your account access and
accepts your files and associates them with your account. Anyone who installs
that version is trusting:
- The npm account system
- npm’s distribution infrastructure
- That no one has hijacked the maintainer’s account or registry itself
There’s no way for the end user (or their CI/CD pipeline, etc.) to check offline, independently that the maintainer actually signed off on the exact version they received.
How Vouchsafe Fixes This
With Vouchsafe, the maintainer creates a cryptographic identity easily, with one command:
[bob@dev] ~> create_vouchsafe_id -l bob_the_dev
Created identity: urn:vouchsafe:bob_the_dev.3gh2mjvkmb5cvt3ywqzr3pradn72ds4we5fhrvw4bm7blw6zwo5a
Saved to: bob_the_dev.json
That file now contains the developers vouchsafe ID and keys. (so treat it with the same care as you would your password)
The URN you see is cryptographically derived from their public key, and anyone can verify it matches the public key directly.
In other words it’s portable, self-verifying, and requires no registry lookups.
When they author is ready to publish a new package version, they generate a Vouchsafe attestation token:
iss
: their Vouchsafe identity URNpackage_version
: the version number (5.3.0
)checksums
: per-file SHA-256 checksums of published filespurpose
:release
That token is signed with their private key. It’s small, just a JWT with some application-specific claims-but it carries everything needed to verify it.
NOTE: no key, public or otherwise, needs to be distributed. Sign the token, you’re done.
How Users Verify the Package
Every package published to npm (or any registry) could include the attestation token inside the tarball (or alongside it).
When you install:
- Your tool obtains the package and the identity (or identities) associated with the package.
- It validates the token using
vouchsafe.validateVouchToken(token)
and ensuring the identity matches. - It checks the package’s files against the entries in
checksums
.
If the token validates, you know the author produced that package and you can use the checksums to verify the files have not been tampered with. All of this can happen offline, without talking to npm or any other service.
If anything had been swapped, tampered, or re-signed, verification would fail immediately.
Why this works
Vouchsafe tokens are JWTs-but with critical enhancements:
- Not guessable, not phishable - A Vouchsafe identity is cryptographically bound to a public key. You can’t trick someone into giving it away.
- Key-backed, not password-backed - Tokens can only be produced by the private key holder. No account takeover, no 2FA bypass can fake it.
- Self-contained - Each token carries the issuer’s public key and signature. Verification requires no central directory, no API calls, no infrastructure dependency.
- Simple to use - If
vouchsafe.validateVouchToken(token)
passes, you know the token was signed by the issuer in that token and it hasn’t been tampered with. Period.
Why didn’t someone do this already?
Part of the reason something like this hasn’t been done is the key management headache that goes along with it. Pushing keys around, revoking them, etc. is a headache. Key blobs are opaque and easy to swap without anyone easily noticing. AND they all have to be stored somewhere and accessible when you need them.
This is where Vouchsafe changes the game.
Because Vouchsafe tokens are entirely self-verifying, no key distribution is needed. Instead you simply need the human-readable URNs of the people you trust and what they are trusted for.
The token contains the public key needed to verify it. And the identity urn proves the public key hasn’t been tampered with. If anything is changed, the entire verification immediately fails. This process happens entirely offline, no online dependency whatsoever.
Why This Matters
- No central point of failure - Even if npm accounts are compromised, malicious code can’t be vouched for without the maintainer’s private key.
- Portable trust - The same identity works everywhere, can be verified wherever the token is, regardless of system. npm, github, docker, you name it.
- Offline verification - Developers and CI pipelines can confirm authenticity without hitting a key server or registry.
In short: malicious packages like yesterday’s would never pass validation.
The attacker could publish them, but they couldn’t produce a valid Vouchsafe attestation token, and package managers would refuse to install.
Progressive enhancement
This wouldn’t require a ground-up rewrite of npm. It’s a drop-in upgrade that could be done even independently of npm. (but if you npm folks want to incorporate it directly, I’m all for it.)
- Registries can accept a Vouchsafe attestation token, perhaps embedded in the package itself.
- Existing tools that ignore it keep working exactly as they do today-no breakage, no lost functionality.
- Tools that understand Vouchsafe immediately gain strong cryptographic verification of every package.
In other words, your CI/CD pipeline keeps working untouched, but when your package manager is Vouchsafe-aware, you get protection for free.
The Bigger Picture
This isn’t just about npm. Any software distribution channel-pip, crates.io, Docker Hub, even system updates-faces the same problem. Vouchsafe gives us a universal, decentralized way to bind authors to code and make that binding portable and provable everywhere.
Instead of asking: “Do I trust the registry?” You can ask: “Do I trust this maintainer’s URN?”
And then verify it yourself, in milliseconds.
Trust can be chained as well so npm could publish authors it trusts. You’re team could have it’s own list of authors you trust, flagging any packages that get new authors for review. A security firm could issue attestations that certain packages/versions have been reviewed, etc.
Let me demonstrate
Ok. So talk is cheap. I promised some code.
Lets imagine that we want to do this. Seems like a lot of work. It’s not. As I mentioned, it doesn’t even require participation by npm.
The process would look like this. Prior to publish, the package maintainer
runs a script, we’ll call it make-publish-vch.mjs.
This script determines what files would be published by npm and creates a vouch
token including the package version and a sha256 checksum for each file. It
places this in the root of the package as publish.vch
. Then the package
maintainer publishes the package.
On the other side, you can recognize that a publish.vch
is there and you can
run another script, say verify-publish-vch.mjs
to validate the files. If the script passes, all files have been checked and
have not been tampered with and it’s safe to install. If the vouch doesn’t come
from who it should, the script fails. If any file has been changed, the script
fails.
Sounds pretty straightforward, right? I’m not speaking hypothetically… the scripts are below. Less than 200 lines. Built in about an hour. Cryptographically provable origin, offline verifiable, no key distribution, drop-in simple.
Yes, these are simplified versions of what we would actually want, but they do actually accomplish the thing.
make-publish-vch.mjs ( download )
#!/usr/bin/env node
// make-publish-vch.mjs
//
// Usage: node make-publish-vch.mjs ./my-identity.json
//
// - identity JSON created by `create_vouchsafe_id -l
verify-publish-vch.mjs ( download )
#!/usr/bin/env node
// verify-publish-vch.mjs
//
// Usage: node verify-publish-vch.mjs [tokenFile]
//
// permitted_urn = the ONLY issuer URN you accept for this package (receiver policy)
// tokenFile = defaults to ./publish.vch
//
// Exits nonzero (and shouts) on any mismatch or policy failure.
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { createHash } from 'node:crypto';
import { validateVouchToken } from 'vouchsafe'; // per README
function usage(msg) {
if (msg) console.error('Error:', msg);
console.error('Usage: node verify-publish-vch.mjs [tokenFile]');
process.exit(2);
}
const permittedUrn = process.argv[2];
const tokenFile = process.argv[3] || 'publish.vch';
if (!permittedUrn) usage('missing permitted_urn');
// read the token file
const token = readFileSync(resolve(tokenFile), 'utf8').trim();
// 1) Validate the token with the vouchsafe
let decoded;
try {
const res = await validateVouchToken(token); // throws on invalid
decoded = res?.decoded || res || {};
} catch (e) {
console.error('❌ Token failed cryptographic validation:', e.message || e);
process.exit(1);
}
// 2) Enforce receiver policy: token issuer must match permitted URN
// For simplicity in this demo we use one urn, you could support
// multiple permitted publisher URNs
if (decoded.iss !== permittedUrn) {
console.error('❌ Issuer URN not permitted.');
console.error(` expected: ${permittedUrn}`);
console.error(` got: ${decoded.iss}`);
process.exit(1);
}
// 3) look at files in publish.vch, hash, and compare
const expected = decoded.checksums || {};
const expKeys = new Set(Object.keys(expected));
const actual = {};
for (const rel of expKeys) {
try {
const data = readFileSync(resolve(rel));
actual[rel] = createHash('sha256').update(data).digest('hex');
} catch(e) {
console.error(`❌ Unable to verify FILE: ${rel} - `, e.toString());
}
}
let ok = true;
const actKeys = new Set(Object.keys(actual));
for (const k of expKeys) {
if (!actKeys.has(k)) {
console.error(`❌ MISSING FILE: ${k}`);
ok = false;
} else if (expected[k] !== actual[k]) {
console.error(`❌ HASH MISMATCH: ${k}
expected: ${expected[k]}
actual: ${actual[k]}`);
ok = false;
}
}
for (const k of actKeys) {
if (!expKeys.has(k)) {
console.error(`❌ EXTRA FILE: ${k}`);
ok = false;
}
}
// This is a demo, so it's slightly simplified.
//
// If we were verifying for real, we'd add a check
// for any files that are present in the unpacked directory that
// we don't have a vouch for, indicating an attempted injection.
// but for simplicity of demonstration, so that you can run it
// in the working directory, I've omitted that to avoid
// complaining about your .git and node_modules folders etc.
if (!ok) {
console.error('\n🚨🚨🚨 VERIFICATION FAILED — ABORT INSTALL 🚨🚨🚨');
process.exit(1);
}
console.log(`✅ OK — token issuer permitted and all ${actKeys.size} checksums match.`);
How you use the above scripts:
# 0) Install the library and create an identity (one-time)
npm install -g vouchsafe
create_vouchsafe_id -l maint -o maint.json # produces JSON with urn+keypair. Keep this file safe!
# cd to package folder
cd dev/my_package
# 1) Generate an attested manifest of the exact files npm would publish
node path/to/make-publish-vch.mjs location/of/maint.json
# 2) Receiver (or your CI) verifies BEFORE install:
# (Pass the permitted maintainer URN shown in maint.json)
node path/to/verify-publish-vch.mjs "urn:vouchsafe:maint.……"
# -> OK — token issuer permitted and all checksums match.
# 3) Tamper test: change something that would be published
echo "// sneaky change: Send me your money! " >> README.md
# 4) Verify again (simulating the receiver)
node path/to/verify-publish-vch.mjs "urn:vouchsafe:maint.……"
# -> HASH MISMATCH … !!! VERIFICATION FAILED — ABORT INSTALL
Summary
So… what just happened? Well, I showed you how easy it is to use Vouchsafe… Less than 200 lines of code to put some real identity and trust verification into the npm ecosystem.
One additional command prior to publish (and could be incorporated into the npm publish process or even added to the package.json as a prepublish step) and an additional step prior to install on the receiving-side.
Would the real deal need a bit more, sure… but not a ton and mostly related to how and who
is actually integrating it. We’d need to settle on a way to record which identities should be
trusted to publish a package, but a single additional field in the npm database for a
package would do the job. Or an agreement to put them in a PUBLISHERS
in the git
repo, or whatever.
WTF did I just read.
Vouchsafe is a new primitive for identity and trust. It’s based on JWTs so it’s easy to understand and works anywhere JWTs work. It makes the things you do with JWTs now easier and adds some superpowers, like verification without key distribution headaches, and offline validation.
Check out the website, the npm module or, if you’re particularly technical minded, check out the open spec
If it seems interesting and you want to talk about it, Join us on the discord
Vouchsafe: identity you can prove, trust you can carry.
Read more at getvouchsafe.org.
Get the reference implementation package.
Read the open spec.
Or join us on Discord.