|
| 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(¤t_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 | +} |
0 commit comments