feat: Phase 8 — encrypt credentials at rest, DHT refresh, pkarr eval
- Credentials now encrypted with ChaCha20-Poly1305 using node key - Auto-detects plaintext JSON for migration from existing installs - Added did:dht auto-refresh background task (every 2 hours) - Documented pkarr evaluation findings Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -106,15 +106,74 @@ pub async fn load_credentials(data_dir: &Path) -> Result<CredentialStore> {
|
||||
if !path.exists() {
|
||||
return Ok(CredentialStore::default());
|
||||
}
|
||||
let data = fs::read_to_string(&path).await.context("Reading credentials")?;
|
||||
serde_json::from_str(&data).context("Parsing credentials")
|
||||
let raw = fs::read(&path).await.context("Reading credentials")?;
|
||||
// Detect plaintext JSON (migration path) vs encrypted binary
|
||||
if raw.first().map_or(false, |b| *b == b'[' || *b == b'{') {
|
||||
let data = String::from_utf8(raw).context("UTF-8 credentials")?;
|
||||
return serde_json::from_str(&data).context("Parsing credentials");
|
||||
}
|
||||
// Encrypted: decrypt using node key
|
||||
let key = load_encryption_key(data_dir).await?;
|
||||
let plaintext = decrypt_credentials(&raw, &key)?;
|
||||
serde_json::from_slice(&plaintext).context("Parsing decrypted credentials")
|
||||
}
|
||||
|
||||
pub async fn save_credentials(data_dir: &Path, store: &CredentialStore) -> Result<()> {
|
||||
ensure_dir(data_dir).await?;
|
||||
let path = store_path(data_dir);
|
||||
let data = serde_json::to_string_pretty(store)?;
|
||||
fs::write(&path, data).await.context("Writing credentials")
|
||||
let data = serde_json::to_vec(store)?;
|
||||
// Encrypt using node key
|
||||
let key = load_encryption_key(data_dir).await?;
|
||||
let encrypted = encrypt_credentials(&data, &key)?;
|
||||
fs::write(&path, encrypted).await.context("Writing credentials")
|
||||
}
|
||||
|
||||
/// Derive a 32-byte encryption key from the node's identity key via SHA-256.
|
||||
async fn load_encryption_key(data_dir: &Path) -> Result<[u8; 32]> {
|
||||
let node_key_path = data_dir.join("identity").join("node_key");
|
||||
let key_bytes = fs::read(&node_key_path).await.context("Reading node key for credential encryption")?;
|
||||
use sha2::{Sha256, Digest};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(b"archipelago-credential-store-v1");
|
||||
hasher.update(&key_bytes);
|
||||
let hash = hasher.finalize();
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&hash);
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
fn encrypt_credentials(data: &[u8], key: &[u8; 32]) -> Result<Vec<u8>> {
|
||||
use chacha20poly1305::aead::{Aead, KeyInit};
|
||||
let nonce_bytes: [u8; 12] = rand::random();
|
||||
let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(key)
|
||||
.map_err(|e| anyhow::anyhow!("Cipher init: {}", e))?;
|
||||
let ciphertext = cipher
|
||||
.encrypt(
|
||||
chacha20poly1305::aead::generic_array::GenericArray::from_slice(&nonce_bytes),
|
||||
data,
|
||||
)
|
||||
.map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?;
|
||||
let mut output = Vec::with_capacity(12 + ciphertext.len());
|
||||
output.extend_from_slice(&nonce_bytes);
|
||||
output.extend_from_slice(&ciphertext);
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn decrypt_credentials(data: &[u8], key: &[u8; 32]) -> Result<Vec<u8>> {
|
||||
use chacha20poly1305::aead::{Aead, KeyInit};
|
||||
if data.len() < 12 {
|
||||
anyhow::bail!("Encrypted credentials too short");
|
||||
}
|
||||
let nonce = &data[..12];
|
||||
let ciphertext = &data[12..];
|
||||
let cipher = chacha20poly1305::ChaCha20Poly1305::new_from_slice(key)
|
||||
.map_err(|e| anyhow::anyhow!("Cipher init: {}", e))?;
|
||||
cipher
|
||||
.decrypt(
|
||||
chacha20poly1305::aead::generic_array::GenericArray::from_slice(nonce),
|
||||
ciphertext,
|
||||
)
|
||||
.map_err(|_| anyhow::anyhow!("Credential decryption failed — key mismatch or corruption"))
|
||||
}
|
||||
|
||||
/// Issue a new Verifiable Credential following W3C VC Data Model 2.0.
|
||||
|
||||
@@ -156,6 +156,36 @@ impl Server {
|
||||
});
|
||||
}
|
||||
|
||||
// did:dht auto-refresh — re-publish DHT records every 2 hours
|
||||
if config.nostr_discovery_enabled {
|
||||
let data_dir = config.data_dir.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(7200));
|
||||
loop {
|
||||
interval.tick().await;
|
||||
let identity_dir = data_dir.join("identity");
|
||||
let node_key_path = identity_dir.join("node_key");
|
||||
if !node_key_path.exists() {
|
||||
continue;
|
||||
}
|
||||
match tokio::fs::read(&node_key_path).await {
|
||||
Ok(key_bytes) if key_bytes.len() == 32 => {
|
||||
let mut seed = [0u8; 32];
|
||||
seed.copy_from_slice(&key_bytes);
|
||||
let signing_key = ed25519_dalek::SigningKey::from_bytes(&seed);
|
||||
match crate::network::did_dht::create_and_publish(&signing_key, &[]).await {
|
||||
Ok(did) => tracing::info!(did = %did, "did:dht record refreshed"),
|
||||
Err(e) => tracing::debug!("did:dht refresh (non-fatal): {}", e),
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
tracing::debug!("did:dht refresh skipped: no valid node key");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Container health monitoring — auto-restart unhealthy containers
|
||||
// Respects webhook config: skips when disabled or ContainerCrash not subscribed
|
||||
crate::health_monitor::spawn_health_monitor(state_manager.clone(), config.data_dir.clone());
|
||||
|
||||
Reference in New Issue
Block a user