From 8f5bc74b742f31f6d963be051c957d74ce2e18d2 Mon Sep 17 00:00:00 2001 From: Amksongs <289150540+Amksongs@users.noreply.github.com> Date: Thu, 22 Oct 2026 09:27:57 +0000 Subject: [PATCH] fix(core-ts): classify address parse errors instead of misreporting checksum failures (#330) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parse() only ever threw INVALID_CHECKSUM or UNKNOWN_PREFIX, so seed keys, federation addresses, wrong-length and non-Base32 inputs were all misclassified as generic checksum failures. It now inspects prefix and structure before checksum validation: S-prefix inputs are rejected as REJECTED_SEED_KEY to prevent private key leakage, name*domain inputs as FEDERATION_ADDRESS_NOT_SUPPORTED, wrong lengths as INVALID_LENGTH (56 for G/C, 69 for M), and non-Base32 characters as INVALID_BASE32. Added comprehensive test coverage for each error code. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- packages/core-ts/src/address/parse.test.ts | 185 ++++++++++++++++++--- packages/core-ts/src/address/parse.ts | 82 +++++++-- 2 files changed, 232 insertions(+), 35 deletions(-) diff --git a/packages/core-ts/src/address/parse.test.ts b/packages/core-ts/src/address/parse.test.ts index 33c833d7..3c1c06e4 100644 --- a/packages/core-ts/src/address/parse.test.ts +++ b/packages/core-ts/src/address/parse.test.ts @@ -1,12 +1,33 @@ import { describe, it, expect } from "vitest"; import { parse } from "./parse"; -import { AddressParseError } from "./errors"; +import { AddressParseError, ErrorCode } from "./errors"; + +const VALID_G = + "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H"; +const VALID_C = + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM"; +const VALID_M = + "MBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OAAAAAAAAAAAPOGVY"; + +/** Asserts parse(input) throws AddressParseError with the expected code and input. */ +function expectParseCode(input: string, code: ErrorCode): void { + expect(() => parse(input)).toThrowError(AddressParseError); + + try { + parse(input); + } catch (error) { + expect(error).toBeInstanceOf(AddressParseError); + if (error instanceof AddressParseError) { + expect(error.code).toBe(code); + expect(error.input).toBe(input); + } + } +} describe("parse", () => { describe("G addresses", () => { it("should parse a valid G address", () => { - const address = - "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H"; + const address = VALID_G; const result = parse(address); if (result.kind === "G") { @@ -43,15 +64,12 @@ describe("parse", () => { describe("M addresses", () => { it("should parse a valid M address and decode baseG and muxedId", () => { - const mAddress = - "MBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OAAAAAAAAAAAPOGVY"; + const mAddress = VALID_M; const result = parse(mAddress); if (result.kind === "M") { expect(result.address).toBe(mAddress); - expect(result.baseG).toBe( - "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H", - ); + expect(result.baseG).toBe(VALID_G); expect(result.muxedId).toBe(123n); } else { throw new Error(`Expected M address, got ${result.kind}`); @@ -59,8 +77,7 @@ describe("parse", () => { }); it("should parse M address with muxedId as bigint", () => { - const mAddress = - "MBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OAAAAAAAAAAAPOGVY"; + const mAddress = VALID_M; const result = parse(mAddress); if (result.kind === "M") { @@ -78,9 +95,7 @@ describe("parse", () => { if (result.kind === "M") { expect(result.muxedId).toBe(9007199254740993n); - expect(result.baseG).toBe( - "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H", - ); + expect(result.baseG).toBe(VALID_G); } else { throw new Error(`Expected M address, got ${result.kind}`); } @@ -93,9 +108,7 @@ describe("parse", () => { if (result.kind === "M") { expect(result.muxedId).toBe(18446744073709551615n); - expect(result.baseG).toBe( - "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H", - ); + expect(result.baseG).toBe(VALID_G); } else { throw new Error(`Expected M address, got ${result.kind}`); } @@ -122,9 +135,7 @@ describe("parse", () => { if (result.kind === "M") { expect(result.muxedId).toBe(0n); - expect(result.baseG).toBe( - "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H", - ); + expect(result.baseG).toBe(VALID_G); } else { throw new Error(`Expected M address, got ${result.kind}`); } @@ -133,8 +144,7 @@ describe("parse", () => { describe("C addresses", () => { it("should parse a valid C address", () => { - const address = - "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM"; + const address = VALID_C; const result = parse(address); if (result.kind === "C") { @@ -157,6 +167,132 @@ describe("parse", () => { }); }); + describe("error classification", () => { + describe("REJECTED_SEED_KEY", () => { + it("should reject a seed key (S prefix) instead of reporting a checksum failure", () => { + const seedKey = + "SBZVMB74W5CWLWHLMIUBJD3JXWQGBU4QSI6YZFG5CKJLQX5YFZZXCVQN"; + expectParseCode(seedKey, "REJECTED_SEED_KEY"); + }); + + it("should reject a lowercase seed key", () => { + const seedKey = + "sbzvmb74w5cwlwhlmiubjd3jxwqgbu4qsi6yzfg5ckjlqx5yfzzxcvqn"; + expectParseCode(seedKey, "REJECTED_SEED_KEY"); + }); + + it("should reject a bare S string", () => { + expectParseCode("S", "REJECTED_SEED_KEY"); + }); + }); + + describe("FEDERATION_ADDRESS_NOT_SUPPORTED", () => { + it("should reject a federation address", () => { + expectParseCode("alice*stellar.org", "FEDERATION_ADDRESS_NOT_SUPPORTED"); + }); + + it("should reject an uppercase federation address", () => { + expectParseCode("BOB*EXAMPLE.COM", "FEDERATION_ADDRESS_NOT_SUPPORTED"); + }); + }); + + describe("INVALID_LENGTH", () => { + it("should report a single-character G address as INVALID_LENGTH", () => { + expectParseCode("G", "INVALID_LENGTH"); + }); + + it("should report a truncated G address as INVALID_LENGTH", () => { + expectParseCode(VALID_G.slice(0, 55), "INVALID_LENGTH"); + }); + + it("should report a G address with an extra character as INVALID_LENGTH", () => { + expectParseCode(`${VALID_G}H`, "INVALID_LENGTH"); + }); + + it("should report a truncated C address as INVALID_LENGTH", () => { + expectParseCode(VALID_C.slice(0, 40), "INVALID_LENGTH"); + }); + + it("should report a C address with an extra character as INVALID_LENGTH", () => { + expectParseCode(`${VALID_C}A`, "INVALID_LENGTH"); + }); + + it("should report a truncated M address as INVALID_LENGTH", () => { + expectParseCode(VALID_M.slice(0, 68), "INVALID_LENGTH"); + }); + + it("should report an M address with an extra character as INVALID_LENGTH", () => { + expectParseCode(`${VALID_M}A`, "INVALID_LENGTH"); + }); + + it("should report an M-prefixed string of G length as INVALID_LENGTH", () => { + expectParseCode(`M${VALID_G.slice(1)}`, "INVALID_LENGTH"); + }); + }); + + describe("INVALID_BASE32", () => { + it("should report a G address containing 0 as INVALID_BASE32", () => { + expectParseCode(`${VALID_G.slice(0, -1)}0`, "INVALID_BASE32"); + }); + + it("should report a G address containing 1 as INVALID_BASE32", () => { + expectParseCode(`GBRPYHIL2C13FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H`, "INVALID_BASE32"); + }); + + it("should report a G address containing 8 as INVALID_BASE32", () => { + expectParseCode(`GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX8H`, "INVALID_BASE32"); + }); + + it("should report a G address containing an embedded space as INVALID_BASE32", () => { + expectParseCode( + `${VALID_G.slice(0, 25)} ${VALID_G.slice(26)}`, + "INVALID_BASE32" + ); + }); + + it("should report a C address containing a special character as INVALID_BASE32", () => { + expectParseCode(`${VALID_C.slice(0, -1)}$`, "INVALID_BASE32"); + }); + + it("should report an M address containing a non-Base32 character as INVALID_BASE32", () => { + expectParseCode(`${VALID_M.slice(0, -1)}#`, "INVALID_BASE32"); + }); + }); + + describe("INVALID_CHECKSUM", () => { + it("should report a G address with a bad checksum as INVALID_CHECKSUM", () => { + expectParseCode( + "GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2X", + "INVALID_CHECKSUM" + ); + }); + + it("should report a C address with a bad checksum as INVALID_CHECKSUM", () => { + expectParseCode( + "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KN", + "INVALID_CHECKSUM" + ); + }); + + it("should report an M address with a bad checksum as INVALID_CHECKSUM", () => { + expectParseCode( + "MBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OAAAAAAAAAAAPOGVZ", + "INVALID_CHECKSUM" + ); + }); + }); + + describe("UNKNOWN_PREFIX", () => { + it("should report a non-address string as UNKNOWN_PREFIX", () => { + expectParseCode("INVALID_ADDRESS", "UNKNOWN_PREFIX"); + }); + + it("should report a correctly sized string with a wrong prefix as UNKNOWN_PREFIX", () => { + expectParseCode("X".repeat(56), "UNKNOWN_PREFIX"); + }); + }); + }); + describe("error cases", () => { it("should throw AddressParseError for address with unknown prefix", () => { const invalidAddress = "INVALID_ADDRESS"; @@ -178,15 +314,18 @@ describe("parse", () => { const seedKey = "SBZVMB74W5CWLWHLMIUBJD3JXWQGBU4QSI6YZFG5CKJLQX5YFZZXCVQN"; expect(() => parse(seedKey)).toThrowError(AddressParseError); + expectParseCode(seedKey, "REJECTED_SEED_KEY"); }); it("should throw AddressParseError for federation address", () => { const fedAddress = "alice*stellar.org"; expect(() => parse(fedAddress)).toThrowError(AddressParseError); + expectParseCode(fedAddress, "FEDERATION_ADDRESS_NOT_SUPPORTED"); }); it("should throw AddressParseError for empty string", () => { expect(() => parse("")).toThrowError(AddressParseError); + expectParseCode("", "UNKNOWN_PREFIX"); }); it("should throw AddressParseError for G address with invalid checksum", () => { @@ -214,13 +353,13 @@ describe("parse", () => { describe("edge cases", () => { it("should throw AddressParseError for address with whitespace", () => { - const addressWithSpace = - " GBRPYHIL2CI3FNQ4BXLFMNDLFJUNPU2HY3ZMFSHONUCEOASW7QC7OX2H "; + const addressWithSpace = ` ${VALID_G} `; expect(() => parse(addressWithSpace)).toThrowError(AddressParseError); }); it("should throw AddressParseError for very short string", () => { expect(() => parse("G")).toThrowError(AddressParseError); + expectParseCode("G", "INVALID_LENGTH"); }); it("should throw AddressParseError for string with correct length but wrong prefix", () => { diff --git a/packages/core-ts/src/address/parse.ts b/packages/core-ts/src/address/parse.ts index 9158985d..056b4536 100644 --- a/packages/core-ts/src/address/parse.ts +++ b/packages/core-ts/src/address/parse.ts @@ -3,31 +3,90 @@ import { detect } from "./detect"; import { ParseResult } from "./types"; import { AddressParseError } from "../address/errors"; +/** Base32 alphabet used by StrKey (RFC 4648 without padding). */ +const BASE32_CHARS = /^[A-Z2-7]+$/; + +/** Expected total strkey length (version byte + payload + CRC16) per prefix. */ +const EXPECTED_LENGTH: Record<"G" | "C" | "M", number> = { + G: 56, + C: 56, + M: 69, +}; + /** * Parses a Stellar address string and returns a structured result. * Supports G (Public Key), C (Contract), and M (Muxed) addresses. * + * Validation order: seed-key and federation rejection, then prefix, length + * and Base32 structure, and finally the checksum. This ensures each failure + * mode is reported with its specific error code instead of being + * misclassified as a generic checksum failure. + * * @param address - The Stellar address string to parse. * @returns A {@link ParseResult} containing the parsed address components. - * @throws {AddressParseError} If the address prefix is unknown or checksum/length validation fails. + * @throws {AddressParseError} With a specific code describing why validation + * failed: REJECTED_SEED_KEY, FEDERATION_ADDRESS_NOT_SUPPORTED, UNKNOWN_PREFIX, + * INVALID_LENGTH, INVALID_BASE32, or INVALID_CHECKSUM. */ export function parse(address: string): ParseResult { const up = address.toUpperCase(); - const kind = detect(up); - if (kind === "invalid") { - const first = up[0]; - if (first === "G" || first === "M" || first === "C") { - throw new AddressParseError( - "INVALID_CHECKSUM", - address, - "Invalid address checksum" - ); - } + // Reject seed (secret) keys up front. They are sensitive material, must + // never be treated as addresses, and must never be echoed back as valid + // input — preventing accidental private key leakage through this parser. + if (up.startsWith("S")) { + throw new AddressParseError( + "REJECTED_SEED_KEY", + address, + "Seed keys are not valid addresses" + ); + } + + // Federation addresses (name*domain) require SEP-2 resolution, which this + // parser does not perform. + if (up.includes("*")) { + throw new AddressParseError( + "FEDERATION_ADDRESS_NOT_SUPPORTED", + address, + "Federation addresses are not supported" + ); + } + + const prefix = up[0]; + if (prefix !== "G" && prefix !== "M" && prefix !== "C") { throw new AddressParseError("UNKNOWN_PREFIX", address, "Invalid address"); } + // Structural checks run before the checksum (via detect) so that length + // and character problems are not misreported as INVALID_CHECKSUM. + const expectedLength = EXPECTED_LENGTH[prefix]; + if (up.length !== expectedLength) { + throw new AddressParseError( + "INVALID_LENGTH", + address, + `Invalid address length: expected ${expectedLength} for ${prefix} addresses, got ${up.length}` + ); + } + + if (!BASE32_CHARS.test(up)) { + throw new AddressParseError( + "INVALID_BASE32", + address, + "Address contains invalid Base32 characters" + ); + } + + const kind = detect(up); + + if (kind === "invalid") { + throw new AddressParseError( + "INVALID_CHECKSUM", + address, + "Invalid address checksum" + ); + } + switch (kind) { case "G": return { kind: "G", address: up, warnings: [] }; @@ -57,4 +116,3 @@ export function parse(address: string): ParseResult { } } } -