├── .gitignore ├── README.md ├── chattr.html ├── requirements.txt └── scab.py /.gitignore: -------------------------------------------------------------------------------- 1 | Signal-Archive 2 | Signal 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Signal Conversation Archive Backup 2 | 3 | ## SCAB 4 | 5 | Welcome to [Signal Conversation Archive Backup (SCAB)](https://github.com/mattsta/signal-backup)! 6 | 7 | Full writeup is at: https://matt.sh/signal-backup 8 | 9 | ## Usage 10 | 11 | To backup your Signal Desktop database, run the following commands to: 12 | 13 | - check out SCAB 14 | - install Python requirements 15 | - copy your Signal Desktop database (and attachments) into a new directory so nothing is read against your live Signal DB 16 | - generate a single local HTML page web viewer for all your conversations 17 | 18 | ```erlang 19 | git clone https://github.com/mattsta/signal-backup 20 | pip3 install -r requirements.txt 21 | cd signal-backup 22 | rsync -avz "/Users/$(whoami)/Library/Application Support/Signal" Signal-Archive 23 | cd Signal-Archive 24 | python3 ../scab.py 25 | open myConversations.html 26 | ``` 27 | -------------------------------------------------------------------------------- /chattr.html: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 744 | 757 | 758 |
759 |
760 | 765 |
766 |
    767 | 789 |
790 |
791 | 796 |
797 |
798 |
799 | 800 |

hi my name is...

801 |
802 |
803 | 804 |
    805 |
806 |
807 |
808 |
809 | 810 | 1015 | 1016 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pysqlcipher3 2 | -------------------------------------------------------------------------------- /scab.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3.6 2 | 3 | from pysqlcipher3 import dbapi2 as sqlcipher 4 | 5 | import json 6 | import os 7 | 8 | # Locations of things 9 | BASE = os.path.dirname(os.path.abspath(__file__)) 10 | CONFIG = "config.json" 11 | DB = "sql/db.sqlite" 12 | 13 | # Read sqlcipher key from Signal config file 14 | try: 15 | with open(CONFIG, "r") as conf: 16 | key = json.loads(conf.read())['key'] 17 | except FileNotFoundError: 18 | print(f"Error: {CONFIG} not found in current directory!") 19 | print("Run again from inside your Signal Desktop user directory") 20 | import sys 21 | sys.exit(1) 22 | 23 | db = sqlcipher.connect(DB) 24 | c = db.cursor() 25 | c2 = db.cursor() 26 | 27 | # param binding doesn't work for pragmas, so use a direct string concat 28 | for cursor in [c, c2]: 29 | cursor.execute(f'PRAGMA KEY = "x\'{key}\'"') 30 | cursor.execute(f'PRAGMA cipher_page_size = 1024') 31 | cursor.execute(f'PRAGMA kdf_iter = 64000') 32 | cursor.execute(f'PRAGMA cipher_hmac_algorithm = HMAC_SHA1') 33 | cursor.execute(f'PRAGMA cipher_kdf_algorithm = PBKDF2_HMAC_SHA1') 34 | 35 | # Hold numeric user id to conversation/user names 36 | conversations = {} 37 | 38 | # Hold message body data 39 | convos = {} 40 | 41 | c.execute("SELECT json, id, name, profileName, type, members FROM conversations") 42 | for result in c: 43 | cId = result[1] 44 | isGroup = result[4] == "group" 45 | conversations[cId] = { 46 | "id": result[1], 47 | "name": result[2], 48 | "profileName": result[3], 49 | "isGroup": isGroup} 50 | convos[cId] = [] 51 | 52 | if isGroup: 53 | usableMembers = [] 54 | # Attempt to match group members from ID (phone number) back to real 55 | # names if the real names are also in your contact/conversation list. 56 | for member in result[5].split(): 57 | c2.execute( 58 | "SELECT name, profileName FROM conversations WHERE id=?", 59 | [member]) 60 | for name in c2: 61 | useName = name[0] if name else member 62 | usableMembers.append(useName) 63 | 64 | conversations[cId]["members"] = usableMembers 65 | 66 | # We either need an ORDER BY or a manual sort() below because our web interface 67 | # processes message history in array order with javascript object traversal. 68 | # If we skip ordering here, the web interface will show the message history in 69 | # a random order, which can be amusing but not necessarily very useful. 70 | c.execute( 71 | "SELECT json, conversationId, sent_at, received_at FROM messages ORDER BY sent_at") 72 | messages = [] 73 | for result in c: 74 | content = json.loads(result[0]) 75 | cId = result[1] 76 | if not cId: 77 | # Signal's data model isn't as stable as one would imagine 78 | continue 79 | convos[cId].append(content) 80 | 81 | # Unnecessary with our ORDER BY clause 82 | if False: 83 | from operator import itemgetter 84 | for convo in convos: 85 | convos[convo].sort(key=itemgetter("sent_at")) 86 | 87 | # Exporting JSON to files is optional since we also paste it directly 88 | # into the resulting HTML interface 89 | with open("contacts.json", "w") as con: 90 | json.dump(conversations, con) 91 | contactsJSON = json.dumps(conversations) 92 | 93 | with open("convos.json", "w") as ampc: 94 | json.dump(convos, ampc) 95 | convosJSON = json.dumps(convos) 96 | 97 | # Create end result of interactive HTML interface with embedded and formatted 98 | # chat history for all contacts/conversations. 99 | with open(f"{BASE}/chattr.html", "r") as chattr: 100 | newChat = chattr.read() 101 | updated = newChat.replace( 102 | "JSONINSERTHERE", 103 | f"var contacts = {contactsJSON}; var convos = {convosJSON};") 104 | 105 | with open("myConversations.html", "w") as mine: 106 | mine.write(updated) 107 | --------------------------------------------------------------------------------