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:
Scott Reimers 2026-04-21 22:38:12 -04:00
parent 975e7b9bfe
commit ce4b989b17
5 changed files with 287 additions and 50 deletions

View file

@ -26,9 +26,17 @@ pub struct Node {
pub data_dir: PathBuf,
pub storage: Arc<StoragePool>,
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 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)>>,
/// True if an anchor reported another instance of this identity is already active
pub duplicate_detected: Arc<AtomicBool>,
@ -109,10 +117,27 @@ impl Node {
);
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
{
let s = storage.get().await;
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)
@ -136,7 +161,8 @@ impl Node {
network: Arc::clone(&network),
node_id,
blob_store,
secret_seed,
default_posting_id,
default_posting_secret,
bootstrap_anchors: tokio::sync::Mutex::new(Vec::new()),
duplicate_detected: Arc::new(AtomicBool::new(false)),
profile,
@ -492,7 +518,7 @@ impl Node {
/// Get the secret seed bytes (for crypto operations by consumers like Tauri)
pub fn secret_seed_bytes(&self) -> [u8; 32] {
self.secret_seed
self.default_posting_secret
}
// --- CDN Replication Budget ---
@ -554,7 +580,7 @@ impl Node {
// ---- Identity export/import ----
pub fn secret_seed(&self) -> [u8; 32] {
self.secret_seed
self.default_posting_secret
}
pub fn export_identity_hex(&self) -> anyhow::Result<String> {
@ -700,7 +726,7 @@ impl Node {
EncryptionMode::Public => (content, PostVisibility::Public),
EncryptionMode::Recipient { cek, recipients } => {
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,
PostVisibility::Encrypted {
@ -723,7 +749,7 @@ impl Node {
};
let post = Post {
author: self.node_id,
author: self.default_posting_id,
content: final_content,
attachments,
timestamp_ms: now,
@ -769,7 +795,7 @@ impl Node {
let blob_header = crate::types::BlobHeader {
post_id,
author: self.node_id,
author: self.default_posting_id,
reactions: vec![],
comments: vec![],
policy: Default::default(),
@ -792,7 +818,7 @@ impl Node {
let manifest = crate::types::AuthorManifest {
post_id,
author: self.node_id,
author: self.default_posting_id,
author_addresses: self.network.our_addresses(),
created_at: now,
updated_at: now,
@ -800,7 +826,7 @@ impl Node {
following_posts: 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;
manifest.signature = sig;
@ -883,7 +909,7 @@ impl Node {
}
manifest.following_posts.push(new_entry.clone());
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) {
Ok(j) => j,
@ -970,7 +996,7 @@ impl Node {
PostVisibility::Encrypted { recipients } => {
crypto::decrypt_post(
&post.content,
&self.secret_seed,
&self.default_posting_secret,
&self.node_id,
&post.author,
recipients,
@ -1181,7 +1207,7 @@ impl Node {
PostVisibility::Public => Ok(Some(data)),
PostVisibility::Encrypted { recipients } => {
let cek = crypto::unwrap_cek_for_recipient(
&self.secret_seed,
&self.default_posting_secret,
&self.node_id,
&post.author,
recipients,
@ -1500,7 +1526,7 @@ impl Node {
if let Ok(Some(gk)) = storage.get_group_key_by_circle(&circle_name) {
if gk.admin == self.node_id {
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) => {
let mk = crate::types::GroupMemberKey {
member: node_id,
@ -1572,7 +1598,7 @@ impl Node {
storage.store_group_seed(&group_id, 1, &seed)?;
// 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 {
member: self.node_id,
epoch: 1,
@ -1588,7 +1614,7 @@ impl Node {
if *member == self.node_id {
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) => {
let mk = crate::types::GroupMemberKey {
member: *member,
@ -1636,7 +1662,7 @@ impl Node {
if !all_members.contains(&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)) => {
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;
let cp = crate::types::CircleProfile {
author: self.node_id,
author: self.default_posting_id,
circle_name: circle_name.clone(),
display_name,
bio,
@ -1746,7 +1772,7 @@ impl Node {
// Push to all connected mesh peers
let payload = crate::protocol::CircleProfileUpdatePayload {
author: self.node_id,
author: self.default_posting_id,
circle_name,
group_id,
epoch,
@ -1781,7 +1807,7 @@ impl Node {
storage.delete_circle_profile(&self.node_id, &circle_name)?;
crate::protocol::CircleProfileUpdatePayload {
author: self.node_id,
author: self.default_posting_id,
circle_name,
group_id: gk.group_id,
epoch: gk.epoch,
@ -1982,11 +2008,11 @@ impl Node {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.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 {
post_id: *post_id,
author: self.node_id,
author: self.default_posting_id,
timestamp_ms: now,
signature,
};
@ -2070,7 +2096,7 @@ impl Node {
match mode {
RevocationMode::SyncAccessList => {
let new_wrapped = crypto::rewrap_visibility(
&self.secret_seed,
&self.default_posting_secret,
&self.node_id,
existing_recipients,
&new_recipient_ids,
@ -2085,7 +2111,7 @@ impl Node {
let update = VisibilityUpdate {
post_id: *post_id,
author: self.node_id,
author: self.default_posting_id,
visibility: new_vis,
};
let pushed = self.network.push_visibility(&update).await;
@ -2095,7 +2121,7 @@ impl Node {
RevocationMode::ReEncrypt => {
let (new_content, new_wrapped) = crypto::re_encrypt_post(
&post.content,
&self.secret_seed,
&self.default_posting_secret,
&self.node_id,
existing_recipients,
&new_recipient_ids,
@ -2105,7 +2131,7 @@ impl Node {
};
let new_post = Post {
author: self.node_id,
author: self.default_posting_id,
content: new_content,
attachments: post.attachments.clone(),
timestamp_ms: post.timestamp_ms,
@ -3541,7 +3567,7 @@ impl Node {
emoji: String,
private: bool,
) -> 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()
.duration_since(std::time::UNIX_EPOCH)?
.as_millis() as u64;
@ -3552,7 +3578,7 @@ impl Node {
let post = storage.get_post(&post_id)?
.ok_or_else(|| anyhow::anyhow!("post not found"))?;
drop(storage);
let seed = self.secret_seed;
let seed = self.default_posting_secret;
let payload_json = serde_json::json!({
"emoji": emoji,
"reactor": hex::encode(our_node_id),
@ -3563,7 +3589,7 @@ impl Node {
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 {
reactor: our_node_id,
emoji: emoji.clone(),
@ -3598,7 +3624,7 @@ impl Node {
/// Remove a reaction from a post.
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;
storage.remove_reaction(&our_node_id, &post_id, &emoji)?;
drop(storage);
@ -3632,12 +3658,12 @@ impl Node {
let post_info = storage.get_post(&post_id)?;
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 let Some(post) = post_info {
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| {
if let Some(ref enc) = r.encrypted_payload {
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.
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 counts = storage.get_reaction_counts(&post_id, &our_node_id)?;
Ok(counts)
@ -3666,8 +3692,8 @@ impl Node {
post_id: PostId,
content: String,
) -> anyhow::Result<crate::types::InlineComment> {
let our_node_id = self.node_id;
let seed = self.secret_seed;
let our_node_id = self.default_posting_id;
let seed = self.default_posting_secret;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_millis() as u64;
@ -3709,7 +3735,7 @@ impl Node {
timestamp_ms: u64,
new_content: String,
) -> anyhow::Result<()> {
let our_node_id = self.node_id;
let our_node_id = self.default_posting_id;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_millis() as u64;
@ -3743,7 +3769,7 @@ impl Node {
post_id: PostId,
timestamp_ms: u64,
) -> anyhow::Result<()> {
let our_node_id = self.node_id;
let our_node_id = self.default_posting_id;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_millis() as u64;
@ -3790,7 +3816,7 @@ impl Node {
// Propagate policy change
{
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()
.duration_since(std::time::UNIX_EPOCH)?
.as_millis() as u64;
@ -3858,7 +3884,7 @@ impl Node {
PostVisibility::Public => Ok(None),
PostVisibility::Encrypted { recipients } => {
let cek = crypto::unwrap_cek_for_recipient(
&self.secret_seed,
&self.default_posting_secret,
&self.node_id,
&post.author,
recipients,
@ -3939,7 +3965,7 @@ impl Node {
serde_json::from_str::<crate::types::BlobHeader>(&json)
.unwrap_or_else(|_| crate::types::BlobHeader {
post_id,
author: self.node_id,
author: self.default_posting_id,
reactions: vec![],
comments: vec![],
policy: Default::default(),
@ -3952,7 +3978,7 @@ impl Node {
} else {
crate::types::BlobHeader {
post_id,
author: self.node_id,
author: self.default_posting_id,
reactions: vec![],
comments: vec![],
policy: Default::default(),
@ -3978,7 +4004,7 @@ impl Node {
// Propagate via BlobHeaderDiff
let diff = crate::protocol::BlobHeaderDiffPayload {
post_id,
author: self.node_id,
author: self.default_posting_id,
ops: vec![crate::types::BlobHeaderDiffOp::WriteReceiptSlot {
post_id,
slot_index: our_slot as u32,
@ -4022,7 +4048,7 @@ impl Node {
serde_json::from_str::<crate::types::BlobHeader>(&json)
.unwrap_or_else(|_| crate::types::BlobHeader {
post_id,
author: self.node_id,
author: self.default_posting_id,
reactions: vec![],
comments: vec![],
policy: Default::default(),
@ -4035,7 +4061,7 @@ impl Node {
} else {
crate::types::BlobHeader {
post_id,
author: self.node_id,
author: self.default_posting_id,
reactions: vec![],
comments: vec![],
policy: Default::default(),
@ -4096,7 +4122,7 @@ impl Node {
let diff = crate::protocol::BlobHeaderDiffPayload {
post_id,
author: self.node_id,
author: self.default_posting_id,
ops: vec![op],
timestamp_ms: now,
};