Skip to content

AttributesIdentity injects sender_info into read_state polls, breaking ingress sig verification on every update call #1355

@aterga

Description

@aterga

Summary

AttributesIdentity.transformRequest unconditionally injects sender_info into the body of every outgoing HttpAgentRequest. That includes the read_state polls the agent fires after submitting an update call. The IC's read_state hash does not include sender_info in its representation-independent hash, so the polled request's basic signature ends up over hash(body+sender_info) while the IC verifies against hash(body) — every poll fails with:

Status: 400
Body: Invalid signature: Invalid basic signature: Ed25519 signature could not be verified: …

Net effect: any update call made through AttributesIdentity succeeds at /api/v4/.../call but then the agent can never poll the response. To the caller it looks like every actor.method() rejects.

This affects every consumer of AttributesIdentity that calls a non-query canister method, on @icp-sdk/core@^5.x (verified against 5.3.1).

Reproduction

import { HttpAgent, Actor } from '@icp-sdk/core/agent';
import { AuthClient } from '@icp-sdk/auth/client';
import { AttributesIdentity, Ed25519KeyIdentity } from '@icp-sdk/core/identity';
import { Principal } from '@icp-sdk/core/principal';

const session = Ed25519KeyIdentity.generate();
const auth = new AuthClient({
  identity: session,
  identityProvider: 'https://id.ai/authorize',
});

const [inner, attrs] = await Promise.all([
  auth.signIn(),
  pendingNonce.then((nonce) =>
    auth.requestAttributes({ keys: ['sso:dfinity.org:email'], nonce }),
  ),
]);

const identity = new AttributesIdentity({
  inner,
  attributes: attrs,
  signer: { canisterId: Principal.fromText('rdmx6-jaaaa-aaaaa-aaadq-cai') }, // II prod
});
const agent  = await HttpAgent.create({ host: 'https://icp0.io', identity });
const actor  = Actor.createActor(idl, { agent, canisterId: someCanister });

await actor.someUpdate(); // 400 Invalid basic signature on the read_state poll

The bare-delegation flow (skip AttributesIdentity, use inner directly) works fine.

Wire-level evidence

After instrumenting globalThis.fetch and dumping the CBOR-encoded request bodies for one actor.register() call I see exactly four request bodies posted:

  • 1 × /api/v4/<canister>/call (4595 bytes) — both `sender_info` and a valid sender_sig
  • 3 × /api/v2/<canister>/read_state (4580 bytes each) — also has `sender_info` in content, plus a different sender_sig

The IC's 400 error reports the read_state's sender_sig (matches the sig field bytes in the read_state body, NOT the /call). I.e. the call itself was accepted; only the poll fails.

Decoding the read_state body's content shows:

content: a5
  6c 'request_type'    : 6a 'read_state'
  65 'paths'            : 81 [ 82 'request_status' <hash> ]
  66 'sender'           : 581d <29-byte principal>
  6e 'ingress_expiry'   : 1b <u64>
  6b 'sender_info'      : a3 { signer, info, sig }   ← injected by AttributesIdentity

Root cause

representation_independent_hash_call_or_query includes sender_info in the hashed map.

representation_independent_hash_read_state hashes only { request_type, ingress_expiry, paths, sender, nonce? }sender_info is not part of the read_state schema and is not hashed.

But agent-js's AttributesIdentity.transformRequest:

transformRequest(request: HttpAgentRequest): Promise<unknown> {
  return this.#inner.transformRequest({
    ...request,
    body: {
      ...request.body,
      sender_info: { signer, info, sig },   // unconditional
    },
  } as HttpAgentRequest);
}

…doesn't look at request.body.request_type, so it pollutes the read_state body with sender_info. The DelegationIdentity it wraps then computes requestIdOf(body) — which now includes the injected field — and signs that hash. The IC re-derives its own hash without the field, and the signatures disagree.

Suggested fix

Gate the injection on the request type:

transformRequest(request: HttpAgentRequest): Promise<unknown> {
  const body = request.body as { request_type?: string } | undefined;
  if (body?.request_type !== 'call' && body?.request_type !== 'query') {
    // read_state, etc. — IC doesn't hash sender_info on these endpoints
    return this.#inner.transformRequest(request);
  }
  return this.#inner.transformRequest({
    ...request,
    body: {
      ...request.body,
      sender_info: {
        signer: this.#signer.canisterId.toUint8Array(),
        info: this.#attributes.data,
        sig: this.#attributes.signature,
      },
    },
  } as HttpAgentRequest);
}

(Also worth adding a unit test that constructs a fake HttpAgentRequest with request_type: 'read_state' and asserts the transformed body has no sender_info.)

Workaround

Subclass / replace AttributesIdentity locally with a version that does the request-type gate. Verified to fix the bug end-to-end against prod II + a Motoko canister using `mo:identity-attributes`'s `#Authorization` policy. Reference implementation:

https://github.com/aterga/identity-attributes/blob/feat/use-icp-sdk-auth/demos/bagel/frontend/src/main.ts (search for CallOnlyAttributesIdentity)

Environment

  • @icp-sdk/core@5.3.1
  • @icp-sdk/auth@6.2.2 (also reproduces with 6.2.1)
  • IC replica revision `52cf4d105ed7ae882e45d09d364c961d2fb442d3` (subnet io67a-…-gqe, deployed 2026-04-26)
  • Browsers: Chrome 122 on macOS

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions