Compare commits

...

1 Commits

Author SHA1 Message Date
l5y
bb7a09cb6f web: decryption confidence scoring 2026-01-11 08:38:24 +01:00
8 changed files with 135 additions and 17 deletions

View File

@@ -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);

View File

@@ -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")

View File

@@ -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'",

View File

@@ -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.

View File

@@ -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(

View File

@@ -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?

View File

@@ -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)")

View File

@@ -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