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..7903ab4246 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,48 @@ { - "$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" - }, + "invalid": [ { - "name": "pub half is a secret seed (S...) not a public key", - "composite": "SA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYHTIHTY7I6LMUEMCXT3EWG5ANXAU72JQXJRUL3MBKDUL6I4GOXWBDA", - "reason": "pub", - "detectedBy": "format" + "name": "lowercase", + "composite": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaakxk5lvov2xkaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, { - "name": "pub half contains a lowercase character", - "composite": "Ga5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYHTIHTY7I6LMUEMCXT3EWG5ANXAU72JQXJRUL3MBKDUL6I4GOXWBDA", - "reason": "pub", - "detectedBy": "format" + "name": "short", + "composite": "AA" }, { - "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" + "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" - }, + "invalidComposite": [ { - "name": "chain code too short", - "pub": "GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYH", - "chainCode": "TIHTY7I6LMUEMCXT3EWG5ANXAU72JQXJRUL3MBKDUL6I4GOXWBD", - "reason": "chainCode", - "detectedBy": "format" + "name": "lowercase", + "composite": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaakxk5lvov2xkaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, { - "name": "chain code too long", - "pub": "GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYH", - "chainCode": "TIHTY7I6LMUEMCXT3EWG5ANXAU72JQXJRUL3MBKDUL6I4GOXWBDAA", - "reason": "chainCode", - "detectedBy": "format" + "name": "short", + "composite": "AA" }, { - "name": "pub with a bad StrKey checksum", - "pub": "GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYA", - "chainCode": "TIHTY7I6LMUEMCXT3EWG5ANXAU72JQXJRUL3MBKDUL6I4GOXWBDA", - "reason": "pub", - "detectedBy": "coin" + "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' },