diff --git a/modules/sdk-core/src/bitgo/safe/derivableEd25519Pub.ts b/modules/sdk-core/src/bitgo/safe/derivableEd25519Pub.ts new file mode 100644 index 0000000000..0bd1b61852 --- /dev/null +++ b/modules/sdk-core/src/bitgo/safe/derivableEd25519Pub.ts @@ -0,0 +1,164 @@ +/** + * @prettier + * + * @experimental Encode/decode helpers for the *derivable* form of a safe slot-④ + * (`ed25519Multisig`) root public key. + * + * Wallet Safes v1 soft-derives the backup and BitGo co-signer keys of every minted wallet from the + * safe's root public keys, and soft derivation needs a chain code. The secp256k1 slot gets one for + * free (a BIP32 xpub is `point || chaincode`); a bare Stellar StrKey `G…` has nowhere to put one. + * Per TDD Part II-3 §1.3 we therefore concatenate the chain code onto `pub` rather than introduce a + * new field — the same shape BitGo already uses for the MPC slots, whose `commonKeychain` is + * `pub || chaincode`. + * + * pub = || + * exactly 56 chars, 'G…' exactly 64 chars + * total length exactly 120 + * + * StrKey ed25519 public keys are always exactly 56 characters, so the split is a fixed offset. That + * offset is a CROSS-REPO contract shared with wallet-platform, `modules/key-card` and WRW; four + * independent implementations drifting produces unrecoverable wallets. Every call site — here and in + * the other repos — MUST go through these helpers rather than slicing inline. + */ + +/** + * The fixed character offset at which a composite slot-④ pub splits into (StrKey pub, chain code). + * Stellar StrKey ed25519 public keys are a fixed 56 characters, so no length prefix or separator is + * needed. + * + * MUST stay identical to the corresponding constant in wallet-platform, `modules/key-card` and WRW: + * a divergent offset splits the pub in the wrong place and derives co-signer keys nobody else can + * reproduce, permanently bricking the wallets minted with it. + */ +export const DERIVABLE_ED25519_PUB_SPLIT_OFFSET = 56; + +/** Length of the hex-encoded chain code half (32 bytes). */ +export const DERIVABLE_ED25519_CHAIN_CODE_LENGTH = 64; + +/** Total length of a well-formed composite pub. */ +export const DERIVABLE_ED25519_PUB_LENGTH = DERIVABLE_ED25519_PUB_SPLIT_OFFSET + DERIVABLE_ED25519_CHAIN_CODE_LENGTH; + +/** + * Chain codes are serialized as LOWERCASE hex only. Uppercase and mixed-case are rejected rather + * than normalized: accepting both casings would make the composite pub non-canonical, so the same + * key could be stored under two distinct strings and equality against a previously-persisted pub + * would spuriously fail. + */ +const CHAIN_CODE_REGEX = /^[0-9a-f]{64}$/; + +/** StrKey version byte for an ed25519 public key (`G…`). */ +const STRKEY_VERSION_BYTE_ED25519_PUBLIC_KEY = 6 << 3; + +/** Decoded StrKey payload: 1 version byte + 32-byte key + 2-byte checksum. */ +const STRKEY_DECODED_LENGTH = 35; + +const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; +const STRKEY_ED25519_PUBLIC_KEY_REGEX = /^G[A-Z2-7]{55}$/; + +/** + * Decode an unpadded RFC 4648 base32 string. The caller guarantees the input matched + * {@link STRKEY_ED25519_PUBLIC_KEY_REGEX}, so 56 chars × 5 bits = 280 bits = exactly 35 bytes with + * no leftover bits — the encoding is unambiguously canonical. + */ +function base32Decode(input: string): Buffer { + const out = Buffer.alloc((input.length * 5) / 8); + let bits = 0; + let value = 0; + let index = 0; + for (const char of input) { + value = (value << 5) | BASE32_ALPHABET.indexOf(char); + bits += 5; + if (bits >= 8) { + bits -= 8; + out[index++] = (value >>> bits) & 0xff; + } + } + return out; +} + +/** CRC16-XModem, the checksum Stellar StrKey appends (little-endian) to the versioned payload. */ +function crc16Xmodem(data: Buffer): number { + let crc = 0x0000; + for (const byte of data) { + let code = (crc >>> 8) & 0xff; + code ^= byte; + code ^= code >>> 4; + crc = ((crc << 8) & 0xffff) ^ ((code << 12) & 0xffff) ^ ((code << 5) & 0xffff) ^ code; + } + return crc & 0xffff; +} + +/** + * Returns true iff `pub` is a valid Stellar StrKey ed25519 public key. + * + * Implemented here rather than pulled from `stellar-sdk` because `sdk-core` must not depend on a + * coin module. The pub half is validated by CHECKSUM, not merely by length and alphabet — a + * 56-character `G…` string with a corrupted body is rejected. + */ +export function isValidEd25519StrKeyPublicKey(pub: string): boolean { + if (!STRKEY_ED25519_PUBLIC_KEY_REGEX.test(pub)) { + return false; + } + const decoded = base32Decode(pub); + if (decoded.length !== STRKEY_DECODED_LENGTH || decoded[0] !== STRKEY_VERSION_BYTE_ED25519_PUBLIC_KEY) { + return false; + } + return ( + crc16Xmodem(decoded.subarray(0, STRKEY_DECODED_LENGTH - 2)) === decoded.readUInt16LE(STRKEY_DECODED_LENGTH - 2) + ); +} + +/** Returns true iff `chainCode` is exactly 64 lowercase hex characters. */ +export function isValidEd25519ChainCode(chainCode: string): boolean { + return CHAIN_CODE_REGEX.test(chainCode); +} + +/** + * Compose a derivable slot-④ root pub from its two halves. + * + * Throws when either half is malformed: silently emitting a composite whose halves do not round-trip + * would persist a root pub from which no correct co-signer key can ever be derived. + */ +export function encodeDerivableEd25519Pub(pub: string, chainCode: string): string { + if (!isValidEd25519StrKeyPublicKey(pub)) { + throw new Error('Invalid derivable ed25519 pub: pub half is not a valid ed25519 public key'); + } + if (!isValidEd25519ChainCode(chainCode)) { + throw new Error('Invalid derivable ed25519 pub: chainCode must be 64 lowercase hex characters'); + } + return `${pub}${chainCode}`; +} + +/** + * Split a composite slot-④ root pub back into its two halves. + * + * Throws unless the input is EXACTLY the composite form. A lenient decode that accepted a bare + * 56-character pub would hand callers an empty chain code and derive every co-signer from the same + * (zero-length) entropy. + */ +export function decodeDerivableEd25519Pub(composite: string): { pub: string; chainCode: string } { + if (composite.length !== DERIVABLE_ED25519_PUB_LENGTH) { + throw new Error( + `Invalid derivable ed25519 pub: expected ${DERIVABLE_ED25519_PUB_LENGTH} characters, got ${composite.length}` + ); + } + const pub = composite.slice(0, DERIVABLE_ED25519_PUB_SPLIT_OFFSET); + const chainCode = composite.slice(DERIVABLE_ED25519_PUB_SPLIT_OFFSET); + if (!isValidEd25519StrKeyPublicKey(pub)) { + throw new Error('Invalid derivable ed25519 pub: pub half is not a valid ed25519 public key'); + } + if (!isValidEd25519ChainCode(chainCode)) { + throw new Error('Invalid derivable ed25519 pub: chainCode must be 64 lowercase hex characters'); + } + return { pub, chainCode }; +} + +/** Returns true iff `composite` is a well-formed derivable slot-④ root pub. */ +export function isDerivableEd25519Pub(composite: string): boolean { + try { + decodeDerivableEd25519Pub(composite); + return true; + } catch { + return false; + } +} diff --git a/modules/sdk-core/src/bitgo/safe/index.ts b/modules/sdk-core/src/bitgo/safe/index.ts index e23e9e8108..58ebcbc3ce 100644 --- a/modules/sdk-core/src/bitgo/safe/index.ts +++ b/modules/sdk-core/src/bitgo/safe/index.ts @@ -1,3 +1,4 @@ +export * from './derivableEd25519Pub'; export * from './iSafe'; export * from './iSafes'; export * from './safe'; diff --git a/modules/sdk-core/test/unit/bitgo/safe/derivableEd25519Pub.ts b/modules/sdk-core/test/unit/bitgo/safe/derivableEd25519Pub.ts new file mode 100644 index 0000000000..e1dff668c2 --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/safe/derivableEd25519Pub.ts @@ -0,0 +1,111 @@ +import 'should'; +import { + DERIVABLE_ED25519_CHAIN_CODE_LENGTH, + DERIVABLE_ED25519_PUB_LENGTH, + DERIVABLE_ED25519_PUB_SPLIT_OFFSET, + decodeDerivableEd25519Pub, + encodeDerivableEd25519Pub, + isDerivableEd25519Pub, + isValidEd25519ChainCode, + isValidEd25519StrKeyPublicKey, +} from '../../../../src'; + +// Cross-repo fixture: byte-identical to +// packages/wallet-platform/test/unit/base/safes/fixtures/derivableEd25519Pub.json. +// Four independent implementations of this split will otherwise drift, and the failure mode is an +// unrecoverable wallet. +import * as fixture from './fixtures/derivableEd25519Pub.json'; + +describe('derivableEd25519Pub', function () { + describe('format constants', function () { + it('matches the constants pinned in the shared fixture', function () { + DERIVABLE_ED25519_PUB_SPLIT_OFFSET.should.equal(fixture.splitOffset); + DERIVABLE_ED25519_CHAIN_CODE_LENGTH.should.equal(fixture.chainCodeLength); + DERIVABLE_ED25519_PUB_LENGTH.should.equal(fixture.compositeLength); + }); + }); + + describe('encodeDerivableEd25519Pub', function () { + for (const v of fixture.valid) { + it(`composes ${v.name}`, function () { + encodeDerivableEd25519Pub(v.pub, v.chainCode).should.equal(v.composite); + }); + } + + for (const v of fixture.invalidEncodeInputs) { + it(`rejects ${v.name}`, function () { + (() => encodeDerivableEd25519Pub(v.pub, v.chainCode)).should.throw(/Invalid derivable ed25519 pub/); + }); + } + }); + + describe('decodeDerivableEd25519Pub', function () { + for (const v of fixture.valid) { + it(`splits ${v.name}`, function () { + decodeDerivableEd25519Pub(v.composite).should.eql({ pub: v.pub, chainCode: v.chainCode }); + }); + } + + for (const v of fixture.invalidComposite) { + it(`rejects ${v.name}`, function () { + (() => decodeDerivableEd25519Pub(v.composite)).should.throw(/Invalid derivable ed25519 pub/); + isDerivableEd25519Pub(v.composite).should.equal(false); + }); + } + }); + + describe('round trip', function () { + for (const v of fixture.valid) { + it(`round-trips ${v.name}`, function () { + const composite = encodeDerivableEd25519Pub(v.pub, v.chainCode); + const decoded = decodeDerivableEd25519Pub(composite); + decoded.should.eql({ pub: v.pub, chainCode: v.chainCode }); + encodeDerivableEd25519Pub(decoded.pub, decoded.chainCode).should.equal(composite); + isDerivableEd25519Pub(composite).should.equal(true); + }); + } + }); + + describe('isValidEd25519ChainCode', function () { + const { chainCode } = fixture.valid[3]; + + it('accepts 64 lowercase hex characters', function () { + isValidEd25519ChainCode(chainCode).should.equal(true); + }); + + it('rejects uppercase hex', function () { + isValidEd25519ChainCode(chainCode.toUpperCase()).should.equal(false); + }); + + it('rejects a chain code of the wrong length', function () { + isValidEd25519ChainCode(chainCode.slice(0, 63)).should.equal(false); + isValidEd25519ChainCode(chainCode + '0').should.equal(false); + }); + }); + + describe('isValidEd25519StrKeyPublicKey', function () { + for (const v of fixture.valid) { + it(`accepts the pub half of ${v.name}`, function () { + isValidEd25519StrKeyPublicKey(v.pub).should.equal(true); + }); + } + + it('rejects a bad checksum', function () { + // last character of a known-good pub flipped + isValidEd25519StrKeyPublicKey('GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYA').should.equal(false); + }); + + it('rejects a secret seed', function () { + isValidEd25519StrKeyPublicKey('SA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYH').should.equal(false); + }); + + it('rejects a non-base32 character', function () { + isValidEd25519StrKeyPublicKey('GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKY1').should.equal(false); + }); + + it('rejects the empty string and a composite pub', function () { + isValidEd25519StrKeyPublicKey('').should.equal(false); + isValidEd25519StrKeyPublicKey(fixture.valid[3].composite).should.equal(false); + }); + }); +}); diff --git a/modules/sdk-core/test/unit/bitgo/safe/fixtures/derivableEd25519Pub.json b/modules/sdk-core/test/unit/bitgo/safe/fixtures/derivableEd25519Pub.json new file mode 100644 index 0000000000..6c379b3882 --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/safe/fixtures/derivableEd25519Pub.json @@ -0,0 +1,145 @@ +{ + "$comment": "Shared cross-repo fixture for the derivable slot-4 (ed25519Multisig) root pub format: composite = <56-char Stellar StrKey ed25519 public key> || <64 lowercase hex chain code>. Plain JSON with no repo-specific imports so wallet-platform, BitGoJS sdk-core, BitGoJS modules/key-card and WRW can all consume this identical file. Append new vectors rather than editing existing ones in place.", + "splitOffset": 56, + "chainCodeLength": 64, + "compositeLength": 120, + "valid": [ + { + "name": "all-zero pub with all-zero chain code", + "pub": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", + "chainCode": "0000000000000000000000000000000000000000000000000000000000000000", + "composite": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "name": "all-ones pub with all-f chain code", + "pub": "GD7777777777777777777777777777777777777777777777777773DB", + "chainCode": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "composite": "GD7777777777777777777777777777777777777777777777777773DBffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "name": "low-order pub with a leading-zero chain code", + "pub": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC6PV", + "chainCode": "0000000000000000000000000000000000000000000000000000000000000001", + "composite": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC6PV0000000000000000000000000000000000000000000000000000000000000001" + }, + { + "name": "typical pub with a mixed alphanumeric chain code", + "pub": "GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYH", + "chainCode": "9a0f3c7d1e5b28460af3d92c6e81b7053fa4c2e98d17b60543a2fc8e19d7b046", + "composite": "GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYH9a0f3c7d1e5b28460af3d92c6e81b7053fa4c2e98d17b60543a2fc8e19d7b046" + }, + { + "name": "typical pub with an all-digit chain code", + "pub": "GDD7GGYPJUX3BNVA6OPIXUFXYOY2FVHF6YDRQKJ2JNOG27UPSAJDJCMV", + "chainCode": "1234567890123456789012345678901234567890123456789012345678901234", + "composite": "GDD7GGYPJUX3BNVA6OPIXUFXYOY2FVHF6YDRQKJ2JNOG27UPSAJDJCMV1234567890123456789012345678901234567890123456789012345678901234" + } + ], + "invalidComposite": [ + { + "name": "empty string", + "composite": "", + "reason": "length" + }, + { + "name": "bare 56-char pub with no chain code", + "composite": "GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYH", + "reason": "length" + }, + { + "name": "bare chain code with no pub", + "composite": "9a0f3c7d1e5b28460af3d92c6e81b7053fa4c2e98d17b60543a2fc8e19d7b046", + "reason": "length" + }, + { + "name": "uppercase hex chain code", + "composite": "GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYH9A0F3C7D1E5B28460AF3D92C6E81B7053FA4C2E98D17B60543A2FC8E19D7B046", + "reason": "chainCode" + }, + { + "name": "mixed-case hex chain code", + "composite": "GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYH9A0f3c7d1e5b28460af3d92c6e81b7053fa4c2e98d17b60543a2fc8e19d7b046", + "reason": "chainCode" + }, + { + "name": "non-hex character in chain code", + "composite": "GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYHga0f3c7d1e5b28460af3d92c6e81b7053fa4c2e98d17b60543a2fc8e19d7b046", + "reason": "chainCode" + }, + { + "name": "chain code one character short", + "composite": "GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYH9a0f3c7d1e5b28460af3d92c6e81b7053fa4c2e98d17b60543a2fc8e19d7b04", + "reason": "length" + }, + { + "name": "chain code one character long", + "composite": "GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYH9a0f3c7d1e5b28460af3d92c6e81b7053fa4c2e98d17b60543a2fc8e19d7b0460", + "reason": "length" + }, + { + "name": "pub half is not a valid StrKey (bad checksum)", + "composite": "GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYA9a0f3c7d1e5b28460af3d92c6e81b7053fa4c2e98d17b60543a2fc8e19d7b046", + "reason": "pub" + }, + { + "name": "pub half is a secret seed (S...) not a public key", + "composite": "SA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYH9a0f3c7d1e5b28460af3d92c6e81b7053fa4c2e98d17b60543a2fc8e19d7b046", + "reason": "pub" + }, + { + "name": "halves swapped", + "composite": "9a0f3c7d1e5b28460af3d92c6e81b7053fa4c2e98d17b60543a2fc8eGA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYH19d7b046", + "reason": "pub" + } + ], + "invalidEncodeInputs": [ + { + "name": "empty pub", + "pub": "", + "chainCode": "9a0f3c7d1e5b28460af3d92c6e81b7053fa4c2e98d17b60543a2fc8e19d7b046", + "reason": "pub" + }, + { + "name": "pub with bad StrKey checksum", + "pub": "GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYA", + "chainCode": "9a0f3c7d1e5b28460af3d92c6e81b7053fa4c2e98d17b60543a2fc8e19d7b046", + "reason": "pub" + }, + { + "name": "composite passed as the pub half", + "pub": "GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYH9a0f3c7d1e5b28460af3d92c6e81b7053fa4c2e98d17b60543a2fc8e19d7b046", + "chainCode": "9a0f3c7d1e5b28460af3d92c6e81b7053fa4c2e98d17b60543a2fc8e19d7b046", + "reason": "pub" + }, + { + "name": "empty chain code", + "pub": "GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYH", + "chainCode": "", + "reason": "chainCode" + }, + { + "name": "uppercase hex chain code", + "pub": "GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYH", + "chainCode": "9A0F3C7D1E5B28460AF3D92C6E81B7053FA4C2E98D17B60543A2FC8E19D7B046", + "reason": "chainCode" + }, + { + "name": "chain code with 0x prefix", + "pub": "GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYH", + "chainCode": "0x0f3c7d1e5b28460af3d92c6e81b7053fa4c2e98d17b60543a2fc8e19d7b046", + "reason": "chainCode" + }, + { + "name": "chain code too short", + "pub": "GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYH", + "chainCode": "9a0f3c7d1e5b28460af3d92c6e81b7053fa4c2e98d17b60543a2fc8e19d7b04", + "reason": "chainCode" + }, + { + "name": "chain code too long", + "pub": "GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYH", + "chainCode": "9a0f3c7d1e5b28460af3d92c6e81b7053fa4c2e98d17b60543a2fc8e19d7b0460", + "reason": "chainCode" + } + ] +} diff --git a/modules/sdk-core/tsconfig.json b/modules/sdk-core/tsconfig.json index d718116c68..237be6f6cc 100644 --- a/modules/sdk-core/tsconfig.json +++ b/modules/sdk-core/tsconfig.json @@ -7,7 +7,7 @@ "esModuleInterop": true, "typeRoots": ["../../types", "./node_modules/@types", "../../node_modules/@types"] }, - "include": ["src/**/*", "test/**/*"], + "include": ["src/**/*", "test/**/*", "test/unit/bitgo/safe/fixtures/derivableEd25519Pub.json"], "exclude": ["node_modules"], "references": [ {