Files
figcraft/apps/figcraft-server/src/codes.rs
T
bflatastic 6720ea4a21 M9: share with a join code: online through the built-in server or on the local network
The Share popover is one flow: Online (the file is published to the server in
Preferences and the server names its links with short codes) or Local network
(the app hosts; the code carries its address and the session's secret), one
Start sharing button, the code large with Copy code and Copy link, who is here,
can edit / can view, and Stop sharing. Join with a code is one field in the
popover, on the home screen and in the Join dialog, for a code of either kind
or an invite link.

Protocol (within version 2, new variants last): Resolve / Resolved and Admin
code. The server keeps a stretched hash and the wrapped token, never the code,
and limits wrong codes per address (with a lockout) and for everyone. Older
servers leave the links in place of a code; older apps join by link.

collab.share, collab.join {code}, code and share in collab.peers; the server
preference defaults to the built-in server and can be reset or turned off.
2026-10-12 19:42:28 +07:00

87 lines
3.4 KiB
Rust

//! Join codes on the server: a short name for one share link, typed in place of the link.
//!
//! A code is 45 random bits ([`figcraft_collab::code`]). Like a link's token it is never stored:
//! a link keeps the code's hash, to find the link by, and its own token wrapped with the code, to
//! hand to whoever types the code (they then join as with the link). Both come from the code
//! stretched with SHA-256 ([`STRETCH`] rounds), so a copy of the data directory doesn't give the
//! codes away cheaply; guessing against a running server is held back by the limits on wrong codes
//! (`abuse.rs`).
use figcraft_collab::{code, same_secret};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
/// Rounds of SHA-256 a code goes through before it is compared or used as a key.
pub const STRETCH: u32 = 10_000;
// A copy of the data directory mustn't give codes away cheaply.
const _: () = assert!(STRETCH >= 10_000);
/// Longest token a code can wrap (one SHA-256 output).
const MAX_TOKEN: usize = 32;
/// What a link keeps of its join code (`links.json`).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Sealed {
/// The stretched code's hash, hex: what a typed code is looked up by.
pub hash: String,
/// The link's token, wrapped with the stretched code (hex).
pub token: String,
}
/// A typed code, stretched: ready to look links up with (what it is found by, and the pad its
/// link's token is wrapped with).
pub struct Key {
hash: String,
pad: [u8; 32],
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
impl Key {
/// Stretch `code` (its symbols, as [`code::normalize`] gives them).
pub fn new(code: &str) -> Key {
let mut k: [u8; 32] = Sha256::new().chain_update(b"figcraft join code\0").chain_update(code.as_bytes()).finalize().into();
for _ in 0..STRETCH {
k = Sha256::digest(k).into();
}
let part = |what: &[u8]| -> [u8; 32] { Sha256::new().chain_update(what).chain_update(k).finalize().into() };
Key { hash: hex(&part(b"find\0")), pad: part(b"wrap\0") }
}
/// What a link with this code keeps as [`Sealed::hash`].
pub fn hash(&self) -> &str {
&self.hash
}
/// Wrap or unwrap `bytes` (the same operation: XOR with a pad only this code gives).
fn wrap(&self, bytes: &[u8]) -> Vec<u8> {
bytes.iter().zip(self.pad).map(|(b, p)| b ^ p).collect()
}
/// Keep `token` under this code.
pub fn seal(&self, token: &str) -> Result<Sealed, String> {
if token.len() > MAX_TOKEN {
return Err("this link's token is too long for a join code".into());
}
Ok(Sealed { hash: self.hash.clone(), token: hex(&self.wrap(token.as_bytes())) })
}
/// The token `sealed` keeps, when this is its code (compared in constant time).
pub fn open(&self, sealed: &Sealed) -> Option<String> {
if !same_secret(&self.hash, &sealed.hash) || sealed.token.len() > 2 * MAX_TOKEN {
return None;
}
let wrapped: Option<Vec<u8>> =
(0..sealed.token.len() / 2).map(|i| sealed.token.get(2 * i..2 * i + 2).and_then(|h| u8::from_str_radix(h, 16).ok())).collect();
String::from_utf8(self.wrap(&wrapped?)).ok()
}
}
/// A new code's symbols, from the operating system's random numbers.
pub fn new_code() -> Result<String, String> {
figcraft_collab_net::token::random().map(code::online)
}