Skip to content

Commit 1d54a52

Browse files
committed
Add getChildren + account migrate/mirror methods to UserContext
- UserContext::get_children(path): resolve a path and return its directory children (get_by_path already existed). - mirror_login_data (mirrorLoginData): re-derive login keys from the password, re-encrypt the entry points, and POST them to login/setLogin with local=true so this server can serve logins after a migration. - mirror_on_this_server (mirrorOnThisServer, unpaid path): POST core/mirror with the mirror BAT, a signed timestamp and a MIN_DIFFICULTY proof-of-work. - migrate_to_this_server (migrateToThisServer): core/getChain -> append a claim naming this server as storage provider (Migrate.buildMigrationChain) -> core/migrateUser; returns the raw UserSnapshot cbor. New migrate.rs holds the core-node wire helpers (getChain/mirror/migrateUser) and the claim-chain building + date arithmetic (unit-tested). Reuses signup's length-prefixed framing. Not yet exercised against a live server.
1 parent a7002f1 commit 1d54a52

5 files changed

Lines changed: 304 additions & 2 deletions

File tree

crates/peergos-fs/src/context.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,67 @@ impl UserContext {
428428
dir.get_by_path(&comps[matched..].join("/")).await
429429
}
430430

431+
/// The children of the directory at `path` (`getChildren`). Empty if the path
432+
/// doesn't resolve, or resolves to a file.
433+
pub async fn get_children(&self, path: &str) -> Result<Vec<FileWrapper>> {
434+
match self.get_by_path(path).await? {
435+
Some(dir) if dir.is_directory() => dir.children().await,
436+
_ => Ok(Vec::new()),
437+
}
438+
}
439+
440+
/// Mirror this account's login data onto the current server so it can serve
441+
/// logins after a migration (`mirrorLoginData`). Non-legacy accounts only.
442+
pub async fn mirror_login_data(&self, password: &str, mfa: Option<&MfaResponder<'_>>) -> Result<bool> {
443+
let user = self.require_user()?;
444+
crate::login::mirror_login_data(
445+
&user.username,
446+
password,
447+
&user.signer,
448+
mfa,
449+
self.poster.as_ref(),
450+
self.store.clone(),
451+
self.mutable.as_ref(),
452+
)
453+
.await
454+
}
455+
456+
/// Ask the current server to mirror this account's data, authorised by a signed
457+
/// timestamp + proof-of-work (`mirrorOnThisServer`, unpaid path). Requires a
458+
/// mirror BAT.
459+
pub async fn mirror_on_this_server(&self) -> Result<bool> {
460+
let user = self.require_user()?;
461+
let mirror_bat = user.mirror_bat.clone().ok_or_else(|| Error::Protocol("You need a mirror bat!".into()))?;
462+
crate::migrate::start_mirror(self.poster.as_ref(), &user.username, &mirror_bat, &user.signer).await
463+
}
464+
465+
/// Migrate this account's home server to the current server
466+
/// (`migrateToThisServer`): fetch the username claim chain, append a link naming
467+
/// this server as the storage provider, and commit it. Returns the raw
468+
/// `UserSnapshot` cbor the server returns. `password`/`mfa` are accepted for
469+
/// signature parity with the Java API (the current session's identity signer is
470+
/// used to sign the new claim).
471+
pub async fn migrate_to_this_server(&self, _password: &str, _mfa: Option<&MfaResponder<'_>>) -> Result<CborObject> {
472+
let user = self.require_user()?;
473+
let existing = crate::migrate::get_chain(self.poster.as_ref(), &user.username).await?;
474+
let last = existing.last().ok_or_else(|| Error::Protocol("empty claim chain".into()))?;
475+
let original_node_id = crate::migrate::claim_storage_provider(last)?;
476+
let usage = self.get_usage().await?;
477+
let this_server = self.store.id().await?;
478+
let new_chain = crate::migrate::build_migration_chain(&existing, &this_server, &user.signer.secret)?;
479+
let now_secs = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0);
480+
crate::migrate::migrate_user(
481+
self.poster.as_ref(),
482+
&user.username,
483+
&new_chain,
484+
&original_node_id,
485+
user.mirror_bat.as_ref(),
486+
now_secs,
487+
usage,
488+
)
489+
.await
490+
}
491+
431492
/// The user's mirror BAT (`getMirrorBat`), fetched from the server's bats
432493
/// endpoint and authorised by a time-limited signed request. `None` if the
433494
/// account has no registered BAT. Used to keep secret-link data private.

crates/peergos-fs/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ pub mod incoming;
1717
pub mod login;
1818
pub mod messaging;
1919
pub mod mfa;
20+
pub mod migrate;
2021
pub mod mimetype;
2122
pub mod profile;
2223
pub mod publish;

crates/peergos-fs/src/login.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,47 @@ pub async fn change_password(
370370
Ok(())
371371
}
372372

373+
/// Copy the user's login data onto *this* server (`UserContext.mirrorLoginData`),
374+
/// so it can serve logins if the user later migrates here. Re-derives the login
375+
/// keypair + root from the password (same salt), fetches the current entry points,
376+
/// re-encrypts them under the root, signs the login data with the identity
377+
/// `signer`, and POSTs it to `setLogin` with `local=true` (mirror). Non-legacy
378+
/// accounts only.
379+
pub async fn mirror_login_data(
380+
username: &str,
381+
password: &str,
382+
signer: &SigningPrivateKeyAndPublicHash,
383+
mfa: Option<&MfaResponder<'_>>,
384+
poster: &dyn HttpPoster,
385+
store: Arc<dyn ContentAddressedStorage>,
386+
mutable: &dyn MutablePointers,
387+
) -> Result<bool> {
388+
let owner = get_public_key_hash(poster, username)
389+
.await?
390+
.ok_or_else(|| Error::Protocol(format!("Unknown username: {username}")))?;
391+
let pointer = mutable.get_pointer_target(&owner, &owner, store.as_ref()).await?;
392+
let wd_cid = pointer.updated.ok_or_else(|| Error::Protocol("User has been deleted".into()))?;
393+
let wd = store.get(&owner, &wd_cid, None).await?.ok_or_else(|| Error::Protocol("writer data block missing".into()))?;
394+
if wd.get("static").is_some() {
395+
return Err(Error::Protocol("Legacy accounts do not have login data, change your password to upgrade your account.".into()));
396+
}
397+
let algo = ScryptParams::from_writer_data(&wd)?;
398+
399+
let creds = generate_user(username, password, &algo)?;
400+
let entry_points_cbor = get_login_data(poster, username, &creds, mfa).await?;
401+
let new_static =
402+
crate::cryptree::PaddedCipherText::build(&creds.root, &entry_points_cbor, USER_STATIC_DATA_PADDING)?.to_cbor();
403+
let login_data = CborObject::map()
404+
.put("u", CborObject::Str(username.to_string()))
405+
.put("e", new_static)
406+
.put("r", creds.login_pub.to_cbor())
407+
.build();
408+
let auth = to_hex(&signer.secret.signature_only(&login_data.to_bytes())?);
409+
let url = format!("{LOGIN_URL}setLogin?username={username}&auth={auth}&local=true");
410+
let res = poster.post_unzip(&url, login_data.to_bytes(), 0).await?;
411+
Ok(res.first().copied() == Some(1))
412+
}
413+
373414
/// Decrypt a `UserStaticData` (PaddedCipherText) to its `EntryPoints` cbor.
374415
fn decrypt_entry_points(static_data: &CborObject, root: &SymmetricKey) -> Result<CborObject> {
375416
crate::cryptree::PaddedCipherText::from_cbor(static_data)?

crates/peergos-fs/src/migrate.rs

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
//! Account migration + mirroring, ported from the corresponding
2+
//! `peergos.shared.user.UserContext` methods and the `HTTPCoreNode` /
3+
//! `HttpAccount` wire protocol:
4+
//!
5+
//! - [`start_mirror`] (`mirrorOnThisServer`): ask this server to mirror the
6+
//! user's data, authorised by a signed timestamp + proof-of-work.
7+
//! - [`get_chain`] + [`build_migration_chain`] + [`migrate_user`]
8+
//! (`migrateToThisServer`): fetch the username claim chain, append a new link
9+
//! naming this server as the storage provider, and commit it on this server.
10+
//!
11+
//! `mirrorLoginData` lives in [`crate::login`] since it reuses the login helpers.
12+
13+
use crate::signup::{serialize_bytes, serialize_string};
14+
use peergos_cbor::{Cborable, CborObject};
15+
use peergos_core::auth::BatWithId;
16+
use peergos_core::error::{Error, Result};
17+
use peergos_core::keys::{SecretSigningKey, SigningPrivateKeyAndPublicHash};
18+
use peergos_core::HttpPoster;
19+
use peergos_multiformats::Cid;
20+
21+
const CORE_URL: &str = "peergos/v0/core/";
22+
23+
/// `HTTPCoreNode.getChain`: fetch the username's public-key-link claim chain. Each
24+
/// returned value is a `UserPublicKeyLink` cbor map `{owner, claim}`.
25+
pub async fn get_chain(poster: &dyn HttpPoster, username: &str) -> Result<Vec<CborObject>> {
26+
let mut body = Vec::new();
27+
serialize_string(&mut body, username);
28+
let res = poster.post_unzip(&format!("{CORE_URL}getChain"), body, 0).await?;
29+
match CborObject::from_bytes(&res)? {
30+
CborObject::List(items) => Ok(items),
31+
other => Err(Error::Cbor(format!("Invalid cbor for claim chain: {other:?}"))),
32+
}
33+
}
34+
35+
/// `HTTPCoreNode.startMirror` (via `UserContext.mirrorOnThisServer`, unpaid path):
36+
/// POST `core/mirror` with the username, mirror BAT, a signed timestamp and a
37+
/// proof-of-work. Returns the server's boolean acknowledgement.
38+
pub async fn start_mirror(
39+
poster: &dyn HttpPoster,
40+
username: &str,
41+
mirror_bat: &BatWithId,
42+
signer: &SigningPrivateKeyAndPublicHash,
43+
) -> Result<bool> {
44+
let auth = sign_now(&signer.secret)?;
45+
// ProofOfWork.MIN_DIFFICULTY is 0, so this is a trivial proof over the username.
46+
let prefix = peergos_crypto::hash::generate_proof_of_work(0, username.as_bytes());
47+
let proof = CborObject::map()
48+
.put("prefix", CborObject::ByteString(prefix))
49+
.put("type", CborObject::Long(0x12)) // sha2-256
50+
.build();
51+
52+
let mut body = Vec::new();
53+
serialize_string(&mut body, username);
54+
serialize_bytes(&mut body, &mirror_bat.serialize());
55+
serialize_bytes(&mut body, &auth);
56+
serialize_bytes(&mut body, &proof.to_bytes());
57+
let res = poster.post_unzip(&format!("{CORE_URL}mirror"), body, 0).await?;
58+
Ok(res.first().copied() == Some(1))
59+
}
60+
61+
/// `HTTPCoreNode.migrateUser`: commit `new_chain` on this server, naming it as the
62+
/// user's storage provider. `original_node_id` is the previous home server (whose
63+
/// data is being migrated). Returns the raw `UserSnapshot` cbor.
64+
#[allow(clippy::too_many_arguments)]
65+
pub async fn migrate_user(
66+
poster: &dyn HttpPoster,
67+
username: &str,
68+
new_chain: &[CborObject],
69+
original_node_id: &Cid,
70+
mirror_bat: Option<&BatWithId>,
71+
latest_link_count_update_epoch_secs: i64,
72+
current_usage: i64,
73+
) -> Result<CborObject> {
74+
let mut body = Vec::new();
75+
serialize_string(&mut body, username);
76+
serialize_bytes(&mut body, &CborObject::List(new_chain.to_vec()).to_bytes());
77+
serialize_bytes(&mut body, &original_node_id.to_bytes());
78+
body.push(if mirror_bat.is_some() { 1 } else { 0 });
79+
if let Some(bat) = mirror_bat {
80+
serialize_bytes(&mut body, &bat.serialize());
81+
}
82+
body.extend_from_slice(&latest_link_count_update_epoch_secs.to_be_bytes());
83+
body.extend_from_slice(&current_usage.to_be_bytes());
84+
body.push(1); // commitToPki
85+
let res = poster.post_unzip(&format!("{CORE_URL}migrateUser"), body, -1).await?;
86+
Ok(CborObject::from_bytes(&res)?)
87+
}
88+
89+
/// `Migrate.buildMigrationChain`: replace the last link's claim with a new one that
90+
/// names `new_storage_id` as the sole storage provider, expiring one day later,
91+
/// signed by the identity key. Earlier links are unchanged.
92+
pub fn build_migration_chain(
93+
existing: &[CborObject],
94+
new_storage_id: &Cid,
95+
signer: &SecretSigningKey,
96+
) -> Result<Vec<CborObject>> {
97+
let last = existing.last().ok_or_else(|| Error::Protocol("empty claim chain".into()))?;
98+
let owner = last.get("owner").ok_or_else(|| Error::Cbor("chain link missing 'owner'".into()))?.clone();
99+
let claim = last
100+
.get("claim")
101+
.and_then(|c| c.as_list())
102+
.ok_or_else(|| Error::Cbor("chain link missing 'claim'".into()))?;
103+
let username = claim.first().and_then(|c| c.as_string()).ok_or_else(|| Error::Cbor("claim missing username".into()))?;
104+
let expiry = claim.get(1).and_then(|c| c.as_string()).ok_or_else(|| Error::Cbor("claim missing expiry".into()))?;
105+
let new_expiry = date_plus_days(expiry, 1)?;
106+
107+
// Claim.build signed payload: serialize(username) + serialize(expiry) +
108+
// writeInt(providerCount) + serialize(provider) for each provider.
109+
let mut payload = Vec::new();
110+
serialize_string(&mut payload, username);
111+
serialize_string(&mut payload, &new_expiry);
112+
payload.extend_from_slice(&1u32.to_be_bytes());
113+
serialize_bytes(&mut payload, &new_storage_id.to_bytes());
114+
let signed = signer.sign_message(&payload)?;
115+
116+
let new_claim = CborObject::List(vec![
117+
CborObject::Str(username.to_string()),
118+
CborObject::Str(new_expiry),
119+
CborObject::List(vec![CborObject::ByteString(new_storage_id.to_bytes())]),
120+
CborObject::ByteString(signed),
121+
]);
122+
let updated_last = CborObject::map().put("owner", owner).put("claim", new_claim).build();
123+
124+
let mut chain: Vec<CborObject> = existing[..existing.len() - 1].to_vec();
125+
chain.push(updated_last);
126+
Ok(chain)
127+
}
128+
129+
/// The first storage-provider id in a chain link's claim (`claim.storageProviders`).
130+
pub fn claim_storage_provider(link: &CborObject) -> Result<Cid> {
131+
let claim = link
132+
.get("claim")
133+
.and_then(|c| c.as_list())
134+
.ok_or_else(|| Error::Cbor("chain link missing 'claim'".into()))?;
135+
let providers = claim.get(2).and_then(|c| c.as_list()).ok_or_else(|| Error::Cbor("claim missing storage providers".into()))?;
136+
let bytes = providers.first().and_then(|c| c.as_bytes()).ok_or_else(|| Error::Cbor("no storage provider in claim".into()))?;
137+
Ok(Cid::cast(bytes)?)
138+
}
139+
140+
/// `TimeLimitedClient.signNow`: sign `cbor(currentTimeMillis)`, returning the raw
141+
/// NaCl attached signature bytes.
142+
fn sign_now(secret: &SecretSigningKey) -> Result<Vec<u8>> {
143+
let now = std::time::SystemTime::now()
144+
.duration_since(std::time::UNIX_EPOCH)
145+
.map(|d| d.as_millis() as i64)
146+
.unwrap_or(0);
147+
secret.sign_message(&CborObject::Long(now).to_bytes())
148+
}
149+
150+
// ---- date arithmetic on ISO `YYYY-MM-DD` claim expiries --------------------
151+
152+
/// `LocalDate.plusDays` on an ISO date string.
153+
fn date_plus_days(date: &str, days: i64) -> Result<String> {
154+
let d = date_to_epoch_days(date).ok_or_else(|| Error::Protocol(format!("invalid claim expiry date: {date}")))?;
155+
Ok(epoch_days_to_date(d + days))
156+
}
157+
158+
/// ISO `YYYY-MM-DD` → days since the Unix epoch (Howard Hinnant `days_from_civil`).
159+
fn date_to_epoch_days(date: &str) -> Option<i64> {
160+
let mut it = date.split('-');
161+
let y: i64 = it.next()?.parse().ok()?;
162+
let m: i64 = it.next()?.parse().ok()?;
163+
let d: i64 = it.next()?.parse().ok()?;
164+
let y = if m <= 2 { y - 1 } else { y };
165+
let era = (if y >= 0 { y } else { y - 399 }) / 400;
166+
let yoe = y - era * 400;
167+
let mp = if m > 2 { m - 3 } else { m + 9 };
168+
let doy = (153 * mp + 2) / 5 + d - 1;
169+
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
170+
Some(era * 146097 + doe - 719468)
171+
}
172+
173+
/// Days since the Unix epoch → ISO `YYYY-MM-DD` (`days_to_civil`).
174+
fn epoch_days_to_date(days: i64) -> String {
175+
let z = days + 719468;
176+
let era = (if z >= 0 { z } else { z - 146096 }) / 146097;
177+
let doe = z - era * 146097;
178+
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
179+
let y = yoe + era * 400;
180+
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
181+
let mp = (5 * doy + 2) / 153;
182+
let d = doy - (153 * mp + 2) / 5 + 1;
183+
let m = if mp < 10 { mp + 3 } else { mp - 9 };
184+
let y = y + if m <= 2 { 1 } else { 0 };
185+
format!("{y:04}-{m:02}-{d:02}")
186+
}
187+
188+
#[cfg(test)]
189+
mod tests {
190+
use super::*;
191+
192+
#[test]
193+
fn date_roundtrip_and_add() {
194+
assert_eq!(date_to_epoch_days("1970-01-01"), Some(0));
195+
assert_eq!(epoch_days_to_date(0), "1970-01-01");
196+
assert_eq!(date_plus_days("2024-02-28", 1).unwrap(), "2024-02-29"); // leap year
197+
assert_eq!(date_plus_days("2023-12-31", 1).unwrap(), "2024-01-01");
198+
}
199+
}

crates/peergos-fs/src/signup.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,13 +215,13 @@ impl MutablePointers for OpLogStore {
215215
// ---------------------------------------------------------------------------
216216

217217
/// `Serialize.serialize(byte[])`: 4-byte big-endian length prefix, then bytes.
218-
fn serialize_bytes(out: &mut Vec<u8>, b: &[u8]) {
218+
pub(crate) fn serialize_bytes(out: &mut Vec<u8>, b: &[u8]) {
219219
out.extend_from_slice(&(b.len() as u32).to_be_bytes());
220220
out.extend_from_slice(b);
221221
}
222222

223223
/// `Serialize.serialize(String)`: 4-byte big-endian char count, then UTF-8 bytes.
224-
fn serialize_string(out: &mut Vec<u8>, s: &str) {
224+
pub(crate) fn serialize_string(out: &mut Vec<u8>, s: &str) {
225225
out.extend_from_slice(&(s.chars().count() as u32).to_be_bytes());
226226
out.extend_from_slice(s.as_bytes());
227227
}

0 commit comments

Comments
 (0)