Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

## [Unreleased]

- fix(identity): Fix 'Uint8Array expected' error in DelegationChain serialization with ArrayBuffer data

## [4.0.4] - 2025-09-18

- fix(agent): create a fresh default polling strategy per request.
Expand Down
103 changes: 102 additions & 1 deletion packages/identity/src/identity/delegation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ test('DelegationChain can be serialized to and from JSON', async () => {
const rootToMiddleJson = JSON.stringify(rootToMiddle);
// All strings in the JSON should be hex so it is clear how to decode this as different versions
// of `toJSON` evolve.
JSON.parse(rootToMiddleJson, (key, value) => {
JSON.parse(rootToMiddleJson, (_key, value) => {
if (typeof value === 'string') {
const byte = parseInt(value, 16);
if (isNaN(byte)) {
Expand Down Expand Up @@ -155,3 +155,104 @@ describe('PartialDelegationIdentity', () => {
});
});
});

describe('DelegationChain ArrayBuffer serialization bug fix', () => {
it('should handle ArrayBuffer binary data in toJSON without throwing', async () => {
const root = createIdentity(2);
const middle = createIdentity(1);

// Create a normal delegation chain
const chain = await DelegationChain.create(
root,
middle.getPublicKey(),
new Date(1609459200000),
);

// Get the JSON representation first
const originalJson = chain.toJSON();

// Create a new chain from JSON, then manipulate it to simulate the bug condition
// The bug occurred when binary data was ArrayBuffer instead of Uint8Array
const recreated = DelegationChain.fromJSON(originalJson);

// Access the internal delegations and simulate ArrayBuffer conversion
// This simulates what happened in real-world usage when crypto APIs
// or serialization processes returned ArrayBuffer instead of Uint8Array
const delegationsWithArrayBuffer = recreated.delegations.map(signedDelegation => {
// Convert signature Uint8Array to ArrayBuffer (the bug condition)
const signature = signedDelegation.signature;
const arrayBufferSignature = signature.buffer.slice(
signature.byteOffset,
signature.byteOffset + signature.byteLength
);

return {
delegation: signedDelegation.delegation,
signature: arrayBufferSignature as ArrayBuffer // This would cause the original error
};
});

// Create a chain using fromDelegations with ArrayBuffer publicKey too
const publicKeyArrayBuffer = recreated.publicKey.buffer.slice(
recreated.publicKey.byteOffset,
recreated.publicKey.byteOffset + recreated.publicKey.byteLength
);

const chainWithArrayBuffers = DelegationChain.fromDelegations(
delegationsWithArrayBuffer,
publicKeyArrayBuffer as ArrayBuffer
);

// This would throw "Uint8Array expected" before the safeBytesToHex fix
// but should work fine after the fix
expect(() => {
const json = chainWithArrayBuffers.toJSON();
// Verify the output is still valid hex
expect(json.delegations[0].signature).toMatch(/^[0-9a-f]+$/);
expect(json.publicKey).toMatch(/^[0-9a-f]+$/);
}).not.toThrow();
});

it('should handle ArrayBuffer in delegation pubkey during toJSON', async () => {
const root = createIdentity(3);
const middle = createIdentity(1);

const chain = await DelegationChain.create(
root,
middle.getPublicKey(),
new Date(1609459200000),
);

// Simulate the scenario where delegation.pubkey is ArrayBuffer
const delegationsWithArrayBufferPubkey = chain.delegations.map(signedDelegation => {
const pubkey = signedDelegation.delegation.pubkey;
const arrayBufferPubkey = pubkey.buffer.slice(
pubkey.byteOffset,
pubkey.byteOffset + pubkey.byteLength
);

// Create new delegation with ArrayBuffer pubkey
const delegationWithArrayBuffer = {
pubkey: arrayBufferPubkey as Record<string, unknown>,
expiration: signedDelegation.delegation.expiration,
targets: signedDelegation.delegation.targets
};

return {
delegation: delegationWithArrayBuffer as ArrayBuffer,
signature: signedDelegation.signature
};
});

const chainWithArrayBufferPubkey = DelegationChain.fromDelegations(
delegationsWithArrayBufferPubkey,
chain.publicKey
);

// This tests another code path that could fail with ArrayBuffer
expect(() => {
const json = chainWithArrayBufferPubkey.toJSON();
expect(json.delegations[0].delegation.pubkey).toMatch(/^[0-9a-f]+$/);
}).not.toThrow();
});
});
20 changes: 16 additions & 4 deletions packages/identity/src/identity/delegation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,18 @@ import { Principal } from '@dfinity/principal';
import { PartialIdentity } from './partial.ts';
import { bytesToHex, hexToBytes } from '@noble/hashes/utils';

/**
* Safe wrapper around bytesToHex that handles ArrayBuffer/Uint8Array type conversion.
* Required because @noble/hashes v1.8+ strictly expects Uint8Array inputs.
* @param data The binary data to convert to hexadecimal string (ArrayBuffer, Uint8Array, or ArrayLike<number>)
*/
function safeBytesToHex(data: ArrayBuffer | Uint8Array | ArrayLike<number>): string {
if (data instanceof Uint8Array) {
return bytesToHex(data);
}
return bytesToHex(new Uint8Array(data));
}

function _parseBlob(value: unknown): Uint8Array {
if (typeof value !== 'string' || value.length < 64) {
throw new Error('Invalid public key.');
Expand Down Expand Up @@ -50,7 +62,7 @@ export class Delegation implements ToCborValue {
// with an OID). After de-hex, if it's not obvious what it is, it's an ArrayBuffer.
return {
expiration: this.expiration.toString(16),
pubkey: bytesToHex(this.pubkey),
pubkey: safeBytesToHex(this.pubkey),
...(this.targets && { targets: this.targets.map(p => p.toHex()) }),
};
}
Expand Down Expand Up @@ -241,15 +253,15 @@ export class DelegationChain {
return {
delegation: {
expiration: delegation.expiration.toString(16),
pubkey: bytesToHex(delegation.pubkey),
pubkey: safeBytesToHex(delegation.pubkey),
...(targets && {
targets: targets.map(t => t.toHex()),
}),
},
signature: bytesToHex(signature),
signature: safeBytesToHex(signature),
};
}),
publicKey: bytesToHex(this.publicKey),
publicKey: safeBytesToHex(this.publicKey),
};
}
}
Expand Down
Loading