diff --git a/modules/sdk-core/src/bitgo/safe/derivableEd25519Pub.ts b/modules/sdk-core/src/bitgo/safe/derivableEd25519Pub.ts index 67a7851439..5945fbfdd8 100644 --- a/modules/sdk-core/src/bitgo/safe/derivableEd25519Pub.ts +++ b/modules/sdk-core/src/bitgo/safe/derivableEd25519Pub.ts @@ -1,27 +1,34 @@ /** * @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 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. + * @experimental StrKey ed25519 codecs for the safe slot-④ (`ed25519Multisig`) root key. + * + * Two related concerns live here: + * + * 1. The *derivable* form of the slot-④ 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 + * + * 2. Plain StrKey codecs for the hardened user-key path (see {@link ./safeDerivation}): the user + * root is generated as an XLM keychain, so its private material is a StrKey secret seed (`S…`) + * and its hardened children are registered as bare StrKey public keys (`G…`). + * + * All StrKey plumbing (base32, CRC16-XModem) is implemented here rather than pulled from + * `stellar-sdk` because `sdk-core` must not depend on a coin module (`@bitgo/sdk-coin-xlm` / + * `stellar-sdk`). + * + * StrKey ed25519 public keys are always exactly 56 characters, so the composite 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. */ import { randomBytes } from 'crypto'; @@ -59,11 +66,15 @@ 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; +/** StrKey version byte for an ed25519 secret seed (`S…`). */ +const STRKEY_VERSION_BYTE_ED25519_SECRET_SEED = 18 << 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}$/; +const STRKEY_ED25519_SECRET_SEED_REGEX = /^S[A-Z2-7]{55}$/; /** * Decode an unpadded RFC 4648 base32 string. Callers guarantee the input already matched one of the @@ -213,3 +224,42 @@ export function isDerivableEd25519Pub(composite: string): boolean { return false; } } + +/** + * Decode a Stellar StrKey ed25519 secret seed (`S…`) to its 32 raw seed bytes. + * + * This is the hardened user-root input: the safe stores the slot-④ user root as an XLM keychain, + * whose prv is a StrKey secret seed (see {@link ./safeDerivation}). + * + * Validated by length, alphabet, version byte, and CRC16 checksum, so a corrupted seed is rejected + * rather than silently producing the wrong derivation input. + */ +export function decodeEd25519StrKeySecretSeed(seed: string): Buffer { + if (!STRKEY_ED25519_SECRET_SEED_REGEX.test(seed)) { + throw new Error('Invalid ed25519 StrKey secret seed'); + } + const decoded = base32Decode(seed); + if (decoded.length !== STRKEY_DECODED_LENGTH || decoded[0] !== STRKEY_VERSION_BYTE_ED25519_SECRET_SEED) { + throw new Error('Invalid ed25519 StrKey secret seed'); + } + if (crc16Xmodem(decoded.subarray(0, STRKEY_DECODED_LENGTH - 2)) !== decoded.readUInt16LE(STRKEY_DECODED_LENGTH - 2)) { + throw new Error('Invalid ed25519 StrKey secret seed: checksum mismatch'); + } + return decoded.subarray(1, 33); +} + +/** + * Encode 32 raw ed25519 public-key bytes to a Stellar StrKey public key (`G…`). + * + * Hardened derivation (see {@link ./safeDerivation}) produces a bare 32-byte public key that must + * be re-encoded before it is registered with wallet-platform. + */ +export function encodeEd25519StrKeyPublicKey(rawPub: Buffer): string { + if (rawPub.length !== 32) { + throw new Error('ed25519 public key must be 32 bytes'); + } + const payload = Buffer.concat([Buffer.from([STRKEY_VERSION_BYTE_ED25519_PUBLIC_KEY]), rawPub]); + const checksum = Buffer.alloc(2); + checksum.writeUInt16LE(crc16Xmodem(payload), 0); + return base32Encode(Buffer.concat([payload, checksum])); +} 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 decodeEd25519StrKeySecretSeed(fixture.valid[0].pub)).should.throw(/Invalid ed25519 StrKey secret seed/); + }); + + it('rejects a bad checksum', function () { + // last character of a known-good seed flipped (56 chars carry 280 bits with no padding) + const corrupted = SEED_STRKEY.slice(0, 55) + (SEED_STRKEY[55] === 'I' ? 'J' : 'I'); + corrupted.should.not.equal(SEED_STRKEY); + (() => decodeEd25519StrKeySecretSeed(corrupted)).should.throw(/checksum mismatch/); + }); + + it('rejects a wrong length', function () { + (() => decodeEd25519StrKeySecretSeed(SEED_STRKEY.slice(1))).should.throw(/Invalid ed25519 StrKey secret seed/); + (() => decodeEd25519StrKeySecretSeed(SEED_STRKEY + 'A')).should.throw(/Invalid ed25519 StrKey secret seed/); + }); + + it('rejects a non-base32 character', function () { + // 0, 1, 8 and 9 are absent from the RFC 4648 alphabet. + (() => decodeEd25519StrKeySecretSeed('S' + '0' + SEED_STRKEY.slice(2))).should.throw( + /Invalid ed25519 StrKey secret seed/ + ); + }); + + it('rejects the empty string', function () { + (() => decodeEd25519StrKeySecretSeed('')).should.throw(/Invalid ed25519 StrKey secret seed/); + }); + }); + + describe('encodeEd25519StrKeyPublicKey', function () { + // Raw public key of the m/0' derivation vector pinned in + // test/unit/bitgo/safe/safeDerivation.ts; the StrKey encoding was generated by stellar-sdk. + const PUB_HEX = 'a798f3c57940cc37fbe4a01e344d0a39c670726b3b14bc435b980715e4a56977'; + const PUB_STRKEY = 'GCTZR46FPFAMYN734SQB4NCNBI44M4DSNM5RJPCDLOMAOFPEUVUXPK7R'; + + it('encodes 32 raw bytes to the expected StrKey public key', function () { + encodeEd25519StrKeyPublicKey(Buffer.from(PUB_HEX, 'hex')).should.equal(PUB_STRKEY); + isValidEd25519StrKeyPublicKey(PUB_STRKEY).should.equal(true); + }); + + it('round-trips against the composite decoder', function () { + // The pub half of a composite splits back to the exact string the encoder emitted. + const pub = encodeEd25519StrKeyPublicKey(Buffer.from(PUB_HEX, 'hex')); + const { pub: pubHalf } = decodeDerivableEd25519Pub(`${pub}${fixture.valid[0].chainCode}`); + pubHalf.should.equal(pub); + }); + + it('rejects a wrong payload length', function () { + (() => encodeEd25519StrKeyPublicKey(Buffer.alloc(31))).should.throw(/must be 32 bytes/); + (() => encodeEd25519StrKeyPublicKey(Buffer.alloc(33))).should.throw(/must be 32 bytes/); + (() => encodeEd25519StrKeyPublicKey(Buffer.alloc(0))).should.throw(/must be 32 bytes/); + }); + }); }); 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/); + }); + }); +});