Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions iped-app/resources/config/conf/CategoriesConfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]},
Expand Down Expand Up @@ -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"}
Expand Down
13 changes: 13 additions & 0 deletions iped-app/resources/config/conf/CustomSignatures.xml
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,19 @@
<sub-class-of type="text/html"/>
</mime-type>

<mime-type type="application/x-signal-db">
<sub-class-of type="application/x-sqlite3"/>
<glob pattern="signal.db"/>
</mime-type>

<mime-type type="application/x-signal-chat">
<sub-class-of type="text/html"/>
</mime-type>

<mime-type type="message/x-signal-message">
<sub-class-of type="message/x-chat-message"/>
</mime-type>

<mime-type type="application/sqlite-skype">
<sub-class-of type="application/x-sqlite3"/>
<glob pattern="main*.db"/>
Expand Down
5 changes: 5 additions & 0 deletions iped-app/resources/config/conf/ParserConfig.xml
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,11 @@
<param name="extractMediasAsFiles" type="bool">false</param>
</params>
</parser>
<parser class="iped.parsers.signal.SignalParser">
<params>
<param name="extractMessages" type="bool">true</param>
</params>
</parser>
<parser class="iped.parsers.ufed.UfedChatParser">
<params>
<param name="minChatSplitSize" type="int">6000000</param>
Expand Down
3 changes: 3 additions & 0 deletions iped-app/src/main/java/iped/app/ui/IconManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,9 @@ private static Map<String, QualityIcon> 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");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
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 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;

// 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");
}

private static final ThreadLocal<SimpleDateFormat> DATE_FMT = ThreadLocal.withInitial(() -> {
SimpleDateFormat f = new SimpleDateFormat("yyyy-MM-dd");
f.setTimeZone(TimeZone.getDefault());
return f;
});

private static final ThreadLocal<SimpleDateFormat> 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));
}

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

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 = "<div class=\"time\">" + formatTime(m.getDateSent()) + "</div>";

StringBuilder sb = new StringBuilder(256);
sb.append("<div class=\"linha ").append(dirClass).append("\">");
sb.append("<div class=\"").append(bubbleClass).append("\">");

if (incoming && chat.isGroupChat()) {
String senderName = resolveSenderName(m, chat);
if (senderName != null && !senderName.isEmpty()) {
sb.append("<div class=\"from\">").append(escapeHtml(senderName)).append("</div>");
}
}

sb.append(bodyHtml);
sb.append(timeHtml);
sb.append("</div></div>\n");
return sb.toString();
}

private String buildBodyHtml(SignalMessage m) {
SignalMessage.MessageType type = m.getMessageType();
if (type == SignalMessage.MessageType.CALL_OUTGOING) {
return "<div class=\"body call-label\">&#128222; Outgoing call</div>";
}
if (type == SignalMessage.MessageType.CALL_INCOMING) {
return "<div class=\"body call-label\">&#128222; Incoming call</div>";
}
if (type == SignalMessage.MessageType.CALL_MISSED) {
return "<div class=\"body call-label\">&#128222; Missed call</div>";
}
if (m.getBody() == null) {
return "<div class=\"body attachment-label\">[Attachment]</div>";
}
return "<div class=\"body\">" + escapeHtml(m.getBody()) + "</div>";
}

private String renderSystemMessage(SignalMessage m) {
String text = m.getBody() != null ? escapeHtml(m.getBody()) : "System message";
return "<div class=\"systemmessage\"><span>" + text + "</span></div>\n";
}

private String renderDateSeparator(Date date) {
return "<div class=\"date\"><span>" + DATE_FMT.get().format(date) + "</span></div>\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("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;");
}

private static String readResource(String path) {
InputStream is = ReportGenerator.class.getResourceAsStream(path);
if (is == null) {
LOGGER.warn("Signal report resource not found on classpath: {}", path);
return "";
}
try {
byte[] bytes = IOUtil.loadInputStream(is);
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 "";
}
}
}
Original file line number Diff line number Diff line change
@@ -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<SignalMessage> messages = new ArrayList<>();
private List<SignalContact> 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<SignalMessage> getMessages() { return messages; }
public void setMessages(List<SignalMessage> messages) { this.messages = messages; }

public List<SignalContact> 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");
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading