Phase 2d: profile posts signed by the posting identity
Display metadata (display_name, bio, avatar_cid) is no longer broadcast via
the ProfileUpdate direct push when the user edits their name. It travels as
a signed public post with VisibilityIntent::Profile, authored by the posting
identity, and propagates through the normal neighbor-manifest CDN path.
Core pieces:
- `types::ProfilePostContent` — JSON payload serialized into the post's
content field. Ed25519 signature by the posting secret over length-prefixed
display_name + bio + 32-byte avatar_cid (or zeros) + timestamp.
- `crypto::{sign,verify}_profile` with strict length prefixing to prevent
extension attacks.
- New `profile` module: `build_profile_post`, `verify_profile_post`,
`apply_profile_post_if_applicable`. Last-writer-wins by timestamp.
- `control::receive_post` now verifies Profile-intent posts upfront (same
as Control) so bogus signatures never enter storage, and applies them
after store so the `profiles` row updates atomically with the insert.
Node API:
- `Node::set_profile` rewritten: builds a signed Profile post, stores under
intent=Profile, applies it locally (upserts the profiles row keyed by the
posting identity), then propagates via `update_neighbor_manifests_as`.
Stops calling `network.push_profile` — display changes no longer trigger
a direct wire push.
- `Node::my_profile` / `has_profile` read by `default_posting_id` instead of
`node_id`, matching where the row is written now.
ProfileUpdate (0x50) and push_profile stay for now — they still carry
routing-only data (anchors, recent_peers, preferred_peers) via
`sanitized_for_network_broadcast` and are used by `set_anchors` /
`set_public_visible`. Removing the routing fields would be a broader
cleanup; scoped out of this phase.
Tests: roundtrip verify+store, wrong-author rejection (not stored), and
older-timestamp ignored. 114 / 114 core tests pass.
This commit is contained in:
parent
eabdb7ba4f
commit
8b2881d84a
6 changed files with 339 additions and 51 deletions
|
|
@ -88,24 +88,30 @@ pub fn receive_post(
|
||||||
visibility: &PostVisibility,
|
visibility: &PostVisibility,
|
||||||
intent: Option<&VisibilityIntent>,
|
intent: Option<&VisibilityIntent>,
|
||||||
) -> anyhow::Result<bool> {
|
) -> anyhow::Result<bool> {
|
||||||
if matches!(intent, Some(VisibilityIntent::Control)) {
|
// Verify signed intent posts BEFORE storing. Bogus signed posts must
|
||||||
// Verify the ControlOp signature before storing. A bogus control post
|
// never enter storage and get re-propagated via neighbor-manifest diffs.
|
||||||
// with an invalid signature should never enter storage.
|
match intent {
|
||||||
let op: ControlOp = serde_json::from_str(&post.content).map_err(|e| {
|
Some(VisibilityIntent::Control) => {
|
||||||
anyhow::anyhow!("control post content is not a valid ControlOp: {}", e)
|
let op: ControlOp = serde_json::from_str(&post.content).map_err(|e| {
|
||||||
})?;
|
anyhow::anyhow!("control post content is not a valid ControlOp: {}", e)
|
||||||
match &op {
|
})?;
|
||||||
ControlOp::DeletePost { post_id, timestamp_ms, signature } => {
|
match &op {
|
||||||
if !crypto::verify_control_delete(&post.author, post_id, *timestamp_ms, signature) {
|
ControlOp::DeletePost { post_id, timestamp_ms, signature } => {
|
||||||
anyhow::bail!("invalid control-delete signature");
|
if !crypto::verify_control_delete(&post.author, post_id, *timestamp_ms, signature) {
|
||||||
|
anyhow::bail!("invalid control-delete signature");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
ControlOp::UpdateVisibility { post_id, new_visibility, timestamp_ms, signature } => {
|
||||||
ControlOp::UpdateVisibility { post_id, new_visibility, timestamp_ms, signature } => {
|
if !crypto::verify_control_visibility(&post.author, post_id, new_visibility, *timestamp_ms, signature) {
|
||||||
if !crypto::verify_control_visibility(&post.author, post_id, new_visibility, *timestamp_ms, signature) {
|
anyhow::bail!("invalid control-visibility signature");
|
||||||
anyhow::bail!("invalid control-visibility signature");
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Some(VisibilityIntent::Profile) => {
|
||||||
|
crate::profile::verify_profile_post(post)?;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
let stored = if let Some(intent) = intent {
|
let stored = if let Some(intent) = intent {
|
||||||
|
|
@ -115,6 +121,7 @@ pub fn receive_post(
|
||||||
};
|
};
|
||||||
if stored {
|
if stored {
|
||||||
apply_control_post_if_applicable(s, post, intent)?;
|
apply_control_post_if_applicable(s, post, intent)?;
|
||||||
|
crate::profile::apply_profile_post_if_applicable(s, post, intent)?;
|
||||||
}
|
}
|
||||||
Ok(stored)
|
Ok(stored)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -361,6 +361,56 @@ pub fn verify_control_visibility(
|
||||||
vk.verify_strict(&control_visibility_bytes(post_id, &canon, timestamp_ms), &sig).is_ok()
|
vk.verify_strict(&control_visibility_bytes(post_id, &canon, timestamp_ms), &sig).is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Canonical bytes for a Profile-post signature: length-prefixed display_name
|
||||||
|
/// and bio, 32-byte avatar_cid (or zeros), then timestamp_ms. Length prefixes
|
||||||
|
/// prevent extension/reordering attacks.
|
||||||
|
fn profile_post_bytes(
|
||||||
|
display_name: &str,
|
||||||
|
bio: &str,
|
||||||
|
avatar_cid: &Option<[u8; 32]>,
|
||||||
|
timestamp_ms: u64,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let dn = display_name.as_bytes();
|
||||||
|
let bio_bytes = bio.as_bytes();
|
||||||
|
let mut buf = Vec::with_capacity(5 + 8 + dn.len() + 8 + bio_bytes.len() + 32 + 8);
|
||||||
|
buf.extend_from_slice(b"prof:");
|
||||||
|
buf.extend_from_slice(&(dn.len() as u64).to_le_bytes());
|
||||||
|
buf.extend_from_slice(dn);
|
||||||
|
buf.extend_from_slice(&(bio_bytes.len() as u64).to_le_bytes());
|
||||||
|
buf.extend_from_slice(bio_bytes);
|
||||||
|
let avatar = avatar_cid.unwrap_or([0u8; 32]);
|
||||||
|
buf.extend_from_slice(&avatar);
|
||||||
|
buf.extend_from_slice(×tamp_ms.to_le_bytes());
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sign_profile(
|
||||||
|
seed: &[u8; 32],
|
||||||
|
display_name: &str,
|
||||||
|
bio: &str,
|
||||||
|
avatar_cid: &Option<[u8; 32]>,
|
||||||
|
timestamp_ms: u64,
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let signing_key = SigningKey::from_bytes(seed);
|
||||||
|
let sig = signing_key.sign(&profile_post_bytes(display_name, bio, avatar_cid, timestamp_ms));
|
||||||
|
sig.to_bytes().to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify_profile(
|
||||||
|
author: &NodeId,
|
||||||
|
display_name: &str,
|
||||||
|
bio: &str,
|
||||||
|
avatar_cid: &Option<[u8; 32]>,
|
||||||
|
timestamp_ms: u64,
|
||||||
|
signature: &[u8],
|
||||||
|
) -> bool {
|
||||||
|
if signature.len() != 64 { return false; }
|
||||||
|
let sig_bytes: [u8; 64] = match signature.try_into() { Ok(b) => b, Err(_) => return false };
|
||||||
|
let sig = ed25519_dalek::Signature::from_bytes(&sig_bytes);
|
||||||
|
let Ok(vk) = VerifyingKey::from_bytes(author) else { return false };
|
||||||
|
vk.verify_strict(&profile_post_bytes(display_name, bio, avatar_cid, timestamp_ms), &sig).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
/// Verify an ed25519 delete signature: the author's public key signed the post_id.
|
/// Verify an ed25519 delete signature: the author's public key signed the post_id.
|
||||||
pub fn verify_delete_signature(author: &NodeId, post_id: &PostId, signature: &[u8]) -> bool {
|
pub fn verify_delete_signature(author: &NodeId, post_id: &PostId, signature: &[u8]) -> bool {
|
||||||
if signature.len() != 64 {
|
if signature.len() != 64 {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ pub mod identity;
|
||||||
pub mod import;
|
pub mod import;
|
||||||
pub mod network;
|
pub mod network;
|
||||||
pub mod node;
|
pub mod node;
|
||||||
|
pub mod profile;
|
||||||
pub mod protocol;
|
pub mod protocol;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
pub mod stun;
|
pub mod stun;
|
||||||
|
|
|
||||||
|
|
@ -1221,48 +1221,77 @@ impl Node {
|
||||||
|
|
||||||
// ---- Profiles ----
|
// ---- Profiles ----
|
||||||
|
|
||||||
|
/// Set the default posting identity's profile (display_name, bio,
|
||||||
|
/// preserving any existing avatar). Creates a signed
|
||||||
|
/// `VisibilityIntent::Profile` post authored by the posting identity and
|
||||||
|
/// propagates it via the normal neighbor-manifest CDN path. The locally
|
||||||
|
/// stored profile row is keyed by the posting identity — peers who pull
|
||||||
|
/// the profile post apply the same update on their side.
|
||||||
pub async fn set_profile(&self, display_name: String, bio: String) -> anyhow::Result<PublicProfile> {
|
pub async fn set_profile(&self, display_name: String, bio: String) -> anyhow::Result<PublicProfile> {
|
||||||
let now = std::time::SystemTime::now()
|
let posting_id = self.default_posting_id;
|
||||||
.duration_since(std::time::UNIX_EPOCH)?
|
let posting_secret = self.default_posting_secret;
|
||||||
.as_millis() as u64;
|
|
||||||
|
|
||||||
let recent_peers = self.current_recent_peers().await;
|
// Preserve existing avatar if present.
|
||||||
// Profile is keyed by the network NodeId — that's how peers route to
|
let avatar_cid = {
|
||||||
// us. Broadcasts strip display_name / bio / avatar before going on
|
|
||||||
// the wire (see Network::push_profile). The locally stored profile
|
|
||||||
// retains the name for the user's own UI.
|
|
||||||
let profile = {
|
|
||||||
let storage = self.storage.get().await;
|
let storage = self.storage.get().await;
|
||||||
let existing_anchors = storage.get_peer_anchors(&self.node_id).unwrap_or_default();
|
storage.get_profile(&posting_id).ok().flatten().and_then(|p| p.avatar_cid)
|
||||||
let preferred_peers = storage.list_preferred_peers().unwrap_or_default();
|
|
||||||
|
|
||||||
let (existing_visible, existing_avatar) = storage.get_profile(&self.node_id)
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
.map(|p| (p.public_visible, p.avatar_cid))
|
|
||||||
.unwrap_or((true, None));
|
|
||||||
|
|
||||||
let profile = PublicProfile {
|
|
||||||
node_id: self.node_id,
|
|
||||||
display_name,
|
|
||||||
bio,
|
|
||||||
updated_at: now,
|
|
||||||
anchors: existing_anchors,
|
|
||||||
recent_peers,
|
|
||||||
preferred_peers,
|
|
||||||
public_visible: existing_visible,
|
|
||||||
avatar_cid: existing_avatar,
|
|
||||||
};
|
|
||||||
|
|
||||||
storage.store_profile(&profile)?;
|
|
||||||
profile
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let pushed = self.network.push_profile(&profile).await;
|
let profile_post = crate::profile::build_profile_post(
|
||||||
if pushed > 0 {
|
&posting_id,
|
||||||
info!(pushed, "Pushed profile update to peers");
|
&posting_secret,
|
||||||
|
&display_name,
|
||||||
|
&bio,
|
||||||
|
avatar_cid,
|
||||||
|
);
|
||||||
|
let profile_post_id = crate::content::compute_post_id(&profile_post);
|
||||||
|
let timestamp_ms = profile_post.timestamp_ms;
|
||||||
|
|
||||||
|
// Store post with VisibilityIntent::Profile + apply (upserts profile row).
|
||||||
|
{
|
||||||
|
let storage = self.storage.get().await;
|
||||||
|
storage.store_post_with_intent(
|
||||||
|
&profile_post_id,
|
||||||
|
&profile_post,
|
||||||
|
&PostVisibility::Public,
|
||||||
|
&VisibilityIntent::Profile,
|
||||||
|
)?;
|
||||||
|
crate::profile::apply_profile_post_if_applicable(
|
||||||
|
&*storage,
|
||||||
|
&profile_post,
|
||||||
|
Some(&VisibilityIntent::Profile),
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Propagate via neighbor-manifest header diffs like any other post.
|
||||||
|
self.update_neighbor_manifests_as(
|
||||||
|
&posting_id,
|
||||||
|
&posting_secret,
|
||||||
|
&profile_post_id,
|
||||||
|
timestamp_ms,
|
||||||
|
).await;
|
||||||
|
|
||||||
|
let profile = {
|
||||||
|
let storage = self.storage.get().await;
|
||||||
|
storage.get_profile(&posting_id)?
|
||||||
|
.unwrap_or_else(|| PublicProfile {
|
||||||
|
node_id: posting_id,
|
||||||
|
display_name: display_name.clone(),
|
||||||
|
bio: bio.clone(),
|
||||||
|
updated_at: timestamp_ms,
|
||||||
|
anchors: vec![],
|
||||||
|
recent_peers: vec![],
|
||||||
|
preferred_peers: vec![],
|
||||||
|
public_visible: true,
|
||||||
|
avatar_cid,
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
info!(
|
||||||
|
posting_id = hex::encode(posting_id),
|
||||||
|
profile_post_id = hex::encode(profile_post_id),
|
||||||
|
"Published profile post"
|
||||||
|
);
|
||||||
Ok(profile)
|
Ok(profile)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1315,14 +1344,17 @@ impl Node {
|
||||||
storage.get_profile(node_id)
|
storage.get_profile(node_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// v0.6.2: the user's own display profile lives under the default
|
||||||
|
/// posting identity (published as a signed Profile post), not the
|
||||||
|
/// network NodeId.
|
||||||
pub async fn my_profile(&self) -> anyhow::Result<Option<PublicProfile>> {
|
pub async fn my_profile(&self) -> anyhow::Result<Option<PublicProfile>> {
|
||||||
let storage = self.storage.get().await;
|
let storage = self.storage.get().await;
|
||||||
storage.get_profile(&self.node_id)
|
storage.get_profile(&self.default_posting_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn has_profile(&self) -> anyhow::Result<bool> {
|
pub async fn has_profile(&self) -> anyhow::Result<bool> {
|
||||||
let storage = self.storage.get().await;
|
let storage = self.storage.get().await;
|
||||||
Ok(storage.get_profile(&self.node_id)?.is_some())
|
Ok(storage.get_profile(&self.default_posting_id)?.is_some())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_display_name(&self, node_id: &NodeId) -> anyhow::Result<Option<String>> {
|
pub async fn get_display_name(&self, node_id: &NodeId) -> anyhow::Result<Option<String>> {
|
||||||
|
|
|
||||||
181
crates/core/src/profile.rs
Normal file
181
crates/core/src/profile.rs
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
//! Profile posts: persona display metadata (display_name, bio, avatar_cid)
|
||||||
|
//! carried as a signed public post with `VisibilityIntent::Profile`.
|
||||||
|
//!
|
||||||
|
//! The post's `author` is the posting identity; the signature inside
|
||||||
|
//! `ProfilePostContent` is by that identity's secret. Profile posts propagate
|
||||||
|
//! via the normal CDN path (pull + header-diff). Receivers verify the
|
||||||
|
//! signature, then upsert a row in the `profiles` table keyed by the post's
|
||||||
|
//! author (= posting identity) with the new display fields.
|
||||||
|
//!
|
||||||
|
//! Profile posts are never rendered in feeds — the feed filter excludes
|
||||||
|
//! `VisibilityIntent::Profile` posts (see `Storage::get_feed*`).
|
||||||
|
|
||||||
|
use crate::crypto;
|
||||||
|
use crate::storage::Storage;
|
||||||
|
use crate::types::{NodeId, Post, PostId, PostVisibility, ProfilePostContent, PublicProfile, VisibilityIntent};
|
||||||
|
|
||||||
|
/// Verify a profile-post signature without any other side effects. Used by
|
||||||
|
/// receive paths before storing, so bogus profile posts with invalid
|
||||||
|
/// signatures never enter storage and can't be re-propagated.
|
||||||
|
pub fn verify_profile_post(post: &Post) -> anyhow::Result<ProfilePostContent> {
|
||||||
|
let content: ProfilePostContent = serde_json::from_str(&post.content)
|
||||||
|
.map_err(|e| anyhow::anyhow!("profile post content is not a valid ProfilePostContent: {}", e))?;
|
||||||
|
if !crypto::verify_profile(
|
||||||
|
&post.author,
|
||||||
|
&content.display_name,
|
||||||
|
&content.bio,
|
||||||
|
&content.avatar_cid,
|
||||||
|
content.timestamp_ms,
|
||||||
|
&content.signature,
|
||||||
|
) {
|
||||||
|
anyhow::bail!("invalid profile-post signature");
|
||||||
|
}
|
||||||
|
Ok(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// If the post is a Profile post, verify + apply by upserting the
|
||||||
|
/// `profiles` row keyed by the post's author (= posting identity). Only
|
||||||
|
/// applied if newer than the existing row's `updated_at`.
|
||||||
|
pub fn apply_profile_post_if_applicable(
|
||||||
|
s: &Storage,
|
||||||
|
post: &Post,
|
||||||
|
intent: Option<&VisibilityIntent>,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
if !matches!(intent, Some(VisibilityIntent::Profile)) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let content = verify_profile_post(post)?;
|
||||||
|
|
||||||
|
// Only apply if newer than the stored row (last-writer-wins by timestamp).
|
||||||
|
if let Some(existing) = s.get_profile(&post.author)? {
|
||||||
|
if existing.updated_at >= content.timestamp_ms {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let profile = PublicProfile {
|
||||||
|
node_id: post.author,
|
||||||
|
display_name: content.display_name,
|
||||||
|
bio: content.bio,
|
||||||
|
updated_at: content.timestamp_ms,
|
||||||
|
anchors: vec![],
|
||||||
|
recent_peers: vec![],
|
||||||
|
preferred_peers: vec![],
|
||||||
|
public_visible: true,
|
||||||
|
avatar_cid: content.avatar_cid,
|
||||||
|
};
|
||||||
|
s.store_profile(&profile)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a Profile post signed by the posting identity. Caller is
|
||||||
|
/// responsible for storing and propagating it.
|
||||||
|
pub fn build_profile_post(
|
||||||
|
author: &NodeId,
|
||||||
|
author_secret: &[u8; 32],
|
||||||
|
display_name: &str,
|
||||||
|
bio: &str,
|
||||||
|
avatar_cid: Option<[u8; 32]>,
|
||||||
|
) -> Post {
|
||||||
|
let timestamp_ms = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_millis() as u64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let signature = crypto::sign_profile(author_secret, display_name, bio, &avatar_cid, timestamp_ms);
|
||||||
|
let content = ProfilePostContent {
|
||||||
|
display_name: display_name.to_string(),
|
||||||
|
bio: bio.to_string(),
|
||||||
|
avatar_cid,
|
||||||
|
timestamp_ms,
|
||||||
|
signature,
|
||||||
|
};
|
||||||
|
Post {
|
||||||
|
author: *author,
|
||||||
|
content: serde_json::to_string(&content).unwrap_or_default(),
|
||||||
|
attachments: vec![],
|
||||||
|
timestamp_ms,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Profile-post visibility is always Public on the wire: the signature binds
|
||||||
|
/// the content to the posting identity and no recipient targeting is needed.
|
||||||
|
pub fn profile_post_visibility() -> PostVisibility {
|
||||||
|
PostVisibility::Public
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the `PostId` for a freshly-built profile post.
|
||||||
|
pub fn profile_post_id(post: &Post) -> PostId {
|
||||||
|
crate::content::compute_post_id(post)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::storage::Storage;
|
||||||
|
use ed25519_dalek::SigningKey;
|
||||||
|
|
||||||
|
fn temp_storage() -> Storage {
|
||||||
|
Storage::open(":memory:").unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn make_keypair(seed_byte: u8) -> ([u8; 32], NodeId) {
|
||||||
|
let seed = [seed_byte; 32];
|
||||||
|
let signing_key = SigningKey::from_bytes(&seed);
|
||||||
|
let public = signing_key.verifying_key();
|
||||||
|
(seed, *public.as_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_roundtrip_verifies_and_stores() {
|
||||||
|
let s = temp_storage();
|
||||||
|
let (sec, pub_id) = make_keypair(11);
|
||||||
|
|
||||||
|
let post = build_profile_post(&pub_id, &sec, "Alice", "hello world", None);
|
||||||
|
apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile)).unwrap();
|
||||||
|
|
||||||
|
let stored = s.get_profile(&pub_id).unwrap().expect("profile stored");
|
||||||
|
assert_eq!(stored.display_name, "Alice");
|
||||||
|
assert_eq!(stored.bio, "hello world");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_rejects_wrong_author_signature() {
|
||||||
|
let s = temp_storage();
|
||||||
|
let (_sec_a, pub_a) = make_keypair(1);
|
||||||
|
let (sec_b, _pub_b) = make_keypair(2);
|
||||||
|
|
||||||
|
// Build a post claiming `pub_a` but signing with `sec_b`.
|
||||||
|
let post = build_profile_post(&pub_a, &sec_b, "Impostor", "", None);
|
||||||
|
let res = apply_profile_post_if_applicable(&s, &post, Some(&VisibilityIntent::Profile));
|
||||||
|
assert!(res.is_err());
|
||||||
|
assert!(s.get_profile(&pub_a).unwrap().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_ignores_older_timestamp() {
|
||||||
|
let s = temp_storage();
|
||||||
|
let (sec, pub_id) = make_keypair(3);
|
||||||
|
|
||||||
|
// Seed with a newer profile.
|
||||||
|
let mut newer = build_profile_post(&pub_id, &sec, "NewName", "", None);
|
||||||
|
// Hack the timestamp to make it clearly newer.
|
||||||
|
let mut content: ProfilePostContent = serde_json::from_str(&newer.content).unwrap();
|
||||||
|
content.timestamp_ms = 10_000;
|
||||||
|
content.signature = crypto::sign_profile(&sec, &content.display_name, &content.bio, &content.avatar_cid, content.timestamp_ms);
|
||||||
|
newer.content = serde_json::to_string(&content).unwrap();
|
||||||
|
newer.timestamp_ms = 10_000;
|
||||||
|
apply_profile_post_if_applicable(&s, &newer, Some(&VisibilityIntent::Profile)).unwrap();
|
||||||
|
|
||||||
|
// Apply an older profile — should be ignored.
|
||||||
|
let mut older = build_profile_post(&pub_id, &sec, "OldName", "", None);
|
||||||
|
let mut content_o: ProfilePostContent = serde_json::from_str(&older.content).unwrap();
|
||||||
|
content_o.timestamp_ms = 5_000;
|
||||||
|
content_o.signature = crypto::sign_profile(&sec, &content_o.display_name, &content_o.bio, &content_o.avatar_cid, content_o.timestamp_ms);
|
||||||
|
older.content = serde_json::to_string(&content_o).unwrap();
|
||||||
|
older.timestamp_ms = 5_000;
|
||||||
|
apply_profile_post_if_applicable(&s, &older, Some(&VisibilityIntent::Profile)).unwrap();
|
||||||
|
|
||||||
|
let stored = s.get_profile(&pub_id).unwrap().unwrap();
|
||||||
|
assert_eq!(stored.display_name, "NewName");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -239,6 +239,23 @@ pub enum VisibilityIntent {
|
||||||
Profile,
|
Profile,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Content payload of a `VisibilityIntent::Profile` post — persona display
|
||||||
|
/// metadata (display_name, bio, avatar_cid) signed by the posting identity.
|
||||||
|
/// The post's `author` IS the posting identity; `signature` is an ed25519
|
||||||
|
/// signature by that identity's secret over the fields (see `crypto::sign_profile`).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ProfilePostContent {
|
||||||
|
pub display_name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub bio: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub avatar_cid: Option<[u8; 32]>,
|
||||||
|
pub timestamp_ms: u64,
|
||||||
|
/// 64-byte ed25519 signature. See `crypto::sign_profile` for the byte
|
||||||
|
/// layout signed by the posting identity.
|
||||||
|
pub signature: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Content payload of a `VisibilityIntent::Control` post, serialized as JSON
|
/// Content payload of a `VisibilityIntent::Control` post, serialized as JSON
|
||||||
/// into the post's content field.
|
/// into the post's content field.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue