From 1d7ddc3f8aa350868d3f7066941e6ee71bc7870c Mon Sep 17 00:00:00 2001 From: Mark Qvist Date: Thu, 14 May 2026 01:51:22 +0200 Subject: [PATCH] Implemented rngit work document signing --- RNS/Utilities/rngit/pages.py | 24 +++++-- RNS/Utilities/rngit/server.py | 132 ++++++++++++++++++++++++++-------- 2 files changed, 121 insertions(+), 35 deletions(-) diff --git a/RNS/Utilities/rngit/pages.py b/RNS/Utilities/rngit/pages.py index a87cae98..e5eec25d 100644 --- a/RNS/Utilities/rngit/pages.py +++ b/RNS/Utilities/rngit/pages.py @@ -1463,20 +1463,34 @@ class NomadNetworkNode(): meta = doc.get("meta", {}) author = meta.get("author", b"") author_str = RNS.prettyhexrep(author) if author else "Unknown" + signature = meta.get("signature", None) + pubkey = meta.get("identity", None) created = meta.get("created", 0) edited = meta.get("edited", 0) fmt = meta.get("format", "markdown") + content = doc.get("content", "") + + signature_validated = False + signature_str = "Document not signed" + if signature and type(signature) == bytes and len(signature) == RNS.Identity.SIGLENGTH//8: + if pubkey and type(pubkey) == bytes and len(pubkey) == RNS.Identity.KEYSIZE//8: + signature_str = "Not valid" + identity = RNS.Identity(create_keys=False) + identity.load_public_key(pubkey) + signature_validated = identity.validate(signature, content.encode("utf-8")) + if signature_validated: signature_str = "Valid" # Document header content_parts.append(self.m_heading(f"{doc_title}", 2)) - content_parts.append(f"\n{self.CLR_DIM}Author : {author_str}`f\n") - content_parts.append(f"{self.CLR_DIM}Created : {time.strftime('%Y-%m-%d %H:%M', time.localtime(created)) if created else 'unknown'}`f\n") + content_parts.append(f"\n{self.CLR_DIM}Author : {author_str}`f\n") + content_parts.append(f"{self.CLR_DIM}Signature : {signature_str}`f\n") + content_parts.append(f"{self.CLR_DIM}Created : {time.strftime('%Y-%m-%d %H:%M', time.localtime(created)) if created else 'unknown'}`f\n") if edited and edited != created: - content_parts.append(f"{self.CLR_DIM}Edited : {time.strftime('%Y-%m-%d %H:%M', time.localtime(edited))}`f\n") - content_parts.append(f"{self.CLR_DIM}Status : {scope.capitalize()}`f\n\n") + content_parts.append(f"{self.CLR_DIM}Edited : {time.strftime('%Y-%m-%d %H:%M', time.localtime(edited))}`f\n") + content_parts.append(f"{self.CLR_DIM}Status : {scope.capitalize()}`f\n\n") # Document content - content = doc.get("content", "").strip() + content = content.strip() if content: if fmt == "micron": content_parts.append(content) else: content_parts.append(self.mdc.format_block(content)) diff --git a/RNS/Utilities/rngit/server.py b/RNS/Utilities/rngit/server.py index 95c2b805..e21c32b7 100644 --- a/RNS/Utilities/rngit/server.py +++ b/RNS/Utilities/rngit/server.py @@ -726,16 +726,32 @@ class ReticulumGitClient(): if len(response) <= 1: self.abort("Empty response from remote") doc = mp.unpackb(response[1:]) + + author_str = f"{doc['meta']['author']} (not locally validated)" + signature_str = "Document not signed" + signature = doc["meta"].get("signature", None) + pubkey = doc["meta"].get("identity", None) + content = doc.get("content", "") + if signature and type(signature) == bytes and len(signature) == RNS.Identity.SIGLENGTH//8: + if pubkey and type(pubkey) == bytes and len(pubkey) == RNS.Identity.KEYSIZE//8: + signature_str = "Not valid" + identity = RNS.Identity(create_keys=False) + identity.load_public_key(pubkey) + signature_validated = identity.validate(signature, content.encode("utf-8")) + if signature_validated: + signature_str = "Valid" + author_str = RNS.prettyhexrep(identity.hash) dt = f"{doc['meta']['title']} (#{doc['id']})" print(f"{dt}") print("="*len(dt)) - print(f"Author : {doc['meta']['author']}") - print(f"Status : {scope}") - print(f"Created : {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(doc['meta']['created']))}") - print(f"Edited : {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(doc['meta']['edited']))}") - print(f"Format : {doc['meta']['format']}") - print(f"Updates : {len(doc.get('comments', []))}") + print(f"Author : {author_str}") + print(f"Signature : {signature_str}") + print(f"Status : {scope.capitalize()}") + print(f"Created : {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(doc['meta']['created']))}") + print(f"Edited : {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(doc['meta']['edited']))}") + print(f"Format : {doc['meta']['format']}") + print(f"Updates : {len(doc.get('comments', []))}") print() print(doc['content']) @@ -774,9 +790,13 @@ class ReticulumGitClient(): content = self._edit_work_content(title=title) if content is None: print("Creation cancelled"); return + + signature = self.identity.sign(content.encode("utf-8")) + if not signature: self.abort("Could not sign work document") - request_data = { self.IDX_REPOSITORY: repo_path, - "operation": "create", "title": title, "content": content, "format": "markdown" } + request_data = { self.IDX_REPOSITORY: repo_path, "operation": "create", + "title": title, "content": content, "format": "markdown", + "signature": signature } response, metadata = self.send_request(self.PATH_WORK, request_data, timeout=30) if not response or not isinstance(response, bytes): self.abort("No response from remote") @@ -829,9 +849,12 @@ class ReticulumGitClient(): content = self._edit_work_content(title=current_title, content=current_content) if content is None: print("Edit cancelled"); return + signature = self.identity.sign(content.encode("utf-8")) + if not signature: self.abort("Could not sign work document") + title = title or current_title - request_data = { self.IDX_REPOSITORY: repo_path, - "operation": "edit", "doc_id": doc_id, "scope": scope, "content": content, "title": title } + request_data = { self.IDX_REPOSITORY: repo_path, "operation": "edit", "doc_id": doc_id, + "scope": scope, "content": content, "title": title, "signature": signature } response, metadata = self.send_request(self.PATH_WORK, request_data, timeout=30) if not response or not isinstance(response, bytes): self.abort("No response from remote") @@ -2414,13 +2437,22 @@ class ReticulumGitNode(): def _work_view(self, work_path, data, remote_identity): doc_id = data.get("doc_id") - scope = data.get("scope", "active") + scope = data.get("scope", "all") if not scope in ["active", "completed", "all"]: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request" if doc_id is None: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No document ID specified" try: doc_id = int(doc_id) except: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid document ID" + scope = None + doc_dir = None + for s in ["active", "completed"]: + d = os.path.join(work_path, s, str(doc_id)) + if os.path.isdir(d): + scope = s + doc_dir = d + break + doc_dir = os.path.join(work_path, scope, str(doc_id)) root_path = os.path.join(doc_dir, "root") @@ -2448,13 +2480,16 @@ class ReticulumGitNode(): comments.sort(key=lambda x: x["id"]) + meta = doc.get("meta", {}) result = { "id": doc_id, "scope": scope, "content": doc.get("content", ""), "comments": comments, - "meta": { "title": doc.get("meta", {}).get("title", "Untitled"), - "created": doc.get("meta", {}).get("created", 0), - "edited": doc.get("meta", {}).get("edited", 0), - "author": RNS.hexrep(doc.get("meta", {}).get("author", b""), delimit=False) if doc.get("meta", {}).get("author") else "", - "format": doc.get("meta", {}).get("format", "markdown") } } + "meta": { "title": meta.get("title", "Untitled"), + "created": meta.get("created", 0), + "edited": meta.get("edited", 0), + "author": RNS.hexrep(meta.get("author", b""), delimit=False) if meta.get("author") else "", + "identity": meta.get("identity", None), + "signature": meta.get("signature", None), + "format": meta.get("format", "markdown") } } return b"\x00" + mp.packb(result) @@ -2463,9 +2498,13 @@ class ReticulumGitNode(): content = data.get("content", "").strip() format_type = data.get("format", "markdown") signature = data.get("signature", None) + signed_data = content.encode("utf-8") + sig_length = RNS.Identity.SIGLENGTH//8 limit = self.WORK_DOC_LIMIT - if signature and not len(signature) == RNS.Identity.SIGLENGTH: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid signature" + if not signature: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No signature provided" + if signature and not len(signature) == sig_length: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid signature length" + if not remote_identity.validate(signature, signed_data): return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid signature" if len(title)+len(content)+len(format_type) > limit: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Content limit exceeded" if not title: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Title is required" if not content: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Content is required" @@ -2479,8 +2518,8 @@ class ReticulumGitNode(): now = time.time() document = { "content": content, "meta": { "format": format_type if format_type in ["markdown", "micron"] else "markdown", - "title": title, "created": now, "edited": now, - "signature": signature, "author": remote_identity.hash } } + "title": title, "created": now, "edited": now, "author": remote_identity.hash, + "signature": signature, "identity": remote_identity.get_public_key() } } root_path = os.path.join(doc_dir, "root") if not self._work_save_document(root_path, document): @@ -2494,28 +2533,42 @@ class ReticulumGitNode(): return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Remote error" def _work_edit(self, work_path, data, remote_identity): - doc_id = data.get("doc_id") - scope = data.get("scope", "active") - content = data.get("content") - title = data.get("title") - signature = data.get("signature", None) - limit = self.WORK_DOC_LIMIT + doc_id = data.get("doc_id") + scope = data.get("scope", "active") + content = data.get("content", "") + title = data.get("title", "") + signature = data.get("signature", None) + signed_data = content.encode("utf-8") + sig_length = RNS.Identity.SIGLENGTH//8 + limit = self.WORK_DOC_LIMIT size = 0 if title: size += len(title) if content: size += len(content) if not scope in ["active", "completed", "all"]: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid request" - if signature and not len(signature) == RNS.Identity.SIGLENGTH: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid signature" + if not signature: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No signature provided" + if signature and not len(signature) == sig_length: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid signature length" + if not remote_identity.validate(signature, signed_data): return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid signature" if size > limit: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Content limit exceeded" - if content is None and title is None: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No changes specified" - if doc_id is None: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No document ID specified" + if not content and not title: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No changes specified" + if not doc_id: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"No document ID specified" try: doc_id = int(doc_id) except: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid document ID" + + scope = None + doc_dir = None + for s in ["active", "completed"]: + d = os.path.join(work_path, s, str(doc_id)) + if os.path.isdir(d): + scope = s + doc_dir = d + break doc_dir = os.path.join(work_path, scope, str(doc_id)) root_path = os.path.join(doc_dir, "root") + RNS.log(f"PATH: {root_path}") if not os.path.isfile(root_path): return self.RES_NOT_FOUND.to_bytes(1, "big") + b"Document not found" @@ -2525,10 +2578,11 @@ class ReticulumGitNode(): if doc.get("meta", {}).get("author") != remote_identity.hash: return self.RES_DISALLOWED.to_bytes(1, "big") + b"No access, not author" try: - if title is not None: doc["meta"]["title"] = title.strip() - if content is not None: doc["content"] = content.strip() + if title: doc["meta"]["title"] = title.strip() + if content: doc["content"] = content.strip() doc["meta"]["edited"] = time.time() doc["meta"]["signature"] = signature + doc["meta"]["identity"] = remote_identity.get_public_key() if not self._work_save_document(root_path, doc): return self.RES_REMOTE_FAIL.to_bytes(1, "big") + b"Error saving document" @@ -2548,6 +2602,15 @@ class ReticulumGitNode(): try: doc_id = int(doc_id) except: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid document ID" + + scope = None + doc_dir = None + for s in ["active", "completed"]: + d = os.path.join(work_path, s, str(doc_id)) + if os.path.isdir(d): + scope = s + doc_dir = d + break doc_dir = os.path.join(work_path, scope, str(doc_id)) root_path = os.path.join(doc_dir, "root") @@ -2585,6 +2648,15 @@ class ReticulumGitNode(): except: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Invalid document ID" if not content: return self.RES_INVALID_REQ.to_bytes(1, "big") + b"Content is required" + + scope = None + doc_dir = None + for s in ["active", "completed"]: + d = os.path.join(work_path, s, str(doc_id)) + if os.path.isdir(d): + scope = s + doc_dir = d + break doc_dir = os.path.join(work_path, scope, str(doc_id)) root_path = os.path.join(doc_dir, "root")