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
|
|
@ -7,9 +7,9 @@ use crate::types::{
|
|||
Attachment, AudienceDirection, AudienceRecord, AudienceStatus, Circle, CircleProfile,
|
||||
CommentPolicy, DeleteRecord, FollowVisibility, GossipPeerInfo, GroupEpoch, GroupId,
|
||||
GroupKeyRecord, GroupMemberKey, InlineComment, ManifestEntry, NodeId, PeerRecord,
|
||||
PeerSlotKind, PeerWithAddress, Post, PostId, PostVisibility, PublicProfile, Reaction,
|
||||
ReachMethod, SocialRelation, SocialRouteEntry, SocialStatus, ThreadMeta,
|
||||
VisibilityIntent,
|
||||
PeerSlotKind, PeerWithAddress, Post, PostId, PostVisibility, PostingIdentity,
|
||||
PublicProfile, Reaction, ReachMethod, SocialRelation, SocialRouteEntry, SocialStatus,
|
||||
ThreadMeta, VisibilityIntent,
|
||||
};
|
||||
|
||||
/// Direction for file_holders entries: whether we sent the file to this peer,
|
||||
|
|
@ -401,7 +401,13 @@ impl Storage {
|
|||
PRIMARY KEY (post_id, 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(())
|
||||
}
|
||||
|
|
@ -4237,6 +4243,143 @@ impl Storage {
|
|||
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) ---
|
||||
//
|
||||
// A single table for PostId-keyed engagement propagation and CID-keyed
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue