Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 71 additions & 21 deletions modules/sdk-core/src/bitgo/safe/derivableEd25519Pub.ts
Original file line number Diff line number Diff line change
@@ -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 = <StrKey ed25519 public key> || <chainCode, 52 base32 chars>
* 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 = <StrKey ed25519 public key> || <chainCode, 52 base32 chars>
* 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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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]));
}
15 changes: 11 additions & 4 deletions modules/sdk-core/src/bitgo/safe/safe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -45,7 +49,7 @@ const CreateWalletInSafeBody = t.strict({
keys: t.tuple([t.string]),
});

function onchainSlotForCoin(coin: IBaseCoin): Extract<RootKeyType, 'secp256k1Multisig'> {
function onchainSlotForCoin(coin: IBaseCoin): Extract<RootKeyType, 'secp256k1Multisig' | 'ed25519Multisig'> {
if (coin.getDefaultMultisigType() === 'tss') {
throw new Error('MPC safe wallet minting is not yet implemented; use a slot-1 onchain coin');
}
Expand All @@ -54,7 +58,7 @@ function onchainSlotForCoin(coin: IBaseCoin): Extract<RootKeyType, 'secp256k1Mul
return 'secp256k1Multisig';
}
if (curve === KeyCurve.Ed25519) {
throw new Error('ed25519 coin safe wallet minting is not yet supported');
return 'ed25519Multisig';
}
throw new Error(`Coin '${coin.getChain()}' is not supported for safe wallet minting`);
}
Expand Down Expand Up @@ -144,7 +148,10 @@ export class Safe implements ISafe {
throw new IncorrectPasswordError();
}

const derived = deriveAndSelfCheckSafeChildHardened(rootPrv, index);
const derived =
slot === 'ed25519Multisig'
? deriveSafeChildEd25519Hardened(rootPrv, index)
: deriveAndSelfCheckSafeChildHardened(rootPrv, index);
const derivedFromParentWithHardenedPath = decodeWithCodec(
DerivedFromParentWithHardenedPath,
derived.derivationPath,
Expand Down
29 changes: 29 additions & 0 deletions modules/sdk-core/src/bitgo/safe/safeDerivation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@
* that is the custody hashed path and cannot reproduce a safe child.
*/
import * as t from 'io-ts';
import * as nacl from 'tweetnacl';
import { bip32, BIP32Interface } from '@bitgo/utxo-lib';
import { Ed25519KeyDeriver } from '../../account-lib/util/ed25519KeyDeriver';
import { decodeWithCodec } from '../utils/codecs';
import { decodeEd25519StrKeySecretSeed, encodeEd25519StrKeyPublicKey } from './derivableEd25519Pub';

const MAX_BIP32_INDEX = 0x7fffffff;
export const DERIVED_FROM_PARENT_WITH_HARDENED_PATH = /^m\/(\d+)'$/;
Expand Down Expand Up @@ -100,3 +103,29 @@ export function deriveAndSelfCheckSafeChildHardened(rootXprv: string, index: str
}
return first;
}

/**
* SLIP-0010 hardened derivation of a safe user child from an ed25519 root secret seed.
*
* `rootPrv` is a Stellar StrKey `S…` secret seed (what slot-④ `ed25519Multisig` user roots store).
* The derivation reuses the existing SLIP-0010 implementation {@link Ed25519KeyDeriver.derivePath}
* (hardened-only CKDPriv over the `ed25519 seed` HMAC master), then expands the resulting 32-byte
* child seed into a keypair with `nacl`. The child `pub` is a bare StrKey `G…`; the `prv` is the
* raw 32-byte child seed as hex (the seed input a signer needs).
*
* Unlike the secp256k1 path, ed25519 hardened derivation needs no chain code — the composite
* `pub‖chainCode` form is only for SOFT co-signer derivation and is not used here.
*/
export function deriveSafeChildEd25519Hardened(rootPrv: string, index: string | number): SafeHardenedChildKey {
const idx = parseSafeDerivationIndex(index);
const derivationPath = getSafeHardenedDerivationPath(idx);
const rawSeedHex = decodeEd25519StrKeySecretSeed(rootPrv).toString('hex');
const childSeed = Ed25519KeyDeriver.derivePath(derivationPath, rawSeedHex).key;
const keyPair = nacl.sign.keyPair.fromSeed(Uint8Array.from(childSeed));
const pub = encodeEd25519StrKeyPublicKey(Buffer.from(keyPair.publicKey));
return {
prv: Buffer.from(childSeed).toString('hex'),
pub,
derivationPath,
};
}
66 changes: 66 additions & 0 deletions modules/sdk-core/test/unit/bitgo/safe/derivableEd25519Pub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import {
DERIVABLE_ED25519_PUB_LENGTH,
DERIVABLE_ED25519_PUB_SPLIT_OFFSET,
decodeDerivableEd25519Pub,
decodeEd25519StrKeySecretSeed,
encodeDerivableEd25519Pub,
encodeEd25519StrKeyPublicKey,
isDerivableEd25519Pub,
isValidEd25519ChainCode,
isValidEd25519StrKeyPublicKey,
Expand Down Expand Up @@ -127,4 +129,68 @@ describe('derivableEd25519Pub', function () {
isValidEd25519StrKeyPublicKey(fixture.valid[3].composite).should.equal(false);
});
});

// Fixtures generated with stellar-sdk (Keypair.fromRawEd25519Seed) over a synthetic 32-byte seed;
// the derivation itself is pinned in test/unit/bitgo/safe/safeDerivation.ts against published
// SLIP-0010 vectors.
const SEED_HEX = '000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f';
const SEED_STRKEY = 'SAAACAQDAQCQMBYIBEFAWDANBYHRAEISCMKBKFQXDAMRUGY4DUPB6NKI';

describe('decodeEd25519StrKeySecretSeed', function () {
it('decodes a valid secret seed to its 32 raw bytes', function () {
decodeEd25519StrKeySecretSeed(SEED_STRKEY).toString('hex').should.equal(SEED_HEX);
});

it('rejects a public key', function () {
(() => 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/);
});
});
});
50 changes: 45 additions & 5 deletions modules/sdk-core/test/unit/bitgo/safe/safe.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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 () {
Expand Down
Loading
Loading