feat: use @noble and @scure libraries for cryptography (#2273)

Switch to using `@noble/hashes`, `@noble/curves`, `@scure/base`,
`@scure/bip32`, and `@scure/bip39`. This replaces `crypto` polyfills
(such as `crypto-browserify`), `create-hash`, `elliptic`, `hash.js`,
`bn.js` (both versions), and their many dependencies.  This also means
there are 33 less dependencies downloaded when running a fresh
`npm install` and will make the project much easier to maintain.

This reduces the bundle size by 44% (82kb minified and gzipped) over
the current 3.0 branch as well as reducing the amount of configuration
required to bundle.

Closes #1814, #1817, #2272, and #2306

Co-authored-by: Caleb Kniffen <ckniffen@ripple.com>
This commit is contained in:
Nicholas Dudfield
2023-10-10 02:45:58 +07:00
committed by Caleb Kniffen
parent 5607320ce2
commit 217b111ef2
78 changed files with 2911 additions and 2621 deletions

View File

@@ -0,0 +1,14 @@
# Don't ever lint node_modules
node_modules
# Don't lint build output
dist
# don't lint nyc coverage output
coverage
.nyc_output
# Don't lint NYC configuration
nyc.config.js
.idea

View File

@@ -0,0 +1,75 @@
module.exports = {
root: false,
parser: '@typescript-eslint/parser', // Make ESLint compatible with TypeScript
parserOptions: {
// Enable linting rules with type information from our tsconfig
tsconfigRootDir: __dirname,
project: ['./tsconfig.json', './tsconfig.eslint.json'],
sourceType: 'module', // Allow the use of imports / ES modules
ecmaFeatures: {
impliedStrict: true, // Enable global strict mode
},
},
// Specify global variables that are predefined
env: {
browser: true, // Enable browser global variables
node: true, // Enable node global variables & Node.js scoping
es2020: true, // Add all ECMAScript 2020 globals and automatically set the ecmaVersion parser option to ES2020
jest: true, // Add Jest testing global variables
},
plugins: [],
extends: ['@xrplf/eslint-config/base'],
rules: {
// ** TODO **
// all of the below are turned off for now during the migration to a
// monorepo. They need to actually be addressed!
// **
'@typescript-eslint/no-unsafe-assignment': 'off',
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-call': 'off',
'@typescript-eslint/no-magic-numbers': 'off',
'@typescript-eslint/ban-types': 'off',
'@typescript-eslint/restrict-plus-operands': 'off',
'@typescript-eslint/no-unsafe-return': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/explicit-member-accessibility': 'off',
'@typescript-eslint/promise-function-async': 'off',
'@typescript-eslint/restrict-template-expressions': 'off',
'@typescript-eslint/prefer-nullish-coalescing': 'off',
'@typescript-eslint/naming-convention': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/consistent-type-assertions': 'off',
'import/no-unused-modules': 'off',
'import/prefer-default-export': 'off',
'jsdoc/require-jsdoc': 'off',
'jsdoc/require-description': 'off',
'jsdoc/require-returns': 'off',
'jsdoc/require-description-complete-sentence': 'off',
'jsdoc/check-tag-names': 'off',
'jsdoc/check-examples': 'off', // Not implemented in eslint 8
'jsdoc/no-types': 'off',
'tsdoc/syntax': 'off',
'import/order': 'off',
'eslint-comments/require-description': 'off',
'no-shadow': 'off',
'multiline-comment-style': 'off',
'@typescript-eslint/no-require-imports': 'off',
},
overrides: [
{
files: ['test/*.test.ts'],
// tests are importing through full module name to test in an isomorphic way
rules: {
'node/no-extraneous-import': 'off',
'import/no-extraneous-dependencies': 'off',
},
},
],
}

View File

@@ -0,0 +1,12 @@
# @xrplf/isomorphic Release History
## Unreleased
Initial release providing isomorphic and tree-shakable implementations of:
- ripemd160
- sha256
- sha512
- bytesToHash
- hashToBytes
- randomBytes

View File

@@ -0,0 +1,15 @@
ISC License
Copyright (c) 2023 The XRPL developers
Permission to use, copy, modify, and distribute this software for any
purpose with or without fee is hereby granted, provided that the above
copyright notice and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

View File

@@ -0,0 +1,100 @@
# @xrplf/isomorphic
A collection of isomorphic implementations of crypto and utility functions.
Browser implementations of cryptographic functions use `@noble/hashes` and `crypto` for node .
### Hashes
All hash functions operate similarly to `@noble/hashes` and have the following properties:
- They can be called directly by providing a Uint8Array or string which will be converted into a UInt8Array via UTF-8 encoding (not hex).
- They all return a UInt8Array.
```
function hash(message: Uint8Array | string): Uint8Array;
hash(new Uint8Array([1, 3]));
hash('string') == hash(new TextEncoder().encode('string'));
```
All hash functions can be constructed via `hash.create()` method:
- The result is `Hash` subclass instance, which has `update()` and `digest()` methods.
- `digest()` finalizes the hash and makes it no longer usable
```typescript
hash
.create()
.update(new Uint8Array([1, 3]))
.digest();
```
### `@xrplf/isomorphic/ripemd160`
```typescript
import { ripemd160 } from '@xrplf/isomorphic/ripemd160';
const hashA = ripemd160('abc');
const hashB = ripemd160
.create()
.update(Uint8Array.from([1, 2, 3]))
.digest();
```
### `@xrplf/isomorphic/sha256`
```typescript
import { sha256 } from '@xrplf/isomorphic/sha256';
const hashA = sha256('abc');
const hashB = sha256
.create()
.update(Uint8Array.from([1, 2, 3]))
.digest();
```
### `@xrplf/isomorphic/sha512`
```typescript
import { sha512 } from '@xrplf/isomorphic/sha512';
const hashA = sha512('abc');
const hashB = sha512
.create()
.update(Uint8Array.from([1, 2, 3]))
.digest();
```
## Utilities
### `@xrplf/isomorphic/utils`
#### randomBytes
Create an UInt8Array of the supplied size
```typescript
import { randomBytes } from @xrplf/isomorphic/utils
console.log(randomBytes(12)) // Uint8Array(12) [95, 236, 188, 55, 208, 128, 161, 249, 171, 57, 141, 7]
```
#### bytesToHex
Convert an UInt8Array to hex.
```typescript
import { bytesToHex } from @xrplf/isomorphic/utils
console.log(bytesToHex([222, 173, 190, 239])) // "DEADBEEF"
```
#### hexToBytes
Convert hex to an UInt8Array.
```typescript
import { hexToBytes } from @xrplf/isomorphic/utils
console.log(hexToBytes('DEADBEEF')) // [222, 173, 190, 239]
```
### `@xrplf/isomorphic/ws`
// TODO: Websocket Wrapper and `ws`

View File

@@ -0,0 +1,8 @@
// Jest configuration for api
const base = require('../../jest.config.base.js')
module.exports = {
...base,
roots: [...base.roots, '<rootDir>/test'],
displayName: '@xrplf/isomorphic',
}

View File

@@ -0,0 +1,15 @@
const baseKarmaConfig = require('../../karma.config')
const webpackConfig = require('./test/webpack.config')
delete webpackConfig.entry
module.exports = function (config) {
baseKarmaConfig(config)
config.set({
base: '',
webpack: webpackConfig,
// list of files / patterns to load in the browser
files: ['test/**/*.test.ts'],
})
}

View File

@@ -0,0 +1,44 @@
{
"name": "@xrplf/isomorphic",
"version": "1.0.0",
"description": "A collection of isomorphic and tree-shakeable crypto hashes and utils for xrpl.js",
"keywords": [
"crypto",
"isomorphic",
"xrpl"
],
"scripts": {
"build": "tsc --build ./tsconfig.build.json",
"test": "npm run build && jest --verbose false --silent=false ./test/*.test.ts",
"test:browser": "npm run build && karma start ./karma.config.js",
"clean": "rm -rf ./dist ./coverage ./test/testCompiledForWeb tsconfig.build.tsbuildinfo",
"lint": "eslint . --ext .ts",
"prepublish": "npm run lint && npm test"
},
"files": [
"dist/*",
"sha256/*",
"sha512/*",
"ripemd160/*",
"src/*",
"utils/*"
],
"directories": {
"test": "test"
},
"dependencies": {
"@noble/hashes": "^1.0.0"
},
"devDependencies": {
"@types/node": "^16.18.38"
},
"repository": {
"type": "git",
"url": "git@github.com:XRPLF/xrpl.js.git"
},
"license": "ISC",
"prettier": "@xrplf/prettier-config",
"engines": {
"node": ">=16.0.0"
}
}

View File

@@ -0,0 +1,7 @@
{
"name": "@xrplf/isomorphic/ripemd160",
"private": true,
"main": "../dist/ripemd160",
"types": "../dist/ripemd160",
"browser": "../dist/ripemd160/browser.js"
}

View File

@@ -0,0 +1,7 @@
{
"name": "@xrplf/isomorphic/sha256",
"private": true,
"main": "../dist/sha256",
"types": "../dist/sha256",
"browser": "../dist/sha256/browser.js"
}

View File

@@ -0,0 +1,7 @@
{
"name": "@xrplf/isomorphic/sha512",
"private": true,
"main": "../dist/sha512",
"types": "../dist/sha512",
"browser": "../dist/sha512/browser.js"
}

View File

@@ -0,0 +1,11 @@
import { Input } from './types'
/**
* Normalize a string, number array, or Uint8Array to a string or Uint8Array.
* Both node and noble lib functions accept these types.
*
* @param input - value to normalize
*/
export default function normalizeInput(input: Input): string | Uint8Array {
return Array.isArray(input) ? new Uint8Array(input) : input
}

View File

@@ -0,0 +1,33 @@
export type ByteEncodedString = string
export type Input = Uint8Array | number[] | ByteEncodedString
/**
* A stripped down isomorphic hash inspired by node's `crypto.Hash`
*/
export interface Hash {
/**
* Updates the hash content with the given data,
*
* @param data - a byte encoded string, an array of numbers or a Uint8Array
*/
update: (data: Input) => this
/**
* Calculates the digest of all the data passed to be hashed and returns a Uint8Array
*/
digest: () => Uint8Array
}
export interface HashFn {
/**
* Produces a Uint8Array for the given hash contents
* Shorthand for calling `create`, `update`, and then `digest`
*
* @param data - a byte encoded string, an array of numbers or a Uint8Array
*/
(data: Input): Uint8Array
/**
* Creates a new empty `Hash`.
*/
create: () => Hash
}

View File

@@ -0,0 +1,32 @@
import { createHash } from 'crypto'
import { Hash, HashFn, Input } from './types'
import normalizeInput from './normalizeInput'
/**
* Wrap createHash from node to provide an interface that is isomorphic
*
* @param type - the hash name
* @param fn - {createHash} the hash factory
*/
export default function wrapCryptoCreateHash(
type: string,
fn: typeof createHash,
): HashFn {
function hashFn(input: Input): Uint8Array {
return fn(type).update(normalizeInput(input)).digest()
}
hashFn.create = (): Hash => {
const hash = fn(type)
return {
update(input: Input): Hash {
hash.update(normalizeInput(input))
return this
},
digest(): Uint8Array {
return hash.digest()
},
}
}
return hashFn
}

View File

@@ -0,0 +1,28 @@
import { CHash } from '@noble/hashes/utils'
import { Hash, HashFn, Input } from './types'
import normalizeInput from './normalizeInput'
/**
* Wrap a CHash object from @noble/hashes to provide a interface that is isomorphic
*
* @param chash - {CHash} hash function to wrap
*/
export default function wrapNoble(chash: CHash): HashFn {
function wrapped(input: Input): Uint8Array {
return chash(normalizeInput(input))
}
wrapped.create = (): Hash => {
const hash = chash.create()
return {
update(input: Input): Hash {
hash.update(normalizeInput(input))
return this
},
digest(): Uint8Array {
return hash.digest()
},
}
}
return wrapped
}

View File

@@ -0,0 +1,8 @@
import { ripemd160 as nobleImpl } from '@noble/hashes/ripemd160'
import wrapNoble from '../internal/wrapNoble'
/**
* Wrap noble-libs's ripemd160 implementation in HashFn
*/
export const ripemd160 = wrapNoble(nobleImpl)

View File

@@ -0,0 +1,7 @@
import { createHash } from 'crypto'
import wrapCryptoCreateHash from '../internal/wrapCryptoCreateHash'
/**
* Wrap node's native ripemd160 implementation in HashFn
*/
export const ripemd160 = wrapCryptoCreateHash('ripemd160', createHash)

View File

@@ -0,0 +1,8 @@
import { sha256 as nobleImpl } from '@noble/hashes/sha256'
import wrapNoble from '../internal/wrapNoble'
/**
* Wrap noble-libs's sha256 implementation in HashFn
*/
export const sha256 = wrapNoble(nobleImpl)

View File

@@ -0,0 +1,7 @@
import { createHash } from 'crypto'
import wrapCryptoCreateHash from '../internal/wrapCryptoCreateHash'
/**
* Wrap node's native sha256 implementation in HashFn
*/
export const sha256 = wrapCryptoCreateHash('sha256', createHash)

View File

@@ -0,0 +1,8 @@
import { sha512 as nobleImpl } from '@noble/hashes/sha512'
import wrapNoble from '../internal/wrapNoble'
/**
* Wrap noble-libs's sha512 implementation in HashFn
*/
export const sha512 = wrapNoble(nobleImpl)

View File

@@ -0,0 +1,7 @@
import { createHash } from 'crypto'
import wrapCryptoCreateHash from '../internal/wrapCryptoCreateHash'
/**
* Wrap node's native sha512 implementation in HashFn
*/
export const sha512 = wrapCryptoCreateHash('sha512', createHash)

View File

@@ -0,0 +1,17 @@
import {
bytesToHex as nobleBytesToHex,
hexToBytes as nobleHexToBytes,
randomBytes as nobleRandomBytes,
} from '@noble/hashes/utils'
import type { BytesToHexFn, HexToBytesFn, RandomBytesFn } from './types'
/* eslint-disable-next-line func-style -- Typed to ensure uniformity between node and browser implementations and docs */
export const bytesToHex: typeof BytesToHexFn = (bytes) => {
const hex = nobleBytesToHex(
bytes instanceof Uint8Array ? bytes : Uint8Array.from(bytes),
)
return hex.toUpperCase()
}
export const hexToBytes: typeof HexToBytesFn = nobleHexToBytes
export const randomBytes: typeof RandomBytesFn = nobleRandomBytes

View File

@@ -0,0 +1,72 @@
import { randomBytes as cryptoRandomBytes } from 'crypto'
import type { BytesToHexFn, HexToBytesFn, RandomBytesFn } from './types'
const OriginalBuffer = Symbol('OriginalBuffer')
/**
* An extended Uint8Array that incorporates a reference to the original Node.js Buffer.
*
* When converting a Node.js Buffer to a Uint8Array, there's an optimization that shares
* the memory of the original Buffer with the resulting Uint8Array instead of copying data.
* The Uint8ArrayWithReference interface is used to attach a reference to the original Buffer, ensuring
* its persistence in memory (preventing garbage collection) as long as the Uint8Array exists.
* This strategy upholds the ownership semantics of the slice of the ArrayBuffer.
*/
interface Uint8ArrayWithReference extends Uint8Array {
[OriginalBuffer]: Buffer
}
/**
* Converts a Node.js Buffer to a Uint8Array for uniform behavior with browser implementations.
*
* Choices:
* 1. Directly returning the Buffer:
* - Operation: Return Buffer as is (a Buffer *IS* an instanceof Uint8Array).
* - Pros: Most memory and performance efficient.
* - Cons: Violates strict Uint8Array typing and may lead to issues where Buffer-specific features are [ab]used.
*
* 2. Using `new Uint8Array(buffer)` or `Uint8Array.from(buffer)`:
* - Operation: Copies the buffer's data into a new Uint8Array.
* - Pros: Ensures data isolation; memory-safe.
* - Cons: Less performant due to data duplication.
*
* 3. Using buf.buffer slice:
* - Operation: Shares memory between Buffer and Uint8Array.
* - Pros: Performant.
* - Cons: Risks with shared memory and potential for invalid references.
*
* 4. Using buf.buffer slice and keeping a Buffer reference for ownership semantics:
* - Operation: Shares memory and associates the original Buffer with the resulting Uint8Array.
* - Pros: Performant while ensuring the original Buffer isn't garbage collected.
* - Cons: Risks with shared memory but mitigates potential for invalid references.
*
* The chosen method (4) prioritizes performance by sharing memory while ensuring buffer ownership.
*
* @param {Buffer} buffer - The Node.js Buffer to convert.
* @returns {Uint8Array} Resulting Uint8Array sharing the same memory as the Buffer and maintaining a reference to it.
*/
function toUint8Array(buffer: Buffer): Uint8Array {
const u8Array = new Uint8Array(
buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength,
),
) as Uint8ArrayWithReference
u8Array[OriginalBuffer] = buffer
return u8Array
}
/* eslint-disable func-style -- Typed to ensure uniformity between node and browser implementations and docs */
export const bytesToHex: typeof BytesToHexFn = (bytes) => {
const buf = Buffer.from(bytes)
return buf.toString('hex').toUpperCase()
}
export const hexToBytes: typeof HexToBytesFn = (hex) => {
return toUint8Array(Buffer.from(hex, 'hex'))
}
export const randomBytes: typeof RandomBytesFn = (size) => {
return toUint8Array(cryptoRandomBytes(size))
}
/* eslint-enable func-style */

View File

@@ -0,0 +1,20 @@
/**
* Convert a UInt8Array to hex. The returned hex will be in all caps.
*
* @param bytes - {Uint8Array} to convert to hex
*/
export declare function BytesToHexFn(bytes: Uint8Array | number[]): string
/**
* Convert hex to a Uint8Array.
*
* @param hex - {string} to convert to a Uint8Array
*/
export declare function HexToBytesFn(hex: string): Uint8Array
/**
* Create a Uint8Array of the supplied size.
*
* @param size - number of bytes to generate
*/
export declare function RandomBytesFn(size: number): Uint8Array

View File

@@ -0,0 +1,16 @@
import { ripemd160 } from '@xrplf/isomorphic/ripemd160'
import { bytesToHex } from '@xrplf/isomorphic/utils'
describe('ripemd160', () => {
it('hashes', () => {
const hashA = ripemd160('abc')
const hashB = ripemd160
.create()
.update(Uint8Array.from([97, 98, 99]))
.digest()
const expectedHash = `8EB208F7E05D987A9B044A8E98C6B087F15A0BFC`
expect(bytesToHex(hashA)).toEqual(expectedHash)
expect(bytesToHex(hashB)).toEqual(expectedHash)
})
})

View File

@@ -0,0 +1,17 @@
import { sha256 } from '@xrplf/isomorphic/sha256'
import { bytesToHex } from '@xrplf/isomorphic/utils'
describe('sha256', () => {
it('hashes', () => {
const hashA = sha256('abc')
const hashB = sha256
.create()
.update(Uint8Array.from([97, 98, 99]))
.digest()
const expectedHash =
'BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD'
expect(bytesToHex(hashA)).toEqual(expectedHash)
expect(bytesToHex(hashB)).toEqual(expectedHash)
})
})

View File

@@ -0,0 +1,17 @@
import { sha512 } from '@xrplf/isomorphic/sha512'
import { bytesToHex } from '@xrplf/isomorphic/utils'
describe('sha512', () => {
it('hashes', () => {
const hashA = sha512('abc')
const hashB = sha512
.create()
.update(Uint8Array.from([97, 98, 99]))
.digest()
const expectedHash =
'DDAF35A193617ABACC417349AE20413112E6FA4E89A97EA20A9EEEE64B55D39A2192992A274FC1A836BA3C23A3FEEBBD454D4423643CE80E2A9AC94FA54CA49F'
expect(bytesToHex(hashA)).toEqual(expectedHash)
expect(bytesToHex(hashB)).toEqual(expectedHash)
})
})

View File

@@ -0,0 +1,31 @@
import { bytesToHex, hexToBytes, randomBytes } from '../utils'
describe('utils', function () {
it('randomBytes', () => {
expect(randomBytes(16).byteLength).toEqual(16)
})
it('hexToBytes - empty', () => {
expect(hexToBytes('')).toEqual(new Uint8Array([]))
})
it('hexToBytes - zero', () => {
expect(hexToBytes('000000')).toEqual(new Uint8Array([0, 0, 0]))
})
it('hexToBytes - DEADBEEF', () => {
expect(hexToBytes('DEADBEEF')).toEqual(new Uint8Array([222, 173, 190, 239]))
})
it('bytesToHex - DEADBEEF', () => {
expect(bytesToHex([222, 173, 190, 239])).toEqual('DEADBEEF')
})
it('bytesToHex - 010203', () => {
expect(bytesToHex([1, 2, 3])).toEqual('010203')
})
it('bytesToHex - DEADBEEF (Uint8Array)', () => {
expect(bytesToHex(new Uint8Array([222, 173, 190, 239]))).toEqual('DEADBEEF')
})
})

View File

@@ -0,0 +1,9 @@
'use strict'
const { merge } = require('webpack-merge')
const { webpackForTest } = require('../../../weback.test.config')
const { getDefaultConfiguration } = require('../../../webpack.config')
module.exports = merge(
getDefaultConfiguration(),
webpackForTest('./test/index.ts', __dirname),
)

View File

@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"rootDir": "./src",
},
"include": ["./src/**/*.ts", "./src/**/*.json"],
}

View File

@@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"include": ["src/**/*.ts", "test/**/*.ts"]
}

View File

@@ -0,0 +1,18 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"target": "es6",
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"noImplicitAny": false,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"strictNullChecks": true,
"resolveJsonModule": true
},
"include": ["src/**/*.ts"]
}

View File

@@ -0,0 +1,7 @@
{
"name": "@xrplf/isomorphic/utils",
"private": true,
"main": "../dist/utils",
"types": "../dist/utils",
"browser": "../dist/utils/browser.js"
}

View File

@@ -5,6 +5,7 @@
* Bump typescript to 5.x
* Remove Node 14 support
* Remove `assert` dependency. If you were catching `AssertionError` you need to change to `Error`.
* Remove `create-hash` in favor of `@noble/hashes`
### Changes
* Execute test in a browser in addition to node

View File

@@ -10,8 +10,8 @@
"types": "dist/index.d.ts",
"license": "ISC",
"dependencies": {
"base-x": "^3.0.9",
"create-hash": "^1.1.2"
"@xrplf/isomorphic": "1.0.0",
"base-x": "^3.0.9"
},
"keywords": [
"ripple",

View File

@@ -1,4 +1,6 @@
type Sequence = number[] | Buffer | Uint8Array
// Buffer is technically not needed, as a Buffer IS a Uint8Array.
// However, for communication purposes it's listed here
export type ByteArray = number[] | Uint8Array | Buffer
/**
* Check whether two sequences (e.g. Arrays of numbers) are equal.
@@ -6,26 +8,20 @@ type Sequence = number[] | Buffer | Uint8Array
* @param arr1 - One of the arrays to compare.
* @param arr2 - The other array to compare.
*/
export function seqEqual(arr1: Sequence, arr2: Sequence): boolean {
export function arrayEqual(arr1: ByteArray, arr2: ByteArray): boolean {
if (arr1.length !== arr2.length) {
return false
}
for (let i = 0; i < arr1.length; i++) {
if (arr1[i] !== arr2[i]) {
return false
}
}
return true
return arr1.every((value, index) => value === arr2[index])
}
/**
* Check whether a value is a sequence (e.g. Array of numbers).
* Check whether a value is a scalar
*
* @param val - The value to check.
*/
function isSequence(val: Sequence | number): val is Sequence {
return typeof val !== 'number'
function isScalar(val: ByteArray | number): val is number {
return typeof val === 'number'
}
/**
@@ -39,17 +35,9 @@ function isSequence(val: Sequence | number): val is Sequence {
* @param args - Concatenate of these args into a single array.
* @returns Array of concatenated arguments
*/
export function concatArgs(...args: Array<number | Sequence>): number[] {
const ret: number[] = []
args.forEach((arg) => {
if (isSequence(arg)) {
for (const j of arg) {
ret.push(j)
}
} else {
ret.push(arg)
}
export function concatArgs(...args: Array<number | ByteArray>): number[] {
return args.flatMap((arg) => {
return isScalar(arg) ? [arg] : Array.from(arg)
})
return ret
}

View File

@@ -2,19 +2,19 @@
* Codec class
*/
import { sha256 } from '@xrplf/isomorphic/sha256'
import baseCodec = require('base-x')
import type { BaseConverter } from 'base-x'
import createHash = require('create-hash')
import { seqEqual, concatArgs } from './utils'
import { arrayEqual, concatArgs, ByteArray } from './utils'
class Codec {
private readonly _sha256: (bytes: Uint8Array) => Buffer
private readonly _sha256: (bytes: ByteArray) => Uint8Array
private readonly _alphabet: string
private readonly _codec: BaseConverter
public constructor(options: {
sha256: (bytes: Uint8Array) => Buffer
sha256: (bytes: ByteArray) => Uint8Array
alphabet: string
}) {
this._sha256 = options.sha256
@@ -29,7 +29,7 @@ class Codec {
* @param opts - Options object including the version bytes and the expected length of the data to encode.
*/
public encode(
bytes: Buffer,
bytes: ByteArray,
opts: {
versions: number[]
expectedLength: number
@@ -82,7 +82,7 @@ class Codec {
const version: number[] = Array.isArray(versions[i])
? (versions[i] as number[])
: [versions[i] as number]
if (seqEqual(versionBytes, version)) {
if (arrayEqual(versionBytes, version)) {
return {
version,
bytes: payload,
@@ -97,7 +97,7 @@ class Codec {
)
}
public encodeChecked(buffer: Buffer): string {
public encodeChecked(buffer: ByteArray): string {
const check = this._sha256(this._sha256(buffer)).slice(0, 4)
return this._encodeRaw(Buffer.from(concatArgs(buffer, check)))
}
@@ -114,7 +114,7 @@ class Codec {
}
private _encodeVersioned(
bytes: Buffer,
bytes: ByteArray,
versions: number[],
expectedLength: number,
): string {
@@ -124,10 +124,10 @@ class Codec {
' Ensure that the bytes are a Buffer.',
)
}
return this.encodeChecked(Buffer.from(concatArgs(versions, bytes)))
return this.encodeChecked(concatArgs(versions, bytes))
}
private _encodeRaw(bytes: Buffer): string {
private _encodeRaw(bytes: ByteArray): string {
return this._codec.encode(bytes)
}
/* eslint-enable max-lines-per-function */
@@ -136,10 +136,10 @@ class Codec {
return this._codec.decode(base58string)
}
private _verifyCheckSum(bytes: Buffer): boolean {
private _verifyCheckSum(bytes: ByteArray): boolean {
const computed = this._sha256(this._sha256(bytes.slice(0, -4))).slice(0, 4)
const checksum = bytes.slice(-4)
return seqEqual(computed, checksum)
return arrayEqual(computed, checksum)
}
}
@@ -161,9 +161,7 @@ const NODE_PUBLIC = 0x1c
const ED25519_SEED = [0x01, 0xe1, 0x4b]
const codecOptions = {
sha256(bytes: Uint8Array): Buffer {
return createHash('sha256').update(Buffer.from(bytes)).digest()
},
sha256,
alphabet: 'rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz',
}
@@ -174,7 +172,7 @@ export const codec = codecWithXrpAlphabet
// entropy is a Buffer of size 16
// type is 'ed25519' or 'secp256k1'
export function encodeSeed(
entropy: Buffer,
entropy: ByteArray,
type: 'ed25519' | 'secp256k1',
): string {
if (entropy.length !== 16) {
@@ -210,7 +208,7 @@ export function decodeSeed(
return codecWithXrpAlphabet.decode(seed, opts)
}
export function encodeAccountID(bytes: Buffer): string {
export function encodeAccountID(bytes: ByteArray): string {
const opts = { versions: [ACCOUNT_ID], expectedLength: 20 }
return codecWithXrpAlphabet.encode(bytes, opts)
}
@@ -237,12 +235,12 @@ export function decodeNodePublic(base58string: string): Buffer {
return codecWithXrpAlphabet.decode(base58string, opts).bytes
}
export function encodeNodePublic(bytes: Buffer): string {
export function encodeNodePublic(bytes: ByteArray): string {
const opts = { versions: [NODE_PUBLIC], expectedLength: 33 }
return codecWithXrpAlphabet.encode(bytes, opts)
}
export function encodeAccountPublic(bytes: Buffer): string {
export function encodeAccountPublic(bytes: ByteArray): string {
const opts = { versions: [ACCOUNT_PUBLIC_KEY], expectedLength: 33 }
return codecWithXrpAlphabet.encode(bytes, opts)
}

View File

@@ -1,26 +1,28 @@
import { seqEqual, concatArgs } from '../src/utils'
import { arrayEqual, concatArgs } from '../src/utils'
it('two sequences are equal', () => {
expect(seqEqual([1, 2, 3], [1, 2, 3])).toBe(true)
expect(arrayEqual([1, 2, 3], [1, 2, 3])).toBe(true)
})
it('elements must be in the same order', () => {
expect(seqEqual([3, 2, 1], [1, 2, 3])).toBe(false)
expect(arrayEqual([3, 2, 1], [1, 2, 3])).toBe(false)
})
it('sequences do not need to be the same type', () => {
expect(seqEqual(Buffer.from([1, 2, 3]), [1, 2, 3])).toBe(true)
expect(seqEqual(Buffer.from([1, 2, 3]), new Uint8Array([1, 2, 3]))).toBe(true)
expect(arrayEqual(Buffer.from([1, 2, 3]), [1, 2, 3])).toBe(true)
expect(arrayEqual(Buffer.from([1, 2, 3]), new Uint8Array([1, 2, 3]))).toBe(
true,
)
})
it('sequences with a single element', () => {
expect(seqEqual(Buffer.from([1]), [1])).toBe(true)
expect(seqEqual(Buffer.from([1]), new Uint8Array([1]))).toBe(true)
expect(arrayEqual(Buffer.from([1]), [1])).toBe(true)
expect(arrayEqual(Buffer.from([1]), new Uint8Array([1]))).toBe(true)
})
it('empty sequences', () => {
expect(seqEqual(Buffer.from([]), [])).toBe(true)
expect(seqEqual(Buffer.from([]), new Uint8Array([]))).toBe(true)
expect(arrayEqual(Buffer.from([]), [])).toBe(true)
expect(arrayEqual(Buffer.from([]), new Uint8Array([]))).toBe(true)
})
it('plain numbers are concatenated', () => {

View File

@@ -14,5 +14,13 @@
"declaration": true,
"strictNullChecks": true
},
"include": ["src/**/*.ts"]
"references": [
{
"path": "../isomorphic/tsconfig.build.json"
}
],
"files": [],
"include": [
"src/**/*.ts"
]
}

View File

@@ -6,7 +6,7 @@
* Remove Node 14 support
* Remove decimal.js and big-integer. Use `BigNumber` from `bignumber.js` instead of `Decimal` and the native `BigInt` instead of `bigInt`.
* Remove `assert` dependency. If you were catching `AssertionError` you need to change to `Error`.
* Remove `create-hash` in favor of `@noble/hashes`
## 1.11.0 (2023-11-30)
### Added

View File

@@ -12,8 +12,8 @@
},
"dependencies": {
"buffer": "6.0.3",
"@xrplf/isomorphic": "1.0.0",
"bignumber.js": "^9.0.0",
"create-hash": "^1.2.0",
"ripple-address-codec": "^4.3.1"
},
"devDependencies": {

View File

@@ -1,15 +1,15 @@
import { HashPrefix } from './hash-prefixes'
import createHash = require('create-hash')
import { Hash256 } from './types/hash-256'
import { Hash256 } from './types'
import { BytesList } from './serdes/binary-serializer'
import { Buffer } from 'buffer/'
import { sha512 } from '@xrplf/isomorphic/sha512'
/**
* Class for hashing with SHA512
* @extends BytesList So SerializedTypes can write bytes to a Sha512Half
*/
class Sha512Half extends BytesList {
private hash = createHash('sha512')
private hash = sha512.create()
/**
* Construct a new Sha512Hash and write bytes this.hash

View File

@@ -19,7 +19,10 @@
},
"references": [
{
"path": "../ripple-address-codec/tsconfig.json"
"path": "../isomorphic/tsconfig.build.json"
},
{
"path": "../ripple-address-codec/tsconfig.build.json"
}
],
"include": [

View File

@@ -26,6 +26,9 @@ module.exports = {
extends: ['@xrplf/eslint-config/base'],
rules: {
// TODO: put in @xrplf/eslint-config/base ?
'@typescript-eslint/consistent-type-imports': 'error',
// ** TODO **
// all of the below are turned off for now during the migration to a
// monorepo. They need to actually be addressed!

View File

@@ -6,6 +6,13 @@
* Remove Node 14 support
* Remove `assert` dependency. If you were catching `AssertionError` you need to change to `Error`.
* Fix `deriveKeypair` ignoring manual decoding algorithm. (Specifying algorithm=`ed25519` in `opts` now works on secrets like `sNa1...`)
* Remove `crypto` polyfills, `create-hash`, `elliptic`, `hash.js`, and their many dependencies in favor of `@noble/hashes` and `@nobel/curves`
* Remove `bytesToHex` and `hexToBytes`. They can now be found in `@xrplf/isomorphic/utils`
* `verifyTransaction` will throw an error if there is no signature
* Improved key algorithm detection. It will now throw Errors with helpful messages
### Changes
* Remove `brorand` as a dependency and use `@xrplf/isomorphic` instead.
## 1.3.1 (2023-09-27)
### Fixed

View File

@@ -18,10 +18,8 @@
"test": "test"
},
"dependencies": {
"bn.js": "^5.1.1",
"brorand": "^1.0.5",
"elliptic": "^6.5.4",
"hash.js": "^1.0.3",
"@noble/curves": "^1.0.0",
"@xrplf/isomorphic": "1.0.0",
"ripple-address-codec": "^4.3.1"
},
"keywords": [

View File

@@ -1,39 +0,0 @@
/* eslint-disable no-bitwise --
* lots of bitwise operators necessary for this */
import * as hashjs from 'hash.js'
import BigNum = require('bn.js')
export default class Sha512 {
// TODO: type of `hash`?
hash: any
constructor() {
this.hash = hashjs.sha512()
}
add(bytes) {
this.hash.update(bytes)
return this
}
addU32(i) {
return this.add([
(i >>> 24) & 0xff,
(i >>> 16) & 0xff,
(i >>> 8) & 0xff,
i & 0xff,
])
}
finish() {
return this.hash.digest()
}
first256() {
return this.finish().slice(0, 32)
}
first256BN() {
return new BigNum(this.first256())
}
}

View File

@@ -1,168 +1,106 @@
import brorand = require('brorand')
import * as hashjs from 'hash.js'
import * as elliptic from 'elliptic'
import {
decodeNodePublic,
decodeSeed,
encodeAccountID,
encodeSeed,
} from 'ripple-address-codec'
import { ripemd160 } from '@xrplf/isomorphic/ripemd160'
import { sha256 } from '@xrplf/isomorphic/sha256'
import { hexToBytes, randomBytes } from '@xrplf/isomorphic/utils'
import * as addressCodec from 'ripple-address-codec'
import { derivePrivateKey, accountPublicFromPublicGenerator } from './secp256k1'
import * as utils from './utils'
import { accountPublicFromPublicGenerator } from './signing-schemes/secp256k1/utils'
import Sha512 from './utils/Sha512'
import assert from './utils/assert'
import type { Algorithm, HexString, KeyPair, SigningScheme } from './types'
import {
getAlgorithmFromPrivateKey,
getAlgorithmFromPublicKey,
} from './utils/getAlgorithmFromKey'
const Ed25519 = elliptic.eddsa('ed25519')
const Secp256k1 = elliptic.ec('secp256k1')
import secp256k1 from './signing-schemes/secp256k1'
import ed25519 from './signing-schemes/ed25519'
const { hexToBytes } = utils
const { bytesToHex } = utils
function getSigningScheme(algorithm: Algorithm): SigningScheme {
const schemes = { 'ecdsa-secp256k1': secp256k1, ed25519 }
return schemes[algorithm]
}
function generateSeed(
options: {
entropy?: Uint8Array
algorithm?: 'ed25519' | 'ecdsa-secp256k1'
algorithm?: Algorithm
} = {},
): string {
if (!(!options.entropy || options.entropy.length >= 16)) {
throw new Error('entropy too short')
}
const entropy = options.entropy ? options.entropy.slice(0, 16) : brorand(16)
assert.ok(
!options.entropy || options.entropy.length >= 16,
'entropy too short',
)
const entropy = options.entropy
? options.entropy.slice(0, 16)
: randomBytes(16)
const type = options.algorithm === 'ed25519' ? 'ed25519' : 'secp256k1'
return addressCodec.encodeSeed(Buffer.from(entropy), type)
}
function hash(message): number[] {
return hashjs.sha512().update(message).digest().slice(0, 32)
}
const secp256k1 = {
deriveKeypair(
entropy: Uint8Array,
options?: object,
): {
privateKey: string
publicKey: string
} {
const prefix = '00'
const privateKey =
prefix + derivePrivateKey(entropy, options).toString(16, 64).toUpperCase()
const publicKey = bytesToHex(
Secp256k1.keyFromPrivate(privateKey.slice(2))
.getPublic()
.encodeCompressed(),
)
return { privateKey, publicKey }
},
sign(message, privateKey): string {
return bytesToHex(
Secp256k1.sign(hash(message), hexToBytes(privateKey), {
canonical: true,
}).toDER(),
)
},
verify(message, signature, publicKey): boolean {
return Secp256k1.verify(hash(message), signature, hexToBytes(publicKey))
},
}
const ed25519 = {
deriveKeypair(entropy: Uint8Array): {
privateKey: string
publicKey: string
} {
const prefix = 'ED'
const rawPrivateKey = hash(entropy)
const privateKey = prefix + bytesToHex(rawPrivateKey)
const publicKey =
prefix + bytesToHex(Ed25519.keyFromSecret(rawPrivateKey).pubBytes())
return { privateKey, publicKey }
},
sign(message, privateKey): string {
// caution: Ed25519.sign interprets all strings as hex, stripping
// any non-hex characters without warning
if (!Array.isArray(message)) {
throw new Error('message must be array of octets')
}
return bytesToHex(
Ed25519.sign(message, hexToBytes(privateKey).slice(1)).toBytes(),
)
},
verify(message, signature, publicKey): boolean {
return Ed25519.verify(
message,
hexToBytes(signature),
hexToBytes(publicKey).slice(1),
)
},
}
function select(algorithm): any {
const methods = { 'ecdsa-secp256k1': secp256k1, ed25519 }
return methods[algorithm]
return encodeSeed(entropy, type)
}
function deriveKeypair(
seed: string,
options?: {
algorithm?: 'ed25519' | 'ecdsa-secp256k1'
algorithm?: Algorithm
validator?: boolean
accountIndex?: number
},
): {
publicKey: string
privateKey: string
} {
const decoded = addressCodec.decodeSeed(seed)
): KeyPair {
const decoded = decodeSeed(seed)
const proposedAlgorithm = options?.algorithm ?? decoded.type
const algorithm =
proposedAlgorithm === 'ed25519' ? 'ed25519' : 'ecdsa-secp256k1'
const method = select(algorithm)
const keypair = method.deriveKeypair(decoded.bytes, options)
const messageToVerify = hash('This test message should verify.')
const signature = method.sign(messageToVerify, keypair.privateKey)
const scheme = getSigningScheme(algorithm)
const keypair = scheme.deriveKeypair(decoded.bytes, options)
const messageToVerify = Sha512.half('This test message should verify.')
const signature = scheme.sign(messageToVerify, keypair.privateKey)
/* istanbul ignore if */
if (method.verify(messageToVerify, signature, keypair.publicKey) !== true) {
if (!scheme.verify(messageToVerify, signature, keypair.publicKey)) {
throw new Error('derived keypair did not generate verifiable signature')
}
return keypair
}
function getAlgorithmFromKey(key): 'ed25519' | 'ecdsa-secp256k1' {
const bytes = hexToBytes(key)
return bytes.length === 33 && bytes[0] === 0xed
? 'ed25519'
: 'ecdsa-secp256k1'
function sign(messageHex: HexString, privateKey: HexString): HexString {
const algorithm = getAlgorithmFromPrivateKey(privateKey)
return getSigningScheme(algorithm).sign(hexToBytes(messageHex), privateKey)
}
function sign(messageHex, privateKey): string {
const algorithm = getAlgorithmFromKey(privateKey)
return select(algorithm).sign(hexToBytes(messageHex), privateKey)
}
function verify(messageHex, signature, publicKey): boolean {
const algorithm = getAlgorithmFromKey(publicKey)
return select(algorithm).verify(hexToBytes(messageHex), signature, publicKey)
}
function deriveAddressFromBytes(publicKeyBytes: Buffer): string {
return addressCodec.encodeAccountID(
utils.computePublicKeyHash(publicKeyBytes),
function verify(
messageHex: HexString,
signature: HexString,
publicKey: HexString,
): boolean {
const algorithm = getAlgorithmFromPublicKey(publicKey)
return getSigningScheme(algorithm).verify(
hexToBytes(messageHex),
signature,
publicKey,
)
}
function deriveAddress(publicKey): string {
return deriveAddressFromBytes(Buffer.from(hexToBytes(publicKey)))
function computePublicKeyHash(publicKeyBytes: Uint8Array): Uint8Array {
return ripemd160(sha256(publicKeyBytes))
}
function deriveNodeAddress(publicKey): string {
const generatorBytes = addressCodec.decodeNodePublic(publicKey)
function deriveAddressFromBytes(publicKeyBytes: Uint8Array): string {
return encodeAccountID(computePublicKeyHash(publicKeyBytes))
}
function deriveAddress(publicKey: string): string {
return deriveAddressFromBytes(hexToBytes(publicKey))
}
function deriveNodeAddress(publicKey: string): string {
const generatorBytes = decodeNodePublic(publicKey)
const accountPublicBytes = accountPublicFromPublicGenerator(generatorBytes)
return deriveAddressFromBytes(accountPublicBytes)
}
const { decodeSeed } = addressCodec
export {
generateSeed,
deriveKeypair,

View File

@@ -0,0 +1,56 @@
import { ed25519 as nobleEd25519 } from '@noble/curves/ed25519'
import { bytesToHex } from '@xrplf/isomorphic/utils'
import type { HexString, SigningScheme } from '../../types'
import assert from '../../utils/assert'
import Sha512 from '../../utils/Sha512'
const ED_PREFIX = 'ED'
const ed25519: SigningScheme = {
deriveKeypair(entropy: Uint8Array): {
privateKey: string
publicKey: string
} {
const rawPrivateKey = Sha512.half(entropy)
const privateKey = ED_PREFIX + bytesToHex(rawPrivateKey)
const publicKey =
ED_PREFIX + bytesToHex(nobleEd25519.getPublicKey(rawPrivateKey))
return { privateKey, publicKey }
},
sign(message: Uint8Array, privateKey: HexString): string {
assert.ok(message instanceof Uint8Array, 'message must be array of octets')
assert.ok(
privateKey.length === 66,
'private key must be 33 bytes including prefix',
)
return bytesToHex(nobleEd25519.sign(message, privateKey.slice(2)))
},
verify(
message: Uint8Array,
signature: HexString,
publicKey: string,
): boolean {
// Unlikely to be triggered as these are internal and guarded by getAlgorithmFromKey
assert.ok(
publicKey.length === 66,
'public key must be 33 bytes including prefix',
)
return nobleEd25519.verify(
signature,
message,
// Remove the 0xED prefix
publicKey.slice(2),
// By default, set zip215 to false for compatibility reasons.
// ZIP 215 is a stricter Ed25519 signature verification scheme.
// However, setting it to false adheres to the more commonly used
// RFC8032 / NIST186-5 standards, making it compatible with systems
// like the XRP Ledger.
{ zip215: false },
)
},
}
export default ed25519

View File

@@ -0,0 +1,64 @@
import { numberToBytesBE } from '@noble/curves/abstract/utils'
import { secp256k1 as nobleSecp256k1 } from '@noble/curves/secp256k1'
import { bytesToHex } from '@xrplf/isomorphic/utils'
import type {
DeriveKeyPairOptions,
HexString,
SigningScheme,
} from '../../types'
import { derivePrivateKey } from './utils'
import assert from '../../utils/assert'
import Sha512 from '../../utils/Sha512'
const SECP256K1_PREFIX = '00'
const secp256k1: SigningScheme = {
deriveKeypair(
entropy: Uint8Array,
options?: DeriveKeyPairOptions,
): {
privateKey: string
publicKey: string
} {
const derived = derivePrivateKey(entropy, options)
const privateKey =
SECP256K1_PREFIX + bytesToHex(numberToBytesBE(derived, 32))
const publicKey = bytesToHex(nobleSecp256k1.getPublicKey(derived, true))
return { privateKey, publicKey }
},
sign(message: Uint8Array, privateKey: HexString): string {
// Some callers pass the privateKey with the prefix, others without.
// @noble/curves will throw if the key is not exactly 32 bytes, so we
// normalize it before passing to the sign method.
assert.ok(
(privateKey.length === 66 && privateKey.startsWith(SECP256K1_PREFIX)) ||
privateKey.length === 64,
)
const normedPrivateKey =
privateKey.length === 66 ? privateKey.slice(2) : privateKey
return nobleSecp256k1
.sign(Sha512.half(message), normedPrivateKey, {
// "Canonical" signatures
lowS: true,
// Would fail tests if signatures aren't deterministic
extraEntropy: undefined,
})
.toDERHex(true)
.toUpperCase()
},
verify(
message: Uint8Array,
signature: HexString,
publicKey: HexString,
): boolean {
const decoded = nobleSecp256k1.Signature.fromDER(signature)
return nobleSecp256k1.verify(decoded, Sha512.half(message), publicKey)
},
}
export default secp256k1

View File

@@ -1,13 +1,13 @@
import * as elliptic from 'elliptic'
import { secp256k1 } from '@noble/curves/secp256k1'
import Sha512 from './Sha512'
import Sha512 from '../../utils/Sha512'
const secp256k1 = elliptic.ec('secp256k1')
const ZERO = BigInt(0)
function deriveScalar(bytes, discrim?: number) {
const order = secp256k1.curve.n
for (let i = 0; i <= 0xffffffff; i++) {
// We hash the bytes to find a 256 bit number, looping until we are sure it
function deriveScalar(bytes: Uint8Array, discrim?: number): bigint {
const order = secp256k1.CURVE.n
for (let i = 0; i <= 0xffff_ffff; i++) {
// We hash the bytes to find a 256-bit number, looping until we are sure it
// is less than the order of the curve.
const hasher = new Sha512().add(bytes)
// If the optional discriminator index was passed in, update the hash.
@@ -15,9 +15,9 @@ function deriveScalar(bytes, discrim?: number) {
hasher.addU32(discrim)
}
hasher.addU32(i)
const key = hasher.first256BN()
const key = hasher.first256BigInt()
/* istanbul ignore else */
if (key.cmpn(0) > 0 && key.cmp(order) < 0) {
if (key > ZERO && key < order) {
return key
}
}
@@ -27,7 +27,7 @@ function deriveScalar(bytes, discrim?: number) {
// How often will an (essentially) random number generated by Sha512 be larger than that?
// There's 2^32 chances (the for loop) to get a number smaller than the order,
// and it's rare that you'll even get past the first loop iteration.
// Note that in TypeScript we actually need the throw, otherwise the function signature would be BN | undefined
// Note that in TypeScript we actually need the throw, otherwise the function signature would be bigint | undefined
//
/* istanbul ignore next */
throw new Error('impossible unicorn ;)')
@@ -39,18 +39,18 @@ function deriveScalar(bytes, discrim?: number) {
* @param [opts.accountIndex=0] - The account number to generate.
* @param [opts.validator=false] - Generate root key-pair,
* as used by validators.
* @returns {bn.js} 256 bit scalar value.
* @returns {bigint} 256 bit scalar value.
*
*/
export function derivePrivateKey(
seed,
seed: Uint8Array,
opts: {
validator?: boolean
accountIndex?: number
} = {},
) {
): bigint {
const root = opts.validator
const order = secp256k1.curve.n
const order = secp256k1.CURVE.n
// This private generator represents the `root` private key, and is what's
// used by validators for signing when a keypair is generated from a seed.
@@ -59,19 +59,18 @@ export function derivePrivateKey(
// As returned by validation_create for a given seed
return privateGen
}
const publicGen = secp256k1.g.mul(privateGen)
const publicGen =
secp256k1.ProjectivePoint.BASE.multiply(privateGen).toRawBytes(true)
// A seed can generate many keypairs as a function of the seed and a uint32.
// Almost everyone just uses the first account, `0`.
const accountIndex = opts.accountIndex || 0
return deriveScalar(publicGen.encodeCompressed(), accountIndex)
.add(privateGen)
.mod(order)
return (deriveScalar(publicGen, accountIndex) + privateGen) % order
}
export function accountPublicFromPublicGenerator(publicGenBytes) {
const rootPubPoint = secp256k1.curve.decodePoint(publicGenBytes)
export function accountPublicFromPublicGenerator(publicGenBytes: Uint8Array) {
const rootPubPoint = secp256k1.ProjectivePoint.fromHex(publicGenBytes)
const scalar = deriveScalar(publicGenBytes, 0)
const point = secp256k1.g.mul(scalar)
const point = secp256k1.ProjectivePoint.BASE.multiply(scalar)
const offset = rootPubPoint.add(point)
return offset.encodeCompressed()
return offset.toRawBytes(true)
}

View File

@@ -0,0 +1,35 @@
export type HexString = string
export type Algorithm = 'ecdsa-secp256k1' | 'ed25519'
export type KeyType = 'private' | 'public'
export interface KeyPair {
privateKey: HexString
publicKey: HexString
}
export interface DeriveKeyPairOptions {
validator?: boolean
accountIndex?: number
}
export interface SigningScheme {
deriveKeypair: (
entropy: Uint8Array,
options?: DeriveKeyPairOptions,
) => KeyPair
sign: (
// deriveKeyPair creates a Sha512.half as Uint8Array so that's why it takes this
// though it /COULD/ take HexString as well
// for consistency it should be Uint8Array | HexString everywhere,
// or HexString everywhere
message: Uint8Array,
privateKey: HexString,
) => HexString
verify: (
message: Uint8Array,
signature: HexString,
publicKey: HexString,
) => boolean
}

View File

@@ -1,28 +0,0 @@
import * as hashjs from 'hash.js'
import BN = require('bn.js')
function bytesToHex(a: Iterable<number> | ArrayLike<number>): string {
return Array.from(a, (byteValue) => {
const hex = byteValue.toString(16).toUpperCase()
return hex.length > 1 ? hex : `0${hex}`
}).join('')
}
function hexToBytes(a): number[] {
if (a.length % 2 !== 0) {
throw new Error()
}
// Special-case length zero to return [].
// BN.toArray intentionally returns [0] rather than [] for length zero,
// which may make sense for BigNum data, but not for byte strings.
return a.length === 0 ? [] : new BN(a, 16).toArray(null, a.length / 2)
}
function computePublicKeyHash(publicKeyBytes: Buffer): Buffer {
const hash256 = hashjs.sha256().update(publicKeyBytes).digest()
const hash160 = hashjs.ripemd160().update(hash256).digest()
return Buffer.from(hash160)
}
export { bytesToHex, hexToBytes, computePublicKeyHash }

View File

@@ -0,0 +1,36 @@
import { sha512 } from '@xrplf/isomorphic/sha512'
import { bytesToNumberBE } from '@noble/curves/abstract/utils'
type Input = Uint8Array | number[] | string
export default class Sha512 {
// instantiate empty sha512 hash
hash = sha512.create()
static half(input: Input): Uint8Array {
return new Sha512().add(input).first256()
}
add(bytes: Input): this {
this.hash.update(bytes)
return this
}
addU32(i: number): this {
const buffer = new Uint8Array(4)
new DataView(buffer.buffer).setUint32(0, i)
return this.add(buffer)
}
finish(): Uint8Array {
return this.hash.digest()
}
first256(): Uint8Array {
return this.finish().slice(0, 32)
}
first256BigInt(): bigint {
return bytesToNumberBE(this.first256())
}
}

View File

@@ -0,0 +1,11 @@
const assertHelper: {
ok: (cond: boolean, message?: string) => asserts cond is true
} = {
ok(cond, message): asserts cond is true {
if (!cond) {
throw new Error(message)
}
},
}
export default assertHelper

View File

@@ -0,0 +1,121 @@
import type { Algorithm, HexString, KeyType } from '../types'
enum Prefix {
NONE = -1,
ED25519 = 0xed,
SECP256K1_PUB_X = 0x02,
SECP256K1_PUB_X_ODD_Y = 0x03,
SECP256K1_PUB_XY = 0x04,
SECP256K1_PRIVATE = 0x00,
}
type CompositeKey = `${KeyType}_${Prefix}_${number}`
/**
* | Curve | Type | Prefix | Length | Description | Algorithm |
* |-----------|-------------|:------:|:------:|-------------------------------------------------------|----------------:|
* | ed25519 | Private | 0xED | 33 | prefix + Uint256LE (0 < n < order ) | ed25519 |
* | ed25519 | Public | 0xED | 33 | prefix + 32 y-bytes | ed25519 |
* | secp256k1 | Public (1) | 0x02 | 33 | prefix + 32 x-bytes | ecdsa-secp256k1 |
* | secp256k1 | Public (2) | 0x03 | 33 | prefix + 32 x-bytes (y is odd) | ecdsa-secp256k1 |
* | secp256k1 | Public (3) | 0x04 | 65 | prefix + 32 x-bytes + 32 y-bytes | ecdsa-secp256k1 |
* | secp256k1 | Private (1) | None | 32 | Uint256BE (0 < n < order) | ecdsa-secp256k1 |
* | secp256k1 | Private (2) | 0x00 | 33 | prefix + Uint256BE (0 < n < order) | ecdsa-secp256k1 |
*
* Note: The 0x00 prefix for secpk256k1 Private (2) essentially 0 pads the number
* and the interpreted number is the same as 32 bytes.
*/
const KEY_TYPES: Record<CompositeKey, Algorithm> = {
[`private_${Prefix.NONE}_32`]: 'ecdsa-secp256k1',
[`private_${Prefix.SECP256K1_PRIVATE}_33`]: 'ecdsa-secp256k1',
[`private_${Prefix.ED25519}_33`]: 'ed25519',
[`public_${Prefix.ED25519}_33`]: 'ed25519',
[`public_${Prefix.SECP256K1_PUB_X}_33`]: 'ecdsa-secp256k1',
[`public_${Prefix.SECP256K1_PUB_X_ODD_Y}_33`]: 'ecdsa-secp256k1',
[`public_${Prefix.SECP256K1_PUB_XY}_65`]: 'ecdsa-secp256k1',
}
function getKeyInfo(key: HexString) {
return {
prefix: key.length < 2 ? Prefix.NONE : parseInt(key.slice(0, 2), 16),
len: key.length / 2,
}
}
function prefixRepr(prefix: Prefix): string {
return prefix === Prefix.NONE
? 'None'
: `0x${prefix.toString(16).padStart(2, '0')}`
}
function getValidFormatsTable(type: KeyType) {
// No need overkill with renderTable method
const padding = 2
const colWidth = {
algorithm: 'ecdsa-secp256k1'.length + padding,
prefix: '0x00'.length + padding,
}
return Object.entries(KEY_TYPES)
.filter(([key]) => key.startsWith(type))
.map(([key, algorithm]) => {
const [, prefix, length] = key.split('_')
const paddedAlgo = algorithm.padEnd(colWidth.algorithm)
const paddedPrefix = prefixRepr(Number(prefix)).padEnd(colWidth.prefix)
return `${paddedAlgo} - Prefix: ${paddedPrefix} Length: ${length} bytes`
})
.join('\n')
}
function keyError({
key,
type,
prefix,
len,
}: {
key: string
type: KeyType
prefix: number
len: number
}) {
const validFormats = getValidFormatsTable(type)
return `invalid_key:
Type: ${type}
Key: ${key}
Prefix: ${prefixRepr(prefix)}
Length: ${len} bytes
Acceptable ${type} formats are:
${validFormats}
`
}
/**
* Determines the algorithm associated with a given key (public/private).
*
* @param key - hexadecimal string representation of the key.
* @param type - whether expected key is public or private
* @returns Algorithm algorithm for signing/verifying
* @throws Error when key is invalid
*/
export function getAlgorithmFromKey(key: HexString, type: KeyType): Algorithm {
const { prefix, len } = getKeyInfo(key)
// Special case back compat support for no prefix
const usedPrefix = type === 'private' && len === 32 ? Prefix.NONE : prefix
const algorithm = KEY_TYPES[`${type}_${usedPrefix}_${len}`]
if (!algorithm) {
throw new Error(keyError({ key, type, len, prefix: usedPrefix }))
}
return algorithm
}
export function getAlgorithmFromPublicKey(key: HexString): Algorithm {
return getAlgorithmFromKey(key, 'public')
}
export function getAlgorithmFromPrivateKey(key: HexString): Algorithm {
return getAlgorithmFromKey(key, 'private')
}

View File

@@ -0,0 +1,81 @@
import { getAlgorithmFromKey } from '../src/utils/getAlgorithmFromKey'
function hexData(count: number) {
// for our purposes any hex will do
return 'a'.repeat(count)
}
describe('getAlgorithmFromKey', () => {
it('should return ed25519 for valid ed25519 private key', () => {
const privateKey = `ed${hexData(64)}`
expect(getAlgorithmFromKey(privateKey, 'private')).toEqual('ed25519')
})
it('should return ed25519 for valid ed25519 public key', () => {
const publicKey = `ed${hexData(64)}`
expect(getAlgorithmFromKey(publicKey, 'public')).toEqual('ed25519')
})
it('should return ecdsa-secp256k1 for valid secp256k1 private key without prefix', () => {
// 32 bytes, no prefix
const privateKey = hexData(64)
expect(getAlgorithmFromKey(privateKey, 'private')).toEqual(
'ecdsa-secp256k1',
)
})
it('should return ecdsa-secp256k1 for valid secp256k1 private key with 0x00 prefix', () => {
// 33 bytes, 0x00 prefix
const privateKey = `00${hexData(64)}`
expect(getAlgorithmFromKey(privateKey, 'private')).toEqual(
'ecdsa-secp256k1',
)
})
it('should return ecdsa-secp256k1 for valid secp256k1 public key with 0x02 prefix', () => {
// 33 bytes, 0x02 prefix
const publicKey = `02${hexData(64)}`
expect(getAlgorithmFromKey(publicKey, 'public')).toEqual('ecdsa-secp256k1')
})
it('should throw error for invalid private key format', () => {
// Invalid tag and length
const privateKey = `ff${hexData(60)}`
expect(() => getAlgorithmFromKey(privateKey, 'private'))
.toThrowErrorMatchingInlineSnapshot(`
"invalid_key:
Type: private
Key: ffaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
Prefix: 0xff
Length: 31 bytes
Acceptable private formats are:
ecdsa-secp256k1 - Prefix: None Length: 32 bytes
ecdsa-secp256k1 - Prefix: 0x00 Length: 33 bytes
ed25519 - Prefix: 0xed Length: 33 bytes
"
`)
})
it('should throw error for invalid public key format', () => {
// Invalid tag and length
const publicKey = `ff${hexData(60)}`
expect(() => getAlgorithmFromKey(publicKey, 'public'))
.toThrowErrorMatchingInlineSnapshot(`
"invalid_key:
Type: public
Key: ffaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
Prefix: 0xff
Length: 31 bytes
Acceptable public formats are:
ed25519 - Prefix: 0xed Length: 33 bytes
ecdsa-secp256k1 - Prefix: 0x02 Length: 33 bytes
ecdsa-secp256k1 - Prefix: 0x03 Length: 33 bytes
ecdsa-secp256k1 - Prefix: 0x04 Length: 65 bytes
"
`)
})
})

View File

@@ -1,29 +0,0 @@
import assert from 'assert'
import * as utils from '../src/utils'
describe('utils', function () {
it('hexToBytes - empty', () => {
assert.deepEqual(utils.hexToBytes(''), [])
})
it('hexToBytes - zero', () => {
assert.deepEqual(utils.hexToBytes('000000'), [0, 0, 0])
})
it('hexToBytes - DEADBEEF', () => {
assert.deepEqual(utils.hexToBytes('DEADBEEF'), [222, 173, 190, 239])
})
it('bytesToHex - DEADBEEF', () => {
assert.deepEqual(utils.bytesToHex([222, 173, 190, 239]), 'DEADBEEF')
})
it('bytesToHex - DEADBEEF (Uint8Array)', () => {
assert.deepEqual(
utils.bytesToHex(new Uint8Array([222, 173, 190, 239])),
'DEADBEEF',
)
})
})
export {}

View File

@@ -15,8 +15,15 @@
"strictNullChecks": true,
"resolveJsonModule": true
},
"references": [{
"path": "../ripple-address-codec/tsconfig.json"
}],
"include": ["src/**/*.ts"]
"references": [
{
"path": "../isomorphic/tsconfig.build.json"
},
{
"path": "../ripple-address-codec/tsconfig.build.json"
}
],
"include": [
"src/**/*.ts"
]
}

View File

@@ -7,6 +7,7 @@ Subscribe to [the **xrpl-announce** mailing list](https://groups.google.com/g/xr
- Add `xrpl-secret-numbers` by @WietseWind to the mono repo.
- `unpkg` and `jsdelivr` support was simplified.
- Unit tests run in a browser and node.
- Remove `brorand` as a dependency and use `@xrplf/isomorphic` instead.
### BREAKING CHANGES:
- `xrpl-secret-numbers` is now `@xrplf/secret-numbers`.

View File

@@ -29,7 +29,7 @@
"test": "test"
},
"dependencies": {
"brorand": "^1.1.0",
"@xrplf/isomorphic": "^1.0.0",
"ripple-keypairs": "^1.3.0"
},
"repository": {

View File

@@ -1,7 +1,7 @@
import brorand from "brorand";
import { randomBytes } from "@xrplf/isomorphic/utils";
function randomEntropy(): Buffer {
return Buffer.from(brorand(16));
return Buffer.from(randomBytes(16));
}
function calculateChecksum(position: number, value: number): number {

View File

@@ -7,18 +7,22 @@ Subscribe to [the **xrpl-announce** mailing list](https://groups.google.com/g/xr
### Breaking Changes
* Bump typescript to 5.x
* Remove Node 14 support
* Remove `crypto` polyfills, `create-hash`, `elliptic`, `hash.js`, and their many dependencies in favor of `@noble/hashes` and `@nobel/curves`
* Remove `bip32` and `bip39` in favor of `@scure/bip32` and `@scure/bip39`
* Remove `assert` dependency. If you were catching `AssertionError` you need to change to `Error`
* Configuring a proxy:
* Instead of passing various parameters on the `ConnectionsOptions` you know specify the `agent` parameter. This object can use be created by libraries such as `https-proxy-agent` or any that implements the `http.Agent`.
* This was changed to both support the latest `https-proxy-agent` and to remove the need to include the package in bundlers. Tests will still be done using `https-proxy-agent` and only tested in a node environment which was the only way it was previously supported anyway
* Remove `BroadcastClient` which was deprecated
* Uses `@xrplf/secret-numbers` instead of `xrpl-secret-numbers`
* Improve key algorithm detection. It will now throw Errors with helpful messages
* Move `authorizeChannel` from `wallet/signer` to `wallet/authorizeChannel` to solve a circular dependency issue.
### Bundling Changes
* Bundler configurations are much more simplified.
* removed the following polyfills:
* `assert`
* `buffer`
* `crypto-browserify`
* `https-browserify`
* `os-browserify`
* `stream-browserify`
@@ -26,7 +30,6 @@ Subscribe to [the **xrpl-announce** mailing list](https://groups.google.com/g/xr
* `url`
* `util` - previously added automatically by `webpack`
* Removed mappings for:
* `ws` to `WsWrapper`
* Excluding `https-proxy-agent`
### Changed

View File

@@ -22,10 +22,11 @@
"ws": "./dist/npm/client/WSWrapper.js"
},
"dependencies": {
"@scure/bip32": "^1.3.1",
"@scure/bip39": "^1.2.1",
"@xrplf/isomorphic": "1.0.0",
"@xrplf/secret-numbers": "^1.0.0",
"bignumber.js": "^9.0.0",
"bip32": "^2.0.6",
"bip39": "^3.0.4",
"cross-fetch": "^4.0.0",
"ripple-address-codec": "^4.3.1",
"ripple-binary-codec": "^1.11.0",

View File

@@ -0,0 +1,26 @@
import { encodeForSigningClaim } from 'ripple-binary-codec'
import { sign } from 'ripple-keypairs'
import { Wallet } from './index'
/**
* Creates a signature that can be used to redeem a specific amount of XRP from a payment channel.
*
* @param wallet - The account that will sign for this payment channel.
* @param channelId - An id for the payment channel to redeem XRP from.
* @param amount - The amount in drops to redeem.
* @returns A signature that can be used to redeem a specific amount of XRP from a payment channel.
* @category Utilities
*/
export function authorizeChannel(
wallet: Wallet,
channelId: string,
amount: string,
): string {
const signingData = encodeForSigningClaim({
channel: channelId,
amount,
})
return sign(signingData, wallet.privateKey)
}

View File

@@ -1,6 +1,8 @@
import { HDKey } from '@scure/bip32'
import { mnemonicToSeedSync, validateMnemonic } from '@scure/bip39'
import { wordlist } from '@scure/bip39/wordlists/english'
import { bytesToHex } from '@xrplf/isomorphic/utils'
import BigNumber from 'bignumber.js'
import { fromSeed } from 'bip32'
import { mnemonicToSeedSync, validateMnemonic } from 'bip39'
import {
classicAddressToXAddress,
isValidXAddress,
@@ -8,7 +10,6 @@ import {
encodeSeed,
} from 'ripple-address-codec'
import {
decode,
encodeForSigning,
encodeForMultisigning,
encode,
@@ -17,7 +18,6 @@ import {
deriveAddress,
deriveKeypair,
generateSeed,
verify,
sign,
} from 'ripple-keypairs'
@@ -29,12 +29,24 @@ import { omitBy } from '../utils/collections'
import { hashSignedTx } from '../utils/hashes/hashLedger'
import { rfc1751MnemonicToKey } from './rfc1751'
import { verifySignature } from './signer'
const DEFAULT_ALGORITHM: ECDSA = ECDSA.ed25519
const DEFAULT_DERIVATION_PATH = "m/44'/144'/0'/0/0"
function hexFromBuffer(buffer: Buffer): string {
return buffer.toString('hex').toUpperCase()
type ValidHDKey = HDKey & {
privateKey: Uint8Array
publicKey: Uint8Array
}
function validateKey(node: HDKey): asserts node is ValidHDKey {
if (!(node.privateKey instanceof Uint8Array)) {
throw new ValidationError('Unable to derive privateKey from mnemonic input')
}
if (!(node.publicKey instanceof Uint8Array)) {
throw new ValidationError('Unable to derive publicKey from mnemonic input')
}
}
/**
@@ -232,25 +244,21 @@ export class Wallet {
})
}
// Otherwise decode using bip39's mnemonic standard
if (!validateMnemonic(mnemonic)) {
if (!validateMnemonic(mnemonic, wordlist)) {
throw new ValidationError(
'Unable to parse the given mnemonic using bip39 encoding',
)
}
const seed = mnemonicToSeedSync(mnemonic)
const masterNode = fromSeed(seed)
const node = masterNode.derivePath(
const masterNode = HDKey.fromMasterSeed(seed)
const node = masterNode.derive(
opts.derivationPath ?? DEFAULT_DERIVATION_PATH,
)
if (node.privateKey === undefined) {
throw new ValidationError(
'Unable to derive privateKey from mnemonic input',
)
}
validateKey(node)
const publicKey = hexFromBuffer(node.publicKey)
const privateKey = hexFromBuffer(node.privateKey)
const publicKey = bytesToHex(node.publicKey)
const privateKey = bytesToHex(node.privateKey)
return new Wallet(publicKey, `00${privateKey}`, {
masterAddress: opts.masterAddress,
})
@@ -434,15 +442,10 @@ export class Wallet {
*
* @param signedTransaction - A signed transaction (hex string of signTransaction result) to be verified offline.
* @returns Returns true if a signedTransaction is valid.
* @throws {Error} Transaction is missing a signature, TxnSignature
*/
public verifyTransaction(signedTransaction: Transaction | string): boolean {
const tx =
typeof signedTransaction === 'string'
? decode(signedTransaction)
: signedTransaction
const messageHex: string = encodeForSigning(tx)
const signature = tx.TxnSignature
return verify(messageHex, signature, this.publicKey)
return verifySignature(signedTransaction, this.publicKey)
}
/**

View File

@@ -1,19 +1,12 @@
import { BigNumber } from 'bignumber.js'
import { decodeAccountID } from 'ripple-address-codec'
import {
decode,
encode,
encodeForSigning,
encodeForSigningClaim,
} from 'ripple-binary-codec'
import { sign as signWithKeypair, verify } from 'ripple-keypairs'
import { decode, encode, encodeForSigning } from 'ripple-binary-codec'
import { verify } from 'ripple-keypairs'
import { ValidationError } from '../errors'
import { Signer } from '../models/common'
import { Transaction, validate } from '../models/transactions'
import { Wallet } from '.'
/**
* Takes several transactions with Signer fields (in object or blob form) and creates a
* single transaction with all Signers that then gets signed and returned.
@@ -61,42 +54,40 @@ function multisign(transactions: Array<Transaction | string>): string {
return encode(getTransactionWithAllSigners(decodedTransactions))
}
/**
* Creates a signature that can be used to redeem a specific amount of XRP from a payment channel.
*
* @param wallet - The account that will sign for this payment channel.
* @param channelId - An id for the payment channel to redeem XRP from.
* @param amount - The amount in drops to redeem.
* @returns A signature that can be used to redeem a specific amount of XRP from a payment channel.
* @category Utilities
*/
function authorizeChannel(
wallet: Wallet,
channelId: string,
amount: string,
): string {
const signingData = encodeForSigningClaim({
channel: channelId,
amount,
})
return signWithKeypair(signingData, wallet.privateKey)
}
/**
* Verifies that the given transaction has a valid signature based on public-key encryption.
*
* @param tx - A transaction to verify the signature of. (Can be in object or encoded string format).
* @param [publicKey] Specific public key to use to verify. If not specified the `SigningPublicKey` of tx will be used.
* @returns Returns true if tx has a valid signature, and returns false otherwise.
* @throws Error when transaction is missing TxnSignature
* @throws Error when publicKey is not provided and transaction is missing SigningPubKey
* @category Utilities
*/
function verifySignature(tx: Transaction | string): boolean {
function verifySignature(
tx: Transaction | string,
publicKey?: string,
): boolean {
const decodedTx: Transaction = getDecodedTransaction(tx)
return verify(
encodeForSigning(decodedTx),
decodedTx.TxnSignature,
decodedTx.SigningPubKey,
)
let key = publicKey
// Need a SignedTransaction class where TxnSignature is not optional.
if (typeof decodedTx.TxnSignature !== 'string' || !decodedTx.TxnSignature) {
throw new Error('Transaction is missing a signature, TxnSignature')
}
if (!key) {
// Need a SignedTransaction class where TxnSignature is not optional.
if (
typeof decodedTx.SigningPubKey !== 'string' ||
!decodedTx.SigningPubKey
) {
throw new Error('Transaction is missing a public key, SigningPubKey')
}
key = decodedTx.SigningPubKey
}
return verify(encodeForSigning(decodedTx), decodedTx.TxnSignature, key)
}
/**
@@ -168,4 +159,4 @@ function getDecodedTransaction(txOrBlob: Transaction | string): Transaction {
return decode(txOrBlob) as unknown as Transaction
}
export { authorizeChannel, verifySignature, multisign }
export { verifySignature, multisign }

View File

@@ -23,6 +23,7 @@ export function groupBy<T>(
index,
arrayReference,
) {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- being safe for js users
;(acc[iteratee(value, index, arrayReference)] ||= []).push(value)
return acc
},

View File

@@ -1,6 +1,7 @@
import { createHash } from 'crypto'
import { sha512 } from '@xrplf/isomorphic/sha512'
import { bytesToHex, hexToBytes } from '@xrplf/isomorphic/utils'
const HASH_SIZE = 64
const HASH_BYTES = 32
/**
* Compute a sha512Half Hash of a hex string.
@@ -9,11 +10,7 @@ const HASH_SIZE = 64
* @returns Hash of hex.
*/
function sha512Half(hex: string): string {
return createHash('sha512')
.update(Buffer.from(hex, 'hex'))
.digest('hex')
.toUpperCase()
.slice(0, HASH_SIZE)
return bytesToHex(sha512(hexToBytes(hex)).slice(0, HASH_BYTES))
}
export default sha512Half

View File

@@ -0,0 +1,29 @@
import { assert } from 'chai'
import { ECDSA, Wallet } from '../../src'
import { authorizeChannel } from '../../src/Wallet/authorizeChannel'
it('authorizeChannel succeeds with secp256k1 seed', function () {
const secpWallet = Wallet.fromSeed('snGHNrPbHrdUcszeuDEigMdC1Lyyd', {
algorithm: ECDSA.secp256k1,
})
const channelId =
'5DB01B7FFED6B67E6B0414DED11E051D2EE2B7619CE0EAA6286D67A3A4D5BDB3'
const amount = '1000000'
assert.equal(
authorizeChannel(secpWallet, channelId, amount),
'304402204E7052F33DDAFAAA55C9F5B132A5E50EE95B2CF68C0902F61DFE77299BC893740220353640B951DCD24371C16868B3F91B78D38B6F3FD1E826413CDF891FA8250AAC',
)
})
it('authorizeChannel succeeds with ed25519 seed', function () {
const edWallet = Wallet.fromSeed('sEdSuqBPSQaood2DmNYVkwWTn1oQTj2')
const channelId =
'5DB01B7FFED6B67E6B0414DED11E051D2EE2B7619CE0EAA6286D67A3A4D5BDB3'
const amount = '1000000'
assert.equal(
authorizeChannel(edWallet, channelId, amount),
'7E1C217A3E4B3C107B7A356E665088B4FBA6464C48C58267BEF64975E3375EA338AE22E6714E3F5E734AE33E6B97AAD59058E1E196C1F92346FC1498D0674404',
)
})

View File

@@ -1230,6 +1230,18 @@ describe('Wallet', function () {
assert.equal(isVerified, true)
})
it('should throw an error when not signed', () => {
const wallet = new Wallet(publicKey, privateKey)
const decodedTransaction = decode(
prepared.signedTransaction,
) as unknown as Transaction
delete decodedTransaction.TxnSignature
assert.throws(() => {
wallet.verifyTransaction(decodedTransaction)
}, `Transaction is missing a signature, TxnSignature`)
})
})
describe('getXAddress', function () {

View File

@@ -1,13 +1,9 @@
import { assert } from 'chai'
import { decode, encode } from 'ripple-binary-codec'
import { ECDSA, Transaction, ValidationError } from '../../src'
import { Transaction, ValidationError } from '../../src'
import { Wallet } from '../../src/Wallet'
import {
authorizeChannel,
multisign,
verifySignature,
} from '../../src/Wallet/signer'
import { multisign, verifySignature } from '../../src/Wallet/signer'
const publicKey =
'030E58CDD076E798C84755590AAF6237CA8FAE821070A59F648B517A30DC6F589D'
@@ -187,38 +183,13 @@ describe('Signer', function () {
assert.throws(() => multisign(transactions), /forMultisign/u)
})
it('authorizeChannel succeeds with secp256k1 seed', function () {
const secpWallet = Wallet.fromSeed('snGHNrPbHrdUcszeuDEigMdC1Lyyd', {
algorithm: ECDSA.secp256k1,
})
const channelId =
'5DB01B7FFED6B67E6B0414DED11E051D2EE2B7619CE0EAA6286D67A3A4D5BDB3'
const amount = '1000000'
assert.equal(
authorizeChannel(secpWallet, channelId, amount),
'304402204E7052F33DDAFAAA55C9F5B132A5E50EE95B2CF68C0902F61DFE77299BC893740220353640B951DCD24371C16868B3F91B78D38B6F3FD1E826413CDF891FA8250AAC',
)
})
it('authorizeChannel succeeds with ed25519 seed', function () {
const edWallet = Wallet.fromSeed('sEdSuqBPSQaood2DmNYVkwWTn1oQTj2')
const channelId =
'5DB01B7FFED6B67E6B0414DED11E051D2EE2B7619CE0EAA6286D67A3A4D5BDB3'
const amount = '1000000'
assert.equal(
authorizeChannel(edWallet, channelId, amount),
'7E1C217A3E4B3C107B7A356E665088B4FBA6464C48C58267BEF64975E3375EA338AE22E6714E3F5E734AE33E6B97AAD59058E1E196C1F92346FC1498D0674404',
)
})
it('verifySignature succeeds for valid signed transaction blob', function () {
const signedTx = verifyWallet.sign(tx)
assert.isTrue(verifySignature(signedTx.tx_blob))
})
it('verify succeeds for valid signed transaction object', function () {
it('verifySignature succeeds for valid signed transaction object', function () {
const signedTx = verifyWallet.sign(tx)
assert.isTrue(
@@ -226,7 +197,7 @@ describe('Signer', function () {
)
})
it('verify throws for invalid signing key', function () {
it('verifySignature returns false for invalid signing key', function () {
const signedTx = verifyWallet.sign(tx)
const decodedTx = decode(signedTx.tx_blob) as unknown as Transaction
@@ -237,4 +208,17 @@ describe('Signer', function () {
assert.isFalse(verifySignature(decodedTx))
})
it('verifySignature throws for a missing public key', function () {
const signedTx = verifyWallet.sign(tx)
const decodedTx = decode(signedTx.tx_blob) as unknown as Transaction
// Use a different key for validation
delete decodedTx.SigningPubKey
assert.throws(() => {
verifySignature(decodedTx)
}, `Transaction is missing a public key, SigningPubKey`)
})
})

View File

@@ -6,7 +6,10 @@
"include": ["./src/**/*.ts", "./src/**/*.json"],
"references": [
{
"path": "../ripple-address-codec/tsconfig.json"
"path": "../isomorphic/tsconfig.build.json"
},
{
"path": "../ripple-address-codec/tsconfig.build.json"
},
{
"path": "../ripple-binary-codec/tsconfig.json"

View File

@@ -11,13 +11,7 @@ module.exports = merge(getDefaultConfiguration(), {
path: path.join(__dirname, 'build/'),
filename: `xrpl.default.js`,
},
plugins: [
new webpack.NormalModuleReplacementPlugin(/^ws$/, './WSWrapper'),
new webpack.IgnorePlugin({
resourceRegExp: /^\.\/wordlists\/(?!english)/,
contextRegExp: /bip39\/src$/,
}),
],
plugins: [new webpack.NormalModuleReplacementPlugin(/^ws$/, './WSWrapper')],
resolve: {
alias: {
ws: './dist/npm/client/WSWrapper.js',