Skip to main content

m4a_keys/
lib.rs

1//! m4a's keystore layer. m4a CONSUMES keys; the crypto is `tesserax-keytree`.
2//!
3//! - **Standalone**: [`Authority::Own`] — m4a holds its own root, issues session capabilities
4//!   and signs letters itself.
5//! - **Under an orchestrator**: [`Authority::Delegated`] — m4a holds no root. Session keys live
6//!   with the orchestrator's keystore; m4a receives attenuated capabilities and signed letters
7//!   and only verifies them against public keys (shared per room rules).
8//!
9//! A session never signs as another: the signer takes the identity from the capability, and
10//! [`verify_letter`] binds the signature to the sender's nick and public key.
11
12use std::collections::BTreeSet;
13
14pub use tesserax_keytree as keytree;
15
16/// m4a grants (namespace `m4a`).
17pub mod grants {
18    pub const SEND: &str = "m4a.send_as";
19    pub const RECV: &str = "m4a.recv_inbox";
20    /// Earlier un-namespaced names, accepted as aliases.
21    pub const LEGACY: &[(&str, &str)] = &[("send_as", SEND), ("recv_inbox", RECV)];
22
23    pub fn canonical(name: &str) -> &str {
24        LEGACY.iter().find(|(l, _)| *l == name).map(|(_, g)| *g).unwrap_or(name)
25    }
26    /// Op name inside signed letters (the legacy name where one exists, for wire compatibility).
27    pub fn wire(name: &str) -> &str {
28        let g = canonical(name);
29        LEGACY.iter().find(|(_, x)| *x == g).map(|(l, _)| *l).unwrap_or(g)
30    }
31    pub fn is_m4a(name: &str) -> bool {
32        canonical(name).starts_with("m4a.")
33    }
34}
35
36/// Environment names a session process sees (contract with the node; never renamed).
37pub mod env {
38    pub const SESSION_HANDLE: &str = "M4A_SESSION_HANDLE";
39    pub const SIGNER_SOCK: &str = "M4A_SIGNER_SOCK";
40}
41
42/// Who is the root of m4a's keys.
43pub enum Authority {
44    /// Standalone: m4a's own root.
45    Own(keytree::Authority),
46    /// Under an orchestrator: no root here; keys and capabilities are delegated.
47    Delegated,
48}
49
50impl Authority {
51    pub fn standalone() -> Self {
52        Self::Own(keytree::Authority::generate())
53    }
54    pub fn is_delegated(&self) -> bool {
55        matches!(self, Self::Delegated)
56    }
57
58    /// Standalone only: capability for a session nick with m4a grants.
59    pub fn issue_session(&self, nick: &str, grants: &BTreeSet<String>) -> Result<keytree::Issued, keytree::Error> {
60        let Self::Own(root) = self else { return Err(keytree::Error::Denied) };
61        if !grants.iter().all(|g| grants::is_m4a(g)) {
62            return Err(keytree::Error::Denied);
63        }
64        let g: BTreeSet<String> = grants.iter().map(|g| grants::canonical(g).to_owned()).collect();
65        root.issue(&[("nick", nick)], nick, &g)
66    }
67
68    /// Standalone only: the nick a capability grants `op` to.
69    pub fn verify_session(&self, token: &str, op: &str) -> Result<String, keytree::Error> {
70        let Self::Own(root) = self else { return Err(keytree::Error::Denied) };
71        let v = root.verify(token, grants::canonical(op), "nick", &[])?;
72        v.facts.get("nick").cloned().ok_or(keytree::Error::BadToken)
73    }
74}
75
76/// Signed letter bytes: `domain\0nick\0op\0payload`. `domain` comes from the deployment.
77pub fn letter_message(domain: &[u8], nick: &str, op: &str, payload: &[u8]) -> Vec<u8> {
78    keytree::domain_message(domain, &[nick.as_bytes(), grants::wire(op).as_bytes(), payload])
79}
80
81/// Check a letter signed by a session key (own or delegated mode alike).
82pub fn verify_letter(domain: &[u8], public_key: &[u8; 32], nick: &str, op: &str, payload: &[u8], signature: &[u8; 64]) -> bool {
83    keytree::verify_signature(public_key, &letter_message(domain, nick, op, payload), signature)
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    const DOMAIN: &[u8] = b"test-domain/v1";
91
92    #[test]
93    fn letters_bind_nick_and_op() {
94        let h = keytree::Secret::random().signing_handle();
95        let sig = h.sign(&letter_message(DOMAIN, "a-1", grants::SEND, b"hi"));
96        assert!(verify_letter(DOMAIN, &h.public_key(), "a-1", "send_as", b"hi", &sig));
97        assert!(!verify_letter(DOMAIN, &h.public_key(), "a-2", grants::SEND, b"hi", &sig));
98        assert!(!verify_letter(b"other", &h.public_key(), "a-1", grants::SEND, b"hi", &sig));
99    }
100
101    #[test]
102    fn modes() {
103        let own = Authority::standalone();
104        let g: BTreeSet<String> = [grants::SEND.to_owned()].into();
105        let t = own.issue_session("a-1", &g).unwrap();
106        assert_eq!(own.verify_session(&t.token, "send_as").unwrap(), "a-1");
107        assert!(own.verify_session(&t.token, grants::RECV).is_err());
108        assert!(own.issue_session("a-1", &["node.uplink".to_owned()].into()).is_err());
109        let del = Authority::Delegated;
110        assert!(del.issue_session("a-1", &g).is_err(), "no root when delegated");
111    }
112}