Nextcloud 34 → Cairn OCM share refused: Digest header does not match received body #454

Closed
opened 2026-09-09 20:09:06 +00:00 by Cordy · 5 comments
Owner

Symptom

Dogfood (2026-09-09): NC 34.0.3.2 (nextcloud.c0rdyceps.ch) sharing to nikola-test@files-bao.c0rdyceps.ch → NC UI "Failed to create share." NC log: "could not find nikola-test@…, maybe the server is currently unreachable".

Evidence chain

  1. files-bao log 19:52:21Z: GET /.well-known/ocm 200POST /ocm/shares 401 (bytes_in=544, bytes_out=64) → NC falls back to /ocs-provider/ + /ocs/v2.php/cloud/shares (both 401, we do not serve legacy OCS) → NC reports recipient not found.
  2. Unsigned probe from same path → 401, 60-byte body ocm: request is unsigned… → NC's request was NOT refused as unsigned; it carried a Signature.
  3. Probe with valid Digest + garbage signature through the same public ingress (Caddy→WG→Traefik) → passes the digest gate, fails later with the 66-byte signature does not verify body → our digest computation and the transit path are correct for well-formed requests.
  4. 64-byte refusal uniquely matches ocm: content digest does not match the request body (13-byte JSON overhead + 51-char message; verified against the 60/66-byte probes). → NC signed the request but its Digest header does not match the body bytes we received.

NC source findings (stable34, reviewed)

  • Outbound: OCMDiscoveryService::requestRemoteOcmEndpoint json_encodes once, signs that exact string (SignatureManager::signOutgoingRequestIClientPayloadOutgoingSignedRequest, digest SHA-256= + b64 — format identical to ours), passes the same string as Guzzle body. On paper consistent.
  • OC\Http\Client\Client::buildRequestOptions forces HTTP/2 (RequestOptions::VERSION => '2.0', CURL_HTTP_VERSION_2TLS) — the transport differs from the naive picture; suspect list includes h2 framing interactions and NC hashing vs sending divergence.
  • Inbound (NC receiving): digest mismatch only logs a warning and the request is still accepted unless core.enforce_signed_ocm_request is set (default off). → NC↔NC federation works even if NC's own digests are broken — the monoculture masks it. We are the strict receiver that noticed.

Plan

  1. v0.6.143 (this issue, step 1): refusal names the evidence. Digest-mismatch errors (both dialects) carry: received Digest/Content-Digest header value, our computed value, received body byte count, and the Content-Length header. No body content leaks — hashes and lengths only. NC records our response body in its exception, so both admins see the same facts. TDD.
  2. Re-run the NC→bao share, read the refusal → root-cause with real values.
  3. Fix per findings (ours or a documented NC-quirk tolerance). Stance: we will not silently accept a digest that does not match the body; any tolerance for a documented NC defect must be explicit, per-server, and verified against the signed base.
  4. Extend ocm-interop.yml with a regression case once root cause is known.

House rules: karpathy-guidelines (surgical), Superpowers TDD, one-shot on a branch.

## Symptom Dogfood (2026-09-09): NC 34.0.3.2 (nextcloud.c0rdyceps.ch) sharing to `nikola-test@files-bao.c0rdyceps.ch` → NC UI "Failed to create share." NC log: "could not find nikola-test@…, maybe the server is currently unreachable". ## Evidence chain 1. files-bao log 19:52:21Z: `GET /.well-known/ocm 200` → `POST /ocm/shares 401 (bytes_in=544, bytes_out=64)` → NC falls back to `/ocs-provider/` + `/ocs/v2.php/cloud/shares` (both 401, we do not serve legacy OCS) → NC reports recipient not found. 2. Unsigned probe from same path → 401, 60-byte body `ocm: request is unsigned…` → NC's request was NOT refused as unsigned; it carried a Signature. 3. Probe with **valid Digest** + garbage signature through the same public ingress (Caddy→WG→Traefik) → passes the digest gate, fails later with the 66-byte `signature does not verify` body → our digest computation and the transit path are correct for well-formed requests. 4. 64-byte refusal uniquely matches `ocm: content digest does not match the request body` (13-byte JSON overhead + 51-char message; verified against the 60/66-byte probes). → **NC signed the request but its Digest header does not match the body bytes we received.** ## NC source findings (stable34, reviewed) - Outbound: `OCMDiscoveryService::requestRemoteOcmEndpoint` json_encodes once, signs that exact string (`SignatureManager::signOutgoingRequestIClientPayload` → `OutgoingSignedRequest`, digest `SHA-256=` + b64 — format identical to ours), passes the same string as Guzzle `body`. On paper consistent. - `OC\Http\Client\Client::buildRequestOptions` forces **HTTP/2** (`RequestOptions::VERSION => '2.0'`, `CURL_HTTP_VERSION_2TLS`) — the transport differs from the naive picture; suspect list includes h2 framing interactions and NC hashing vs sending divergence. - Inbound (NC receiving): digest mismatch only logs a warning and the request is **still accepted** unless `core.enforce_signed_ocm_request` is set (default off). → **NC↔NC federation works even if NC's own digests are broken — the monoculture masks it.** We are the strict receiver that noticed. ## Plan 1. **v0.6.143 (this issue, step 1): refusal names the evidence.** Digest-mismatch errors (both dialects) carry: received `Digest`/`Content-Digest` header value, our computed value, received body byte count, and the `Content-Length` header. No body content leaks — hashes and lengths only. NC records our response body in its exception, so both admins see the same facts. TDD. 2. Re-run the NC→bao share, read the refusal → root-cause with real values. 3. Fix per findings (ours or a documented NC-quirk tolerance). **Stance:** we will not silently accept a digest that does not match the body; any tolerance for a documented NC defect must be explicit, per-server, and verified against the signed base. 4. Extend `ocm-interop.yml` with a regression case once root cause is known. House rules: karpathy-guidelines (surgical), Superpowers TDD, one-shot on a branch.
Author
Owner

Step 1 shipped: v0.6.143 (PR #456, red witnessed on runner) live on both dogfoods. Digest refusals now name received header, computed digest, body bytes, Content-Length. Next: Nikola re-attempts the NC → files-bao share; the refusal in the pod log / NC exception gives the real values for root-causing.

Step 1 shipped: v0.6.143 (PR #456, red witnessed on runner) live on both dogfoods. Digest refusals now name received header, computed digest, body bytes, Content-Length. Next: Nikola re-attempts the NC → files-bao share; the refusal in the pod log / NC exception gives the real values for root-causing.
Author
Owner

Root cause captured (v0.6.144 refusal log, 2026-09-09 21:22:50Z):

Digest header:  SHA-512=B6URSwZqTMe4z4oZLNMByhOpbXSsSO4F+oDC2DzaaD2FSM58ER+HQ/bSRW+C1pkz0eMAyuxZ+sj64nUrDDoFHw==
computed:       SHA-256=X0pd/w59ioM5PSpJyZYHHMdTZb+Vbdv95mj3XDsXNk0=
545 received bytes, Content-Length header "545"

Body arrived intact (545 = 545). Nextcloud signs OCM requests with SHA-512, not SHA-256. Confirmed in stable34 OC\OCM\OCMSignatoryManager::getOptions(): 'algorithm' => SignatureAlgorithm::RSA_SHA512, 'digestAlgorithm' => DigestAlgorithm::SHA512 — overriding their own SHA-256 defaults and the documented example in SignatureManager. NC↔NC never notices because NC receivers accept broken/mismatched signatures unless enforce_signed_ocm_request is set (monoculture masking, as suspected).

Three defects on our side fall out of the faithful reproduction:

  1. Legacy digest gate hardcodes SHA-256=.
  2. Signature verification hardcodes crypto.SHA256, ignoring the algorithm="…" parameter.
  3. legacyBase builds a fixed list (request-target) content-length date digest, ignoring the sender's headers="…" declaration — NC signs … digest host, and over HTTP/2 (NC 34 forces h2) the host must be reconstructed from the request authority (req.Host), not the header map.

Fix (v0.6.145, TDD — red test is a byte-faithful NC-34-style signed request): honour declared digest algorithm (SHA-256/SHA-512, unsupported refused by name), honour declared signature algorithm (sha256/sha512), rebuild the base from the declared header list with host from the authority. Our own SignLegacy dialect unchanged and still verifies via the fallback list.

**Root cause captured** (v0.6.144 refusal log, 2026-09-09 21:22:50Z): ``` Digest header: SHA-512=B6URSwZqTMe4z4oZLNMByhOpbXSsSO4F+oDC2DzaaD2FSM58ER+HQ/bSRW+C1pkz0eMAyuxZ+sj64nUrDDoFHw== computed: SHA-256=X0pd/w59ioM5PSpJyZYHHMdTZb+Vbdv95mj3XDsXNk0= 545 received bytes, Content-Length header "545" ``` Body arrived intact (545 = 545). **Nextcloud signs OCM requests with SHA-512**, not SHA-256. Confirmed in stable34 `OC\OCM\OCMSignatoryManager::getOptions()`: `'algorithm' => SignatureAlgorithm::RSA_SHA512, 'digestAlgorithm' => DigestAlgorithm::SHA512` — overriding their own SHA-256 defaults and the documented example in `SignatureManager`. NC↔NC never notices because NC receivers accept broken/mismatched signatures unless `enforce_signed_ocm_request` is set (monoculture masking, as suspected). Three defects on our side fall out of the faithful reproduction: 1. Legacy digest gate hardcodes `SHA-256=`. 2. Signature verification hardcodes `crypto.SHA256`, ignoring the `algorithm="…"` parameter. 3. `legacyBase` builds a fixed list `(request-target) content-length date digest`, ignoring the sender's `headers="…"` declaration — NC signs `… digest host`, and over HTTP/2 (NC 34 forces h2) the host must be reconstructed from the request authority (`req.Host`), not the header map. Fix (v0.6.145, TDD — red test is a byte-faithful NC-34-style signed request): honour declared digest algorithm (SHA-256/SHA-512, unsupported refused by name), honour declared signature algorithm (sha256/sha512), rebuild the base from the declared header list with `host` from the authority. Our own SignLegacy dialect unchanged and still verifies via the fallback list.
Author
Owner

Fix shipped: v0.6.145 (PR #458, red witnessed with a byte-faithful NC-34 signed request, full suite green), live on both dogfoods. Verifier honours declared digest algorithm (SHA-256/SHA-512), declared signature algorithm (sha256/sha512), and rebuilds the signing base from the sender's headers list with host from the request authority (HTTP/2-safe). Awaiting the live NC → files-bao share for end-to-end confirmation; then: interop-workflow regression case + handbook note on the NC SHA-512 quirk + close.

**Fix shipped: v0.6.145** (PR #458, red witnessed with a byte-faithful NC-34 signed request, full suite green), live on both dogfoods. Verifier honours declared digest algorithm (SHA-256/SHA-512), declared signature algorithm (sha256/sha512), and rebuilds the signing base from the sender's `headers` list with `host` from the request authority (HTTP/2-safe). Awaiting the live NC → files-bao share for end-to-end confirmation; then: interop-workflow regression case + handbook note on the NC SHA-512 quirk + close.
Author
Owner

End-to-end confirmed live (2026-09-10): NC 34 → files-bao share created without error, landed in Federated → Requests, accepted, now lives under the nextcloud.c0rdyceps.ch federation folder. First successful Nextcloud → Cairn OCM share. Remaining before close: interop-workflow regression case for the SHA-512 dialect + handbook note on the NC quirk (folding into the next release).

**End-to-end confirmed live** (2026-09-10): NC 34 → files-bao share created without error, landed in Federated → Requests, accepted, now lives under the nextcloud.c0rdyceps.ch federation folder. First successful Nextcloud → Cairn OCM share. Remaining before close: interop-workflow regression case for the SHA-512 dialect + handbook note on the NC quirk (folding into the next release).
Author
Owner

Closing: fix confirmed end-to-end live (first NC → Cairn share accepted and browsable); TestVerifyNextcloudSHA512Signature is the permanent byte-faithful regression in the suite; handbook interop note on the NC SHA-512 dialect shipped in v0.6.146. Full arc: v0.6.143 evidence-bearing refusals → v0.6.144 server-side refusal logging → capture → v0.6.145 SHA-512 + declared-header-list verification.

Closing: fix confirmed end-to-end live (first NC → Cairn share accepted and browsable); `TestVerifyNextcloudSHA512Signature` is the permanent byte-faithful regression in the suite; handbook interop note on the NC SHA-512 dialect shipped in v0.6.146. Full arc: v0.6.143 evidence-bearing refusals → v0.6.144 server-side refusal logging → capture → v0.6.145 SHA-512 + declared-header-list verification.
Cordy closed this issue 2026-09-09 22:37:12 +00:00
Sign in to join this conversation.
No labels
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: Cordy/Cairn#454
No description provided.