astrid-kernel 0.8.0

Astrid micro-kernel, the core of the Astrid OS
Documentation
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
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
//! Layer 6 invite-token handlers (issue #756).
//!
//! Sibling of [`super::handlers`]; lives in its own file to keep the
//! main admin-handler module under the 1000-line CI threshold. Each
//! function assumes the admin dispatcher has already established
//! authorization (or, for [`invite_redeem`], that the token-is-auth
//! preamble has been honoured by the caller). Every mutating handler
//! acquires [`crate::Kernel::admin_write_lock`] before touching
//! `invites.toml` or `profile.toml`.

use std::sync::Arc;

use astrid_core::PrincipalId;
use astrid_core::groups::GroupConfig;
use astrid_core::kernel_api::{AdminResponseBody, InviteIssued, InviteRedeemed, InviteSummary};
use astrid_core::profile::{AuthConfig, AuthMethod, PrincipalProfile};
use sha2::{Digest, Sha256};
use tracing::{info, warn};

use crate::invite::{self, Invite, InviteStore, MAX_EXPIRY_SECS};

/// Hex-encoded SHA-256 of a hex-encoded ed25519 public key. Surfaced
/// as the `public_key_fingerprint` field on
/// [`AdminResponseBody::InviteRedeemed`] and used by the audit
/// sanitiser to redact the raw key from persisted audit rows.
#[must_use]
pub(crate) fn fingerprint_public_key(hex_key: &str) -> String {
    let mut hasher = Sha256::new();
    hasher.update(hex_key.as_bytes());
    hex::encode(hasher.finalize())
}

// ── invite.issue ──────────────────────────────────────────────────────

pub(crate) async fn invite_issue(
    kernel: &Arc<crate::Kernel>,
    group: String,
    expires_secs: Option<u64>,
    max_uses: u32,
    metadata: Option<String>,
) -> AdminResponseBody {
    if max_uses == 0 {
        return err_bad_input("max_uses must be greater than 0".into());
    }
    if let Some(exp) = expires_secs
        && exp > MAX_EXPIRY_SECS
    {
        return err_bad_input(format!(
            "expires_secs {exp} exceeds the 30-day cap ({MAX_EXPIRY_SECS}s)"
        ));
    }
    // Group must already exist in the live config — typos here would
    // mint dead invites that fail on redeem with a cryptic error.
    if !group_exists(kernel, &group) {
        return err_bad_input(format!(
            "group {group:?} is not defined — create it via `astrid group create` first"
        ));
    }

    let _guard = kernel.admin_write_lock.lock().await;
    let store = InviteStore::new(InviteStore::path_for(&kernel.astrid_home));
    let mut invites = match store.load() {
        Ok(v) => v,
        Err(e) => return err_internal(format!("invites.toml load failed: {e}")),
    };
    // Lazy prune on every mutating op — cheap, bounded by store size,
    // keeps `invite.list` clean without a background sweeper.
    let _ = invite::prune_expired(&mut invites);

    let now = invite::now_epoch();
    let expires_at_epoch = expires_secs.map(|s| now.saturating_add(s));
    let token = invite::generate_token();
    let token_hash = invite::hash_token(&token);

    invites.push(Invite {
        token_hash: token_hash.clone(),
        group: group.clone(),
        remaining_uses: max_uses,
        expires_at_epoch,
        issued_at_epoch: now,
        metadata: metadata.clone(),
    });

    if let Err(e) = store.save(&invites) {
        return err_internal(format!("invites.toml save failed: {e}"));
    }

    info!(
        token_fingerprint = %token_hash,
        group = %group,
        max_uses,
        expires_at_epoch = ?expires_at_epoch,
        "Layer 6 invite.issue"
    );

    AdminResponseBody::Invite(InviteIssued {
        token,
        group,
        remaining_uses: max_uses,
        expires_at_epoch,
        metadata,
    })
}

// ── invite.redeem ─────────────────────────────────────────────────────

pub(crate) async fn invite_redeem(
    kernel: &Arc<crate::Kernel>,
    token: String,
    public_key: String,
    display_name: Option<String>,
) -> AdminResponseBody {
    // Validate the ed25519 key shape FIRST — same-shape rejection
    // before any token comparison keeps the redeem path from being a
    // hashing-oracle for malformed tokens.
    let normalised_key = match normalise_public_key(&public_key) {
        Ok(k) => k,
        Err(e) => return err_bad_input(e),
    };

    let _guard = kernel.admin_write_lock.lock().await;
    let store = InviteStore::new(InviteStore::path_for(&kernel.astrid_home));
    let mut invites = match store.load() {
        Ok(v) => v,
        Err(e) => return err_internal(format!("invites.toml load failed: {e}")),
    };
    let _ = invite::prune_expired(&mut invites);

    let token_hash = invite::hash_token(&token);
    let now = invite::now_epoch();

    // Constant-time scan over all live invites. We avoid `Vec::find`
    // short-circuiting because timing on its early-return would leak
    // partial-match length information.
    let mut matched_index: Option<usize> = None;
    for (i, inv) in invites.iter().enumerate() {
        let live = inv.remaining_uses > 0 && inv.expires_at_epoch.is_none_or(|e| e > now);
        // Always run the hash compare so a malformed/expired/consumed
        // entry takes the same time as a live one.
        let hit = invite::ct_hash_eq(&inv.token_hash, &token_hash) && live;
        if hit && matched_index.is_none() {
            matched_index = Some(i);
        }
    }

    let Some(idx) = matched_index else {
        return err_unauthorized("invite token invalid, expired, or already consumed".into());
    };

    let chosen = invites[idx].clone();

    // Mint the principal id. `display_name` is treated as a soft
    // suggestion: slugify and dedupe; on hard collision fall back to a
    // random tag so a malicious redeemer can't grief future redeemers
    // by hogging human-friendly names.
    let principal = match allocate_principal(kernel, display_name.as_deref()) {
        Ok(p) => p,
        Err(e) => return err_internal(e),
    };

    // Build the profile up-front so we can register the public key
    // before saving — no two-write race window in which a redeemer
    // sees their principal exist but the key not yet registered.
    let mut auth = AuthConfig::default();
    auth.methods.push(AuthMethod::Keypair);
    auth.public_keys.push(format!("ed25519:{normalised_key}"));

    let profile = PrincipalProfile {
        groups: vec![chosen.group.clone()],
        auth,
        ..PrincipalProfile::default()
    };
    if let Err(e) = profile.validate() {
        return err_internal(format!("profile rejected: {e}"));
    }

    // Reuse the existing identity-store + profile-save flow used by
    // the regular agent.create. We can't call `agent_create` directly
    // because the redeem path needs the pre-built `AuthConfig`, but
    // the responsibility split is identical: identity store first,
    // profile second, home tree third — with rollback at every step.
    let user = match kernel
        .identity_store
        .create_user(Some(principal.as_str()))
        .await
    {
        Ok(u) => u,
        Err(e) => return err_internal(format!("identity store create_user failed: {e}")),
    };
    if let Err(e) = kernel
        .identity_store
        .link("cli", principal.as_str(), user.id, "system")
        .await
    {
        let _ = kernel.identity_store.delete_user(user.id).await;
        return err_internal(format!("identity store link failed: {e}"));
    }
    let profile_path = kernel.astrid_home.profile_path(&principal);
    if let Err(e) = profile.save_to_path(&profile_path) {
        let _ = kernel
            .identity_store
            .unlink("cli", principal.as_str())
            .await;
        let _ = kernel.identity_store.delete_user(user.id).await;
        return err_internal(format!("profile save failed: {e}"));
    }
    if let Err(e) = kernel.astrid_home.principal_home(&principal).ensure() {
        let _ = kernel
            .identity_store
            .unlink("cli", principal.as_str())
            .await;
        let _ = kernel.identity_store.delete_user(user.id).await;
        let _ = std::fs::remove_file(&profile_path);
        return err_internal(format!("principal home tree provisioning failed: {e}"));
    }

    // Decrement / remove the invite. Saturating sub guards against
    // an externally-edited `remaining_uses = 0` slipping past the
    // live-check above.
    invites[idx].remaining_uses = invites[idx].remaining_uses.saturating_sub(1);
    if invites[idx].remaining_uses == 0 {
        invites.remove(idx);
    }
    if let Err(e) = store.save(&invites) {
        // We could roll back the principal but that would leave the
        // redeemer in a worse position than "your token is consumed
        // and your principal exists" — log loudly instead.
        warn!(
            error = %e,
            security_event = true,
            principal = %principal,
            "invite.redeem: invites.toml save failed AFTER principal mint; manual reconciliation may be required"
        );
    }

    let fingerprint = fingerprint_public_key(&format!("ed25519:{normalised_key}"));
    info!(
        %principal,
        group = %chosen.group,
        public_key_fingerprint = %fingerprint,
        "Layer 6 invite.redeem"
    );

    AdminResponseBody::InviteRedeemed(InviteRedeemed {
        principal,
        group: chosen.group,
        public_key_fingerprint: fingerprint,
    })
}

// ── invite.list ───────────────────────────────────────────────────────

pub(crate) async fn invite_list(kernel: &Arc<crate::Kernel>) -> AdminResponseBody {
    let _guard = kernel.admin_write_lock.lock().await;
    let store = InviteStore::new(InviteStore::path_for(&kernel.astrid_home));
    let mut invites = match store.load() {
        Ok(v) => v,
        Err(e) => return err_internal(format!("invites.toml load failed: {e}")),
    };
    if invite::prune_expired(&mut invites) > 0 {
        // Best-effort: a failed save just means the next prune retries.
        if let Err(e) = store.save(&invites) {
            warn!(error = %e, "invite.list: lazy prune save failed");
        }
    }
    let summaries: Vec<InviteSummary> = invites
        .into_iter()
        .map(|i| InviteSummary {
            token_fingerprint: i.token_hash,
            group: i.group,
            remaining_uses: i.remaining_uses,
            expires_at_epoch: i.expires_at_epoch,
            issued_at_epoch: i.issued_at_epoch,
            metadata: i.metadata,
        })
        .collect();
    AdminResponseBody::InviteList(summaries)
}

// ── invite.revoke ─────────────────────────────────────────────────────

pub(crate) async fn invite_revoke(kernel: &Arc<crate::Kernel>, token: String) -> AdminResponseBody {
    let _guard = kernel.admin_write_lock.lock().await;
    let store = InviteStore::new(InviteStore::path_for(&kernel.astrid_home));
    let mut invites = match store.load() {
        Ok(v) => v,
        Err(e) => return err_internal(format!("invites.toml load failed: {e}")),
    };
    // `token` here may be either the raw token (operator paste) or the
    // hex fingerprint (operator copy from `invite.list`). Hash the
    // input as raw token first; if no match, also try the input verbatim
    // (treating it as the already-hashed fingerprint). This dual lookup
    // never leaks which form matched — both produce the same
    // success/failure shape.
    let from_raw = invite::hash_token(&token);
    let pre_len = invites.len();
    invites.retain(|i| {
        !invite::ct_hash_eq(&i.token_hash, &from_raw) && !invite::ct_hash_eq(&i.token_hash, &token)
    });
    if invites.len() == pre_len {
        return err_bad_input("no invite matches the supplied token or fingerprint".into());
    }
    if let Err(e) = store.save(&invites) {
        return err_internal(format!("invites.toml save failed: {e}"));
    }
    let removed = pre_len.saturating_sub(invites.len());
    info!(removed, "Layer 6 invite.revoke");
    AdminResponseBody::Success(serde_json::json!({ "removed": removed }))
}

// ── helpers ───────────────────────────────────────────────────────────

fn group_exists(kernel: &Arc<crate::Kernel>, name: &str) -> bool {
    let cfg = kernel.groups.load_full();
    GroupConfig::is_builtin_name(name) || cfg.iter().any(|(n, _)| n == name)
}

/// Validate an ed25519 public key string. Accepts either bare 64 hex
/// chars or the `ed25519:<hex>` self-describing form. Returns the bare
/// hex form (lowercased) on success.
fn normalise_public_key(raw: &str) -> Result<String, String> {
    let candidate = raw
        .strip_prefix("ed25519:")
        .unwrap_or(raw)
        .trim()
        .to_ascii_lowercase();
    if candidate.len() != 64 {
        return Err(format!(
            "public_key must be 32 bytes hex-encoded (64 hex chars); got {} chars",
            candidate.len()
        ));
    }
    if !candidate.chars().all(|c| c.is_ascii_hexdigit()) {
        return Err("public_key contains non-hex characters".into());
    }
    Ok(candidate)
}

/// Allocate a fresh [`PrincipalId`]. Tries the user-supplied
/// `display_name` (slugified); on collision falls back to a random
/// `agent-<8-hex>` id. `default` and other reserved names are
/// rejected up-front.
fn allocate_principal(
    kernel: &Arc<crate::Kernel>,
    display_name: Option<&str>,
) -> Result<PrincipalId, String> {
    if let Some(name) = display_name {
        let slug = slugify_principal(name);
        if !slug.is_empty() {
            let pid = PrincipalId::new(&slug)
                .map_err(|e| format!("display_name {name:?} produces invalid principal: {e}"))?;
            if pid == PrincipalId::default() {
                return Err("`default` is the bootstrap principal and cannot be re-created".into());
            }
            let path = kernel.astrid_home.profile_path(&pid);
            if !path.exists() {
                return Ok(pid);
            }
            // Collision — fall through to random allocation rather
            // than leak whether this name is taken (the redeemer
            // sees the random id and learns nothing about other
            // principals).
        }
    }
    for _ in 0..16 {
        let candidate = format!("agent-{}", random_suffix());
        if let Ok(pid) = PrincipalId::new(&candidate) {
            let path = kernel.astrid_home.profile_path(&pid);
            if !path.exists() {
                return Ok(pid);
            }
        }
    }
    Err("failed to allocate a unique principal id after 16 attempts".into())
}

/// Maximum length of a slugified principal id. Bounded so an attacker
/// supplying a multi-megabyte `display_name` cannot force the kernel
/// to (a) iterate the full string and (b) produce a profile path
/// longer than the filesystem's `NAME_MAX` (typically 255 on Unix,
/// 143 on legacy eCryptfs). 64 is well under every supported limit
/// and matches the ergonomic ceiling for human-friendly names.
const MAX_PRINCIPAL_SLUG_LEN: usize = 64;

fn slugify_principal(input: &str) -> String {
    let mut out = String::with_capacity(input.len().min(MAX_PRINCIPAL_SLUG_LEN));
    let mut last_was_dash = false;
    // `.take(MAX_PRINCIPAL_SLUG_LEN)` short-circuits the iterator so
    // the oversize-input case is O(MAX) not O(input.len()), preventing
    // the CPU-exhaustion shape of "redeem with a giant display_name".
    for ch in input.chars().take(MAX_PRINCIPAL_SLUG_LEN) {
        if ch.is_ascii_alphanumeric() {
            out.push(ch.to_ascii_lowercase());
            last_was_dash = false;
        } else if !last_was_dash && !out.is_empty() {
            out.push('-');
            last_was_dash = true;
        }
    }
    while out.ends_with('-') {
        out.pop();
    }
    out
}

fn random_suffix() -> String {
    use rand::RngCore;
    let mut bytes = [0u8; 4];
    rand::rngs::OsRng.fill_bytes(&mut bytes);
    hex::encode(bytes)
}

fn err_bad_input(msg: String) -> AdminResponseBody {
    warn!(error = %msg, "invite request rejected: bad input");
    AdminResponseBody::Error(msg)
}

fn err_internal(msg: String) -> AdminResponseBody {
    warn!(error = %msg, "invite request failed: internal error");
    AdminResponseBody::Error(msg)
}

fn err_unauthorized(msg: String) -> AdminResponseBody {
    warn!(security_event = true, error = %msg, "invite request denied");
    AdminResponseBody::Error(msg)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn normalise_public_key_accepts_bare_hex() {
        let key = "a".repeat(64);
        assert_eq!(normalise_public_key(&key).unwrap(), key);
    }

    #[test]
    fn normalise_public_key_accepts_prefixed_hex() {
        let key = "B".repeat(64);
        let normalised = normalise_public_key(&format!("ed25519:{key}")).unwrap();
        assert_eq!(normalised, "b".repeat(64));
    }

    #[test]
    fn normalise_public_key_rejects_wrong_length() {
        assert!(normalise_public_key("deadbeef").is_err());
        assert!(normalise_public_key(&"a".repeat(63)).is_err());
        assert!(normalise_public_key(&"a".repeat(65)).is_err());
    }

    #[test]
    fn normalise_public_key_rejects_non_hex() {
        let bad = "g".repeat(64);
        assert!(normalise_public_key(&bad).is_err());
    }

    #[test]
    fn slugify_principal_lowercases_and_dashes() {
        assert_eq!(slugify_principal("Alice Smith"), "alice-smith");
        assert_eq!(slugify_principal("alice@example.com"), "alice-example-com");
        assert_eq!(slugify_principal("--Alice--"), "alice");
        assert_eq!(slugify_principal(""), "");
    }

    #[test]
    fn slugify_principal_caps_oversize_input() {
        let monster = "a".repeat(10_000);
        let out = slugify_principal(&monster);
        assert!(
            out.len() <= MAX_PRINCIPAL_SLUG_LEN,
            "expected len <= {MAX_PRINCIPAL_SLUG_LEN}, got {}",
            out.len()
        );
        assert_eq!(out, "a".repeat(MAX_PRINCIPAL_SLUG_LEN));
    }

    #[test]
    fn fingerprint_is_deterministic() {
        let a = fingerprint_public_key("ed25519:abcd");
        let b = fingerprint_public_key("ed25519:abcd");
        assert_eq!(a, b);
        assert_ne!(a, fingerprint_public_key("ed25519:abce"));
        assert_eq!(a.len(), 64);
    }
}