mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-07-21 09:02:54 +02:00
web: decryption confidence scoring
This commit is contained in:
+3
-1
@@ -29,7 +29,9 @@ CREATE TABLE IF NOT EXISTS messages (
|
|||||||
modem_preset TEXT,
|
modem_preset TEXT,
|
||||||
channel_name TEXT,
|
channel_name TEXT,
|
||||||
reply_id INTEGER,
|
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);
|
CREATE INDEX IF NOT EXISTS idx_messages_rx_time ON messages(rx_time);
|
||||||
|
|||||||
@@ -1497,6 +1497,7 @@ module PotatoMesh
|
|||||||
portnum: data[:portnum],
|
portnum: data[:portnum],
|
||||||
payload: data[:payload],
|
payload: data[:payload],
|
||||||
channel_name: channel_name,
|
channel_name: channel_name,
|
||||||
|
decryption_confidence: data[:decryption_confidence],
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -1568,9 +1569,11 @@ module PotatoMesh
|
|||||||
decrypted_payload = nil
|
decrypted_payload = nil
|
||||||
decrypted_text = nil
|
decrypted_text = nil
|
||||||
decrypted_portnum = nil
|
decrypted_portnum = nil
|
||||||
|
decrypted_flag = false
|
||||||
|
decryption_confidence = nil
|
||||||
|
|
||||||
if encrypted && (text.nil? || text.to_s.strip.empty?)
|
if encrypted && (text.nil? || text.to_s.strip.empty?)
|
||||||
decrypted = decrypt_meshtastic_message(
|
decrypted_data = decrypt_meshtastic_message(
|
||||||
message,
|
message,
|
||||||
msg_id,
|
msg_id,
|
||||||
from_id,
|
from_id,
|
||||||
@@ -1578,17 +1581,19 @@ module PotatoMesh
|
|||||||
channel_index,
|
channel_index,
|
||||||
)
|
)
|
||||||
|
|
||||||
if decrypted
|
if decrypted_data
|
||||||
decrypted_payload = decrypted
|
decrypted_payload = decrypted_data
|
||||||
decrypted_portnum = decrypted[:portnum]
|
decrypted_portnum = decrypted_data[:portnum]
|
||||||
|
|
||||||
if decrypted[:text]
|
if decrypted_data[:text]
|
||||||
text = decrypted[:text]
|
text = decrypted_data[:text]
|
||||||
decrypted_text = text
|
decrypted_text = text
|
||||||
clear_encrypted = true
|
clear_encrypted = true
|
||||||
encrypted = nil
|
encrypted = nil
|
||||||
message["text"] = text
|
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
|
if portnum.nil? && decrypted_portnum
|
||||||
portnum = decrypted_portnum
|
portnum = decrypted_portnum
|
||||||
message["portnum"] = portnum
|
message["portnum"] = portnum
|
||||||
@@ -1626,11 +1631,13 @@ module PotatoMesh
|
|||||||
channel_name,
|
channel_name,
|
||||||
reply_id,
|
reply_id,
|
||||||
emoji,
|
emoji,
|
||||||
|
decrypted_flag ? 1 : 0,
|
||||||
|
decryption_confidence,
|
||||||
]
|
]
|
||||||
|
|
||||||
with_busy_retry do
|
with_busy_retry do
|
||||||
existing = db.get_first_row(
|
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],
|
[msg_id],
|
||||||
)
|
)
|
||||||
if existing
|
if existing
|
||||||
@@ -1685,6 +1692,11 @@ module PotatoMesh
|
|||||||
updates["rx_iso"] = rx_iso if rx_iso
|
updates["rx_iso"] = rx_iso if rx_iso
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if clear_encrypted
|
||||||
|
updates["decrypted"] = 1
|
||||||
|
updates["decryption_confidence"] = decryption_confidence
|
||||||
|
end
|
||||||
|
|
||||||
if portnum
|
if portnum
|
||||||
existing_portnum = existing.is_a?(Hash) ? existing["portnum"] : existing[9]
|
existing_portnum = existing.is_a?(Hash) ? existing["portnum"] : existing[9]
|
||||||
existing_portnum_str = existing_portnum&.to_s
|
existing_portnum_str = existing_portnum&.to_s
|
||||||
@@ -1737,8 +1749,8 @@ module PotatoMesh
|
|||||||
|
|
||||||
begin
|
begin
|
||||||
db.execute <<~SQL, row
|
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)
|
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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||||
SQL
|
SQL
|
||||||
rescue SQLite3::ConstraintException
|
rescue SQLite3::ConstraintException
|
||||||
existing_row = db.get_first_row(
|
existing_row = db.get_first_row(
|
||||||
@@ -1759,6 +1771,10 @@ module PotatoMesh
|
|||||||
fallback_updates["encrypted"] = encrypted if encrypted && allow_encrypted_update
|
fallback_updates["encrypted"] = encrypted if encrypted && allow_encrypted_update
|
||||||
fallback_updates["encrypted"] = nil if clear_encrypted
|
fallback_updates["encrypted"] = nil if clear_encrypted
|
||||||
fallback_updates["portnum"] = portnum if portnum
|
fallback_updates["portnum"] = portnum if portnum
|
||||||
|
if clear_encrypted
|
||||||
|
fallback_updates["decrypted"] = 1
|
||||||
|
fallback_updates["decryption_confidence"] = decryption_confidence
|
||||||
|
end
|
||||||
if decrypted_precedence
|
if decrypted_precedence
|
||||||
fallback_updates["channel"] = message["channel"] if message.key?("channel")
|
fallback_updates["channel"] = message["channel"] if message.key?("channel")
|
||||||
fallback_updates["snr"] = message["snr"] if message.key?("snr")
|
fallback_updates["snr"] = message["snr"] if message.key?("snr")
|
||||||
|
|||||||
@@ -150,6 +150,16 @@ module PotatoMesh
|
|||||||
message_columns << "emoji"
|
message_columns << "emoji"
|
||||||
end
|
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 =
|
reply_index_exists =
|
||||||
db.get_first_value(
|
db.get_first_value(
|
||||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_messages_reply_id'",
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name='idx_messages_reply_id'",
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ module PotatoMesh
|
|||||||
|
|
||||||
DEFAULT_PSK_B64 = "AQ=="
|
DEFAULT_PSK_B64 = "AQ=="
|
||||||
TEXT_MESSAGE_PORTNUM = 1
|
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.
|
# Decrypt an encrypted Meshtastic payload into UTF-8 text.
|
||||||
#
|
#
|
||||||
@@ -78,12 +80,21 @@ module PotatoMesh
|
|||||||
return nil unless data
|
return nil unless data
|
||||||
|
|
||||||
text = nil
|
text = nil
|
||||||
|
decryption_confidence = nil
|
||||||
if data[:portnum] == TEXT_MESSAGE_PORTNUM
|
if data[:portnum] == TEXT_MESSAGE_PORTNUM
|
||||||
candidate = data[:payload].dup.force_encoding("UTF-8")
|
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
|
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
|
rescue ArgumentError, OpenSSL::Cipher::CipherError
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
@@ -154,6 +165,25 @@ module PotatoMesh
|
|||||||
nil
|
nil
|
||||||
end
|
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.
|
# Resolve the node number from any of the supported identifiers.
|
||||||
#
|
#
|
||||||
# @param from_id [String, nil] Meshtastic node identifier.
|
# @param from_id [String, nil] Meshtastic node identifier.
|
||||||
|
|||||||
@@ -357,7 +357,7 @@ module PotatoMesh
|
|||||||
SELECT m.id, m.rx_time, m.rx_iso, m.from_id, m.to_id, m.channel,
|
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.portnum, m.text, m.encrypted, m.rssi, m.hop_limit,
|
||||||
m.lora_freq, m.modem_preset, m.channel_name, m.snr,
|
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
|
FROM messages m
|
||||||
SQL
|
SQL
|
||||||
sql += " WHERE #{where_clauses.join(" AND ")}\n"
|
sql += " WHERE #{where_clauses.join(" AND ")}\n"
|
||||||
@@ -374,6 +374,27 @@ module PotatoMesh
|
|||||||
if string_or_nil(r["encrypted"])
|
if string_or_nil(r["encrypted"])
|
||||||
r.delete("portnum")
|
r.delete("portnum")
|
||||||
end
|
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?)
|
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
|
raw = db.execute("SELECT * FROM messages WHERE id = ?", [r["id"]]).first
|
||||||
debug_log(
|
debug_log(
|
||||||
|
|||||||
@@ -4007,13 +4007,16 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
|||||||
with_db(readonly: true) do |db|
|
with_db(readonly: true) do |db|
|
||||||
db.results_as_hash = true
|
db.results_as_hash = true
|
||||||
row = db.get_first_row(
|
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"]],
|
[payload["packet_id"]],
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(row["text"]).to eq("Nabend")
|
expect(row["text"]).to eq("Nabend")
|
||||||
expect(row["encrypted"]).to be_nil
|
expect(row["encrypted"]).to be_nil
|
||||||
expect(row["channel_name"]).to eq("BerlinMesh")
|
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
|
end
|
||||||
ensure
|
ensure
|
||||||
if previous_psk.nil?
|
if previous_psk.nil?
|
||||||
@@ -4087,13 +4090,15 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
|||||||
with_db(readonly: true) do |db|
|
with_db(readonly: true) do |db|
|
||||||
db.results_as_hash = true
|
db.results_as_hash = true
|
||||||
row = db.get_first_row(
|
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"]],
|
[payload["packet_id"]],
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(row["text"]).to be_nil
|
expect(row["text"]).to be_nil
|
||||||
expect(row["encrypted"]).to eq(encrypted_payload)
|
expect(row["encrypted"]).to eq(encrypted_payload)
|
||||||
expect(row["portnum"]).to be_nil
|
expect(row["portnum"]).to be_nil
|
||||||
|
expect(row["decrypted"]).to eq(0)
|
||||||
|
expect(row["decryption_confidence"]).to be_nil
|
||||||
end
|
end
|
||||||
ensure
|
ensure
|
||||||
if previous_psk.nil?
|
if previous_psk.nil?
|
||||||
|
|||||||
@@ -166,6 +166,20 @@ RSpec.describe PotatoMesh::App::Database do
|
|||||||
expect(telemetry_columns).to include("rx_time", "battery_level")
|
expect(telemetry_columns).to include("rx_time", "battery_level")
|
||||||
end
|
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
|
it "creates trace tables when absent" do
|
||||||
SQLite3::Database.new(PotatoMesh::Config.db_path) do |db|
|
SQLite3::Database.new(PotatoMesh::Config.db_path) do |db|
|
||||||
db.execute("CREATE TABLE nodes(node_id TEXT)")
|
db.execute("CREATE TABLE nodes(node_id TEXT)")
|
||||||
|
|||||||
@@ -143,6 +143,18 @@ RSpec.describe PotatoMesh::App::Meshtastic::Cipher do
|
|||||||
expect(text).to eq("Nabend")
|
expect(text).to eq("Nabend")
|
||||||
end
|
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
|
it "decrypts the public PSK alias sample payload" do
|
||||||
text = described_class.decrypt_text(
|
text = described_class.decrypt_text(
|
||||||
cipher_b64: "otu3OyMrTIUlcaisLVDyAnLW",
|
cipher_b64: "otu3OyMrTIUlcaisLVDyAnLW",
|
||||||
@@ -196,7 +208,7 @@ RSpec.describe PotatoMesh::App::Meshtastic::Cipher do
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(text).to be_nil
|
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
|
end
|
||||||
|
|
||||||
it "normalizes packet ids from numeric strings" do
|
it "normalizes packet ids from numeric strings" do
|
||||||
@@ -277,4 +289,12 @@ RSpec.describe PotatoMesh::App::Meshtastic::Cipher do
|
|||||||
|
|
||||||
expect(data).to be_nil
|
expect(data).to be_nil
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user