UNPKG

@ledgerhq/hw-app-canton

Version:
416 lines 17.3 kB
"use strict"; 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