diff --git a/packages/sdk/src/__test__/unit/encryptedResponses.test.ts b/packages/sdk/src/__test__/unit/encryptedResponses.test.ts new file mode 100644 index 00000000..b02990e6 --- /dev/null +++ b/packages/sdk/src/__test__/unit/encryptedResponses.test.ts @@ -0,0 +1,105 @@ +import { + LatticeSecureEncryptedRequestType, + ProtocolConstants, + encryptedSecureRequest, +} from '../../protocol'; +import { aes256_encrypt, checksum, getP256KeyPair } from '../../util'; +import { request } from '../../shared/functions'; + +vi.mock('../../shared/functions', async () => { + const actual = await vi.importActual( + '../../shared/functions', + ); + return { + ...actual, + request: vi.fn(), + }; +}); + +const requestMock = vi.mocked(request); + +const buildEncryptedResponse = ({ + sharedSecret, + requestType, + status, + responsePub, +}: { + sharedSecret: Buffer; + requestType: LatticeSecureEncryptedRequestType; + status: number; + responsePub: Buffer; +}) => { + const responseDataSize = + ProtocolConstants.msgSizes.secure.data.response.encrypted[requestType]; + const decrypted = Buffer.alloc( + ProtocolConstants.msgSizes.secure.data.response.encrypted.encryptedData, + ); + responsePub.copy(decrypted, 0); + decrypted[responsePub.length] = status; + const checksumOffset = responsePub.length + responseDataSize; + const cs = checksum(decrypted.slice(0, checksumOffset)); + decrypted.writeUInt32BE(cs, checksumOffset); + return aes256_encrypt(decrypted, sharedSecret); +}; + +describe('encryptedSecureRequest response sizes', () => { + const requestType = LatticeSecureEncryptedRequestType.event; + const sharedSecret = Buffer.alloc(32, 7); + const ephemeralPub = getP256KeyPair(Buffer.alloc(32, 3)); + const requestData = Buffer.alloc( + ProtocolConstants.msgSizes.secure.data.request.encrypted[requestType], + ); + const responseKey = getP256KeyPair(Buffer.alloc(32, 9)); + const responsePub = Buffer.from( + responseKey.getPublic().encode('hex', false), + 'hex', + ); + + beforeEach(() => { + requestMock.mockReset(); + }); + + it('accepts compact encrypted response size', async () => { + const encryptedResponse = buildEncryptedResponse({ + sharedSecret, + requestType, + status: 0, + responsePub, + }); + requestMock.mockResolvedValueOnce(encryptedResponse); + + const result = await encryptedSecureRequest({ + data: requestData, + requestType, + sharedSecret, + ephemeralPub, + url: 'http://example.test', + }); + + expect(result.decryptedData[0]).toBe(0); + }); + + it('accepts legacy encrypted response size', async () => { + const encryptedResponse = buildEncryptedResponse({ + sharedSecret, + requestType, + status: 0, + responsePub, + }); + const legacyResponse = Buffer.concat([ + encryptedResponse, + Buffer.alloc(encryptedResponse.length), + ]); + requestMock.mockResolvedValueOnce(legacyResponse); + + const result = await encryptedSecureRequest({ + data: requestData, + requestType, + sharedSecret, + ephemeralPub, + url: 'http://example.test', + }); + + expect(result.decryptedData[0]).toBe(0); + }); +}); diff --git a/packages/sdk/src/__test__/unit/sendEvent.test.ts b/packages/sdk/src/__test__/unit/sendEvent.test.ts new file mode 100644 index 00000000..a67d60e4 --- /dev/null +++ b/packages/sdk/src/__test__/unit/sendEvent.test.ts @@ -0,0 +1,60 @@ +import { __private__ as sendEventPrivate } from '../../functions/sendEvent'; + +const { encodeEventPayload } = sendEventPrivate; + +describe('sendEvent encoding', () => { + it('encodes and pads message payload', () => { + const payload = encodeEventPayload({ + eventType: 1, + eventId: '00000000-0000-0000-0000-000000000000', + message: 'hi', + }); + expect(payload.length).toBe(1722); + expect(payload[0]).toBe(1); + expect(payload.slice(1, 17).every((b) => b === 0)).toBe(true); + expect(payload.readUInt16LE(17)).toBe(2); + expect(payload.slice(19, 21).toString('utf8')).toBe('hi'); + expect(payload.slice(21).every((b) => b === 0)).toBe(true); + }); + + it('throws on empty message', () => { + expect(() => + encodeEventPayload({ + eventType: 1, + eventId: '00000000-0000-0000-0000-000000000000', + message: '', + }), + ).toThrow(/must not be empty/i); + }); + + it('throws when message is too long', () => { + const longMsg = 'a'.repeat(1704); + expect(() => + encodeEventPayload({ + eventType: 1, + eventId: '00000000-0000-0000-0000-000000000000', + message: longMsg, + }), + ).toThrow(/too long/i); + }); + + it('throws on invalid eventId', () => { + expect(() => + encodeEventPayload({ + eventType: 1, + eventId: 'not-a-uuid', + message: 'hi', + }), + ).toThrow(/eventId/i); + }); + + it('throws on invalid eventType', () => { + expect(() => + encodeEventPayload({ + eventType: 256, + eventId: '00000000-0000-0000-0000-000000000000', + message: 'hi', + }), + ).toThrow(/eventType/i); + }); +}); diff --git a/packages/sdk/src/__test__/unit/solana.test.ts b/packages/sdk/src/__test__/unit/solana.test.ts new file mode 100644 index 00000000..b1feb0e1 --- /dev/null +++ b/packages/sdk/src/__test__/unit/solana.test.ts @@ -0,0 +1,214 @@ +import { decodeTransaction, toEd25519Bytes } from '../../calldata/solana'; + +describe('Solana utilities', () => { + describe('decodeTransaction', () => { + // Generate a valid base64 transaction of minimum valid size (100 bytes) + const createValidBase64Tx = (length: number): string => { + const bytes = new Uint8Array(length); + for (let i = 0; i < length; i++) { + bytes[i] = i % 256; + } + return Buffer.from(bytes).toString('base64'); + }; + + test('should decode valid base64 transaction within size range', () => { + const validTx = createValidBase64Tx(200); + const result = decodeTransaction(validTx); + + expect(result.encoding).toBe('base64'); + expect(result.bytes).toBeInstanceOf(Uint8Array); + expect(result.bytes.length).toBe(200); + }); + + test('should decode transaction at minimum valid size (100 bytes)', () => { + const validTx = createValidBase64Tx(100); + const result = decodeTransaction(validTx); + + expect(result.encoding).toBe('base64'); + expect(result.bytes.length).toBe(100); + }); + + test('should decode transaction at maximum valid size (1232 bytes)', () => { + const validTx = createValidBase64Tx(1232); + const result = decodeTransaction(validTx); + + expect(result.encoding).toBe('base64'); + expect(result.bytes.length).toBe(1232); + }); + + test('should reject transaction below minimum size (99 bytes)', () => { + const tooSmall = createValidBase64Tx(99); + expect(() => decodeTransaction(tooSmall)).toThrow( + /outside valid Solana range/, + ); + }); + + test('should reject transaction above maximum size (1233 bytes)', () => { + const tooLarge = createValidBase64Tx(1233); + expect(() => decodeTransaction(tooLarge)).toThrow( + /outside valid Solana range/, + ); + }); + + test('should reject base58-encoded string (common Solana RPC format)', () => { + // This is a base58 string (uses only base58 alphabet: 123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz) + // Note: base58 excludes 0, O, I, l which are in base64 + // This should NOT silently decode as garbage base64 + const base58String = '4vJ9JU1bJJE96FWSJKvHsmmFADCg4gpZQff4P3bkLKi'; + + expect(() => decodeTransaction(base58String)).toThrow(/not valid base64/); + }); + + test('should reject base58 transaction that would decode to valid length if treated as base64', () => { + // Create a string with length not divisible by 4 (invalid base64 padding) + // This simulates a base58 string that would fail the round-trip check + const fakeBase58 = `${'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijk123456789'.repeat(5)}ABC`; + + expect(() => decodeTransaction(fakeBase58)).toThrow(/not valid base64/); + }); + + test('should reject strings with invalid base64 characters', () => { + // Contains characters not in base64 alphabet ($ and @) + const invalidChars = 'SGVsbG8$V29ybGQ@'; + expect(() => decodeTransaction(invalidChars)).toThrow(/not valid base64/); + }); + + test('should reject strings with wrong padding', () => { + // Valid base64 should have proper = padding + const wrongPadding = 'SGVsbG8gV29ybGQ'; + expect(() => decodeTransaction(wrongPadding)).toThrow(/not valid base64/); + }); + + test('should reject empty string', () => { + expect(() => decodeTransaction('')).toThrow(); + }); + }); + + describe('toEd25519Bytes', () => { + test('should return 32-byte array for valid 32-byte Uint8Array', () => { + const validKey = new Uint8Array(32).fill(42); + const result = toEd25519Bytes(validKey); + + expect(result).not.toBeNull(); + expect(result?.length).toBe(32); + expect(result?.[0]).toBe(42); + }); + + test('should truncate 64-byte Uint8Array to 32 bytes', () => { + const longKey = new Uint8Array(64); + for (let i = 0; i < 64; i++) { + longKey[i] = i; + } + const result = toEd25519Bytes(longKey); + + expect(result).not.toBeNull(); + expect(result?.length).toBe(32); + expect(result?.[31]).toBe(31); + }); + + test('should return null for Uint8Array shorter than 32 bytes', () => { + const shortKey = new Uint8Array(16).fill(1); + const result = toEd25519Bytes(shortKey); + + expect(result).toBeNull(); + }); + + test('should return null for 31-byte Uint8Array (off by one)', () => { + const almostValid = new Uint8Array(31).fill(1); + const result = toEd25519Bytes(almostValid); + + expect(result).toBeNull(); + }); + + test('should return null for empty Uint8Array', () => { + const empty = new Uint8Array(0); + const result = toEd25519Bytes(empty); + + expect(result).toBeNull(); + }); + + test('should return 32-byte array for valid 32-byte Buffer', () => { + const validBuffer = Buffer.alloc(32, 0xab); + const result = toEd25519Bytes(validBuffer); + + expect(result).not.toBeNull(); + expect(result?.length).toBe(32); + expect(result?.[0]).toBe(0xab); + }); + + test('should truncate longer Buffer to 32 bytes', () => { + const longBuffer = Buffer.alloc(48, 0xcd); + const result = toEd25519Bytes(longBuffer); + + expect(result).not.toBeNull(); + expect(result?.length).toBe(32); + }); + + test('should return null for Buffer shorter than 32 bytes', () => { + const shortBuffer = Buffer.alloc(20, 0xef); + const result = toEd25519Bytes(shortBuffer); + + expect(result).toBeNull(); + }); + + test('should parse valid 64-character hex string (32 bytes)', () => { + const hex64 = 'a'.repeat(64); + const result = toEd25519Bytes(hex64); + + expect(result).not.toBeNull(); + expect(result?.length).toBe(32); + expect(result?.[0]).toBe(0xaa); + }); + + test('should parse valid 0x-prefixed hex string', () => { + const hex = `0x${'b'.repeat(64)}`; + const result = toEd25519Bytes(hex); + + expect(result).not.toBeNull(); + expect(result?.length).toBe(32); + expect(result?.[0]).toBe(0xbb); + }); + + test('should truncate longer hex string to 32 bytes', () => { + const longHex = `0x${'c'.repeat(128)}`; + const result = toEd25519Bytes(longHex); + + expect(result).not.toBeNull(); + expect(result?.length).toBe(32); + }); + + test('should return null for hex string shorter than 64 chars (32 bytes)', () => { + const shortHex = 'd'.repeat(62); + const result = toEd25519Bytes(shortHex); + + expect(result).toBeNull(); + }); + + test('should return null for non-hex string', () => { + const notHex = 'not-a-valid-hex-string-at-all-xyz'; + const result = toEd25519Bytes(notHex); + + expect(result).toBeNull(); + }); + + test('should return null for null input', () => { + const result = toEd25519Bytes(null); + expect(result).toBeNull(); + }); + + test('should return null for undefined input', () => { + const result = toEd25519Bytes(undefined); + expect(result).toBeNull(); + }); + + test('should return null for number input', () => { + const result = toEd25519Bytes(12345); + expect(result).toBeNull(); + }); + + test('should return null for object input', () => { + const result = toEd25519Bytes({ length: 32 }); + expect(result).toBeNull(); + }); + }); +}); diff --git a/packages/sdk/src/calldata/index.ts b/packages/sdk/src/calldata/index.ts index c5d46b8e..1c57ec3d 100644 --- a/packages/sdk/src/calldata/index.ts +++ b/packages/sdk/src/calldata/index.ts @@ -9,6 +9,16 @@ import { parseSolidityJSONABI, replaceNestedDefs, } from './evm'; +import { + bytesToHex, + decodeTransaction, + extractMessageFromTransaction, + fromBase58Bytes, + hexToBytes, + injectSignature, + readCompactU16, + toEd25519Bytes, +} from './solana'; export const CALLDATA = { EVM: { @@ -22,4 +32,29 @@ export const CALLDATA = { replaceNestedDefs, }, }, + SOLANA: { + type: 2, + parsers: { + /** Decode a transaction from base64 encoding */ + decodeTransaction, + /** Wrap pre-decoded base58 bytes */ + fromBase58Bytes, + /** Read a compact-u16 value from buffer */ + readCompactU16, + }, + processors: { + /** Extract the message portion from a full transaction */ + extractMessageFromTransaction, + /** Inject a signature into a transaction */ + injectSignature, + }, + utils: { + /** Convert Ed25519 public key to normalized bytes */ + toEd25519Bytes, + /** Convert bytes to hex string */ + bytesToHex, + /** Convert hex string to bytes */ + hexToBytes, + }, + }, }; diff --git a/packages/sdk/src/calldata/solana.ts b/packages/sdk/src/calldata/solana.ts new file mode 100644 index 00000000..89a0c65a --- /dev/null +++ b/packages/sdk/src/calldata/solana.ts @@ -0,0 +1,220 @@ +/** + * Solana transaction parsing utilities. + * @module calldata/solana + */ + +/** Read a compact-u16 (shortvec) from buffer. Little-endian 7-bit groups. */ +export function readCompactU16( + buffer: Uint8Array, + offset: number, +): [number, number] { + if (offset >= buffer.length) { + throw new Error('Buffer underflow reading compact-u16'); + } + + const first = buffer[offset]; + if ((first & 0x80) === 0) { + return [first, 1]; + } + + if (offset + 1 >= buffer.length) { + throw new Error('Buffer underflow reading compact-u16 (2 bytes)'); + } + const second = buffer[offset + 1]; + + if ((second & 0x80) === 0) { + return [(first & 0x7f) | ((second & 0x7f) << 7), 2]; + } + + if (offset + 2 >= buffer.length) { + throw new Error('Buffer underflow reading compact-u16 (3 bytes)'); + } + const third = buffer[offset + 2]; + + return [(first & 0x7f) | ((second & 0x7f) << 7) | ((third & 0x03) << 14), 3]; +} + +export interface DecodedTransaction { + encoding: 'base64' | 'base58'; + bytes: Uint8Array; +} + +/** + * Validates that a string is strictly valid base64 encoding. + * Base58 strings (common Solana RPC format) will be rejected since base58 + * uses a different alphabet that would decode to garbage if treated as base64. + * @param input - The string to validate + * @returns true if the string is valid base64, false otherwise + */ +function isValidBase64(input: string): boolean { + // Base64 must have length divisible by 4 (with padding) + if (input.length % 4 !== 0) { + return false; + } + + // Check for valid base64 characters: A-Z, a-z, 0-9, +, /, and = for padding + // This explicitly rejects base58-only chars that aren't in base64 + if (!/^[A-Za-z0-9+/]*={0,2}$/.test(input)) { + return false; + } + + // Round-trip validation: decode and re-encode to verify integrity + try { + if (typeof Buffer !== 'undefined') { + const decoded = Buffer.from(input, 'base64'); + const reencoded = decoded.toString('base64'); + return reencoded === input; + } + const decoded = atob(input); + const reencoded = btoa(decoded); + return reencoded === input; + } catch { + return false; + } +} + +/** + * Decode base64 transaction. Throws if invalid. + * @throws Error if the input is not valid base64 or decoded length is outside 100-1232 bytes + */ +export function decodeTransaction(transaction: string): DecodedTransaction { + // Validate base64 strictly before decoding to reject base58 inputs + if (!isValidBase64(transaction)) { + throw new Error( + 'Transaction is not valid base64. Use fromBase58Bytes for base58.', + ); + } + + const decoded = + typeof Buffer !== 'undefined' + ? Buffer.from(transaction, 'base64') + : Uint8Array.from(atob(transaction), (c) => c.charCodeAt(0)); + + // Solana txs are 100-1232 bytes + if (decoded.length >= 100 && decoded.length <= 1232) { + return { encoding: 'base64', bytes: new Uint8Array(decoded) }; + } + + throw new Error( + `Decoded transaction length ${decoded.length} is outside valid Solana range (100-1232 bytes).`, + ); +} + +/** Wrap pre-decoded base58 bytes. */ +export function fromBase58Bytes(decodedBytes: Uint8Array): DecodedTransaction { + return { encoding: 'base58', bytes: decodedBytes }; +} + +export interface ParsedTransaction { + numSignatures: number; + signaturesOffset: number; + messageOffset: number; + messageBytes: Uint8Array; +} + +/** Extract message bytes from Solana transaction wire format. */ +export function extractMessageFromTransaction( + txBytes: Uint8Array, +): ParsedTransaction { + const [numSignatures, sigCountBytes] = readCompactU16(txBytes, 0); + const signaturesOffset = sigCountBytes; + const messageOffset = sigCountBytes + numSignatures * 64; + + if (messageOffset > txBytes.length) { + throw new Error( + `Invalid transaction: offset ${messageOffset} exceeds length ${txBytes.length}`, + ); + } + + return { + numSignatures, + signaturesOffset, + messageOffset, + messageBytes: txBytes.subarray(messageOffset), + }; +} + +/** Inject 64-byte signature into transaction at given slot index. */ +export function injectSignature( + txBytes: Uint8Array, + signature: Uint8Array, + signatureIndex = 0, +): Uint8Array { + if (signature.length !== 64) { + throw new Error(`Expected 64-byte signature, got ${signature.length}`); + } + + const [numSignatures, sigCountBytes] = readCompactU16(txBytes, 0); + + if (signatureIndex >= numSignatures) { + throw new Error( + `Signature index ${signatureIndex} out of bounds (${numSignatures} slots)`, + ); + } + + const signedTx = new Uint8Array(txBytes); + signedTx.set(signature, sigCountBytes + signatureIndex * 64); + return signedTx; +} + +/** + * Convert raw Ed25519 pubkey to 32-byte Uint8Array. + * @param entry - The input to convert (Uint8Array, Buffer, or hex string) + * @returns A 32-byte Uint8Array, or null if the input is invalid or too short + */ +export function toEd25519Bytes(entry: unknown): Uint8Array | null { + if (entry instanceof Uint8Array) { + // Reject inputs shorter than 32 bytes to prevent malformed keys + if (entry.length < 32) { + return null; + } + return entry.slice(0, 32); + } + + if (typeof Buffer !== 'undefined' && Buffer.isBuffer(entry)) { + // Reject inputs shorter than 32 bytes to prevent malformed keys + if (entry.length < 32) { + return null; + } + return new Uint8Array(entry.slice(0, 32)); + } + + if (typeof entry === 'string') { + const hex = entry.startsWith('0x') + ? entry.slice(2) + : /^[0-9a-fA-F]+$/.test(entry) + ? entry + : ''; + if (hex.length >= 64) { + const out = new Uint8Array(32); + for (let i = 0; i < 32; i++) { + out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return out; + } + } + + return null; +} + +export function bytesToHex(bytes: Uint8Array, prefix = true): string { + const hex = Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + return prefix ? `0x${hex}` : hex; +} + +export function hexToBytes(hex: string): Uint8Array { + const normalized = hex.startsWith('0x') ? hex.slice(2) : hex; + if (normalized.length % 2 !== 0) { + throw new Error('Hex string must have even length'); + } + if (!/^[0-9a-fA-F]*$/.test(normalized)) { + throw new Error('Invalid hex characters'); + } + const bytes = new Uint8Array(normalized.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = Number.parseInt(normalized.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +} diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 797a6159..015efdd1 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -15,6 +15,7 @@ import { pair, removeKvRecords, sign, + sendEvent, } from './functions/index'; import { buildRetryWrapper } from './shared/functions'; import { getPubKeyBytes } from './shared/utilities'; @@ -30,6 +31,8 @@ import type { RemoveKvRecordsRequestParams, SignData, SignRequestParams, + SendEventParams, + SendEventResponse, } from './types'; import { getP256KeyPair, getP256KeyPairFromPub, randomBytes } from './util'; @@ -105,13 +108,15 @@ export class Client { /** Function to set the stored client data */ setStoredClient?: (clientData: string | null) => Promise; }) { + const retryOverride = + typeof retryCount === 'number' ? retryCount : undefined; this.name = name || 'Unknown'; this.baseUrl = baseUrl || BASE_URL; this.deviceId = deviceId; this.isPaired = false; this.activeWallets = DEFAULT_ACTIVE_WALLETS; this.timeout = timeout || 60000; - this.retryCount = retryCount || 3; + this.retryCount = retryOverride ?? 3; this.skipRetryOnWrongWallet = skipRetryOnWrongWallet || false; this.privKey = privKey || randomBytes(32); this.key = getP256KeyPair(this.privKey); @@ -124,6 +129,10 @@ export class Client { if (stateData) { this.unpackAndApplyStateData(stateData); } + if (retryOverride !== undefined) { + this.retryCount = retryOverride; + this.retryWrapper = buildRetryWrapper(this, this.retryCount); + } } /** @@ -261,6 +270,18 @@ export class Client { return this.retryWrapper(removeKvRecords, { type, ids }); } + /** + * Send a simple message to the device firmware. + * @category Lattice + */ + public async sendEvent({ + eventType, + eventId, + message, + }: SendEventParams): Promise { + return this.retryWrapper(sendEvent, { eventType, eventId, message }); + } + /** * Fetch a record of encrypted data from the Lattice. * Must specify a data type. Returns a Buffer containing diff --git a/packages/sdk/src/constants.ts b/packages/sdk/src/constants.ts index 8d52ed67..84bca0e4 100644 --- a/packages/sdk/src/constants.ts +++ b/packages/sdk/src/constants.ts @@ -13,6 +13,9 @@ import type { WalletPath, } from './types/index.js'; +/** @internal Hardened offset for BIP32 derivation paths */ +const HARDENED = 0x80000000; + /** * Externally exported constants used for building requests * @public @@ -71,6 +74,18 @@ export const EXTERNAL = { VOLUNTARY_EXIT: Buffer.from('04000000', 'hex'), }, }, + // Standard derivation paths for various chains + DERIVATION_PATHS: { + /** Solana: m/44'/501'/0'/0' - Ed25519 curve */ + SOLANA: [ + HARDENED + 44, + HARDENED + 501, + HARDENED, + HARDENED, + ] as readonly number[], + /** Ethereum: m/44'/60'/0'/0/0 - secp256k1 curve */ + ETH: [HARDENED + 44, HARDENED + 60, HARDENED, 0, 0] as readonly number[], + }, } as const; //=============================== diff --git a/packages/sdk/src/functions/index.ts b/packages/sdk/src/functions/index.ts index dea08a11..5f9fcd97 100644 --- a/packages/sdk/src/functions/index.ts +++ b/packages/sdk/src/functions/index.ts @@ -6,4 +6,5 @@ export * from './getAddresses'; export * from './getKvRecords'; export * from './pair'; export * from './removeKvRecords'; +export * from './sendEvent'; export * from './sign'; diff --git a/packages/sdk/src/functions/sendEvent.ts b/packages/sdk/src/functions/sendEvent.ts new file mode 100644 index 00000000..6a02d975 --- /dev/null +++ b/packages/sdk/src/functions/sendEvent.ts @@ -0,0 +1,91 @@ +import { + LatticeSecureEncryptedRequestType, + encryptedSecureRequest, +} from '../protocol'; +import { parse as parseUuid, validate as validateUuid } from 'uuid'; +import { validateConnectedClient } from '../shared/validators'; +import type { + SendEventRequestFunctionParams, + SendEventResponse, +} from '../types'; + +const EVENT_TYPE_BYTES = 1; +const EVENT_ID_BYTES = 16; +const MESSAGE_LENGTH_BYTES = 2; +const MAX_MESSAGE_BYTES = 1703; +const EVENT_PAYLOAD_BYTES = + EVENT_TYPE_BYTES + EVENT_ID_BYTES + MESSAGE_LENGTH_BYTES + MAX_MESSAGE_BYTES; + +const parseEventId = (eventId: string): Buffer => { + if (!validateUuid(eventId)) { + throw new Error('eventId must be a valid UUID.'); + } + const bytes = Buffer.from(parseUuid(eventId)); + if (bytes.length !== EVENT_ID_BYTES) { + throw new Error('eventId must be 16 bytes.'); + } + return bytes; +}; + +const validateEventType = (eventType: number) => { + if (!Number.isInteger(eventType) || eventType < 0 || eventType > 0xff) { + throw new Error('eventType must be a uint8.'); + } +}; + +const encodeEventPayload = ({ + eventType, + eventId, + message, +}: { + eventType: number; + eventId: string; + message: string; +}): Buffer => { + validateEventType(eventType); + const msgBytes = Buffer.from(message, 'utf8'); + if (msgBytes.length === 0) { + throw new Error('Message must not be empty.'); + } + if (msgBytes.length > MAX_MESSAGE_BYTES) { + throw new Error( + `Message is too long. Max length is ${MAX_MESSAGE_BYTES} bytes.`, + ); + } + + const payload = Buffer.alloc(EVENT_PAYLOAD_BYTES); + const eventIdBytes = parseEventId(eventId); + payload[0] = eventType; + eventIdBytes.copy(payload, EVENT_TYPE_BYTES); + payload.writeUInt16LE(msgBytes.length, EVENT_TYPE_BYTES + EVENT_ID_BYTES); + msgBytes.copy( + payload, + EVENT_TYPE_BYTES + EVENT_ID_BYTES + MESSAGE_LENGTH_BYTES, + ); + return payload; +}; + +/** Send an event payload to device firmware. */ +export const sendEvent = async ({ + client, + eventType, + eventId, + message, +}: SendEventRequestFunctionParams): Promise => { + const { url, sharedSecret, ephemeralPub } = validateConnectedClient(client); + const data = encodeEventPayload({ eventType, eventId, message }); + + const { decryptedData, newEphemeralPub } = await encryptedSecureRequest({ + data, + requestType: LatticeSecureEncryptedRequestType.event, + sharedSecret, + ephemeralPub, + url, + }); + + client.mutate({ ephemeralPub: newEphemeralPub }); + + return { status: decryptedData[0] ?? 0 }; +}; + +export const __private__ = { encodeEventPayload }; diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 53feb6e8..158138ca 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -4,3 +4,4 @@ export { EXTERNAL as Constants } from './constants'; export { EXTERNAL as Utils } from './util'; export * from './api'; export * as btc from './btc'; +export type { SignData, LatticeSignature } from './types/client'; diff --git a/packages/sdk/src/protocol/latticeConstants.ts b/packages/sdk/src/protocol/latticeConstants.ts index 93993b75..5d535c78 100644 --- a/packages/sdk/src/protocol/latticeConstants.ts +++ b/packages/sdk/src/protocol/latticeConstants.ts @@ -41,6 +41,7 @@ export enum LatticeSecureEncryptedRequestType { removeKvRecords = 9, fetchEncryptedData = 12, test = 13, + event = 14, } export enum LatticeGetAddressesFlag { @@ -177,6 +178,7 @@ export const ProtocolConstants = { [LatticeSecureEncryptedRequestType.removeKvRecords]: 405, [LatticeSecureEncryptedRequestType.fetchEncryptedData]: 1025, [LatticeSecureEncryptedRequestType.test]: 506, + [LatticeSecureEncryptedRequestType.event]: 1722, }, }, // All responses also have a `responseCode`, which is omitted @@ -197,6 +199,7 @@ export const ProtocolConstants = { [LatticeSecureEncryptedRequestType.removeKvRecords]: 0, [LatticeSecureEncryptedRequestType.fetchEncryptedData]: 1608, [LatticeSecureEncryptedRequestType.test]: 1646, + [LatticeSecureEncryptedRequestType.event]: 1, }, }, }, diff --git a/packages/sdk/src/protocol/secureMessages.ts b/packages/sdk/src/protocol/secureMessages.ts index 5a7acf59..f50cdc12 100644 --- a/packages/sdk/src/protocol/secureMessages.ts +++ b/packages/sdk/src/protocol/secureMessages.ts @@ -132,8 +132,13 @@ export async function encryptedSecureRequest({ payload: msg, }); - // Deserialize the response payload data - if (resp.length !== szs.payload.response.encrypted - 1) { + // Deserialize the response payload data. Accept both legacy and compact sizes. + const legacyResponseSize = szs.payload.response.encrypted - 1; + const compactResponseSize = szs.data.response.encrypted.encryptedData; + if ( + resp.length !== legacyResponseSize && + resp.length !== compactResponseSize + ) { throw new Error('Wrong Lattice response message size.'); } diff --git a/packages/sdk/src/types/event.ts b/packages/sdk/src/types/event.ts new file mode 100644 index 00000000..6c219a35 --- /dev/null +++ b/packages/sdk/src/types/event.ts @@ -0,0 +1,15 @@ +import type { Client } from '../client'; + +export interface SendEventParams { + eventType: number; + eventId: string; + message: string; +} + +export interface SendEventRequestFunctionParams extends SendEventParams { + client: Client; +} + +export interface SendEventResponse { + status: number; +} diff --git a/packages/sdk/src/types/index.ts b/packages/sdk/src/types/index.ts index b111da12..c87fc7cd 100644 --- a/packages/sdk/src/types/index.ts +++ b/packages/sdk/src/types/index.ts @@ -28,6 +28,9 @@ export * from './messages'; // Re-export everything from pair.ts export * from './pair'; +// Re-export everything from event.ts +export * from './event'; + // Re-export everything from removeKvRecords.ts export * from './removeKvRecords'; @@ -72,6 +75,13 @@ export type { EIP2335KeyExportData, } from './fetchEncData'; +// Exports from message.ts +export type { + SendEventParams, + SendEventRequestFunctionParams, + SendEventResponse, +} from './event'; + // Exports from getKvRecords.ts export type { GetKvRecordsRequestParams,