From f498d68a2a59d4d6cfa1aa676684da7e9950a43d Mon Sep 17 00:00:00 2001 From: David Kaplan Date: Tue, 8 Sep 2026 23:19:10 -0400 Subject: [PATCH] feat(sdk-core): migrate Ed25519 Safe roots to neutral base32 Change the ed25519Multisig Safe root public-key contract so backup and BitGo roots are represented by raw Ed25519 public derivation material: raw public key (32 bytes) || raw chain code (32 bytes) Encode the 64-byte payload as canonical, unpadded RFC 4648 base32 using the uppercase A-Z2-7 alphabet. The resulting root value is exactly 103 characters and is independent of Stellar, Algorand, HBAR, or any other coin-specific public-key format. Update Safe backup-key creation to decode the representative XLM public key into raw bytes before composing the neutral root value. Update Safe root validation to validate the canonical base32 payload and to apply the same contract to both backup and BitGo ed25519Multisig roots. Keep Stellar StrKey handling isolated to the user-root and coin-specific child-key boundaries. The neutral Safe root codec now uses Buffer inputs and outputs for raw public-key and chain-code material, minimizing string parsing and hexadecimal re-encoding. Public derivation continues to consume the SDK's internal raw public-key-plus-chain-code representation and discards child chain codes at the wallet-child boundary. Update SDK Safe root fixtures and tests for: - canonical base32 encoding and decoding; - exact 32-byte public-key and chain-code boundaries; - rejection of lowercase, malformed, padded, truncated, and non-canonical values; - raw Buffer-based codec APIs; - deterministic Ed25519 public derivation vectors; - unchanged Stellar StrKey user-root encoding; - backup and BitGo Safe root validation. Ticket: WCN-2411 --- .../sdk-core/src/bitgo/keychain/keychains.ts | 17 +- .../src/bitgo/safe/derivableEd25519Pub.ts | 268 ++++++++---------- modules/sdk-core/src/bitgo/safe/safe.ts | 15 +- .../sdk-core/src/bitgo/safe/safeDerivation.ts | 29 ++ modules/sdk-core/src/bitgo/safe/safes.ts | 22 +- .../test/unit/bitgo/keychain/keychains.ts | 68 +---- .../unit/bitgo/safe/derivableEd25519Pub.ts | 147 +++------- .../safe/fixtures/derivableEd25519Pub.json | 207 ++------------ modules/sdk-core/test/unit/bitgo/safe/safe.ts | 50 +++- .../test/unit/bitgo/safe/safeDerivation.ts | 91 ++++++ .../sdk-core/test/unit/bitgo/safe/safes.ts | 20 +- 11 files changed, 397 insertions(+), 537 deletions(-) create mode 100644 modules/sdk-core/test/unit/bitgo/safe/safeDerivation.ts diff --git a/modules/sdk-core/src/bitgo/keychain/keychains.ts b/modules/sdk-core/src/bitgo/keychain/keychains.ts index f50bd22b8b..7f452b3f3d 100644 --- a/modules/sdk-core/src/bitgo/keychain/keychains.ts +++ b/modules/sdk-core/src/bitgo/keychain/keychains.ts @@ -6,8 +6,9 @@ import { IBaseCoin, KeychainsTriplet, KeyPair } from '../baseCoin'; import { BitGoBase } from '../bitgoBase'; import { SafeMpcCeremonyUnsupportedError } from '../errors'; import { + decodeEd25519StrKeyPublicKey, encodeDerivableEd25519Pub, - generateEd25519ChainCodeBase32, + generateEd25519ChainCode, isValidEd25519StrKeyPublicKey, } from '../safe/derivableEd25519Pub'; import { decodeOrElse, ECDSAUtils, EDDSAUtils, generateRandomPassword, RequestTracer } from '../utils'; @@ -351,17 +352,13 @@ export class Keychains implements IKeychains { } } - // Wallet Safes v1 slot ④ (`ed25519Multisig`): make the backup root soft-derivable by folding a - // fresh chain code into `pub`. Nothing else on the wire changes — the chain code has no field of - // its own, and callers recover it with `decodeDerivableEd25519Pub` on the returned pub. - // - // The slot is identified from the generated pub itself: `safeId` marks it as a safe root, and a - // StrKey ed25519 pub narrows it to slot ④. That is exact — slot ① roots are secp256k1 (an xpub, - // which already carries its own chain code) and slots ②③ are MPC, which run through `createMpc` - // and never reach here. + // Wallet Safes v1 slot ④ (`ed25519Multisig`): store neutral raw derivation material as + // publicKey32 || chainCode32, serialized as canonical lowercase hex. The root is deliberately + // not encoded as a Stellar/Algorand/HBAR public key; coin-specific encoding happens after + // public soft derivation when the wallet child is minted. const withKey = params as CreateBackupOptions & { pub?: string }; if (params.safeId !== undefined && withKey.pub !== undefined && isValidEd25519StrKeyPublicKey(withKey.pub)) { - withKey.pub = encodeDerivableEd25519Pub(withKey.pub, generateEd25519ChainCodeBase32()); + withKey.pub = encodeDerivableEd25519Pub(decodeEd25519StrKeyPublicKey(withKey.pub), generateEd25519ChainCode()); } const serverResponse = await this.add(params); diff --git a/modules/sdk-core/src/bitgo/safe/derivableEd25519Pub.ts b/modules/sdk-core/src/bitgo/safe/derivableEd25519Pub.ts index 67a7851439..6a4443320c 100644 --- a/modules/sdk-core/src/bitgo/safe/derivableEd25519Pub.ts +++ b/modules/sdk-core/src/bitgo/safe/derivableEd25519Pub.ts @@ -1,215 +1,181 @@ /** - * @prettier + * Ed25519 safe-root public derivation material and Stellar user-key codecs. * - * @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 52 chars - * total length exactly 108 - * - * Both halves use the SAME encoding — RFC 4648 base32 over the alphabet StrKey itself uses — so the - * composite is one uniform string rather than a base32 pub with a hex tail bolted on. - * - * 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. + * Safe backup and BitGo roots store raw public key (32 bytes) || raw chain code (32 bytes), + * encoded together as canonical unpadded RFC 4648 base32. Coin-specific public-key encodings are + * applied only to derived wallet children. */ import { randomBytes } from 'crypto'; +import { Ed25519BIP32, Eddsa } from '../../account-lib'; -/** - * 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; - -/** Raw length of a chain code before encoding. */ +export const DERIVABLE_ED25519_PUBLIC_KEY_BYTES = 32; export const DERIVABLE_ED25519_CHAIN_CODE_BYTES = 32; - -/** Length of the base32-encoded chain code half: ceil(32 bytes * 8 / 5) = 52 characters. */ -export const DERIVABLE_ED25519_CHAIN_CODE_LENGTH = 52; - -/** 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 unpadded RFC 4648 base32, the same encoding and alphabet StrKey - * uses, so the composite pub is base32 end to end. - * - * The alphabet is uppercase-only and lowercase is rejected rather than normalized: accepting both - * casings would make the composite 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 = /^[A-Z2-7]{52}$/; - -/** 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; +export const DERIVABLE_ED25519_PUB_LENGTH = 103; const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; -const STRKEY_ED25519_PUBLIC_KEY_REGEX = /^G[A-Z2-7]{55}$/; +const BASE32_ROOT_REGEX = /^[A-Z2-7]{103}$/; +const STRKEY_PUBLIC_KEY_REGEX = /^G[A-Z2-7]{55}$/; +const STRKEY_SECRET_SEED_REGEX = /^S[A-Z2-7]{55}$/; +const STRKEY_VERSION_PUBLIC_KEY = 6 << 3; +const STRKEY_VERSION_SECRET_SEED = 18 << 3; +const STRKEY_DECODED_BYTES = 35; -/** - * Decode an unpadded RFC 4648 base32 string. Callers guarantee the input already matched one of the - * alphabet regexes below. Any bits left over past the last whole byte are dropped, so a decode alone - * does NOT prove the input was canonical — see {@link isValidEd25519ChainCode}, which re-encodes. - */ function base32Decode(input: string): Buffer { - const out = Buffer.alloc(Math.floor((input.length * 5) / 8)); + const output = Buffer.alloc(Math.floor((input.length * 5) / 8)); let bits = 0; let value = 0; - let index = 0; + let offset = 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; + output[offset++] = (value >>> bits) & 0xff; } } - return out; + return output; } -/** Encode to unpadded RFC 4648 base32. Trailing bits of the final character are zero-filled. */ function base32Encode(data: Buffer): string { let bits = 0; let value = 0; - let out = ''; + let output = ''; for (const byte of data) { value = (value << 8) | byte; bits += 8; while (bits >= 5) { bits -= 5; - out += BASE32_ALPHABET[(value >>> bits) & 0x1f]; + output += BASE32_ALPHABET[(value >>> bits) & 0x1f]; } } if (bits > 0) { - out += BASE32_ALPHABET[(value << (5 - bits)) & 0x1f]; + output += BASE32_ALPHABET[(value << (5 - bits)) & 0x1f]; } - return out; + return output; } -/** CRC16-XModem, the checksum Stellar StrKey appends (little-endian) to the versioned payload. */ function crc16Xmodem(data: Buffer): number { - let crc = 0x0000; + let crc = 0; 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; + crc ^= byte << 8; + for (let bit = 0; bit < 8; bit++) { + crc = crc & 0x8000 ? ((crc << 1) ^ 0x1021) & 0xffff : (crc << 1) & 0xffff; + } } - return crc & 0xffff; + return crc; } -/** - * 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; +function decodeStrKey(value: string, version: number, label: string): Buffer { + const decoded = base32Decode(value); + if ( + decoded.length !== STRKEY_DECODED_BYTES || + decoded[0] !== version || + base32Encode(decoded) !== value || + crc16Xmodem(decoded.subarray(0, STRKEY_DECODED_BYTES - 2)) !== decoded.readUInt16LE(STRKEY_DECODED_BYTES - 2) + ) { + throw new Error(`Invalid ed25519 StrKey ${label}`); } - const decoded = base32Decode(pub); - if (decoded.length !== STRKEY_DECODED_LENGTH || decoded[0] !== STRKEY_VERSION_BYTE_ED25519_PUBLIC_KEY) { - return false; + return decoded.subarray(1, 33); +} + +/** Returns true for canonical base32-encoded 64-byte public derivation material. */ +export function isDerivableEd25519Pub(pub: string): boolean { + return BASE32_ROOT_REGEX.test(pub) && base32Encode(base32Decode(pub)) === pub; +} + +/** Throws unless the value is canonical base32-encoded 64-byte root material. */ +export function assertDerivableEd25519Pub(pub: string): void { + if (!isDerivableEd25519Pub(pub)) { + throw new Error('Invalid derivable ed25519 pub: expected 64 bytes of canonical base32'); } - return ( - crc16Xmodem(decoded.subarray(0, STRKEY_DECODED_LENGTH - 2)) === decoded.readUInt16LE(STRKEY_DECODED_LENGTH - 2) - ); } -/** - * Returns true iff `chainCode` is the canonical base32 encoding of exactly 32 bytes. - * - * The length check alone is not sufficient. 52 base32 characters carry 260 bits but a chain code is - * only 256, so the final character has 4 unused low bits — 16 distinct strings decode to the same 32 - * bytes. Only the one whose trailing bits are zero is accepted, which the re-encode enforces. Were - * non-canonical spellings allowed, one key could be persisted under several different composite pubs - * and equality against a stored pub would spuriously fail. - */ -export function isValidEd25519ChainCode(chainCode: string): boolean { - if (!CHAIN_CODE_REGEX.test(chainCode)) { - return false; +/** Compose raw public-key and chain-code bytes into one base32 root value. */ +export function encodeDerivableEd25519Pub(pub: Buffer, chainCode: Buffer): string { + if (pub.length !== DERIVABLE_ED25519_PUBLIC_KEY_BYTES || chainCode.length !== DERIVABLE_ED25519_CHAIN_CODE_BYTES) { + throw new Error('Invalid derivable ed25519 pub: expected 32-byte public key and chain code buffers'); } - return base32Encode(base32Decode(chainCode)) === chainCode; + return base32Encode(Buffer.concat([pub, chainCode])); } -/** - * Mint a fresh chain code for a derivable slot-④ root, base32-encoded. - * - * The chain code is independent randomness (TDD Part II-3 D1) — it is NOT derived from the seed, so - * it can be generated wherever the composite pub is assembled. Encoding 32 bytes always yields the - * canonical form, so the result satisfies {@link isValidEd25519ChainCode} by construction. - */ -export function generateEd25519ChainCodeBase32(): string { - return base32Encode(randomBytes(DERIVABLE_ED25519_CHAIN_CODE_BYTES)); +/** Decode a base32 root into raw public-key and chain-code buffers. */ +export function decodeDerivableEd25519Pub(composite: string): { pub: Buffer; chainCode: Buffer } { + assertDerivableEd25519Pub(composite); + const decoded = base32Decode(composite); + return { + pub: decoded.subarray(0, DERIVABLE_ED25519_PUBLIC_KEY_BYTES), + chainCode: decoded.subarray(DERIVABLE_ED25519_PUBLIC_KEY_BYTES), + }; } -/** - * 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 52 canonical base32 characters'); +/** Generate a fresh raw 32-byte chain code for root composition. */ +export function generateEd25519ChainCode(): Buffer { + return randomBytes(DERIVABLE_ED25519_CHAIN_CODE_BYTES); +} + +export function decodeEd25519StrKeyPublicKey(pub: string): Buffer { + if (!STRKEY_PUBLIC_KEY_REGEX.test(pub)) { + throw new Error('Invalid ed25519 StrKey public key'); } - return `${pub}${chainCode}`; + return decodeStrKey(pub, STRKEY_VERSION_PUBLIC_KEY, 'public key'); } -/** - * 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}` - ); +export function isValidEd25519StrKeyPublicKey(pub: string): boolean { + try { + decodeEd25519StrKeyPublicKey(pub); + return true; + } catch { + return false; } - 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'); +} + +export function decodeEd25519StrKeySecretSeed(seed: string): Buffer { + if (!STRKEY_SECRET_SEED_REGEX.test(seed)) { + throw new Error('Invalid ed25519 StrKey secret seed'); } - if (!isValidEd25519ChainCode(chainCode)) { - throw new Error('Invalid derivable ed25519 pub: chainCode must be 52 canonical base32 characters'); + return decodeStrKey(seed, STRKEY_VERSION_SECRET_SEED, 'secret seed'); +} + +export function encodeEd25519StrKeyPublicKey(rawPub: Buffer): string { + if (rawPub.length !== DERIVABLE_ED25519_PUBLIC_KEY_BYTES) { + throw new Error('ed25519 public key must be 32 bytes'); } - return { pub, chainCode }; + const payload = Buffer.concat([Buffer.from([STRKEY_VERSION_PUBLIC_KEY]), rawPub]); + const checksum = Buffer.alloc(2); + checksum.writeUInt16LE(crc16Xmodem(payload), 0); + return base32Encode(Buffer.concat([payload, checksum])); } -/** Returns true iff `composite` is a well-formed derivable slot-④ root pub. */ -export function isDerivableEd25519Pub(composite: string): boolean { +export function isChecksumValidStrKeyEd25519Pub(pub: string): boolean { try { - decodeDerivableEd25519Pub(composite); + decodeEd25519StrKeyPublicKey(pub); return true; } catch { return false; } } + +let eddsaPromise: Promise | undefined; +function getEddsa(): Promise { + if (!eddsaPromise) { + eddsaPromise = (async () => Eddsa.initialize(await Ed25519BIP32.initialize()))(); + eddsaPromise.catch(() => { + eddsaPromise = undefined; + }); + } + return eddsaPromise; +} + +/** Soft-derive `m/` and return the derived raw 32-byte public key. */ +export async function softDeriveChildPubEd25519(compositePub: string, index: number): Promise { + if (!Number.isInteger(index) || index < 0 || index > 0x7fffffff) { + throw new Error(`ed25519 safe co-signer derivation index must be non-hardened, got ${index}`); + } + const { pub, chainCode } = decodeDerivableEd25519Pub(compositePub); + const eddsa = await getEddsa(); + return Buffer.from( + eddsa.deriveUnhardened(Buffer.concat([pub, chainCode]).toString('hex'), `m/${index}`).slice(0, 64), + 'hex' + ); +} diff --git a/modules/sdk-core/src/bitgo/safe/safe.ts b/modules/sdk-core/src/bitgo/safe/safe.ts index a1b198c7df..e73d79bb7b 100644 --- a/modules/sdk-core/src/bitgo/safe/safe.ts +++ b/modules/sdk-core/src/bitgo/safe/safe.ts @@ -23,7 +23,11 @@ import { ISafe, WalletShareData, } from './iSafe'; -import { deriveAndSelfCheckSafeChildHardened, DerivedFromParentWithHardenedPath } from './safeDerivation'; +import { + deriveAndSelfCheckSafeChildHardened, + deriveSafeChildEd25519Hardened, + DerivedFromParentWithHardenedPath, +} from './safeDerivation'; const SafeRootKeySlot = t.keyof({ secp256k1Multisig: null, @@ -45,7 +49,7 @@ const CreateWalletInSafeBody = t.strict({ keys: t.tuple([t.string]), }); -function onchainSlotForCoin(coin: IBaseCoin): Extract { +function onchainSlotForCoin(coin: IBaseCoin): Extract { if (coin.getDefaultMultisigType() === 'tss') { throw new Error('MPC safe wallet minting is not yet implemented; use a slot-1 onchain coin'); } @@ -54,7 +58,7 @@ function onchainSlotForCoin(coin: IBaseCoin): Extract 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 52 canonical base32 characters', function () { - isValidEd25519ChainCode(chainCode).should.equal(true); + describe('base32 root format', function () { + it('encodes 32-byte public key plus 32-byte chain code', function () { + DERIVABLE_ED25519_CHAIN_CODE_BYTES.should.equal(32); + DERIVABLE_ED25519_PUB_LENGTH.should.equal(103); + ROOT_PUB.should.match(/^[A-Z2-7]{103}$/); + decodeDerivableEd25519Pub(ROOT_PUB).pub.equals(PUBLIC_KEY).should.equal(true); + decodeDerivableEd25519Pub(ROOT_PUB).chainCode.equals(CHAIN_CODE).should.equal(true); }); - it('rejects lowercase', function () { - isValidEd25519ChainCode(chainCode.toLowerCase()).should.equal(false); + it('rejects non-canonical and wrong-length material', function () { + isDerivableEd25519Pub(ROOT_PUB.toLowerCase()).should.equal(false); + (() => encodeDerivableEd25519Pub(PUBLIC_KEY.subarray(0, 31), CHAIN_CODE)).should.throw(/32-byte/); + (() => decodeDerivableEd25519Pub(`${ROOT_PUB}A`)).should.throw(/64 bytes/); }); - it('rejects a chain code of the wrong length', function () { - isValidEd25519ChainCode(chainCode.slice(0, 51)).should.equal(false); - isValidEd25519ChainCode(chainCode + 'A').should.equal(false); - }); - - it('rejects a character outside the base32 alphabet', function () { - // 0, 1, 8 and 9 are absent from the RFC 4648 alphabet. - isValidEd25519ChainCode('0' + chainCode.slice(1)).should.equal(false); - }); - - it('rejects a non-canonical spelling whose padding bits are set', function () { - // The 52nd character holds 1 significant bit and 4 padding bits, so 16 strings decode to the - // same 32 bytes. Only the zero-padded one is the chain code. - const BASE32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; - const bumped = chainCode.slice(0, 51) + BASE32[BASE32.indexOf(chainCode[51]) + 1]; - bumped.should.not.equal(chainCode); - isValidEd25519ChainCode(bumped).should.equal(false); - }); - - it('accepts what the generator mints', function () { - isValidEd25519ChainCode(generateEd25519ChainCodeBase32()).should.equal(true); + it('generates raw chain code for root composition', function () { + generateEd25519ChainCode().length.should.equal(32); }); }); - 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); + describe('raw public soft derivation', function () { + it('derives a deterministic public key and child chain code', async function () { + const eddsa = await Eddsa.initialize(await Ed25519BIP32.initialize()); + const derived = eddsa.deriveUnhardened(ROOT_KEYCHAIN, 'm/7'); + derived.length.should.equal(128); + derived.slice(0, 64).should.match(/^[0-9a-f]{64}$/); + derived.slice(64).should.match(/^[0-9a-f]{64}$/); }); + }); - it('rejects the empty string and a composite pub', function () { - isValidEd25519StrKeyPublicKey('').should.equal(false); - isValidEd25519StrKeyPublicKey(fixture.valid[3].composite).should.equal(false); + describe('Stellar user-key codecs', function () { + it('keeps Stellar encoding separate from the neutral root format', function () { + isValidEd25519StrKeyPublicKey(STRKEY_PUBLIC).should.equal(true); + encodeEd25519StrKeyPublicKey(decodeEd25519StrKeyPublicKey(STRKEY_PUBLIC)).should.equal(STRKEY_PUBLIC); + decodeEd25519StrKeySecretSeed(STRKEY_SEED).length.should.equal(32); + isDerivableEd25519Pub(STRKEY_PUBLIC).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 index 0a0a2941fd..dcd1db740c 100644 --- a/modules/sdk-core/test/unit/bitgo/safe/fixtures/derivableEd25519Pub.json +++ b/modules/sdk-core/test/unit/bitgo/safe/fixtures/derivableEd25519Pub.json @@ -1,197 +1,30 @@ { - "$comment": "Shared cross-repo fixture for the derivable slot-4 (ed25519Multisig) root pub format: composite = <56-char Stellar StrKey ed25519 public key> || <52-char base32 chain code>. BASE32 END TO END: both halves use RFC 4648 unpadded base32 over the alphabet StrKey itself uses, so the composite is one uniform string. A chain code is 32 bytes = 52 base32 characters, whose final character carries 1 significant bit and 4 zero padding bits, so 16 spellings decode to the same bytes and only the zero-padded one is valid — validators must re-encode, not just match the alphabet. 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.", - "$detectedBy": "Which check rejects an invalid vector. format = the coin-agnostic codec (length, StrKey shape, canonical base32 chain code) rejects it, so encode/decode catch it. coin = the codec accepts it and only the coin-aware gate (StrKey checksum via coin.isValidPub) rejects it; an implementation without a coin instance MUST NOT be expected to catch these. An implementation whose codec verifies the StrKey checksum itself (e.g. BitGoJS sdk-core) rejects the coin vectors too — detectedBy is a floor, not a ceiling.", - "splitOffset": 56, - "chainCodeLength": 52, - "compositeLength": 108, + "$comment": "Shared cross-repo fixture: raw 32-byte public key || raw 32-byte chain code encoded together as canonical unpadded RFC 4648 base32.", + "publicKeyBytes": 32, + "chainCodeBytes": 32, + "rootPubBase32Length": 103, "valid": [ { - "name": "all-zero pub with all-zero chain code", - "pub": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", - "chainCode": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", - "composite": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + "name": "zero public key and repeated chain code", + "pub": "0000000000000000000000000000000000000000000000000000000000000000", + "chainCode": "abababababababababababababababababababababababababababababababab", + "composite": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKXK5LVOV2XKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" }, { - "name": "all-ones pub with all-ones chain code", - "pub": "GD7777777777777777777777777777777777777777777777777773DB", - "chainCode": "777777777777777777777777777777777777777777777777777Q", - "composite": "GD7777777777777777777777777777777777777777777777777773DB777777777777777777777777777777777777777777777777777Q" - }, - { - "name": "low-order pub with a near-zero chain code", - "pub": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC6PV", - "chainCode": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQ", - "composite": "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC6PVAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQ" - }, - { - "name": "typical pub with a mixed alphanumeric chain code", - "pub": "GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYH", - "chainCode": "TIHTY7I6LMUEMCXT3EWG5ANXAU72JQXJRUL3MBKDUL6I4GOXWBDA", - "composite": "GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYHTIHTY7I6LMUEMCXT3EWG5ANXAU72JQXJRUL3MBKDUL6I4GOXWBDA" - }, - { - "name": "typical pub with a repeating chain code", - "pub": "GDD7GGYPJUX3BNVA6OPIXUFXYOY2FVHF6YDRQKJ2JNOG27UPSAJDJCMV", - "chainCode": "CI2FM6EQCI2FM6EQCI2FM6EQCI2FM6EQCI2FM6EQCI2FM6EQCI2A", - "composite": "GDD7GGYPJUX3BNVA6OPIXUFXYOY2FVHF6YDRQKJ2JNOG27UPSAJDJCMVCI2FM6EQCI2FM6EQCI2FM6EQCI2FM6EQCI2FM6EQCI2FM6EQCI2A" + "name": "mixed public key and chain code", + "pub": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "chainCode": "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210", + "composite": "AERUKZ4JVPG7AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" } ], - "invalidComposite": [ - { - "name": "empty string", - "composite": "", - "reason": "length", - "detectedBy": "format" - }, - { - "name": "bare 56-char pub with no chain code", - "composite": "GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYH", - "reason": "length", - "detectedBy": "format" - }, - { - "name": "bare chain code with no pub", - "composite": "TIHTY7I6LMUEMCXT3EWG5ANXAU72JQXJRUL3MBKDUL6I4GOXWBDA", - "reason": "length", - "detectedBy": "format" - }, - { - "name": "chain code one character short", - "composite": "GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYHTIHTY7I6LMUEMCXT3EWG5ANXAU72JQXJRUL3MBKDUL6I4GOXWBD", - "reason": "length", - "detectedBy": "format" - }, - { - "name": "chain code one character long", - "composite": "GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYHTIHTY7I6LMUEMCXT3EWG5ANXAU72JQXJRUL3MBKDUL6I4GOXWBDAA", - "reason": "length", - "detectedBy": "format" - }, - { - "name": "lowercase chain code", - "composite": "GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYHtihty7i6lmuemcxt3ewg5anxau72jqxjrul3mbkdul6i4goxwbda", - "reason": "chainCode", - "detectedBy": "format" - }, - { - "name": "mixed-case chain code", - "composite": "GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYHTihty7i6lmuemcxt3ewg5anxau72jqxjrul3mbkdul6i4goxwbda", - "reason": "chainCode", - "detectedBy": "format" - }, - { - "name": "chain code with a character outside the base32 alphabet", - "composite": "GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYH0IHTY7I6LMUEMCXT3EWG5ANXAU72JQXJRUL3MBKDUL6I4GOXWBDA", - "reason": "chainCode", - "detectedBy": "format" - }, - { - "name": "non-canonical chain code with padding bits set", - "composite": "GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYHTIHTY7I6LMUEMCXT3EWG5ANXAU72JQXJRUL3MBKDUL6I4GOXWBDB", - "reason": "chainCode", - "detectedBy": "format" - }, - { - "name": "pub half is a secret seed (S...) not a public key", - "composite": "SA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYHTIHTY7I6LMUEMCXT3EWG5ANXAU72JQXJRUL3MBKDUL6I4GOXWBDA", - "reason": "pub", - "detectedBy": "format" - }, - { - "name": "pub half contains a lowercase character", - "composite": "Ga5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYHTIHTY7I6LMUEMCXT3EWG5ANXAU72JQXJRUL3MBKDUL6I4GOXWBDA", - "reason": "pub", - "detectedBy": "format" - }, - { - "name": "pub half contains 0 and 1, which are not in the base32 alphabet", - "composite": "G01WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYHTIHTY7I6LMUEMCXT3EWG5ANXAU72JQXJRUL3MBKDUL6I4GOXWBDA", - "reason": "pub", - "detectedBy": "format" - }, - { - "name": "halves swapped", - "composite": "TIHTY7I6LMUEMCXT3EWG5ANXAU72JQXJRUL3MBKDUL6I4GOXWBDAGA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYH", - "reason": "pub", - "detectedBy": "format" - }, - { - "name": "pub half has a bad StrKey checksum", - "composite": "GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYATIHTY7I6LMUEMCXT3EWG5ANXAU72JQXJRUL3MBKDUL6I4GOXWBDA", - "reason": "pub", - "detectedBy": "coin" - } + "invalid": [ + { "name": "lowercase", "composite": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaakxk5lvov2xkaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + { "name": "short", "composite": "AA" }, + { "name": "non-canonical trailing bits", "composite": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKXK5LVOV2XKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB" } ], - "invalidEncodeInputs": [ - { - "name": "empty pub", - "pub": "", - "chainCode": "TIHTY7I6LMUEMCXT3EWG5ANXAU72JQXJRUL3MBKDUL6I4GOXWBDA", - "reason": "pub", - "detectedBy": "format" - }, - { - "name": "composite passed as the pub half", - "pub": "GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYHTIHTY7I6LMUEMCXT3EWG5ANXAU72JQXJRUL3MBKDUL6I4GOXWBDA", - "chainCode": "TIHTY7I6LMUEMCXT3EWG5ANXAU72JQXJRUL3MBKDUL6I4GOXWBDA", - "reason": "pub", - "detectedBy": "format" - }, - { - "name": "pub half is a secret seed (S...)", - "pub": "SA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYH", - "chainCode": "TIHTY7I6LMUEMCXT3EWG5ANXAU72JQXJRUL3MBKDUL6I4GOXWBDA", - "reason": "pub", - "detectedBy": "format" - }, - { - "name": "empty chain code", - "pub": "GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYH", - "chainCode": "", - "reason": "chainCode", - "detectedBy": "format" - }, - { - "name": "lowercase chain code", - "pub": "GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYH", - "chainCode": "tihty7i6lmuemcxt3ewg5anxau72jqxjrul3mbkdul6i4goxwbda", - "reason": "chainCode", - "detectedBy": "format" - }, - { - "name": "chain code with 0x prefix", - "pub": "GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYH", - "chainCode": "0xHTY7I6LMUEMCXT3EWG5ANXAU72JQXJRUL3MBKDUL6I4GOXWBDA", - "reason": "chainCode", - "detectedBy": "format" - }, - { - "name": "non-canonical chain code with padding bits set", - "pub": "GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYH", - "chainCode": "TIHTY7I6LMUEMCXT3EWG5ANXAU72JQXJRUL3MBKDUL6I4GOXWBDB", - "reason": "chainCode", - "detectedBy": "format" - }, - { - "name": "chain code too short", - "pub": "GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYH", - "chainCode": "TIHTY7I6LMUEMCXT3EWG5ANXAU72JQXJRUL3MBKDUL6I4GOXWBD", - "reason": "chainCode", - "detectedBy": "format" - }, - { - "name": "chain code too long", - "pub": "GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYH", - "chainCode": "TIHTY7I6LMUEMCXT3EWG5ANXAU72JQXJRUL3MBKDUL6I4GOXWBDAA", - "reason": "chainCode", - "detectedBy": "format" - }, - { - "name": "pub with a bad StrKey checksum", - "pub": "GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYA", - "chainCode": "TIHTY7I6LMUEMCXT3EWG5ANXAU72JQXJRUL3MBKDUL6I4GOXWBDA", - "reason": "pub", - "detectedBy": "coin" - } + "invalidComposite": [ + { "name": "lowercase", "composite": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaakxk5lvov2xkaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + { "name": "short", "composite": "AA" }, + { "name": "non-canonical trailing bits", "composite": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKXK5LVOV2XKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB" } ] } diff --git a/modules/sdk-core/test/unit/bitgo/safe/safe.ts b/modules/sdk-core/test/unit/bitgo/safe/safe.ts index 94aedde4c3..fbcae256f6 100644 --- a/modules/sdk-core/test/unit/bitgo/safe/safe.ts +++ b/modules/sdk-core/test/unit/bitgo/safe/safe.ts @@ -1,11 +1,21 @@ import * as sinon from 'sinon'; import 'should'; import { SafeData } from '@bitgo/public-types'; -import { IncorrectPasswordError, Safe, deriveSafeChildHardenedFromXprv } from '../../../../src'; +import { + IncorrectPasswordError, + Safe, + deriveSafeChildEd25519Hardened, + deriveSafeChildHardenedFromXprv, +} from '../../../../src'; const ROOT_XPRV = 'xprv9s21ZrQH143K3hekyNj7TciR4XNYe1kMj68W2ipjJGNHETWP7o42AjDnSPgKhdZ4x8NBAvaL72RrXjuXNdmkMqLERZza73oYugGtbLFXG8g'; +// 32-byte synthetic seed, StrKey spelling generated with stellar-sdk; pinned derivation vectors in +// test/unit/bitgo/safe/safeDerivation.ts. +const ROOT_ED25519_SEED_STRKEY = 'SAAACAQDAQCQMBYIBEFAWDANBYHRAEISCMKBKFQXDAMRUGY4DUPB6NKI'; +const ed25519ChildAt0 = deriveSafeChildEd25519Hardened(ROOT_ED25519_SEED_STRKEY, 0); + describe('Safe', function () { let safe: Safe; let mockBitGo: any; @@ -251,11 +261,41 @@ describe('Safe', function () { .should.be.rejectedWith(/returned slot 'ecdsaMpc'/); }); - it('rejects ed25519 onchain coins', async function () { + it('mints an ed25519 wallet from the StrKey seed user root', async function () { stubCoin('txlm'); - await safe - .createWallet({ coin: 'txlm', label: 'xlm', passphrase: 'pw' }) - .should.be.rejectedWith(/ed25519 coin safe wallet minting is not yet supported/); + keychainsGet.resolves({ + id: 'user-root-id', + source: 'user', + encryptedPrv: `enc:${ROOT_ED25519_SEED_STRKEY}`, + pub: 'GAB2CB576PHBBPQ5ODORRZ2LYCMWPZGWGCN2KDK7DXOIMZASKUY3QZ6Q', + type: 'independent', + }); + keychainsAdd.resolves({ id: 'child-key-id', pub: ed25519ChildAt0.pub, type: 'independent' }); + derivationQuery.returns({ + result: sinon.stub().resolves({ slot: 'ed25519Multisig', index: 0 }), + }); + + await safe.createWallet({ coin: 'txlm', label: 'xlm desk', passphrase: 'pw' }); + + derivationQuery.calledOnceWithExactly({ slot: 'ed25519Multisig' }).should.be.true(); + keychainsGet.calledOnceWithExactly({ id: 'ed-user' }).should.be.true(); + const addArgs = keychainsAdd.firstCall.args[0]; + addArgs.should.eql({ + pub: ed25519ChildAt0.pub, + source: 'user', + keyType: 'independent', + parent: 'ed-user', + safeId: 'test-safe-id', + derivedFromParentWithPath: "m/0'", + }); + addArgs.should.not.have.property('encryptedPrv'); + mintSend.firstCall.args[0].should.eql({ + coin: 'txlm', + label: 'xlm desk', + type: 'hot', + multisigType: 'onchain', + keys: ['child-key-id'], + }); }); it('rejects an empty passphrase', async function () { diff --git a/modules/sdk-core/test/unit/bitgo/safe/safeDerivation.ts b/modules/sdk-core/test/unit/bitgo/safe/safeDerivation.ts new file mode 100644 index 0000000000..9dfb6c5e5d --- /dev/null +++ b/modules/sdk-core/test/unit/bitgo/safe/safeDerivation.ts @@ -0,0 +1,91 @@ +import 'should'; +import { + deriveSafeChildEd25519Hardened, + deriveSafeChildHardenedFromXprv, + getSafeHardenedDerivationPath, + parseSafeDerivationIndex, +} from '../../../../src'; + +// 32-byte synthetic root seed. The StrKey spelling was generated with stellar-sdk +// (Keypair.fromRawEd25519Seed); the derivation vectors below were generated with an independent +// SLIP-0010 implementation written from the spec and cross-checked against published vectors +// (SLIP-0010 test vector 1 seed 000102...0f derives m/0' to 68e0fe46...dade7a3). +const ROOT_SEED_STRKEY = 'SAAACAQDAQCQMBYIBEFAWDANBYHRAEISCMKBKFQXDAMRUGY4DUPB6NKI'; + +const ROOT_XPRV = + 'xprv9s21ZrQH143K3hekyNj7TciR4XNYe1kMj68W2ipjJGNHETWP7o42AjDnSPgKhdZ4x8NBAvaL72RrXjuXNdmkMqLERZza73oYugGtbLFXG8g'; + +describe('safeDerivation', function () { + describe('parseSafeDerivationIndex', function () { + it('accepts numbers and digit strings', function () { + parseSafeDerivationIndex(0).should.equal(0); + parseSafeDerivationIndex(7).should.equal(7); + parseSafeDerivationIndex('42').should.equal(42); + parseSafeDerivationIndex(0x7fffffff).should.equal(0x7fffffff); + }); + + it('rejects negatives, non-integers, and out-of-range values', function () { + (() => parseSafeDerivationIndex(-1)).should.throw(/Invalid safe derivation index/); + (() => parseSafeDerivationIndex(1.5)).should.throw(/Invalid safe derivation index/); + (() => parseSafeDerivationIndex(0x80000000)).should.throw(/Invalid safe derivation index/); + }); + + it('rejects non-numeric strings', function () { + (() => parseSafeDerivationIndex("0'")).should.throw(/Invalid safe derivation index/); + (() => parseSafeDerivationIndex('m/0')).should.throw(/Invalid safe derivation index/); + (() => parseSafeDerivationIndex('')).should.throw(/Invalid safe derivation index/); + }); + }); + + describe('getSafeHardenedDerivationPath', function () { + it('formats m/ with hardened apostrophe', function () { + getSafeHardenedDerivationPath(0).should.equal("m/0'"); + getSafeHardenedDerivationPath('7').should.equal("m/7'"); + getSafeHardenedDerivationPath('007').should.equal("m/7'"); + }); + }); + + describe('deriveSafeChildHardenedFromXprv', function () { + it('derives m/0 from the root xprv', function () { + const child = deriveSafeChildHardenedFromXprv(ROOT_XPRV, 0); + child.derivationPath.should.equal("m/0'"); + child.pub.should.equal( + 'xpub69PbR6HB6ZaW3Q9CWAzNsmWXC8TBDq1VEmd25XkwUgrU3PVGAbj6bksqPnGWcFdAodXWRpWMXJ5KGim45n55cZjXeW7FDw4BqahtxTEN4wB' + ); + child.prv.should.equal( + 'xprv9vQF1akHGC2Cpv4jQ9TNWdZne6cgpNHdsYhRH9MKvMKVAbA7d4Qr3xZMYXqAS35V4damCDP2hYohCLViHzcGhX4Tr7djjCBruAX73SsjCiC' + ); + }); + }); + + describe('deriveSafeChildEd25519Hardened', function () { + it('derives m/0 to the pinned SLIP-0010 vector', function () { + const child = deriveSafeChildEd25519Hardened(ROOT_SEED_STRKEY, 0); + child.should.eql({ + prv: 'b127eb5092011c085345c8ce0bfeda6064f9e1249e29cc238c1d64bf2e587ce7', + pub: 'GCTZR46FPFAMYN734SQB4NCNBI44M4DSNM5RJPCDLOMAOFPEUVUXPK7R', + derivationPath: "m/0'", + }); + }); + + it('derives m/7 to the pinned SLIP-0010 vector', function () { + const child = deriveSafeChildEd25519Hardened(ROOT_SEED_STRKEY, '7'); + child.should.eql({ + prv: 'd54701e221cf51e9e208a7c59e3fe3e4cfbb6b91fd3f35ce092a471c35228217', + pub: 'GC2Y5EU2XA22SOSCRSZRNDEY3UAA4OJSLZ5NQUFUZDBQSLMVCIZCBHIN', + derivationPath: "m/7'", + }); + }); + + it('rejects a malformed root seed', function () { + (() => deriveSafeChildEd25519Hardened(ROOT_SEED_STRKEY.slice(0, 55), 0)).should.throw( + /Invalid ed25519 StrKey secret seed/ + ); + (() => deriveSafeChildEd25519Hardened(ROOT_XPRV, 0)).should.throw(/Invalid ed25519 StrKey secret seed/); + }); + + it('rejects an invalid index', function () { + (() => deriveSafeChildEd25519Hardened(ROOT_SEED_STRKEY, -1)).should.throw(/Invalid safe derivation index/); + }); + }); +}); diff --git a/modules/sdk-core/test/unit/bitgo/safe/safes.ts b/modules/sdk-core/test/unit/bitgo/safe/safes.ts index b4fc015ec6..c07da966a8 100644 --- a/modules/sdk-core/test/unit/bitgo/safe/safes.ts +++ b/modules/sdk-core/test/unit/bitgo/safe/safes.ts @@ -3,9 +3,9 @@ import 'should'; import { InitializeSafeResponse } from '@bitgo/public-types'; import { Enterprise, Safe, SafeKeys, Safes } from '../../../../src'; -/** A derivable slot-④ backup root pub: 56-char StrKey || 52-char base32 chain code. */ +/** A derivable slot-④ root: raw 32-byte public key plus raw 32-byte chain code in base32. */ const COMPOSITE_BACKUP_PUB = - 'GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYH' + 'TIHTY7I6LMUEMCXT3EWG5ANXAU72JQXJRUL3MBKDUL6I4GOXWBDA'; + 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKXK5LVOV2XKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; describe('Safes', function () { let safes: Safes; @@ -71,13 +71,15 @@ describe('Safes', function () { return { create: sinon.stub().returns({ pub: `${coin}-pub`, prv: `${coin}-prv` }), add: sinon.stub().resolves({ id: `${coin}-user` }), - // txlm is the ed25519Multisig root coin, so its backup pub comes back composite — - // createMultisigRoot asserts that before returning the triplet. + // txlm is the ed25519Multisig root coin, so its backup pub is neutral raw derivation material. createBackup: sinon.stub().resolves({ id: `${coin}-backup`, ...(coin === 'txlm' || coin === 'xlm' ? { pub: COMPOSITE_BACKUP_PUB } : {}), }), - createBitGo: sinon.stub().resolves({ id: `${coin}-bitgo` }), + createBitGo: sinon.stub().resolves({ + id: `${coin}-bitgo`, + ...(coin === 'txlm' || coin === 'xlm' ? { pub: COMPOSITE_BACKUP_PUB } : {}), + }), createMpc: sinon.stub().resolves({ userKeychain: { id: `${coin}-user` }, backupKeychain: { id: `${coin}-backup` }, @@ -114,10 +116,8 @@ describe('Safes', function () { }); it('rejects an ed25519Multisig backup root that came back non-derivable', async function () { - // createBackup infers slot ④ from the generated pub's shape; if that inference ever misses, - // the root is silently non-derivable and the wallets minted from it are unrecoverable. + // A bare Stellar StrKey is not the neutral raw root format. keychainsByCoin['txlm'] = makeKeychains('txlm'); - // A bare 56-char StrKey: the pub createBackup would post if it failed to recognise slot ④. keychainsByCoin['txlm'].createBackup.resolves({ id: 'txlm-backup', pub: 'GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYH', @@ -125,7 +125,7 @@ describe('Safes', function () { await safes .createSafeKeys({ label: 'my safe', passphrase: 'pw', safeId: 'safe-1' }) - .should.be.rejectedWith(/ed25519Multisig backup root is not derivable/); + .should.be.rejectedWith(/ed25519Multisig roots are not derivable/); }); it('does not apply the derivable check to the secp256k1Multisig slot', async function () { @@ -196,7 +196,7 @@ describe('Safes', function () { keychainsByCoin['tbtc'] = makeKeychains('tbtc'); keychainsByCoin['tbtc'].createBitGo = gated({ id: 'tbtc-bitgo' }); keychainsByCoin['txlm'] = makeKeychains('txlm'); - keychainsByCoin['txlm'].createBitGo = gated({ id: 'txlm-bitgo' }); + keychainsByCoin['txlm'].createBitGo = gated({ id: 'txlm-bitgo', pub: COMPOSITE_BACKUP_PUB }); keychainsByCoin['hteth'] = makeKeychains('hteth'); keychainsByCoin['hteth'].createMpc = gated({ userKeychain: { id: 'hteth-user' },