mirror of
https://code.gri.mw/GUI/grim.git
synced 2026-07-15 03:08:56 +00:00
goblin: held nostr identity set (storage + migration)
One wallet, one grin seed / one balance, but a SET of nostr identities (nsecs), exactly one active at a time. New identities module owns the held-identity index (identities.json): which identities the wallet holds, their order, and which is active. It stores no secrets; each held identity is its own NIP-49 ncryptsec on disk, exactly like the single identity today (identity.rs create/unlock/backup reused unchanged, plus save_at/load_at for the per-identity files and pubkey_hex for the index key). Migration is fund-safe and needs no key regen: a pre-feature wallet has only identity.json, which the index adopts in place as the single active identity number one; the legacy file is never overwritten, so an older build still opens the wallet on it (clean rollback). The store gains a per-identity last_active_at so a switch back to a dormant identity can catch up from when it last listened, not merely from the wallet-wide last connection. Unit tests cover migration, add/switch/cap/dedupe, active resolution across a reload, corrupt-index fallback, reencrypt-all, and the catch-up-since rule.
This commit is contained in:
@@ -0,0 +1,544 @@
|
||||
// Copyright 2026 The Goblin Developers
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! One wallet, one grin seed / one balance, but MANY nostr identities (nsecs),
|
||||
//! exactly one of which is ACTIVE at a time. This module is the wallet-level
|
||||
//! held-identity INDEX: it owns which identities the wallet holds, their display
|
||||
//! order, and which is active. It stores NO secrets. Each held identity is a
|
||||
//! full [`NostrIdentity`] on disk (its own NIP-49 ncryptsec, see
|
||||
//! [`crate::nostr::identity`]); this index only points at those files.
|
||||
//!
|
||||
//! On-disk layout under `<base_data>/nostr/`:
|
||||
//! ```text
|
||||
//! nostr/
|
||||
//! identities.json # this index (0600, no secrets): active + order + entries
|
||||
//! identity.json # identity #1 (the legacy file, NEVER overwritten by a switch)
|
||||
//! identities/<hex>/identity.json # each additional held identity
|
||||
//! db/ # shared rkv store (dedup, contacts, meta) — one for all identities
|
||||
//! ```
|
||||
//!
|
||||
//! Migration is trivial and fund-safe: a pre-feature wallet has only a bare
|
||||
//! `identity.json`. On first load the index adopts it as the single, active
|
||||
//! identity #1 — no key regeneration, no rewrite of the legacy file, and the
|
||||
//! grin seed/balance are never touched (this module cannot reach them).
|
||||
//!
|
||||
//! A switch only moves the `active` pointer here and rebinds the running service
|
||||
//! to the target's key (the wallet does the teardown + bring-up + catch-up). The
|
||||
//! legacy `identity.json` is deliberately never overwritten, so an older build
|
||||
//! that ignores this index still opens the wallet on identity #1 (clean rollback).
|
||||
|
||||
use crate::nostr::identity::NostrIdentity;
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Index file name inside the nostr directory.
|
||||
const INDEX_FILE: &str = "identities.json";
|
||||
/// The legacy single-identity file, which becomes identity #1.
|
||||
const LEGACY_FILE: &str = "identity.json";
|
||||
/// Sub-directory holding each additional (non-legacy) identity.
|
||||
const SUBDIR: &str = "identities";
|
||||
|
||||
/// Cap on how many identities one wallet may hold. Bounds the on-disk key files
|
||||
/// and the switcher list, and stops a hostile import loop from ballooning either.
|
||||
pub const MAX_IDENTITIES: usize = 8;
|
||||
|
||||
/// One held identity, referenced by the index. No secret material.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
|
||||
pub struct HeldEntry {
|
||||
/// Public key, lowercase hex — the stable id of this identity.
|
||||
pub pubkey: String,
|
||||
/// Path to the identity's `identity.json`, RELATIVE to the nostr dir:
|
||||
/// `"identity.json"` for the legacy identity #1, else
|
||||
/// `"identities/<hex>/identity.json"`.
|
||||
pub path: String,
|
||||
/// A short human label (its @name, or "Primary" / a fallback). Plaintext by
|
||||
/// design (this index carries no secret); the npub/name are recoverable from
|
||||
/// the referenced file anyway.
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
impl HeldEntry {
|
||||
/// Absolute path to this entry's identity file under `nostr_dir`.
|
||||
pub fn abs_path(&self, nostr_dir: &PathBuf) -> PathBuf {
|
||||
let mut p = nostr_dir.clone();
|
||||
for seg in self.path.split('/') {
|
||||
p.push(seg);
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// Load the full [`NostrIdentity`] this entry points at.
|
||||
pub fn load(&self, nostr_dir: &PathBuf) -> Option<NostrIdentity> {
|
||||
NostrIdentity::load_at(&self.abs_path(nostr_dir))
|
||||
}
|
||||
}
|
||||
|
||||
/// The held-identity index: which identities the wallet holds and which is
|
||||
/// active. Persisted as `identities.json`.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct HeldIdentities {
|
||||
pub ver: u8,
|
||||
/// Active identity, lowercase hex. Drives the single live subscription and
|
||||
/// all display; the only pointer a switch moves.
|
||||
pub active: String,
|
||||
/// Display order, lowercase hex.
|
||||
pub order: Vec<String>,
|
||||
/// Entry metadata (no secrets).
|
||||
pub identities: Vec<HeldEntry>,
|
||||
}
|
||||
|
||||
impl HeldIdentities {
|
||||
/// Index file path inside the nostr dir.
|
||||
pub fn index_path(nostr_dir: &PathBuf) -> PathBuf {
|
||||
let mut p = nostr_dir.clone();
|
||||
p.push(INDEX_FILE);
|
||||
p
|
||||
}
|
||||
|
||||
/// Relative path an additional identity's file lives at.
|
||||
fn rel_path_for(hex: &str) -> String {
|
||||
format!("{SUBDIR}/{hex}/{LEGACY_FILE}")
|
||||
}
|
||||
|
||||
/// Load the index if present and parseable.
|
||||
pub fn load(nostr_dir: &PathBuf) -> Option<HeldIdentities> {
|
||||
let raw = fs::read_to_string(Self::index_path(nostr_dir)).ok()?;
|
||||
serde_json::from_str(&raw).ok()
|
||||
}
|
||||
|
||||
/// Persist the index with owner-only (0600) permissions. It carries no
|
||||
/// secret, but a consistent 0700/0600 posture across the nostr dir is
|
||||
/// simplest to reason about.
|
||||
pub fn save(&self, nostr_dir: &PathBuf) -> std::io::Result<()> {
|
||||
fs::create_dir_all(nostr_dir)?;
|
||||
let raw = serde_json::to_string_pretty(self)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
|
||||
write_private_0600(&Self::index_path(nostr_dir), raw.as_bytes())
|
||||
}
|
||||
|
||||
/// The active entry, if the pointer resolves to a held identity.
|
||||
pub fn active_entry(&self) -> Option<&HeldEntry> {
|
||||
self.identities.iter().find(|e| e.pubkey == self.active)
|
||||
}
|
||||
|
||||
/// Look up an entry by hex.
|
||||
pub fn entry(&self, hex: &str) -> Option<&HeldEntry> {
|
||||
self.identities.iter().find(|e| e.pubkey == hex)
|
||||
}
|
||||
|
||||
/// Whether the wallet already holds this pubkey (dedupe guard on add/import).
|
||||
pub fn contains(&self, hex: &str) -> bool {
|
||||
self.identities.iter().any(|e| e.pubkey == hex)
|
||||
}
|
||||
|
||||
/// Held-identity count.
|
||||
pub fn len(&self) -> usize {
|
||||
self.identities.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.identities.is_empty()
|
||||
}
|
||||
|
||||
/// True if another identity may still be added under the cap.
|
||||
pub fn has_room(&self) -> bool {
|
||||
self.identities.len() < MAX_IDENTITIES
|
||||
}
|
||||
|
||||
/// Build a fresh single-identity index from the legacy identity #1. This is
|
||||
/// the migration shape: exactly one held identity, active, referencing the
|
||||
/// legacy `identity.json` in place (never rewritten).
|
||||
pub fn from_legacy(legacy: &NostrIdentity) -> Option<HeldIdentities> {
|
||||
let hex = legacy.pubkey_hex()?;
|
||||
Some(HeldIdentities {
|
||||
ver: 1,
|
||||
active: hex.clone(),
|
||||
order: vec![hex.clone()],
|
||||
identities: vec![HeldEntry {
|
||||
pubkey: hex,
|
||||
path: LEGACY_FILE.to_string(),
|
||||
label: label_for(legacy),
|
||||
}],
|
||||
})
|
||||
}
|
||||
|
||||
/// Load the index, migrating a legacy single-identity wallet in place, and
|
||||
/// self-healing an index whose `active` pointer no longer resolves. Returns
|
||||
/// the index plus the ACTIVE identity to run. `legacy` is the identity loaded
|
||||
/// from the bare `identity.json` (identity #1), used for migration/repair.
|
||||
///
|
||||
/// Never touches funds and never regenerates a key. Writes only the index
|
||||
/// (`identities.json`) — the identity files themselves are left as they are.
|
||||
pub fn load_or_migrate(
|
||||
nostr_dir: &PathBuf,
|
||||
legacy: &NostrIdentity,
|
||||
) -> Option<(HeldIdentities, NostrIdentity)> {
|
||||
let legacy_hex = legacy.pubkey_hex()?;
|
||||
match Self::load(nostr_dir) {
|
||||
Some(mut idx) => {
|
||||
// Repair: ensure identity #1 is always represented (it is the
|
||||
// rollback anchor), without disturbing the active pointer.
|
||||
if !idx.contains(&legacy_hex) {
|
||||
idx.identities.push(HeldEntry {
|
||||
pubkey: legacy_hex.clone(),
|
||||
path: LEGACY_FILE.to_string(),
|
||||
label: label_for(legacy),
|
||||
});
|
||||
if !idx.order.contains(&legacy_hex) {
|
||||
idx.order.push(legacy_hex.clone());
|
||||
}
|
||||
let _ = idx.save(nostr_dir);
|
||||
}
|
||||
// Resolve the active identity; if its file is missing/corrupt,
|
||||
// fall back to identity #1 so the wallet always has a running
|
||||
// identity rather than none.
|
||||
let active = idx
|
||||
.active_entry()
|
||||
.and_then(|e| e.load(nostr_dir))
|
||||
.or_else(|| {
|
||||
idx.active = legacy_hex.clone();
|
||||
let _ = idx.save(nostr_dir);
|
||||
Some(legacy.clone())
|
||||
})?;
|
||||
Some((idx, active))
|
||||
}
|
||||
None => {
|
||||
// Legacy layout: adopt identity.json as the sole, active identity.
|
||||
let idx = Self::from_legacy(legacy)?;
|
||||
let _ = idx.save(nostr_dir);
|
||||
Some((idx, legacy.clone()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Add an already-built identity to the set (does NOT change the active
|
||||
/// pointer). Writes the identity's file under `identities/<hex>/` and records
|
||||
/// the entry. Enforces the cap and dedupe. Returns the new entry's hex.
|
||||
pub fn add(
|
||||
&mut self,
|
||||
nostr_dir: &PathBuf,
|
||||
identity: &NostrIdentity,
|
||||
) -> Result<String, HeldError> {
|
||||
let hex = identity.pubkey_hex().ok_or(HeldError::BadPubkey)?;
|
||||
if self.contains(&hex) {
|
||||
return Err(HeldError::AlreadyHeld);
|
||||
}
|
||||
if !self.has_room() {
|
||||
return Err(HeldError::AtCapacity);
|
||||
}
|
||||
let rel = Self::rel_path_for(&hex);
|
||||
let mut abs = nostr_dir.clone();
|
||||
for seg in rel.split('/') {
|
||||
abs.push(seg);
|
||||
}
|
||||
identity
|
||||
.save_at(&abs)
|
||||
.map_err(|e| HeldError::Io(e.to_string()))?;
|
||||
self.identities.push(HeldEntry {
|
||||
pubkey: hex.clone(),
|
||||
path: rel,
|
||||
label: label_for(identity),
|
||||
});
|
||||
self.order.push(hex.clone());
|
||||
self.save(nostr_dir)
|
||||
.map_err(|e| HeldError::Io(e.to_string()))?;
|
||||
Ok(hex)
|
||||
}
|
||||
|
||||
/// Move the active pointer to a held identity. The caller is responsible for
|
||||
/// tearing down and re-standing the service on the new key; this only records
|
||||
/// the choice so the next open lands on it too.
|
||||
pub fn set_active(&mut self, nostr_dir: &PathBuf, hex: &str) -> Result<(), HeldError> {
|
||||
if !self.contains(hex) {
|
||||
return Err(HeldError::NotHeld);
|
||||
}
|
||||
self.active = hex.to_string();
|
||||
self.save(nostr_dir)
|
||||
.map_err(|e| HeldError::Io(e.to_string()))
|
||||
}
|
||||
|
||||
/// Re-encrypt every held identity's ncryptsec from `old` to `new`, in place
|
||||
/// on disk. Used by the wallet-password change so all front doors follow the
|
||||
/// one password. Best-effort per file; returns the first error encountered
|
||||
/// after attempting the rest.
|
||||
pub fn reencrypt_all(
|
||||
&self,
|
||||
nostr_dir: &PathBuf,
|
||||
old: &str,
|
||||
new: &str,
|
||||
) -> Result<(), HeldError> {
|
||||
let mut first_err = None;
|
||||
for entry in &self.identities {
|
||||
let abs = entry.abs_path(nostr_dir);
|
||||
match NostrIdentity::load_at(&abs) {
|
||||
Some(mut id) => {
|
||||
if let Err(e) = id.reencrypt(old, new) {
|
||||
first_err.get_or_insert(HeldError::Io(e.to_string()));
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = id.save_at(&abs) {
|
||||
first_err.get_or_insert(HeldError::Io(e.to_string()));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
first_err.get_or_insert(HeldError::Io(format!(
|
||||
"identity file unreadable: {}",
|
||||
entry.path
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
match first_err {
|
||||
Some(e) => Err(e),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors from held-identity index operations. Carries no secret material.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum HeldError {
|
||||
#[error("that identity is already in this wallet")]
|
||||
AlreadyHeld,
|
||||
#[error("this wallet already holds the maximum number of identities")]
|
||||
AtCapacity,
|
||||
#[error("identity not held by this wallet")]
|
||||
NotHeld,
|
||||
#[error("identity has a malformed public key")]
|
||||
BadPubkey,
|
||||
#[error("identity store error: {0}")]
|
||||
Io(String),
|
||||
}
|
||||
|
||||
/// A short display label for an identity: its @name when claimed, else "Primary".
|
||||
/// Never includes secret material.
|
||||
fn label_for(id: &NostrIdentity) -> String {
|
||||
id.nip05
|
||||
.as_deref()
|
||||
.and_then(|n| n.split('@').next())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "Primary".to_string())
|
||||
}
|
||||
|
||||
/// The catch-up `since` (unix seconds) for the identity we are bringing up. We
|
||||
/// want to cover "since THIS identity last listened", not "since the wallet last
|
||||
/// connected on any identity", so a payment that arrived while this identity was
|
||||
/// dormant is fetched and redeemed on switch. Falls back to the wallet-wide last
|
||||
/// connection, then to now, and always subtracts the same generous lookback so a
|
||||
/// boundary payment is never missed. Pure — unit tested.
|
||||
pub fn catchup_since(
|
||||
identity_last_active: Option<i64>,
|
||||
wallet_last_connected: Option<i64>,
|
||||
now: i64,
|
||||
lookback: i64,
|
||||
) -> i64 {
|
||||
let base = identity_last_active
|
||||
.or(wallet_last_connected)
|
||||
.unwrap_or(now);
|
||||
(base - lookback).max(0)
|
||||
}
|
||||
|
||||
/// Write a file with owner-only (0600) permissions on Unix.
|
||||
fn write_private_0600(path: &PathBuf, data: &[u8]) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(path)?;
|
||||
f.write_all(data)?;
|
||||
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
std::fs::write(path, data)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tmpdir(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!(
|
||||
"goblin-held-test-{tag}-{}-{:?}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
let _ = std::fs::create_dir_all(&d);
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_adopts_legacy_as_single_active_identity() {
|
||||
let dir = tmpdir("migrate");
|
||||
let (legacy, _) = NostrIdentity::create_random("pw").unwrap();
|
||||
legacy.save(&dir).unwrap(); // writes identity.json
|
||||
let legacy_hex = legacy.pubkey_hex().unwrap();
|
||||
|
||||
// No index yet -> migrate.
|
||||
assert!(HeldIdentities::load(&dir).is_none());
|
||||
let (idx, active) = HeldIdentities::load_or_migrate(&dir, &legacy).unwrap();
|
||||
assert_eq!(idx.len(), 1);
|
||||
assert_eq!(idx.active, legacy_hex);
|
||||
assert_eq!(active.npub, legacy.npub);
|
||||
// Legacy entry points at the untouched identity.json.
|
||||
assert_eq!(idx.active_entry().unwrap().path, "identity.json");
|
||||
// Index now persisted and reloads identically.
|
||||
let reloaded = HeldIdentities::load(&dir).unwrap();
|
||||
assert_eq!(reloaded.active, legacy_hex);
|
||||
assert_eq!(reloaded.len(), 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_switch_and_cap() {
|
||||
let dir = tmpdir("addswitch");
|
||||
let (legacy, _) = NostrIdentity::create_random("pw").unwrap();
|
||||
legacy.save(&dir).unwrap();
|
||||
let (mut idx, _) = HeldIdentities::load_or_migrate(&dir, &legacy).unwrap();
|
||||
let legacy_hex = legacy.pubkey_hex().unwrap();
|
||||
|
||||
// Add a second identity; active stays on #1 until we switch.
|
||||
let (second, _) = NostrIdentity::create_random("pw").unwrap();
|
||||
let second_hex = idx.add(&dir, &second).unwrap();
|
||||
assert_eq!(idx.len(), 2);
|
||||
assert_eq!(idx.active, legacy_hex);
|
||||
// It has its own file under identities/<hex>/.
|
||||
assert!(idx.entry(&second_hex).unwrap().load(&dir).is_some());
|
||||
|
||||
// Dedupe: adding the same pubkey again is refused.
|
||||
assert!(matches!(
|
||||
idx.add(&dir, &second),
|
||||
Err(HeldError::AlreadyHeld)
|
||||
));
|
||||
|
||||
// Switch active pointer.
|
||||
idx.set_active(&dir, &second_hex).unwrap();
|
||||
assert_eq!(idx.active, second_hex);
|
||||
// Persisted.
|
||||
assert_eq!(HeldIdentities::load(&dir).unwrap().active, second_hex);
|
||||
// Switching to an unknown identity is refused.
|
||||
assert!(matches!(
|
||||
idx.set_active(&dir, "deadbeef"),
|
||||
Err(HeldError::NotHeld)
|
||||
));
|
||||
|
||||
// Fill to the cap and assert the (N+1)th is rejected.
|
||||
while idx.has_room() {
|
||||
let (extra, _) = NostrIdentity::create_random("pw").unwrap();
|
||||
idx.add(&dir, &extra).unwrap();
|
||||
}
|
||||
assert_eq!(idx.len(), MAX_IDENTITIES);
|
||||
let (overflow, _) = NostrIdentity::create_random("pw").unwrap();
|
||||
assert!(matches!(
|
||||
idx.add(&dir, &overflow),
|
||||
Err(HeldError::AtCapacity)
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_resolves_after_reload_and_survives_switch() {
|
||||
let dir = tmpdir("resolve");
|
||||
let (legacy, _) = NostrIdentity::create_random("pw").unwrap();
|
||||
legacy.save(&dir).unwrap();
|
||||
let (mut idx, _) = HeldIdentities::load_or_migrate(&dir, &legacy).unwrap();
|
||||
let (second, _) = NostrIdentity::create_random("pw").unwrap();
|
||||
let second_hex = idx.add(&dir, &second).unwrap();
|
||||
idx.set_active(&dir, &second_hex).unwrap();
|
||||
|
||||
// Reopen: load_or_migrate must return the ACTIVE identity (#2), and the
|
||||
// legacy identity.json must be UNCHANGED (still identity #1 for rollback).
|
||||
let (reidx, active) = HeldIdentities::load_or_migrate(&dir, &legacy).unwrap();
|
||||
assert_eq!(reidx.active, second_hex);
|
||||
assert_eq!(active.npub, second.npub);
|
||||
let legacy_on_disk = NostrIdentity::load(&dir).unwrap();
|
||||
assert_eq!(legacy_on_disk.npub, legacy.npub);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_index_active_falls_back_to_legacy() {
|
||||
let dir = tmpdir("fallback");
|
||||
let (legacy, _) = NostrIdentity::create_random("pw").unwrap();
|
||||
legacy.save(&dir).unwrap();
|
||||
let legacy_hex = legacy.pubkey_hex().unwrap();
|
||||
// Index points active at an identity whose file does not exist.
|
||||
let idx = HeldIdentities {
|
||||
ver: 1,
|
||||
active: "00ff00ff".repeat(8),
|
||||
order: vec![legacy_hex.clone()],
|
||||
identities: vec![HeldEntry {
|
||||
pubkey: legacy_hex.clone(),
|
||||
path: "identity.json".to_string(),
|
||||
label: "Primary".to_string(),
|
||||
}],
|
||||
};
|
||||
idx.save(&dir).unwrap();
|
||||
let (repaired, active) = HeldIdentities::load_or_migrate(&dir, &legacy).unwrap();
|
||||
// Falls back to identity #1 rather than leaving no running identity.
|
||||
assert_eq!(active.npub, legacy.npub);
|
||||
assert_eq!(repaired.active, legacy_hex);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reencrypt_all_moves_every_identity_to_new_password() {
|
||||
let dir = tmpdir("reencrypt");
|
||||
let (legacy, _) = NostrIdentity::create_random("old").unwrap();
|
||||
legacy.save(&dir).unwrap();
|
||||
let (mut idx, _) = HeldIdentities::load_or_migrate(&dir, &legacy).unwrap();
|
||||
let (second, _) = NostrIdentity::create_random("old").unwrap();
|
||||
idx.add(&dir, &second).unwrap();
|
||||
|
||||
idx.reencrypt_all(&dir, "old", "new").unwrap();
|
||||
for entry in &idx.identities {
|
||||
let id = entry.load(&dir).unwrap();
|
||||
assert!(id.unlock("new").is_ok(), "must open under the new password");
|
||||
assert!(id.unlock("old").is_err(), "must not open under the old one");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catchup_since_prefers_identity_then_wallet_then_now() {
|
||||
let lookback = 3 * 86_400;
|
||||
let now = 1_000_000;
|
||||
// Per-identity value wins: cover from when THIS identity last listened.
|
||||
assert_eq!(
|
||||
catchup_since(Some(500_000), Some(900_000), now, lookback),
|
||||
500_000 - lookback
|
||||
);
|
||||
// Falls back to the wallet-wide last connection when the identity has none.
|
||||
assert_eq!(
|
||||
catchup_since(None, Some(900_000), now, lookback),
|
||||
900_000 - lookback
|
||||
);
|
||||
// Falls back to now when nothing is known (never worse than a fresh start).
|
||||
assert_eq!(catchup_since(None, None, now, lookback), now - lookback);
|
||||
// Never negative.
|
||||
assert_eq!(catchup_since(Some(10), None, now, lookback), 0);
|
||||
}
|
||||
}
|
||||
@@ -146,6 +146,38 @@ impl NostrIdentity {
|
||||
let _ = fs::remove_file(Self::path(nostr_dir));
|
||||
}
|
||||
|
||||
/// Load an identity from an explicit file path — a member of the held
|
||||
/// identity set (see [`crate::nostr::identities`]), which stores each
|
||||
/// additional identity in its own `identities/<hex>/identity.json`.
|
||||
pub fn load_at(path: &PathBuf) -> Option<NostrIdentity> {
|
||||
let raw = fs::read_to_string(path).ok()?;
|
||||
serde_json::from_str(&raw).ok()
|
||||
}
|
||||
|
||||
/// Persist this identity to an explicit file path with owner-only (0600)
|
||||
/// permissions, creating (and 0700-restricting) the parent directory. Used
|
||||
/// by the held identity set for the non-legacy identities; the ncryptsec
|
||||
/// blob must never be world-readable.
|
||||
pub fn save_at(&self, path: &PathBuf) -> Result<(), IdentityError> {
|
||||
if let Some(dir) = path.parent() {
|
||||
fs::create_dir_all(dir)?;
|
||||
restrict_dir(&dir.to_path_buf());
|
||||
}
|
||||
let raw = serde_json::to_string_pretty(self)?;
|
||||
write_private(path, raw.as_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The identity's public key as lowercase hex — the stable id used to key it
|
||||
/// in the held-identity index and on disk. `None` if the stored npub is
|
||||
/// malformed (never expected for an identity we wrote).
|
||||
pub fn pubkey_hex(&self) -> Option<String> {
|
||||
use nostr_sdk::PublicKey;
|
||||
PublicKey::from_bech32(&self.npub)
|
||||
.ok()
|
||||
.map(|pk| pk.to_hex())
|
||||
}
|
||||
|
||||
/// Build an identity from already-unlocked keys under a (possibly
|
||||
/// different) password — used when importing a backup that was exported
|
||||
/// under another wallet's password.
|
||||
|
||||
@@ -31,6 +31,9 @@ pub use store::NostrStore;
|
||||
mod identity;
|
||||
pub use identity::{IdentitySource, NostrIdentity};
|
||||
|
||||
pub mod identities;
|
||||
pub use identities::{HeldError, HeldIdentities, MAX_IDENTITIES, catchup_since};
|
||||
|
||||
pub mod protocol;
|
||||
pub use protocol::*;
|
||||
|
||||
|
||||
@@ -276,6 +276,30 @@ impl NostrStore {
|
||||
let _ = writer.commit();
|
||||
}
|
||||
|
||||
/// Unix time this identity (by pubkey hex) was last the ACTIVE, live-listening
|
||||
/// identity. Held per identity in the one shared settings store so that a
|
||||
/// switch back to a dormant identity can catch up "since it last listened"
|
||||
/// rather than "since the wallet last connected". `None` for an identity that
|
||||
/// has never been active (fresh/imported), which the catch-up handles by
|
||||
/// falling back to the wallet-wide last connection.
|
||||
pub fn last_active_at(&self, pubkey_hex: &str) -> Option<i64> {
|
||||
let env = self.env.read().unwrap_or_else(|e| e.into_inner());
|
||||
let reader = env.read().unwrap();
|
||||
let key = format!("last_active_at:{pubkey_hex}");
|
||||
if let Ok(Some(Value::I64(v))) = self.settings.get(&reader, &key) {
|
||||
return Some(v);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn set_last_active_at(&self, pubkey_hex: &str, ts: i64) {
|
||||
let env = self.env.read().unwrap_or_else(|e| e.into_inner());
|
||||
let mut writer = env.write().unwrap();
|
||||
let key = format!("last_active_at:{pubkey_hex}");
|
||||
let _ = self.settings.put(&mut writer, &key, &Value::I64(ts));
|
||||
let _ = writer.commit();
|
||||
}
|
||||
|
||||
/// Unix time of the last contact-name re-verify sweep (persisted across
|
||||
/// restarts so a fresh launch only re-sweeps if it's been a while).
|
||||
pub fn last_name_sweep_at(&self) -> Option<i64> {
|
||||
|
||||
Reference in New Issue
Block a user