paperclipai/paperclip · error

Teams upload bytes do not match consent

Error message

Teams upload bytes do not match consent

What it means

For operation 'put', exchange() requires an actual Buffer snapshot whose length equals the consented byteSize and whose SHA-256 equals the sha256 recorded in the consent binding. This error means the bytes offered for upload are not exactly the bytes the user consented to receive — either no/invalid buffer, a size mismatch, or different content. It is an integrity guard so the uploaded file can never diverge from the consent card shown to the user.

Source

Thrown at server/src/services/chat-teams-file-consent.ts:443

    options: UploadRequestOptions,
  ): Promise<TeamsUploadOutcome> {
    if (
      (operation !== "put" && operation !== "status") ||
      options.byteSize !== this.#byteSize
    )
      throw new Error("Invalid Teams upload binding");
    // The capability is itself a security boundary; calling it directly cannot
    // bypass the outer convenience function's exact-byte checks or mutate a
    // caller-owned Buffer while current authorization is awaited.
    const snapshot =
      operation === "put" && Buffer.isBuffer(bytes) ? Buffer.from(bytes) : null;
    if (
      operation === "put" &&
      (!snapshot ||
        snapshot.length !== this.#byteSize ||
        createHash("sha256").update(snapshot).digest("hex") !== this.#sha256)
    )
      throw new Error("Teams upload bytes do not match consent");
    const controller = new AbortController();
    const signal = options.signal
      ? AbortSignal.any([options.signal, controller.signal])
      : controller.signal;
    const timer = setTimeout(() => controller.abort(), 30_000);
    try {
      await abortable(Promise.resolve().then(options.authorize), signal);
      signal.throwIfAborted();
      if (this.#expiresAt <= Date.now())
        return { kind: "uncertain", reason: "session_unavailable" };
      if (operation === "put") {
        if (this.#confirmed) return { kind: "uploaded" };
        if (this.#putStarted)
          return { kind: "uncertain", reason: "put_already_attempted" };
        this.#putStarted = true;
      }
      const response = await abortable(
        (options.request ?? guardedRemoteHttpFetch)(

View on GitHub (pinned to 01ad858492)

Solutions

  1. Recompute sha256 and byte length from the exact buffer you pass and compare against binding.sha256/binding.byteSize before calling exchange; if they differ, refresh the source attachment rather than forcing the upload.
  2. Pass a Node Buffer (not string/Uint8Array view of different content) — exchange snapshots it via Buffer.from.
  3. If the file legitimately changed, create a new consent binding and send a new consent card; the old consent is bound to the old bytes.
  4. Check that the digest was computed with createHash('sha256').update(buffer).digest('hex') on the same bytes actually uploaded (no encoding conversion).

Example fix

// before
await exchangeTeamsFileUpload({ upload, binding, operation: "put", bytes: maybeStaleBuffer });

// after
const bytes = await loadAttachmentBytes(attachmentId); // fresh read
const sha256 = createHash("sha256").update(bytes).digest("hex");
if (bytes.length !== binding.byteSize || sha256 !== binding.sha256) {
  throw new Error("source attachment changed since consent; re-issue consent card");
}
await exchangeTeamsFileUpload({ upload, binding, operation: "put", bytes });
Defensive patterns

Strategy: validation

Validate before calling

import { createHash } from "node:crypto";
const sha256 = createHash("sha256").update(bytes).digest("hex");
if (!(bytes instanceof Buffer) || bytes.length !== binding.byteSize || sha256 !== binding.sha256) {
  throw new Error("upload bytes no longer match consented content");
}

Type guard

function matchesConsent(bytes: unknown, binding: TeamsFileConsentBinding): bytes is Buffer {
  return bytes instanceof Buffer &&
    bytes.length === binding.byteSize &&
    createHash("sha256").update(bytes).digest("hex") === binding.sha256;
}

Try / catch

try {
  await exchangeTeamsFileUpload({ upload, binding, operation: "put", bytes });
} catch (e) {
  if (e instanceof Error && e.message === "Teams upload bytes do not match consent") {
    // source changed since consent: re-issue the consent card with a new binding
    return { kind: "uncertain", reason: "consent_stale" };
  }
  throw e;
}

Prevention

When it happens

Trigger: exchange('put', bytes, ...) where bytes is not a Buffer (snapshot null), snapshot.length !== this.#byteSize, or sha256(snapshot) !== this.#sha256. Happens when the source file was modified or re-encoded after the binding was created, the wrong file/buffer is passed, or the caller hashed a different variant (e.g. normalized line endings) than the binding's digest.

Common situations: Attachment content regenerated between consent grant and upload (digest recorded at publication time, file changed since); passing a string or Blob instead of Buffer; streaming pipeline that dropped or added bytes; retrieving the wrong attachment row so size or hash differs.

Understand the failure class

Background: Checksum mismatch errors: "checksum verification failed", "digest mismatch", "expected vs actual checksum" — what they mean and how to fix them — this error's family across 41 libraries.

Related errors


AI-assisted analysis of paperclipai/paperclip@01ad858492 (2026-09-10). Data as JSON: /api/errors/b4a868c542b78161. Report an issue: GitHub.