clawft-types 0.8.3

Core types for the clawft AI assistant framework
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
//! Project key certificate, anchor statement and proof-of-possession bytes
//! (ADR-103 A6, Phase 2 package A).
//!
//! One project key (`<root>/.weftos/project.key`) is the node key, the chain
//! signing key, the anchor key and the proof-of-possession key. It therefore
//! never signs bare bytes: every statement starts with a domain tag.
//!
//! | Statement | Signer | Signed bytes |
//! |---|---|---|
//! | [`ProjectCert`] | user key | `"weftos-project-cert-v1\n"` + canonical JSON of the cert without `sig` |
//! | [`ProjectAnchorStmt`] | project key | `"weftos-project-anchor-v1\n"` + canonical JSON of the statement without `sig` |
//! | mesh-local PoP | project key | `"weftos-mesh-local-pop-v2\n"` + op + `"\n"` + user key id + `"\n"` + nonce + `"\n"` + project id (see [`pop_signed_bytes`]) |
//!
//! Canonical JSON is defined in [`canonical_json`]: sorted keys, no
//! whitespace. Hex fields are lowercase. A `key_id` is the first 16 bytes of
//! SHA-256 of the public key (32 hex characters); public keys and signatures
//! are 32 and 64 bytes (64 and 128 hex characters).
//!
//! A certificate carries no root path (projects move). `expires_at` is null
//! in Phase 2; the verifier honours it anyway.

use chrono::{DateTime, SecondsFormat, Utc};
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};

use super::canon::{canonical_json, hex_decode, hex_encode, is_lower_hex};
use super::ids::validate_id;

/// Domain tag prepended to the signed bytes of a [`ProjectCert`].
pub const CERT_DOMAIN: &str = "weftos-project-cert-v1\n";
/// Domain tag prepended to the signed bytes of a [`ProjectAnchorStmt`].
pub const ANCHOR_DOMAIN: &str = "weftos-project-anchor-v1\n";
/// Domain tag prepended to the mesh-local proof-of-possession bytes.
///
/// `v2` (package C review): the signed bytes also carry the operation and the
/// user key id, so a proof made for one purpose or one user cannot be reused.
pub const POP_DOMAIN: &str = "weftos-mesh-local-pop-v2\n";
/// `type` field of a project certificate.
pub const CERT_TYPE: &str = "project-cert";
/// Certificate format version.
pub const CERT_VERSION: u32 = 1;
/// How far in the future `issued_at` may be (clock skew), seconds.
pub const MAX_ISSUE_SKEW_SECS: i64 = 60;
/// Proof-of-possession nonce length: 16 bytes as lowercase hex.
pub const POP_NONCE_HEX_LEN: usize = 32;

/// Why a certificate or statement failed verification.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum CertError {
    /// `v` is not the supported version.
    #[error("unsupported certificate version {0}")]
    BadVersion(u32),
    /// `type` is not `project-cert`.
    #[error("unexpected certificate type {0:?}")]
    BadType(String),
    /// A hex field is malformed or has the wrong length.
    #[error("malformed hex field `{0}`")]
    BadHex(&'static str),
    /// A timestamp field is not RFC 3339.
    #[error("malformed timestamp field `{0}`")]
    BadTimestamp(&'static str),
    /// A `*_key_id` does not equal the hash of its public key.
    #[error("`{0}` does not match its public key")]
    KeyIdMismatch(&'static str),
    /// The certificate was not issued by the trusted user key.
    #[error("certificate not issued by the trusted user key")]
    UntrustedUser,
    /// The signature does not verify.
    #[error("signature does not verify")]
    BadSignature,
    /// `expires_at` is at or before `now`.
    #[error("certificate expired")]
    Expired,
    /// `issued_at` is more than [`MAX_ISSUE_SKEW_SECS`] ahead of `now`.
    #[error("certificate issued in the future")]
    NotYetValid,
    /// `project_id` is not a canonical ULID.
    #[error("project_id is not a canonical ULID")]
    BadProjectId,
    /// A field that must be 64 lowercase hex characters is not.
    #[error("field `{0}` is not 64 lowercase hex characters")]
    BadDigest(&'static str),
    /// A proof-of-possession nonce is not [`POP_NONCE_HEX_LEN`] lowercase hex.
    #[error("nonce is not {POP_NONCE_HEX_LEN} lowercase hex characters")]
    BadNonce,
}

/// First 16 bytes of SHA-256 of `pubkey`, as 32 lowercase hex characters.
pub fn key_id(pubkey: &[u8]) -> String {
    hex_encode(&Sha256::digest(pubkey)[..16])
}

/// Canonical timestamp spelling: `YYYY-MM-DDTHH:MM:SSZ`.
pub fn ts(t: DateTime<Utc>) -> String {
    t.to_rfc3339_opts(SecondsFormat::Secs, true)
}

fn parse_ts(s: &str, field: &'static str) -> Result<DateTime<Utc>, CertError> {
    let t = DateTime::parse_from_rfc3339(s)
        .map(|t| t.with_timezone(&Utc))
        .map_err(|_| CertError::BadTimestamp(field))?;
    // One spelling per instant: `YYYY-MM-DDTHH:MM:SSZ` only.
    if ts(t) != s {
        return Err(CertError::BadTimestamp(field));
    }
    Ok(t)
}

fn verifying_key(pk: &[u8; 32]) -> Result<VerifyingKey, CertError> {
    VerifyingKey::from_bytes(pk).map_err(|_| CertError::BadHex("pubkey"))
}

fn check_sig(
    pk: &[u8; 32],
    sig_hex: &str,
    domain: &str,
    body: &Value,
) -> Result<(), CertError> {
    let sig: [u8; 64] = hex_decode(sig_hex).ok_or(CertError::BadHex("sig"))?;
    let bytes = domain_bytes(domain, body);
    verifying_key(pk)?
        .verify_strict(&bytes, &Signature::from_bytes(&sig))
        .map_err(|_| CertError::BadSignature)
}

fn domain_bytes(domain: &str, body: &Value) -> Vec<u8> {
    let mut out = domain.as_bytes().to_vec();
    out.extend_from_slice(canonical_json(body).as_bytes());
    out
}

/// Inputs the user daemon chooses when it certifies a project key.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CertRequest {
    /// Project ULID.
    pub project_id: String,
    /// The project's public key.
    pub project_pubkey: [u8; 32],
    /// Monotonic serial (1 on first issue).
    pub serial: u64,
    /// Issue time (second precision is signed).
    pub issued_at: DateTime<Utc>,
    /// Expiry; `None` in Phase 2.
    pub expires_at: Option<DateTime<Utc>>,
}

/// The project key certificate, `~/.weftos/projects/<id>.cert.json`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectCert {
    /// Format version (1).
    pub v: u32,
    /// Always `project-cert`.
    #[serde(rename = "type")]
    pub kind: String,
    /// Project ULID.
    pub project_id: String,
    /// Project public key, hex (64 chars).
    pub project_pubkey: String,
    /// `key_id(project_pubkey)`, hex (32 chars).
    pub project_key_id: String,
    /// `key_id(user_pubkey)`, hex (32 chars).
    pub user_key_id: String,
    /// Issuing user public key, hex (64 chars).
    pub user_pubkey: String,
    /// Monotonic serial.
    pub serial: u64,
    /// RFC 3339 UTC, second precision.
    pub issued_at: String,
    /// RFC 3339 UTC or null (never expires).
    pub expires_at: Option<String>,
    /// Ed25519 signature by the user key, hex (128 chars).
    #[serde(default)]
    pub sig: String,
}

impl ProjectCert {
    /// The signed JSON object: every field except `sig`.
    fn body(&self) -> Value {
        json!({
            "v": self.v,
            "type": self.kind,
            "project_id": self.project_id,
            "project_pubkey": self.project_pubkey,
            "project_key_id": self.project_key_id,
            "user_key_id": self.user_key_id,
            "user_pubkey": self.user_pubkey,
            "serial": self.serial,
            "issued_at": self.issued_at,
            "expires_at": self.expires_at,
        })
    }

    /// The bytes the user key signs: domain tag plus canonical JSON.
    pub fn canonical_bytes(&self) -> Vec<u8> {
        domain_bytes(CERT_DOMAIN, &self.body())
    }

    /// Certify `req` with the user key. Deterministic: the same inputs give
    /// byte-identical signed bytes and signature.
    pub fn sign(user_key: &SigningKey, req: &CertRequest) -> Self {
        let user_pubkey = user_key.verifying_key().to_bytes();
        let mut cert = Self {
            v: CERT_VERSION,
            kind: CERT_TYPE.to_owned(),
            project_id: req.project_id.clone(),
            project_pubkey: hex_encode(&req.project_pubkey),
            project_key_id: key_id(&req.project_pubkey),
            user_key_id: key_id(&user_pubkey),
            user_pubkey: hex_encode(&user_pubkey),
            serial: req.serial,
            issued_at: ts(req.issued_at),
            expires_at: req.expires_at.map(ts),
            sig: String::new(),
        };
        cert.sig = hex_encode(&user_key.sign(&cert.canonical_bytes()).to_bytes());
        cert
    }

    /// The project key id (`key_id` of `project_pubkey`), recomputed.
    pub fn computed_key_id(&self) -> Result<String, CertError> {
        let pk: [u8; 32] =
            hex_decode(&self.project_pubkey).ok_or(CertError::BadHex("project_pubkey"))?;
        Ok(key_id(&pk))
    }

    /// Verify against the user key the caller trusts: version, type, hex
    /// shapes, both key ids recomputed from their public keys, issuer, the
    /// signature, then expiry against `now`.
    pub fn verify(
        &self,
        trusted_user_pubkey: &[u8; 32],
        now: DateTime<Utc>,
    ) -> Result<(), CertError> {
        if self.v != CERT_VERSION {
            return Err(CertError::BadVersion(self.v));
        }
        if self.kind != CERT_TYPE {
            return Err(CertError::BadType(self.kind.clone()));
        }
        validate_id(&self.project_id).map_err(|_| CertError::BadProjectId)?;
        let project_pk: [u8; 32] =
            hex_decode(&self.project_pubkey).ok_or(CertError::BadHex("project_pubkey"))?;
        let user_pk: [u8; 32] =
            hex_decode(&self.user_pubkey).ok_or(CertError::BadHex("user_pubkey"))?;
        if self.project_key_id != key_id(&project_pk) {
            return Err(CertError::KeyIdMismatch("project_key_id"));
        }
        if self.user_key_id != key_id(&user_pk) {
            return Err(CertError::KeyIdMismatch("user_key_id"));
        }
        if &user_pk != trusted_user_pubkey {
            return Err(CertError::UntrustedUser);
        }
        let issued = parse_ts(&self.issued_at, "issued_at")?;
        if issued > now + chrono::Duration::seconds(MAX_ISSUE_SKEW_SECS) {
            return Err(CertError::NotYetValid);
        }
        let expiry = self
            .expires_at
            .as_deref()
            .map(|s| parse_ts(s, "expires_at"))
            .transpose()?;
        check_sig(&user_pk, &self.sig, CERT_DOMAIN, &self.body())?;
        match expiry {
            Some(t) if t <= now => Err(CertError::Expired),
            _ => Ok(()),
        }
    }
}

/// The project's signed statement of its chain head, appended by the user
/// daemon as a `project.anchor` event.
///
/// Honest limit: the user daemon attests "this key claimed head X at time
/// T"; it cannot verify X without subscribing to the project chain.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectAnchorStmt {
    /// Project ULID.
    pub project_id: String,
    /// `key_id` of the signing project key.
    pub project_key_id: String,
    /// Serial of the certificate the project holds.
    pub cert_serial: u64,
    /// Accepted-statement counter; the user daemon requires last + 1.
    pub seq: u64,
    /// Chain id within the project (0 for the main chain).
    pub chain_id: u64,
    /// Chain head hash, hex (64 chars).
    pub head_hash: String,
    /// Chain head sequence number.
    pub head_seq: u64,
    /// Effective rule hash at that head, hex (64 chars).
    pub rule_hash: String,
    /// RFC 3339 UTC; the user daemon refuses more than 5 minutes ahead.
    pub at: String,
    /// [`ProjectAnchorStmt::hash`] of the previous accepted statement.
    pub prev_anchor: Option<String>,
    /// Ed25519 signature by the project key, hex (128 chars).
    #[serde(default)]
    pub sig: String,
}

impl ProjectAnchorStmt {
    fn body(&self) -> Value {
        json!({
            "project_id": self.project_id,
            "project_key_id": self.project_key_id,
            "cert_serial": self.cert_serial,
            "seq": self.seq,
            "chain_id": self.chain_id,
            "head_hash": self.head_hash,
            "head_seq": self.head_seq,
            "rule_hash": self.rule_hash,
            "at": self.at,
            "prev_anchor": self.prev_anchor,
        })
    }

    /// The bytes the project key signs: domain tag plus canonical JSON.
    pub fn canonical_bytes(&self) -> Vec<u8> {
        domain_bytes(ANCHOR_DOMAIN, &self.body())
    }

    /// Fill `sig` (and `project_key_id`) with `project_key`.
    pub fn sign(mut self, project_key: &SigningKey) -> Self {
        self.project_key_id = key_id(&project_key.verifying_key().to_bytes());
        self.sig = hex_encode(&project_key.sign(&self.canonical_bytes()).to_bytes());
        self
    }

    /// Verify the signature and that `project_key_id` names `project_pubkey`.
    /// Sequence, time and chain-link checks belong to the user daemon.
    pub fn verify(&self, project_pubkey: &[u8; 32]) -> Result<(), CertError> {
        if self.project_key_id != key_id(project_pubkey) {
            return Err(CertError::KeyIdMismatch("project_key_id"));
        }
        validate_id(&self.project_id).map_err(|_| CertError::BadProjectId)?;
        for (name, v) in [("head_hash", &self.head_hash), ("rule_hash", &self.rule_hash)] {
            if !is_lower_hex(v, 64) {
                return Err(CertError::BadDigest(name));
            }
        }
        if let Some(p) = &self.prev_anchor
            && !is_lower_hex(p, 64)
        {
            return Err(CertError::BadDigest("prev_anchor"));
        }
        parse_ts(&self.at, "at")?;
        check_sig(project_pubkey, &self.sig, ANCHOR_DOMAIN, &self.body())
    }

    /// Statement hash used as the next statement's `prev_anchor`: SHA-256 of
    /// the anchor domain tag plus canonical JSON including `sig`, as hex.
    pub fn hash(&self) -> String {
        let mut body = self.body();
        body["sig"] = Value::String(self.sig.clone());
        hex_encode(&Sha256::digest(domain_bytes(ANCHOR_DOMAIN, &body)))
    }
}

/// What a proof of possession authorises.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PopOp {
    /// First certification of a key (`mesh.register`).
    Register,
    /// Replacing the certified key (`project.rekey`).
    Rekey,
}

impl PopOp {
    /// The tag signed into the bytes.
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Register => "register",
            Self::Rekey => "rekey",
        }
    }
}

/// Bytes the project key signs to prove possession:
/// `"weftos-mesh-local-pop-v2\n<op>\n<user_key_id>\n<nonce>\n<project_id>"`.
///
/// `user_key_id` names the user chain the certificate will be issued on
/// (32 lowercase hex). All parts are validated first (nonce as
/// [`POP_NONCE_HEX_LEN`] lowercase hex, id as a canonical ULID) so none can
/// contain the `\n` separator and two different tuples never give the same
/// bytes.
pub fn pop_signed_bytes(
    op: PopOp,
    user_key_id: &str,
    nonce: &str,
    project_id: &str,
) -> Result<Vec<u8>, CertError> {
    if !is_lower_hex(user_key_id, 32) {
        return Err(CertError::BadHex("user_key_id"));
    }
    if !is_lower_hex(nonce, POP_NONCE_HEX_LEN) {
        return Err(CertError::BadNonce);
    }
    validate_id(project_id).map_err(|_| CertError::BadProjectId)?;
    Ok(format!("{POP_DOMAIN}{}\n{user_key_id}\n{nonce}\n{project_id}", op.as_str()).into_bytes())
}