Phase 2e: rich comments — optional ref_post_id with signed preview
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<PostId>` (#[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.
This commit is contained in:
parent
8b2881d84a
commit
88d5cc9f23
5 changed files with 166 additions and 27 deletions
|
|
@ -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<u8> {
|
||||
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};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue