Skip to content

Commit

Permalink
colornote: improve error handling and documentation
Browse files Browse the repository at this point in the history
  • Loading branch information
marph91 committed Oct 15, 2024
1 parent ea8e42e commit ba32e83
Show file tree
Hide file tree
Showing 2 changed files with 39 additions and 19 deletions.
3 changes: 2 additions & 1 deletion docs/formats/colornote.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ This page describes how to convert notes from ColorNote to Markdown.

1. Export as described [at the website](https://www.colornote.com/faq-question/what-is-device-backup/)
2. [Install jimmy](../index.md#installation)
3. Convert to Markdown. Example: `jimmy-cli-linux colornote-20241013.backup --format colornote --password 1234`
3. Convert to Markdown. Example: `jimmy-cli-linux colornote-20241013.backup --format colornote --password 0000`
1. The default password for automatic backups is `0000`.
4. [Import to your app](../import_instructions.md)
55 changes: 37 additions & 18 deletions src/formats/colornote.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,28 +16,44 @@
import markdown_lib.common


def decrypt(salt: bytes, password: bytes, ciphertext: bytes) -> bytes:
# decrypting is based on:
# https://github.com/olejorgenb/ColorNote-backup-decryptor/blob/61e105d6f13b2cd22b5141b6334bb098617665e1/src/ColorNoteBackupDecrypt.java
key = hashlib.md5(password + salt).digest()
iv = hashlib.md5(key + password + salt).digest()

cipher = Cipher(algorithms.AES128(key), modes.CBC(iv))
decryptor = cipher.decryptor()
plaintext_padded = decryptor.update(ciphertext) + decryptor.finalize()

unpadder = padding.PKCS7(cipher.algorithm.block_size).unpadder()
plaintext = unpadder.update(plaintext_padded) + unpadder.finalize()
return plaintext


class Converter(converter.BaseConverter):
accepted_extensions = [".backup"]

def __init__(self, config):
super().__init__(config)
self.password = config.password

def parse_metadata(self, ciphertext: bytes):
# TODO: Is the reverse-engineered data correct?
# print(ciphertext[:8].decode("utf-8") == "NOTE")
major, minor, timestamp, note_count = struct.unpack(">LLQL", ciphertext[8:])
date = common.timestamp_to_datetime(timestamp / 1000).strftime(
"%Y-%m-%d %H:%M:%S"
)
self.logger.info(
f"Metadata: {note_count} notes exported at {date} "
f"with version {major}.{minor}"
)

def decrypt(self, salt: bytes, password: bytes, ciphertext: bytes) -> bytes | None:
# decrypting is based on:
# https://github.com/olejorgenb/ColorNote-backup-decryptor/blob/61e105d6f13b2cd22b5141b6334bb098617665e1/src/ColorNoteBackupDecrypt.java
key = hashlib.md5(password + salt).digest()
iv = hashlib.md5(key + password + salt).digest()

cipher = Cipher(algorithms.AES128(key), modes.CBC(iv))
decryptor = cipher.decryptor()
plaintext_padded = decryptor.update(ciphertext) + decryptor.finalize()

unpadder = padding.PKCS7(cipher.algorithm.block_size).unpadder()
try:
plaintext = unpadder.update(plaintext_padded) + unpadder.finalize()
return plaintext
except ValueError as exc:
self.logger.error("Decrypting failed. Wrong password?")
self.logger.debug(exc, exc_info=True)
return None

def handle_wikilink_links(self, body: str) -> imf.NoteLinks:
# only internal links
# https://www.colornote.com/faq-question/how-can-i-link-a-note-with-another-note/
Expand All @@ -48,10 +64,13 @@ def handle_wikilink_links(self, body: str) -> imf.NoteLinks:

def convert(self, file_or_folder: Path):
ciphertext = file_or_folder.read_bytes()
# TODO: Meaning of ciphertext[:28]?
plaintext = decrypt(

self.parse_metadata(ciphertext[:28])
plaintext = self.decrypt(
b"ColorNote Fixed Salt", self.password.encode("utf-8"), ciphertext[28:]
)
if plaintext is None:
return

# TODO: Meaning of plaintext[:16]? Looks similar to the iv.
plaintext_stream = io.BytesIO(plaintext)
Expand All @@ -60,7 +79,7 @@ def convert(self, file_or_folder: Path):
# parse binary colornote format
# 4 bytes: chunk length
# chunk length bytes: json data
(chunk_length,) = struct.unpack(">L", chunk_length_bytes)
chunk_length = struct.unpack(">L", chunk_length_bytes)[0]
chunk_bytes = plaintext_stream.read(chunk_length)
note_json = json.loads(chunk_bytes.decode("utf-8"))

Expand Down

0 comments on commit ba32e83

Please sign in to comment.