Skip to main content

ijima_server/
key_store.rs

1// Copyright (C) 2026 Industrial Algebra
2// SPDX-License-Identifier: Apache-2.0
3
4//! Persistence for the Ijima issuer key seed.
5//!
6//! The Schubert capability tokens Ijima mints are signed by a single
7//! Ed25519 issuer key. For the daemon and the `ijima token` CLI to agree,
8//! they must share the same 32-byte seed. This module resolves the path
9//! (`$IJIMA_DIR/issuer.key`, default `~/.ijima/issuer.key`) and delegates
10//! load/create to [`schubert::crypto::KeyStore`] (upstream since Schubert
11//! v0.4 — Ijima no longer reimplements the file/permission logic).
12
13use std::path::{Path, PathBuf};
14
15use ijima_core::{IjimaError, Result};
16
17const KEY_FILENAME: &str = "issuer.key";
18
19/// Resolves the issuer key path: `$IJIMA_DIR/issuer.key`, else
20/// `$HOME/.ijima/issuer.key`.
21///
22/// # Errors
23///
24/// Returns [`IjimaError::InvalidInput`] if `IJIMA_DIR` is unset and the
25/// home directory cannot be determined.
26pub fn default_key_path() -> Result<PathBuf> {
27    if let Ok(dir) = std::env::var("IJIMA_DIR") {
28        return Ok(PathBuf::from(dir).join(KEY_FILENAME));
29    }
30    let home = home_dir().ok_or_else(|| {
31        IjimaError::invalid_input("cannot resolve Ijima data dir: set IJIMA_DIR or HOME")
32    })?;
33    Ok(home.join(".ijima").join(KEY_FILENAME))
34}
35
36fn home_dir() -> Option<PathBuf> {
37    std::env::var_os("HOME")
38        .or_else(|| std::env::var_os("USERPROFILE"))
39        .map(PathBuf::from)
40}
41
42/// Loads the seed at `path`, or creates it with a fresh random value if
43/// absent (mode `0600` on Unix). Used by both the daemon (first start)
44/// and the `ijima token issue` CLI. Delegates to
45/// [`schubert::crypto::KeyStore::load_or_create`].
46///
47/// # Errors
48///
49/// Returns [`IjimaError::Store`] on I/O failure or
50/// [`IjimaError::InvalidInput`] if an existing file is not 32 bytes.
51pub fn load_or_create(path: &Path) -> Result<[u8; 32]> {
52    schubert::crypto::KeyStore::load_or_create(path).map_err(|e| IjimaError::Store {
53        detail: format!("issuer key {}: {e}", path.display()),
54    })
55}
56
57/// Loads an existing seed without creating one. Delegates to
58/// [`schubert::crypto::KeyStore::load`].
59///
60/// # Errors
61///
62/// Returns [`IjimaError::Store`] on I/O failure or
63/// [`IjimaError::InvalidInput`] if the file is absent or not 32 bytes.
64pub fn load(path: &Path) -> Result<[u8; 32]> {
65    schubert::crypto::KeyStore::load(path).map_err(|e| IjimaError::Store {
66        detail: format!("issuer key {}: {e}", path.display()),
67    })
68}
69
70/// Formats a seed's derived public key as lowercase hex (for display).
71pub fn public_key_hex(seed: &[u8; 32]) -> String {
72    use ed25519_dalek::SigningKey;
73    SigningKey::from_bytes(seed)
74        .verifying_key()
75        .to_bytes()
76        .iter()
77        .map(|b| format!("{b:02x}"))
78        .collect()
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    #[test]
86    fn load_or_create_then_load_round_trips() {
87        let dir = tempfile_dir();
88        let path = dir.join(KEY_FILENAME);
89        let created = load_or_create(&path).expect("create");
90        // Second call must read the same seed, not regenerate.
91        let loaded = load_or_create(&path).expect("load");
92        assert_eq!(created, loaded);
93        let direct = load(&path).expect("direct load");
94        assert_eq!(created, direct);
95    }
96
97    #[test]
98    fn public_key_hex_is_64_lowercase_chars() {
99        let seed = [1u8; 32];
100        let hex = public_key_hex(&seed);
101        assert_eq!(hex.len(), 64);
102        assert!(
103            hex.chars()
104                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
105        );
106    }
107
108    #[test]
109    fn wrong_size_key_file_rejected() {
110        let dir = tempfile_dir();
111        let path = dir.join(KEY_FILENAME);
112        std::fs::write(&path, b"too short").unwrap();
113        assert!(load(&path).is_err());
114    }
115
116    fn tempfile_dir() -> PathBuf {
117        let dir = std::env::temp_dir().join(format!(
118            "ijima-keystore-test-{}",
119            std::time::SystemTime::now()
120                .duration_since(std::time::UNIX_EPOCH)
121                .unwrap()
122                .as_nanos()
123        ));
124        std::fs::create_dir_all(&dir).unwrap();
125        dir
126    }
127}