From 10dfd024448542ab286ba3b92025860c33949d80 Mon Sep 17 00:00:00 2001 From: Pranish Nepal Date: Wed, 14 Oct 2026 14:49:54 -0400 Subject: [PATCH] feat: add slot-2 safe recovery derivation Adds the ed25519Multisig (slot 2) arm to deriveSafeWalletKeys so offline Safe recovery can derive ALGO/XLM/HBAR wallet keys, plus the pure codecs and encoders it needs. - sdk-lib-safes: decodeEd25519ChainCode, encodeEd25519StrKeySecretSeed, encodeAlgoKey, and HBAR DER encoders - sdk-lib-safes: deriveSafeChildEd25519HardenedAtPath - sdk-core: softDeriveChildPubEd25519AtPath and softDeriveChildPrvEd25519AtPath - sdk-core: ed25519Multisig arm + SafeEd25519SeedKey/SafeEd25519ExtendedKey typed shapes (kind: seed | extended) The user child derives hardened at m/44'/coinType'/2'/account'; backup and bitgo soft at m/44/coinType/2/account from seed+chainCode and the composite pub respectively. Ticket: WCN-2894 --- .../src/bitgo/safe/derivableEd25519Pub.ts | 71 +++++++++++++++++-- modules/sdk-core/src/bitgo/safe/recovery.ts | 65 ++++++++++++++++- .../unit/bitgo/safe/derivableEd25519Pub.ts | 36 ++++++++++ .../sdk-core/test/unit/bitgo/safe/recovery.ts | 51 ++++++++++++- modules/sdk-lib-safes/package.json | 1 + modules/sdk-lib-safes/src/ed25519Pub.ts | 48 +++++++++++++ modules/sdk-lib-safes/src/safeDerivation.ts | 20 ++++++ modules/sdk-lib-safes/test/unit/ed25519Pub.ts | 43 +++++++++++ 8 files changed, 322 insertions(+), 13 deletions(-) diff --git a/modules/sdk-core/src/bitgo/safe/derivableEd25519Pub.ts b/modules/sdk-core/src/bitgo/safe/derivableEd25519Pub.ts index c315c5dd13f..19a06b29f66 100644 --- a/modules/sdk-core/src/bitgo/safe/derivableEd25519Pub.ts +++ b/modules/sdk-core/src/bitgo/safe/derivableEd25519Pub.ts @@ -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 = { + readonly kL: T; // clamped signing scalar + readonly kR: T; // nonce prefix + readonly pub: T; // 32-byte public key +}; let eddsaPromise: Promise | undefined; function getEddsa(): Promise { @@ -23,15 +44,51 @@ function getEddsa(): Promise { 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 { + 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/` 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); + 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> { + 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), + }; } diff --git a/modules/sdk-core/src/bitgo/safe/recovery.ts b/modules/sdk-core/src/bitgo/safe/recovery.ts index 85c19f2e5fe..64526ad370d 100644 --- a/modules/sdk-core/src/bitgo/safe/recovery.ts +++ b/modules/sdk-core/src/bitgo/safe/recovery.ts @@ -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'; @@ -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; // 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; @@ -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; @@ -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`); } diff --git a/modules/sdk-core/test/unit/bitgo/safe/derivableEd25519Pub.ts b/modules/sdk-core/test/unit/bitgo/safe/derivableEd25519Pub.ts index ac5709eae30..e914601b99b 100644 --- a/modules/sdk-core/test/unit/bitgo/safe/derivableEd25519Pub.ts +++ b/modules/sdk-core/test/unit/bitgo/safe/derivableEd25519Pub.ts @@ -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 @@ -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); diff --git a/modules/sdk-core/test/unit/bitgo/safe/recovery.ts b/modules/sdk-core/test/unit/bitgo/safe/recovery.ts index 7b96a026834..f4e203f9777 100644 --- a/modules/sdk-core/test/unit/bitgo/safe/recovery.ts +++ b/modules/sdk-core/test/unit/bitgo/safe/recovery.ts @@ -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'; @@ -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 () => { diff --git a/modules/sdk-lib-safes/package.json b/modules/sdk-lib-safes/package.json index 865ff75d7ae..bcc91c7efd7 100644 --- a/modules/sdk-lib-safes/package.json +++ b/modules/sdk-lib-safes/package.json @@ -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": { diff --git a/modules/sdk-lib-safes/src/ed25519Pub.ts b/modules/sdk-lib-safes/src/ed25519Pub.ts index a3e141e7fc8..dfe642cebaa 100644 --- a/modules/sdk-lib-safes/src/ed25519Pub.ts +++ b/modules/sdk-lib-safes/src/ed25519Pub.ts @@ -12,6 +12,7 @@ */ import { randomBytes } from 'crypto'; +import sha512 from 'js-sha512'; export const DERIVABLE_ED25519_PUBLIC_KEY_BYTES = 32; export const DERIVABLE_ED25519_CHAIN_CODE_BYTES = 32; @@ -19,11 +20,15 @@ export const DERIVABLE_ED25519_PUB_LENGTH = 103; const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; const BASE32_ROOT_REGEX = /^[A-Z2-7]{103}$/; +const CHAIN_CODE_REGEX = /^[A-Z2-7]{52}$/; 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; +const ALGORAND_CHECKSUM_BYTES = 4; +const HBAR_PUBLIC_KEY_PREFIX = '302a300506032b6570032100'; +const HBAR_PRIVATE_KEY_PREFIX = '302e020100300506032b657004220420'; function base32Decode(input: string): Buffer { const output = Buffer.alloc(Math.floor((input.length * 5) / 8)); @@ -126,6 +131,14 @@ export function encodeEd25519ChainCode(chainCode: Buffer): string { return base32Encode(chainCode); } +/** Decode a canonical base32 chain code into its raw 32-byte buffer. */ +export function decodeEd25519ChainCode(chainCode: string): Buffer { + if (!CHAIN_CODE_REGEX.test(chainCode) || base32Encode(base32Decode(chainCode)) !== chainCode) { + throw new Error('Invalid ed25519 chain code: expected canonical 52-character base32'); + } + return base32Decode(chainCode); +} + export function decodeEd25519StrKeyPublicKey(pub: string): Buffer { if (!STRKEY_PUBLIC_KEY_REGEX.test(pub)) { throw new Error('Invalid ed25519 StrKey public key'); @@ -159,6 +172,16 @@ export function encodeEd25519StrKeyPublicKey(rawPub: Buffer): string { return base32Encode(Buffer.concat([payload, checksum])); } +export function encodeEd25519StrKeySecretSeed(rawSeed: Buffer): string { + if (rawSeed.length !== DERIVABLE_ED25519_PUBLIC_KEY_BYTES) { + throw new Error('ed25519 secret seed must be 32 bytes'); + } + const payload = Buffer.concat([Buffer.from([STRKEY_VERSION_SECRET_SEED]), rawSeed]); + const checksum = Buffer.alloc(2); + checksum.writeUInt16LE(crc16Xmodem(payload), 0); + return base32Encode(Buffer.concat([payload, checksum])); +} + export function isChecksumValidStrKeyEd25519Pub(pub: string): boolean { try { decodeEd25519StrKeyPublicKey(pub); @@ -167,3 +190,28 @@ export function isChecksumValidStrKeyEd25519Pub(pub: string): boolean { return false; } } + +/** Encode a raw 32-byte key (secret seed or public key) as an Algorand 58-character base32 value. */ +export function encodeAlgoKey(bytes: Buffer): string { + if (bytes.length !== DERIVABLE_ED25519_PUBLIC_KEY_BYTES) { + throw new Error('ed25519 key must be 32 bytes'); + } + const checksum = Buffer.from(sha512.sha512_256.array(bytes).slice(28, 28 + ALGORAND_CHECKSUM_BYTES)); + return base32Encode(Buffer.concat([bytes, checksum])); +} + +/** Encode a raw 32-byte public key as a DER-prefixed Hedera public key (88 hex chars). */ +export function encodeHbarPublicKeyDer(pub: Buffer): string { + if (pub.length !== DERIVABLE_ED25519_PUBLIC_KEY_BYTES) { + throw new Error('ed25519 public key must be 32 bytes'); + } + return HBAR_PUBLIC_KEY_PREFIX + pub.toString('hex'); +} + +/** Encode a raw 32-byte secret seed as a DER-prefixed Hedera private key (96 hex chars). */ +export function encodeHbarPrivateKeyDer(seed: Buffer): string { + if (seed.length !== DERIVABLE_ED25519_PUBLIC_KEY_BYTES) { + throw new Error('ed25519 secret seed must be 32 bytes'); + } + return HBAR_PRIVATE_KEY_PREFIX + seed.toString('hex'); +} diff --git a/modules/sdk-lib-safes/src/safeDerivation.ts b/modules/sdk-lib-safes/src/safeDerivation.ts index e1b997eff1f..f3f64c032c8 100644 --- a/modules/sdk-lib-safes/src/safeDerivation.ts +++ b/modules/sdk-lib-safes/src/safeDerivation.ts @@ -29,6 +29,26 @@ export interface SafeBip44PathSegments { account: number; } +/** + * Assert `path` is a valid BIP32 path for the given derivation kind. Hardened paths require every + * segment to carry the `'` marker; soft paths reject it. Every segment must be a non-negative + * integer at or below {@link MAX_BIP32_INDEX}. + */ +export function assertBip32Path(path: string, opts: { hardened: boolean }): void { + const re = opts.hardened ? /^m(\/\d+')+$/ : /^m(\/\d+)+$/; + if (!re.test(path)) { + throw new Error(`Invalid ${opts.hardened ? 'hardened' : 'soft'} derivation path '${path}'`); + } + const outOfRange = path + .split('/') + .slice(1) + .map((segment) => Number(opts.hardened ? segment.slice(0, -1) : segment)) + .some((index) => !Number.isInteger(index) || index < 0 || index > MAX_BIP32_INDEX); + if (outOfRange) { + throw new Error(`Invalid ${opts.hardened ? 'hardened' : 'soft'} derivation path '${path}': index out of range`); + } +} + /** * Canonical decimal only: `044` and `44` name one key under two strings, so string-equality * path checks (the server's unique index) would treat one child as two. diff --git a/modules/sdk-lib-safes/test/unit/ed25519Pub.ts b/modules/sdk-lib-safes/test/unit/ed25519Pub.ts index 6e976eee56a..3ac4bd765dd 100644 --- a/modules/sdk-lib-safes/test/unit/ed25519Pub.ts +++ b/modules/sdk-lib-safes/test/unit/ed25519Pub.ts @@ -3,11 +3,16 @@ import { DERIVABLE_ED25519_CHAIN_CODE_BYTES, DERIVABLE_ED25519_PUB_LENGTH, decodeDerivableEd25519Pub, + decodeEd25519ChainCode, decodeEd25519StrKeyPublicKey, decodeEd25519StrKeySecretSeed, + encodeAlgoKey, encodeDerivableEd25519Pub, encodeEd25519ChainCode, encodeEd25519StrKeyPublicKey, + encodeEd25519StrKeySecretSeed, + encodeHbarPrivateKeyDer, + encodeHbarPublicKeyDer, generateEd25519ChainCode, isChecksumValidStrKeyEd25519Pub, isDerivableEd25519Pub, @@ -39,6 +44,15 @@ describe('ed25519Pub', function () { (() => encodeEd25519ChainCode(Buffer.alloc(31))).should.throw(/expected 32 bytes/); }); + it('decodes a canonical 52-char chain code and rejects non-canonical trailing bits', function () { + decodeEd25519ChainCode(encodeEd25519ChainCode(CHAIN_CODE)).equals(CHAIN_CODE).should.equal(true); + // same 32 bytes, different spelling of the 4 padding bits in the final char + (() => decodeEd25519ChainCode('VOV2XK5LVOV2XK5LVOV2XK5LVOV2XK5LVOV2XK5LVOV2XK5LVOVR')).should.throw( + /Invalid ed25519 chain code/ + ); + (() => decodeEd25519ChainCode('AA')).should.throw(/Invalid ed25519 chain code/); + }); + it('rejects malformed composite values', function () { isDerivableEd25519Pub('not-a-root').should.equal(false); (() => decodeDerivableEd25519Pub('not-a-root')).should.throw(/Invalid derivable ed25519 pub/); @@ -54,6 +68,11 @@ describe('ed25519Pub', function () { isChecksumValidStrKeyEd25519Pub(STRKEY_PUBLIC).should.equal(true); }); + it('encodes a 32-byte seed as an S… StrKey and round-trips the fixture', function () { + encodeEd25519StrKeySecretSeed(decodeEd25519StrKeySecretSeed(STRKEY_SEED)).should.equal(STRKEY_SEED); + (() => encodeEd25519StrKeySecretSeed(Buffer.alloc(31))).should.throw(/32 bytes/); + }); + it('rejects a public key with a corrupted checksum', function () { isValidEd25519StrKeyPublicKey('GA5WUJ54Z23KILLCUOUNAKTPBVZWKMQVO4O6EQ5GHLAERIMLLHNCSKYA').should.equal(false); }); @@ -71,4 +90,28 @@ describe('ed25519Pub', function () { isValidEd25519StrKeyPublicKey(STRKEY_PUBLIC.toLowerCase()).should.equal(false); }); }); + + describe('coin child encodings', function () { + // Golden vectors generated with the reference implementations (js-sha512 + hi-base32), + // pinned here to prove the leaf's base32Encode + Node sha512-256 reproduce them byte-for-byte. + const SEED = Buffer.alloc(32, 0x01); + const PUB = Buffer.alloc(32, 0x02); + const ALGO_SEED = 'AEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEA5RCDXMI'; + const ALGO_ADDRESS = 'AIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBMXPWWNQ'; + + it('encodes Algorand seed and address as 58-char base32 with a sha512-256 checksum', function () { + encodeAlgoKey(SEED).should.equal(ALGO_SEED); + encodeAlgoKey(SEED).should.match(/^[A-Z2-7]{58}$/); + encodeAlgoKey(PUB).should.equal(ALGO_ADDRESS); + (() => encodeAlgoKey(Buffer.alloc(31))).should.throw(/32 bytes/); + }); + + it('encodes Hedera public/private keys as DER-prefixed hex', function () { + encodeHbarPublicKeyDer(PUB).should.equal('302a300506032b6570032100' + PUB.toString('hex')); + encodeHbarPublicKeyDer(PUB).should.match(/^[0-9a-f]{88}$/); + encodeHbarPrivateKeyDer(SEED).should.equal('302e020100300506032b657004220420' + SEED.toString('hex')); + encodeHbarPrivateKeyDer(SEED).should.match(/^[0-9a-f]{96}$/); + (() => encodeHbarPublicKeyDer(Buffer.alloc(31))).should.throw(/32 bytes/); + }); + }); });