Docs

api-auth

Source: crates/lvcs-api/src/auth.rs.

/// The first line the daemon sends on a host peer stream (decision 300):
/// the push `challenge`. A device's `hello` signs it ([`hello_message`]),
/// so its proof can't be replayed on another stream or to another host.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChallengePush {
    /// A fresh nonce, lowercase hex.
    pub nonce: String,
    /// The host peer's id.
    pub host_peer: String,
}

/// A device's proof of its key in `hello` on a host peer stream
/// (decision 300): `key` (`ID_ENCODING`) and its Ed25519 `signature`
/// (lowercase hex) over [`hello_message`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct DeviceProof {
    pub key: String,
    pub signature: String,
}

/// What a device signs in `hello`: `HELLO_PROOF_CONTEXT`, the challenge's
/// `nonce`, the `host_peer` id, and the `root` the `hello` names (empty
/// for none), one per line.
#[must_use]
pub fn hello_message(nonce: &str, host_peer: &str, root: Option<&str>) -> String {
    format!(
        "{HELLO_PROOF_CONTEXT}\n{nonce}\n{host_peer}\n{}",
        root.unwrap_or("")
    )
}

/// What a pairing link carries after `#pair=` (decision 300), as
/// unpadded base64url of its JSON: the host peer to dial, the project
/// root to name in `hello`, and the pairing's nonce for `auth.pair`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PairLink {
    /// The link's version, 1.
    pub v: u8,
    /// The host peer's id.
    pub peer: String,
    /// The host peer's WebRTC-direct addresses, each ending in
    /// `/p2p/<peer>`.
    pub addrs: Vec<String>,
    /// The project root.
    pub root: String,
    /// The project's id, which the tracker finds the host peer by
    /// (decision 327).
    pub project: String,
    /// The tracker the host peer announces to, for a browser that can
    /// reach none of `addrs`; none when it announces to none.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tracker: Option<String>,
    /// The STUN servers the host peer asks, which the browser asks too.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub stun: Vec<String>,
    /// The pairing's nonce.
    pub nonce: String,
}

/// `auth.login` (decision 300): start pairing a device for the caller.
/// The device gets `read` on `read` (every path when empty), and with
/// `write` also `message`, so it may coordinate (ask, answer, hold, say,
/// run), never edit; it signs those records itself (the push `sign`).
/// `alias` names the device (decision 330): only the owner gives one, it
/// is shaped as `grant`'s alias is, and one already granted to any
/// participant or device, revoked or not, is refused `INVALID_PARAMS`
/// with a free alias in `next`. Answered at once with the nonce; the
/// pairing expires after `PAIRING_TTL_SECS`.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct AuthLoginParams {
    #[serde(default, skip_serializing_if = "core::ops::Not::not")]
    pub write: bool,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub read: Vec<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub alias: Option<String>,
}

/// What a public link carries after `#` (decision 337): `p=<project>@<peer>`,
/// the project's id and the host peer that serves it, then `&t=<tracker>`
/// when its tracker isn't `TRACKER_URL`, and `&s=<stun>,...` when its STUN
/// servers aren't `STUN_URLS`. Anyone who opens it reads the project
/// anonymously, through that tracker, as a `read` grant to anyone allows;
/// the host peer's id pins the daemon whose signed answer the browser
/// takes, so a peer that announces the same project id serves it nothing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadLink {
    /// The project's id.
    pub project: String,
    /// The host peer's id.
    pub peer: String,
    /// The tracker the host peer announces to.
    pub tracker: String,
    /// The STUN servers it asks.
    pub stun: Vec<String>,
}

impl ReadLink {
    /// The link's fragment, without its `#`.
    #[must_use]
    pub fn fragment(&self) -> String {
        let mut out = format!("p={}@{}", self.project, self.peer);
        if self.tracker != TRACKER_URL {
            out.push_str("&t=");
            out.push_str(&self.tracker);
        }
        if self.stun.join(",") != STUN_URLS {
            out.push_str("&s=");
            out.push_str(&self.stun.join(","));
        }
        out
    }

    /// The link in the fragment `fragment` (with or without its `#`);
    /// `None` when it is no public link.
    #[must_use]
    pub fn parse(fragment: &str) -> Option<Self> {
        let fragment = fragment.strip_prefix('#').unwrap_or(fragment);
        let mut link: Option<Self> = None;
        let mut tracker = None;
        let mut stun = None;
        for part in fragment.split('&') {
            match part.split_once('=')? {
                ("p", value) => {
                    let (project, peer) = value.split_once('@')?;
                    let valid = |text: &str| {
                        !text.is_empty() && text.bytes().all(|b| b.is_ascii_alphanumeric())
                    };
                    if !valid(project) || !valid(peer) {
                        return None;
                    }
                    link = Some(Self {
                        project: project.to_owned(),
                        peer: peer.to_owned(),
                        tracker: TRACKER_URL.to_owned(),
                        stun: Vec::new(),
                    });
                }
                ("t", value) => tracker = Some(value.to_owned()),
                ("s", value) => stun = Some(value.to_owned()),
                _ => {}
            }
        }
        let mut link = link?;
        if let Some(tracker) = tracker {
            link.tracker = tracker;
        }
        link.stun = stun
            .as_deref()
            .unwrap_or(STUN_URLS)
            .split(',')
            .filter(|url| !url.is_empty())
            .map(str::to_owned)
            .collect();
        Some(link)
    }
}

/// `auth.login` result. `lvcs auth login` builds the link to open from
/// it and the host's `status`: `APP_URL`, then `#pair=` and the
/// [`PairLink`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthLoginResult {
    /// The pairing's nonce, for `auth.wait`, `auth.confirm`, and the link.
    pub nonce: String,
    /// When the pairing expires, in Unix milliseconds.
    pub expires_ms: u64,
}

/// `auth.wait` (decision 300): wait for a device to claim pairing
/// `nonce`; answered then, or refused once it expired.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct AuthWaitParams {
    pub nonce: String,
}

/// `auth.wait` result: the device that claimed the pairing.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthWaitResult {
    /// Its key, `ID_ENCODING`.
    pub device: String,
    /// The name it gave.
    pub name: String,
    /// The code it shows: confirm only when the two match.
    pub code: String,
    /// The alias it gets unless `auth.confirm` names another: the login's
    /// `alias`, else `<name>-<n>`; absent when the login's participant
    /// isn't the owner, which gives no alias.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub alias: Option<String>,
}

/// `auth.confirm` (decision 300): accept (`accept`) or decline the
/// device that claimed pairing `nonce`. Accepting signs its device grant
/// as the caller, under a permanent alias when the caller is the owner:
/// the login's `alias`, else this `alias` (decision 330, checked as
/// `auth.login` checks it, and a refused one leaves the pairing to
/// confirm again), else `<name>-<n>`; answered as `grant` is
/// (`GrantResult`). Declining answers `{}`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct AuthConfirmParams {
    pub nonce: String,
    pub accept: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub alias: Option<String>,
}

/// `auth.pair`'s result once the device's grant renders.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthPaired {
    /// The device grant's id.
    pub grant: String,
    /// The device's key, `ID_ENCODING`.
    pub device: String,
    /// Its alias, absent when the issuer isn't the owner.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub alias: Option<String>,
    /// Whether it may coordinate.
    pub write: bool,
    /// The paths it reads.
    pub read: Vec<String>,
}

/// `auth.pair` (decision 300): the device whose `hello` proved its key
/// claims pairing `nonce`, under `name` (at most `DEVICE_NAME_MAX_BYTES`
/// of lowercase letters, digits, and `-`). Answered once the pairing is
/// confirmed, refused when it is declined or expires; meanwhile the push
/// `pairing` gives the code to show.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct AuthPairParams {
    pub nonce: String,
    pub name: String,
}

/// The push `pairing` (decision 300): the code a claiming device shows,
/// the one `lvcs auth login` shows in the terminal.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PairingPush {
    pub nonce: String,
    pub code: String,
}

/// The push `sign` (decision 300): a write device's coordination record
/// (a thread change or a gossip message) is signed by the device itself,
/// since the daemon holds no device key. The device answers with
/// `auth.sign`, its Ed25519 signature over `input` (lowercase hex bytes);
/// a record whose signature never comes is never published.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SignPush {
    /// The signing request's id, for `auth.sign`.
    pub request: u64,
    /// What to sign, lowercase hex.
    pub input: String,
}

/// `auth.sign` (decision 300): the device's `signature` (lowercase hex)
/// over the input of the push `sign` with id `request`. Answered `{}`;
/// refused when no such request waits, or the signature isn't the
/// device's over it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct AuthSignParams {
    pub request: u64,
    pub signature: String,
}

/// `auth.status` (decision 300): the caller's paired devices.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct AuthStatusParams {}

/// `auth.status` result.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthStatusResult {
    #[serde(default)]
    pub devices: Vec<PairedDevice>,
}

/// One live device grant the caller issued.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PairedDevice {
    /// The grant's id.
    pub grant: String,
    /// The device's key, `ID_ENCODING`.
    pub device: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub alias: Option<String>,
    pub write: bool,
    pub read: Vec<String>,
    /// When it was paired, in Unix seconds.
    pub paired_s: i64,
    /// When this daemon last saw it on a host peer stream since it
    /// started, in Unix milliseconds.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_seen_ms: Option<u64>,
}

/// `auth.logout` (decision 300): revoke the device `device` (its alias, or
/// its key or a prefix of it) and every grant of its lineage, as
/// `revoke` does; its open streams close. Answered with `RevokeResult`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct AuthLogoutParams {
    pub device: String,
}