From bb7a09cb6fb3d13ccef0f656d4bdce2723d7890d Mon Sep 17 00:00:00 2001 From: l5y <220195275+l5yth@users.noreply.github.com> Date: Sun, 11 Jan 2026 08:38:24 +0100 Subject: [PATCH] web: decryption confidence scoring --- data/messages.sql | 4 ++- .../application/data_processing.rb | 36 +++++++++++++------ web/lib/potato_mesh/application/database.rb | 10 ++++++ .../application/meshtastic/cipher.rb | 34 ++++++++++++++++-- web/lib/potato_mesh/application/queries.rb | 23 +++++++++++- web/spec/app_spec.rb | 9 +++-- web/spec/database_spec.rb | 14 ++++++++ web/spec/meshtastic_cipher_spec.rb | 22 +++++++++++- 8 files changed, 135 insertions(+), 17 deletions(-) diff --git a/data/messages.sql b/data/messages.sql index c401799..fead3c6 100644 --- a/data/messages.sql +++ b/data/messages.sql @@ -29,7 +29,9 @@ CREATE TABLE IF NOT EXISTS messages ( modem_preset TEXT, channel_name TEXT, reply_id INTEGER, - emoji TEXT + emoji TEXT, + decrypted INTEGER NOT NULL DEFAULT 0, + decryption_confidence REAL ); CREATE INDEX IF NOT EXISTS idx_messages_rx_time ON messages(rx_time); diff --git a/web/lib/potato_mesh/application/data_processing.rb b/web/lib/potato_mesh/application/data_processing.rb index 2d6a7d3..2f53176 100644 --- a/web/lib/potato_mesh/application/data_processing.rb +++ b/web/lib/potato_mesh/application/data_processing.rb @@ -1497,6 +1497,7 @@ module PotatoMesh portnum: data[:portnum], payload: data[:payload], channel_name: channel_name, + decryption_confidence: data[:decryption_confidence], } end @@ -1568,9 +1569,11 @@ module PotatoMesh decrypted_payload = nil decrypted_text = nil decrypted_portnum = nil + decrypted_flag = false + decryption_confidence = nil if encrypted && (text.nil? || text.to_s.strip.empty?) - decrypted = decrypt_meshtastic_message( + decrypted_data = decrypt_meshtastic_message( message, msg_id, from_id, @@ -1578,17 +1581,19 @@ module PotatoMesh channel_index, ) - if decrypted - decrypted_payload = decrypted - decrypted_portnum = decrypted[:portnum] + if decrypted_data + decrypted_payload = decrypted_data + decrypted_portnum = decrypted_data[:portnum] - if decrypted[:text] - text = decrypted[:text] + if decrypted_data[:text] + text = decrypted_data[:text] decrypted_text = text clear_encrypted = true encrypted = nil message["text"] = text - message["channel_name"] ||= decrypted[:channel_name] + message["channel_name"] ||= decrypted_data[:channel_name] + decrypted_flag = true + decryption_confidence = decrypted_data[:decryption_confidence] || 0.0 if portnum.nil? && decrypted_portnum portnum = decrypted_portnum message["portnum"] = portnum @@ -1626,11 +1631,13 @@ module PotatoMesh channel_name, reply_id, emoji, + decrypted_flag ? 1 : 0, + decryption_confidence, ] with_busy_retry do existing = db.get_first_row( - "SELECT from_id, to_id, text, encrypted, lora_freq, modem_preset, channel_name, reply_id, emoji, portnum FROM messages WHERE id = ?", + "SELECT from_id, to_id, text, encrypted, lora_freq, modem_preset, channel_name, reply_id, emoji, portnum, decrypted, decryption_confidence FROM messages WHERE id = ?", [msg_id], ) if existing @@ -1685,6 +1692,11 @@ module PotatoMesh updates["rx_iso"] = rx_iso if rx_iso end + if clear_encrypted + updates["decrypted"] = 1 + updates["decryption_confidence"] = decryption_confidence + end + if portnum existing_portnum = existing.is_a?(Hash) ? existing["portnum"] : existing[9] existing_portnum_str = existing_portnum&.to_s @@ -1737,8 +1749,8 @@ module PotatoMesh begin db.execute <<~SQL, row - INSERT INTO messages(id,rx_time,rx_iso,from_id,to_id,channel,portnum,text,encrypted,snr,rssi,hop_limit,lora_freq,modem_preset,channel_name,reply_id,emoji) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + INSERT INTO messages(id,rx_time,rx_iso,from_id,to_id,channel,portnum,text,encrypted,snr,rssi,hop_limit,lora_freq,modem_preset,channel_name,reply_id,emoji,decrypted,decryption_confidence) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) SQL rescue SQLite3::ConstraintException existing_row = db.get_first_row( @@ -1759,6 +1771,10 @@ module PotatoMesh fallback_updates["encrypted"] = encrypted if encrypted && allow_encrypted_update fallback_updates["encrypted"] = nil if clear_encrypted fallback_updates["portnum"] = portnum if portnum + if clear_encrypted + fallback_updates["decrypted"] = 1 + fallback_updates["decryption_confidence"] = decryption_confidence + end if decrypted_precedence fallback_updates["channel"] = message["channel"] if message.key?("channel") fallback_updates["snr"] = message["snr"] if message.key?("snr") diff --git a/web/lib/potato_mesh/application/database.rb b/web/lib/potato_mesh/application/database.rb index a94a884..83a13fb 100644 --- a/web/lib/potato_mesh/application/database.rb +++ b/web/lib/potato_mesh/application/database.rb @@ -150,6 +150,16 @@ module PotatoMesh message_columns << "emoji" end + unless message_columns.include?("decrypted") + db.execute("ALTER TABLE messages ADD COLUMN decrypted INTEGER NOT NULL DEFAULT 0") + message_columns << "decrypted" + end + + unless message_columns.include?("decryption_confidence") + db.execute("ALTER TABLE messages ADD COLUMN decryption_confidence REAL") + message_columns << "decryption_confidence" + end + reply_index_exists = db.get_first_value( "SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_messages_reply_id'", diff --git a/web/lib/potato_mesh/application/meshtastic/cipher.rb b/web/lib/potato_mesh/application/meshtastic/cipher.rb index a996f40..106bc61 100644 --- a/web/lib/potato_mesh/application/meshtastic/cipher.rb +++ b/web/lib/potato_mesh/application/meshtastic/cipher.rb @@ -29,6 +29,8 @@ module PotatoMesh DEFAULT_PSK_B64 = "AQ==" TEXT_MESSAGE_PORTNUM = 1 + # Number of characters required for full confidence scoring. + CONFIDENCE_LENGTH_TARGET = 8.0 # Decrypt an encrypted Meshtastic payload into UTF-8 text. # @@ -78,12 +80,21 @@ module PotatoMesh return nil unless data text = nil + decryption_confidence = nil if data[:portnum] == TEXT_MESSAGE_PORTNUM candidate = data[:payload].dup.force_encoding("UTF-8") - text = candidate if candidate.valid_encoding? && !candidate.empty? + if candidate.valid_encoding? && !candidate.empty? + text = candidate + decryption_confidence = text_confidence(text) + end end - { portnum: data[:portnum], payload: data[:payload], text: text } + { + portnum: data[:portnum], + payload: data[:payload], + text: text, + decryption_confidence: decryption_confidence, + } rescue ArgumentError, OpenSSL::Cipher::CipherError nil end @@ -154,6 +165,25 @@ module PotatoMesh nil end + # Score the plausibility of decrypted text content. + # + # @param text [String] decrypted text candidate. + # @return [Float] confidence score between 0.0 and 1.0. + def text_confidence(text) + return 0.0 unless text.is_a?(String) + return 0.0 if text.empty? + + total = text.length.to_f + length_score = [total / CONFIDENCE_LENGTH_TARGET, 1.0].min + control_count = text.scan(/[\p{Cc}\p{Cs}]/).length + control_ratio = control_count / total + acceptable_count = text.scan(/[\p{L}\p{N}\p{P}\p{S}\p{Zs}\t\n\r]/).length + acceptable_ratio = acceptable_count / total + + score = length_score * acceptable_ratio * (1.0 - control_ratio) + score.clamp(0.0, 1.0) + end + # Resolve the node number from any of the supported identifiers. # # @param from_id [String, nil] Meshtastic node identifier. diff --git a/web/lib/potato_mesh/application/queries.rb b/web/lib/potato_mesh/application/queries.rb index 1d268b0..5536c01 100644 --- a/web/lib/potato_mesh/application/queries.rb +++ b/web/lib/potato_mesh/application/queries.rb @@ -357,7 +357,7 @@ module PotatoMesh SELECT m.id, m.rx_time, m.rx_iso, m.from_id, m.to_id, m.channel, m.portnum, m.text, m.encrypted, m.rssi, m.hop_limit, m.lora_freq, m.modem_preset, m.channel_name, m.snr, - m.reply_id, m.emoji + m.reply_id, m.emoji, m.decrypted, m.decryption_confidence FROM messages m SQL sql += " WHERE #{where_clauses.join(" AND ")}\n" @@ -374,6 +374,27 @@ module PotatoMesh if string_or_nil(r["encrypted"]) r.delete("portnum") end + + if r.key?("decrypted") + decrypted_raw = r["decrypted"] + decrypted = case decrypted_raw + when true, false + decrypted_raw + when Integer + !decrypted_raw.zero? + when String + trimmed = decrypted_raw.strip + !trimmed.empty? && trimmed != "0" && trimmed.casecmp("false") != 0 + else + !!decrypted_raw + end + r["decrypted"] = decrypted + r.delete("decryption_confidence") unless decrypted + end + + if r.key?("decryption_confidence") && !r["decryption_confidence"].nil? + r["decryption_confidence"] = r["decryption_confidence"].to_f + end if PotatoMesh::Config.debug? && (r["from_id"].nil? || r["from_id"].to_s.strip.empty?) raw = db.execute("SELECT * FROM messages WHERE id = ?", [r["id"]]).first debug_log( diff --git a/web/spec/app_spec.rb b/web/spec/app_spec.rb index 22e83e8..bf87e9c 100644 --- a/web/spec/app_spec.rb +++ b/web/spec/app_spec.rb @@ -4007,13 +4007,16 @@ RSpec.describe "Potato Mesh Sinatra app" do with_db(readonly: true) do |db| db.results_as_hash = true row = db.get_first_row( - "SELECT text, encrypted, channel_name FROM messages WHERE id = ?", + "SELECT text, encrypted, channel_name, decrypted, decryption_confidence FROM messages WHERE id = ?", [payload["packet_id"]], ) expect(row["text"]).to eq("Nabend") expect(row["encrypted"]).to be_nil expect(row["channel_name"]).to eq("BerlinMesh") + expect(row["decrypted"]).to eq(1) + expect(row["decryption_confidence"]).to be > 0.0 + expect(row["decryption_confidence"]).to be <= 1.0 end ensure if previous_psk.nil? @@ -4087,13 +4090,15 @@ RSpec.describe "Potato Mesh Sinatra app" do with_db(readonly: true) do |db| db.results_as_hash = true row = db.get_first_row( - "SELECT text, encrypted, portnum FROM messages WHERE id = ?", + "SELECT text, encrypted, portnum, decrypted, decryption_confidence FROM messages WHERE id = ?", [payload["packet_id"]], ) expect(row["text"]).to be_nil expect(row["encrypted"]).to eq(encrypted_payload) expect(row["portnum"]).to be_nil + expect(row["decrypted"]).to eq(0) + expect(row["decryption_confidence"]).to be_nil end ensure if previous_psk.nil? diff --git a/web/spec/database_spec.rb b/web/spec/database_spec.rb index 316dd44..7b21fd5 100644 --- a/web/spec/database_spec.rb +++ b/web/spec/database_spec.rb @@ -166,6 +166,20 @@ RSpec.describe PotatoMesh::App::Database do expect(telemetry_columns).to include("rx_time", "battery_level") end + it "adds decryption metadata columns to existing messages tables" do + SQLite3::Database.new(PotatoMesh::Config.db_path) do |db| + db.execute("CREATE TABLE nodes(node_id TEXT)") + db.execute("CREATE TABLE messages(id INTEGER PRIMARY KEY)") + end + + expect(column_names_for("messages")).not_to include("decrypted", "decryption_confidence") + + harness_class.ensure_schema_upgrades + + message_columns = column_names_for("messages") + expect(message_columns).to include("decrypted", "decryption_confidence") + end + it "creates trace tables when absent" do SQLite3::Database.new(PotatoMesh::Config.db_path) do |db| db.execute("CREATE TABLE nodes(node_id TEXT)") diff --git a/web/spec/meshtastic_cipher_spec.rb b/web/spec/meshtastic_cipher_spec.rb index 813a0a0..ff9d6e6 100644 --- a/web/spec/meshtastic_cipher_spec.rb +++ b/web/spec/meshtastic_cipher_spec.rb @@ -143,6 +143,18 @@ RSpec.describe PotatoMesh::App::Meshtastic::Cipher do expect(text).to eq("Nabend") end + it "captures a confidence score for decrypted text" do + data = described_class.decrypt_data( + cipher_b64: cipher_b64, + packet_id: packet_id, + from_id: from_id, + psk_b64: psk_b64, + ) + + expect(data[:text]).to eq("Nabend") + expect(data[:decryption_confidence]).to be_between(0.0, 1.0) + end + it "decrypts the public PSK alias sample payload" do text = described_class.decrypt_text( cipher_b64: "otu3OyMrTIUlcaisLVDyAnLW", @@ -196,7 +208,7 @@ RSpec.describe PotatoMesh::App::Meshtastic::Cipher do ) expect(text).to be_nil - expect(data).to eq({ portnum: 3, payload: payload, text: nil }) + expect(data).to eq({ portnum: 3, payload: payload, text: nil, decryption_confidence: nil }) end it "normalizes packet ids from numeric strings" do @@ -277,4 +289,12 @@ RSpec.describe PotatoMesh::App::Meshtastic::Cipher do expect(data).to be_nil end + + it "scores text confidence higher for longer printable content" do + low = described_class.text_confidence("AC") + high = described_class.text_confidence("This looks like a sentence.") + + expect(low).to be < high + expect(high).to be_between(0.0, 1.0) + end end