387 lines
14 KiB
Rust
387 lines
14 KiB
Rust
//! Node identity: persistent Ed25519 key for private identification.
|
|
//! Enables future P2P features (file transfer, streaming, ecash/Lightning).
|
|
//! Supports did:key (W3C) for Web5/DID interoperability.
|
|
|
|
use anyhow::{Context, Result};
|
|
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
|
|
use rand::rngs::OsRng;
|
|
use std::path::{Path, PathBuf};
|
|
use tokio::fs;
|
|
|
|
const NODE_KEY_FILE: &str = "node_key";
|
|
const NODE_KEY_PUB_FILE: &str = "node_key.pub";
|
|
|
|
/// Persistent node identity (Ed25519 keypair).
|
|
/// Survives reboots; used for signing, verification, and node address.
|
|
pub struct NodeIdentity {
|
|
signing_key: SigningKey,
|
|
_identity_dir: PathBuf,
|
|
}
|
|
|
|
impl NodeIdentity {
|
|
/// Load existing identity or create and persist a new one.
|
|
pub async fn load_or_create(identity_dir: &Path) -> Result<Self> {
|
|
fs::create_dir_all(identity_dir)
|
|
.await
|
|
.context("Failed to create identity directory")?;
|
|
|
|
let key_path = identity_dir.join(NODE_KEY_FILE);
|
|
let pub_path = identity_dir.join(NODE_KEY_PUB_FILE);
|
|
|
|
let signing_key = if key_path.exists() {
|
|
let bytes = fs::read(&key_path)
|
|
.await
|
|
.context("Failed to read node key")?;
|
|
let arr: [u8; 32] = bytes
|
|
.try_into()
|
|
.map_err(|_| anyhow::anyhow!("Invalid node key length"))?;
|
|
let key = SigningKey::from_bytes(&arr);
|
|
let pubkey_hex = hex::encode(key.verifying_key().as_bytes());
|
|
tracing::info!("Loaded existing node identity (pubkey: {}...)", &pubkey_hex[..16]);
|
|
key
|
|
} else {
|
|
let signing_key = SigningKey::generate(&mut OsRng);
|
|
fs::write(&key_path, signing_key.to_bytes())
|
|
.await
|
|
.context("Failed to write node key")?;
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600))
|
|
.await
|
|
.context("Failed to set key permissions")?;
|
|
}
|
|
fs::write(&pub_path, signing_key.verifying_key().as_bytes())
|
|
.await
|
|
.context("Failed to write node public key")?;
|
|
tracing::info!("🔑 Generated new node identity at {}", identity_dir.display());
|
|
signing_key
|
|
};
|
|
|
|
Ok(Self {
|
|
signing_key,
|
|
_identity_dir: identity_dir.to_path_buf(),
|
|
})
|
|
}
|
|
|
|
/// Access the signing key (for key derivation, e.g. mesh encryption).
|
|
pub fn signing_key(&self) -> &SigningKey {
|
|
&self.signing_key
|
|
}
|
|
|
|
/// Public key as hex string (for ServerInfo, Nostr, etc.)
|
|
pub fn pubkey_hex(&self) -> String {
|
|
hex::encode(self.signing_key.verifying_key().as_bytes())
|
|
}
|
|
|
|
/// Stable node ID derived from pubkey (first 16 chars of hex).
|
|
pub fn node_id(&self) -> String {
|
|
self.pubkey_hex().chars().take(16).collect()
|
|
}
|
|
|
|
/// Sign data; returns hex-encoded signature.
|
|
pub fn sign(&self, data: &[u8]) -> String {
|
|
hex::encode(self.signing_key.sign(data).to_bytes())
|
|
}
|
|
|
|
/// Verify a signature from a peer (pubkey hex, data, signature hex).
|
|
pub fn verify(pubkey_hex: &str, data: &[u8], sig_hex: &str) -> Result<bool> {
|
|
let bytes = hex::decode(pubkey_hex).context("Invalid pubkey hex")?;
|
|
let verifying_key = VerifyingKey::from_bytes(
|
|
bytes
|
|
.as_slice()
|
|
.try_into()
|
|
.map_err(|_| anyhow::anyhow!("Invalid pubkey length"))?,
|
|
)?;
|
|
let sig_bytes = hex::decode(sig_hex).context("Invalid signature hex")?;
|
|
let sig = Signature::from_bytes(
|
|
sig_bytes
|
|
.as_slice()
|
|
.try_into()
|
|
.map_err(|_| anyhow::anyhow!("Invalid signature length"))?,
|
|
);
|
|
Ok(verifying_key.verify(data, &sig).is_ok())
|
|
}
|
|
|
|
/// Node address format for invites: archipelago://<onion>#<pubkey>
|
|
pub fn node_address(&self, onion: &str) -> String {
|
|
format!("archipelago://{}#{}", onion.trim_end_matches('/'), self.pubkey_hex())
|
|
}
|
|
|
|
/// DID in did:key format (W3C did:key method, Ed25519).
|
|
/// Format: did:key:z<base58btc(multicodec_ed25519_pub + 32-byte pubkey)>
|
|
pub fn did_key(&self) -> Result<String> {
|
|
did_key_from_pubkey_hex(&self.pubkey_hex())
|
|
.map_err(|e| anyhow::anyhow!("Invalid pubkey hex: {}", e))
|
|
}
|
|
|
|
}
|
|
|
|
/// Convert Ed25519 pubkey (hex) to did:key format.
|
|
/// Used by RPC when identity is loaded from state.
|
|
pub fn did_key_from_pubkey_hex(pubkey_hex: &str) -> Result<String> {
|
|
let bytes = hex::decode(pubkey_hex).context("Invalid pubkey hex")?;
|
|
if bytes.len() != 32 {
|
|
return Err(anyhow::anyhow!("Invalid pubkey length"));
|
|
}
|
|
let mut multicodec_pubkey = [0u8; 34];
|
|
multicodec_pubkey[0] = 0xed;
|
|
multicodec_pubkey[1] = 0x01;
|
|
multicodec_pubkey[2..34].copy_from_slice(&bytes);
|
|
Ok(format!("did:key:z{}", bs58::encode(multicodec_pubkey).into_string()))
|
|
}
|
|
|
|
/// Generate a W3C DID Core v1.0 compliant DID Document from an Ed25519 public key.
|
|
/// Follows: https://www.w3.org/TR/did-core/
|
|
/// Includes: verificationMethod, authentication, assertionMethod, keyAgreement contexts.
|
|
pub fn did_document_from_pubkey_hex(pubkey_hex: &str) -> Result<serde_json::Value> {
|
|
let did = did_key_from_pubkey_hex(pubkey_hex)?;
|
|
let pubkey_bytes = hex::decode(pubkey_hex).context("Invalid pubkey hex")?;
|
|
let pubkey_multibase = format!("z{}", bs58::encode(&pubkey_bytes).into_string());
|
|
let key_id = format!("{}#key-1", did);
|
|
|
|
// Build X25519 key agreement key from Ed25519 public key
|
|
// Ed25519 -> X25519 conversion (Montgomery form)
|
|
let ed_point = curve25519_dalek::edwards::CompressedEdwardsY(
|
|
pubkey_bytes
|
|
.as_slice()
|
|
.try_into()
|
|
.map_err(|_| anyhow::anyhow!("Invalid pubkey length"))?,
|
|
);
|
|
let x25519_key = if let Some(point) = ed_point.decompress() {
|
|
let montgomery = point.to_montgomery();
|
|
format!("z{}", bs58::encode(montgomery.as_bytes()).into_string())
|
|
} else {
|
|
// Fallback: use Ed25519 key if conversion fails
|
|
pubkey_multibase.clone()
|
|
};
|
|
let x25519_key_id = format!("{}#key-x25519-1", did);
|
|
|
|
Ok(serde_json::json!({
|
|
"@context": [
|
|
"https://www.w3.org/ns/did/v1",
|
|
"https://w3id.org/security/suites/ed25519-2020/v1",
|
|
"https://w3id.org/security/suites/x25519-2020/v1"
|
|
],
|
|
"id": did,
|
|
"verificationMethod": [
|
|
{
|
|
"id": key_id,
|
|
"type": "Ed25519VerificationKey2020",
|
|
"controller": did,
|
|
"publicKeyMultibase": pubkey_multibase
|
|
},
|
|
{
|
|
"id": x25519_key_id,
|
|
"type": "X25519KeyAgreementKey2020",
|
|
"controller": did,
|
|
"publicKeyMultibase": x25519_key
|
|
}
|
|
],
|
|
"authentication": [key_id],
|
|
"assertionMethod": [key_id],
|
|
"capabilityInvocation": [key_id],
|
|
"capabilityDelegation": [key_id],
|
|
"keyAgreement": [x25519_key_id]
|
|
}))
|
|
}
|
|
|
|
/// Generate a DID Document that includes both the Ed25519 key and a Nostr secp256k1 key.
|
|
/// The Nostr key is added as an additional verification method, formally pairing
|
|
/// the two identities so a user can use either protocol.
|
|
pub fn did_document_with_nostr(
|
|
pubkey_hex: &str,
|
|
nostr_pubkey_hex: &str,
|
|
) -> Result<serde_json::Value> {
|
|
let mut doc = did_document_from_pubkey_hex(pubkey_hex)?;
|
|
let did = did_key_from_pubkey_hex(pubkey_hex)?;
|
|
let nostr_key_id = format!("{}#key-nostr-1", did);
|
|
|
|
// Add EcdsaSecp256k1VerificationKey2019 context
|
|
if let Some(contexts) = doc["@context"].as_array_mut() {
|
|
contexts.push(serde_json::json!(
|
|
"https://w3id.org/security/suites/secp256k1-2019/v1"
|
|
));
|
|
}
|
|
|
|
// Add Nostr secp256k1 key to verificationMethod array
|
|
if let Some(vms) = doc["verificationMethod"].as_array_mut() {
|
|
vms.push(serde_json::json!({
|
|
"id": nostr_key_id,
|
|
"type": "EcdsaSecp256k1VerificationKey2019",
|
|
"controller": did,
|
|
"publicKeyHex": nostr_pubkey_hex
|
|
}));
|
|
}
|
|
|
|
// Add to authentication (Nostr key can also authenticate)
|
|
if let Some(auth) = doc["authentication"].as_array_mut() {
|
|
auth.push(serde_json::json!(nostr_key_id));
|
|
}
|
|
|
|
Ok(doc)
|
|
}
|
|
|
|
/// Extract the raw 32-byte Ed25519 public key from a did:key string.
|
|
pub fn pubkey_bytes_from_did_key(did: &str) -> Result<[u8; 32]> {
|
|
let multibase_str = did
|
|
.strip_prefix("did:key:z")
|
|
.ok_or_else(|| anyhow::anyhow!("Invalid did:key format"))?;
|
|
let decoded = bs58::decode(multibase_str)
|
|
.into_vec()
|
|
.context("Invalid base58 in did:key")?;
|
|
if decoded.len() != 34 || decoded[0] != 0xed || decoded[1] != 0x01 {
|
|
return Err(anyhow::anyhow!("Invalid Ed25519 multicodec prefix"));
|
|
}
|
|
let mut pubkey = [0u8; 32];
|
|
pubkey.copy_from_slice(&decoded[2..34]);
|
|
Ok(pubkey)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn test_load_or_create_generates_new_identity() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let identity_dir = dir.path().join("identity");
|
|
|
|
let identity = NodeIdentity::load_or_create(&identity_dir).await.unwrap();
|
|
|
|
// pubkey_hex should be 64 hex chars (32 bytes)
|
|
assert_eq!(identity.pubkey_hex().len(), 64);
|
|
// node_id should be first 16 chars of pubkey_hex
|
|
assert_eq!(identity.node_id(), &identity.pubkey_hex()[..16]);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_load_or_create_persists_and_reloads() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let identity_dir = dir.path().join("identity");
|
|
|
|
let identity1 = NodeIdentity::load_or_create(&identity_dir).await.unwrap();
|
|
let pubkey1 = identity1.pubkey_hex();
|
|
|
|
let identity2 = NodeIdentity::load_or_create(&identity_dir).await.unwrap();
|
|
let pubkey2 = identity2.pubkey_hex();
|
|
|
|
assert_eq!(pubkey1, pubkey2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_sign_and_verify() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let identity = NodeIdentity::load_or_create(&dir.path().join("id")).await.unwrap();
|
|
|
|
let data = b"hello world";
|
|
let sig = identity.sign(data);
|
|
|
|
let valid = NodeIdentity::verify(&identity.pubkey_hex(), data, &sig).unwrap();
|
|
assert!(valid);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_verify_wrong_data() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let identity = NodeIdentity::load_or_create(&dir.path().join("id")).await.unwrap();
|
|
|
|
let sig = identity.sign(b"hello");
|
|
let valid = NodeIdentity::verify(&identity.pubkey_hex(), b"wrong", &sig).unwrap();
|
|
assert!(!valid);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_did_key_format() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let identity = NodeIdentity::load_or_create(&dir.path().join("id")).await.unwrap();
|
|
|
|
let did = identity.did_key().unwrap();
|
|
assert!(did.starts_with("did:key:z"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_did_key_from_pubkey_hex() {
|
|
// 32-byte all-zeros pubkey in hex
|
|
let hex = "0000000000000000000000000000000000000000000000000000000000000000";
|
|
let did = did_key_from_pubkey_hex(hex).unwrap();
|
|
assert!(did.starts_with("did:key:z"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_did_key_from_invalid_hex() {
|
|
assert!(did_key_from_pubkey_hex("not_hex").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_did_key_from_wrong_length() {
|
|
assert!(did_key_from_pubkey_hex("0011").is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_node_address_format() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let identity = NodeIdentity::load_or_create(&dir.path().join("id")).await.unwrap();
|
|
|
|
let addr = identity.node_address("abc123.onion");
|
|
assert!(addr.starts_with("archipelago://abc123.onion#"));
|
|
assert!(addr.contains(&identity.pubkey_hex()));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_did_document_w3c_structure() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let identity = NodeIdentity::load_or_create(&dir.path().join("id")).await.unwrap();
|
|
|
|
let doc = identity.did_document().unwrap();
|
|
let did = identity.did_key().unwrap();
|
|
|
|
// Verify @context
|
|
let context = doc["@context"].as_array().unwrap();
|
|
assert_eq!(context[0], "https://www.w3.org/ns/did/v1");
|
|
|
|
// Verify id matches did:key
|
|
assert_eq!(doc["id"], did);
|
|
|
|
// Verify verificationMethod has Ed25519 and X25519 keys
|
|
let vms = doc["verificationMethod"].as_array().unwrap();
|
|
assert_eq!(vms.len(), 2);
|
|
assert_eq!(vms[0]["type"], "Ed25519VerificationKey2020");
|
|
assert_eq!(vms[1]["type"], "X25519KeyAgreementKey2020");
|
|
assert_eq!(vms[0]["controller"], did);
|
|
|
|
// Verify authentication references key-1
|
|
let auth = doc["authentication"].as_array().unwrap();
|
|
assert_eq!(auth[0], format!("{}#key-1", did));
|
|
|
|
// Verify assertionMethod
|
|
assert!(doc["assertionMethod"].as_array().is_some());
|
|
|
|
// Verify keyAgreement references x25519 key
|
|
let ka = doc["keyAgreement"].as_array().unwrap();
|
|
assert_eq!(ka[0], format!("{}#key-x25519-1", did));
|
|
}
|
|
|
|
#[test]
|
|
fn test_did_document_from_pubkey_hex() {
|
|
let hex = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa3f4a18446b7e21e7e2c33";
|
|
let doc = did_document_from_pubkey_hex(hex).unwrap();
|
|
assert_eq!(doc["@context"].as_array().unwrap().len(), 3);
|
|
assert!(doc["id"].as_str().unwrap().starts_with("did:key:z"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_pubkey_bytes_from_did_key_roundtrip() {
|
|
let hex = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa3f4a18446b7e21e7e2c33";
|
|
let did = did_key_from_pubkey_hex(hex).unwrap();
|
|
let recovered = pubkey_bytes_from_did_key(&did).unwrap();
|
|
assert_eq!(hex::encode(recovered), hex);
|
|
}
|
|
|
|
#[test]
|
|
fn test_pubkey_bytes_from_invalid_did() {
|
|
assert!(pubkey_bytes_from_did_key("did:web:example.com").is_err());
|
|
assert!(pubkey_bytes_from_did_key("did:key:invalid").is_err());
|
|
}
|
|
}
|