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
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ export interface HdKeypair {
* https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0005.md
* https://github.com/satoshilabs/slips/blob/master/slip-0010.md
*
* @deprecated
* This is the sanctioned hardened-derivation path for Wallet Safes v1 ed25519 user children
* (see `deriveSafeChildEd25519Hardened`); hardened SLIP-0010 derivation needs no chain code and
* no WASM machinery, unlike the unhardened co-signer path in `bitgo/safe/derivableEd25519Pub`.
*/
export class Ed25519KeyDeriver {
/**
Expand Down
18 changes: 8 additions & 10 deletions modules/sdk-core/src/bitgo/keychain/keychains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -354,17 +355,14 @@ 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 together as canonical unpadded RFC 4648 base32
// (exactly 103 uppercase characters). 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);
Expand Down
268 changes: 117 additions & 151 deletions modules/sdk-core/src/bitgo/safe/derivableEd25519Pub.ts
Original file line number Diff line number Diff line change
@@ -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 = <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.
* 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<Eddsa> | undefined;
function getEddsa(): Promise<Eddsa> {
if (!eddsaPromise) {
eddsaPromise = (async () => Eddsa.initialize(await Ed25519BIP32.initialize()))();
eddsaPromise.catch(() => {
eddsaPromise = undefined;
});
}
return eddsaPromise;
}

/** 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);
const eddsa = await getEddsa();
return Buffer.from(
eddsa.deriveUnhardened(Buffer.concat([pub, chainCode]).toString('hex'), `m/${index}`).slice(0, 64),
'hex'
);
}
Loading