From 88d5cc9f23065e5094371211af9c4b20a31bcb8e Mon Sep 17 00:00:00 2001 From: Scott Reimers Date: Wed, 22 Apr 2026 22:46:24 -0400 Subject: [PATCH] =?UTF-8?q?Phase=202e:=20rich=20comments=20=E2=80=94=20opt?= =?UTF-8?q?ional=20ref=5Fpost=5Fid=20with=20signed=20preview?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A comment can now reference a separate Post that carries the full body (long text, attachments, rich formatting). The inline comment's `content` becomes a short preview string; the referenced post propagates through the normal CDN and readers fetch it lazily when rendering the expanded view. Type change: - `InlineComment` gains `ref_post_id: Option` (#[serde(default)]). When None, `content` is the full comment text (v0.6.1 shape — unchanged on the wire). When Some, `content` is the preview. Signature binding: - `crypto::sign_comment` / `verify_comment_signature` now take `ref_post_id: Option<&PostId>`. The signed digest appends `b"ref:" || ref_post_id` only when a ref is present, so plain comments produce the same digest as the v0.6.1 scheme and remain verifiable without a migration. When a ref is present the signature binds it, so a peer can't strip or swap the reference without re-signing. Storage: - `comments` table gets a `ref_post_id BLOB` column (nullable). Added to both the CREATE TABLE statement and a conditional ALTER TABLE migration so upgraded DBs pick it up automatically. - `store_comment`, `get_comments`, `get_comments_with_tombstones` read and write the column. Node API: - `comment_on_post` stays as the plain-comment entry point (calls the inner helper with `ref_post_id = None`). - New `comment_on_post_with_ref(post_id, preview, ref_post_id)` for rich comments. Both share a single inner helper that signs, stores, and propagates via BlobHeaderDiff. connection.rs BlobHeaderDiff handler passes `comment.ref_post_id.as_ref()` to the signature verify so forged or rewritten refs are rejected. Tests: new crypto test asserting the signature binds ref_post_id (strip / swap / drop all fail); new storage test asserting ref_post_id roundtrips through live + tombstone reads. 116 / 116 core tests pass. Client-side UX (pulling the ref post on expand, composing rich comments) is frontend work that will land with the next UI iteration. --- crates/core/src/connection.rs | 1 + crates/core/src/crypto.rs | 60 ++++++++++++++++++++++------ crates/core/src/node.rs | 39 +++++++++++++++++-- crates/core/src/storage.rs | 73 ++++++++++++++++++++++++++++++----- crates/core/src/types.rs | 20 ++++++++-- 5 files changed, 166 insertions(+), 27 deletions(-) diff --git a/crates/core/src/connection.rs b/crates/core/src/connection.rs index f764b1a..c554200 100644 --- a/crates/core/src/connection.rs +++ b/crates/core/src/connection.rs @@ -6117,6 +6117,7 @@ impl ConnectionManager { &comment.content, comment.timestamp_ms, &comment.signature, + comment.ref_post_id.as_ref(), ) { continue; // Skip forged comments } diff --git a/crates/core/src/crypto.rs b/crates/core/src/crypto.rs index 301ed15..e19033e 100644 --- a/crates/core/src/crypto.rs +++ b/crates/core/src/crypto.rs @@ -728,20 +728,37 @@ pub fn decrypt_private_reaction( } /// Sign a comment: ed25519 over BLAKE3(author || post_id || content || timestamp_ms). +fn comment_digest( + author: &NodeId, + post_id: &PostId, + content: &str, + timestamp_ms: u64, + ref_post_id: Option<&PostId>, +) -> blake3::Hash { + let mut hasher = blake3::Hasher::new_derive_key(COMMENT_SIGN_CONTEXT); + hasher.update(author); + hasher.update(post_id); + hasher.update(content.as_bytes()); + hasher.update(×tamp_ms.to_le_bytes()); + // Domain-separated append: `None` yields the same digest as the v0.6.1 + // scheme, so plain comments keep verifying; `Some(ref)` adds the ref id. + if let Some(rid) = ref_post_id { + hasher.update(b"ref:"); + hasher.update(rid); + } + hasher.finalize() +} + pub fn sign_comment( seed: &[u8; 32], author: &NodeId, post_id: &PostId, content: &str, timestamp_ms: u64, + ref_post_id: Option<&PostId>, ) -> Vec { let signing_key = SigningKey::from_bytes(seed); - let mut hasher = blake3::Hasher::new_derive_key(COMMENT_SIGN_CONTEXT); - hasher.update(author); - hasher.update(post_id); - hasher.update(content.as_bytes()); - hasher.update(×tamp_ms.to_le_bytes()); - let digest = hasher.finalize(); + let digest = comment_digest(author, post_id, content, timestamp_ms, ref_post_id); signing_key.sign(digest.as_bytes()).to_bytes().to_vec() } @@ -752,6 +769,7 @@ pub fn verify_comment_signature( content: &str, timestamp_ms: u64, signature: &[u8], + ref_post_id: Option<&PostId>, ) -> bool { let Ok(verifying_key) = VerifyingKey::from_bytes(author) else { return false; @@ -759,12 +777,7 @@ pub fn verify_comment_signature( let Ok(sig) = ed25519_dalek::Signature::from_slice(signature) else { return false; }; - let mut hasher = blake3::Hasher::new_derive_key(COMMENT_SIGN_CONTEXT); - hasher.update(author); - hasher.update(post_id); - hasher.update(content.as_bytes()); - hasher.update(×tamp_ms.to_le_bytes()); - let digest = hasher.finalize(); + let digest = comment_digest(author, post_id, content, timestamp_ms, ref_post_id); verifying_key.verify(digest.as_bytes(), &sig).is_ok() } @@ -999,6 +1012,29 @@ mod tests { } } + #[test] + fn comment_signature_binds_ref_post_id() { + let (seed, nid) = make_keypair(7); + let post_id = [1u8; 32]; + let ref_post = [2u8; 32]; + let content = "preview"; + let ts = 1000u64; + + // Signature including ref_post_id. + let sig_with_ref = sign_comment(&seed, &nid, &post_id, content, ts, Some(&ref_post)); + // Verifies only when the ref is supplied. + assert!(verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, Some(&ref_post))); + // Same signature must NOT verify when the ref is dropped (binding). + assert!(!verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, None)); + // Nor when the ref is swapped. + let other_ref = [3u8; 32]; + assert!(!verify_comment_signature(&nid, &post_id, content, ts, &sig_with_ref, Some(&other_ref))); + + // Plain-comment signature still works (backward compat with v0.6.1). + let sig_plain = sign_comment(&seed, &nid, &post_id, content, ts, None); + assert!(verify_comment_signature(&nid, &post_id, content, ts, &sig_plain, None)); + } + #[test] fn test_sign_verify_manifest() { use crate::types::{AuthorManifest, ManifestEntry}; diff --git a/crates/core/src/node.rs b/crates/core/src/node.rs index 9cbadf2..1d058ae 100644 --- a/crates/core/src/node.rs +++ b/crates/core/src/node.rs @@ -3801,11 +3801,36 @@ impl Node { Ok(counts) } - /// Add a comment to a post (signed with our key). + /// Add a plain inline comment to a post (signed with our posting key). + /// The comment's `content` is the full text; `ref_post_id` is None. pub async fn comment_on_post( &self, post_id: PostId, content: String, + ) -> anyhow::Result { + self.comment_on_post_inner(post_id, content, None).await + } + + /// Add a rich comment: the full body lives in `ref_post_id` (typically a + /// newly-created public post by the commenter that carries attachments + /// or a long body). The inline `preview` text appears in the parent + /// post's header-diff and is what most clients render by default; the + /// expanded view fetches the referenced post. Signature binds the + /// preview + ref_post_id so a peer can't rewrite either independently. + pub async fn comment_on_post_with_ref( + &self, + post_id: PostId, + preview: String, + ref_post_id: PostId, + ) -> anyhow::Result { + self.comment_on_post_inner(post_id, preview, Some(ref_post_id)).await + } + + async fn comment_on_post_inner( + &self, + post_id: PostId, + content: String, + ref_post_id: Option, ) -> anyhow::Result { let our_node_id = self.default_posting_id; let seed = self.default_posting_secret; @@ -3813,7 +3838,14 @@ impl Node { .duration_since(std::time::UNIX_EPOCH)? .as_millis() as u64; - let signature = crate::crypto::sign_comment(&seed, &our_node_id, &post_id, &content, now); + let signature = crate::crypto::sign_comment( + &seed, + &our_node_id, + &post_id, + &content, + now, + ref_post_id.as_ref(), + ); let comment = crate::types::InlineComment { author: our_node_id, @@ -3822,13 +3854,14 @@ impl Node { timestamp_ms: now, signature, deleted_at: None, + ref_post_id, }; let storage = self.storage.get().await; storage.store_comment(&comment)?; drop(storage); - // Propagate via BlobHeaderDiff to downstream + upstream + // Propagate via BlobHeaderDiff to the target post's known holders. { let network = &self.network; let diff = crate::protocol::BlobHeaderDiffPayload { diff --git a/crates/core/src/storage.rs b/crates/core/src/storage.rs index a5605c6..ac0a331 100644 --- a/crates/core/src/storage.rs +++ b/crates/core/src/storage.rs @@ -347,6 +347,7 @@ impl Storage { content TEXT NOT NULL, timestamp_ms INTEGER NOT NULL, signature BLOB NOT NULL, + ref_post_id BLOB, PRIMARY KEY (author, post_id, timestamp_ms) ); CREATE INDEX IF NOT EXISTS idx_comments_post ON comments(post_id); @@ -636,6 +637,17 @@ impl Storage { )?; } + // v0.6.2: add ref_post_id for rich comments (preview-inline, + // full-body-in-referenced-post). NULL for plain comments. + let has_ref_post_id = self.conn.prepare( + "SELECT COUNT(*) FROM pragma_table_info('comments') WHERE name='ref_post_id'" + )?.query_row([], |row| row.get::<_, i64>(0))?; + if has_ref_post_id == 0 { + self.conn.execute_batch( + "ALTER TABLE comments ADD COLUMN ref_post_id BLOB DEFAULT NULL;" + )?; + } + // Add device_role column to peers if missing (Active CDN replication) let has_device_role = self.conn.prepare( "SELECT COUNT(*) FROM pragma_table_info('peers') WHERE name='device_role'" @@ -4541,11 +4553,12 @@ impl Storage { /// deleted_at tombstone, store it so the tombstone propagates. pub fn store_comment(&self, comment: &InlineComment) -> anyhow::Result<()> { self.conn.execute( - "INSERT INTO comments (author, post_id, content, timestamp_ms, signature, deleted_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6) + "INSERT INTO comments (author, post_id, content, timestamp_ms, signature, deleted_at, ref_post_id) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) ON CONFLICT(author, post_id, timestamp_ms) DO UPDATE SET content = CASE WHEN excluded.deleted_at IS NOT NULL THEN content ELSE excluded.content END, - deleted_at = CASE WHEN excluded.deleted_at IS NOT NULL THEN excluded.deleted_at ELSE deleted_at END", + deleted_at = CASE WHEN excluded.deleted_at IS NOT NULL THEN excluded.deleted_at ELSE deleted_at END, + ref_post_id = COALESCE(excluded.ref_post_id, ref_post_id)", params![ comment.author.as_slice(), comment.post_id.as_slice(), @@ -4553,6 +4566,7 @@ impl Storage { comment.timestamp_ms as i64, comment.signature, comment.deleted_at.map(|v| v as i64), + comment.ref_post_id.as_ref().map(|r| r.as_slice()), ], )?; Ok(()) @@ -4579,7 +4593,7 @@ impl Storage { /// Get live (non-tombstoned) comments for a post. Used for UI display. pub fn get_comments(&self, post_id: &PostId) -> anyhow::Result> { let mut stmt = self.conn.prepare( - "SELECT author, post_id, content, timestamp_ms, signature + "SELECT author, post_id, content, timestamp_ms, signature, ref_post_id FROM comments WHERE post_id = ?1 AND deleted_at IS NULL ORDER BY timestamp_ms ASC" )?; let rows = stmt.query_map(params![post_id.as_slice()], |row| { @@ -4588,13 +4602,18 @@ impl Storage { let content: String = row.get(2)?; let ts: i64 = row.get(3)?; let sig: Vec = row.get(4)?; - Ok((author, pid, content, ts, sig)) + let ref_post: Option> = row.get(5)?; + Ok((author, pid, content, ts, sig, ref_post)) })?; let mut result = Vec::new(); for row in rows { - let (author_bytes, pid_bytes, content, ts, sig) = row?; + let (author_bytes, pid_bytes, content, ts, sig, ref_post) = row?; let author = blob_to_nodeid(author_bytes)?; let post_id = blob_to_postid(pid_bytes)?; + let ref_post_id = match ref_post { + Some(b) => Some(blob_to_postid(b)?), + None => None, + }; result.push(InlineComment { author, post_id, @@ -4602,6 +4621,7 @@ impl Storage { timestamp_ms: ts as u64, signature: sig, deleted_at: None, + ref_post_id, }); } Ok(result) @@ -4611,7 +4631,7 @@ impl Storage { /// so tombstones propagate through pull-based sync. pub fn get_comments_with_tombstones(&self, post_id: &PostId) -> anyhow::Result> { let mut stmt = self.conn.prepare( - "SELECT author, post_id, content, timestamp_ms, signature, deleted_at + "SELECT author, post_id, content, timestamp_ms, signature, deleted_at, ref_post_id FROM comments WHERE post_id = ?1 ORDER BY timestamp_ms ASC" )?; let rows = stmt.query_map(params![post_id.as_slice()], |row| { @@ -4621,13 +4641,18 @@ impl Storage { let ts: i64 = row.get(3)?; let sig: Vec = row.get(4)?; let del: Option = row.get(5)?; - Ok((author, pid, content, ts, sig, del)) + let ref_post: Option> = row.get(6)?; + Ok((author, pid, content, ts, sig, del, ref_post)) })?; let mut result = Vec::new(); for row in rows { - let (author_bytes, pid_bytes, content, ts, sig, del) = row?; + let (author_bytes, pid_bytes, content, ts, sig, del, ref_post) = row?; let author = blob_to_nodeid(author_bytes)?; let post_id = blob_to_postid(pid_bytes)?; + let ref_post_id = match ref_post { + Some(b) => Some(blob_to_postid(b)?), + None => None, + }; result.push(InlineComment { author, post_id, @@ -4635,6 +4660,7 @@ impl Storage { timestamp_ms: ts as u64, signature: sig, deleted_at: del.map(|v| v as u64), + ref_post_id, }); } Ok(result) @@ -6045,6 +6071,7 @@ mod tests { timestamp_ms: 1000, signature: vec![0u8; 64], deleted_at: None, + ref_post_id: None, }).unwrap(); s.store_comment(&InlineComment { @@ -6054,6 +6081,7 @@ mod tests { timestamp_ms: 1001, signature: vec![1u8; 64], deleted_at: None, + ref_post_id: None, }).unwrap(); let comments = s.get_comments(&post_id).unwrap(); @@ -6063,6 +6091,33 @@ mod tests { assert_eq!(s.get_comment_count(&post_id).unwrap(), 2); } + #[test] + fn rich_comment_ref_post_id_roundtrip() { + use crate::types::InlineComment; + let s = temp_storage(); + let post_id = make_post_id(1); + let author = make_node_id(5); + let ref_post = make_post_id(42); + + s.store_comment(&InlineComment { + author, + post_id, + content: "(preview of a long body)".to_string(), + timestamp_ms: 2000, + signature: vec![9u8; 64], + deleted_at: None, + ref_post_id: Some(ref_post), + }).unwrap(); + + let live = s.get_comments(&post_id).unwrap(); + assert_eq!(live.len(), 1); + assert_eq!(live[0].ref_post_id, Some(ref_post)); + + let all = s.get_comments_with_tombstones(&post_id).unwrap(); + assert_eq!(all.len(), 1); + assert_eq!(all[0].ref_post_id, Some(ref_post)); + } + #[test] fn comment_policy_crud() { use crate::types::{CommentPermission, CommentPolicy, ModerationMode, ReactPermission}; diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index a511478..863dbbe 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -777,22 +777,36 @@ pub struct Reaction { pub signature: Vec, } -/// An inline comment on a post +/// An inline comment on a post. +/// +/// v0.6.2 adds `ref_post_id`: when present, `content` is a short preview +/// string and the full comment body (long text, attachments, rich formatting) +/// lives in a separate referenced Post authored by the commenter. Clients +/// pull the referenced post lazily when rendering the expanded view. +/// When `ref_post_id` is `None`, `content` is the complete comment text +/// (the v0.6.1 shape). #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct InlineComment { /// Comment author pub author: NodeId, /// Which post this comment is on pub post_id: PostId, - /// Comment text + /// Either the full comment text (short comments) or a short preview of + /// the referenced post (when `ref_post_id` is set). pub content: String, /// When the comment was created (ms) pub timestamp_ms: u64, - /// ed25519 signature over BLAKE3(author || post_id || content || timestamp_ms) + /// ed25519 signature. Binds author/post_id/content/timestamp_ms, plus + /// `ref_post_id` when present. See `crypto::sign_comment`. pub signature: Vec, /// Tombstone timestamp — if set, this comment has been soft-deleted #[serde(default)] pub deleted_at: Option, + /// Optional reference to a full-content Post (long body + attachments). + /// When set, `content` is a preview; readers fetch the referenced post + /// for the expanded view. + #[serde(default)] + pub ref_post_id: Option, } /// Permission level for comments on a post