Skip to content

Commit

Permalink
feat: xrpl DR script [SIGNER-140] (#17)
Browse files Browse the repository at this point in the history
* feat: print out pubkey for XRPL tool

* feat: add xrpl-tool recovery script

* chore: update README

* chore: add README for xrpl-tool

* Update README.md

* chore: bump version v5.1.0
  • Loading branch information
notatestuser authored Dec 9, 2024
1 parent bc51c92 commit c6f83d1
Show file tree
Hide file tree
Showing 8 changed files with 601 additions and 3 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
.DS_Store
.idea
bin/*
**/node_modules

6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ After syncing up the chain (may take a while), Electrum should show your balance

Please use [TronLink](https://www.tronlink.org) to recover Tron and Tron assets. [Follow this guide](https://support.tronlink.org/hc/en-us/articles/5982285631769-How-to-Import-Your-Account-in-TronLink-Wallet-Extension) and import your vault's private key output by the tool.

### Others (XRPL, SOL, TON, TAO, etc.)
### XRP Ledger Recovery

We use a different key format than XRPL usually uses, so there is a separate script that we must use after running the DR tool. Head to [scripts/xrpl-tool](./scripts/xrpl-tool) and run `npm start` in that directory to start running the interactive tool.

### Others (SOL, TON, TAO, etc.)

Use the EdDSA key output for these chains that use EdDSA (Edwards / Ed25519) keys.
2 changes: 1 addition & 1 deletion internal/ui/ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func Banner() string {
b := "\n"
b += fmt.Sprintf("%s%s %s\n", AnsiCodes["invertOn"], AnsiCodes["bold"], AnsiCodes["reset"])
b += fmt.Sprintf("%s%s io.finnet Key Recovery Tool %s\n", AnsiCodes["invertOn"], AnsiCodes["bold"], AnsiCodes["reset"])
b += fmt.Sprintf("%s%s v5.0.1 %s\n", AnsiCodes["invertOn"], AnsiCodes["bold"], AnsiCodes["reset"])
b += fmt.Sprintf("%s%s v5.1.0 %s\n", AnsiCodes["invertOn"], AnsiCodes["bold"], AnsiCodes["reset"])
b += fmt.Sprintf("%s%s %s\n", AnsiCodes["invertOn"], AnsiCodes["bold"], AnsiCodes["reset"])
b += "\n"
return b
Expand Down
11 changes: 10 additions & 1 deletion main.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/IoFinnet/io-vault-disaster-recovery-cli/internal/ui"
"github.com/IoFinnet/io-vault-disaster-recovery-cli/internal/wif"
"github.com/charmbracelet/lipgloss"
"github.com/decred/dcrd/dcrec/edwards/v2"
)

const (
Expand Down Expand Up @@ -146,9 +147,17 @@ func main() {
fmt.Printf("\nHere is your private key for EDDSA based assets. Keep safe and do not share.\n")
fmt.Printf("Recovered EdDSA/Ed25519 private key (for XRPL, SOL, TAO, etc): %s%s%s\n",
ui.AnsiCodes["bold"], hex.EncodeToString(edSK), ui.AnsiCodes["reset"])

// load the eddsa private key in edSK and output the public key
_, edPK, err2 := edwards.PrivKeyFromScalar(edSK)
if err2 != nil {
panic("ed25519: internal error: setting scalar failed")
}
fmt.Printf("Recovered EdDSA/Ed25519 public key (for XRPL tool): %s%s%s\n",
ui.AnsiCodes["bold"], hex.EncodeToString(edPK.SerializeCompressed()), ui.AnsiCodes["reset"])

} else {
fmt.Println("\nNo EdDSA/Ed25519 private key found for this older vault.")
}

fmt.Printf("\nNote: Some wallet apps may require you to prefix hex strings with 0x to load the key.\n")
}
79 changes: 79 additions & 0 deletions scripts/xrpl-tool/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# XRP Transfer Tool

A command-line tool for transferring XRP using EdDSA keypairs. This tool
allows you to:
- Import EdDSA keypairs in the scalar format used by the DR tool
- Check wallet balances (optional)
- Create and sign XRP transfer transactions
- Broadcast transactions to the XRPL network (optional)

## Prerequisites

- Node.js 20 or higher
- npm or yarn

## Installation

1. Clone this repository or download the files
2. Install dependencies:
```bash
npm i
```

## Running the Tool

Run the script using tsx:
```bash
npx tsx main.ts
```

## Usage Flow

1. Choose network (testnet/mainnet)
2. Enter your EdDSA keypair:
- Public key (64 hex chars)
- Private key (64 hex chars)
3. View your wallet address
4. Optionally check your wallet balance
5. Create a transfer:
- Enter destination address
- Enter amount of XRP
6. Review the signed transaction
7. Choose to broadcast now or save for later

## Offline Usage

The tool can work offline except for:

- Balance checking (optional)
- Transaction broadcasting (optional)

If you choose not to broadcast immediately, you'll receive instructions for
broadcasting the signed transaction later using other tools.

## Security Notes

- Your private key is never stored or transmitted
- The tool can be used offline for the most part
- Always verify transaction details before broadcasting
- Use testnet first to familiarize yourself with the tool

## Example Keys Format

Public and private keys should be in raw hex format (64 characters each).
Example:

- Public key:
`0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF`
- Private key:
`0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF`

## Error Handling

The tool includes validation for:

- Key format and length
- XRP amount validity
- Destination address format
- Available balance (if checked)
- Network connectivity issues
254 changes: 254 additions & 0 deletions scripts/xrpl-tool/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
import { Client, decode, encode, encodeForSigning, Transaction, verifySignature, Wallet, xrpToDrops } from 'xrpl';
import readlineSync from 'readline-sync';
import { webcrypto } from 'crypto';
import * as ed from '@noble/ed25519';
import { bytesToNumberBE, bytesToNumberLE, numberToBytesBE, numberToBytesLE } from '@noble/curves/abstract/utils';
import { hashSignedTx } from 'xrpl/dist/npm/utils/hashes';
import { sha512 } from '@noble/hashes/sha512';

// polyfill for Node.js
ed.etc.sha512Sync = (...m) => sha512(ed.etc.concatBytes(...m));

const TESTNET_URL = 'wss://testnet.xrpl-labs.com';
const MAINNET_URL = 'wss://s1.ripple.com';

function validateHexKey(key, length) {
if (!key.match(/^[0-9a-fA-F]+$/)) {
return false;
}
return key.length === length * 2;
}

function validateXRPAmount(amount) {
const num = Number(amount);
return !isNaN(num) && num > 0 && num <= 100000000000;
}

function validateDestinationAddress(address) {
return address.match(/^r[1-9A-HJ-NP-Za-km-z]{25,34}$/);
}

async function main() {
console.log('XRP Transfer Tool\n');

const useMainNet = readlineSync.keyInYNStrict('Would you like to use mainnet? (No for testnet)');
const rpcUrl = !useMainNet ? TESTNET_URL : MAINNET_URL;

console.log('\nPlease enter your EdDSA keypair from the DR tool (64 bytes each, in hexadecimal):');

let publicKey, privateKey;
do {
publicKey = readlineSync.question('Public Key (64 hex chars): ');
if (!validateHexKey(publicKey, 32)) {
console.log('Invalid public key format. Must be 64 hexadecimal characters.');
}
} while (!validateHexKey(publicKey, 32));

do {
privateKey = readlineSync.question('Private Key (64 hex chars): ', {
hideEchoBack: true
});
if (!validateHexKey(privateKey, 32)) {
console.log('Invalid private key format. Must be 64 hexadecimal characters.');
}
} while (!validateHexKey(privateKey, 32));

const wallet = new Wallet('ed' + publicKey, 'ed' + privateKey);
console.log(`\nWallet address: ${wallet.address}`);

const checkBalance = readlineSync.keyInYNStrict('\nWould you like to check the wallet balance? (requires network connection)');

let xrpBalance;
if (checkBalance) {
try {
const client = new Client(rpcUrl);
await client.connect();

const accountInfo = await client.request({
command: 'account_info',
account: wallet.address,
ledger_index: 'validated'
});

xrpBalance = Number(accountInfo.result.account_data.Balance) / 1000000;
console.log(`Balance: ${xrpBalance} XRP`);

await client.disconnect();
} catch (error) {
console.error('Error fetching balance:', error.message);
console.log('Continuing in offline mode...');
}
}

const wantToTransfer = readlineSync.keyInYNStrict('\nWould you like to transfer XRP?');
if (!wantToTransfer) {
console.log('Exiting...');
return;
}

// Get and validate destination address
let destinationAddress;
do {
destinationAddress = readlineSync.question('\nEnter destination address: ');
if (!validateDestinationAddress(destinationAddress)) {
console.log('Invalid destination address format. Must be a valid XRPL address starting with "r".');
}
} while (!validateDestinationAddress(destinationAddress));

// Get and validate amount
let amount;
do {
amount = readlineSync.question('\nEnter amount of XRP to send: ');
if (!validateXRPAmount(amount)) {
console.log('Invalid amount. Must be a positive number less than 100 billion XRP.');
} else if (xrpBalance !== undefined && Number(amount) > xrpBalance) {
console.log('Amount exceeds available balance.');
continue;
}
break;
} while (true);

console.log('We must be online to fetch the XRP ledger sequence and fees data. Please ensure you have network connectivity.');
try {
const client = new Client(rpcUrl);
await client.connect();

// Prepare transaction
const tx = await client.autofill({
TransactionType: 'Payment',
Account: wallet.address,
Destination: destinationAddress,
Amount: xrpToDrops(amount),
}) as Transaction;
tx.SigningPubKey = wallet.publicKey;
if (tx.LastLedgerSequence) {
//** Adds 15 minutes worth of ledgers (assuming 4 ledgers per second) to the existing LastLedgerSequence value. */
tx.LastLedgerSequence = tx.LastLedgerSequence + 15 * 60 * 4;
}

// Sign transaction
const preImageHex = encodeForSigning(tx);
console.log('Transaction Pre-image:', encodeForSigning(tx));

const { signature } = await signWithScalar(preImageHex, privateKey);
tx.TxnSignature = signature;
console.log('Transaction Details:', tx);

const encodedTxHex = encode(tx);
const signedTxHash = hashSignedTx(encodedTxHex);
console.log('\nSigned transaction hex:');
console.log(encodedTxHex);

if (!verifySignature(encodedTxHex, tx.SigningPubKey)) throw new Error('Signature verification failed');

const wantToBroadcast = readlineSync.keyInYNStrict('\nWould you like to broadcast this transaction now?');

if (wantToBroadcast) {
console.log('\nBroadcasting transaction...');
const submit = await client.submit(encodedTxHex);
console.log(`Initial status: ${submit.result.engine_result_message}`);

if (submit.result.engine_result.includes('SUCCESS')) {
console.log('\nWaiting for validation...');
const txResponse = await client.request({
command: 'tx',
transaction: signedTxHash,
binary: false
});

if (txResponse.result.validated) {
console.log('\nTransaction validated!');
console.log(`Transaction hash: ${signedTxHash}`);
} else {
console.log('\nTransaction not yet validated. You can check status later with hash:');
console.log(signedTxHash);
}
} else {
console.log('\nTransaction failed to submit.');
console.log(`Error: ${submit.result.engine_result_message}`);
}
} else {
console.log('\nTo broadcast this transaction later:');
console.log('1. Use the XRPL CLI tool: xrpl submit <tx_blob>');
console.log('2. Or use any XRPL node\'s RPC interface with the submit method');
}

await client.disconnect();
} catch (error) {
console.error('Error:', error.data?.error_exception || error.message);
console.log('Please ensure you have network connectivity to prepare and broadcast the transaction.');
process.exit(1);
}
}

main().catch(console.error);

async function signWithScalar(messageHex: string, privateKeyHex: string): Promise<{ signature: string, publicKey: string }> {
// Remove '0x' prefix if present from inputs
messageHex = messageHex.replace(/^0x/, '');
privateKeyHex = privateKeyHex.replace(/^0x/, '');

// Convert hex message to Uint8Array
const message = Buffer.from(messageHex, 'hex');

// Convert hex private key scalar to Uint8Array
const privateKeyBytes = Buffer.from(privateKeyHex, 'hex');
const scalar = bytesToNumberBE(privateKeyBytes);
if (scalar >= ed.CURVE.n) {
throw new Error('Private key scalar must be less than curve order');
}

// Validate private key length (32 bytes for Ed25519)
if (privateKeyBytes.length !== 32) {
throw new Error('Private key must be 32 bytes');
}

// Calculate public key directly from private key scalar
const publicKey = ed.ExtendedPoint.BASE.multiply(bytesToNumberBE(privateKeyBytes)).toRawBytes();

// Note: This nonce generation differs from standard Ed25519, which uses
// the first half of SHA-512(private_key_seed). We're creating a deterministic
// nonce from the raw scalar and message instead.
const nonceInput = new Uint8Array([...privateKeyBytes, ...message]);
const nonceArrayBuffer = await webcrypto.subtle.digest('SHA-512', nonceInput);
const nonceArray = new Uint8Array(nonceArrayBuffer);

// Reduce nonce modulo L (Ed25519 curve order)
const reducedNonce = bytesToNumberLE(nonceArray) % ed.CURVE.n;

// Calculate R = k * G
const R = ed.ExtendedPoint.BASE.multiply(reducedNonce);

// Calculate S = (r + H(R,A,m) * s) mod L
const hramInput = new Uint8Array([
...R.toRawBytes(),
...publicKey,
...message
]);

const hArrayBuffer = await webcrypto.subtle.digest('SHA-512', hramInput);
const h = new Uint8Array(hArrayBuffer);
const hnum = bytesToNumberLE(h) % ed.CURVE.n;

const s = bytesToNumberBE(privateKeyBytes);
const S = (reducedNonce + (hnum * s)) % ed.CURVE.n;

// Combine R and S to form signature
const signature = new Uint8Array([
...R.toRawBytes(),
...numberToBytesLE(S, 32)
]);

// Convert outputs to hex strings
return {
signature: bytesToHex(signature),
publicKey: bytesToHex(publicKey)
};
}

// Helper function to convert Uint8Array to hex string
function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes)
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
Loading

0 comments on commit c6f83d1

Please sign in to comment.