PCH-02-008-binaryreader-bounds.patch.md

4.6 KB · raw

rendered · source

PCH-02-008 — BinaryReader explicit bounds checks

Severity: Informational Target file: src/crypto/peachPayload.ts:940-969 (BinaryReader) Report: peach-code-review.md, Finding PCH-02-008

Current failure modes (verified by reading the class)

The decoded bytes normally sit inside a GCM-authenticated envelope, but the same decoder runs on imported .peach payloads where an attacker who knows the passphrase controls the plaintext (report text). The MV-4 declared-length and cumulative caps (peachPayload.ts:301–311) bound total work before this point; this fix makes the per-read failure mode uniform and self-evidently safe.

Error-code choice (deviation from the brief's sample text)

The brief's sample throws prose messages ("BinaryReader: unexpected end of buffer reading u8"). This file's decode layer throws ALL_CAPS error codes (PAYLOAD_TOO_SHORT, SECTION_LENGTH_MISMATCH, BAD_MAGIC, UNKNOWN_ENTRY_TYPE:*) that the import UI maps to user copy. A truncated buffer mid-entry IS a too-short payload, so the bounds check throws the existing PAYLOAD_TOO_SHORT code — the import UI handles it today with zero UI changes. (Distinct prose codes would surface as the generic error string instead.)

Exact replacement — BinaryReader class

// REPLACE the whole class (peachPayload.ts:940-969) with:

class BinaryReader {
  cursor: number;
  private view: DataView;
  constructor(private data: Uint8Array, start: number) {
    this.cursor = start;
    this.view = new DataView(data.buffer, data.byteOffset, data.byteLength);
  }
  // PCH-02-008: every read validates the cursor stays inside the buffer. The
  // payload normally arrives through a GCM-authenticated envelope, but the
  // same decoder runs on imported `.peach` files where the plaintext is
  // attacker-shaped (given the passphrase); a section truncated mid-entry
  // must fail with the same PAYLOAD_TOO_SHORT code the section parser uses,
  // never with undefined/NaN reads or engine RangeErrors.
  private ensure(n: number): void {
    if (this.cursor + n > this.data.length) throw new Error("PAYLOAD_TOO_SHORT");
  }
  u8(): number {
    this.ensure(1);
    return this.data[this.cursor++];
  }
  u16(): number {
    this.ensure(2);
    const v = this.view.getUint16(this.cursor, true);
    this.cursor += 2;
    return v;
  }
  u32(): number {
    this.ensure(4);
    const v = this.view.getUint32(this.cursor, true);
    this.cursor += 4;
    return v;
  }
  read(n: number): Uint8Array {
    this.ensure(n);
    const out = this.data.subarray(this.cursor, this.cursor + n);
    this.cursor += n;
    return out;
  }
  lpString(lenSize: 1 | 2): string {
    const len = lenSize === 1 ? this.u8() : this.u16();
    return new TextDecoder().decode(this.read(len));
  }
}

Notes:

Test seam — export the class

BinaryReader is module-private and only reachable end-to-end through a full encrypted envelope, which cannot express "truncated mid-entry" without also failing the section-length check first. Export it for direct regression coverage (same precedent as the initHash/hkdf2 exports added for the PCH-01-002 KATs):

// BEFORE (peachPayload.ts:940)
class BinaryReader {

// AFTER
// Exported for the PCH-02-008 bounds-check regression tests only — not part
// of the payload API surface.
export class BinaryReader {

Tests

peachPayload.binaryreader-bounds.test.ts (in this folder) — 8 tests: each read method past-end throws PAYLOAD_TOO_SHORT (u8/u16/u32/read/lpString, including a declared length-prefix that overruns), valid reads at exact boundaries still succeed, cursor advances correctly (regression), and a non-zero start offset is respected. Destination: src/crypto/peachPayload.binaryreader-bounds.test.ts.

Existing round-trip coverage (peachPayload.test.ts, peachPayload.bomb.test.ts) regression-guards the happy decode path end-to-end.

Save message (integration)

fix: PCH-02-008 BinaryReader explicit bounds checks on every read — uniform PAYLOAD_TOO_SHORT on truncation