Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: log error when ledger signing failed #3212

Merged
merged 13 commits into from
Aug 2, 2024
2 changes: 1 addition & 1 deletion packages/neuron-wallet/src/services/hardware/hardware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export abstract class Hardware {
i => witnessesArgs[0].lockArgs.slice(0, 42) === Multisig.hash([i.blake160])
)!.blake160
const serializedMultiSign: string = Multisig.serialize([blake160])
const witnesses = await TransactionSender.signSingleMultiSignScript(
const witnesses = TransactionSender.signSingleMultiSignScript(
path,
serializedWitnesses,
txHash,
Expand Down
31 changes: 25 additions & 6 deletions packages/neuron-wallet/src/services/hardware/ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { hd } from '@ckb-lumos/lumos'
import logger from '../../utils/logger'
import NetworksService from '../../services/networks'
import { generateRPC } from '../../utils/ckb-rpc'
import LogEncryption from '../log-encryption'

const UNCOMPRESSED_KEY_LENGTH = 130
const compressPublicKey = (key: string) => {
Expand Down Expand Up @@ -78,12 +79,30 @@ export default class Ledger extends Hardware {
context = txs.map(i => rpc.paramsFormatter.toRawTransaction(i.transaction))
}

const signature = await this.ledgerCKB!.signTransaction(
path === hd.AccountExtendedPublicKey.pathForReceiving(0) ? this.defaultPath : path,
rawTx,
witnesses,
context,
this.defaultPath
const hdPath = path === hd.AccountExtendedPublicKey.pathForReceiving(0) ? this.defaultPath : path
const signature = await this.ledgerCKB!.signTransaction(hdPath, rawTx, witnesses, context, this.defaultPath).catch(
error => {
const errorMessage = error instanceof Error ? error.message : String(error)
const encryption = LogEncryption.getInstance()
logger.error(
encryption.encrypt(
JSON.stringify([
'Ledger: failed to sign the transaction ',
errorMessage,
' HD path:',
hdPath,
' raw transaction:',
JSON.stringify(rawTx),
' witnesses:',
JSON.stringify(witnesses),
' context:',
JSON.stringify(context),
])
)
)

return Promise.reject(error)
}
)

return signature
Expand Down
127 changes: 127 additions & 0 deletions packages/neuron-wallet/src/services/log-encryption.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { randomBytes, createCipheriv, publicEncrypt, privateDecrypt, createDecipheriv } from 'node:crypto'
import logger from '../utils/logger'

export const DEFAULT_ALGORITHM = 'aes-256-cbc'

export default class LogEncryption {
/**
* We use CBC mode here to prevent pattern-discerned
* > a one-bit change in a plaintext or initialization vector (IV) affects all following ciphertext blocks
* @private
*/
private readonly algorithm = DEFAULT_ALGORITHM

/**
* A PEM-formatted RSA public key
* @private
*/
private readonly adminPublicKey: string

/**
*
* @param adminPublicKey a PEM-formatted RSA public key
*/
constructor(adminPublicKey: string) {
this.adminPublicKey = adminPublicKey
}

/**
* Encrypt a message
* @param message
*/
encrypt(message: unknown): string {
if (message == null) return ''
if (!this.adminPublicKey) return 'The admin public key does not exist, skip encrypting message'

const localLogKey = randomBytes(32)
const iv = randomBytes(16)

const cipher = createCipheriv(this.algorithm, localLogKey, iv)
const serializedMessage = typeof message === 'string' ? message : JSON.stringify(message, JSONSerializer)

const encryptedLogKey = publicEncrypt(this.adminPublicKey, localLogKey).toString('base64')
const encryptedMsg = Buffer.concat([cipher.update(serializedMessage), cipher.final()]).toString('base64')

return `[key:${encryptedLogKey}] [iv:${iv.toString('base64')}] ${encryptedMsg}`
}

private static instance: LogEncryption

static getInstance(): LogEncryption {
if (!LogEncryption.instance) {
const adminPublicKey = process.env.LOG_ENCRYPTION_PUBLIC_KEY ?? ''
if (!adminPublicKey) {
logger.warn('LOG_ENCRYPTION_PUBLIC_KEY is required to create LogEncryption instance')
}

LogEncryption.instance = new LogEncryption(adminPublicKey)
}

return LogEncryption.instance
}
}

export class LogDecryption {
private readonly adminPrivateKey: string

constructor(adminPrivateKey: string) {
this.adminPrivateKey = adminPrivateKey
}

decrypt(encryptedMessage: string): string {
const { iv, key, content } = parseMessage(encryptedMessage)

const decipher = createDecipheriv(
DEFAULT_ALGORITHM,
privateDecrypt(this.adminPrivateKey, Buffer.from(key, 'base64')),
Buffer.from(iv, 'base64')
)

return Buffer.concat([decipher.update(content, 'base64'), decipher.final()]).toString('utf-8')
}
}

/**
* Parse a message into a JSON
*
* Input:
* ```
* [key1:value2] [key2:value2] remain content
* ```
* Output:
* ```json
* {
* "key1": "value1",
* "key2": "value2",
* "content": "remain content"
* }
* ```
* @param message
*/
function parseMessage(message: string) {
const result: Record<string, string> = {}
const regex = /\[([^\]:]+):([^\]]+)]/g
let match
let lastIndex = 0

while ((match = regex.exec(message)) !== null) {
const [, key, value] = match
result[key.trim()] = value.trim()
lastIndex = regex.lastIndex
}

// Extract remaining content after the last bracket
const remainingContent = message.slice(lastIndex).trim()
if (remainingContent) {
result.content = remainingContent
}

return result
}

const JSONSerializer = (_key: string, value: any) => {
if (typeof value === 'bigint') {
return String(value) + 'n'
}
return value
}
2 changes: 1 addition & 1 deletion packages/neuron-wallet/src/services/transaction-sender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,7 @@ export default class TransactionSender {
return tx
}

public static async signSingleMultiSignScript(
public static signSingleMultiSignScript(
privateKeyOrPath: string,
witnesses: (string | WitnessArgs)[],
txHash: string,
Expand Down
19 changes: 19 additions & 0 deletions packages/neuron-wallet/tests/services/log-encryption.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import LogEncryption, { LogDecryption } from '../../src/services/log-encryption'
import { generateKeyPairSync } from 'node:crypto'

describe('Test LogEncryption', () => {
it('encrypted message should be able to decrypt', () => {
const { publicKey: adminPublicKey, privateKey: adminPrivateKey } = generateKeyPairSync('rsa', {
modulusLength: 2048,
})

const encryption = new LogEncryption(adminPublicKey.export({ format: 'pem', type: 'pkcs1' }).toString())
const decryption = new LogDecryption(adminPrivateKey.export({ format: 'pem', type: 'pkcs1' }).toString())

const message = 'hello'
const encryptedMessage = encryption.encrypt(message)
const decryptedMessage = decryption.decrypt(encryptedMessage)

expect(decryptedMessage).toBe(message)
})
})
58 changes: 58 additions & 0 deletions scripts/admin/decrypt-log/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
## Log Encryption & Decryption

## Encryption

An environment variable `LOG_ENCRYPTION_PUBLIC_KEY` must be set to enable log encryption when releasing Neuron. If the variable is not set, a placeholder message will be left in the log file.

To generate a keypair

```sh
# for ADMIN_PRIVATE_KEY
openssl genrsa -out key.pem 2048

# for LOG_ENCRYPTION_PUBLIC_KEY
openssl rsa -in key.pem -outform PEM -pubout -out public.pem
```

## Decryption

An encrypted log is following the pattern

```
[key:<encrypted_aes_password>] [iv:<random initial vector>] <content>
```

And here is a real world example

```
[2024-07-31T11:15:06.811Z] [info] [key:sWFKSuG+GzC52QlqDUcLhCvWFevSR8JjcvlIwCmB6U750UbO59zQZlQFyIUCBMH2Vamdr/ScZaF00wObzyi2BERMkKCQ9XY1ELcQSvCaAjUy4251B4MIyrnYPu4Bf+bca5U/906ko37G6dZMDNCcm2J5pm3+0TvqwXFA+BDXsAeZ7YWXpNha+WTMbQJiGj+ltbjIlodXhtqGWBhkLHgeZtfpM/OQDclOUfSP4SDva1LUvjdkQjnmUB+5dLumEAQpm7u7mroXl5eMTpVhyVtULm+QkQ4aA/D9Q/Y1dGUxl8jU2zcgL1h8Uhrb9FMpCaLyu13gGZr42HlFVU4j/VzD/g==] [iv:/jDhuN6b/qEetyHnU2WPDw==] 0+B+gimzrZgbxfxBTtznyA==
```

To decrypt the message

```sh
export LOG_MESSAGE="<log message similar the above mentioned>"
export ADMIN_PRIVATE_KEY="<pem formatted rsa private key>"
bun run.ts
```

### Advanced

If the admin key is protected, please decrypt the log key by the following script

```sh
openssl pkeyutl -pkeyopt rsa_padding_mode:oaep -passin "${MY_KEY_PASSWORD}" -decrypt -inkey private.key -in "${LOCAL_KEY}" -out "aes.key"

Check warning on line 44 in scripts/admin/decrypt-log/README.md

View workflow job for this annotation

GitHub Actions / Check spell

"passin" should be "passing".

Check warning on line 44 in scripts/admin/decrypt-log/README.md

View workflow job for this annotation

GitHub Actions / Check spell

"passin" should be "passing".
```

```js
const aesKey = "<aes.key>";
const iv = Buffer.from("<iv>", "base64");
const decipher = createDecipheriv("aes-256-cbc", aesKey, iv);

console.log(
Buffer.concat([
decipher.update("<content>", "base64"),
decipher.final(),
]).toString("utf-8")
);
```
10 changes: 10 additions & 0 deletions scripts/admin/decrypt-log/run.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { LogDecryption } from "../../../packages/neuron-wallet/src/services/log-encryption";

const ADMIN_PRIVATE_KEY = process.env
.ADMIN_PRIVATE_KEY!.split(/\r?\n/)
.map((line) => line.trim())
.join("\n");
const LOG_MESSAGE = process.env.LOG_MESSAGE!;

const decryption = new LogDecryption(ADMIN_PRIVATE_KEY);
console.log(decryption.decrypt(LOG_MESSAGE));
Keith-CY marked this conversation as resolved.
Show resolved Hide resolved
Loading