@ledgerhq/hw-app-canton
Version:
Ledger Hardware Wallet Canton Application API
416 lines • 17.3 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.SIGNATURE_END_BYTE = exports.SIGNATURE_FRAMING_BYTE = exports.STATUS = exports.INS = exports.P2_MSG_END = exports.P2_MORE = exports.P2_FIRST = exports.P2_NONE = exports.P1_SIGN_PREPARED_TRANSACTION = exports.P1_SIGN_UNTYPED_VERSIONED_MESSAGE = exports.P1_CONFIRM = exports.P1_NON_CONFIRM = exports.CLA = void 0;
const errors_1 = require("@ledgerhq/errors");
const bip32_path_1 = __importDefault(require("bip32-path"));
exports.CLA = 0xe0;
exports.P1_NON_CONFIRM = 0x00;
exports.P1_CONFIRM = 0x01;
exports.P1_SIGN_UNTYPED_VERSIONED_MESSAGE = 0x01;
exports.P1_SIGN_PREPARED_TRANSACTION = 0x02;
exports.P2_NONE = 0x00;
exports.P2_FIRST = 0x01;
exports.P2_MORE = 0x02;
exports.P2_MSG_END = 0x04;
exports.INS = {
GET_VERSION: 0x03,
GET_APP_NAME: 0x04,
GET_ADDR: 0x05,
SIGN: 0x06,
};
exports.STATUS = {
OK: 0x9000,
USER_CANCEL: 0x6985,
};
exports.SIGNATURE_FRAMING_BYTE = 0x40;
exports.SIGNATURE_END_BYTE = 0x00;
const MAX_APDU_DATA_LENGTH = 255;
const TLV_SIGNATURE_LENGTH = 131; // bytes: [40][64B main][00][40][64B challenge]
const TLV_SIGNATURE_START_OFFSET = 1; // After framing byte
const TLV_SIGNATURE_END_OFFSET = 65; // End of main signature
const TLV_APPLICATION_SIGNATURE_START_OFFSET = 67; // After [00][40]
const TLV_APPLICATION_SIGNATURE_END_OFFSET = 131; // End of application signature
const ED25519_SIGNATURE_BYTE_LENGTH = 64; // bytes
/**
* Canton BOLOS API
*/
class Canton {
transport;
constructor(transport, scrambleKey = "canton_default_scramble_key") {
this.transport = transport;
transport.decorateAppAPIMethods(this, ["getAddress", "signTransaction", "getAppConfiguration"], scrambleKey);
}
/**
* Get a Canton address for a given BIP-32 path.
*
* @param path a path in BIP-32 format
* @param display whether to display the address on the device
* @return the address and public key
*/
async getAddress(path, display = false) {
const serializedPath = this.serializeBipPath(path);
const p1 = display ? exports.P1_CONFIRM : exports.P1_NON_CONFIRM;
const response = await this.transport.send(exports.CLA, exports.INS.GET_ADDR, p1, exports.P2_NONE, serializedPath);
this.checkTransportResponse(response);
const responseData = this.extractResponseData(response);
const { publicKey } = this.extractPublicKeyAndChainCode(responseData);
const address = `canton_${this.publicKeyToAddress(publicKey)}`;
return {
publicKey,
address,
path,
};
}
/**
* Sign a Canton transaction
* using the appropriate signing method based on transaction type.
*
* @param path a path in BIP-32 format
* @param data either prepared transaction components, untyped versioned message, or txHash string (backwards compatibility)
* @return the signature
*/
async signTransaction(path, data) {
// Backwards compatibility: handle txHash string format
if (typeof data === "string") {
return this.signTxHash(path, data);
}
if ("damlTransaction" in data) {
return this.signPreparedTransaction(path, data);
}
else {
return this.signUntypedVersionedMessage(path, data);
}
}
/**
* Sign a transaction hash (backwards compatibility)
* @private
*/
async signTxHash(path, txHash) {
// Reuse signUntypedVersionedMessage with a single transaction
return this.signUntypedVersionedMessage(path, {
transactions: [txHash],
});
}
/**
* Sign a prepared Canton transaction
* @private
*/
async signPreparedTransaction(path, components) {
this.validateUint8Array(components.damlTransaction, "DAML transaction");
this.validateUint8Array(components.metadata, "Metadata");
components.nodes.forEach((node, i) => {
this.validateUint8Array(node, `Node at index ${i}`);
});
components.inputContracts.forEach((inputContract, i) => {
this.validateUint8Array(inputContract, `Input contract at index ${i}`);
});
const damlTransactionBuffer = Buffer.from(components.damlTransaction);
const metadataBuffer = Buffer.from(components.metadata);
const nodeBuffers = components.nodes.map(node => Buffer.from(node));
const inputContractBuffers = components.inputContracts.map(inputContract => Buffer.from(inputContract));
let responseData = null;
// 1. Send the derivation path
const serializedPath = this.serializeBipPath(path);
const pathResponse = await this.transport.send(exports.CLA, exports.INS.SIGN, exports.P1_SIGN_PREPARED_TRANSACTION, exports.P2_FIRST | exports.P2_MORE, serializedPath);
this.checkTransportResponse(pathResponse);
// 2. Send the DAML transaction
await this.sendChunkedData({
ins: exports.INS.SIGN,
p1: exports.P1_SIGN_PREPARED_TRANSACTION,
payload: damlTransactionBuffer,
isFinal: false,
});
// 3. Send each node
for (const nodeBuffer of nodeBuffers) {
await this.sendChunkedData({
ins: exports.INS.SIGN,
p1: exports.P1_SIGN_PREPARED_TRANSACTION,
payload: nodeBuffer,
isFinal: false,
});
}
// 4. Send the metadata
const isFinal = inputContractBuffers.length === 0;
const result = await this.sendChunkedData({
ins: exports.INS.SIGN,
p1: exports.P1_SIGN_PREPARED_TRANSACTION,
payload: metadataBuffer,
isFinal,
});
if (isFinal) {
responseData = result;
}
// 5. Send each input contract - last one should return data
for (const [i, inputContractBuffer] of inputContractBuffers.entries()) {
const isFinal = i === inputContractBuffers.length - 1;
const result = await this.sendChunkedData({
ins: exports.INS.SIGN,
p1: exports.P1_SIGN_PREPARED_TRANSACTION,
payload: inputContractBuffer,
isFinal,
});
if (isFinal) {
responseData = result;
}
}
if (!responseData) {
throw new Error("No response data received from device");
}
return this.parseSignatureResponse(responseData);
}
/**
* Sign topology transactions for Canton onboarding
* @private
*/
async signUntypedVersionedMessage(path, data) {
const { transactions, challenge } = data;
// Pre-validate all transactions upfront
if (!transactions || transactions.length === 0) {
throw new TypeError("At least one transaction is required");
}
transactions.forEach((transaction, i) => {
if (!transaction) {
throw new TypeError(`Transaction at index ${i} is undefined or null`);
}
});
// Pre-convert all hex strings to Buffers
const transactionBuffers = transactions.map(transaction => Buffer.from(transaction, "hex"));
// 1. Send the derivation path with optional challenge
const serializedPath = this.serializeBipPath(path);
let pathData = serializedPath;
if (challenge) {
const challengeBuffer = Buffer.from(challenge, "hex");
pathData = Buffer.concat([serializedPath, challengeBuffer]);
}
const pathResponse = await this.transport.send(exports.CLA, exports.INS.SIGN, exports.P1_SIGN_UNTYPED_VERSIONED_MESSAGE, exports.P2_FIRST | exports.P2_MORE, pathData);
this.checkTransportResponse(pathResponse);
// 2. Send each transaction using consistent chunking
for (const [i, transactionBuffer] of transactionBuffers.entries()) {
const isLastTransaction = i === transactionBuffers.length - 1;
const responseData = await this.sendChunkedData({
ins: exports.INS.SIGN,
p1: exports.P1_SIGN_UNTYPED_VERSIONED_MESSAGE,
payload: transactionBuffer,
isFinal: isLastTransaction,
});
if (isLastTransaction && responseData) {
return this.parseSignatureResponse(responseData, challenge);
}
}
throw new TypeError("No transactions provided");
}
/**
* Get the app configuration.
* @return the app configuration including version
*/
async getAppConfiguration() {
const response = await this.transport.send(exports.CLA, exports.INS.GET_VERSION, exports.P1_NON_CONFIRM, exports.P2_NONE, Buffer.alloc(0));
this.checkTransportResponse(response);
const responseData = this.extractResponseData(response);
const { major, minor, patch } = this.extractVersion(responseData);
return {
version: `${major}.${minor}.${patch}`,
};
}
/**
* Validate Uint8Array with descriptive error message
* @private
*/
validateUint8Array(value, context) {
if (!value) {
throw new TypeError(`${context} is undefined or null`);
}
if (!(value instanceof Uint8Array)) {
throw new TypeError(`${context} is not a Uint8Array: ${typeof value}`);
}
}
/**
* Unified chunking strategy for sending data to device
*
* P2 flag combinations per protocol (see app-canton/doc/APDU.md):
* - P2_MORE (0x02): More chunks to come for current transaction
* - P2_MORE | P2_MSG_END (0x06): Transaction complete, more transactions expected
* - P2_MSG_END (0x04): Final transaction, end of sequence
*
* @private
*/
async sendChunkedData({ ins, p1, payload, isFinal = false, }) {
const chunks = this.createChunks(payload);
let responseData = null;
for (const [i, chunk] of chunks.entries()) {
const isLastChunk = i === chunks.length - 1;
let p2 = exports.P2_MORE;
if (isLastChunk) {
// Last chunk: P2_MSG_END if final transaction, P2_MORE | P2_MSG_END if more transactions follow
p2 = isFinal ? exports.P2_MSG_END : exports.P2_MORE | exports.P2_MSG_END;
}
const response = await this.transport.send(exports.CLA, ins, p1, p2, chunk);
if (isFinal && isLastChunk) {
this.checkTransportResponse(response);
responseData = this.extractResponseData(response);
}
else {
this.checkTransportResponse(response);
}
}
return responseData;
}
/**
* Create optimized chunks from payload
* @private
*/
createChunks(payload) {
if (payload.length <= MAX_APDU_DATA_LENGTH) {
return [payload];
}
const totalChunks = Math.ceil(payload.length / MAX_APDU_DATA_LENGTH);
const chunks = new Array(totalChunks);
let offset = 0;
for (let i = 0; i < totalChunks; i++) {
const chunkSize = Math.min(MAX_APDU_DATA_LENGTH, payload.length - offset);
chunks[i] = payload.slice(offset, offset + chunkSize);
offset += chunkSize;
}
return chunks;
}
/**
* Parse signature response - handles both TLV format (onboarding) and single signatures
* @private
*/
parseSignatureResponse(response, challenge) {
// Handle TLV (Type-Length-Value) format: [40][64B main][00][40][64B challenge] = 131 bytes
if (response.length === TLV_SIGNATURE_LENGTH &&
response.readUInt8(0) === exports.SIGNATURE_FRAMING_BYTE &&
response.readUInt8(TLV_SIGNATURE_END_OFFSET) === exports.SIGNATURE_END_BYTE &&
response.readUInt8(TLV_APPLICATION_SIGNATURE_START_OFFSET - 1) === exports.SIGNATURE_FRAMING_BYTE) {
const signature = response
.slice(TLV_SIGNATURE_START_OFFSET, TLV_SIGNATURE_END_OFFSET)
.toString("hex");
const applicationSignature = response
.slice(TLV_APPLICATION_SIGNATURE_START_OFFSET, TLV_APPLICATION_SIGNATURE_END_OFFSET)
.toString("hex");
// Include applicationSignature only if challenge was provided in the request
return {
signature,
...(challenge && { applicationSignature }),
};
}
// Handle single signature formats - check length before converting to hex
if (response.length === ED25519_SIGNATURE_BYTE_LENGTH) {
// Pure 64-byte Ed25519 signature = 128 hex chars (64 bytes)
return { signature: response.toString("hex") };
}
if (response.length === ED25519_SIGNATURE_BYTE_LENGTH + 2) {
// Canton-framed signature: [40][64B Ed25519 sig][00] = 66 bytes (132 hex chars)
const cleanedSignature = response.slice(1, -1).toString("hex");
return { signature: cleanedSignature };
}
// Fallback: return as hex string
return { signature: response.toString("hex") };
}
/**
* Check transport response for errors and throw appropriate exceptions
* @private
*/
checkTransportResponse(response) {
const statusCode = response.readUInt16BE(response.length - 2);
if (statusCode !== exports.STATUS.OK) {
throw new errors_1.TransportStatusError(statusCode);
}
}
/**
* Extract response data from transport response
* APDU responses have format: [data][status_code(2_bytes)]
* @private
*/
extractResponseData(response) {
return response.slice(0, -2);
}
/**
* Serialize a BIP-32 path string to a data buffer for Canton BOLOS
* @private
*/
serializeBipPath(pathString) {
const bipPath = bip32_path_1.default.fromString(pathString).toPathArray();
return this.serializePath(bipPath);
}
/**
* Serialize a BIP path array to a data buffer for Canton BOLOS
* @private
*/
serializePath(path) {
const data = Buffer.alloc(1 + path.length * 4);
data.writeUInt8(path.length, 0); // Write path length as first byte
path.forEach((segment, index) => {
data.writeUInt32BE(segment, 1 + index * 4); // Write each segment as 32-bit integer
});
return data;
}
/**
* Convert public key to address
* @private
*/
publicKeyToAddress(str) {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash = hash & hash;
}
return Math.abs(hash).toString(16);
}
/**
* Extract Pubkey info from APDU response
* @private
* @returns Object with publicKey and chainCode as hex strings
*/
extractPublicKeyAndChainCode(data) {
// Parse the response according to the Python unpack_get_addr_response format:
// response = pubkey_len (1) + pubkey (var) + chaincode_len (1) + chaincode (var)
// Bounds checking: ensure we have at least 1 byte for pubkey length
if (data.length < 1) {
throw new Error("Invalid response: insufficient data for public key length");
}
let offset = 0;
// Extract public key length (1 byte)
const pubkeySize = data.readUInt8(offset);
offset += 1;
// Bounds checking: ensure we have enough data for public key
if (data.length < offset + pubkeySize) {
throw new Error(`Invalid response: insufficient data for public key (expected ${pubkeySize} bytes)`);
}
// Extract public key
const pubKey = data.subarray(offset, offset + pubkeySize);
offset += pubkeySize;
// Bounds checking: ensure we have at least 1 byte for chaincode length
if (data.length < offset + 1) {
throw new Error("Invalid response: insufficient data for chaincode length");
}
// Extract chain code length (1 byte)
const chainCodeSize = data.readUInt8(offset);
offset += 1;
// Bounds checking: ensure we have enough data for chaincode
if (data.length < offset + chainCodeSize) {
throw new Error(`Invalid response: insufficient data for chaincode (expected ${chainCodeSize} bytes)`);
}
// Extract chain code
const chainCode = data.subarray(offset, offset + chainCodeSize);
return { publicKey: pubKey.toString("hex"), chainCode: chainCode.toString("hex") };
}
/**
* Extract AppVersion from APDU response
* @private
*/
extractVersion(data) {
return {
major: data.readUInt8(0),
minor: data.readUInt8(1),
patch: data.readUInt8(2),
};
}
}
exports.default = Canton;
//# sourceMappingURL=Canton.js.map