diff --git a/README.md b/README.md index c0dd778..2b6dd96 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,30 @@ structure.payload.segments.forEach((seg, i) => { }); ``` +### GroupText Channel Hash Size (1-byte or 2-byte) + +Some networks are moving from a 1-byte GroupText channel hash prefix to a 2-byte prefix to reduce collisions. + +The decoder now supports: + +- `groupTextChannelHashBytes: 1` for legacy behavior +- `groupTextChannelHashBytes: 2` for experimental 2-byte prefix +- `groupTextChannelHashBytes: 'auto'` (recommended) to try both formats + +```typescript +const packet = MeshCoreDecoder.decode(groupTextHexData, { + keyStore, + groupTextChannelHashBytes: 'auto' +}); +``` + +CLI usage: + +```bash +meshcore-decoder decode --group-hash-bytes auto --key +meshcore-decoder decode --group-hash-bytes 2 --key +``` + ### Regions (transport codes) MeshCore repeaters use **regions** (e.g. `#Europe`, `*`) to control which packets are flooded or blocked. Region information is **not** inside the GroupText (or other) payload; it is carried in the **packet header** as **transport codes** when the route type is **Transport flood** (`0x00`) or **Transport direct** (`0x03`). diff --git a/src/cli.ts b/src/cli.ts index e13a93b..19cb650 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -20,6 +20,7 @@ program .description('Decode a MeshCore packet') .argument('', 'Hex string of the packet to decode') .option('-k, --key ', 'Channel secret keys for decryption (hex)') + .option('--group-hash-bytes ', 'GroupText channel hash bytes: auto, 1, or 2', 'auto') .option('-j, --json', 'Output as JSON instead of formatted text') .option('-s, --structure', 'Show detailed packet structure analysis') .action(async (hex: string, options: any) => { @@ -35,13 +36,25 @@ program }); } + const groupHashMode = options.groupHashBytes === '1' + ? 1 + : options.groupHashBytes === '2' + ? 2 + : 'auto'; + // Decode packet with signature verification - const packet = await MeshCorePacketDecoder.decodeWithVerification(cleanHex, { keyStore }); + const packet = await MeshCorePacketDecoder.decodeWithVerification(cleanHex, { + keyStore, + groupTextChannelHashBytes: groupHashMode + }); if (options.json) { // JSON output if (options.structure) { - const structure = await MeshCorePacketDecoder.analyzeStructureWithVerification(cleanHex, { keyStore }); + const structure = await MeshCorePacketDecoder.analyzeStructureWithVerification(cleanHex, { + keyStore, + groupTextChannelHashBytes: groupHashMode + }); console.log(JSON.stringify({ packet, structure }, null, 2)); } else { console.log(JSON.stringify(packet, null, 2)); @@ -81,7 +94,10 @@ program // Show structure if requested if (options.structure) { - const structure = await MeshCorePacketDecoder.analyzeStructureWithVerification(cleanHex, { keyStore }); + const structure = await MeshCorePacketDecoder.analyzeStructureWithVerification(cleanHex, { + keyStore, + groupTextChannelHashBytes: groupHashMode + }); console.log(chalk.cyan('\n=== Packet Structure ===')); console.log(chalk.yellow('\nMain Segments:')); diff --git a/src/crypto/channel-crypto.ts b/src/crypto/channel-crypto.ts index a9f2ec9..07d17db 100644 --- a/src/crypto/channel-crypto.ts +++ b/src/crypto/channel-crypto.ts @@ -104,11 +104,15 @@ export class ChannelCrypto { /** * Calculate MeshCore channel hash from secret key - * Returns the first byte of SHA256(secret) as hex string + * Returns the first N bytes of SHA256(secret) as hex string */ - static calculateChannelHash(secretKeyHex: string): string { + static calculateChannelHash(secretKeyHex: string, hashByteCount = 1): string { + if (hashByteCount < 1) { + throw new Error('hashBytes must be >= 1'); + } + const hash = SHA256(enc.Hex.parse(secretKeyHex)); - const hashBytes = hexToBytes(hash.toString(enc.Hex)); - return hashBytes[0].toString(16).padStart(2, '0'); + const hashBuffer = hexToBytes(hash.toString(enc.Hex)); + return bytesToHex(hashBuffer.subarray(0, hashByteCount)); } } diff --git a/src/crypto/key-manager.ts b/src/crypto/key-manager.ts index f0ea606..fc34793 100644 --- a/src/crypto/key-manager.ts +++ b/src/crypto/key-manager.ts @@ -7,8 +7,9 @@ import { ChannelCrypto } from './channel-crypto'; export class MeshCoreKeyStore implements CryptoKeyStore { public nodeKeys: Map = new Map(); - // internal map for hash -> multiple keys (collision handling) - private channelHashToKeys = new Map(); + // internal maps for hash prefix -> multiple keys (collision handling) + private channelHashToKeys1 = new Map(); + private channelHashToKeys2 = new Map(); constructor(initialKeys?: { channelSecrets?: string[]; @@ -32,7 +33,13 @@ export class MeshCoreKeyStore implements CryptoKeyStore { hasChannelKey(channelHash: string): boolean { const normalizedHash = channelHash.toLowerCase(); - return this.channelHashToKeys.has(normalizedHash); + if (normalizedHash.length === 2) { + return this.channelHashToKeys1.has(normalizedHash); + } + if (normalizedHash.length === 4) { + return this.channelHashToKeys2.has(normalizedHash); + } + return false; } hasNodeKey(publicKey: string): boolean { @@ -45,7 +52,13 @@ export class MeshCoreKeyStore implements CryptoKeyStore { */ getChannelKeys(channelHash: string): string[] { const normalizedHash = channelHash.toLowerCase(); - return this.channelHashToKeys.get(normalizedHash) || []; + if (normalizedHash.length === 2) { + return this.channelHashToKeys1.get(normalizedHash) || []; + } + if (normalizedHash.length === 4) { + return this.channelHashToKeys2.get(normalizedHash) || []; + } + return []; } getNodeKey(publicKey: string): string | undefined { @@ -59,13 +72,18 @@ export class MeshCoreKeyStore implements CryptoKeyStore { */ addChannelSecrets(secretKeys: string[]): void { for (const secretKey of secretKeys) { - const channelHash = ChannelCrypto.calculateChannelHash(secretKey).toLowerCase(); - - // Handle potential hash collisions - if (!this.channelHashToKeys.has(channelHash)) { - this.channelHashToKeys.set(channelHash, []); + const channelHash1 = ChannelCrypto.calculateChannelHash(secretKey, 1).toLowerCase(); + const channelHash2 = ChannelCrypto.calculateChannelHash(secretKey, 2).toLowerCase(); + + if (!this.channelHashToKeys1.has(channelHash1)) { + this.channelHashToKeys1.set(channelHash1, []); + } + this.channelHashToKeys1.get(channelHash1)!.push(secretKey); + + if (!this.channelHashToKeys2.has(channelHash2)) { + this.channelHashToKeys2.set(channelHash2, []); } - this.channelHashToKeys.get(channelHash)!.push(secretKey); + this.channelHashToKeys2.get(channelHash2)!.push(secretKey); } } } diff --git a/src/decoder/payload-decoders/group-text.ts b/src/decoder/payload-decoders/group-text.ts index 16a804b..afdebc9 100644 --- a/src/decoder/payload-decoders/group-text.ts +++ b/src/decoder/payload-decoders/group-text.ts @@ -8,15 +8,56 @@ import { DecryptionOptions } from '../../types/crypto'; import { ChannelCrypto } from '../../crypto/channel-crypto'; import { byteToHex, bytesToHex } from '../../utils/hex'; +interface GroupTextCandidate { + channelHash: string; + cipherMac: string; + ciphertext: string; + ciphertextLength: number; + hashByteCount: 1 | 2; +} + +function parseGroupTextCandidate(payload: Uint8Array, hashByteCount: 1 | 2): GroupTextCandidate | null { + const minimumLength = hashByteCount + 2; + if (payload.length < minimumLength) { + return null; + } + + let offset = 0; + const channelHash = hashByteCount === 1 + ? byteToHex(payload[offset]) + : bytesToHex(payload.subarray(offset, offset + 2)); + offset += hashByteCount; + + const cipherMac = bytesToHex(payload.subarray(offset, offset + 2)); + offset += 2; + + const ciphertext = bytesToHex(payload.subarray(offset)); + + return { + channelHash, + cipherMac, + ciphertext, + ciphertextLength: payload.length - minimumLength, + hashByteCount + }; +} + export class GroupTextPayloadDecoder { static decode(payload: Uint8Array, options?: DecryptionOptions & { includeSegments?: boolean; segmentOffset?: number }): GroupTextPayload & { segments?: PayloadSegment[] } | null { try { - if (payload.length < 3) { + const mode = options?.groupTextChannelHashBytes ?? 'auto'; + const minimumLength = mode === 2 ? 4 : 3; + + if (payload.length < minimumLength) { const result: GroupTextPayload & { segments?: PayloadSegment[] } = { type: PayloadType.GroupText, version: PayloadVersion.Version1, isValid: false, - errors: ['GroupText payload too short (need at least channel_hash(1) + MAC(2))'], + errors: [ + mode === 2 + ? 'GroupText payload too short (need at least channel_hash(2) + MAC(2))' + : 'GroupText payload too short (need at least channel_hash(1) + MAC(2))' + ], channelHash: '', cipherMac: '', ciphertext: '', @@ -38,77 +79,105 @@ export class GroupTextPayloadDecoder { const segments: PayloadSegment[] = []; const segmentOffset = options?.segmentOffset || 0; - let offset = 0; + const candidates: GroupTextCandidate[] = []; + const requestedOrder: Array<1 | 2> = + mode === 1 ? [1] : + mode === 2 ? [2] : + [1, 2]; - // channel hash (1 byte) - first byte of SHA256 of channel's shared key - const channelHash = byteToHex(payload[offset]); - if (options?.includeSegments) { - segments.push({ - name: 'Channel Hash', - description: 'First byte of SHA256 of channel\'s shared key', - startByte: segmentOffset + offset, - endByte: segmentOffset + offset, - value: channelHash - }); - } - offset += 1; - - // MAC (2 bytes) - message authentication code - const cipherMac = bytesToHex(payload.subarray(offset, offset + 2)); - if (options?.includeSegments) { - segments.push({ - name: 'Cipher MAC', - description: 'MAC for encrypted data', - startByte: segmentOffset + offset, - endByte: segmentOffset + offset + 1, - value: cipherMac - }); + for (const hashByteCount of requestedOrder) { + const candidate = parseGroupTextCandidate(payload, hashByteCount); + if (candidate) { + candidates.push(candidate); + } } - offset += 2; - - // ciphertext (remaining bytes) - encrypted message - const ciphertext = bytesToHex(payload.subarray(offset)); - if (options?.includeSegments && payload.length > offset) { - segments.push({ - name: 'Ciphertext', - description: 'Encrypted message content (timestamp + flags + message)', - startByte: segmentOffset + offset, - endByte: segmentOffset + payload.length - 1, - value: ciphertext - }); + + if (candidates.length === 0) { + throw new Error('Failed to parse GroupText payload'); } + let selected = candidates[0]; + const groupText: GroupTextPayload & { segments?: PayloadSegment[] } = { type: PayloadType.GroupText, version: PayloadVersion.Version1, isValid: true, - channelHash, - cipherMac, - ciphertext, - ciphertextLength: payload.length - 3 + channelHash: selected.channelHash, + cipherMac: selected.cipherMac, + ciphertext: selected.ciphertext, + ciphertextLength: selected.ciphertextLength }; // attempt decryption if key store is provided - if (options?.keyStore && options.keyStore.hasChannelKey(channelHash)) { - // try all possible keys for this hash (handles collisions) - const channelKeys = options.keyStore.getChannelKeys(channelHash); - - for (const channelKey of channelKeys) { - const decryptionResult = ChannelCrypto.decryptGroupTextMessage( - ciphertext, - cipherMac, - channelKey - ); - - if (decryptionResult.success && decryptionResult.data) { - groupText.decrypted = { - timestamp: decryptionResult.data.timestamp, - flags: decryptionResult.data.flags, - sender: decryptionResult.data.sender, - message: decryptionResult.data.message - }; - break; // stop trying keys once we find one that works + if (options?.keyStore) { + for (const candidate of candidates) { + const channelKeys = options.keyStore.getChannelKeys(candidate.channelHash); + const fallbackKeys = + candidate.hashByteCount === 2 + ? options.keyStore.getChannelKeys(candidate.channelHash.substring(0, 2)) + : []; + const keysToTry = Array.from(new Set([...channelKeys, ...fallbackKeys])); + + for (const channelKey of keysToTry) { + const decryptionResult = ChannelCrypto.decryptGroupTextMessage( + candidate.ciphertext, + candidate.cipherMac, + channelKey + ); + + if (decryptionResult.success && decryptionResult.data) { + selected = candidate; + groupText.channelHash = candidate.channelHash; + groupText.cipherMac = candidate.cipherMac; + groupText.ciphertext = candidate.ciphertext; + groupText.ciphertextLength = candidate.ciphertextLength; + groupText.decrypted = { + timestamp: decryptionResult.data.timestamp, + flags: decryptionResult.data.flags, + sender: decryptionResult.data.sender, + message: decryptionResult.data.message + }; + break; + } } + + if (groupText.decrypted) { + break; + } + } + } + + if (options?.includeSegments) { + const hashDescription = selected.hashByteCount === 2 + ? 'First 2 bytes of SHA256 of channel\'s shared key' + : 'First byte of SHA256 of channel\'s shared key'; + + segments.push({ + name: 'Channel Hash', + description: hashDescription, + startByte: segmentOffset, + endByte: segmentOffset + selected.hashByteCount - 1, + value: selected.channelHash + }); + + const macStart = selected.hashByteCount; + segments.push({ + name: 'Cipher MAC', + description: 'MAC for encrypted data', + startByte: segmentOffset + macStart, + endByte: segmentOffset + macStart + 1, + value: selected.cipherMac + }); + + if (payload.length > selected.hashByteCount + 2) { + const ciphertextStart = selected.hashByteCount + 2; + segments.push({ + name: 'Ciphertext', + description: 'Encrypted message content (timestamp + flags + message)', + startByte: segmentOffset + ciphertextStart, + endByte: segmentOffset + payload.length - 1, + value: selected.ciphertext + }); } } diff --git a/src/types/crypto.ts b/src/types/crypto.ts index fd63b83..758ba3c 100644 --- a/src/types/crypto.ts +++ b/src/types/crypto.ts @@ -18,6 +18,8 @@ export interface DecryptionOptions { keyStore?: CryptoKeyStore; attemptDecryption?: boolean; // default: true if keyStore provided includeRawCiphertext?: boolean; // default: true + // GroupText channel hash size: 1 byte (legacy), 2 bytes (experimental), or auto-detect + groupTextChannelHashBytes?: 1 | 2 | 'auto'; } export interface DecryptionResult { diff --git a/tests/grouptext-decryption.test.ts b/tests/grouptext-decryption.test.ts index 94737d5..7739224 100644 --- a/tests/grouptext-decryption.test.ts +++ b/tests/grouptext-decryption.test.ts @@ -201,4 +201,48 @@ describe('GroupText Decryption', () => { expect(groupText.decrypted?.sender).toBe('🌲 Tree'); expect(groupText.decrypted?.message).toBe('☁️'); }); + + it('should auto-detect 2-byte GroupText channel hash format', () => { + // Same ciphertext as public channel sample, but with a 2-byte channel hash prefix (11E7) + const hexData = '150011E7C3C1354D619BAE9590E4D177DB7EEAF982F5BDCF78005D75157D9535FA90178F785D'; + + const keyStore = MeshCorePacketDecoder.createKeyStore({ + channelSecrets: [ + '8b3387e9c5cdea6ac9e5edbaa115cd72' + ] + }); + + const packet = MeshCorePacketDecoder.decode(hexData, { + keyStore, + groupTextChannelHashBytes: 'auto' + }); + + const groupText = packet.payload.decoded as GroupTextPayload; + expect(groupText.isValid).toBe(true); + expect(groupText.channelHash).toBe('11E7'); + expect(groupText.cipherMac).toBe('C3C1'); + expect(groupText.decrypted).toBeDefined(); + expect(groupText.decrypted?.sender).toBe('🌲 Tree'); + expect(groupText.decrypted?.message).toBe('☁️'); + }); + + it('should decrypt 2-byte GroupText channel hash when mode is forced', () => { + const hexData = '150011E7C3C1354D619BAE9590E4D177DB7EEAF982F5BDCF78005D75157D9535FA90178F785D'; + + const keyStore = MeshCorePacketDecoder.createKeyStore({ + channelSecrets: [ + '8b3387e9c5cdea6ac9e5edbaa115cd72' + ] + }); + + const packet = MeshCorePacketDecoder.decode(hexData, { + keyStore, + groupTextChannelHashBytes: 2 + }); + + const groupText = packet.payload.decoded as GroupTextPayload; + expect(groupText.isValid).toBe(true); + expect(groupText.channelHash).toBe('11E7'); + expect(groupText.decrypted).toBeDefined(); + }); });