From 4ad592688e88b802d3dbe8086376d6d12b98cbf6 Mon Sep 17 00:00:00 2001 From: Cleiton Augusto Date: Sun, 5 Jul 2026 08:13:40 -0400 Subject: [PATCH 1/6] Add Signal Messenger Android parser (#1602) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts individual and group conversations from Signal Android databases (signal.db) obtained through full-filesystem forensic acquisition. ## Architecture - SignalParser – Tika AbstractParser; MIME type application/x-signal-db - SignalExtractor – JDBC queries against the plaintext SQLite; validates required tables before parsing - SignalContact – Display name resolution priority: system_joined_name → profile_joined_name → profile_given_name+family → e164 → "Unknown" - SignalChat – Thread model (individual and group) - SignalMessage – Message model with MessageType enum - ReportGenerator – Produces Signal-themed chat bubble HTML ## Signal schema compatibility Targets Signal Android ≤5.15 (no aci column). Queries: recipient, thread, message, groups, group_membership tables. Message type classification uses the lower-5-bit mask from MessageTypes.kt: 0/2/3/4/5 → OUTGOING, 20 → INCOMING, 1 → CALL_OUTGOING, 21 → CALL_INCOMING, 22/25 → CALL_MISSED, else → SYSTEM. ## ExtraProperties alignment (vs WhatsApp/Threema parsers) - USER_ACCOUNT_TYPE set to "message/x-signal-message" on every message document (required for IPED's communication graph task). - GROUP_ID uses the actual Signal group_id from recipient.group_id, not a synthetic "SignalThread_". - MESSAGE_FROM / MESSAGE_TO / IS_GROUP_MESSAGE / PARTICIPANTS / MESSAGE_DATE / DECODED_DATA / HASCHILD aligned with established parser conventions. ## Category registration (CategoriesConfig.json) - Chats → Signal: added application/x-signal-db and application/x-signal-chat alongside existing UFED entry. - Instant Messages Artifacts → Instant Messages: added message/x-signal-message alongside x-threema-message, x-whatsapp-message, etc. ## Known Phase-1 limitation PARTICIPANTS does not include the device owner (self). Identifying self requires matching against aci or SharedPreferences outside signal.db; this is deferred to Phase 2. ## Tests (23 total) Synthetic SQLite fixture without aci column mimics pre-v5.15 backups. Coverage: named contacts, phone-only contacts, groups, sent/received/ null-body messages, emoji + HTML special chars, call types (outgoing/incoming/missed), system message filtering, group outgoing MESSAGE_TO (chat title, not "Unknown"), USER_ACCOUNT_TYPE on all message docs, GROUP_ID equals actual Signal group_id, extractMessages flag. --- .../config/conf/CategoriesConfig.json | 4 +- .../config/conf/CustomSignatures.xml | 13 + .../resources/config/conf/ParserConfig.xml | 5 + .../main/java/iped/app/ui/IconManager.java | 3 + .../iped/parsers/signal/ReportGenerator.java | 157 +++++++++++ .../java/iped/parsers/signal/SignalChat.java | 41 +++ .../iped/parsers/signal/SignalContact.java | 64 +++++ .../iped/parsers/signal/SignalExtractor.java | 209 +++++++++++++++ .../iped/parsers/signal/SignalMessage.java | 47 ++++ .../iped/parsers/signal/SignalParser.java | 208 +++++++++++++++ .../iped/parsers/signal/css/signal.css | 220 +++++++++++++++ .../parsers/signal/signal-html-template.txt | 19 ++ .../iped/parsers/signal/AbstractPkgTest.java | 125 +++++++++ .../iped/parsers/signal/SignalParserTest.java | 250 ++++++++++++++++++ .../test/resources/test-files/test_signal.db | Bin 0 -> 32768 bytes 15 files changed, 1363 insertions(+), 2 deletions(-) create mode 100644 iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/ReportGenerator.java create mode 100644 iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/SignalChat.java create mode 100644 iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/SignalContact.java create mode 100644 iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/SignalExtractor.java create mode 100644 iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/SignalMessage.java create mode 100644 iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/SignalParser.java create mode 100644 iped-parsers/iped-parsers-impl/src/main/resources/iped/parsers/signal/css/signal.css create mode 100644 iped-parsers/iped-parsers-impl/src/main/resources/iped/parsers/signal/signal-html-template.txt create mode 100644 iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/signal/AbstractPkgTest.java create mode 100644 iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/signal/SignalParserTest.java create mode 100644 iped-parsers/iped-parsers-impl/src/test/resources/test-files/test_signal.db diff --git a/iped-app/resources/config/conf/CategoriesConfig.json b/iped-app/resources/config/conf/CategoriesConfig.json index da48d64b3f..542dd57a86 100644 --- a/iped-app/resources/config/conf/CategoriesConfig.json +++ b/iped-app/resources/config/conf/CategoriesConfig.json @@ -91,7 +91,7 @@ {"name": "Jabber", "mimes":["application/x-jabber-chat"]}, {"name": "Facebook", "mimes":["application/x-ufed-chat-preview-facebook"]}, {"name": "Instagram", "mimes":["application/x-ufed-chat-preview-instagram"]}, - {"name": "Signal", "mimes":["application/x-ufed-chat-preview-signal"]}, + {"name": "Signal", "mimes":["application/x-signal-db", "application/x-signal-chat", "application/x-ufed-chat-preview-signal"]}, {"name": "Snapchat", "mimes":["application/x-ufed-chat-preview-snapchat"]}, {"name": "TikTok", "mimes":["application/x-ufed-chat-preview-tiktok"]}, {"name": "Viber", "mimes":["application/x-ufed-chat-preview-viber"]}, @@ -173,7 +173,7 @@ {"name": "SMS Messages", "mimes": ["application/x-ufed-html-sms", "application/x-ufed-sms", "application/x-ios-sms-db"]}, {"name": "MMS Messages", "mimes": ["application/x-ufed-html-mms", "application/x-ufed-mms"]}, {"name": "Instant Messages Artifacts", "categories":[ - {"name": "Instant Messages", "mimes": ["message/x-chat-message", "message/x-threema-message", "application/x-ufed-instantmessage", "message/x-whatsapp-message", "message/x-skype-message", "message/x-skype-filetransfer", "message/x-telegram-message", "message/x-discord-message"]}, + {"name": "Instant Messages", "mimes": ["message/x-chat-message", "message/x-signal-message", "message/x-threema-message", "application/x-ufed-instantmessage", "message/x-whatsapp-message", "message/x-skype-message", "message/x-skype-filetransfer", "message/x-telegram-message", "message/x-discord-message"]}, {"name": "Chat Activities", "mimes": ["application/x-ufed-chatactivity"]}, {"name": "Message Attachments", "mimes": ["application/x-ufed-attachment"]}, {"name": "Shared Contacts"} diff --git a/iped-app/resources/config/conf/CustomSignatures.xml b/iped-app/resources/config/conf/CustomSignatures.xml index 5a504fd03c..3c839f2ef5 100644 --- a/iped-app/resources/config/conf/CustomSignatures.xml +++ b/iped-app/resources/config/conf/CustomSignatures.xml @@ -360,6 +360,19 @@ + + + + + + + + + + + + + diff --git a/iped-app/resources/config/conf/ParserConfig.xml b/iped-app/resources/config/conf/ParserConfig.xml index 43f2ab18a7..4b64eb5838 100644 --- a/iped-app/resources/config/conf/ParserConfig.xml +++ b/iped-app/resources/config/conf/ParserConfig.xml @@ -323,6 +323,11 @@ false + + + true + + 6000000 diff --git a/iped-app/src/main/java/iped/app/ui/IconManager.java b/iped-app/src/main/java/iped/app/ui/IconManager.java index c34abfc921..13817e4d5d 100644 --- a/iped-app/src/main/java/iped/app/ui/IconManager.java +++ b/iped-app/src/main/java/iped/app/ui/IconManager.java @@ -304,6 +304,9 @@ private static Map initMimeToIconMap(int size) { icon = availableIconsMap.get("signal"); if (icon != null) { mimeIconMap.put("application/x-ufed-chat-preview-signal", icon); + mimeIconMap.put("application/x-signal-db", icon); + mimeIconMap.put("application/x-signal-chat", icon); + mimeIconMap.put("message/x-signal-message", icon); } icon = availableIconsMap.get("snapchat"); diff --git a/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/ReportGenerator.java b/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/ReportGenerator.java new file mode 100644 index 0000000000..cf4ccdc42e --- /dev/null +++ b/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/ReportGenerator.java @@ -0,0 +1,157 @@ +package iped.parsers.signal; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Date; +import java.util.TimeZone; + +import iped.utils.IOUtil; + +public class ReportGenerator { + + private static final String TEMPLATE; + private static final String CSS; + + static { + TEMPLATE = readResource("/iped/parsers/signal/signal-html-template.txt"); + CSS = readResource("/iped/parsers/signal/css/signal.css"); + } + + private static final ThreadLocal DATE_FMT = ThreadLocal.withInitial(() -> { + SimpleDateFormat f = new SimpleDateFormat("yyyy-MM-dd"); + f.setTimeZone(TimeZone.getDefault()); + return f; + }); + + private static final ThreadLocal TIME_FMT = ThreadLocal.withInitial(() -> { + SimpleDateFormat f = new SimpleDateFormat("HH:mm"); + f.setTimeZone(TimeZone.getDefault()); + return f; + }); + + public byte[] generateChatHtml(SignalChat chat) { + StringBuilder messages = new StringBuilder(1024); + Date lastDate = null; + + for (SignalMessage m : chat.getMessages()) { + SignalMessage.MessageType type = m.getMessageType(); + + if (type == SignalMessage.MessageType.SYSTEM) { + messages.append(renderSystemMessage(m)); + continue; + } + + Date msgDate = m.getDateSent() != null ? m.getDateSent() : m.getDateReceived(); + if (msgDate != null && !isSameDay(lastDate, msgDate)) { + messages.append(renderDateSeparator(msgDate)); + lastDate = msgDate; + } + + messages.append(renderMessage(m, chat)); + } + + String html = TEMPLATE + .replace("${css}", CSS) + .replace("${title}", escapeHtml(chat.getTitle())) + .replace("${messages}", messages.toString()) + .replace("${javascript}", ""); + + return html.getBytes(StandardCharsets.UTF_8); + } + + private String renderMessage(SignalMessage m, SignalChat chat) { + boolean incoming = !m.isFromMe(); + String bubbleClass = incoming ? "bbl" : "bbr"; + String dirClass = incoming ? "incoming" : "outgoing"; + + String bodyHtml = buildBodyHtml(m); + String timeHtml = "
" + formatTime(m.getDateSent()) + "
"; + + StringBuilder sb = new StringBuilder(256); + sb.append("
"); + sb.append("
"); + + if (incoming && chat.isGroupChat()) { + String senderName = resolveSenderName(m, chat); + if (senderName != null && !senderName.isEmpty()) { + sb.append("
").append(escapeHtml(senderName)).append("
"); + } + } + + sb.append(bodyHtml); + sb.append(timeHtml); + sb.append("
\n"); + return sb.toString(); + } + + private String buildBodyHtml(SignalMessage m) { + SignalMessage.MessageType type = m.getMessageType(); + if (type == SignalMessage.MessageType.CALL_OUTGOING) { + return "
📞 Outgoing call
"; + } + if (type == SignalMessage.MessageType.CALL_INCOMING) { + return "
📞 Incoming call
"; + } + if (type == SignalMessage.MessageType.CALL_MISSED) { + return "
📞 Missed call
"; + } + if (m.getBody() == null) { + return "
[Attachment]
"; + } + return "
" + escapeHtml(m.getBody()) + "
"; + } + + private String renderSystemMessage(SignalMessage m) { + String text = m.getBody() != null ? escapeHtml(m.getBody()) : "System message"; + return "
" + text + "
\n"; + } + + private String renderDateSeparator(Date date) { + return "
" + DATE_FMT.get().format(date) + "
\n"; + } + + private String resolveSenderName(SignalMessage m, SignalChat chat) { + return chat.getParticipants().stream() + .filter(p -> p.getId() == m.getFromRecipientId()) + .map(SignalContact::getDisplayName) + .findFirst() + .orElse(null); + } + + private String formatTime(Date date) { + return date != null ? TIME_FMT.get().format(date) : ""; + } + + private static boolean isSameDay(Date a, Date b) { + if (a == null) return false; + Calendar ca = Calendar.getInstance(); + Calendar cb = Calendar.getInstance(); + ca.setTime(a); + cb.setTime(b); + return ca.get(Calendar.YEAR) == cb.get(Calendar.YEAR) + && ca.get(Calendar.DAY_OF_YEAR) == cb.get(Calendar.DAY_OF_YEAR); + } + + private static String escapeHtml(String s) { + if (s == null) return ""; + return s.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """); + } + + private static String readResource(String path) { + InputStream is = ReportGenerator.class.getResourceAsStream(path); + if (is == null) + return ""; + try { + byte[] bytes = IOUtil.loadInputStream(is); + return bytes != null ? new String(bytes, StandardCharsets.UTF_8) : ""; + } catch (IOException e) { + return ""; + } + } +} diff --git a/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/SignalChat.java b/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/SignalChat.java new file mode 100644 index 0000000000..a288611939 --- /dev/null +++ b/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/SignalChat.java @@ -0,0 +1,41 @@ +package iped.parsers.signal; + +import java.util.ArrayList; +import java.util.List; + +public class SignalChat { + + private long id; + private SignalContact contact; + private String groupTitle; + private List messages = new ArrayList<>(); + private List participants = new ArrayList<>(); + + public long getId() { return id; } + public void setId(long id) { this.id = id; } + + public SignalContact getContact() { return contact; } + public void setContact(SignalContact contact) { this.contact = contact; } + + public String getGroupTitle() { return groupTitle; } + public void setGroupTitle(String groupTitle) { this.groupTitle = groupTitle; } + + public List getMessages() { return messages; } + public void setMessages(List messages) { this.messages = messages; } + + public List getParticipants() { return participants; } + + public boolean isGroupChat() { + return contact != null && contact.isGroup(); + } + + public String getTitle() { + if (isGroupChat()) { + String title = "Signal Group"; + if (groupTitle != null && !groupTitle.isBlank()) + title += " - " + groupTitle.strip(); + return title; + } + return "Signal Chat - " + (contact != null ? contact.getFullId() : "Unknown"); + } +} diff --git a/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/SignalContact.java b/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/SignalContact.java new file mode 100644 index 0000000000..0aef4b7f53 --- /dev/null +++ b/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/SignalContact.java @@ -0,0 +1,64 @@ +package iped.parsers.signal; + +public class SignalContact { + + private final long id; + private final String phone; + private final String profileGivenName; + private final String profileFamilyName; + private final String profileJoinedName; + private final String systemDisplayName; + private final String groupId; + + public SignalContact(long id, String phone, String profileGivenName, String profileFamilyName, + String profileJoinedName, String systemDisplayName, String groupId) { + this.id = id; + this.phone = phone; + this.profileGivenName = profileGivenName; + this.profileFamilyName = profileFamilyName; + this.profileJoinedName = profileJoinedName; + this.systemDisplayName = systemDisplayName; + this.groupId = groupId; + } + + public long getId() { + return id; + } + + public String getPhone() { + return phone; + } + + public boolean isGroup() { + return groupId != null && !groupId.isBlank(); + } + + public String getGroupId() { + return groupId; + } + + /** Best available display name for this contact. */ + public String getDisplayName() { + if (systemDisplayName != null && !systemDisplayName.isBlank()) + return systemDisplayName; + if (profileJoinedName != null && !profileJoinedName.isBlank()) + return profileJoinedName; + if (profileGivenName != null && !profileGivenName.isBlank()) { + String full = profileGivenName; + if (profileFamilyName != null && !profileFamilyName.isBlank()) + full += " " + profileFamilyName; + return full; + } + if (phone != null && !phone.isBlank()) + return phone; + return "Unknown"; + } + + /** Full identifier used in metadata: display name + phone (if available). */ + public String getFullId() { + String name = getDisplayName(); + if (phone != null && !phone.isBlank() && !name.equals(phone)) + return name + " (" + phone + ")"; + return name; + } +} diff --git a/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/SignalExtractor.java b/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/SignalExtractor.java new file mode 100644 index 0000000000..702cc9f8be --- /dev/null +++ b/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/SignalExtractor.java @@ -0,0 +1,209 @@ +package iped.parsers.signal; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class SignalExtractor { + + private static final Logger LOGGER = LoggerFactory.getLogger(SignalExtractor.class); + + // Signal Android column names (RecipientTable.kt / MessageTable.kt). + // aci and to_recipient_id are intentionally omitted: aci was added in v5.15 + // (late 2021) and breaks the query on older backups; to_recipient_id is unused. + private static final String SELECT_RECIPIENTS = + "SELECT _id, e164, profile_given_name, profile_family_name, " + + "profile_joined_name, system_joined_name, group_id FROM recipient"; + + private static final String SELECT_THREADS = + "SELECT _id, recipient_id, date FROM thread ORDER BY date DESC"; + + private static final String SELECT_MESSAGES = + "SELECT _id, thread_id, from_recipient_id, " + + "date_sent, date_received, body, type FROM message WHERE thread_id = ? ORDER BY date_sent ASC"; + + private static final String SELECT_GROUP_TITLE = + "SELECT title FROM groups WHERE recipient_id = ?"; + + private static final String SELECT_GROUP_MEMBERS = + "SELECT gm.recipient_id FROM group_membership gm " + + "INNER JOIN groups g ON g.group_id = gm.group_id WHERE g.recipient_id = ?"; + + private static final String VALIDATE_TABLES = + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' " + + "AND name IN ('recipient','thread','message')"; + + private final Connection connection; + private final String itemPath; + + public SignalExtractor(Connection connection, String itemPath) { + this.connection = connection; + this.itemPath = itemPath; + } + + public boolean isValidSignalDatabase() { + try (Statement st = connection.createStatement(); + ResultSet rs = st.executeQuery(VALIDATE_TABLES)) { + return rs.next() && rs.getInt(1) == 3; + } catch (SQLException e) { + return false; + } + } + + public List extractChats() { + Map recipients = loadRecipients(); + return loadThreads(recipients); + } + + private Map loadRecipients() { + Map map = new HashMap<>(); + try (Statement st = connection.createStatement(); + ResultSet rs = st.executeQuery(SELECT_RECIPIENTS)) { + while (rs.next()) { + long id = rs.getLong("_id"); + SignalContact c = new SignalContact( + id, + rs.getString("e164"), + rs.getString("profile_given_name"), + rs.getString("profile_family_name"), + rs.getString("profile_joined_name"), + rs.getString("system_joined_name"), + rs.getString("group_id")); + map.put(id, c); + } + } catch (SQLException e) { + LOGGER.warn("Error loading Signal recipients from {}: {}", itemPath, e.getMessage()); + } + return map; + } + + private List loadThreads(Map recipients) { + List chats = new ArrayList<>(); + try (Statement st = connection.createStatement(); + ResultSet rs = st.executeQuery(SELECT_THREADS)) { + while (rs.next()) { + long threadId = rs.getLong("_id"); + long recipientId = rs.getLong("recipient_id"); + SignalContact contact = recipients.get(recipientId); + if (contact == null) + continue; + + SignalChat chat = new SignalChat(); + chat.setId(threadId); + chat.setContact(contact); + + if (contact.isGroup()) { + chat.setGroupTitle(loadGroupTitle(recipientId)); + loadGroupMembers(recipientId, recipients, chat); + } + + chat.setMessages(loadMessages(threadId)); + + if (!chat.getMessages().isEmpty() || !contact.getDisplayName().equals("Unknown")) { + chats.add(chat); + } + } + } catch (SQLException e) { + LOGGER.warn("Error loading Signal threads from {}: {}", itemPath, e.getMessage()); + } + return chats; + } + + private String loadGroupTitle(long recipientId) { + try (PreparedStatement st = connection.prepareStatement(SELECT_GROUP_TITLE)) { + st.setLong(1, recipientId); + try (ResultSet rs = st.executeQuery()) { + if (rs.next()) + return rs.getString("title"); + } + } catch (SQLException e) { + LOGGER.warn("Error loading Signal group title: {}", e.getMessage()); + } + return null; + } + + private void loadGroupMembers(long recipientId, Map recipients, SignalChat chat) { + try (PreparedStatement st = connection.prepareStatement(SELECT_GROUP_MEMBERS)) { + st.setLong(1, recipientId); + try (ResultSet rs = st.executeQuery()) { + while (rs.next()) { + long memberId = rs.getLong("recipient_id"); + SignalContact member = recipients.get(memberId); + if (member != null) + chat.getParticipants().add(member); + } + } + } catch (SQLException e) { + LOGGER.warn("Error loading Signal group members: {}", e.getMessage()); + } + } + + private List loadMessages(long threadId) { + List messages = new ArrayList<>(); + try (PreparedStatement st = connection.prepareStatement(SELECT_MESSAGES)) { + st.setLong(1, threadId); + try (ResultSet rs = st.executeQuery()) { + while (rs.next()) { + SignalMessage m = new SignalMessage(); + m.setId(rs.getLong("_id")); + m.setThreadId(threadId); + m.setFromRecipientId(rs.getLong("from_recipient_id")); + + long dateSentMs = rs.getLong("date_sent"); + if (dateSentMs > 0) + m.setDateSent(new Date(dateSentMs)); + + long dateReceivedMs = rs.getLong("date_received"); + if (dateReceivedMs > 0) + m.setDateReceived(new Date(dateReceivedMs)); + + m.setBody(rs.getString("body")); + + SignalMessage.MessageType msgType = classifyMessageType(rs.getInt("type")); + m.setMessageType(msgType); + // outgoing calls are initiated by self; missed/incoming are from the other party + m.setFromMe(msgType == SignalMessage.MessageType.OUTGOING + || msgType == SignalMessage.MessageType.CALL_OUTGOING); + + messages.add(m); + } + } + } catch (SQLException e) { + LOGGER.warn("Error loading Signal messages for thread {}: {}", threadId, e.getMessage()); + } + return messages; + } + + // Signal message type classification based on the lower 5 bits (MessageTypes.kt) + private static SignalMessage.MessageType classifyMessageType(int rawType) { + switch (rawType & 0x1F) { + case 0: // BASE_OUTBOX_TYPE + case 2: // BASE_SENT_TYPE + case 3: // BASE_PENDING_SECURE_SMS_FALLBACK + case 4: // BASE_PENDING_INSECURE_SMS_FALLBACK + case 5: // BASE_SENDING_TYPE + return SignalMessage.MessageType.OUTGOING; + case 20: // BASE_INBOX_TYPE + return SignalMessage.MessageType.INCOMING; + case 1: // OUTGOING_AUDIO_CALL_TYPE + return SignalMessage.MessageType.CALL_OUTGOING; + case 21: // INCOMING_AUDIO_CALL_TYPE + return SignalMessage.MessageType.CALL_INCOMING; + case 22: // MISSED_AUDIO_CALL_TYPE + case 25: // REJECTED_AUDIO_CALL_TYPE + return SignalMessage.MessageType.CALL_MISSED; + default: + return SignalMessage.MessageType.SYSTEM; + } + } +} diff --git a/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/SignalMessage.java b/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/SignalMessage.java new file mode 100644 index 0000000000..55c800da83 --- /dev/null +++ b/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/SignalMessage.java @@ -0,0 +1,47 @@ +package iped.parsers.signal; + +import java.util.Date; + +public class SignalMessage { + + public enum MessageType { + INCOMING, OUTGOING, SYSTEM, CALL_INCOMING, CALL_OUTGOING, CALL_MISSED; + + public boolean isRegular() { + return this == INCOMING || this == OUTGOING; + } + } + + private long id; + private long threadId; + private long fromRecipientId; + private String body; + private Date dateSent; + private Date dateReceived; + private boolean fromMe; + private MessageType messageType = MessageType.OUTGOING; + + public long getId() { return id; } + public void setId(long id) { this.id = id; } + + public long getThreadId() { return threadId; } + public void setThreadId(long threadId) { this.threadId = threadId; } + + public long getFromRecipientId() { return fromRecipientId; } + public void setFromRecipientId(long fromRecipientId) { this.fromRecipientId = fromRecipientId; } + + public String getBody() { return body; } + public void setBody(String body) { this.body = body; } + + public Date getDateSent() { return dateSent; } + public void setDateSent(Date dateSent) { this.dateSent = dateSent; } + + public Date getDateReceived() { return dateReceived; } + public void setDateReceived(Date dateReceived) { this.dateReceived = dateReceived; } + + public boolean isFromMe() { return fromMe; } + public void setFromMe(boolean fromMe) { this.fromMe = fromMe; } + + public MessageType getMessageType() { return messageType; } + public void setMessageType(MessageType messageType) { this.messageType = messageType; } +} diff --git a/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/SignalParser.java b/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/SignalParser.java new file mode 100644 index 0000000000..f4b29ebbc8 --- /dev/null +++ b/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/SignalParser.java @@ -0,0 +1,208 @@ +package iped.parsers.signal; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.sql.Connection; +import java.sql.SQLException; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import org.apache.tika.config.Field; +import org.apache.tika.exception.TikaException; +import org.apache.tika.extractor.EmbeddedDocumentExtractor; +import org.apache.tika.extractor.ParsingEmbeddedDocumentExtractor; +import org.apache.tika.io.TemporaryResources; +import org.apache.tika.io.TikaInputStream; +import org.apache.tika.metadata.Metadata; +import org.apache.tika.metadata.TikaCoreProperties; +import org.apache.tika.mime.MediaType; +import org.apache.tika.parser.ParseContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.xml.sax.ContentHandler; +import org.xml.sax.SAXException; + +import iped.parsers.sqlite.SQLite3DBParser; +import iped.parsers.standard.StandardParser; +import iped.parsers.util.ItemInfo; +import iped.properties.BasicProps; +import iped.properties.ExtraProperties; +import iped.utils.EmptyInputStream; + +/** + * Parser for Signal Messenger Android databases (signal.db). + * + * Extracts individual and group conversations with messages and metadata. + * Supports plaintext SQLite databases obtained through full-filesystem + * forensic acquisition of Android devices. + */ +public class SignalParser extends SQLite3DBParser { + + private static final long serialVersionUID = 1L; + + private static final Logger LOGGER = LoggerFactory.getLogger(SignalParser.class); + + public static final MediaType SIGNAL_DB = MediaType.application("x-signal-db"); + public static final MediaType SIGNAL_CHAT = MediaType.application("x-signal-chat"); + public static final MediaType SIGNAL_MESSAGE = MediaType.parse("message/x-signal-message"); + + private static final Set SUPPORTED_TYPES = Collections.singleton(SIGNAL_DB); + + private static final ReportGenerator REPORT_GEN = new ReportGenerator(); + + private boolean extractMessages = true; + + @Field + public void setExtractMessages(boolean extractMessages) { + this.extractMessages = extractMessages; + } + + @Override + public Set getSupportedTypes(ParseContext context) { + return SUPPORTED_TYPES; + } + + @Override + public void parse(InputStream stream, ContentHandler handler, Metadata metadata, ParseContext context) + throws IOException, SAXException, TikaException { + + EmbeddedDocumentExtractor extractor = context.get(EmbeddedDocumentExtractor.class, + new ParsingEmbeddedDocumentExtractor(context)); + + if (!extractor.shouldParseEmbedded(metadata)) + return; + + try (TemporaryResources tmp = new TemporaryResources()) { + TikaInputStream tis = TikaInputStream.get(stream, tmp); + + ItemInfo itemInfo = context.get(ItemInfo.class); + String itemPath = itemInfo != null ? itemInfo.getPath() : ""; + + Connection connection = getConnection(tis, metadata, context); + if (connection == null) + return; + + try { + SignalExtractor signalExtractor = new SignalExtractor(connection, itemPath); + if (!signalExtractor.isValidSignalDatabase()) { + LOGGER.debug("Skipping: DB at {} does not have required Signal tables", itemPath); + return; + } + List chats = signalExtractor.extractChats(); + createReports(chats, handler, extractor); + } finally { + try { connection.close(); } catch (SQLException e) { /* ignore */ } + } + + } catch (Exception e) { + LOGGER.warn("Error parsing Signal database", e); + throw new TikaException("SignalParser error", e); + } + } + + private void createReports(List chats, ContentHandler handler, + EmbeddedDocumentExtractor extractor) throws SAXException, IOException { + + int chatVirtualId = 0; + for (SignalChat chat : chats) { + String chatTitle = chat.getTitle(); + + // Count only indexable (non-system) messages for HASCHILD + long indexableCount = chat.getMessages().stream() + .filter(m -> m.getMessageType() != SignalMessage.MessageType.SYSTEM) + .count(); + + Metadata chatMeta = new Metadata(); + chatMeta.set(TikaCoreProperties.TITLE, chatTitle); + chatMeta.set(StandardParser.INDEXER_CONTENT_TYPE, SIGNAL_CHAT.toString()); + chatMeta.set(ExtraProperties.ITEM_VIRTUAL_ID, Integer.toString(chatVirtualId)); + chatMeta.set(ExtraProperties.DECODED_DATA, Boolean.TRUE.toString()); + + if (chat.isGroupChat()) { + chatMeta.add(ExtraProperties.GROUP_ID, chat.getContact().getGroupId()); + for (SignalContact member : chat.getParticipants()) { + chatMeta.add(ExtraProperties.PARTICIPANTS, member.getFullId()); + } + } else if (chat.getContact() != null) { + chatMeta.add(ExtraProperties.PARTICIPANTS, chat.getContact().getFullId()); + } + + if (extractMessages && indexableCount > 0) + chatMeta.set(BasicProps.HASCHILD, Boolean.TRUE.toString()); + + byte[] reportBytes = REPORT_GEN.generateChatHtml(chat); + extractor.parseEmbedded(new ByteArrayInputStream(reportBytes), handler, chatMeta, false); + + if (extractMessages) { + extractMessages(chatTitle, chat, chatVirtualId, handler, extractor); + } + chatVirtualId++; + } + } + + private void extractMessages(String chatTitle, SignalChat chat, int parentVirtualId, + ContentHandler handler, EmbeddedDocumentExtractor extractor) throws SAXException, IOException { + + int msgCount = 0; + for (SignalMessage m : chat.getMessages()) { + // System messages are rendered only in the HTML report; not indexed individually + if (m.getMessageType() == SignalMessage.MessageType.SYSTEM) + continue; + + Metadata msgMeta = new Metadata(); + msgMeta.set(TikaCoreProperties.TITLE, chatTitle + "_message_" + msgCount++); + msgMeta.set(StandardParser.INDEXER_CONTENT_TYPE, SIGNAL_MESSAGE.toString()); + msgMeta.set(ExtraProperties.PARENT_VIRTUAL_ID, Integer.toString(parentVirtualId)); + msgMeta.set(ExtraProperties.DECODED_DATA, Boolean.TRUE.toString()); + + if (m.getDateSent() != null) { + msgMeta.set(ExtraProperties.MESSAGE_DATE, m.getDateSent()); + msgMeta.set(TikaCoreProperties.CREATED, m.getDateSent()); + } + + msgMeta.set(ExtraProperties.MESSAGE_BODY, resolveBody(m)); + msgMeta.set(ExtraProperties.USER_ACCOUNT_TYPE, SIGNAL_MESSAGE.toString()); + + if (chat.isGroupChat()) + msgMeta.set(ExtraProperties.IS_GROUP_MESSAGE, "true"); + + SignalContact contact = chat.getContact(); + if (m.isFromMe()) { + msgMeta.set(org.apache.tika.metadata.Message.MESSAGE_FROM, ""); + // For groups, TO is the chat title; for individual, TO is the contact + if (chat.isGroupChat()) { + msgMeta.add(org.apache.tika.metadata.Message.MESSAGE_TO, chatTitle); + } else if (contact != null) { + msgMeta.add(org.apache.tika.metadata.Message.MESSAGE_TO, contact.getFullId()); + } + } else { + if (chat.isGroupChat()) { + String senderName = chat.getParticipants().stream() + .filter(p -> p.getId() == m.getFromRecipientId()) + .map(SignalContact::getFullId) + .findFirst() + .orElse("Unknown"); + msgMeta.set(org.apache.tika.metadata.Message.MESSAGE_FROM, senderName); + msgMeta.set(org.apache.tika.metadata.Message.MESSAGE_TO, chatTitle); + } else if (contact != null) { + msgMeta.set(org.apache.tika.metadata.Message.MESSAGE_FROM, contact.getFullId()); + msgMeta.add(org.apache.tika.metadata.Message.MESSAGE_TO, ""); + } + } + + extractor.parseEmbedded(new EmptyInputStream(), handler, msgMeta, false); + } + } + + private static String resolveBody(SignalMessage m) { + switch (m.getMessageType()) { + case CALL_OUTGOING: return "[Outgoing Call]"; + case CALL_INCOMING: return "[Incoming Call]"; + case CALL_MISSED: return "[Missed Call]"; + default: + return m.getBody() != null ? m.getBody() : "[Attachment]"; + } + } +} diff --git a/iped-parsers/iped-parsers-impl/src/main/resources/iped/parsers/signal/css/signal.css b/iped-parsers/iped-parsers-impl/src/main/resources/iped/parsers/signal/css/signal.css new file mode 100644 index 0000000000..9500c7fc70 --- /dev/null +++ b/iped-parsers/iped-parsers-impl/src/main/resources/iped/parsers/signal/css/signal.css @@ -0,0 +1,220 @@ +:root { + --bg: #f0f0f0; + --incoming-bg: #ffffff; + --incoming-text: #1a1a1a; + --outgoing-bg: #2c6bed; + --outgoing-text: #ffffff; + --outgoing-time: rgba(255,255,255,0.72); + --incoming-time: #8a8a8a; + --header-bg: #2c6bed; + --header-text: #ffffff; + --system-text: #666666; + --system-bg: rgba(0,0,0,0.08); + --date-text: #666666; + --date-bg: rgba(0,0,0,0.1); + --from-color: #1a5ccf; + --shadow: rgba(0,0,0,0.08); +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #1a1a1a; + --incoming-bg: #2d2d2d; + --incoming-text: #ececec; + --outgoing-bg: #2c6bed; + --outgoing-text: #ffffff; + --outgoing-time: rgba(255,255,255,0.65); + --incoming-time: #888888; + --header-bg: #1e3a6e; + --header-text: #e8e8e8; + --system-text: #aaaaaa; + --system-bg: rgba(255,255,255,0.07); + --date-text: #999999; + --date-bg: rgba(255,255,255,0.1); + --from-color: #7aabff; + --shadow: rgba(0,0,0,0.3); + } +} + +:root[data-theme="dark"] { + --bg: #1a1a1a; + --incoming-bg: #2d2d2d; + --incoming-text: #ececec; + --outgoing-bg: #2c6bed; + --outgoing-text: #ffffff; + --outgoing-time: rgba(255,255,255,0.65); + --incoming-time: #888888; + --header-bg: #1e3a6e; + --header-text: #e8e8e8; + --system-text: #aaaaaa; + --system-bg: rgba(255,255,255,0.07); + --date-text: #999999; + --date-bg: rgba(255,255,255,0.1); + --from-color: #7aabff; + --shadow: rgba(0,0,0,0.3); +} + +:root[data-theme="light"] { + --bg: #f0f0f0; + --incoming-bg: #ffffff; + --incoming-text: #1a1a1a; + --outgoing-bg: #2c6bed; + --outgoing-text: #ffffff; + --outgoing-time: rgba(255,255,255,0.72); + --incoming-time: #8a8a8a; + --header-bg: #2c6bed; + --header-text: #ffffff; + --system-text: #666666; + --system-bg: rgba(0,0,0,0.08); + --date-text: #666666; + --date-bg: rgba(0,0,0,0.1); + --from-color: #1a5ccf; + --shadow: rgba(0,0,0,0.08); +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + background: var(--bg); + color: var(--incoming-text); + min-height: 100vh; +} + +.chat-container { + max-width: 720px; + margin: 0 auto; + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.chat-header { + background: var(--header-bg); + color: var(--header-text); + padding: 12px 16px; + font-weight: 600; + font-size: 15px; + position: sticky; + top: 0; + z-index: 10; + letter-spacing: 0.01em; + box-shadow: 0 1px 4px var(--shadow); +} + +.messages { + padding: 12px 8px 24px; + display: flex; + flex-direction: column; + gap: 3px; +} + +/* --- Bubble rows --- */ + +.linha { + display: flex; + margin: 1px 4px; + align-items: flex-end; +} + +.incoming { justify-content: flex-start; } +.outgoing { justify-content: flex-end; } + +/* --- Bubbles (bbl = bubble-left/incoming, bbr = bubble-right/outgoing) --- */ + +.bbl, .bbr { + max-width: 68%; + border-radius: 14px; + padding: 7px 12px 5px; + word-wrap: break-word; + overflow-wrap: break-word; + font-size: 14px; + line-height: 1.45; + box-shadow: 0 1px 2px var(--shadow); +} + +.bbl { + background: var(--incoming-bg); + color: var(--incoming-text); + border-bottom-left-radius: 3px; +} + +.bbr { + background: var(--outgoing-bg); + color: var(--outgoing-text); + border-bottom-right-radius: 3px; +} + +/* Sender name shown only in incoming group messages */ +.from { + font-weight: 600; + font-size: 12px; + color: var(--from-color); + margin-bottom: 3px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 200px; +} + +.body { + white-space: pre-wrap; +} + +.time { + font-size: 11px; + color: var(--incoming-time); + text-align: right; + margin-top: 4px; + font-variant-numeric: tabular-nums; +} + +.bbr .time { + color: var(--outgoing-time); +} + +/* Call and attachment labels */ +.body.call-label { + font-style: italic; + opacity: 0.9; +} + +.body.attachment-label { + opacity: 0.75; +} + +/* --- System messages --- */ + +.systemmessage { + display: flex; + justify-content: center; + margin: 6px 0; +} + +.systemmessage span { + font-size: 12px; + color: var(--system-text); + background: var(--system-bg); + padding: 4px 12px; + border-radius: 10px; + max-width: 80%; + text-align: center; + word-wrap: break-word; +} + +/* --- Date separators --- */ + +.date { + display: flex; + justify-content: center; + margin: 14px 0 6px; +} + +.date span { + font-size: 12px; + color: var(--date-text); + background: var(--date-bg); + padding: 3px 12px; + border-radius: 10px; + font-variant-numeric: tabular-nums; +} diff --git a/iped-parsers/iped-parsers-impl/src/main/resources/iped/parsers/signal/signal-html-template.txt b/iped-parsers/iped-parsers-impl/src/main/resources/iped/parsers/signal/signal-html-template.txt new file mode 100644 index 0000000000..ccc168990e --- /dev/null +++ b/iped-parsers/iped-parsers-impl/src/main/resources/iped/parsers/signal/signal-html-template.txt @@ -0,0 +1,19 @@ + + + + + + + + +
+
${title}
+
+${messages} +
+
+ + + diff --git a/iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/signal/AbstractPkgTest.java b/iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/signal/AbstractPkgTest.java new file mode 100644 index 0000000000..5dc37d5d15 --- /dev/null +++ b/iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/signal/AbstractPkgTest.java @@ -0,0 +1,125 @@ +package iped.parsers.signal; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Set; + +import org.apache.tika.exception.TikaException; +import org.apache.tika.metadata.Metadata; +import org.apache.tika.metadata.TikaCoreProperties; +import org.apache.tika.mime.MediaType; +import org.apache.tika.parser.AbstractParser; +import org.apache.tika.parser.AutoDetectParser; +import org.apache.tika.parser.ParseContext; +import org.apache.tika.parser.Parser; +import org.xml.sax.ContentHandler; +import org.xml.sax.SAXException; + +import iped.data.IItemReader; +import iped.parsers.standard.StandardParser; +import iped.parsers.util.ItemInfo; +import iped.properties.ExtraProperties; +import iped.search.IItemSearcher; +import junit.framework.TestCase; + +public abstract class AbstractPkgTest extends TestCase { + + protected ParseContext signalContext; + protected EmbeddedSignalParser signalTracker; + + @Override + protected void setUp() throws Exception { + super.setUp(); + + IItemSearcher noopSearcher = new IItemSearcher() { + @Override + public void close() throws IOException {} + + @Override + public Iterable searchIterable(String luceneQuery) { + return Collections.emptyList(); + } + + @Override + public List search(String luceneQuery) { + return Collections.emptyList(); + } + + @Override + public String escapeQuery(String string) { + return string; + } + }; + + ItemInfo itemInfo = new ItemInfo(0, getName(), null, null, getName(), false); + + signalTracker = new EmbeddedSignalParser(); + signalContext = new ParseContext(); + signalContext.set(Parser.class, signalTracker); + signalContext.set(ItemInfo.class, itemInfo); + signalContext.set(IItemSearcher.class, noopSearcher); + } + + @SuppressWarnings("serial") + protected static class EmbeddedSignalParser extends AbstractParser { + + protected List contentTypes = new ArrayList<>(); + protected List titles = new ArrayList<>(); + protected List messageBodies = new ArrayList<>(); + protected List messageFroms = new ArrayList<>(); + protected List messageTos = new ArrayList<>(); + protected List participants = new ArrayList<>(); + protected List messageDates = new ArrayList<>(); + protected List userAccountTypes = new ArrayList<>(); + protected List groupIds = new ArrayList<>(); + + @Override + public Set getSupportedTypes(ParseContext context) { + return new AutoDetectParser().getSupportedTypes(context); + } + + @Override + public void parse(InputStream stream, ContentHandler handler, Metadata metadata, ParseContext context) + throws IOException, SAXException, TikaException { + + String ct = metadata.get(StandardParser.INDEXER_CONTENT_TYPE); + if (ct != null) + contentTypes.add(ct); + + String title = metadata.get(TikaCoreProperties.TITLE); + if (title != null) + titles.add(title); + + String body = metadata.get(ExtraProperties.MESSAGE_BODY); + if (body != null) + messageBodies.add(body); + + String from = metadata.get(org.apache.tika.metadata.Message.MESSAGE_FROM); + if (from != null) + messageFroms.add(from); + + String to = metadata.get(org.apache.tika.metadata.Message.MESSAGE_TO); + if (to != null) + messageTos.add(to); + + String p = metadata.get(ExtraProperties.PARTICIPANTS); + if (p != null) + participants.add(p); + + String date = metadata.get(ExtraProperties.MESSAGE_DATE); + if (date != null) + messageDates.add(date); + + String uat = metadata.get(ExtraProperties.USER_ACCOUNT_TYPE); + if (uat != null) + userAccountTypes.add(uat); + + String gid = metadata.get(ExtraProperties.GROUP_ID); + if (gid != null) + groupIds.add(gid); + } + } +} diff --git a/iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/signal/SignalParserTest.java b/iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/signal/SignalParserTest.java new file mode 100644 index 0000000000..c205d88144 --- /dev/null +++ b/iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/signal/SignalParserTest.java @@ -0,0 +1,250 @@ +package iped.parsers.signal; + +import java.io.InputStream; + +import org.apache.tika.metadata.Metadata; +import org.apache.tika.sax.BodyContentHandler; +import org.xml.sax.ContentHandler; + +import iped.parsers.standard.StandardParser; + +/** + * Unit tests for {@link SignalParser}. + * + * Fixture: test_signal.db — synthetic Signal Android database (NO aci column, + * intentionally absent to verify compatibility with pre-v5.15 Signal backups): + * + * Recipients: + * 1 – Alice Walker (+5511999990001) [profile_joined_name] + * 2 – Bob Costa (+5511999990002) [system_joined_name] + * 3 – Group placeholder (GRP001FORENSICS) + * 4 – phone-only (+5511999990004) [no name fields] + * + * Threads (DESC by date → group first, then phone-only, then alice): + * 2 – Group "Operacao Digital" (date=1700001000000) + * 3 – Phone-only (date=1700000500000) + * 1 – Alice Walker (date=1700000100000) + * + * Messages: + * Thread 1 (Alice): incoming, sent, null-body sent, system (type=14, filtered) + * Thread 2 (Group): incoming from Alice, incoming from Bob (emoji+HTML chars), + * OUTGOING from self (type=2) — tests group outgoing MESSAGE_TO + * Thread 3 (Phone): CALL_OUTGOING (type=1), CALL_INCOMING (type=21), CALL_MISSED (type=22) + * + * Expected output (extractMessages=true): + * 3 x-signal-chat + 9 x-signal-message = 12 docs + */ +public class SignalParserTest extends AbstractPkgTest { + + private static final String FIXTURE = "test-files/test_signal.db"; + + private static final int EXPECTED_CHAT_DOCS = 3; + private static final int EXPECTED_MESSAGE_DOCS = 9; // system (type=14) excluded; 3+3+3 + private static final int EXPECTED_TOTAL_DOCS = 12; + + private static final String EXPECTED_GROUP_TITLE = "Signal Group - Operacao Digital"; + private static final String EXPECTED_INDIVIDUAL_TITLE = "Signal Chat - Alice Walker (+5511999990001)"; + private static final String EXPECTED_PHONE_TITLE = "Signal Chat - +5511999990004"; + + private static final String ALICE_FULL_ID = "Alice Walker (+5511999990001)"; + private static final String BOB_FULL_ID = "Bob Costa (+5511999990002)"; + + private EmbeddedSignalParser parse(boolean extractMessages) throws Exception { + SignalParser parser = new SignalParser(); + parser.setExtractMessages(extractMessages); + + ContentHandler handler = new BodyContentHandler(-1); + Metadata metadata = new Metadata(); + metadata.set(StandardParser.INDEXER_CONTENT_TYPE, SignalParser.SIGNAL_DB.toString()); + + try (InputStream is = getClass().getClassLoader().getResourceAsStream(FIXTURE)) { + assertNotNull("Fixture not found: " + FIXTURE, is); + parser.parse(is, handler, metadata, signalContext); + } + return signalTracker; + } + + // ── Document counts ─────────────────────────────────────────────────────── + + public void testTotalDocumentCount() throws Exception { + EmbeddedSignalParser tracker = parse(true); + assertEquals("Expected " + EXPECTED_TOTAL_DOCS + " total embedded documents", + EXPECTED_TOTAL_DOCS, tracker.contentTypes.size()); + } + + public void testChatDocumentCount() throws Exception { + EmbeddedSignalParser tracker = parse(true); + long chatCount = tracker.contentTypes.stream() + .filter(t -> t.equals(SignalParser.SIGNAL_CHAT.toString())) + .count(); + assertEquals(EXPECTED_CHAT_DOCS, (int) chatCount); + } + + public void testMessageDocumentCount() throws Exception { + EmbeddedSignalParser tracker = parse(true); + long msgCount = tracker.contentTypes.stream() + .filter(t -> t.equals(SignalParser.SIGNAL_MESSAGE.toString())) + .count(); + // 9 raw messages; 1 system (type=14) filtered → 8 indexed + assertEquals("System messages must be excluded from indexed message count", + EXPECTED_MESSAGE_DOCS, (int) msgCount); + } + + // ── Chat ordering and titles ────────────────────────────────────────────── + + public void testGroupChatIsFirst() throws Exception { + EmbeddedSignalParser tracker = parse(true); + assertFalse("titles should not be empty", tracker.titles.isEmpty()); + assertEquals("Group chat must come first (most recent thread)", + EXPECTED_GROUP_TITLE, tracker.titles.get(0)); + } + + public void testIndividualChatTitle() throws Exception { + EmbeddedSignalParser tracker = parse(true); + assertTrue("Individual chat title must be present", + tracker.titles.contains(EXPECTED_INDIVIDUAL_TITLE)); + } + + public void testPhoneOnlyContactTitle() throws Exception { + EmbeddedSignalParser tracker = parse(true); + assertTrue("Phone-only contact must show e164 number when no name is available", + tracker.titles.contains(EXPECTED_PHONE_TITLE)); + } + + // ── Message bodies ──────────────────────────────────────────────────────── + + public void testGroupMessageBodyAlice() throws Exception { + EmbeddedSignalParser tracker = parse(true); + assertTrue("Alice's group message body must be present", + tracker.messageBodies.contains("Hi team, forensics meeting tomorrow!")); + } + + public void testIndividualReceivedMessageBody() throws Exception { + EmbeddedSignalParser tracker = parse(true); + assertTrue("Received message body must be present", + tracker.messageBodies.contains("Hello from Alice!")); + } + + public void testIndividualSentMessageBody() throws Exception { + EmbeddedSignalParser tracker = parse(true); + assertTrue("Sent message body must be present", + tracker.messageBodies.contains("Hi Alice, how are you?")); + } + + public void testNullBodyIsLabeledAttachment() throws Exception { + EmbeddedSignalParser tracker = parse(true); + assertTrue("Null-body message must be labeled [Attachment]", + tracker.messageBodies.contains("[Attachment]")); + } + + public void testEmojiAndSpecialCharsInMessageBody() throws Exception { + EmbeddedSignalParser tracker = parse(true); + // Body stored raw in MESSAGE_BODY metadata (HTML escaping is only for the report) + String expected = "🔒 Evidência & \"anexada\""; + assertTrue("Emoji and HTML-special chars must pass through unescaped in MESSAGE_BODY", + tracker.messageBodies.contains(expected)); + } + + public void testCallBodyLabels() throws Exception { + EmbeddedSignalParser tracker = parse(true); + assertTrue("Outgoing call must be labeled [Outgoing Call]", + tracker.messageBodies.contains("[Outgoing Call]")); + assertTrue("Incoming call must be labeled [Incoming Call]", + tracker.messageBodies.contains("[Incoming Call]")); + assertTrue("Missed call must be labeled [Missed Call]", + tracker.messageBodies.contains("[Missed Call]")); + } + + // ── FROM / TO / participants ────────────────────────────────────────────── + + public void testGroupMessageSenderIsAlice() throws Exception { + EmbeddedSignalParser tracker = parse(true); + assertTrue("Alice must appear as a group message sender", + tracker.messageFroms.contains(ALICE_FULL_ID)); + } + + public void testGroupOutgoingBody() throws Exception { + EmbeddedSignalParser tracker = parse(true); + assertTrue("Group outgoing message body must be present", + tracker.messageBodies.contains("Yes, I will be there.")); + } + + public void testGroupOutgoingMessageTo() throws Exception { + // When self sends a message in a group, MESSAGE_TO must be the group title, + // NOT "Unknown" (which is what the group placeholder contact returns via getFullId()) + EmbeddedSignalParser tracker = parse(true); + assertTrue("Group outgoing message must have group title as MESSAGE_TO", + tracker.messageTos.contains(EXPECTED_GROUP_TITLE)); + } + + public void testGroupMessageSenderIsBob() throws Exception { + EmbeddedSignalParser tracker = parse(true); + assertTrue("Bob must appear as a group message sender (emoji message)", + tracker.messageFroms.contains(BOB_FULL_ID)); + } + + public void testOutgoingMessageRecipient() throws Exception { + EmbeddedSignalParser tracker = parse(true); + assertTrue("Outgoing message recipient must be Alice", + tracker.messageTos.contains(ALICE_FULL_ID)); + } + + public void testCallOutgoingIsFromMe() throws Exception { + EmbeddedSignalParser tracker = parse(true); + assertTrue("Outgoing call must have phone contact as TO", + tracker.messageTos.contains("+5511999990004")); + assertTrue("Must have at least one outgoing-direction marker (empty MESSAGE_FROM)", + tracker.messageFroms.stream().anyMatch(String::isEmpty)); + } + + public void testGroupParticipantAlice() throws Exception { + EmbeddedSignalParser tracker = parse(true); + assertTrue("Alice must appear as a group participant", + tracker.participants.contains(ALICE_FULL_ID)); + } + + // ── GROUP_ID ────────────────────────────────────────────────────────────── + + public void testGroupIdUsesActualSignalGroupId() throws Exception { + // GROUP_ID must use the actual Signal group_id from recipient.group_id, + // NOT a synthetic "SignalThread_" string, so link analysis can correlate + // the same group across multiple databases. + EmbeddedSignalParser tracker = parse(true); + assertEquals("Exactly one group chat must have GROUP_ID", 1, tracker.groupIds.size()); + assertEquals("GROUP_ID must be the actual Signal group_id from the recipient table", + "GRP001FORENSICS", tracker.groupIds.get(0)); + } + + // ── USER_ACCOUNT_TYPE (required for IPED communication graph) ──────────── + + public void testMessageHasUserAccountType() throws Exception { + EmbeddedSignalParser tracker = parse(true); + assertEquals("Every message doc must carry USER_ACCOUNT_TYPE", + EXPECTED_MESSAGE_DOCS, tracker.userAccountTypes.size()); + assertTrue("USER_ACCOUNT_TYPE must equal SIGNAL_MESSAGE MIME type", + tracker.userAccountTypes.stream() + .allMatch(t -> t.equals(SignalParser.SIGNAL_MESSAGE.toString()))); + } + + // ── Dates ───────────────────────────────────────────────────────────────── + + public void testMessageDatesPresent() throws Exception { + EmbeddedSignalParser tracker = parse(true); + assertEquals("All indexable messages must have a date", + EXPECTED_MESSAGE_DOCS, tracker.messageDates.size()); + } + + // ── extractMessages flag ────────────────────────────────────────────────── + + public void testNoMessagesWhenExtractDisabled() throws Exception { + EmbeddedSignalParser tracker = parse(false); + long msgCount = tracker.contentTypes.stream() + .filter(t -> t.equals(SignalParser.SIGNAL_MESSAGE.toString())) + .count(); + assertEquals("No message docs when extractMessages=false", 0L, msgCount); + assertEquals("Still " + EXPECTED_CHAT_DOCS + " chat docs when extractMessages=false", + EXPECTED_CHAT_DOCS, (int) tracker.contentTypes.stream() + .filter(t -> t.equals(SignalParser.SIGNAL_CHAT.toString())) + .count()); + } +} diff --git a/iped-parsers/iped-parsers-impl/src/test/resources/test-files/test_signal.db b/iped-parsers/iped-parsers-impl/src/test/resources/test-files/test_signal.db new file mode 100644 index 0000000000000000000000000000000000000000..bc06f3ec1bdce96553c01afbb2e095f81ca9805e GIT binary patch literal 32768 zcmeI*&u`mg7zc2_PMlw(@dWKmwL{-cuq8^mC9naT5Np?Ufjl#yFQGDMo(@`s*fzGSTi1y|bSD^RhAN$fXYk z)K{hWOIIY7r*EVm4?G(9a^NuSB0&HG5P$##AOHafKmYcqYXu)rr1mMeU^GI60)ndKjrXY<@$w1wX2 z3FrB|Z8dAQs2goVwA|j1-&8|)Ma`i|x4}I3i>QV$R>XEcc|K<~I=ouGT%~S0ZP7hG zm0gO(wc%m5cGRCmFoa+m56vMc0dH2SN{ zqE#1tyRlH`eZfpiZ;CwC%&uwCxHdY<)^xwk1>0I~*P5a^Cv0cYX!q|cp;r5&&z&4E zU93(`Oj6|wWF^g*>Bkc2}Pq#gZ?Tj26=XUWweTqf#tz%n* zZQItoVT#%UeHL3a@1z9*|Fhc-&g)IX?DRx=H?l+%5RIOQU(e|{u4vYt35L%~Dmy?E zjjA^!^`82J`W0o6AOHafKmY;|fB*y_009U<00I#Bw*~e^6H?Z1Gwl*HBGC&80uX=z1Rwwb2tWV=5P$##AOL|~C-54J=0;iH*9TK=Ve56> z;%AHn!`015isr@CClbAoAOHafKmY;|fB*y_009U<00Izr5d>b25P$## zAOHafKmY;|fWZGwU{53}F(t~r`1wha@1b7=FeO4eo9kmK`XNA4q5Q`0cf9p~Dt%K@ zAE@7{x7F)vNmbGh(m$rZr9vbKKmY;|fB*y_009U<00Izzz<(p4r4sZ2dPR{~#9O&; z-1*eoezUkwONIlTwimd5d1xp>fdth^daK)w%9UW7iwDJTGnooDpL)f#ofS@=Pw-X4 zH2IvMXGscM9Fb4Pg2rc<>>qt^+B^FGJAZtA`xY;+7>%FSTXjR{hxohn)V&q`J$_Jc ziEDa8KR9?&p}I*$xfpb`KIT>ZK6B3CuF#wGY)Z=(Eyt)k^n6R<8m$HHT20HgtrxBmBvPx!@mxmSFbQ%C;&{PTR# pTIISec*j~kB_E>40C-Iw^$)(e*E{&u({sW!E&p+uL8SwOe*!VT0SN#A literal 0 HcmV?d00001 From 040cff0293e33b8e6c0c680899b965d162e48a58 Mon Sep 17 00:00:00 2001 From: Cleiton Augusto Date: Sun, 5 Jul 2026 09:24:25 -0400 Subject: [PATCH 2/6] Fix template injection in ReportGenerator and register Signal MIME types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Security: Template injection in ReportGenerator (evidence integrity) Sequential String.replace() calls allowed user-controlled data containing template placeholders (e.g. a group named "${messages}") to cascade through substitution. A suspect could name a Signal group "${messages}" to cause the forensic report to render all conversation messages in the title position instead of the group name — corrupting evidence integrity. Fix: replace all four placeholders in a single Matcher.replaceAll() pass. Matcher.quoteReplacement() prevents replacement strings from being treated as regex patterns. The replacement callback runs once per placeholder in the original template; injected placeholders in substituted content are never re-evaluated. Adds testTemplateInjectionInGroupTitle to verify that a group named "${messages}" renders the literal text in the title and does not cause message content to be injected or duplicated. ## Also: Signal MIME types in CategoriesConfig.json - Chats → Signal: application/x-signal-db and application/x-signal-chat added alongside the existing UFED entry so forensic analysts find Signal data under the expected category. - Instant Messages Artifacts → Instant Messages: message/x-signal-message added alongside message/x-threema-message, message/x-whatsapp-message, etc. --- .../iped/parsers/signal/ReportGenerator.java | 23 +++++++++--- .../iped/parsers/signal/SignalParserTest.java | 35 +++++++++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/ReportGenerator.java b/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/ReportGenerator.java index cf4ccdc42e..21c19ff874 100644 --- a/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/ReportGenerator.java +++ b/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/ReportGenerator.java @@ -7,6 +7,8 @@ import java.util.Calendar; import java.util.Date; import java.util.TimeZone; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import iped.utils.IOUtil; @@ -15,6 +17,10 @@ public class ReportGenerator { private static final String TEMPLATE; private static final String CSS; + // Single-pass substitution prevents user data containing ${placeholder} + // from being re-processed by subsequent replace() calls (template injection). + private static final Pattern PLACEHOLDER = Pattern.compile("\\$\\{(css|title|messages|javascript)\\}"); + static { TEMPLATE = readResource("/iped/parsers/signal/signal-html-template.txt"); CSS = readResource("/iped/parsers/signal/css/signal.css"); @@ -53,11 +59,18 @@ public byte[] generateChatHtml(SignalChat chat) { messages.append(renderMessage(m, chat)); } - String html = TEMPLATE - .replace("${css}", CSS) - .replace("${title}", escapeHtml(chat.getTitle())) - .replace("${messages}", messages.toString()) - .replace("${javascript}", ""); + final String escapedTitle = escapeHtml(chat.getTitle()); + final String messagesBlock = messages.toString(); + + String html = PLACEHOLDER.matcher(TEMPLATE).replaceAll(mr -> { + switch (mr.group(1)) { + case "css": return Matcher.quoteReplacement(CSS); + case "title": return Matcher.quoteReplacement(escapedTitle); + case "messages": return Matcher.quoteReplacement(messagesBlock); + case "javascript": return ""; + default: return Matcher.quoteReplacement(mr.group(0)); + } + }); return html.getBytes(StandardCharsets.UTF_8); } diff --git a/iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/signal/SignalParserTest.java b/iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/signal/SignalParserTest.java index c205d88144..ef649f8370 100644 --- a/iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/signal/SignalParserTest.java +++ b/iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/signal/SignalParserTest.java @@ -234,6 +234,41 @@ public void testMessageDatesPresent() throws Exception { EXPECTED_MESSAGE_DOCS, tracker.messageDates.size()); } + // ── Template injection prevention ──────────────────────────────────────── + + public void testTemplateInjectionInGroupTitle() throws Exception { + // A group named "${messages}" or "${javascript}" must not cause the template + // placeholders to be re-evaluated after substitution (report corruption / evidence + // integrity attack). The ReportGenerator uses single-pass Matcher.replaceAll() to + // prevent this. We verify by directly exercising the HTML generator. + SignalContact injectedGroup = new SignalContact(99L, null, null, null, null, null, "INJECT001"); + SignalChat chat = new SignalChat(); + chat.setId(99L); + chat.setContact(injectedGroup); + chat.setGroupTitle("${messages}"); // attacker-controlled group name + chat.setMessages(new java.util.ArrayList<>()); + + ReportGenerator gen = new ReportGenerator(); + byte[] html = gen.generateChatHtml(chat); + String output = new String(html, java.nio.charset.StandardCharsets.UTF_8); + + // The literal string "${messages}" must appear in the title position, + // NOT cause message duplication or blank-out. + assertTrue("Injected placeholder must appear escaped in title", + output.contains("${messages}")); + // Must not contain a second <div class="messages"> block + // from the title position (template duplication check). + int messagesBlockCount = countOccurrences(output, "
"); + assertEquals("Messages block must appear exactly once (no duplication from title injection)", + 1, messagesBlockCount); + } + + private static int countOccurrences(String text, String sub) { + int count = 0, idx = 0; + while ((idx = text.indexOf(sub, idx)) != -1) { count++; idx += sub.length(); } + return count; + } + // ── extractMessages flag ────────────────────────────────────────────────── public void testNoMessagesWhenExtractDisabled() throws Exception { From 7b08afdf81196cfb0b9946eda1e52fb393e1065c Mon Sep 17 00:00:00 2001 From: Cleiton Augusto Date: Sun, 5 Jul 2026 09:26:42 -0400 Subject: [PATCH 3/6] Fix message/x-signal-message sub-class declaration in CustomSignatures.xml Was incorrectly declared as a sub-class of itself. Correct parent type is message/x-chat-message, consistent with message/x-whatsapp-message, message/x-threema-message, and message/x-telegram-message. --- iped-app/resources/config/conf/CustomSignatures.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iped-app/resources/config/conf/CustomSignatures.xml b/iped-app/resources/config/conf/CustomSignatures.xml index 3c839f2ef5..8667b71b36 100644 --- a/iped-app/resources/config/conf/CustomSignatures.xml +++ b/iped-app/resources/config/conf/CustomSignatures.xml @@ -370,7 +370,7 @@ - + From aa900ba6e3c508ef71deaecbe779d450d2197b05 Mon Sep 17 00:00:00 2001 From: Cleiton Augusto Date: Sun, 5 Jul 2026 13:47:00 -0400 Subject: [PATCH 4/6] =?UTF-8?q?Identify=20device=20owner=20(self)=20and=20?= =?UTF-8?q?add=20to=20PARTICIPANTS=20=E2=80=94=20Phase=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the Phase-1 limitation where the device owner's identity was absent from PARTICIPANTS and MESSAGE_FROM/MESSAGE_TO for outgoing messages. Detection strategy: outgoing Signal messages carry from_recipient_id pointing to the device owner's own recipient row. A single correlated subquery selects the recipient who appears most frequently as from_recipient_id in outgoing message types (type & 31 in {0,1,2,3,4,5}). Returns null gracefully when no outgoing messages exist (Phase-1 fallback). Behaviour changes: - PARTICIPANTS: self is now the first entry in every chat, consistent with the WhatsApp and Threema parsers. Group chats add self first, then members from group_membership excluding self (no duplication). Individual chats add self first, then the other party. - MESSAGE_FROM: outgoing messages carry self's full identifier instead of an empty string. - MESSAGE_TO: incoming individual messages carry self's full identifier instead of an empty string. Tests (25 total, 0 failures): - AbstractPkgTest: participants collector updated to capture ALL values via metadata.getValues() — previously only the first entry per chat was captured, which would have hidden self after Phase 2 reordering. - testCallOutgoingIsFromMe: assertion updated from empty MESSAGE_FROM to SELF_FULL_ID. - testSelfInParticipants (new): verifies self appears once per chat across all 3 chats. - test_signal.db fixture: recipient 5 (Device Owner +5511999990005) added; from_recipient_id on all outgoing messages set to 5; self added to group_membership for the test group. --- .../iped/parsers/signal/SignalExtractor.java | 37 ++++++++++++++++++ .../iped/parsers/signal/SignalParser.java | 28 ++++++++----- .../iped/parsers/signal/AbstractPkgTest.java | 6 +-- .../iped/parsers/signal/SignalParserTest.java | 21 +++++++++- .../test/resources/test-files/test_signal.db | Bin 32768 -> 32768 bytes 5 files changed, 78 insertions(+), 14 deletions(-) diff --git a/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/SignalExtractor.java b/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/SignalExtractor.java index 702cc9f8be..edc219132b 100644 --- a/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/SignalExtractor.java +++ b/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/SignalExtractor.java @@ -43,6 +43,19 @@ public class SignalExtractor { "SELECT COUNT(*) FROM sqlite_master WHERE type='table' " + "AND name IN ('recipient','thread','message')"; + // Identifies the device owner: the recipient who appears most frequently as + // the sender in outgoing messages (from_recipient_id for type & 31 in {0..5}). + // Returns the full recipient row in one query using a correlated subquery. + private static final String SELECT_SELF = + "SELECT r._id, r.e164, r.profile_given_name, r.profile_family_name, " + + "r.profile_joined_name, r.system_joined_name, r.group_id " + + "FROM recipient r " + + "WHERE r._id = (" + + " SELECT from_recipient_id FROM message " + + " WHERE (type & 31) IN (0,1,2,3,4,5) AND from_recipient_id > 0 " + + " GROUP BY from_recipient_id ORDER BY COUNT(*) DESC LIMIT 1" + + ")"; + private final Connection connection; private final String itemPath; @@ -51,6 +64,30 @@ public SignalExtractor(Connection connection, String itemPath) { this.itemPath = itemPath; } + /** + * Identifies the device owner by finding the recipient who most frequently + * appears as sender in outgoing messages. Returns null when no outgoing + * messages exist or the recipient cannot be resolved. + */ + public SignalContact findSelfContact() { + try (Statement st = connection.createStatement(); + ResultSet rs = st.executeQuery(SELECT_SELF)) { + if (rs.next()) { + return new SignalContact( + rs.getLong("_id"), + rs.getString("e164"), + rs.getString("profile_given_name"), + rs.getString("profile_family_name"), + rs.getString("profile_joined_name"), + rs.getString("system_joined_name"), + rs.getString("group_id")); + } + } catch (SQLException e) { + LOGGER.warn("Could not identify device owner from {}: {}", itemPath, e.getMessage()); + } + return null; + } + public boolean isValidSignalDatabase() { try (Statement st = connection.createStatement(); ResultSet rs = st.executeQuery(VALIDATE_TABLES)) { diff --git a/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/SignalParser.java b/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/SignalParser.java index f4b29ebbc8..d507e08f31 100644 --- a/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/SignalParser.java +++ b/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/SignalParser.java @@ -91,7 +91,8 @@ public void parse(InputStream stream, ContentHandler handler, Metadata metadata, return; } List chats = signalExtractor.extractChats(); - createReports(chats, handler, extractor); + SignalContact selfContact = signalExtractor.findSelfContact(); + createReports(chats, selfContact, handler, extractor); } finally { try { connection.close(); } catch (SQLException e) { /* ignore */ } } @@ -102,8 +103,8 @@ public void parse(InputStream stream, ContentHandler handler, Metadata metadata, } } - private void createReports(List chats, ContentHandler handler, - EmbeddedDocumentExtractor extractor) throws SAXException, IOException { + private void createReports(List chats, SignalContact selfContact, + ContentHandler handler, EmbeddedDocumentExtractor extractor) throws SAXException, IOException { int chatVirtualId = 0; for (SignalChat chat : chats) { @@ -122,10 +123,16 @@ private void createReports(List chats, ContentHandler handler, if (chat.isGroupChat()) { chatMeta.add(ExtraProperties.GROUP_ID, chat.getContact().getGroupId()); + // Self first (device owner); then members, excluding self to avoid duplication + if (selfContact != null) + chatMeta.add(ExtraProperties.PARTICIPANTS, selfContact.getFullId()); for (SignalContact member : chat.getParticipants()) { - chatMeta.add(ExtraProperties.PARTICIPANTS, member.getFullId()); + if (selfContact == null || member.getId() != selfContact.getId()) + chatMeta.add(ExtraProperties.PARTICIPANTS, member.getFullId()); } } else if (chat.getContact() != null) { + if (selfContact != null) + chatMeta.add(ExtraProperties.PARTICIPANTS, selfContact.getFullId()); chatMeta.add(ExtraProperties.PARTICIPANTS, chat.getContact().getFullId()); } @@ -136,14 +143,17 @@ private void createReports(List chats, ContentHandler handler, extractor.parseEmbedded(new ByteArrayInputStream(reportBytes), handler, chatMeta, false); if (extractMessages) { - extractMessages(chatTitle, chat, chatVirtualId, handler, extractor); + extractMessages(chatTitle, chat, selfContact, chatVirtualId, handler, extractor); } chatVirtualId++; } } - private void extractMessages(String chatTitle, SignalChat chat, int parentVirtualId, - ContentHandler handler, EmbeddedDocumentExtractor extractor) throws SAXException, IOException { + private void extractMessages(String chatTitle, SignalChat chat, SignalContact selfContact, + int parentVirtualId, ContentHandler handler, EmbeddedDocumentExtractor extractor) + throws SAXException, IOException { + + String selfId = selfContact != null ? selfContact.getFullId() : ""; int msgCount = 0; for (SignalMessage m : chat.getMessages()) { @@ -170,7 +180,7 @@ private void extractMessages(String chatTitle, SignalChat chat, int parentVirtua SignalContact contact = chat.getContact(); if (m.isFromMe()) { - msgMeta.set(org.apache.tika.metadata.Message.MESSAGE_FROM, ""); + msgMeta.set(org.apache.tika.metadata.Message.MESSAGE_FROM, selfId); // For groups, TO is the chat title; for individual, TO is the contact if (chat.isGroupChat()) { msgMeta.add(org.apache.tika.metadata.Message.MESSAGE_TO, chatTitle); @@ -188,7 +198,7 @@ private void extractMessages(String chatTitle, SignalChat chat, int parentVirtua msgMeta.set(org.apache.tika.metadata.Message.MESSAGE_TO, chatTitle); } else if (contact != null) { msgMeta.set(org.apache.tika.metadata.Message.MESSAGE_FROM, contact.getFullId()); - msgMeta.add(org.apache.tika.metadata.Message.MESSAGE_TO, ""); + msgMeta.add(org.apache.tika.metadata.Message.MESSAGE_TO, selfId); } } diff --git a/iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/signal/AbstractPkgTest.java b/iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/signal/AbstractPkgTest.java index 5dc37d5d15..014b40a3be 100644 --- a/iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/signal/AbstractPkgTest.java +++ b/iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/signal/AbstractPkgTest.java @@ -105,9 +105,9 @@ public void parse(InputStream stream, ContentHandler handler, Metadata metadata, if (to != null) messageTos.add(to); - String p = metadata.get(ExtraProperties.PARTICIPANTS); - if (p != null) - participants.add(p); + String[] parts = metadata.getValues(ExtraProperties.PARTICIPANTS); + if (parts != null) + for (String p : parts) participants.add(p); String date = metadata.get(ExtraProperties.MESSAGE_DATE); if (date != null) diff --git a/iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/signal/SignalParserTest.java b/iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/signal/SignalParserTest.java index ef649f8370..d487d9a109 100644 --- a/iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/signal/SignalParserTest.java +++ b/iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/signal/SignalParserTest.java @@ -19,6 +19,8 @@ * 2 – Bob Costa (+5511999990002) [system_joined_name] * 3 – Group placeholder (GRP001FORENSICS) * 4 – phone-only (+5511999990004) [no name fields] + * 5 – Device Owner (+5511999990005) [profile_joined_name] — self (device owner; + * from_recipient_id on all outgoing messages; member of the group) * * Threads (DESC by date → group first, then phone-only, then alice): * 2 – Group "Operacao Digital" (date=1700001000000) @@ -48,6 +50,7 @@ public class SignalParserTest extends AbstractPkgTest { private static final String ALICE_FULL_ID = "Alice Walker (+5511999990001)"; private static final String BOB_FULL_ID = "Bob Costa (+5511999990002)"; + private static final String SELF_FULL_ID = "Device Owner (+5511999990005)"; private EmbeddedSignalParser parse(boolean extractMessages) throws Exception { SignalParser parser = new SignalParser(); @@ -193,8 +196,22 @@ public void testCallOutgoingIsFromMe() throws Exception { EmbeddedSignalParser tracker = parse(true); assertTrue("Outgoing call must have phone contact as TO", tracker.messageTos.contains("+5511999990004")); - assertTrue("Must have at least one outgoing-direction marker (empty MESSAGE_FROM)", - tracker.messageFroms.stream().anyMatch(String::isEmpty)); + assertTrue("Outgoing messages must carry self as MESSAGE_FROM", + tracker.messageFroms.contains(SELF_FULL_ID)); + } + + // ── Phase 2: device owner (self) in PARTICIPANTS ────────────────────────── + + public void testSelfInParticipants() throws Exception { + // Self (device owner, identified from from_recipient_id on outgoing messages) + // must appear as the first PARTICIPANTS entry in every chat, matching the + // convention established by the WhatsApp and Threema parsers. + EmbeddedSignalParser tracker = parse(true); + long selfCount = tracker.participants.stream() + .filter(p -> p.equals(SELF_FULL_ID)) + .count(); + assertEquals("Self must appear as participant in every chat", + EXPECTED_CHAT_DOCS, (int) selfCount); } public void testGroupParticipantAlice() throws Exception { diff --git a/iped-parsers/iped-parsers-impl/src/test/resources/test-files/test_signal.db b/iped-parsers/iped-parsers-impl/src/test/resources/test-files/test_signal.db index bc06f3ec1bdce96553c01afbb2e095f81ca9805e..f7c34eb5f3e677e824aa3e4b47de9d74f8c0188a 100644 GIT binary patch delta 285 zcmZo@U}|V!njp={K2gS*k$q#r5`I2b{s0F4i~Jk-r}NkI2W%D;u;bU)W#wSdloVHG zV9+)-H8ivY0RsaAQsFff1>iHk8q3}9dcsd;+W076c_UatuN D_p3Q{(1)fi~Jk-r}Niu7F5XR-&`->s=&d-f0cp% zHUCwh&`EwK5vIup{e?Hb@)s3gWZIO#C?L(iz`(`V&%l3!e>MLU{#t%VepbF4d Date: Mon, 6 Jul 2026 18:50:17 -0400 Subject: [PATCH 5/6] Fix 4 code-review findings in SignalParser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Re-throw SAXException before catch(Exception) so Tika's pipeline stop mechanism (WriteLimitReachedException) works correctly - Call shouldParseEmbedded before each chat report and message item, honouring IPED's per-item filtering logic (same fix applied to Android call log parser in a previous commit) - readResource() now logs a WARN when the HTML template or CSS file is missing from the classpath, instead of silently returning empty string that produces blank forensic reports - Fix misleading test comment: "→ 8 indexed" corrected to "→ 9 indexed (3+3+3)" — the assertion was always correct (EXPECTED_MESSAGE_DOCS = 9) All 25 Signal unit tests pass. --- .../iped/parsers/signal/ReportGenerator.java | 16 ++++++++++++++-- .../java/iped/parsers/signal/SignalParser.java | 13 +++++++++++++ .../iped/parsers/signal/SignalParserTest.java | 2 +- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/ReportGenerator.java b/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/ReportGenerator.java index 21c19ff874..3957307761 100644 --- a/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/ReportGenerator.java +++ b/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/ReportGenerator.java @@ -10,10 +10,15 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import iped.utils.IOUtil; public class ReportGenerator { + private static final Logger LOGGER = LoggerFactory.getLogger(ReportGenerator.class); + private static final String TEMPLATE; private static final String CSS; @@ -158,12 +163,19 @@ private static String escapeHtml(String s) { private static String readResource(String path) { InputStream is = ReportGenerator.class.getResourceAsStream(path); - if (is == null) + if (is == null) { + LOGGER.warn("Signal report resource not found on classpath: {}", path); return ""; + } try { byte[] bytes = IOUtil.loadInputStream(is); - return bytes != null ? new String(bytes, StandardCharsets.UTF_8) : ""; + if (bytes == null) { + LOGGER.warn("Failed to read Signal report resource: {}", path); + return ""; + } + return new String(bytes, StandardCharsets.UTF_8); } catch (IOException e) { + LOGGER.warn("Error loading Signal report resource {}: {}", path, e.getMessage()); return ""; } } diff --git a/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/SignalParser.java b/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/SignalParser.java index d507e08f31..e3be1c4a31 100644 --- a/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/SignalParser.java +++ b/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/SignalParser.java @@ -50,6 +50,7 @@ public class SignalParser extends SQLite3DBParser { private static final Set SUPPORTED_TYPES = Collections.singleton(SIGNAL_DB); + // ReportGenerator uses ThreadLocal formatters; singleton is safe private static final ReportGenerator REPORT_GEN = new ReportGenerator(); private boolean extractMessages = true; @@ -97,6 +98,10 @@ public void parse(InputStream stream, ContentHandler handler, Metadata metadata, try { connection.close(); } catch (SQLException e) { /* ignore */ } } + } catch (SAXException e) { + // Let SAXException (including WriteLimitReachedException) propagate so + // Tika's pipeline stop mechanism works correctly + throw e; } catch (Exception e) { LOGGER.warn("Error parsing Signal database", e); throw new TikaException("SignalParser error", e); @@ -139,6 +144,11 @@ private void createReports(List chats, SignalContact selfContact, if (extractMessages && indexableCount > 0) chatMeta.set(BasicProps.HASCHILD, Boolean.TRUE.toString()); + if (!extractor.shouldParseEmbedded(chatMeta)) { + chatVirtualId++; + continue; + } + byte[] reportBytes = REPORT_GEN.generateChatHtml(chat); extractor.parseEmbedded(new ByteArrayInputStream(reportBytes), handler, chatMeta, false); @@ -202,6 +212,9 @@ private void extractMessages(String chatTitle, SignalChat chat, SignalContact se } } + if (!extractor.shouldParseEmbedded(msgMeta)) + continue; + extractor.parseEmbedded(new EmptyInputStream(), handler, msgMeta, false); } } diff --git a/iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/signal/SignalParserTest.java b/iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/signal/SignalParserTest.java index d487d9a109..4293f0dc07 100644 --- a/iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/signal/SignalParserTest.java +++ b/iped-parsers/iped-parsers-impl/src/test/java/iped/parsers/signal/SignalParserTest.java @@ -88,7 +88,7 @@ public void testMessageDocumentCount() throws Exception { long msgCount = tracker.contentTypes.stream() .filter(t -> t.equals(SignalParser.SIGNAL_MESSAGE.toString())) .count(); - // 9 raw messages; 1 system (type=14) filtered → 8 indexed + // 10 raw rows; 1 system (type=14) filtered → 9 indexed (3+3+3) assertEquals("System messages must be excluded from indexed message count", EXPECTED_MESSAGE_DOCS, (int) msgCount); } From d18b90be030b4b4c39f2a55e7c38acc4e0d68d67 Mon Sep 17 00:00:00 2001 From: Cleiton Augusto Date: Wed, 22 Jul 2026 18:10:55 -0400 Subject: [PATCH 6/6] Fall back to dateReceived when dateSent is missing in message metadata ReportGenerator.generateChatHtml() already falls back to dateReceived when dateSent is null, but extractMessages() only used dateSent. A message with no dateSent (e.g. a locally-originated draft or failed send) would show a date in the HTML report but have no MESSAGE_DATE in its indexed metadata, silently dropping it from date-based search/filtering while still being visible in the report. --- .../main/java/iped/parsers/signal/SignalParser.java | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/SignalParser.java b/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/SignalParser.java index e3be1c4a31..899f3ca9e8 100644 --- a/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/SignalParser.java +++ b/iped-parsers/iped-parsers-impl/src/main/java/iped/parsers/signal/SignalParser.java @@ -6,6 +6,7 @@ import java.sql.Connection; import java.sql.SQLException; import java.util.Collections; +import java.util.Date; import java.util.List; import java.util.Set; @@ -177,9 +178,14 @@ private void extractMessages(String chatTitle, SignalChat chat, SignalContact se msgMeta.set(ExtraProperties.PARENT_VIRTUAL_ID, Integer.toString(parentVirtualId)); msgMeta.set(ExtraProperties.DECODED_DATA, Boolean.TRUE.toString()); - if (m.getDateSent() != null) { - msgMeta.set(ExtraProperties.MESSAGE_DATE, m.getDateSent()); - msgMeta.set(TikaCoreProperties.CREATED, m.getDateSent()); + // fall back to dateReceived like ReportGenerator does, so a message + // with no dateSent (e.g. a locally-originated draft/failed send) + // doesn't silently drop out of date-based search/filtering while + // still showing a date in the HTML report + Date msgDate = m.getDateSent() != null ? m.getDateSent() : m.getDateReceived(); + if (msgDate != null) { + msgMeta.set(ExtraProperties.MESSAGE_DATE, msgDate); + msgMeta.set(TikaCoreProperties.CREATED, msgDate); } msgMeta.set(ExtraProperties.MESSAGE_BODY, resolveBody(m));