Skip to content
Merged
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
71 changes: 64 additions & 7 deletions modules/sdk-core/src/bitgo/safe/derivableEd25519Pub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,29 @@
* soft-derivation remains here (it needs Ed25519BIP32/Eddsa, which live in sdk-core).
*/

import { Ed25519BIP32, Eddsa } from '../../account-lib';
import { decodeDerivableEd25519Pub } from '@bitgo/sdk-lib-safes';
import { createHash } from 'crypto';
import {
Ed25519BIP32,
Eddsa,
bigIntFromBufferBE,
bigIntFromBufferLE,
bigIntToBufferBE,
bigIntToBufferLE,
clamp,
} from '../../account-lib';
import {
assertBip32Path,
decodeDerivableEd25519Pub,
decodeEd25519ChainCode,
decodeEd25519StrKeySecretSeed,
} from '@bitgo/sdk-lib-safes';

/** Ed25519 extended-key halves + public point, parameterised by encoding (Buffer raw, string hex). */
export type Ed25519ExtendedKeyParts<T> = {
readonly kL: T; // clamped signing scalar
readonly kR: T; // nonce prefix
readonly pub: T; // 32-byte public key
};

let eddsaPromise: Promise<Eddsa> | undefined;
function getEddsa(): Promise<Eddsa> {
Expand All @@ -23,15 +44,51 @@ function getEddsa(): Promise<Eddsa> {
return eddsaPromise;
}

/** Soft-derive an unhardened path and return the derived raw 32-byte public key. */
export async function softDeriveChildPubEd25519AtPath(compositePub: string, path: string): Promise<Buffer> {
assertBip32Path(path, { hardened: false });
const { pub, chainCode } = decodeDerivableEd25519Pub(compositePub);
const eddsa = await getEddsa();
return Buffer.from(eddsa.deriveUnhardened(Buffer.concat([pub, chainCode]).toString('hex'), path).slice(0, 64), 'hex');
}

/** Soft-derive `m/<index>` and return the derived raw 32-byte public key. */
export async function softDeriveChildPubEd25519(compositePub: string, index: number): Promise<Buffer> {
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);
return softDeriveChildPubEd25519AtPath(compositePub, `m/${index}`);
}

/**
* Soft-derive an ed25519 co-signer child at an unhardened path, returning the private
* extended-key material — the clamped scalar `kL`, the nonce prefix `kR`, and the matching
* public point — from a root secret seed plus the root chain code carried on the keycard.
*/
export async function softDeriveChildPrvEd25519AtPath(params: {
seedStrKey: string;
chainCode: string;
path: string;
}): Promise<Ed25519ExtendedKeyParts<Buffer>> {
const { seedStrKey, chainCode, path } = params;
assertBip32Path(path, { hardened: false });
const seed = decodeEd25519StrKeySecretSeed(seedStrKey);
const chainCodeBuf = decodeEd25519ChainCode(chainCode);
const eddsa = await getEddsa();
return Buffer.from(
eddsa.deriveUnhardened(Buffer.concat([pub, chainCode]).toString('hex'), `m/${index}`).slice(0, 64),
'hex'
);
const h = createHash('sha512').update(seed).digest();
const sk = clamp(bigIntFromBufferLE(h.subarray(0, 32)));
const prefix = bigIntFromBufferBE(h.subarray(32));
const pk = Eddsa.curve.basePointMult(sk);
if (!eddsa.hdTree) {
throw new Error('Eddsa HD tree is not initialized');
}
const derived = eddsa.hdTree.privateDerive({ pk, sk, prefix, chaincode: bigIntFromBufferBE(chainCodeBuf) }, path);
if (derived.prefix === undefined) {
throw new Error('soft derivation did not produce a nonce prefix');
}
return {
kL: bigIntToBufferLE(derived.sk, 32),
kR: bigIntToBufferBE(derived.prefix, 32),
pub: bigIntToBufferLE(derived.pk, 32),
};
}
65 changes: 62 additions & 3 deletions modules/sdk-core/src/bitgo/safe/recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,21 @@ import { MAX_BIP32_INDEX } from '@bitgo/statics';
import type { RootKeyType } from '@bitgo/public-types';
import {
buildSafeBip44Path,
decodeEd25519StrKeyPublicKey,
deriveSafeChildEd25519Hardened,
deriveSafeChildTriplet,
Ed25519ChainCodeEntryCodec,
getSafeBip44CoinType,
parseSafeBip44Path,
parseSafeKeycardBox,
toSoftTwinPath,
type SafeKeycardRootEntry,
} from '@bitgo/sdk-lib-safes';
import {
softDeriveChildPubEd25519AtPath,
softDeriveChildPrvEd25519AtPath,
type Ed25519ExtendedKeyParts,
} from './derivableEd25519Pub';
import { DklsVrfUtils, MpsVrfUtils } from '@bitgo/sdk-lib-mpc';
import { BitGoBase } from '../bitgoBase';
import { IncorrectPasswordError } from '../errors';
Expand Down Expand Up @@ -46,10 +54,34 @@ export type EcdsaMpcChildKeys = {

export type EddsaMpcChildKeys = EcdsaMpcChildKeys;

/**
* A hardened (SLIP-0010) ed25519 child. The `kind` discriminator prevents a raw seed from being
* mistaken for an extended key downstream — both are 32-byte values that `isValidPub` would accept.
*/
export type SafeEd25519SeedKey = {
readonly kind: 'seed';
readonly seed: string; // 32-byte child seed (hex) — the signer input
readonly pub: string; // 32-byte public key (hex)
};

/**
* A soft (BIP32-Ed25519) ed25519 child. No 32-byte seed maps back to a soft-derived child, so the
* signer needs both halves directly: `kL` (the clamped scalar) and `kR` (the nonce prefix).
*/
export type SafeEd25519ExtendedKey = { readonly kind: 'extended' } & Ed25519ExtendedKeyParts<string>; // hex

/** The three ed25519 co-signer children. BitGo is pub-only: its private key stays in the HSM. */
export type Ed25519MultisigChildKeys = {
user: SafeEd25519SeedKey;
backup: SafeEd25519ExtendedKey;
bitgo: { readonly pub: string };
};

export type DerivedSafeWalletKeys =
| { slot: 'secp256k1Multisig'; path: string; keys: Secp256k1MultisigChildKeys }
| { slot: 'ecdsaMpc'; path: string; keys: EcdsaMpcChildKeys }
| { slot: 'eddsaMpc'; path: string; keys: EddsaMpcChildKeys };
| { slot: 'eddsaMpc'; path: string; keys: EddsaMpcChildKeys }
| { slot: 'ed25519Multisig'; path: string; keys: Ed25519MultisigChildKeys };

export type SafeRecoverKeyParams = {
userKey: string;
Expand Down Expand Up @@ -137,8 +169,7 @@ export async function decryptSafeKeycard(params: {

/**
* Derives a Safe wallet's child keys at the BIP44 path for a given slot and account.
* Supports `secp256k1Multisig`, `ecdsaMpc`, and `eddsaMpc`; `ed25519Multisig` remains
* unsupported until its derivation arm lands (WCN-2894).
* Supports all four slots: `secp256k1Multisig`, `ed25519Multisig`, `ecdsaMpc`, and `eddsaMpc`.
*/
export async function deriveSafeWalletKeys(params: {
coin: string;
Expand Down Expand Up @@ -188,6 +219,34 @@ export async function deriveSafeWalletKeys(params: {
};
}

if (params.slot === 'ed25519Multisig') {
const root = roots.ed25519Multisig;
const cosignerPath = toSoftTwinPath(path);

const user = deriveSafeChildEd25519Hardened(root.user, path);
const backup = await softDeriveChildPrvEd25519AtPath({
seedStrKey: root.backup.prv,
chainCode: root.backup.chainCode,
path: cosignerPath,
});
const bitgo = await softDeriveChildPubEd25519AtPath(root.bitgo, cosignerPath);

return {
slot: 'ed25519Multisig',
path,
keys: {
user: { kind: 'seed', seed: user.prv, pub: decodeEd25519StrKeyPublicKey(user.pub).toString('hex') },
backup: {
kind: 'extended',
kL: backup.kL.toString('hex'),
kR: backup.kR.toString('hex'),
pub: backup.pub.toString('hex'),
},
bitgo: { pub: bitgo.toString('hex') },
},
};
}

throw new Error(`deriveSafeWalletKeys: slot '${params.slot}' is not supported yet`);
}

Expand Down
36 changes: 36 additions & 0 deletions modules/sdk-core/test/unit/bitgo/safe/derivableEd25519Pub.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
import 'should';
import * as nacl from 'tweetnacl';
import {
DERIVABLE_ED25519_CHAIN_CODE_BYTES,
DERIVABLE_ED25519_PUB_LENGTH,
Ed25519BIP32,
Eddsa,
bigIntFromBufferLE,
bigIntToBufferLE,
decodeDerivableEd25519Pub,
decodeEd25519StrKeyPublicKey,
decodeEd25519StrKeySecretSeed,
encodeDerivableEd25519Pub,
encodeEd25519ChainCode,
encodeEd25519StrKeyPublicKey,
generateEd25519ChainCode,
isChecksumValidStrKeyEd25519Pub,
isDerivableEd25519Pub,
isValidEd25519StrKeyPublicKey,
softDeriveChildPubEd25519,
softDeriveChildPubEd25519AtPath,
softDeriveChildPrvEd25519AtPath,
} from '../../../../src';

// Cross-repo fixture: byte-identical to wallet-platform
Expand Down Expand Up @@ -105,6 +111,36 @@ describe('derivableEd25519Pub', function () {
});
});

describe('multi-segment soft derivation', function () {
const seed = decodeEd25519StrKeySecretSeed(STRKEY_SEED);
const rootPub = Buffer.from(nacl.sign.keyPair.fromSeed(seed).publicKey);
const rootChainCode = Buffer.alloc(32, 0xab);
const composite = encodeDerivableEd25519Pub(rootPub, rootChainCode);
const chainCodeStr = encodeEd25519ChainCode(rootChainCode);

it('private derive public half matches public derive at m/0 and m/44/283/2/0', async function () {
for (const path of ['m/0', 'm/44/283/2/0']) {
const pub = await softDeriveChildPubEd25519AtPath(composite, path);
const prv = await softDeriveChildPrvEd25519AtPath({ seedStrKey: STRKEY_SEED, chainCode: chainCodeStr, path });
prv.pub.equals(pub).should.equal(true);
// Recompute the public point directly from kL to prove the private scalar maps to pub.
bigIntToBufferLE(Eddsa.curve.basePointMult(bigIntFromBufferLE(prv.kL)), 32)
.equals(prv.pub)
.should.equal(true);
}
});

it('rejects hardened and out-of-range paths', async function () {
await softDeriveChildPubEd25519AtPath(composite, "m/0'").should.be.rejectedWith(/Invalid soft derivation path/);
await softDeriveChildPrvEd25519AtPath({
seedStrKey: STRKEY_SEED,
chainCode: chainCodeStr,
path: "m/0'",
}).should.be.rejectedWith(/Invalid soft derivation path/);
await softDeriveChildPubEd25519AtPath(composite, 'm/2147483648').should.be.rejectedWith(/out of range/);
});
});

describe('Stellar user-key codecs', function () {
it('keeps Stellar encoding separate from the neutral root format', function () {
isValidEd25519StrKeyPublicKey(STRKEY_PUBLIC).should.equal(true);
Expand Down
51 changes: 48 additions & 3 deletions modules/sdk-core/test/unit/bitgo/safe/recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ import {
decryptSafeKeycard,
deriveSafeWalletKeys,
} from '../../../../src/bitgo/safe/recovery';
import {
decodeEd25519StrKeySecretSeed,
encodeDerivableEd25519Pub,
encodeEd25519ChainCode,
softDeriveChildPubEd25519AtPath,
} from '../../../../src';
import { BitGoBase } from '../../../../src/bitgo/bitgoBase';
import { getInitializedMpcInstance } from '../../../../src/bitgo/tss/eddsa/eddsa';
import { signEddsaMpcV2RecoveryTx } from '../../../../src/bitgo/utils/tss/eddsa/eddsaMPCv2';
Expand Down Expand Up @@ -354,10 +360,49 @@ describe('deriveSafeWalletKeys', () => {
}
});

it('rejects the unsupported ed25519Multisig slot', async () => {
await deriveSafeWalletKeys({ coin: 'btc', slot: 'ed25519Multisig', account: 0, roots }).should.be.rejectedWith(
/not supported yet/
it('derives the ed25519Multisig triplet at the BIP44 path', async () => {
const STRKEY_SEED = 'SAAACAQDAQCQMBYIBEFAWDANBYHRAEISCMKBKFQXDAMRUGY4DUPB6NKI';
const backupSeed = decodeEd25519StrKeySecretSeed(STRKEY_SEED);
const backupRootPub = Buffer.from(ed25519.getPublicKey(backupSeed));
const backupChainCode = Buffer.alloc(32, 0xab);
const ed25519Roots: DecryptedSafeRoots = {
...roots,
ed25519Multisig: {
user: STRKEY_SEED,
backup: { prv: STRKEY_SEED, chainCode: encodeEd25519ChainCode(backupChainCode) },
bitgo: encodeDerivableEd25519Pub(backupRootPub, backupChainCode),
},
};

const result = await deriveSafeWalletKeys({
coin: 'algo',
slot: 'ed25519Multisig',
account: 0,
roots: ed25519Roots,
});
if (result.slot !== 'ed25519Multisig') {
throw new Error('expected ed25519Multisig slot');
}

result.path.should.equal("m/44'/283'/2'/0'");

// user: hardened SLIP-0010 child, pinned to the leaf's golden vector
result.keys.user.kind.should.equal('seed');
result.keys.user.seed.should.equal('be69531ebbab851b69aa41bb9c905afed52d26ff4278e6418609d4d60570510c');
result.keys.user.pub.should.equal(
Buffer.from(ed25519.getPublicKey(Buffer.from(result.keys.user.seed, 'hex'))).toString('hex')
);

// backup: extended key whose public half equals the public soft-derive of root pub ‖ chain code
const bitgoPub = (
await softDeriveChildPubEd25519AtPath(ed25519Roots.ed25519Multisig.bitgo, 'm/44/283/2/0')
).toString('hex');
result.keys.backup.kind.should.equal('extended');
// Pin the private halves (self-generated; to be superseded by GV1 cross-repo vectors).
result.keys.backup.kL.should.equal('9ad60bb591fec65df3a72fb041ee53a3263c43e6056512bc49de6b342cff8e0f');
result.keys.backup.kR.should.equal('1262f2d5439c03d11f2a295d691b4ea629f14e9e25c614d4a8aff8edd1865cdf');
result.keys.backup.pub.should.equal(bitgoPub);
result.keys.bitgo.pub.should.equal(bitgoPub);
});

it('rejects a non-integer account', async () => {
Expand Down
1 change: 1 addition & 0 deletions modules/sdk-lib-safes/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"fp-ts": "^2.12.2",
"io-ts": "npm:@bitgo-forks/io-ts@2.1.4",
"io-ts-types": "^0.5.19",
"js-sha512": "0.8.0",
"tweetnacl": "^1.0.3"
},
"devDependencies": {
Expand Down
Loading
Loading