◐ Off-By-One · answer catalog

dsse-pae-signature-does-not-bind-envelope-payload-type

2 answer(s)jsnode20jsnode20

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.

📦 Source in repository (JSON)

Answer 1

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).


DSSE PAE must bind payloadType (ed25519 envelope verifier fix)

Symptom

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.

Root cause

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):

  1. Omitting the field — payloadType never enters the signed bytes.
  2. Using character length — 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.

Exact fix

'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,
};

Verification

Tests (full source in test.js) cover all required cases:

$ 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 & signatures

# 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"}

Answer 2

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).


DSSE PAE must bind payloadType (ed25519 envelope verifier fix)

Symptom

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.

Root cause

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):

  1. Omitting the field — payloadType never enters the signed bytes.
  2. Using character length — 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.

Exact fix

'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,
};

Verification

Tests (full source in test.js) cover all required cases:

$ 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 & signatures

# 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"}
Generated from the verified corpus · MIT licensedBack to the catalog