diff --git a/crates/rusty_esp_mid-core/src/settings_adoption.rs b/crates/rusty_esp_mid-core/src/settings_adoption.rs index 28b47fc..4ee83fe 100644 --- a/crates/rusty_esp_mid-core/src/settings_adoption.rs +++ b/crates/rusty_esp_mid-core/src/settings_adoption.rs @@ -184,6 +184,76 @@ fn stored_pin(identity: &I) -> Result> { } } +/// The adoption the identity store holds, as `/v1/status` presents it +/// ([`Stored::as_status`]). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Stored { + /// The owner the device accepted. + pub owner_did: String, + /// [`KIND_MATA_STATEMENT`] or [`crate::status::KIND_ADOPTION_V1`]. + pub kind: &'static str, + /// The stored bytes: the statement container, or the v1 envelope. + pub bytes: Vec, + /// The pin was taken on a structural check only ([`KV_PIN_PROVISIONAL`]). + pub provisional: bool, +} + +impl Stored { + /// As the status document takes it. + #[must_use] + pub fn as_status(&self) -> crate::status::Adoption<'_> { + crate::status::Adoption { + owner_did: &self.owner_did, + kind: self.kind, + bytes: &self.bytes, + } + } +} + +/// The adoption in `identity`, if it holds one for `my_did`: a statement +/// [`apply`] stored (its kind key says so), or a v1 envelope stored by the +/// mesh's adoption (no kind key). Bytes that parse as neither, or that name +/// another device, are not presented: `None`. +pub fn stored(identity: &I, my_did: &str) -> Result> { + let mut bytes = vec![0u8; ADOPT_MAX]; + let n = match identity.get(KV_ADOPTION, &mut bytes) { + Ok(Some(n)) => n, + Ok(None) | Err(Error::BufferTooSmall { .. }) => return Ok(None), + Err(e) => return Err(e), + }; + bytes.truncate(n); + let mut kind = [0u8; 32]; + let is_statement = match identity.get(KV_ADOPTION_KIND, &mut kind) { + Ok(Some(k)) => &kind[..k] == KIND_MATA_STATEMENT.as_bytes(), + Ok(None) => false, + Err(Error::BufferTooSmall { .. }) => return Ok(None), + Err(e) => return Err(e), + }; + let mut flag = [0u8; 1]; + let provisional = + matches!(identity.get(KV_PIN_PROVISIONAL, &mut flag), Ok(Some(1))) && flag[0] == 1; + let (owner_did, kind) = if is_statement { + match check_statement(&bytes, my_did) { + Ok(c) => (c.owner_did, KIND_MATA_STATEMENT), + Err(_) => return Ok(None), + } + } else { + match crate::adoption::Adoption::decode(&bytes) { + Ok(a) if a.fields.device_did == my_did => ( + String::from(a.fields.owner_did), + crate::status::KIND_ADOPTION_V1, + ), + _ => return Ok(None), + } + }; + Ok(Some(Stored { + owner_did, + kind, + bytes, + provisional, + })) +} + /// Take the settings' adoption into the identity store (see the module /// docs). `my_did` is this device's `did:mata`. pub fn apply( @@ -294,6 +364,56 @@ mod tests { (device, owner, other) } + #[test] + fn the_stored_adoption_is_read_back_for_the_status() { + let (device, owner, other) = setup(); + let (mut s, mut id) = (MemoryKv::new(), MemoryKv::new()); + assert_eq!(stored(&id, &device), Ok(None)); + + let c = container(&device, &owner, &owner, 1, STATEMENT_CONTEXT); + s.put(SETTINGS_ADOPT, &c).unwrap(); + apply(&mut s, &mut id, &device).unwrap(); + let got = stored(&id, &device).unwrap().unwrap(); + assert_eq!( + got, + Stored { + owner_did: owner.clone(), + kind: KIND_MATA_STATEMENT, + bytes: c, + provisional: true, + } + ); + assert_eq!(got.as_status().owner_did, owner); + // another device's store: not presented as ours + assert_eq!(stored(&id, &other), Ok(None)); + + // a v1 envelope, as the mesh stores it: no kind key, no provisional mark + let owner_key = DeviceKey::from_seed_for_tests("settings-adopt-owner", "o"); + let pubkey = *owner_key.did().pubkey(); + let fields = crate::adoption::AdoptionFields { + device_did: &device, + owner_did: &owner, + owner_genesis_pubkey: &pubkey, + hub_endpoint_id: &[0; 32], + hub_relay: "", + hub_host: "", + caps: crate::adoption::CapList::Slice(&[]), + roster_version: 1, + issued_at: 1_700_000_000, + expires_at: 0, + }; + let mut env = vec![0u8; fields.canonical_len() + 64]; + let n = fields.sign_into(&owner_key, &mut env).unwrap(); + env.truncate(n); + let mut id = MemoryKv::new(); + id.put(KV_ADOPTION, &env).unwrap(); + let got = stored(&id, &device).unwrap().unwrap(); + assert_eq!(got.kind, crate::status::KIND_ADOPTION_V1); + assert_eq!(got.owner_did, owner); + assert!(!got.provisional); + assert_eq!(got.bytes, env); + } + #[test] fn nothing_in_the_settings_is_nothing() { let (device, ..) = setup(); diff --git a/crates/rusty_esp_mid-core/src/status.rs b/crates/rusty_esp_mid-core/src/status.rs index 73460a2..5cabb2f 100644 --- a/crates/rusty_esp_mid-core/src/status.rs +++ b/crates/rusty_esp_mid-core/src/status.rs @@ -201,6 +201,123 @@ pub fn parse_nonce_hex(s: &str) -> Result<[u8; 32]> { Ok(out) } +#[cfg(feature = "alloc")] +pub use document::{Adoption, StatusDocument}; + +/// The `GET /v1/status` body (the OEM sidecar status contract, v1): the +/// discovery fields, and with a nonce the proof fields and the device's +/// signature over [`StatusProof`]. +#[cfg(feature = "alloc")] +mod document { + use alloc::string::{String, ToString}; + + use base64::Engine; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use serde_json::{Map, Value, json}; + + use super::StatusProof; + + /// The adoption a device holds, as it stored it. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub struct Adoption<'a> { + /// The owner the device accepted. + pub owner_did: &'a str, + /// [`super::KIND_MATA_STATEMENT`] or [`super::KIND_ADOPTION_V1`]. + pub kind: &'a str, + /// Its decoded bytes: what the signature hashes and `adoption` carries. + pub bytes: &'a [u8], + } + + /// What a device says about itself on `/v1/status`. + #[derive(Debug, Clone, PartialEq)] + pub struct StatusDocument<'a> { + /// The device's own `did:mata`. + pub device_did: &'a str, + /// The maker's DID, when the firmware names one. + pub maker_did: Option<&'a str>, + /// A label for people. + pub model: &'a str, + /// The iroh endpoint id, or `None` on a cell without one. + pub node_id: Option<&'a str>, + /// The adoption, when the device holds one. + pub adoption: Option>, + /// Any other fields (health: `boots`, `rssi`, `fps`, …), served with + /// the signed form. A key that names a contract field is ignored. + pub extra: Map, + } + + /// The contract's own fields, which `extra` may not replace. + const FIELDS: [&str; 9] = [ + "pair", + "maker_did", + "model", + "device_did", + "node_id", + "owner_did", + "adoption_kind", + "adoption", + "device_sig", + ]; + + impl StatusDocument<'_> { + /// `"ready"`, or `{"paired":{"owner_did":…}}` once adopted. + fn pair(&self) -> Value { + match &self.adoption { + None => Value::from("ready"), + Some(a) => json!({ "paired": { "owner_did": a.owner_did } }), + } + } + + /// The body without a nonce: the discovery fields only. + #[must_use] + pub fn plain(&self) -> Value { + json!({ + "pair": self.pair(), + "maker_did": self.maker_did, + "model": self.model, + "device_did": self.device_did, + "node_id": self.node_id, + }) + } + + /// The body for `?nonce=`: the discovery fields, the adoption, the + /// other fields, and `device_sig` from `sign` (the device key's + /// `sign_prehash`) over the [`StatusProof`] of `nonce`. + pub fn signed( + &self, + nonce: &[u8; 32], + sign: impl FnOnce(&[u8; 32]) -> [u8; 64], + ) -> rusty_esp_core::error::Result { + let proof = match &self.adoption { + None => StatusProof::unadopted(nonce, self.device_did), + Some(a) => { + StatusProof::adopted(nonce, self.device_did, a.owner_did, a.kind, a.bytes) + } + }; + let sig = sign(&proof.prehash()?); + let mut out = Map::new(); + for (k, v) in &self.extra { + if !FIELDS.contains(&k.as_str()) { + out.insert(k.clone(), v.clone()); + } + } + if let Value::Object(plain) = self.plain() { + out.extend(plain); + } + if let Some(a) = &self.adoption { + out.insert("owner_did".to_string(), a.owner_did.into()); + out.insert("adoption_kind".to_string(), a.kind.into()); + out.insert( + "adoption".to_string(), + URL_SAFE_NO_PAD.encode(a.bytes).into(), + ); + } + out.insert("device_sig".to_string(), URL_SAFE_NO_PAD.encode(sig).into()); + Ok(Value::Object(out)) + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -380,4 +497,76 @@ mod vector { assert_eq!(hex(&sig), SIGNATURE); assert_eq!(p.verify(&sig), Ok(())); } + + #[test] + fn the_document_carries_the_vector() { + use base64::Engine; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use mid_signer::DeviceSigner; + use serde_json::{Map, Value, json}; + + let key = DeviceKey::from_seed_for_tests("janus-status-v1-vector", "vector"); + let mut extra = Map::new(); + extra.insert("boots".into(), 7.into()); + // a contract field in `extra` does not replace the real one + extra.insert("device_did".into(), "did:mata:someoneElse".into()); + let doc = StatusDocument { + device_did: DEVICE_DID, + maker_did: Some("did:mata:maker"), + model: "XIAO ESP32-S3 Sense camera", + node_id: None, + adoption: Some(Adoption { + owner_did: OWNER_DID, + kind: KIND_ADOPTION_V1, + bytes: ADOPTION, + }), + extra, + }; + + let plain = doc.plain(); + assert_eq!( + plain, + json!({ + "pair": { "paired": { "owner_did": OWNER_DID } }, + "maker_did": "did:mata:maker", + "model": "XIAO ESP32-S3 Sense camera", + "device_did": DEVICE_DID, + "node_id": null, + }) + ); + + let signed = doc.signed(&NONCE, |h| key.sign_prehash(h)).unwrap(); + let s = |k: &str| signed[k].as_str().unwrap().to_owned(); + assert_eq!( + hex(&URL_SAFE_NO_PAD.decode(s("device_sig")).unwrap()), + SIGNATURE + ); + assert_eq!(URL_SAFE_NO_PAD.decode(s("adoption")).unwrap(), ADOPTION); + assert_eq!(s("owner_did"), OWNER_DID); + assert_eq!(s("adoption_kind"), KIND_ADOPTION_V1); + assert_eq!(s("device_did"), DEVICE_DID); + assert_eq!(signed["boots"], 7); + assert_eq!(signed["node_id"], Value::Null); + + // not adopted: "ready", no adoption fields, still signed + let doc = StatusDocument { + adoption: None, + extra: Map::new(), + ..doc + }; + assert_eq!(doc.plain()["pair"], "ready"); + let signed = doc.signed(&NONCE, |h| key.sign_prehash(h)).unwrap(); + for k in ["owner_did", "adoption_kind", "adoption"] { + assert!(signed.get(k).is_none(), "{k}"); + } + let sig: [u8; 64] = URL_SAFE_NO_PAD + .decode(signed["device_sig"].as_str().unwrap()) + .unwrap() + .try_into() + .unwrap(); + assert_eq!( + StatusProof::unadopted(&NONCE, DEVICE_DID).verify(&sig), + Ok(()) + ); + } }