Skip to content

Commit

Permalink
feature: message signing (CIP-8)
Browse files Browse the repository at this point in the history
  • Loading branch information
janmazak committed Mar 26, 2024
1 parent f36815d commit 087e899
Show file tree
Hide file tree
Showing 14 changed files with 637 additions and 2 deletions.
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).


## [7.1.0](TBD) - [TBD]

Message signing (CIP-8)

### Added

- support for message signing (CIP-8, CIP-30)


## [7.0.1](TBD) - [TBD]

### Changed
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@cardano-foundation/ledgerjs-hw-app-cardano",
"version": "7.0.1",
"version": "7.1.1",
"files": [
"dist"
],
Expand Down
30 changes: 30 additions & 0 deletions src/Ada.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import {runTests} from './interactions/runTests'
import {showAddress} from './interactions/showAddress'
import {signCVote} from './interactions/signCVote'
import {signOperationalCertificate} from './interactions/signOperationalCertificate'
import {signMessage} from './interactions/signMessage'
import {parseMessageData} from './parsing/messageData'
import {signTransaction} from './interactions/signTx'
import {parseAddress} from './parsing/address'
import {parseCVote} from './parsing/cVote'
Expand All @@ -42,6 +44,7 @@ import {parseSignTransactionRequest} from './parsing/transaction'
import type {
ParsedAddressParams,
ParsedCVote,
ParsedMessageData,
ParsedNativeScript,
ParsedOperationalCertificate,
ParsedSigningRequest,
Expand All @@ -54,6 +57,8 @@ import type {
DeviceCompatibility,
DeviceOwnedAddress,
ExtendedPublicKey,
MessageData,
SignedMessageData,
NativeScript,
NativeScriptHash,
NativeScriptHashDisplayFormat,
Expand Down Expand Up @@ -367,6 +372,18 @@ export class Ada {
return yield* signOperationalCertificate(version, request)
}

async signMessage(request: SignMessageRequest): Promise<SignMessageResponse> {
const parsedMsgData = parseMessageData(request)

return interact(this._signMessage(parsedMsgData), this._send)
}

/** @ignore */
*_signMessage(request: ParsedMessageData): Interaction<SignedMessageData> {
const version = yield* getVersion()
return yield* signMessage(version, request)
}

async signCIP36Vote(
request: SignCIP36VoteRequest,
): Promise<SignCIP36VoteResponse> {
Expand Down Expand Up @@ -500,6 +517,19 @@ export type SignOperationalCertificateRequest = OperationalCertificate
*/
export type SignOperationalCertificateResponse = OperationalCertificateSignature

/**
* Sign CIP-8 message ([[Ada.signMessage]]) request data
* @category Main
* @see [[SignMessageResponse]]
*/
export type SignMessageRequest = MessageData
/**
* Sign CIP-8 message ([[Ada.signMessage]]) response data
* @category Main
* @see [[SignMessageRequest]]
*/
export type SignMessageResponse = SignedMessageData

/**
* Sign CIP36 vote ([[Ada.signCIP36Vote]]) request data
* @category Main
Expand Down
4 changes: 4 additions & 0 deletions src/errors/invalidDataReason.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,10 @@ export enum InvalidDataReason {
OPERATIONAL_CERTIFICATE_INVALID_ISSUE_COUNTER = 'invalid operational certificate issue counter',
OPERATIONAL_CERTIFICATE_INVALID_COLD_KEY_PATH = 'invalid operational certificate cold key path',

MESSAGE_DATA_INVALID_WITNESS_PATH = 'CIP-8 message signing: invalid witness path',
MESSAGE_DATA_INVALID_MESSAGE_HEX = 'CIP-8 message signing: invalid message hex string',
MESSAGE_DATA_LONG_NON_HASHED_MSG = 'CIP-8 message signing: non-hashed message too long',

CVOTE_INVALID_VOTECAST_DATA = 'invalid votecast data for CIP36 vote',
CVOTE_INVALID_WITNESS = 'invalid witness for CIP36 vote',

Expand Down
1 change: 1 addition & 0 deletions src/interactions/common/ins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const enum INS {
SIGN_TX = 0x21,
SIGN_OPERATIONAL_CERTIFICATE = 0x22,
SIGN_CIP36_VOTE = 0x23,
SIGN_MESSAGE = 0x24,

RUN_TESTS = 0xf0,
}
4 changes: 4 additions & 0 deletions src/interactions/getVersion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ export function getCompatibility(version: Version): DeviceCompatibility {
const v7_0 =
isLedgerAppVersionAtLeast(version, 7, 0) &&
isLedgerAppVersionAtMost(version, 7, Infinity)
const v7_1 =
isLedgerAppVersionAtLeast(version, 7, 1) &&
isLedgerAppVersionAtMost(version, 7, Infinity)

const isAppXS = version.flags.isAppXS

Expand All @@ -110,6 +113,7 @@ export function getCompatibility(version: Version): DeviceCompatibility {
supportsBabbage: v5_0,
supportsCIP36Vote: v6_0,
supportsConway: v7_0,
supportsMessageSigning: v7_1,
}
}

Expand Down
44 changes: 44 additions & 0 deletions src/interactions/serialization/messageData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import type {Version} from '../../types/public'
import {MessageAddressFieldType} from '../../types/public'
import type {ParsedMessageData, Uint32_t, Uint8_t} from '../../types/internal'
import {path_to_buf, uint32_to_buf, uint8_to_buf} from '../../utils/serialize'
import {serializeAddressParams} from './addressParams'

export function serializeMessageDataInit(
version: Version,
msgData: ParsedMessageData,
): Buffer {
const msgLengthBuffer = uint32_to_buf(
(msgData.messageHex.length / 2) as Uint32_t,
)

const hashPayloadBuffer = msgData.hashPayload
? uint8_to_buf(1 as Uint8_t)
: uint8_to_buf(0 as Uint8_t)

const isAsciiBuffer = msgData.isAscii
? uint8_to_buf(1 as Uint8_t)
: uint8_to_buf(0 as Uint8_t)

const addressFieldTypeEncoding = {
[MessageAddressFieldType.ADDRESS]: 0x01,
[MessageAddressFieldType.KEY_HASH]: 0x02,
} as const
const addressFieldTypeBuffer = uint8_to_buf(
addressFieldTypeEncoding[msgData.addressFieldType] as Uint8_t,
)

const addressBuffer =
msgData.addressFieldType === MessageAddressFieldType.ADDRESS
? serializeAddressParams(msgData.address, version)
: Buffer.concat([])

return Buffer.concat([
msgLengthBuffer,
path_to_buf(msgData.signingPath),
hashPayloadBuffer,
isAsciiBuffer,
addressFieldTypeBuffer,
addressBuffer,
])
}
134 changes: 134 additions & 0 deletions src/interactions/signMessage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import {buf_to_uint32, hex_to_buf, uint32_to_buf} from '../utils/serialize'
import {DeviceVersionUnsupported, InvalidDataReason} from '../errors'
import type {ParsedMessageData} from '../types/internal'
import {
ED25519_SIGNATURE_LENGTH,
PUBLIC_KEY_LENGTH,
Uint32_t,
} from '../types/internal'
import type {SignedMessageData, Version} from '../types/public'
import {getVersionString} from '../utils'
import {INS} from './common/ins'
import type {Interaction, SendParams} from './common/types'
import {getCompatibility} from './getVersion'
import {serializeMessageDataInit} from './serialization/messageData'
import {validate} from '../utils/parse'

const send = (params: {
p1: number
p2: number
data: Buffer
expectedResponseLength?: number
}): SendParams => ({ins: INS.SIGN_MESSAGE, ...params})

export function* signMessage(
version: Version,
msgData: ParsedMessageData,
): Interaction<SignedMessageData> {
if (!getCompatibility(version).supportsMessageSigning) {
throw new DeviceVersionUnsupported(
`CIP-8 message signing not supported by Ledger app version ${getVersionString(
version,
)}.`,
)
}

const enum P1 {
STAGE_INIT = 0x01,
STAGE_CHUNK = 0x02,
STAGE_CONFIRM = 0x03,
}
const enum P2 {
UNUSED = 0x00,
}

// INIT
yield send({
p1: P1.STAGE_INIT,
p2: P2.UNUSED,
data: serializeMessageDataInit(version, msgData),
expectedResponseLength: 0,
})

// CHUNK
const MAX_CIP8_MSG_FIRST_CHUNK_ASCII_SIZE = 198
const MAX_CIP8_MSG_FIRST_CHUNK_HEX_SIZE = 99
const MAX_CIP8_MSG_HIDDEN_CHUNK_SIZE = 250

const msgBytes = hex_to_buf(msgData.messageHex)

const getChunkData = (start: number, end: number) => {
const chunk = msgBytes.slice(start, end)
return Buffer.concat([uint32_to_buf(chunk.length as Uint32_t), chunk])
}

const firstChunkSize = msgData.isAscii
? MAX_CIP8_MSG_FIRST_CHUNK_ASCII_SIZE
: MAX_CIP8_MSG_FIRST_CHUNK_HEX_SIZE

let start = 0
let end = Math.min(msgBytes.length, firstChunkSize)

yield send({
p1: P1.STAGE_CHUNK,
p2: P2.UNUSED,
data: getChunkData(start, end),
expectedResponseLength: 0,
})
start = end

if (start < msgBytes.length) {
// non-hashed messages must be processed in a single APDU
validate(
msgData.hashPayload,
InvalidDataReason.MESSAGE_DATA_LONG_NON_HASHED_MSG,
)
}
while (start < msgBytes.length) {
end = Math.min(msgBytes.length, start + MAX_CIP8_MSG_HIDDEN_CHUNK_SIZE)

yield send({
p1: P1.STAGE_CHUNK,
p2: P2.UNUSED,
data: getChunkData(start, end),
expectedResponseLength: 0,
})

start = end
}

// CONFIRM
const MAX_ADDRESS_SIZE = 128

const confirmResponse = yield send({
p1: P1.STAGE_CONFIRM,
p2: P2.UNUSED,
data: Buffer.concat([]),
expectedResponseLength:
ED25519_SIGNATURE_LENGTH + PUBLIC_KEY_LENGTH + 4 + MAX_ADDRESS_SIZE,
})

let s = 0
const signatureHex = confirmResponse
.slice(s, s + ED25519_SIGNATURE_LENGTH)
.toString('hex')
s += ED25519_SIGNATURE_LENGTH

const signingPublicKeyHex = confirmResponse
.slice(s, s + PUBLIC_KEY_LENGTH)
.toString('hex')
s += PUBLIC_KEY_LENGTH

const addressFieldSizeBuf = confirmResponse.slice(s, s + 4)
s += 4
const addressFieldSize = buf_to_uint32(addressFieldSizeBuf)
const addressFieldHex = confirmResponse
.slice(s, s + addressFieldSize)
.toString('hex')

return {
signatureHex,
signingPublicKeyHex,
addressFieldHex,
}
}
77 changes: 77 additions & 0 deletions src/parsing/messageData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import {unreachable} from '../utils/assert'
import {InvalidDataReason} from '../errors/invalidDataReason'
import type {ParsedMessageData} from '../types/internal'
import type {MessageData} from '../types/public'
import {MessageAddressFieldType} from '../types/public'
import {parseBIP32Path, parseHexString} from '../utils/parse'
import {parseAddress} from './address'

// check if a non-null-terminated buffer contains printable ASCII between 32 and 126 (inclusive)
// copied from Ledger app
function isPrintableAscii(buffer: Buffer): boolean {
for (let i = 0; i < buffer.length; i++) {
if (buffer[i] > 126) return false
if (buffer[i] < 32) return false
}

return true
}

// check if the string can be unambiguously displayed to the user
// copied from Ledger app
function isAscii(msg: string): boolean {
const buffer = Buffer.from(msg, 'hex')

// must not be empty
if (buffer.length === 0) return false

// no non-printable characters except spaces
if (!isPrintableAscii(buffer)) return false

const space = ' '.charCodeAt(0)

// no leading spaces
if (buffer[0] === space) return false

// no trailing spaces
if (buffer[buffer.length - 1] === space) return false

// only single spaces
for (let i = 0; i + 1 < buffer.length; i++) {
if (buffer[i] === space && buffer[i + 1] === space) return false
}

return true
}

export function parseMessageData(data: MessageData): ParsedMessageData {
const preferHexDisplay = data.preferHexDisplay || false
const common = {
signingPath: parseBIP32Path(
data.signingPath,
InvalidDataReason.MESSAGE_DATA_INVALID_WITNESS_PATH,
),
isAscii: isAscii(data.messageHex) && !preferHexDisplay,
hashPayload: data.hashPayload,
messageHex: parseHexString(
data.messageHex,
InvalidDataReason.MESSAGE_DATA_INVALID_MESSAGE_HEX,
),
}

switch (data.addressFieldType) {
case MessageAddressFieldType.ADDRESS:
return {
...common,
addressFieldType: MessageAddressFieldType.ADDRESS,
address: parseAddress(data.network, data.address),
}
case MessageAddressFieldType.KEY_HASH:
return {
...common,
addressFieldType: MessageAddressFieldType.KEY_HASH,
}
default:
unreachable(data)
}
}

0 comments on commit 087e899

Please sign in to comment.