Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <hex> --group-hash-bytes auto --key <secret>
meshcore-decoder decode <hex> --group-hash-bytes 2 --key <secret>
```

### 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`).
Expand Down
22 changes: 19 additions & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ program
.description('Decode a MeshCore packet')
.argument('<hex>', 'Hex string of the packet to decode')
.option('-k, --key <keys...>', 'Channel secret keys for decryption (hex)')
.option('--group-hash-bytes <mode>', '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) => {
Expand All @@ -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));
Expand Down Expand Up @@ -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:'));
Expand Down
12 changes: 8 additions & 4 deletions src/crypto/channel-crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}
38 changes: 28 additions & 10 deletions src/crypto/key-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import { ChannelCrypto } from './channel-crypto';
export class MeshCoreKeyStore implements CryptoKeyStore {
public nodeKeys: Map<string, string> = new Map();

// internal map for hash -> multiple keys (collision handling)
private channelHashToKeys = new Map<string, string[]>();
// internal maps for hash prefix -> multiple keys (collision handling)
private channelHashToKeys1 = new Map<string, string[]>();
private channelHashToKeys2 = new Map<string, string[]>();

constructor(initialKeys?: {
channelSecrets?: string[];
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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);
}
}
}
191 changes: 130 additions & 61 deletions src/decoder/payload-decoders/group-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '',
Expand All @@ -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
});
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/types/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading