Skip to main content

pointlock_provider_kit/
lockfile.rs

1//! Capability lockfile and runtime attestation (spine §4.1).
2//!
3//! `pointlock lock` runs `system.hello` + `device.capabilities` against a
4//! real daemon and freezes the result into a [`CapabilityLockfile`] (checked
5//! into the repository like a dependency lockfile). At `openSession` the
6//! provider replays the handshake and compares the live world against
7//! `lockfileDigest`; any mismatch is `capability_drift` — refuse to run,
8//! never silently degrade.
9
10use std::collections::{BTreeMap, BTreeSet};
11
12use pointlock_ir::{ActionName, FeatureId, Hash, domain_hash};
13use schemars::JsonSchema;
14use serde::{Deserialize, Serialize};
15
16use crate::manifest::{ActionDefinitionStatic, PlatformKind};
17
18/// Domain tag of the lockfile digest, following the 02 §12.2 domain-hash
19/// construction (`sha256(utf8(tag + "\n" + JCS(content)))`).
20///
21/// Pending spine incorporation: the spine fixes the digest as "sha256 of the
22/// canonical form of the content" without naming the domain tag; this crate
23/// pins it to `pointlock-lockfile/1`.
24pub const LOCKFILE_DIGEST_DOMAIN_TAG: &str = "pointlock-lockfile/1";
25
26/// Identity of the provider package the lockfile was produced by
27/// (spine §4.1 `CapabilityLockfile.provider`).
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
29#[serde(rename_all = "camelCase", deny_unknown_fields)]
30pub struct LockfileProvider {
31    /// Provider name (e.g. `"devicerail"`).
32    pub name: String,
33    /// Provider package version.
34    pub version: String,
35}
36
37/// A negotiated protocol version (`{ major, minor }`).
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
39#[serde(rename_all = "camelCase", deny_unknown_fields)]
40pub struct ProtocolVersion {
41    /// Major version.
42    pub major: u64,
43    /// Minor version.
44    pub minor: u64,
45}
46
47/// Daemon identity (DeviceRail `PeerInfo`, spine A.8).
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
49#[serde(rename_all = "camelCase", deny_unknown_fields)]
50pub struct PeerInfo {
51    /// Server name.
52    pub name: String,
53    /// Server version.
54    pub version: String,
55}
56
57/// The frozen `system.hello` outcome (spine §4.1 `CapabilityLockfile.hello`).
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
59#[serde(rename_all = "camelCase", deny_unknown_fields)]
60pub struct LockfileHello {
61    /// Negotiated protocol version (expected `{ major: 1, minor: 5 }`).
62    pub protocol_selected: ProtocolVersion,
63    /// `FeatureSelection.enabled`, verbatim.
64    pub features_enabled: Vec<FeatureId>,
65    /// Daemon `PeerInfo`.
66    pub server: PeerInfo,
67}
68
69/// The frozen `device.capabilities` outcome (spine §4.1
70/// `CapabilityLockfile.device`).
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
72#[serde(rename_all = "camelCase", deny_unknown_fields)]
73pub struct LockfileDevice {
74    /// Platform of the locked device.
75    pub platform: PlatformKind,
76    /// The device's `ActionDefinition[]`, frozen verbatim.
77    pub actions: Vec<ActionDefinitionStatic>,
78}
79
80/// The capability lockfile `pointlock lock` freezes after talking to a real
81/// daemon (spine §4.1 `CapabilityLockfile`). Its `digest` is embedded into
82/// `FlowIR.lockfileDigest` at compile time and re-checked by attestation at
83/// every `openSession`.
84#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
85#[serde(rename_all = "camelCase", deny_unknown_fields)]
86pub struct CapabilityLockfile {
87    /// The provider package that produced this lockfile.
88    pub provider: LockfileProvider,
89    /// ISO timestamp of the lock run.
90    pub attested_at: String,
91    /// Frozen `system.hello` outcome.
92    pub hello: LockfileHello,
93    /// Frozen `device.capabilities` outcome.
94    pub device: LockfileDevice,
95    /// sha256 of the canonical form of the fields above (see
96    /// [`lockfile_digest`]); embedded into `FlowIR.lockfileDigest`.
97    pub digest: Hash,
98}
99
100impl CapabilityLockfile {
101    /// Recomputes the digest from this lockfile's content and compares it to
102    /// the stored `digest` field.
103    pub fn digest_consistent(&self) -> bool {
104        lockfile_digest(self) == self.digest
105    }
106
107    /// Overwrites `digest` with the digest recomputed from the content
108    /// fields, sealing the lockfile.
109    pub fn seal(&mut self) {
110        self.digest = lockfile_digest(self);
111    }
112}
113
114/// Computes the canonical digest of a lockfile's content — every field
115/// except `digest` itself and the volatile `attestedAt` timestamp — via
116/// [`pointlock_ir::domain_hash`] under [`LOCKFILE_DIGEST_DOMAIN_TAG`].
117///
118/// `attestedAt` is excluded so that re-locking an unchanged daemon yields
119/// a byte-identical digest (04 §10.2 reproducibility): a timestamp must
120/// never invalidate capability facts.
121pub fn lockfile_digest(lockfile: &CapabilityLockfile) -> Hash {
122    let mut content = serde_json::to_value(lockfile).expect("a lockfile serializes to JSON");
123    let object = content
124        .as_object_mut()
125        .expect("a lockfile serializes to a JSON object");
126    object.remove("digest");
127    object.remove("attestedAt");
128    domain_hash(LOCKFILE_DIGEST_DOMAIN_TAG, &content)
129}
130
131/// The runtime attestation result exposed on an open session (spine §4.2
132/// `CapabilityAttestation`). `openSession` has already compared it against
133/// the expected `lockfileDigest`; it is surfaced for Evidence and reports.
134#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
135#[serde(rename_all = "camelCase", deny_unknown_fields)]
136pub struct CapabilityAttestation {
137    /// Provider identity.
138    pub provider_id: String,
139    /// Protocol version selected by the live handshake.
140    pub protocol_selected: ProtocolVersion,
141    /// Features enabled by the live handshake.
142    pub features_enabled: BTreeSet<FeatureId>,
143    /// Attested actions, keyed by action name.
144    pub actions: BTreeMap<ActionName, ActionDefinitionStatic>,
145    /// The lockfile digest the live world was verified against.
146    pub lockfile_digest: Hash,
147    /// ISO timestamp of the attestation.
148    pub attested_at: String,
149}
150
151impl CapabilityAttestation {
152    /// Builds the attestation view of a lockfile, as a provider does after a
153    /// successful `openSession` comparison (`attested_at` is the live
154    /// attestation time, not the lock time).
155    pub fn from_lockfile(lockfile: &CapabilityLockfile, attested_at: impl Into<String>) -> Self {
156        CapabilityAttestation {
157            provider_id: lockfile.provider.name.clone(),
158            protocol_selected: lockfile.hello.protocol_selected,
159            features_enabled: lockfile.hello.features_enabled.iter().cloned().collect(),
160            actions: lockfile
161                .device
162                .actions
163                .iter()
164                .map(|action| (action.name.clone(), action.clone()))
165                .collect(),
166            lockfile_digest: lockfile.digest.clone(),
167            attested_at: attested_at.into(),
168        }
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use crate::manifest::ActionProtection;
176    use pointlock_ir::JsonSchemaDocument;
177    use serde_json::json;
178
179    fn placeholder_hash() -> Hash {
180        Hash::new(format!("sha256:{}", "0".repeat(64))).unwrap()
181    }
182
183    fn sample_lockfile() -> CapabilityLockfile {
184        let mut lockfile = CapabilityLockfile {
185            provider: LockfileProvider {
186                name: "devicerail".to_owned(),
187                version: "0.1.0".to_owned(),
188            },
189            attested_at: "2026-01-01T00:00:00Z".to_owned(),
190            hello: LockfileHello {
191                protocol_selected: ProtocolVersion { major: 1, minor: 5 },
192                features_enabled: vec![
193                    FeatureId::new("device.semanticActions.v1").unwrap(),
194                    FeatureId::new("verdict.record.v1").unwrap(),
195                ],
196                server: PeerInfo {
197                    name: "devicerail-daemon".to_owned(),
198                    version: "1.5.0".to_owned(),
199                },
200            },
201            device: LockfileDevice {
202                platform: PlatformKind::Android,
203                actions: vec![ActionDefinitionStatic {
204                    name: ActionName::new("tapElement").unwrap(),
205                    input_schema: JsonSchemaDocument::new(json!({ "type": "object" })).unwrap(),
206                    output_schema: None,
207                    protection: ActionProtection::Standard,
208                    synthetic: false,
209                }],
210            },
211            digest: placeholder_hash(),
212        };
213        lockfile.seal();
214        lockfile
215    }
216
217    #[test]
218    fn lockfile_digest_is_deterministic_and_excludes_digest_field() {
219        let lockfile = sample_lockfile();
220        assert!(lockfile.digest_consistent());
221        assert_eq!(lockfile_digest(&lockfile), lockfile.digest);
222
223        let mut tampered = lockfile.clone();
224        tampered.hello.features_enabled.pop();
225        assert!(!tampered.digest_consistent());
226
227        // Changing only the digest field does not change the recomputed
228        // digest (the digest hashes the content, not itself).
229        let mut redigested = lockfile.clone();
230        redigested.digest = placeholder_hash();
231        assert_eq!(lockfile_digest(&redigested), lockfile.digest);
232
233        // The volatile attestedAt timestamp is outside the digest domain:
234        // re-locking an unchanged daemon is byte-identical (04 §10.2).
235        let mut relocked = lockfile.clone();
236        relocked.attested_at = "2027-01-01T00:00:00Z".to_owned();
237        assert_eq!(lockfile_digest(&relocked), lockfile.digest);
238    }
239
240    #[test]
241    fn lockfile_wire_shape_round_trips() {
242        let lockfile = sample_lockfile();
243        let wire = serde_json::to_value(&lockfile).expect("serialize");
244        assert_eq!(wire["hello"]["protocolSelected"]["minor"], 5);
245        assert_eq!(
246            wire["hello"]["featuresEnabled"][0],
247            "device.semanticActions.v1"
248        );
249        assert_eq!(wire["device"]["platform"], "android");
250        assert_eq!(wire["attestedAt"], "2026-01-01T00:00:00Z");
251        let back: CapabilityLockfile = serde_json::from_value(wire).expect("deserialize");
252        assert_eq!(back, lockfile);
253    }
254
255    #[test]
256    fn attestation_projects_lockfile_and_round_trips() {
257        let lockfile = sample_lockfile();
258        let attestation = CapabilityAttestation::from_lockfile(&lockfile, "2026-01-02T00:00:00Z");
259        assert_eq!(attestation.provider_id, "devicerail");
260        assert_eq!(attestation.lockfile_digest, lockfile.digest);
261        assert!(
262            attestation
263                .actions
264                .contains_key(&ActionName::new("tapElement").unwrap())
265        );
266        let wire = serde_json::to_value(&attestation).expect("serialize");
267        assert_eq!(wire["providerId"], "devicerail");
268        assert_eq!(wire["actions"]["tapElement"]["protection"], "standard");
269        let back: CapabilityAttestation = serde_json::from_value(wire).expect("deserialize");
270        assert_eq!(back, attestation);
271    }
272}