XDR byte fields of named typedef types are wrapper objects, not Uint8Array — Keypair.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.length → TS2339, 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
- 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.
- 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.
- Replace § 6's
Array.from() advice with .equals(); drop Array.from() or restrict it to raw fields.
- Document
toBytes() — the accessor the SDK uses internally is in no consumer-facing doc.
- Add
DecoratedSignature.signature and .hint to § 16 and to the CHANGELOG entry listing AuthEntrySignature.signature, with the rule that generates the list.
- Optional, closes the class: have the byte wrappers extend
Uint8Array, or at minimum implement Symbol.iterator.
XDR byte fields of named typedef types are wrapper objects, not
Uint8Array—Keypair.verifyreturnsfalsefor a valid signatureSeverity: Medium. Needs a code fix and doc corrections. Tested on
17.0.0-rc.1from npm; Node 22.22.0, Deno 2.8.3, Bun 1.3.14 identical.docs/XDR_MIGRATION.md§ 6 andCHANGELOG.mdboth state that every byte field (opaque[N],opaque<N>,Hash,Signature,ScBytes, …) "is aUint8Array" and that indexing and.lengthkeep working. For byte fields whose XDR type is a named typedef with a generated class that is false — they areBytesValuesubclasses holding the bytes at.value, readable via.toBytes():instanceof Uint8Array.lengthArray.from(…)new xdr.Hash(bytes)undefined[]xdr.ScVal.scvBytes(b).bytesundefined[]hash(…),Keypair#rawPublicKey(),StrKey.decode*The wrappers are exactly the
BytesValuesubclasses, and there are fourteen:Inline
opaque[N]/opaque<N>and theuint256typedef stay raw — 26 such fields, pergrep -c "readonly [A-Za-z0-9_]*: Uint8Array;" lib/esm/xdr/generated/*.d.ts. Nothing at the call site distinguishes them —transaction-v0.d.tsdeclaresreadonly sourceAccountEd25519: Uint8Arrayforuint256,ledger-key-contract-code.d.tsdeclaresreadonly hash: HashforHash.Consequence 1 — a valid signature verifies as invalid.
DecoratedSignature.signatureis aSignaturewrapper.@noble/ed25519throwsTypeError: expected Uint8Array of length 64, got type=object, andKeypair#verify'stry { … } catch { return false }turns that type error into a cryptographic verdict, indistinguishable from a forgery. Through 16.2.0signature()returned aBuffer, so this path was unreachable.verifyMessageshares it.The SDK's own
gatherTxSigners(src/webauth/utils.ts) needed exactly this fix internally —decSig.signature.toBytes(), anddecSig.hint.toBytes()for the hint.toBytes()appears zero times indocs/XDR_MIGRATION.md,docs/UINT8ARRAY_MIGRATION.mdandCHANGELOG.md. § 16 "Byte-typed results" listsAuthEntrySignature.signatureas raw (correct) but never mentionsDecoratedSignature.signature— two public fields, same name, opposite shapes, only the raw one documented.Consequence 2 — § 6's
Array.from()recipe passes vacuously.Array.fromsees an object that is neither iterable nor array-like and yields[], so a byte assertion migrated by the guide can no longer fail:.equals()is correct on wrappers and raw fields alike (including class-aware:new xdr.Hash(x).equals(new xdr.PoolId(x))isfalse), and § 1's net-new-methods table already documents it for this purpose while cross-referencing § 6..toXdr("base64"),deepEqualon the wrappers,.value,.toBytes()and.toJson()also compare correctly;[...wrapper]andBuffer.from(wrapper)throw loudly.Exposure: plain JavaScript, and TypeScript run without a type-check pass. With
strict: truethe published declarations reject all four hazards at the exact line —verify(hash, sig)→TS2345,wrapper.length→TS2339,wrapper[0]→TS7053,Array.from(wrapper)→TS2769. Theverifycase emits no error, no stack and nothing to search for;console.loggives it away instantly (Signature { value: Uint8Array(64) […] }), but only if you suspect the value's shape. TheArray.from()half emits no signal ever.Reproduce
Suggested fix
Keypair#verify/verifyMessage: keep the deliberate "malformed input →false" contract, but reject a non-Uint8Arrayshape before thetry. Not substitutable by docs — a wrong shape currently reads as a bad signature.CHANGELOGline to scope theUint8Arrayclaim 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.Array.from()advice with.equals(); dropArray.from()or restrict it to raw fields.toBytes()— the accessor the SDK uses internally is in no consumer-facing doc.DecoratedSignature.signatureand.hintto § 16 and to theCHANGELOGentry listingAuthEntrySignature.signature, with the rule that generates the list.Uint8Array, or at minimum implementSymbol.iterator.