Skip to content
Draft
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
1 change: 1 addition & 0 deletions public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -2856,6 +2856,7 @@
"messages.signal_info": "Signal strength (SNR/RSSI) - Direct message",
"messages.click_for_relay": "Click to see potential relay nodes",
"messages.via_mqtt": "Received via MQTT",
"messages.xeddsa_signed": "Cryptographically signed (XEdDSA)",

"link_preview.loading": "Loading preview...",
"link_preview.image_alt": "Link preview",
Expand Down
1 change: 1 addition & 0 deletions src/components/ChannelsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1116,6 +1116,7 @@ export default function ChannelsTab({
relayNode={msg.relayNode}
viaMqtt={msg.viaMqtt}
viaStoreForward={msg.viaStoreForward}
xeddsaSigned={msg.xeddsaSigned}
onClick={() => handleRelayClick(msg)}
/>
</span>
Expand Down
38 changes: 38 additions & 0 deletions src/components/HopCountDisplay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,44 @@ describe('HopCountDisplay', () => {
});
});

describe('XEdDSA signed indicator', () => {
it('shows signed shield when xeddsaSigned is true', () => {
render(<HopCountDisplay xeddsaSigned={true} />);
const shield = screen.getByLabelText(/xeddsa_signed/i);
expect(shield).toBeDefined();
expect(shield.textContent).toBe('🛡️');
});

it('does not show shield when xeddsaSigned is false', () => {
render(<HopCountDisplay xeddsaSigned={false} />);
expect(screen.queryByLabelText(/xeddsa_signed/i)).toBeNull();
});

it('does not show shield when xeddsaSigned is undefined', () => {
render(<HopCountDisplay />);
expect(screen.queryByLabelText(/xeddsa_signed/i)).toBeNull();
});

it('shows shield alongside hop count when hops are available', () => {
render(<HopCountDisplay hopStart={7} hopLimit={5} xeddsaSigned={true} />);
expect(screen.getByLabelText(/xeddsa_signed/i)).toBeDefined();
expect(screen.getByText(/hops/i)).toBeDefined();
});

it('shows shield with SNR/RSSI for direct messages (0 hops)', () => {
render(<HopCountDisplay hopStart={7} hopLimit={7} rxSnr={9.5} rxRssi={-52} xeddsaSigned={true} />);
expect(screen.getByLabelText(/xeddsa_signed/i)).toBeDefined();
expect(screen.getByText(/9\.5 dB/)).toBeDefined();
});

it('shows shield alongside MQTT and S&F indicators', () => {
render(<HopCountDisplay xeddsaSigned={true} viaMqtt={true} viaStoreForward={true} />);
expect(screen.getByLabelText(/xeddsa_signed/i)).toBeDefined();
expect(screen.getByLabelText(/mqtt/i)).toBeDefined();
expect(screen.getByLabelText(/store_forward/i)).toBeDefined();
});
});

describe('MQTT indicator', () => {
it('shows MQTT icon when viaMqtt is true', () => {
render(<HopCountDisplay viaMqtt={true} />);
Expand Down
22 changes: 20 additions & 2 deletions src/components/HopCountDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ interface HopCountDisplayProps {
relayNode?: number;
viaMqtt?: boolean;
viaStoreForward?: boolean;
xeddsaSigned?: boolean;
onClick?: () => void;
}

Expand All @@ -27,10 +28,25 @@ const HopCountDisplay: React.FC<HopCountDisplayProps> = ({
relayNode,
viaMqtt,
viaStoreForward,
xeddsaSigned,
onClick,
}) => {
const { t } = useTranslation();

// XEdDSA signing indicator (firmware 2.8+): green shield shown when the
// receiving node cryptographically verified the broadcast's signature,
// matching the official mobile clients' verified-signature indicator.
const SignedIndicator = xeddsaSigned ? (
<span
style={{ marginLeft: '4px', opacity: 0.9, color: 'var(--success-color, #16a34a)' }}
title={t('messages.xeddsa_signed', 'Cryptographically signed (XEdDSA)')}
aria-label={t('messages.xeddsa_signed', 'Cryptographically signed (XEdDSA)')}
role="img"
>
🛡️
</span>
) : null;

// Store & Forward indicator component
const StoreForwardIndicator = viaStoreForward ? (
<span
Expand All @@ -57,14 +73,14 @@ const HopCountDisplay: React.FC<HopCountDisplayProps> = ({

// Return null if either hop value is missing (but show indicators if present)
if (hopStart === undefined || hopLimit === undefined) {
return <>{StoreForwardIndicator}{MqttIndicator}</>;
return <>{SignedIndicator}{StoreForwardIndicator}{MqttIndicator}</>;
}

const hopCount = hopStart - hopLimit;

// Guard against malformed data (negative hop counts)
if (hopCount < 0) {
return <>{StoreForwardIndicator}{MqttIndicator}</>;
return <>{SignedIndicator}{StoreForwardIndicator}{MqttIndicator}</>;
}

// Check if this hop count is clickable (has relay node info)
Expand Down Expand Up @@ -94,6 +110,7 @@ const HopCountDisplay: React.FC<HopCountDisplayProps> = ({
<span style={{ fontSize: '0.75em', marginLeft: '4px', opacity: 0.85 }} title={t('messages.signal_info')}>
({parts.join(' / ')})
</span>
{SignedIndicator}
{StoreForwardIndicator}
{MqttIndicator}
</>
Expand All @@ -109,6 +126,7 @@ const HopCountDisplay: React.FC<HopCountDisplayProps> = ({
>
({t('messages.hops', { count: hopCount, hopStart: hopStart })})
</span>
{SignedIndicator}
{StoreForwardIndicator}
{MqttIndicator}
</>
Expand Down
2 changes: 2 additions & 0 deletions src/components/MessagesTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1467,6 +1467,7 @@ const MessagesTab: React.FC<MessagesTabProps> = ({
relayNode={msg.relayNode}
viaMqtt={msg.viaMqtt}
viaStoreForward={msg.viaStoreForward}
xeddsaSigned={msg.xeddsaSigned}
onClick={() => handleRelayClick(msg)}
/>
</span>
Expand Down Expand Up @@ -1578,6 +1579,7 @@ const MessagesTab: React.FC<MessagesTabProps> = ({
relayNode={msg.relayNode}
viaMqtt={msg.viaMqtt}
viaStoreForward={msg.viaStoreForward}
xeddsaSigned={msg.xeddsaSigned}
onClick={() => handleRelayClick(msg)}
/>
</span>
Expand Down
12 changes: 6 additions & 6 deletions src/db/migrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { describe, it, expect } from 'vitest';
import { registry } from './migrations.js';

describe('migrations registry', () => {
it('has all 112 migrations registered', () => {
expect(registry.count()).toBe(112);
it('has all 113 migrations registered', () => {
expect(registry.count()).toBe(113);
});

// Bumping these counts: when adding a new migration, increment to <N>+1 and
Expand All @@ -15,14 +15,14 @@ describe('migrations registry', () => {
expect(all[0].name).toContain('v37_baseline');
});

it('last migration is add_notes_to_nodes', () => {
it('last migration is add_xeddsa_signed', () => {
const all = registry.getAll();
const last = all[all.length - 1];
expect(last.number).toBe(112);
expect(last.name).toContain('add_notes_to_nodes');
expect(last.number).toBe(113);
expect(last.name).toContain('add_xeddsa_signed');
});

it('migrations are sequentially numbered from 1 to 112', () => {
it('migrations are sequentially numbered from 1 to 113', () => {
const all = registry.getAll();
for (let i = 0; i < all.length; i++) {
expect(all[i].number).toBe(i + 1);
Expand Down
18 changes: 18 additions & 0 deletions src/db/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ import { migration as clampFutureTracerouteMigration, runMigration109Postgres as
import { migration as meshcorePositionHistoryMigration, runMigration110Postgres as runMeshcorePositionHistoryPostgres, runMigration110Mysql as runMeshcorePositionHistoryMysql } from '../server/migrations/110_add_meshcore_position_history.js';
import { migration as meshcoreNodePositionSourceMigration, runMigration111Postgres as runMeshcoreNodePositionSourcePostgres, runMigration111Mysql as runMeshcoreNodePositionSourceMysql } from '../server/migrations/111_meshcore_node_position_source.js';
import { migration as nodeNotesMigration, runMigration112Postgres as runNodeNotesPostgres, runMigration112Mysql as runNodeNotesMysql } from '../server/migrations/112_add_notes_to_nodes.js';
import { migration as xeddsaSignedMigration, runMigration113Postgres as runXeddsaSignedPostgres, runMigration113Mysql as runXeddsaSignedMysql } from '../server/migrations/113_add_xeddsa_signed.js';

// ============================================================================
// Registry
Expand Down Expand Up @@ -1782,3 +1783,20 @@ registry.register({
postgres: (client) => runNodeNotesPostgres(client),
mysql: (pool) => runNodeNotesMysql(pool),
});

// ---------------------------------------------------------------------------
// Migration 113: `xeddsaSigned` flag on `messages` (#3923)
// Records whether a received broadcast carried a cryptographically verified
// XEdDSA signature (Meshtastic firmware 2.8+, MeshPacket field 22). Surfaced
// as a "signed" shield in the message UI, mirroring the official mobile
// clients. Nullable; existing rows keep NULL.
// ---------------------------------------------------------------------------

registry.register({
number: 113,
name: 'add_xeddsa_signed',
settingsKey: 'migration_113_add_xeddsa_signed',
sqlite: (db) => xeddsaSignedMigration.up(db),
postgres: (client) => runXeddsaSignedPostgres(client),
mysql: (pool) => runXeddsaSignedMysql(pool),
});
21 changes: 21 additions & 0 deletions src/db/repositories/messages.insert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,4 +89,25 @@ describe('MessagesRepository.insertMessage duplicate detection', () => {
expect(result1).toBe(true);
expect(result2).toBe(true);
});

it('persists the xeddsaSigned flag (#3923)', async () => {
await repo.insertMessage({ ...makeMessage('msg-signed'), xeddsaSigned: true } as any);
await repo.insertMessage({ ...makeMessage('msg-unsigned'), xeddsaSigned: undefined } as any);

const signed = db
.prepare('SELECT xeddsaSigned FROM messages WHERE id = ?')
.get('msg-signed') as { xeddsaSigned: number | null };
const unsigned = db
.prepare('SELECT xeddsaSigned FROM messages WHERE id = ?')
.get('msg-unsigned') as { xeddsaSigned: number | null };

// SQLite stores booleans as 1 / NULL for absent.
expect(signed.xeddsaSigned).toBe(1);
expect(unsigned.xeddsaSigned == null).toBe(true);

// And it survives the async read path.
const rows = await repo.getMessages(50, 0);
const readBack = rows.find((r) => r.id === 'msg-signed');
expect(readBack?.xeddsaSigned).toBe(true);
});
});
2 changes: 2 additions & 0 deletions src/db/repositories/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export class MessagesRepository extends BaseRepository {
emoji: messageData.emoji ?? null,
viaMqtt: messageData.viaMqtt ?? null,
viaStoreForward: messageData.viaStoreForward ?? null,
xeddsaSigned: messageData.xeddsaSigned ?? null,
rxSnr: messageData.rxSnr ?? null,
rxRssi: messageData.rxRssi ?? null,
ackFailed: messageData.ackFailed ?? null,
Expand Down Expand Up @@ -351,6 +352,7 @@ export class MessagesRepository extends BaseRepository {
emoji: messageData.emoji ?? null,
viaMqtt: messageData.viaMqtt ?? null,
viaStoreForward: (messageData as any).viaStoreForward ?? null,
xeddsaSigned: (messageData as any).xeddsaSigned ?? null,
rxSnr: messageData.rxSnr ?? null,
rxRssi: messageData.rxRssi ?? null,
ackFailed: (messageData as any).ackFailed ?? null,
Expand Down
4 changes: 4 additions & 0 deletions src/db/schema/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export const messagesSqlite = sqliteTable('messages', {
emoji: integer('emoji'),
viaMqtt: integer('viaMqtt', { mode: 'boolean' }),
viaStoreForward: integer('viaStoreForward', { mode: 'boolean' }),
// XEdDSA packet signing (firmware 2.8+): broadcast had a verified signature.
xeddsaSigned: integer('xeddsaSigned', { mode: 'boolean' }),
rxSnr: real('rxSnr'),
rxRssi: real('rxRssi'),
// Delivery tracking
Expand Down Expand Up @@ -68,6 +70,7 @@ export const messagesPostgres = pgTable('messages', {
emoji: pgInteger('emoji'),
viaMqtt: pgBoolean('viaMqtt'),
viaStoreForward: pgBoolean('viaStoreForward'),
xeddsaSigned: pgBoolean('xeddsaSigned'),
rxSnr: pgReal('rxSnr'),
rxRssi: pgReal('rxRssi'),
// Delivery tracking
Expand Down Expand Up @@ -107,6 +110,7 @@ export const messagesMysql = mysqlTable('messages', {
emoji: myInt('emoji'),
viaMqtt: myBoolean('viaMqtt'),
viaStoreForward: myBoolean('viaStoreForward'),
xeddsaSigned: myBoolean('xeddsaSigned'),
rxSnr: myDouble('rxSnr'),
rxRssi: myDouble('rxRssi'),
// Delivery tracking
Expand Down
2 changes: 2 additions & 0 deletions src/db/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ export interface DbMessage {
emoji?: number | null;
viaMqtt?: boolean | null;
viaStoreForward?: boolean | null;
/** Broadcast carried a verified XEdDSA signature (firmware 2.8+). NULL for pre-migration rows / unsigned traffic. */
xeddsaSigned?: boolean | null;
rxSnr?: number | null;
rxRssi?: number | null;
ackFailed?: boolean | null;
Expand Down
12 changes: 12 additions & 0 deletions src/pages/UnifiedMessagesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ interface Reception {
rxRssi: number | null;
rxTime: number | null;
timestamp: number;
/** Broadcast carried a verified XEdDSA signature on this reception (firmware 2.8+). */
xeddsaSigned?: boolean | null;
}

interface UnifiedMessage {
Expand Down Expand Up @@ -670,6 +672,16 @@ export default function UnifiedMessagesPage() {
style={{ background: sourceColor(r.sourceId) }}
/>
{r.sourceName}
{r.xeddsaSigned ? (
<span
style={{ marginLeft: '4px', color: 'var(--success-color, #16a34a)' }}
title={t('messages.xeddsa_signed', 'Cryptographically signed (XEdDSA)')}
aria-label={t('messages.xeddsa_signed', 'Cryptographically signed (XEdDSA)')}
role="img"
>
🛡️
</span>
) : null}
</td>
<td>{hopDisplay(r.hopStart, r.hopLimit, t)}</td>
<td>{r.rxSnr != null ? `${r.rxSnr.toFixed(1)} dB` : '—'}</td>
Expand Down
6 changes: 6 additions & 0 deletions src/server/meshtasticManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,7 @@ type TextMessage = {
createdAt: number;
decryptedBy?: 'node' | 'server' | null; // Decryption source - 'server' means read-only
viaStoreForward?: boolean; // Message received via Store & Forward replay
xeddsaSigned?: boolean; // Broadcast had a cryptographically verified XEdDSA signature (firmware 2.8+)
sourceIp?: string | null; // Per-message ingress attribution (client IP for HTTP injects)
sourcePath?: 'http_api' | 'tcp_radio' | 'mqtt_bridge' | 'system' | null;
spoofSuspected?: boolean; // #2584 — claims from == our local node but arrived over RF
Expand Down Expand Up @@ -5871,6 +5872,10 @@ class MeshtasticManager implements ISourceManager {
createdAt: Date.now(),
decryptedBy: context?.decryptedBy ?? null, // Track decryption source - 'server' means read-only
viaStoreForward: context?.viaStoreForward === true ? true : undefined, // Message received via Store & Forward replay
// XEdDSA signing (firmware 2.8+): the node reports whether it
// cryptographically verified this broadcast's signature. Read both
// camelCase and snake_case for protobufjs decode-shape safety.
xeddsaSigned: ((meshPacket as any).xeddsaSigned === true || (meshPacket as any).xeddsa_signed === true) ? true : undefined,
// Inbound radio path — message arrived from a meshtastic node over TCP.
// MQTT-bridged inbound packets are flagged via viaMqtt above; we still
// attribute the row's ingress to 'tcp_radio' because it arrived via
Expand Down Expand Up @@ -14286,6 +14291,7 @@ class MeshtasticManager implements ISourceManager {
replyId: msg.replyId ?? undefined,
emoji: msg.emoji ?? undefined,
viaMqtt: Boolean(msg.viaMqtt),
xeddsaSigned: (msg as any).xeddsaSigned ? true : undefined,
rxSnr: msg.rxSnr ?? undefined,
rxRssi: msg.rxRssi ?? undefined,
// Include delivery tracking fields
Expand Down
38 changes: 38 additions & 0 deletions src/server/migrations/113_add_xeddsa_signed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Migration 113: Add xeddsaSigned column to messages table
*
* Adds a boolean column recording whether a received broadcast carried a
* cryptographically verified XEdDSA signature (Meshtastic firmware 2.8+,
* MeshPacket field 22 `xeddsa_signed`). Follows the same additive, nullable
* pattern as the existing viaMqtt / viaStoreForward columns.
*/
import type { Database } from 'better-sqlite3';

// SQLite migration
export const migration = {
up(db: Database) {
try {
db.exec(`ALTER TABLE messages ADD COLUMN "xeddsaSigned" INTEGER`);
} catch (e: any) {
if (!e.message?.includes('duplicate column')) throw e;
}
},
};

// PostgreSQL migration
export async function runMigration113Postgres(client: any): Promise<void> {
await client.query(`
ALTER TABLE messages ADD COLUMN IF NOT EXISTS "xeddsaSigned" BOOLEAN
`);
}

// MySQL migration
export async function runMigration113Mysql(pool: any): Promise<void> {
const [rows] = await pool.query(`
SELECT COLUMN_NAME FROM information_schema.COLUMNS
WHERE TABLE_NAME = 'messages' AND COLUMN_NAME = 'xeddsaSigned'
`);
if ((rows as any[]).length === 0) {
await pool.query(`ALTER TABLE messages ADD COLUMN \`xeddsaSigned\` BOOLEAN`);
}
}
7 changes: 7 additions & 0 deletions src/server/protobufLoader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,13 @@ export interface MeshPacket {
encrypted?: Uint8Array;
/** Transport mechanism - see TransportMechanism enum in constants/meshtastic.ts */
transportMechanism?: number;
/**
* XEdDSA packet signing (firmware 2.8+). True when the receiving node
* cryptographically verified the broadcast's signature (MeshPacket field 22,
* `xeddsa_signed`). Absent/false on packets from pre-2.8 firmware or unsigned
* traffic.
*/
xeddsaSigned?: boolean;
}

export interface Data {
Expand Down
Loading
Loading