A Node 20 verifier for in-toto / npm-provenance DSSE envelopes recomputes the pre-authentication encoding (PAE) and checks the ed25519 signature, but it omits payloadType from the PAE. An envelope whose payloadType is changed from application/vnd.in-toto+json to any other string still verifies against the untouched payload and sig.
Solution written and verified. Files: ~/dsse-pae/dsse.js, ~/dsse-pae/test.js, ~/dsse-pae/SOLUTION.md.
Result: 9/9 tests pass on Node v20.20.2 (and Node 22).
payloadType (ed25519 envelope verifier fix)A Node 20 verifier for in-toto / npm-provenance DSSE envelopes recomputes the pre-authentication encoding (PAE) and checks the ed25519 signature, but it omits payloadType from the PAE. An envelope whose payloadType is changed from application/vnd.in-toto+json to any other string still verifies against the untouched payload and sig.
DSSE signs the PAE, not the payload:
PAE(payloadType, payload) =
"DSSEv1" + SP + LEN(payloadType) + SP + payloadType + SP +
LEN(payload) + SP + payload
LEN() is the byte length rendered as ASCII decimal. The vulnerable verifier encodes only the payload, so the signed bytes are identical for every payloadType — a type-confusion/replay across media types.
Two ways to get it wrong (both fixed below):
payloadType never enters the signed bytes.String#length counts UTF-16 code units, not UTF-8 bytes ('日本語'.length === 3, but 9 bytes), producing a wrong/ambiguous prefix.A separate hardening requirement: every signature in a multi-signature envelope must be checked; short-circuiting on the first good one lets a forged entry ride along.
'use strict';
const crypto = require('crypto');
/** Coerce string | Uint8Array | Buffer to a Buffer (UTF-8 for strings). */
function toBytes(value) {
if (Buffer.isBuffer(value)) return value;
if (value instanceof Uint8Array) return Buffer.from(value);
if (typeof value === 'string') return Buffer.from(value, 'utf8');
throw new TypeError(`expected string, Buffer or Uint8Array, got ${typeof value}`);
}
/** Correct PAE; payloadType is bound into the signed bytes. */
function pae(payloadType, payload) {
const type = toBytes(payloadType);
const body = toBytes(payload);
return Buffer.concat([
Buffer.from(`DSSEv1 ${type.length} `, 'ascii'),
type,
Buffer.from(` ${body.length} `, 'ascii'),
body,
]);
}
/** The vulnerable encoding: ignores payloadType (negative control only). */
function paeIgnoringPayloadType(payloadType, payload) {
return pae('', payload);
}
/** Import a public key from PEM, KeyObject, or raw 32-byte ed25519 key. */
function importPublicKey(publicKey) {
if (publicKey instanceof crypto.KeyObject) return publicKey;
if (typeof publicKey === 'string') return crypto.createPublicKey(publicKey);
if (Buffer.isBuffer(publicKey) || publicKey instanceof Uint8Array) {
const raw = Buffer.from(publicKey);
if (raw.length === 32) {
const prefix = Buffer.from('302a300506032b6570032100', 'hex'); // SPKI ed25519
return crypto.createPublicKey({ key: Buffer.concat([prefix, raw]), format: 'der', type: 'spki' });
}
return crypto.createPublicKey({ key: raw, format: 'der', type: 'spki' });
}
throw new TypeError('unsupported public key');
}
/** Sign a payload, producing a single-signature DSSE envelope. */
function signEnvelope(payloadType, payload, privateKey, keyid = '') {
const typeBytes = toBytes(payloadType);
const body = toBytes(payload);
const preAuth = pae(typeBytes, body);
const sig = crypto.sign(null, preAuth, privateKey);
return {
payloadType: typeBytes.toString('utf8'),
payload: body.toString('base64'),
signatures: [{ keyid, sig: sig.toString('base64') }],
};
}
/**
* Verify a DSSE envelope. Recomputes PAE from the envelope's own payloadType
* AND payload, so swapping payloadType invalidates verification. Every
* candidate signature must verify; a single bad one is fatal.
*/
function verifyEnvelope(env, opts) {
if (!env || typeof env !== 'object') throw new Error('invalid envelope');
if (typeof env.payloadType !== 'string') throw new Error('missing payloadType');
if (typeof env.payload !== 'string') throw new Error('missing payload');
if (!Array.isArray(env.signatures) || env.signatures.length === 0) {
throw new Error('envelope has no signatures');
}
const { publicKey, keyid } = opts || {};
if (publicKey == null) throw new Error('publicKey is required');
const key = importPublicKey(publicKey);
const body = Buffer.from(env.payload, 'base64');
const preAuth = pae(env.payloadType, body);
const candidates = keyid == null
? env.signatures
: env.signatures.filter((s) => s && s.keyid === keyid);
if (candidates.length === 0) throw new Error(`no signature for keyid ${keyid}`);
for (const sig of candidates) {
if (!sig || typeof sig.sig !== 'string') throw new Error('malformed signature entry');
const sigBytes = Buffer.from(sig.sig, 'base64');
let ok = false;
try {
ok = crypto.verify(null, preAuth, key, sigBytes);
} catch {
ok = false;
}
if (!ok) throw new Error('signature verification failed');
}
return true;
}
/** Vulnerable verifier: binds only the payload, not the payloadType. */
function verifyEnvelopeIgnoringPayloadType(env, opts) {
if (!env || typeof env !== 'object') throw new Error('invalid envelope');
const { publicKey, keyid } = opts || {};
const key = importPublicKey(publicKey);
const body = Buffer.from(env.payload, 'base64');
const preAuth = paeIgnoringPayloadType(env.payloadType, body);
const candidates = keyid == null
? env.signatures
: env.signatures.filter((s) => s && s.keyid === keyid);
if (candidates.length === 0) throw new Error(`no signature for keyid ${keyid}`);
for (const sig of candidates) {
const sigBytes = Buffer.from(sig.sig, 'base64');
if (!crypto.verify(null, preAuth, key, sigBytes)) {
throw new Error('signature verification failed');
}
}
return true;
}
/** Vulnerable signer counterpart, to demonstrate the end-to-end replay. */
function signEnvelopeIgnoringPayloadType(payloadType, payload, privateKey, keyid = '') {
const typeBytes = toBytes(payloadType);
const body = toBytes(payload);
const preAuth = paeIgnoringPayloadType(typeBytes, body);
const sig = crypto.sign(null, preAuth, privateKey);
return {
payloadType: typeBytes.toString('utf8'),
payload: body.toString('base64'),
signatures: [{ keyid, sig: sig.toString('base64') }],
};
}
module.exports = {
pae, paeIgnoringPayloadType,
signEnvelope, signEnvelopeIgnoringPayloadType,
verifyEnvelope, verifyEnvelopeIgnoringPayloadType,
importPublicKey, toBytes,
};
Tests (full source in test.js) cover all required cases:
application/vnd.in-toto+json/hello → DSSEv1 28 application/vnd.in-toto+json 5 hello; multibyte type/日本語 (14 bytes, String#length === 8) with empty payload → DSSEv1 14 type/日本語 0; empty/empty → DSSEv1 0 0.pae('a','bc') = DSSEv1 1 a 2 bc ≠ pae('ab','c') = DSSEv1 2 ab 1 c.payloadType after signing rejects.$ node --test
# tests 9 # pass 9 # fail 0
$ npx -y node@20 --test
# tests 9 # pass 9 # fail 0
Multibyte hex proof (14 = 0x3134, not 8):
PAE = DSSEv1 14 type/日本語 0
HEX = 44535345763120313420747970652fe697a5e69cace8aa9e203020
# Evidence - Problem class: dsse-pae-signature-does-not-bind-envelope-payload-type - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-20T10:52:47.771Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "A Node 20 verifier for in-toto DSSE envelopes (npm-provenance / sigstore shape) recomputes the pre-authentication encoding and verifies ed25519 signature(s), but its PAE omits payloadType, so an envelope whose payloadType was swapped from application/vnd.in-toto+json to any other string still verifies against the untouched signature and payload. Implement `pae(payloadType, payload)` per the DSSE spec exactly (the literal DSSEv1, a single space, the byte-length of payloadType as ASCII decimal, a space, the payloadType, a space, the byte-length of payload, a space, the payload) and `verifyEnvelope(env, { publicKey, keyid })` that binds the PAE to the envelope it was signed for and rejects on a single bad signature in a multi-signature envelope. Prove it with: byte-exact PAE vectors including a multibyte UTF-8 payloadType (length in bytes, not characters) and an empty payload (length 0); the ambiguity assertion that (payloadType='a', payload='bc') and (payloadType='ab', payload='c') encode to different bytes; positive verification of a valid envelope plus rejection after flipping one payload byte; rejection of the payloadType-swapped envelope; and a negative control showing the payloadType-ignoring implementation accepts that same swapped envelope.", "environment": "node20", "language": "js", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "dsse-pae-signature-does-not-bind-envelope-payload-type", "provider": "openrouter", "solved_at": "2026-09-20T10:52:47.771Z", "version": "20"}Solution written and verified. Files: ~/dsse-pae/dsse.js, ~/dsse-pae/test.js, ~/dsse-pae/SOLUTION.md.
Result: 9/9 tests pass on Node v20.20.2 (and Node 22).
payloadType (ed25519 envelope verifier fix)A Node 20 verifier for in-toto / npm-provenance DSSE envelopes recomputes the pre-authentication encoding (PAE) and checks the ed25519 signature, but it omits payloadType from the PAE. An envelope whose payloadType is changed from application/vnd.in-toto+json to any other string still verifies against the untouched payload and sig.
DSSE signs the PAE, not the payload:
PAE(payloadType, payload) =
"DSSEv1" + SP + LEN(payloadType) + SP + payloadType + SP +
LEN(payload) + SP + payload
LEN() is the byte length rendered as ASCII decimal. The vulnerable verifier encodes only the payload, so the signed bytes are identical for every payloadType — a type-confusion/replay across media types.
Two ways to get it wrong (both fixed below):
payloadType never enters the signed bytes.String#length counts UTF-16 code units, not UTF-8 bytes ('日本語'.length === 3, but 9 bytes), producing a wrong/ambiguous prefix.A separate hardening requirement: every signature in a multi-signature envelope must be checked; short-circuiting on the first good one lets a forged entry ride along.
'use strict';
const crypto = require('crypto');
/** Coerce string | Uint8Array | Buffer to a Buffer (UTF-8 for strings). */
function toBytes(value) {
if (Buffer.isBuffer(value)) return value;
if (value instanceof Uint8Array) return Buffer.from(value);
if (typeof value === 'string') return Buffer.from(value, 'utf8');
throw new TypeError(`expected string, Buffer or Uint8Array, got ${typeof value}`);
}
/** Correct PAE; payloadType is bound into the signed bytes. */
function pae(payloadType, payload) {
const type = toBytes(payloadType);
const body = toBytes(payload);
return Buffer.concat([
Buffer.from(`DSSEv1 ${type.length} `, 'ascii'),
type,
Buffer.from(` ${body.length} `, 'ascii'),
body,
]);
}
/** The vulnerable encoding: ignores payloadType (negative control only). */
function paeIgnoringPayloadType(payloadType, payload) {
return pae('', payload);
}
/** Import a public key from PEM, KeyObject, or raw 32-byte ed25519 key. */
function importPublicKey(publicKey) {
if (publicKey instanceof crypto.KeyObject) return publicKey;
if (typeof publicKey === 'string') return crypto.createPublicKey(publicKey);
if (Buffer.isBuffer(publicKey) || publicKey instanceof Uint8Array) {
const raw = Buffer.from(publicKey);
if (raw.length === 32) {
const prefix = Buffer.from('302a300506032b6570032100', 'hex'); // SPKI ed25519
return crypto.createPublicKey({ key: Buffer.concat([prefix, raw]), format: 'der', type: 'spki' });
}
return crypto.createPublicKey({ key: raw, format: 'der', type: 'spki' });
}
throw new TypeError('unsupported public key');
}
/** Sign a payload, producing a single-signature DSSE envelope. */
function signEnvelope(payloadType, payload, privateKey, keyid = '') {
const typeBytes = toBytes(payloadType);
const body = toBytes(payload);
const preAuth = pae(typeBytes, body);
const sig = crypto.sign(null, preAuth, privateKey);
return {
payloadType: typeBytes.toString('utf8'),
payload: body.toString('base64'),
signatures: [{ keyid, sig: sig.toString('base64') }],
};
}
/**
* Verify a DSSE envelope. Recomputes PAE from the envelope's own payloadType
* AND payload, so swapping payloadType invalidates verification. Every
* candidate signature must verify; a single bad one is fatal.
*/
function verifyEnvelope(env, opts) {
if (!env || typeof env !== 'object') throw new Error('invalid envelope');
if (typeof env.payloadType !== 'string') throw new Error('missing payloadType');
if (typeof env.payload !== 'string') throw new Error('missing payload');
if (!Array.isArray(env.signatures) || env.signatures.length === 0) {
throw new Error('envelope has no signatures');
}
const { publicKey, keyid } = opts || {};
if (publicKey == null) throw new Error('publicKey is required');
const key = importPublicKey(publicKey);
const body = Buffer.from(env.payload, 'base64');
const preAuth = pae(env.payloadType, body);
const candidates = keyid == null
? env.signatures
: env.signatures.filter((s) => s && s.keyid === keyid);
if (candidates.length === 0) throw new Error(`no signature for keyid ${keyid}`);
for (const sig of candidates) {
if (!sig || typeof sig.sig !== 'string') throw new Error('malformed signature entry');
const sigBytes = Buffer.from(sig.sig, 'base64');
let ok = false;
try {
ok = crypto.verify(null, preAuth, key, sigBytes);
} catch {
ok = false;
}
if (!ok) throw new Error('signature verification failed');
}
return true;
}
/** Vulnerable verifier: binds only the payload, not the payloadType. */
function verifyEnvelopeIgnoringPayloadType(env, opts) {
if (!env || typeof env !== 'object') throw new Error('invalid envelope');
const { publicKey, keyid } = opts || {};
const key = importPublicKey(publicKey);
const body = Buffer.from(env.payload, 'base64');
const preAuth = paeIgnoringPayloadType(env.payloadType, body);
const candidates = keyid == null
? env.signatures
: env.signatures.filter((s) => s && s.keyid === keyid);
if (candidates.length === 0) throw new Error(`no signature for keyid ${keyid}`);
for (const sig of candidates) {
const sigBytes = Buffer.from(sig.sig, 'base64');
if (!crypto.verify(null, preAuth, key, sigBytes)) {
throw new Error('signature verification failed');
}
}
return true;
}
/** Vulnerable signer counterpart, to demonstrate the end-to-end replay. */
function signEnvelopeIgnoringPayloadType(payloadType, payload, privateKey, keyid = '') {
const typeBytes = toBytes(payloadType);
const body = toBytes(payload);
const preAuth = paeIgnoringPayloadType(typeBytes, body);
const sig = crypto.sign(null, preAuth, privateKey);
return {
payloadType: typeBytes.toString('utf8'),
payload: body.toString('base64'),
signatures: [{ keyid, sig: sig.toString('base64') }],
};
}
module.exports = {
pae, paeIgnoringPayloadType,
signEnvelope, signEnvelopeIgnoringPayloadType,
verifyEnvelope, verifyEnvelopeIgnoringPayloadType,
importPublicKey, toBytes,
};
Tests (full source in test.js) cover all required cases:
application/vnd.in-toto+json/hello → DSSEv1 28 application/vnd.in-toto+json 5 hello; multibyte type/日本語 (14 bytes, String#length === 8) with empty payload → DSSEv1 14 type/日本語 0; empty/empty → DSSEv1 0 0.pae('a','bc') = DSSEv1 1 a 2 bc ≠ pae('ab','c') = DSSEv1 2 ab 1 c.payloadType after signing rejects.$ node --test
# tests 9 # pass 9 # fail 0
$ npx -y node@20 --test
# tests 9 # pass 9 # fail 0
Multibyte hex proof (14 = 0x3134, not 8):
PAE = DSSEv1 14 type/日本語 0
HEX = 44535345763120313420747970652fe697a5e69cace8aa9e203020
# Evidence - Problem class: dsse-pae-signature-does-not-bind-envelope-payload-type - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-20T10:52:47.771Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "A Node 20 verifier for in-toto DSSE envelopes (npm-provenance / sigstore shape) recomputes the pre-authentication encoding and verifies ed25519 signature(s), but its PAE omits payloadType, so an envelope whose payloadType was swapped from application/vnd.in-toto+json to any other string still verifies against the untouched signature and payload. Implement `pae(payloadType, payload)` per the DSSE spec exactly (the literal DSSEv1, a single space, the byte-length of payloadType as ASCII decimal, a space, the payloadType, a space, the byte-length of payload, a space, the payload) and `verifyEnvelope(env, { publicKey, keyid })` that binds the PAE to the envelope it was signed for and rejects on a single bad signature in a multi-signature envelope. Prove it with: byte-exact PAE vectors including a multibyte UTF-8 payloadType (length in bytes, not characters) and an empty payload (length 0); the ambiguity assertion that (payloadType='a', payload='bc') and (payloadType='ab', payload='c') encode to different bytes; positive verification of a valid envelope plus rejection after flipping one payload byte; rejection of the payloadType-swapped envelope; and a negative control showing the payloadType-ignoring implementation accepts that same swapped envelope.", "environment": "node20", "language": "js", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "dsse-pae-signature-does-not-bind-envelope-payload-type", "provider": "openrouter", "solved_at": "2026-09-20T10:52:47.771Z", "version": "20"}