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    // Env IJIMA_KEY / config `issuer_key` override the data-dir default;
28    // the data dir itself resolves env > file > ~/.ijima (see `config`).
29    let file = crate::config::load().ok();
30    if let Some(p) = crate::config::resolve_path("IJIMA_KEY", file.and_then(|c| c.issuer_key)) {
31        return Ok(p);
32    }
33    Ok(crate::config::resolve_data_dir()?.join(KEY_FILENAME))
34}
35
36/// Loads the seed at `path`, or creates it with a fresh random value if
37/// absent (mode `0600` on Unix). Used by both the daemon (first start)
38/// and the `ijima token issue` CLI. Delegates to
39/// [`schubert::crypto::KeyStore::load_or_create`].
40///
41/// # Errors
42///
43/// Returns [`IjimaError::Store`] on I/O failure or
44/// [`IjimaError::InvalidInput`] if an existing file is not 32 bytes.
45pub fn load_or_create(path: &Path) -> Result<[u8; 32]> {
46    schubert::crypto::KeyStore::load_or_create(path).map_err(|e| IjimaError::Store {
47        detail: format!("issuer key {}: {e}", path.display()),
48    })
49}
50
51/// Loads an existing seed without creating one. Delegates to
52/// [`schubert::crypto::KeyStore::load`].
53///
54/// # Errors
55///
56/// Returns [`IjimaError::Store`] on I/O failure or
57/// [`IjimaError::InvalidInput`] if the file is absent or not 32 bytes.
58pub fn load(path: &Path) -> Result<[u8; 32]> {
59    schubert::crypto::KeyStore::load(path).map_err(|e| IjimaError::Store {
60        detail: format!("issuer key {}: {e}", path.display()),
61    })
62}
63
64/// Formats a seed's derived public key as lowercase hex (for display).
65pub fn public_key_hex(seed: &[u8; 32]) -> String {
66    use ed25519_dalek::SigningKey;
67    SigningKey::from_bytes(seed)
68        .verifying_key()
69        .to_bytes()
70        .iter()
71        .map(|b| format!("{b:02x}"))
72        .collect()
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    #[test]
80    fn load_or_create_then_load_round_trips() {
81        let dir = tempfile_dir();
82        let path = dir.join(KEY_FILENAME);
83        let created = load_or_create(&path).expect("create");
84        // Second call must read the same seed, not regenerate.
85        let loaded = load_or_create(&path).expect("load");
86        assert_eq!(created, loaded);
87        let direct = load(&path).expect("direct load");
88        assert_eq!(created, direct);
89    }
90
91    #[test]
92    fn public_key_hex_is_64_lowercase_chars() {
93        let seed = [1u8; 32];
94        let hex = public_key_hex(&seed);
95        assert_eq!(hex.len(), 64);
96        assert!(
97            hex.chars()
98                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
99        );
100    }
101
102    #[test]
103    fn wrong_size_key_file_rejected() {
104        let dir = tempfile_dir();
105        let path = dir.join(KEY_FILENAME);
106        std::fs::write(&path, b"too short").unwrap();
107        assert!(load(&path).is_err());
108    }
109
110    fn tempfile_dir() -> PathBuf {
111        let dir = std::env::temp_dir().join(format!(
112            "ijima-keystore-test-{}",
113            std::time::SystemTime::now()
114                .duration_since(std::time::UNIX_EPOCH)
115                .unwrap()
116                .as_nanos()
117        ));
118        std::fs::create_dir_all(&dir).unwrap();
119        dir
120    }
121}