Skip to content

XDR byte fields of named typedef types are wrapper objects, not Uint8Array #1639

Description

@quietbits

XDR byte fields of named typedef types are wrapper objects, not Uint8ArrayKeypair.verify returns false for a valid signature

Severity: Medium. Needs a code fix and doc corrections. Tested on 17.0.0-rc.1 from npm; Node 22.22.0, Deno 2.8.3, Bun 1.3.14 identical.

docs/XDR_MIGRATION.md § 6 and CHANGELOG.md both state that every byte field (opaque[N], opaque<N>, Hash, Signature, ScBytes, …) "is a Uint8Array" and that indexing and .length keep working. For byte fields whose XDR type is a named typedef with a generated class that is false — they are BytesValue subclasses holding the bytes at .value, readable via .toBytes():

Expression instanceof Uint8Array .length Array.from(…)
new xdr.Hash(bytes) false undefined []
xdr.ScVal.scvBytes(b).bytes false undefined []
hash(…), Keypair#rawPublicKey(), StrKey.decode* true 32 32 elements

The wrappers are exactly the BytesValue subclasses, and there are fourteen:

import { xdr } from "@stellar/stellar-sdk";
const wrappers = Object.entries(xdr).filter(([, v]) => {
  let p = Object.getPrototypeOf(v);
  while (typeof p === "function") { if (p.name === "BytesValue") return true; p = Object.getPrototypeOf(p); }
  return false;
});
console.log(wrappers.length, wrappers.map(([k]) => k).sort().join(", "));
// 14 AssetCode12, AssetCode4, ContractId, DataValue, EncodedLedgerKey, EncryptedBody,
//    Hash, PoolId, ScBytes, Signature, SignatureHint, Thresholds, UpgradeType, Value

Inline opaque[N] / opaque<N> and the uint256 typedef stay raw — 26 such fields, per grep -c "readonly [A-Za-z0-9_]*: Uint8Array;" lib/esm/xdr/generated/*.d.ts. Nothing at the call site distinguishes them — transaction-v0.d.ts declares readonly sourceAccountEd25519: Uint8Array for uint256, ledger-key-contract-code.d.ts declares readonly hash: Hash for Hash.

Consequence 1 — a valid signature verifies as invalid. DecoratedSignature.signature is a Signature wrapper. @noble/ed25519 throws TypeError: expected Uint8Array of length 64, got type=object, and Keypair#verify's try { … } catch { return false } turns that type error into a cryptographic verdict, indistinguishable from a forgery. Through 16.2.0 signature() returned a Buffer, so this path was unreachable. verifyMessage shares it.

const sig = tx.signatures[0].signature;
kp.verify(tx.hash(), sig);            // false  <-- valid signature
kp.verify(tx.hash(), sig.toBytes());  // true

The SDK's own gatherTxSigners (src/webauth/utils.ts) needed exactly this fix internally — decSig.signature.toBytes(), and decSig.hint.toBytes() for the hint. toBytes() appears zero times in docs/XDR_MIGRATION.md, docs/UINT8ARRAY_MIGRATION.md and CHANGELOG.md. § 16 "Byte-typed results" lists AuthEntrySignature.signature as raw (correct) but never mentions DecoratedSignature.signature — two public fields, same name, opposite shapes, only the raw one documented.

Consequence 2 — § 6's Array.from() recipe passes vacuously. Array.from sees an object that is neither iterable nor array-like and yields [], so a byte assertion migrated by the guide can no longer fail:

const a = new xdr.Hash(new Uint8Array(32).fill(1));
const b = new xdr.Hash(new Uint8Array(32).fill(2));
assert.deepEqual(Array.from(a), Array.from(b)); // PASSES — both are []

.equals() is correct on wrappers and raw fields alike (including class-aware: new xdr.Hash(x).equals(new xdr.PoolId(x)) is false), and § 1's net-new-methods table already documents it for this purpose while cross-referencing § 6. .toXdr("base64"), deepEqual on the wrappers, .value, .toBytes() and .toJson() also compare correctly; [...wrapper] and Buffer.from(wrapper) throw loudly.

Exposure: plain JavaScript, and TypeScript run without a type-check pass. With strict: true the published declarations reject all four hazards at the exact line — verify(hash, sig)TS2345, wrapper.lengthTS2339, wrapper[0]TS7053, Array.from(wrapper)TS2769. The verify case emits no error, no stack and nothing to search for; console.log gives it away instantly (Signature { value: Uint8Array(64) […] }), but only if you suspect the value's shape. The Array.from() half emits no signal ever.

Reproduce

import { xdr, Keypair, Networks, TransactionBuilder, Account, Operation, Asset } from "@stellar/stellar-sdk";

const h = new xdr.Hash(new Uint8Array(32).fill(1));
console.log(h instanceof Uint8Array, h.length, Array.from(h)); // false undefined []

const kp = Keypair.master(Networks.TESTNET);
const tx = new TransactionBuilder(new Account(kp.publicKey(), "1"), { fee: "100", networkPassphrase: Networks.TESTNET })
  .addOperation(Operation.payment({ destination: kp.publicKey(), asset: Asset.native(), amount: "1" }))
  .setTimeout(30).build();
tx.sign(kp);
console.log(kp.verify(tx.hash(), tx.signatures[0].signature));       // false — valid signature
console.log(kp.verify(tx.hash(), tx.signatures[0].signature.value)); // true

Suggested fix

  1. Type-guard Keypair#verify / verifyMessage: keep the deliberate "malformed input → false" contract, but reject a non-Uint8Array shape before the try. Not substitutable by docs — a wrong shape currently reads as a bad signature.
  2. Correct § 6, § 6.1, the § 10 quick reference and the CHANGELOG line to scope the Uint8Array claim to inline opaques and name the fourteen wrapper types with their .value / .toBytes() read path. § 6.1's "plain JavaScript callers see no error" holds for construction but is inverted for reads.
  3. Replace § 6's Array.from() advice with .equals(); drop Array.from() or restrict it to raw fields.
  4. Document toBytes() — the accessor the SDK uses internally is in no consumer-facing doc.
  5. Add DecoratedSignature.signature and .hint to § 16 and to the CHANGELOG entry listing AuthEntrySignature.signature, with the rule that generates the list.
  6. Optional, closes the class: have the byte wrappers extend Uint8Array, or at minimum implement Symbol.iterator.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

Status
Done

Relationships

None yet

Development

No branches or pull requests

Issue actions