]> git.codecow.com Git - libnemo.git/commitdiff
Add support for SLIP-0010 'Bitcoin seed' using secp256k1.
authorChris Duncan <chris@zoso.dev>
Sun, 23 Nov 2025 10:36:49 +0000 (02:36 -0800)
committerChris Duncan <chris@zoso.dev>
Sun, 23 Nov 2025 10:36:49 +0000 (02:36 -0800)
package-lock.json
package.json
src/lib/crypto/bip44.ts

index 3cfb296ea053e9729fc7e776afa326669bab07d5..9e4b822e5413750bc60a6677d01ea43bb346dbc1 100644 (file)
@@ -12,6 +12,7 @@
                                "@ledgerhq/hw-transport-web-ble": "^6.29.12",
                                "@ledgerhq/hw-transport-webhid": "^6.30.8",
                                "@ledgerhq/hw-transport-webusb": "^6.29.12",
+                               "@noble/secp256k1": "^3.0.0",
                                "nano-pow": "^5.1.8"
                        },
                        "devDependencies": {
                        "integrity": "sha512-4+qRW2Pc8V+btL0QEmdB2X+uyx0kOWMWE1/LWsq5sZy3Q5tpi4eItJS6mB0XL3wGW59RQ+8bchNQQ1OW/va8Og==",
                        "license": "Apache-2.0"
                },
+               "node_modules/@noble/secp256k1": {
+                       "version": "3.0.0",
+                       "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-3.0.0.tgz",
+                       "integrity": "sha512-NJBaR352KyIvj3t6sgT/+7xrNyF9Xk9QlLSIqUGVUYlsnDTAUqY8LOmwpcgEx4AMJXRITQ5XEVHD+mMaPfr3mg==",
+                       "license": "MIT",
+                       "funding": {
+                               "url": "https://paulmillr.com/funding/"
+                       }
+               },
                "node_modules/@puppeteer/browsers": {
                        "version": "2.10.11",
                        "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.11.tgz",
                                "node": ">= 14"
                        }
                },
+               "node_modules/devtools-protocol": {
+                       "version": "0.0.1548823",
+                       "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1548823.tgz",
+                       "integrity": "sha512-VPuIGAx/GgqzNwyzfss5dFD/PDZMnaFaeEIb/3GeX0aKO5K5igm1BB7nYy0WXxhL9LA7vhI1XXbGi09t6cjGIA==",
+                       "license": "BSD-3-Clause",
+                       "optional": true,
+                       "peer": true
+               },
                "node_modules/emoji-regex": {
                        "version": "8.0.0",
                        "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
                        "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
                        "devOptional": true,
                        "license": "Apache-2.0",
-                       "peer": true,
                        "bin": {
                                "tsc": "bin/tsc",
                                "tsserver": "bin/tsserver"
index 386bb59c253e4758ab2d2f59e6b7c0a4df3ad36b..fd33b1caca6d83ec63d07414d56849d26693a953 100644 (file)
@@ -61,6 +61,7 @@
                "@ledgerhq/hw-transport-web-ble": "^6.29.12",
                "@ledgerhq/hw-transport-webhid": "^6.30.8",
                "@ledgerhq/hw-transport-webusb": "^6.29.12",
+               "@noble/secp256k1": "^3.0.0",
                "nano-pow": "^5.1.8"
        },
        "devDependencies": {
index b0989bd613ef6f54c4b4db33ce59a71e461ecfa3..1007f171911eb7a7e3868241581cc001ec7fe901 100644 (file)
@@ -1,31 +1,42 @@
 //! SPDX-FileCopyrightText: 2025 Chris Duncan <chris@zoso.dev>
 //! SPDX-License-Identifier: GPL-3.0-or-later
 
+import { getPublicKey } from '@noble/secp256k1'
+
 type ExtendedKey = {
        privateKey: ArrayBuffer
        chainCode: ArrayBuffer
+       publicKey?: ArrayBuffer
 }
 
+type Curve = 'Bitcoin seed' | 'ed25519 seed'
+
 export class Bip44 {
        static get BIP44_PURPOSE (): 44 { return 44 }
        static get HARDENED_OFFSET (): 0x80000000 { return 0x80000000 }
-       static get SLIP10_ED25519 (): 'ed25519 seed' { return 'ed25519 seed' }
+       static get SECP256K1_N (): bigint {
+               return 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141n
+       }
 
        /**
        * Derives a private child key for a coin by following the specified BIP-32 and
-       * BIP-44 derivation path. Purpose is always 44'. Only hardened child keys are
-       * defined.
+       * BIP-44 derivation path. Purpose is always 44'. For ed25519, only hardened
+       * child keys are supported.
        *
+       * @param {string} curve - 'Bitcoin seed' or 'ed25519 seed'
        * @param {ArrayBuffer} seed - Hexadecimal seed derived from mnemonic phrase
-       * @param {number} coin - Number registered to a specific coin in SLIP-044
+       * @param {number} coin - Number registered to a specific coin in SLIP-0044
        * @param {number} account - Account number between 0 and 2^31-1
        * @param {number} [change] - Used for change transactions, 0 for external and 1 for internal
        * @param {number} [address] - Sequentially increasing index of addresses to use for each account
        * @returns {Promise<ArrayBuffer>} Private child key for the account
        */
-       static ckd (seed: ArrayBuffer, coin: number, account: number, change?: number, address?: number): Promise<ArrayBuffer> {
+       static ckd (curve: Curve, seed: ArrayBuffer, coin: number, account: number, change?: number, address?: number): Promise<ArrayBuffer> {
+               if (curve !== 'Bitcoin seed' && curve !== 'ed25519 seed') {
+                       throw new TypeError(`Unsupported curve ${curve}`)
+               }
                if (seed.byteLength < 16 || seed.byteLength > 64) {
-                       throw new RangeError(`Invalid seed length`)
+                       throw new RangeError(`Invalid seed byte length ${seed.byteLength}`)
                }
                if (!Number.isSafeInteger(coin) || coin < 0 || coin > 0x7fffffff) {
                        throw new RangeError(`Invalid coin 0x${coin.toString(16)}`)
@@ -39,12 +50,12 @@ export class Bip44 {
                if (address !== undefined && (!Number.isSafeInteger(address) || address < 0 || address > 0x7fffffff)) {
                        throw new RangeError(`Invalid address index 0x${account.toString(16)}`)
                }
-               return this.slip10(this.SLIP10_ED25519, seed)
-                       .then(masterKey => this.CKDpriv(masterKey, this.BIP44_PURPOSE))
-                       .then(purposeKey => this.CKDpriv(purposeKey, coin))
-                       .then(coinKey => this.CKDpriv(coinKey, account))
-                       .then(accountKey => this.CKDpriv(accountKey, change))
-                       .then(chainKey => this.CKDpriv(chainKey, address))
+               return this.slip10(curve, seed)
+                       .then(masterKey => this.CKDpriv(curve, masterKey, this.BIP44_PURPOSE + this.HARDENED_OFFSET))
+                       .then(purposeKey => this.CKDpriv(curve, purposeKey, coin + this.HARDENED_OFFSET))
+                       .then(coinKey => this.CKDpriv(curve, coinKey, account + this.HARDENED_OFFSET))
+                       .then(accountKey => this.CKDpriv(curve, accountKey, change))
+                       .then(chainKey => this.CKDpriv(curve, chainKey, address))
                        .then(addressKey => addressKey.privateKey)
        }
 
@@ -59,21 +70,44 @@ export class Bip44 {
                        })
        }
 
-       static CKDpriv ({ privateKey, chainCode }: ExtendedKey, index?: number): Promise<ExtendedKey> {
+       static CKDpriv (curve: Curve, { privateKey, chainCode }: ExtendedKey, index?: number): Promise<ExtendedKey> {
+               console.log(index)
+               console.log([...(new Uint8Array(privateKey))].map(v => v.toString(16).padStart(2, '0')).join(''))
+               console.log([...(new Uint8Array(chainCode))].map(v => v.toString(16).padStart(2, '0')).join(''))
                if (index === undefined) {
                        return Promise.resolve({ privateKey, chainCode })
                }
-               index += this.HARDENED_OFFSET
+               const pk = new Uint8Array(privateKey)
                const key = new Uint8Array(chainCode)
                const data = new Uint8Array(37)
-               data.set([0])
-               data.set(this.ser256(privateKey), 1)
-               data.set(this.ser32(index), 33)
+               if (index >= this.HARDENED_OFFSET) {
+                       data.set([0])
+                       data.set(pk, 1)
+                       data.set(this.ser32(index), 33)
+               } else if (curve === 'ed25519 seed') {
+                       throw new RangeError('Only hardened child keys are supported for ed25519')
+               } else {
+                       data.set(getPublicKey(pk))
+                       data.set(this.ser32(index), 33)
+               }
                return this.hmac(key, data)
                        .then(I => {
                                const IL = I.slice(0, I.byteLength / 2)
                                const IR = I.slice(I.byteLength / 2)
-                               return ({ privateKey: IL, chainCode: IR })
+                               if (curve === 'ed25519 seed') {
+                                       return ({ privateKey: IL, chainCode: IR })
+                               } else {
+                                       const ILparsed = this.parse256(new Uint8Array(IL))
+                                       if (ILparsed >= this.SECP256K1_N) {
+                                               throw new Error('Invalid child key is greater than the order of the curve')
+                                       }
+                                       const pkParsed = this.parse256(pk)
+                                       const childKey = (ILparsed + pkParsed) % this.SECP256K1_N
+                                       if (childKey === 0n) {
+                                               throw new Error('Invalid child key is zero')
+                                       }
+                                       return ({ privateKey: this.ser256(childKey).buffer, chainCode: IR })
+                               }
                        })
        }
 
@@ -83,8 +117,20 @@ export class Bip44 {
                return new Uint8Array(view.buffer)
        }
 
-       static ser256 (integer: ArrayBuffer): Uint8Array<ArrayBuffer> {
-               return new Uint8Array(integer)
+       static ser256 (integer: bigint): Uint8Array<ArrayBuffer> {
+               let bytes = new Uint8Array(32)
+               for (let i = bytes.byteLength - 1; i >= 0; i--) {
+                       bytes[i] = Number((integer >> BigInt(i * 8)) & 0xffn)
+               }
+               return bytes
+       }
+
+       static parse256 (integer: Uint8Array<ArrayBuffer>): bigint {
+               let result = 0n
+               for (let i = 0; i < integer.byteLength; i++) {
+                       result = (result << 8n) | BigInt(integer[i])
+               }
+               return result
        }
 
        static hmac (key: Uint8Array<ArrayBuffer>, data: Uint8Array<ArrayBuffer>): Promise<ArrayBuffer> {