Phase 4 (0.6.3-beta): posting-key / network-key split (plumbing)
Decouple the signing identity from the network identity. This phase
ships the plumbing only — every device still has exactly one posting
identity, copied from the network key on first 0.6.3 launch so all
existing signed content keeps verifying. Phase 5 builds the
multi-persona UX on top.
Types:
- New PostingIdentity struct: { node_id, secret_seed, display_name,
created_at }
Storage:
- New posting_identities(node_id, secret_seed, display_name,
created_at) table
- Methods: upsert / get / list / delete posting identities;
get/set default posting id (stored in settings)
- seed_posting_identity_from_network: idempotent migration inserts
the network key as the single posting identity and sets it default
on first 0.6.3 launch
Node:
- default_posting_id + default_posting_secret fields populated on
startup via the migration
- All content signing / encryption / key wrapping now uses
default_posting_secret; the old Node.secret_seed field is gone
(iroh holds the network secret internally)
- author field on all locally-created content is now
default_posting_id (equal to node_id for upgraders until Phase 5
introduces separate personas)
- Auto-follow-self covers both network_id and default_posting_id
(same in 0.6.3, may diverge in 0.6.4+)
Export/import:
- Bundle now includes posting_identities.json in
IdentityOnly / PostsWithIdentity / Everything scopes
- restore_posting_identities(zip, storage) reads and upserts on
import
Smoke-tested:
- Fresh 0.6.3 install: posting_identities seeded from network key;
default set; new post's author = default_posting_id = network_id
- Two-node pull sync: B pulls A's post, signature verifies across
the wire
- 111 core tests pass
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
975e7b9bfe
commit
ce4b989b17
5 changed files with 287 additions and 50 deletions
|
|
@ -138,13 +138,29 @@ pub async fn export_data(
|
||||||
zip.write_all(manifest_json.as_bytes())?;
|
zip.write_all(manifest_json.as_bytes())?;
|
||||||
current_size += manifest_json.len() as u64;
|
current_size += manifest_json.len() as u64;
|
||||||
|
|
||||||
// Identity key
|
// Identity key (network key)
|
||||||
if let Some(ref key) = identity_key {
|
if let Some(ref key) = identity_key {
|
||||||
zip.start_file("itsgoin-export/identity.key", options)?;
|
zip.start_file("itsgoin-export/identity.key", options)?;
|
||||||
zip.write_all(key)?;
|
zip.write_all(key)?;
|
||||||
current_size += key.len() as u64;
|
current_size += key.len() as u64;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Posting identities (0.6.3+) — list of all posting keys held by this
|
||||||
|
// device. For upgraders the only entry is a copy of the network key;
|
||||||
|
// for multi-persona users (0.6.4+) this is the source of truth.
|
||||||
|
if scope != ExportScope::PostsOnly {
|
||||||
|
let posting_identities = {
|
||||||
|
let s = storage.get().await;
|
||||||
|
s.list_posting_identities().unwrap_or_default()
|
||||||
|
};
|
||||||
|
if !posting_identities.is_empty() {
|
||||||
|
let pi_json = serde_json::to_string_pretty(&posting_identities)?;
|
||||||
|
zip.start_file("itsgoin-export/posting_identities.json", options)?;
|
||||||
|
zip.write_all(pi_json.as_bytes())?;
|
||||||
|
current_size += pi_json.len() as u64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Posts
|
// Posts
|
||||||
if !posts.is_empty() {
|
if !posts.is_empty() {
|
||||||
let posts_json = serde_json::to_string_pretty(&posts)?;
|
let posts_json = serde_json::to_string_pretty(&posts)?;
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,40 @@ use crate::blob::BlobStore;
|
||||||
use crate::content::compute_post_id;
|
use crate::content::compute_post_id;
|
||||||
use crate::export::{ExportManifest, ExportedPost};
|
use crate::export::{ExportManifest, ExportedPost};
|
||||||
use crate::storage::StoragePool;
|
use crate::storage::StoragePool;
|
||||||
use crate::types::{Attachment, NodeId, Post, PostVisibility};
|
use crate::types::{Attachment, NodeId, Post, PostVisibility, PostingIdentity};
|
||||||
|
|
||||||
|
/// Extract posting_identities.json from an export ZIP and upsert each entry
|
||||||
|
/// into storage. Called during import so multi-persona users restore all
|
||||||
|
/// their posting keys. Idempotent — INSERT OR IGNORE on conflict. No-op if
|
||||||
|
/// the file is missing (pre-0.6.3 bundle).
|
||||||
|
pub async fn restore_posting_identities(
|
||||||
|
zip_path: &Path,
|
||||||
|
storage: &StoragePool,
|
||||||
|
) -> anyhow::Result<usize> {
|
||||||
|
let zip_path = zip_path.to_path_buf();
|
||||||
|
let identities: Vec<PostingIdentity> = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<PostingIdentity>> {
|
||||||
|
let file = std::fs::File::open(&zip_path)?;
|
||||||
|
let mut archive = zip::ZipArchive::new(file)?;
|
||||||
|
let buf = {
|
||||||
|
let mut entry = match archive.by_name("itsgoin-export/posting_identities.json") {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => return Ok(Vec::new()),
|
||||||
|
};
|
||||||
|
let mut s = String::new();
|
||||||
|
entry.read_to_string(&mut s)?;
|
||||||
|
s
|
||||||
|
};
|
||||||
|
Ok(serde_json::from_str(&buf).unwrap_or_default())
|
||||||
|
}).await??;
|
||||||
|
|
||||||
|
let s = storage.get().await;
|
||||||
|
let mut restored = 0usize;
|
||||||
|
for id in &identities {
|
||||||
|
s.upsert_posting_identity(id)?;
|
||||||
|
restored += 1;
|
||||||
|
}
|
||||||
|
Ok(restored)
|
||||||
|
}
|
||||||
|
|
||||||
/// What to do with the imported data.
|
/// What to do with the imported data.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,17 @@ pub struct Node {
|
||||||
pub data_dir: PathBuf,
|
pub data_dir: PathBuf,
|
||||||
pub storage: Arc<StoragePool>,
|
pub storage: Arc<StoragePool>,
|
||||||
pub network: Arc<Network>,
|
pub network: Arc<Network>,
|
||||||
|
/// Network identity — used for QUIC connections / routing. Stays hidden
|
||||||
|
/// from peers after the posting-key split ships end-to-end.
|
||||||
pub node_id: NodeId,
|
pub node_id: NodeId,
|
||||||
pub blob_store: Arc<BlobStore>,
|
pub blob_store: Arc<BlobStore>,
|
||||||
secret_seed: [u8; 32],
|
/// Active default posting identity's public NodeId. Used as `author` on
|
||||||
|
/// content signed by this device.
|
||||||
|
pub default_posting_id: NodeId,
|
||||||
|
/// Active default posting identity's secret seed. Used to sign content
|
||||||
|
/// (posts, manifests, reactions, comments, deletes) and to wrap/unwrap
|
||||||
|
/// encryption keys.
|
||||||
|
default_posting_secret: [u8; 32],
|
||||||
bootstrap_anchors: tokio::sync::Mutex<Vec<(NodeId, iroh::EndpointAddr)>>,
|
bootstrap_anchors: tokio::sync::Mutex<Vec<(NodeId, iroh::EndpointAddr)>>,
|
||||||
/// True if an anchor reported another instance of this identity is already active
|
/// True if an anchor reported another instance of this identity is already active
|
||||||
pub duplicate_detected: Arc<AtomicBool>,
|
pub duplicate_detected: Arc<AtomicBool>,
|
||||||
|
|
@ -109,10 +117,27 @@ impl Node {
|
||||||
);
|
);
|
||||||
let node_id = network.node_id_bytes();
|
let node_id = network.node_id_bytes();
|
||||||
|
|
||||||
|
// Seed the posting-identity table from the network key on first launch
|
||||||
|
// (0.6.3 migration). For upgraders, the default posting identity has
|
||||||
|
// the same key bytes as the network identity so all existing signed
|
||||||
|
// content keeps verifying.
|
||||||
|
let (default_posting_id, default_posting_secret) = {
|
||||||
|
let s = storage.get().await;
|
||||||
|
s.seed_posting_identity_from_network(&node_id, &secret_seed)?;
|
||||||
|
let default_id = s.get_default_posting_id()?.unwrap_or(node_id);
|
||||||
|
let default_seed = s.get_posting_identity(&default_id)?
|
||||||
|
.map(|pi| pi.secret_seed)
|
||||||
|
.unwrap_or(secret_seed);
|
||||||
|
(default_id, default_seed)
|
||||||
|
};
|
||||||
|
|
||||||
// Auto-follow ourselves so our own posts show in the feed
|
// Auto-follow ourselves so our own posts show in the feed
|
||||||
{
|
{
|
||||||
let s = storage.get().await;
|
let s = storage.get().await;
|
||||||
s.add_follow(&node_id)?;
|
s.add_follow(&node_id)?;
|
||||||
|
if default_posting_id != node_id {
|
||||||
|
s.add_follow(&default_posting_id)?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the node (fast path — no network I/O beyond endpoint creation)
|
// Build the node (fast path — no network I/O beyond endpoint creation)
|
||||||
|
|
@ -136,7 +161,8 @@ impl Node {
|
||||||
network: Arc::clone(&network),
|
network: Arc::clone(&network),
|
||||||
node_id,
|
node_id,
|
||||||
blob_store,
|
blob_store,
|
||||||
secret_seed,
|
default_posting_id,
|
||||||
|
default_posting_secret,
|
||||||
bootstrap_anchors: tokio::sync::Mutex::new(Vec::new()),
|
bootstrap_anchors: tokio::sync::Mutex::new(Vec::new()),
|
||||||
duplicate_detected: Arc::new(AtomicBool::new(false)),
|
duplicate_detected: Arc::new(AtomicBool::new(false)),
|
||||||
profile,
|
profile,
|
||||||
|
|
@ -492,7 +518,7 @@ impl Node {
|
||||||
|
|
||||||
/// Get the secret seed bytes (for crypto operations by consumers like Tauri)
|
/// Get the secret seed bytes (for crypto operations by consumers like Tauri)
|
||||||
pub fn secret_seed_bytes(&self) -> [u8; 32] {
|
pub fn secret_seed_bytes(&self) -> [u8; 32] {
|
||||||
self.secret_seed
|
self.default_posting_secret
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- CDN Replication Budget ---
|
// --- CDN Replication Budget ---
|
||||||
|
|
@ -554,7 +580,7 @@ impl Node {
|
||||||
// ---- Identity export/import ----
|
// ---- Identity export/import ----
|
||||||
|
|
||||||
pub fn secret_seed(&self) -> [u8; 32] {
|
pub fn secret_seed(&self) -> [u8; 32] {
|
||||||
self.secret_seed
|
self.default_posting_secret
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn export_identity_hex(&self) -> anyhow::Result<String> {
|
pub fn export_identity_hex(&self) -> anyhow::Result<String> {
|
||||||
|
|
@ -700,7 +726,7 @@ impl Node {
|
||||||
EncryptionMode::Public => (content, PostVisibility::Public),
|
EncryptionMode::Public => (content, PostVisibility::Public),
|
||||||
EncryptionMode::Recipient { cek, recipients } => {
|
EncryptionMode::Recipient { cek, recipients } => {
|
||||||
let (encrypted, wrapped_keys) =
|
let (encrypted, wrapped_keys) =
|
||||||
crypto::encrypt_post_with_cek(&content, &cek, &self.secret_seed, &self.node_id, &recipients)?;
|
crypto::encrypt_post_with_cek(&content, &cek, &self.default_posting_secret, &self.node_id, &recipients)?;
|
||||||
(
|
(
|
||||||
encrypted,
|
encrypted,
|
||||||
PostVisibility::Encrypted {
|
PostVisibility::Encrypted {
|
||||||
|
|
@ -723,7 +749,7 @@ impl Node {
|
||||||
};
|
};
|
||||||
|
|
||||||
let post = Post {
|
let post = Post {
|
||||||
author: self.node_id,
|
author: self.default_posting_id,
|
||||||
content: final_content,
|
content: final_content,
|
||||||
attachments,
|
attachments,
|
||||||
timestamp_ms: now,
|
timestamp_ms: now,
|
||||||
|
|
@ -769,7 +795,7 @@ impl Node {
|
||||||
|
|
||||||
let blob_header = crate::types::BlobHeader {
|
let blob_header = crate::types::BlobHeader {
|
||||||
post_id,
|
post_id,
|
||||||
author: self.node_id,
|
author: self.default_posting_id,
|
||||||
reactions: vec![],
|
reactions: vec![],
|
||||||
comments: vec![],
|
comments: vec![],
|
||||||
policy: Default::default(),
|
policy: Default::default(),
|
||||||
|
|
@ -792,7 +818,7 @@ impl Node {
|
||||||
|
|
||||||
let manifest = crate::types::AuthorManifest {
|
let manifest = crate::types::AuthorManifest {
|
||||||
post_id,
|
post_id,
|
||||||
author: self.node_id,
|
author: self.default_posting_id,
|
||||||
author_addresses: self.network.our_addresses(),
|
author_addresses: self.network.our_addresses(),
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
|
|
@ -800,7 +826,7 @@ impl Node {
|
||||||
following_posts: vec![],
|
following_posts: vec![],
|
||||||
signature: vec![],
|
signature: vec![],
|
||||||
};
|
};
|
||||||
let sig = crypto::sign_manifest(&self.secret_seed, &manifest);
|
let sig = crypto::sign_manifest(&self.default_posting_secret, &manifest);
|
||||||
let mut manifest = manifest;
|
let mut manifest = manifest;
|
||||||
manifest.signature = sig;
|
manifest.signature = sig;
|
||||||
|
|
||||||
|
|
@ -883,7 +909,7 @@ impl Node {
|
||||||
}
|
}
|
||||||
manifest.following_posts.push(new_entry.clone());
|
manifest.following_posts.push(new_entry.clone());
|
||||||
manifest.updated_at = new_timestamp_ms;
|
manifest.updated_at = new_timestamp_ms;
|
||||||
manifest.signature = crypto::sign_manifest(&self.secret_seed, &manifest);
|
manifest.signature = crypto::sign_manifest(&self.default_posting_secret, &manifest);
|
||||||
|
|
||||||
let updated_json = match serde_json::to_string(&manifest) {
|
let updated_json = match serde_json::to_string(&manifest) {
|
||||||
Ok(j) => j,
|
Ok(j) => j,
|
||||||
|
|
@ -970,7 +996,7 @@ impl Node {
|
||||||
PostVisibility::Encrypted { recipients } => {
|
PostVisibility::Encrypted { recipients } => {
|
||||||
crypto::decrypt_post(
|
crypto::decrypt_post(
|
||||||
&post.content,
|
&post.content,
|
||||||
&self.secret_seed,
|
&self.default_posting_secret,
|
||||||
&self.node_id,
|
&self.node_id,
|
||||||
&post.author,
|
&post.author,
|
||||||
recipients,
|
recipients,
|
||||||
|
|
@ -1181,7 +1207,7 @@ impl Node {
|
||||||
PostVisibility::Public => Ok(Some(data)),
|
PostVisibility::Public => Ok(Some(data)),
|
||||||
PostVisibility::Encrypted { recipients } => {
|
PostVisibility::Encrypted { recipients } => {
|
||||||
let cek = crypto::unwrap_cek_for_recipient(
|
let cek = crypto::unwrap_cek_for_recipient(
|
||||||
&self.secret_seed,
|
&self.default_posting_secret,
|
||||||
&self.node_id,
|
&self.node_id,
|
||||||
&post.author,
|
&post.author,
|
||||||
recipients,
|
recipients,
|
||||||
|
|
@ -1500,7 +1526,7 @@ impl Node {
|
||||||
if let Ok(Some(gk)) = storage.get_group_key_by_circle(&circle_name) {
|
if let Ok(Some(gk)) = storage.get_group_key_by_circle(&circle_name) {
|
||||||
if gk.admin == self.node_id {
|
if gk.admin == self.node_id {
|
||||||
if let Ok(Some(seed)) = storage.get_group_seed(&gk.group_id, gk.epoch) {
|
if let Ok(Some(seed)) = storage.get_group_seed(&gk.group_id, gk.epoch) {
|
||||||
match crypto::wrap_group_key_for_member(&self.secret_seed, &node_id, &seed) {
|
match crypto::wrap_group_key_for_member(&self.default_posting_secret, &node_id, &seed) {
|
||||||
Ok(wrapped) => {
|
Ok(wrapped) => {
|
||||||
let mk = crate::types::GroupMemberKey {
|
let mk = crate::types::GroupMemberKey {
|
||||||
member: node_id,
|
member: node_id,
|
||||||
|
|
@ -1572,7 +1598,7 @@ impl Node {
|
||||||
storage.store_group_seed(&group_id, 1, &seed)?;
|
storage.store_group_seed(&group_id, 1, &seed)?;
|
||||||
|
|
||||||
// Wrap for ourselves
|
// Wrap for ourselves
|
||||||
let self_wrapped = crypto::wrap_group_key_for_member(&self.secret_seed, &self.node_id, &seed)?;
|
let self_wrapped = crypto::wrap_group_key_for_member(&self.default_posting_secret, &self.node_id, &seed)?;
|
||||||
let self_mk = crate::types::GroupMemberKey {
|
let self_mk = crate::types::GroupMemberKey {
|
||||||
member: self.node_id,
|
member: self.node_id,
|
||||||
epoch: 1,
|
epoch: 1,
|
||||||
|
|
@ -1588,7 +1614,7 @@ impl Node {
|
||||||
if *member == self.node_id {
|
if *member == self.node_id {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
match crypto::wrap_group_key_for_member(&self.secret_seed, member, &seed) {
|
match crypto::wrap_group_key_for_member(&self.default_posting_secret, member, &seed) {
|
||||||
Ok(wrapped) => {
|
Ok(wrapped) => {
|
||||||
let mk = crate::types::GroupMemberKey {
|
let mk = crate::types::GroupMemberKey {
|
||||||
member: *member,
|
member: *member,
|
||||||
|
|
@ -1636,7 +1662,7 @@ impl Node {
|
||||||
if !all_members.contains(&self.node_id) {
|
if !all_members.contains(&self.node_id) {
|
||||||
all_members.push(self.node_id);
|
all_members.push(self.node_id);
|
||||||
}
|
}
|
||||||
match crypto::rotate_group_key(&self.secret_seed, gk.epoch, &all_members) {
|
match crypto::rotate_group_key(&self.default_posting_secret, gk.epoch, &all_members) {
|
||||||
Ok((new_seed, new_pubkey, new_epoch, member_keys)) => {
|
Ok((new_seed, new_pubkey, new_epoch, member_keys)) => {
|
||||||
Some((gk.group_id, new_seed, new_pubkey, new_epoch, member_keys, circle_name.to_string()))
|
Some((gk.group_id, new_seed, new_pubkey, new_epoch, member_keys, circle_name.to_string()))
|
||||||
}
|
}
|
||||||
|
|
@ -1698,7 +1724,7 @@ impl Node {
|
||||||
.as_millis() as u64;
|
.as_millis() as u64;
|
||||||
|
|
||||||
let cp = crate::types::CircleProfile {
|
let cp = crate::types::CircleProfile {
|
||||||
author: self.node_id,
|
author: self.default_posting_id,
|
||||||
circle_name: circle_name.clone(),
|
circle_name: circle_name.clone(),
|
||||||
display_name,
|
display_name,
|
||||||
bio,
|
bio,
|
||||||
|
|
@ -1746,7 +1772,7 @@ impl Node {
|
||||||
|
|
||||||
// Push to all connected mesh peers
|
// Push to all connected mesh peers
|
||||||
let payload = crate::protocol::CircleProfileUpdatePayload {
|
let payload = crate::protocol::CircleProfileUpdatePayload {
|
||||||
author: self.node_id,
|
author: self.default_posting_id,
|
||||||
circle_name,
|
circle_name,
|
||||||
group_id,
|
group_id,
|
||||||
epoch,
|
epoch,
|
||||||
|
|
@ -1781,7 +1807,7 @@ impl Node {
|
||||||
storage.delete_circle_profile(&self.node_id, &circle_name)?;
|
storage.delete_circle_profile(&self.node_id, &circle_name)?;
|
||||||
|
|
||||||
crate::protocol::CircleProfileUpdatePayload {
|
crate::protocol::CircleProfileUpdatePayload {
|
||||||
author: self.node_id,
|
author: self.default_posting_id,
|
||||||
circle_name,
|
circle_name,
|
||||||
group_id: gk.group_id,
|
group_id: gk.group_id,
|
||||||
epoch: gk.epoch,
|
epoch: gk.epoch,
|
||||||
|
|
@ -1982,11 +2008,11 @@ impl Node {
|
||||||
let now = std::time::SystemTime::now()
|
let now = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)?
|
.duration_since(std::time::UNIX_EPOCH)?
|
||||||
.as_millis() as u64;
|
.as_millis() as u64;
|
||||||
let signature = crypto::sign_delete(&self.secret_seed, post_id);
|
let signature = crypto::sign_delete(&self.default_posting_secret, post_id);
|
||||||
|
|
||||||
let record = DeleteRecord {
|
let record = DeleteRecord {
|
||||||
post_id: *post_id,
|
post_id: *post_id,
|
||||||
author: self.node_id,
|
author: self.default_posting_id,
|
||||||
timestamp_ms: now,
|
timestamp_ms: now,
|
||||||
signature,
|
signature,
|
||||||
};
|
};
|
||||||
|
|
@ -2070,7 +2096,7 @@ impl Node {
|
||||||
match mode {
|
match mode {
|
||||||
RevocationMode::SyncAccessList => {
|
RevocationMode::SyncAccessList => {
|
||||||
let new_wrapped = crypto::rewrap_visibility(
|
let new_wrapped = crypto::rewrap_visibility(
|
||||||
&self.secret_seed,
|
&self.default_posting_secret,
|
||||||
&self.node_id,
|
&self.node_id,
|
||||||
existing_recipients,
|
existing_recipients,
|
||||||
&new_recipient_ids,
|
&new_recipient_ids,
|
||||||
|
|
@ -2085,7 +2111,7 @@ impl Node {
|
||||||
|
|
||||||
let update = VisibilityUpdate {
|
let update = VisibilityUpdate {
|
||||||
post_id: *post_id,
|
post_id: *post_id,
|
||||||
author: self.node_id,
|
author: self.default_posting_id,
|
||||||
visibility: new_vis,
|
visibility: new_vis,
|
||||||
};
|
};
|
||||||
let pushed = self.network.push_visibility(&update).await;
|
let pushed = self.network.push_visibility(&update).await;
|
||||||
|
|
@ -2095,7 +2121,7 @@ impl Node {
|
||||||
RevocationMode::ReEncrypt => {
|
RevocationMode::ReEncrypt => {
|
||||||
let (new_content, new_wrapped) = crypto::re_encrypt_post(
|
let (new_content, new_wrapped) = crypto::re_encrypt_post(
|
||||||
&post.content,
|
&post.content,
|
||||||
&self.secret_seed,
|
&self.default_posting_secret,
|
||||||
&self.node_id,
|
&self.node_id,
|
||||||
existing_recipients,
|
existing_recipients,
|
||||||
&new_recipient_ids,
|
&new_recipient_ids,
|
||||||
|
|
@ -2105,7 +2131,7 @@ impl Node {
|
||||||
};
|
};
|
||||||
|
|
||||||
let new_post = Post {
|
let new_post = Post {
|
||||||
author: self.node_id,
|
author: self.default_posting_id,
|
||||||
content: new_content,
|
content: new_content,
|
||||||
attachments: post.attachments.clone(),
|
attachments: post.attachments.clone(),
|
||||||
timestamp_ms: post.timestamp_ms,
|
timestamp_ms: post.timestamp_ms,
|
||||||
|
|
@ -3541,7 +3567,7 @@ impl Node {
|
||||||
emoji: String,
|
emoji: String,
|
||||||
private: bool,
|
private: bool,
|
||||||
) -> anyhow::Result<crate::types::Reaction> {
|
) -> anyhow::Result<crate::types::Reaction> {
|
||||||
let our_node_id = self.node_id;
|
let our_node_id = self.default_posting_id;
|
||||||
let now = std::time::SystemTime::now()
|
let now = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)?
|
.duration_since(std::time::UNIX_EPOCH)?
|
||||||
.as_millis() as u64;
|
.as_millis() as u64;
|
||||||
|
|
@ -3552,7 +3578,7 @@ impl Node {
|
||||||
let post = storage.get_post(&post_id)?
|
let post = storage.get_post(&post_id)?
|
||||||
.ok_or_else(|| anyhow::anyhow!("post not found"))?;
|
.ok_or_else(|| anyhow::anyhow!("post not found"))?;
|
||||||
drop(storage);
|
drop(storage);
|
||||||
let seed = self.secret_seed;
|
let seed = self.default_posting_secret;
|
||||||
let payload_json = serde_json::json!({
|
let payload_json = serde_json::json!({
|
||||||
"emoji": emoji,
|
"emoji": emoji,
|
||||||
"reactor": hex::encode(our_node_id),
|
"reactor": hex::encode(our_node_id),
|
||||||
|
|
@ -3563,7 +3589,7 @@ impl Node {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let signature = crate::crypto::sign_reaction(&self.secret_seed, &our_node_id, &post_id, &emoji, now);
|
let signature = crate::crypto::sign_reaction(&self.default_posting_secret, &our_node_id, &post_id, &emoji, now);
|
||||||
let reaction = crate::types::Reaction {
|
let reaction = crate::types::Reaction {
|
||||||
reactor: our_node_id,
|
reactor: our_node_id,
|
||||||
emoji: emoji.clone(),
|
emoji: emoji.clone(),
|
||||||
|
|
@ -3598,7 +3624,7 @@ impl Node {
|
||||||
|
|
||||||
/// Remove a reaction from a post.
|
/// Remove a reaction from a post.
|
||||||
pub async fn remove_reaction(&self, post_id: PostId, emoji: String) -> anyhow::Result<()> {
|
pub async fn remove_reaction(&self, post_id: PostId, emoji: String) -> anyhow::Result<()> {
|
||||||
let our_node_id = self.node_id;
|
let our_node_id = self.default_posting_id;
|
||||||
let storage = self.storage.get().await;
|
let storage = self.storage.get().await;
|
||||||
storage.remove_reaction(&our_node_id, &post_id, &emoji)?;
|
storage.remove_reaction(&our_node_id, &post_id, &emoji)?;
|
||||||
drop(storage);
|
drop(storage);
|
||||||
|
|
@ -3632,12 +3658,12 @@ impl Node {
|
||||||
let post_info = storage.get_post(&post_id)?;
|
let post_info = storage.get_post(&post_id)?;
|
||||||
drop(storage);
|
drop(storage);
|
||||||
|
|
||||||
let our_node_id = self.node_id;
|
let our_node_id = self.default_posting_id;
|
||||||
|
|
||||||
// If we're the author, decrypt private reactions
|
// If we're the author, decrypt private reactions
|
||||||
if let Some(post) = post_info {
|
if let Some(post) = post_info {
|
||||||
if post.author == our_node_id {
|
if post.author == our_node_id {
|
||||||
let seed = self.secret_seed;
|
let seed = self.default_posting_secret;
|
||||||
return Ok(reactions.into_iter().map(|mut r| {
|
return Ok(reactions.into_iter().map(|mut r| {
|
||||||
if let Some(ref enc) = r.encrypted_payload {
|
if let Some(ref enc) = r.encrypted_payload {
|
||||||
if let Ok(decrypted) = crate::crypto::decrypt_private_reaction(&seed, &r.reactor, enc) {
|
if let Ok(decrypted) = crate::crypto::decrypt_private_reaction(&seed, &r.reactor, enc) {
|
||||||
|
|
@ -3654,7 +3680,7 @@ impl Node {
|
||||||
|
|
||||||
/// Get reaction counts grouped by emoji for a post.
|
/// Get reaction counts grouped by emoji for a post.
|
||||||
pub async fn get_reaction_counts(&self, post_id: PostId) -> anyhow::Result<Vec<(String, u64, bool)>> {
|
pub async fn get_reaction_counts(&self, post_id: PostId) -> anyhow::Result<Vec<(String, u64, bool)>> {
|
||||||
let our_node_id = self.node_id;
|
let our_node_id = self.default_posting_id;
|
||||||
let storage = self.storage.get().await;
|
let storage = self.storage.get().await;
|
||||||
let counts = storage.get_reaction_counts(&post_id, &our_node_id)?;
|
let counts = storage.get_reaction_counts(&post_id, &our_node_id)?;
|
||||||
Ok(counts)
|
Ok(counts)
|
||||||
|
|
@ -3666,8 +3692,8 @@ impl Node {
|
||||||
post_id: PostId,
|
post_id: PostId,
|
||||||
content: String,
|
content: String,
|
||||||
) -> anyhow::Result<crate::types::InlineComment> {
|
) -> anyhow::Result<crate::types::InlineComment> {
|
||||||
let our_node_id = self.node_id;
|
let our_node_id = self.default_posting_id;
|
||||||
let seed = self.secret_seed;
|
let seed = self.default_posting_secret;
|
||||||
let now = std::time::SystemTime::now()
|
let now = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)?
|
.duration_since(std::time::UNIX_EPOCH)?
|
||||||
.as_millis() as u64;
|
.as_millis() as u64;
|
||||||
|
|
@ -3709,7 +3735,7 @@ impl Node {
|
||||||
timestamp_ms: u64,
|
timestamp_ms: u64,
|
||||||
new_content: String,
|
new_content: String,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let our_node_id = self.node_id;
|
let our_node_id = self.default_posting_id;
|
||||||
let now = std::time::SystemTime::now()
|
let now = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)?
|
.duration_since(std::time::UNIX_EPOCH)?
|
||||||
.as_millis() as u64;
|
.as_millis() as u64;
|
||||||
|
|
@ -3743,7 +3769,7 @@ impl Node {
|
||||||
post_id: PostId,
|
post_id: PostId,
|
||||||
timestamp_ms: u64,
|
timestamp_ms: u64,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let our_node_id = self.node_id;
|
let our_node_id = self.default_posting_id;
|
||||||
let now = std::time::SystemTime::now()
|
let now = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)?
|
.duration_since(std::time::UNIX_EPOCH)?
|
||||||
.as_millis() as u64;
|
.as_millis() as u64;
|
||||||
|
|
@ -3790,7 +3816,7 @@ impl Node {
|
||||||
// Propagate policy change
|
// Propagate policy change
|
||||||
{
|
{
|
||||||
let network = &self.network;
|
let network = &self.network;
|
||||||
let our_node_id = self.node_id;
|
let our_node_id = self.default_posting_id;
|
||||||
let now = std::time::SystemTime::now()
|
let now = std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)?
|
.duration_since(std::time::UNIX_EPOCH)?
|
||||||
.as_millis() as u64;
|
.as_millis() as u64;
|
||||||
|
|
@ -3858,7 +3884,7 @@ impl Node {
|
||||||
PostVisibility::Public => Ok(None),
|
PostVisibility::Public => Ok(None),
|
||||||
PostVisibility::Encrypted { recipients } => {
|
PostVisibility::Encrypted { recipients } => {
|
||||||
let cek = crypto::unwrap_cek_for_recipient(
|
let cek = crypto::unwrap_cek_for_recipient(
|
||||||
&self.secret_seed,
|
&self.default_posting_secret,
|
||||||
&self.node_id,
|
&self.node_id,
|
||||||
&post.author,
|
&post.author,
|
||||||
recipients,
|
recipients,
|
||||||
|
|
@ -3939,7 +3965,7 @@ impl Node {
|
||||||
serde_json::from_str::<crate::types::BlobHeader>(&json)
|
serde_json::from_str::<crate::types::BlobHeader>(&json)
|
||||||
.unwrap_or_else(|_| crate::types::BlobHeader {
|
.unwrap_or_else(|_| crate::types::BlobHeader {
|
||||||
post_id,
|
post_id,
|
||||||
author: self.node_id,
|
author: self.default_posting_id,
|
||||||
reactions: vec![],
|
reactions: vec![],
|
||||||
comments: vec![],
|
comments: vec![],
|
||||||
policy: Default::default(),
|
policy: Default::default(),
|
||||||
|
|
@ -3952,7 +3978,7 @@ impl Node {
|
||||||
} else {
|
} else {
|
||||||
crate::types::BlobHeader {
|
crate::types::BlobHeader {
|
||||||
post_id,
|
post_id,
|
||||||
author: self.node_id,
|
author: self.default_posting_id,
|
||||||
reactions: vec![],
|
reactions: vec![],
|
||||||
comments: vec![],
|
comments: vec![],
|
||||||
policy: Default::default(),
|
policy: Default::default(),
|
||||||
|
|
@ -3978,7 +4004,7 @@ impl Node {
|
||||||
// Propagate via BlobHeaderDiff
|
// Propagate via BlobHeaderDiff
|
||||||
let diff = crate::protocol::BlobHeaderDiffPayload {
|
let diff = crate::protocol::BlobHeaderDiffPayload {
|
||||||
post_id,
|
post_id,
|
||||||
author: self.node_id,
|
author: self.default_posting_id,
|
||||||
ops: vec![crate::types::BlobHeaderDiffOp::WriteReceiptSlot {
|
ops: vec![crate::types::BlobHeaderDiffOp::WriteReceiptSlot {
|
||||||
post_id,
|
post_id,
|
||||||
slot_index: our_slot as u32,
|
slot_index: our_slot as u32,
|
||||||
|
|
@ -4022,7 +4048,7 @@ impl Node {
|
||||||
serde_json::from_str::<crate::types::BlobHeader>(&json)
|
serde_json::from_str::<crate::types::BlobHeader>(&json)
|
||||||
.unwrap_or_else(|_| crate::types::BlobHeader {
|
.unwrap_or_else(|_| crate::types::BlobHeader {
|
||||||
post_id,
|
post_id,
|
||||||
author: self.node_id,
|
author: self.default_posting_id,
|
||||||
reactions: vec![],
|
reactions: vec![],
|
||||||
comments: vec![],
|
comments: vec![],
|
||||||
policy: Default::default(),
|
policy: Default::default(),
|
||||||
|
|
@ -4035,7 +4061,7 @@ impl Node {
|
||||||
} else {
|
} else {
|
||||||
crate::types::BlobHeader {
|
crate::types::BlobHeader {
|
||||||
post_id,
|
post_id,
|
||||||
author: self.node_id,
|
author: self.default_posting_id,
|
||||||
reactions: vec![],
|
reactions: vec![],
|
||||||
comments: vec![],
|
comments: vec![],
|
||||||
policy: Default::default(),
|
policy: Default::default(),
|
||||||
|
|
@ -4096,7 +4122,7 @@ impl Node {
|
||||||
|
|
||||||
let diff = crate::protocol::BlobHeaderDiffPayload {
|
let diff = crate::protocol::BlobHeaderDiffPayload {
|
||||||
post_id,
|
post_id,
|
||||||
author: self.node_id,
|
author: self.default_posting_id,
|
||||||
ops: vec![op],
|
ops: vec![op],
|
||||||
timestamp_ms: now,
|
timestamp_ms: now,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,9 @@ use crate::types::{
|
||||||
Attachment, AudienceDirection, AudienceRecord, AudienceStatus, Circle, CircleProfile,
|
Attachment, AudienceDirection, AudienceRecord, AudienceStatus, Circle, CircleProfile,
|
||||||
CommentPolicy, DeleteRecord, FollowVisibility, GossipPeerInfo, GroupEpoch, GroupId,
|
CommentPolicy, DeleteRecord, FollowVisibility, GossipPeerInfo, GroupEpoch, GroupId,
|
||||||
GroupKeyRecord, GroupMemberKey, InlineComment, ManifestEntry, NodeId, PeerRecord,
|
GroupKeyRecord, GroupMemberKey, InlineComment, ManifestEntry, NodeId, PeerRecord,
|
||||||
PeerSlotKind, PeerWithAddress, Post, PostId, PostVisibility, PublicProfile, Reaction,
|
PeerSlotKind, PeerWithAddress, Post, PostId, PostVisibility, PostingIdentity,
|
||||||
ReachMethod, SocialRelation, SocialRouteEntry, SocialStatus, ThreadMeta,
|
PublicProfile, Reaction, ReachMethod, SocialRelation, SocialRouteEntry, SocialStatus,
|
||||||
VisibilityIntent,
|
ThreadMeta, VisibilityIntent,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Direction for file_holders entries: whether we sent the file to this peer,
|
/// Direction for file_holders entries: whether we sent the file to this peer,
|
||||||
|
|
@ -401,7 +401,13 @@ impl Storage {
|
||||||
PRIMARY KEY (post_id, recipient)
|
PRIMARY KEY (post_id, recipient)
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_post_recipients_recipient
|
CREATE INDEX IF NOT EXISTS idx_post_recipients_recipient
|
||||||
ON post_recipients(recipient);",
|
ON post_recipients(recipient);
|
||||||
|
CREATE TABLE IF NOT EXISTS posting_identities (
|
||||||
|
node_id BLOB PRIMARY KEY,
|
||||||
|
secret_seed BLOB NOT NULL,
|
||||||
|
display_name TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);",
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -4237,6 +4243,143 @@ impl Storage {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Posting identities (multi-persona plumbing) ---
|
||||||
|
|
||||||
|
pub fn upsert_posting_identity(&self, id: &PostingIdentity) -> anyhow::Result<()> {
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT INTO posting_identities (node_id, secret_seed, display_name, created_at)
|
||||||
|
VALUES (?1, ?2, ?3, ?4)
|
||||||
|
ON CONFLICT(node_id) DO UPDATE SET
|
||||||
|
display_name = excluded.display_name",
|
||||||
|
params![
|
||||||
|
id.node_id.as_slice(),
|
||||||
|
id.secret_seed.as_slice(),
|
||||||
|
id.display_name,
|
||||||
|
id.created_at as i64,
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_posting_identity(&self, node_id: &NodeId) -> anyhow::Result<Option<PostingIdentity>> {
|
||||||
|
let result = self.conn.query_row(
|
||||||
|
"SELECT node_id, secret_seed, display_name, created_at
|
||||||
|
FROM posting_identities WHERE node_id = ?1",
|
||||||
|
params![node_id.as_slice()],
|
||||||
|
|row| {
|
||||||
|
let nid: Vec<u8> = row.get(0)?;
|
||||||
|
let seed: Vec<u8> = row.get(1)?;
|
||||||
|
let name: String = row.get(2)?;
|
||||||
|
let ts: i64 = row.get(3)?;
|
||||||
|
Ok((nid, seed, name, ts))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
match result {
|
||||||
|
Ok((nid_bytes, seed_bytes, name, ts)) => {
|
||||||
|
let nid: NodeId = nid_bytes.as_slice().try_into()
|
||||||
|
.map_err(|_| anyhow::anyhow!("invalid posting identity node_id"))?;
|
||||||
|
let seed: [u8; 32] = seed_bytes.as_slice().try_into()
|
||||||
|
.map_err(|_| anyhow::anyhow!("invalid posting identity seed"))?;
|
||||||
|
Ok(Some(PostingIdentity {
|
||||||
|
node_id: nid,
|
||||||
|
secret_seed: seed,
|
||||||
|
display_name: name,
|
||||||
|
created_at: ts as u64,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||||
|
Err(e) => Err(e.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_posting_identities(&self) -> anyhow::Result<Vec<PostingIdentity>> {
|
||||||
|
let mut stmt = self.conn.prepare(
|
||||||
|
"SELECT node_id, secret_seed, display_name, created_at
|
||||||
|
FROM posting_identities ORDER BY created_at ASC",
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map([], |row| {
|
||||||
|
let nid: Vec<u8> = row.get(0)?;
|
||||||
|
let seed: Vec<u8> = row.get(1)?;
|
||||||
|
let name: String = row.get(2)?;
|
||||||
|
let ts: i64 = row.get(3)?;
|
||||||
|
Ok((nid, seed, name, ts))
|
||||||
|
})?;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for row in rows {
|
||||||
|
let (nid_bytes, seed_bytes, name, ts) = row?;
|
||||||
|
let nid: NodeId = match nid_bytes.as_slice().try_into() {
|
||||||
|
Ok(n) => n, Err(_) => continue,
|
||||||
|
};
|
||||||
|
let seed: [u8; 32] = match seed_bytes.as_slice().try_into() {
|
||||||
|
Ok(s) => s, Err(_) => continue,
|
||||||
|
};
|
||||||
|
out.push(PostingIdentity {
|
||||||
|
node_id: nid,
|
||||||
|
secret_seed: seed,
|
||||||
|
display_name: name,
|
||||||
|
created_at: ts as u64,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_posting_identity(&self, node_id: &NodeId) -> anyhow::Result<()> {
|
||||||
|
self.conn.execute(
|
||||||
|
"DELETE FROM posting_identities WHERE node_id = ?1",
|
||||||
|
params![node_id.as_slice()],
|
||||||
|
)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the NodeId of the currently default posting identity.
|
||||||
|
/// Stored in `settings` under key `active_default_posting_id`.
|
||||||
|
pub fn get_default_posting_id(&self) -> anyhow::Result<Option<NodeId>> {
|
||||||
|
match self.get_setting("active_default_posting_id")? {
|
||||||
|
Some(hex_str) => {
|
||||||
|
let bytes = hex::decode(&hex_str).unwrap_or_default();
|
||||||
|
if bytes.len() == 32 {
|
||||||
|
let mut nid = [0u8; 32];
|
||||||
|
nid.copy_from_slice(&bytes);
|
||||||
|
Ok(Some(nid))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => Ok(None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_default_posting_id(&self, node_id: &NodeId) -> anyhow::Result<()> {
|
||||||
|
self.set_setting("active_default_posting_id", &hex::encode(node_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ensure the posting_identities table has at least one entry. On first
|
||||||
|
/// launch after 0.6.3 upgrade, copies the network key from
|
||||||
|
/// `identity.key` on disk into posting_identities and sets it as default,
|
||||||
|
/// preserving signature validity of all existing content.
|
||||||
|
pub fn seed_posting_identity_from_network(
|
||||||
|
&self,
|
||||||
|
network_node_id: &NodeId,
|
||||||
|
network_secret: &[u8; 32],
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let existing: i64 = self.conn.prepare(
|
||||||
|
"SELECT COUNT(*) FROM posting_identities",
|
||||||
|
)?.query_row([], |row| row.get(0))?;
|
||||||
|
if existing == 0 {
|
||||||
|
let now = now_ms();
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT INTO posting_identities (node_id, secret_seed, display_name, created_at)
|
||||||
|
VALUES (?1, ?2, '', ?3)",
|
||||||
|
params![network_node_id.as_slice(), network_secret.as_slice(), now as i64],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
// Always ensure a default is set (no-op if already pointing at a valid identity).
|
||||||
|
if self.get_default_posting_id()?.is_none() {
|
||||||
|
self.set_default_posting_id(network_node_id)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// --- File holders (flat, per-file, LRU-capped at 5) ---
|
// --- File holders (flat, per-file, LRU-capped at 5) ---
|
||||||
//
|
//
|
||||||
// A single table for PostId-keyed engagement propagation and CID-keyed
|
// A single table for PostId-keyed engagement propagation and CID-keyed
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,25 @@ pub type PostId = [u8; 32];
|
||||||
/// A node identifier — ed25519 public key bytes (same as iroh EndpointId)
|
/// A node identifier — ed25519 public key bytes (same as iroh EndpointId)
|
||||||
pub type NodeId = [u8; 32];
|
pub type NodeId = [u8; 32];
|
||||||
|
|
||||||
|
/// A posting identity: the signing key a user authors content with.
|
||||||
|
///
|
||||||
|
/// In 0.6.3-beta, every device has exactly one posting identity, which is a
|
||||||
|
/// copy of the network key (so all existing content keeps verifying). From
|
||||||
|
/// 0.6.4-beta onward, users can hold multiple posting identities as
|
||||||
|
/// simultaneously-active personas. The network key stays hidden — peers
|
||||||
|
/// never learn which network key holds which posting key.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
pub struct PostingIdentity {
|
||||||
|
/// ed25519 public key (also the author field on signed content).
|
||||||
|
pub node_id: NodeId,
|
||||||
|
/// ed25519 secret seed.
|
||||||
|
pub secret_seed: [u8; 32],
|
||||||
|
/// User-facing label for this persona. Empty string if unset.
|
||||||
|
pub display_name: String,
|
||||||
|
/// Creation timestamp (ms since epoch).
|
||||||
|
pub created_at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
/// A public post on the network
|
/// A public post on the network
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct Post {
|
pub struct Post {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue