+1 and
@@ -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);
diff --git a/src/db/migrations.ts b/src/db/migrations.ts
index eacd1d62f..f1003560e 100644
--- a/src/db/migrations.ts
+++ b/src/db/migrations.ts
@@ -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
@@ -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),
+});
diff --git a/src/db/repositories/messages.insert.test.ts b/src/db/repositories/messages.insert.test.ts
index 9d1e1781c..c21df9ced 100644
--- a/src/db/repositories/messages.insert.test.ts
+++ b/src/db/repositories/messages.insert.test.ts
@@ -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);
+ });
});
diff --git a/src/db/repositories/messages.ts b/src/db/repositories/messages.ts
index f8e6bb6d0..fbe85b2ac 100644
--- a/src/db/repositories/messages.ts
+++ b/src/db/repositories/messages.ts
@@ -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,
@@ -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,
diff --git a/src/db/schema/messages.ts b/src/db/schema/messages.ts
index d2dd8a698..4644869f1 100644
--- a/src/db/schema/messages.ts
+++ b/src/db/schema/messages.ts
@@ -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
@@ -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
@@ -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
diff --git a/src/db/types.ts b/src/db/types.ts
index 985d4f62a..76c7c8dad 100644
--- a/src/db/types.ts
+++ b/src/db/types.ts
@@ -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;
diff --git a/src/pages/UnifiedMessagesPage.tsx b/src/pages/UnifiedMessagesPage.tsx
index 8b703935e..769a5a058 100644
--- a/src/pages/UnifiedMessagesPage.tsx
+++ b/src/pages/UnifiedMessagesPage.tsx
@@ -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 {
@@ -670,6 +672,16 @@ export default function UnifiedMessagesPage() {
style={{ background: sourceColor(r.sourceId) }}
/>
{r.sourceName}
+ {r.xeddsaSigned ? (
+
+ 🛡️
+
+ ) : null}
| {hopDisplay(r.hopStart, r.hopLimit, t)} |
{r.rxSnr != null ? `${r.rxSnr.toFixed(1)} dB` : '—'} |
diff --git a/src/server/meshtasticManager.ts b/src/server/meshtasticManager.ts
index 7b36e0022..5d766c8b0 100644
--- a/src/server/meshtasticManager.ts
+++ b/src/server/meshtasticManager.ts
@@ -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
@@ -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
@@ -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
diff --git a/src/server/migrations/113_add_xeddsa_signed.ts b/src/server/migrations/113_add_xeddsa_signed.ts
new file mode 100644
index 000000000..3744e168b
--- /dev/null
+++ b/src/server/migrations/113_add_xeddsa_signed.ts
@@ -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 {
+ await client.query(`
+ ALTER TABLE messages ADD COLUMN IF NOT EXISTS "xeddsaSigned" BOOLEAN
+ `);
+}
+
+// MySQL migration
+export async function runMigration113Mysql(pool: any): Promise {
+ 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`);
+ }
+}
diff --git a/src/server/protobufLoader.ts b/src/server/protobufLoader.ts
index 211eeb3c1..290694201 100644
--- a/src/server/protobufLoader.ts
+++ b/src/server/protobufLoader.ts
@@ -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 {
diff --git a/src/server/routes/unifiedRoutes.ts b/src/server/routes/unifiedRoutes.ts
index 345d879b7..2c2652074 100644
--- a/src/server/routes/unifiedRoutes.ts
+++ b/src/server/routes/unifiedRoutes.ts
@@ -426,6 +426,8 @@ router.get('/messages', async (req: Request, res: Response) => {
rxRssi: number | null;
rxTime: number | null;
timestamp: number;
+ /** Broadcast carried a verified XEdDSA signature on this reception (firmware 2.8+). */
+ xeddsaSigned: boolean | null;
};
type Merged = {
dedupKey: string;
@@ -539,6 +541,8 @@ router.get('/messages', async (req: Request, res: Response) => {
rxRssi: m.rssi ?? null,
rxTime: null,
timestamp: m.timestamp,
+ // MeshCore has no XEdDSA packet signing concept.
+ xeddsaSigned: null,
};
// MeshCore ids are unique within a source; we don't cross-source-dedup
// MeshCore (each radio is a distinct receiver), so key by source + id.
@@ -763,6 +767,7 @@ router.get('/messages', async (req: Request, res: Response) => {
rxRssi: m.rxRssi ?? null,
rxTime,
timestamp: m.timestamp,
+ xeddsaSigned: (m as any).xeddsaSigned ?? null,
};
const existing = merged.get(dedupKey);
diff --git a/src/server/server.ts b/src/server/server.ts
index 582e44e2b..6691b88c3 100644
--- a/src/server/server.ts
+++ b/src/server/server.ts
@@ -2514,6 +2514,7 @@ function transformDbMessageToMeshMessage(msg: DbMessage): MeshMessage {
replyId: msg.replyId ?? undefined,
emoji: msg.emoji ?? undefined,
viaMqtt: Boolean((msg as any).viaMqtt),
+ xeddsaSigned: (msg as any).xeddsaSigned ? true : undefined,
rxSnr: msg.rxSnr ?? undefined,
rxRssi: msg.rxRssi ?? undefined,
requestId: (msg as any).requestId,
diff --git a/src/services/database.ts b/src/services/database.ts
index 5c7169715..295c60684 100644
--- a/src/services/database.ts
+++ b/src/services/database.ts
@@ -135,6 +135,8 @@ export interface DbMessage {
replyId?: number;
emoji?: number;
viaMqtt?: boolean;
+ /** Broadcast carried a verified XEdDSA signature (firmware 2.8+). */
+ xeddsaSigned?: boolean;
rxSnr?: number;
rxRssi?: number;
createdAt: number;
@@ -3079,6 +3081,7 @@ class DatabaseService {
replyId: msg.replyId ?? undefined,
emoji: msg.emoji ?? undefined,
viaMqtt: msg.viaMqtt ?? undefined,
+ xeddsaSigned: msg.xeddsaSigned ?? undefined,
rxSnr: msg.rxSnr ?? undefined,
rxRssi: msg.rxRssi ?? undefined,
ackFailed: msg.ackFailed ?? undefined,
@@ -8741,6 +8744,7 @@ class DatabaseService {
replyId: result.replyId ?? undefined,
emoji: result.emoji ?? undefined,
viaMqtt: result.viaMqtt ?? undefined,
+ xeddsaSigned: result.xeddsaSigned ?? undefined,
rxSnr: result.rxSnr ?? undefined,
rxRssi: result.rxRssi ?? undefined,
ackFailed: result.ackFailed ?? undefined,
diff --git a/src/types/message.ts b/src/types/message.ts
index 87262f309..9a29ce139 100644
--- a/src/types/message.ts
+++ b/src/types/message.ts
@@ -37,6 +37,7 @@ export interface MeshMessage {
emoji?: number;
viaMqtt?: boolean; // Whether message was received via MQTT bridge
viaStoreForward?: boolean; // Whether message was received via Store & Forward replay
+ xeddsaSigned?: boolean; // Broadcast carried a cryptographically verified XEdDSA signature (firmware 2.8+)
rxSnr?: number; // SNR of received packet (for direct messages)
rxRssi?: number; // RSSI of received packet (for direct messages)
// Enhanced delivery tracking