Aliases: human-readable names mapped to fingerprints
Server: - POST /v1/alias/register — claim an alias (one per fingerprint) - GET /v1/alias/resolve/:name — alias → fingerprint - GET /v1/alias/whois/:fingerprint — fingerprint → alias (reverse) - GET /v1/alias/list — list all aliases - Bidirectional mapping in sled (a:name→fp, fp:fp→name) - One alias per person, re-registering replaces old alias Web client: - /alias <name> — register your alias - /aliases — list all registered aliases - /info — now shows alias alongside fingerprint - Peer input accepts @alias (resolved before sending) - Received messages show @alias instead of fingerprint - DM: paste @alias or fingerprint in peer input CLI TUI: - /alias <name> — register alias - /aliases — list all aliases - /peer @alias — resolves alias to fingerprint - Alias resolution displayed in system messages Addressing model: - @manwe (local) → server resolves → fingerprint - @manwe.b1.example.com (federated) → DNS resolve (Phase 3) - Raw fingerprint → always works, no resolution Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -54,7 +54,7 @@ impl App {
|
|||||||
} else {
|
} else {
|
||||||
messages.lock().unwrap().push(ChatLine {
|
messages.lock().unwrap().push(ChatLine {
|
||||||
sender: "system".into(),
|
sender: "system".into(),
|
||||||
text: "No peer set. Use /peer <fingerprint> to start chatting".into(),
|
text: "No peer set. Use /peer <fp>, /peer @alias, or /g <group>".into(),
|
||||||
is_system: true,
|
is_system: true,
|
||||||
is_self: false,
|
is_self: false,
|
||||||
});
|
});
|
||||||
@@ -62,7 +62,7 @@ impl App {
|
|||||||
|
|
||||||
messages.lock().unwrap().push(ChatLine {
|
messages.lock().unwrap().push(ChatLine {
|
||||||
sender: "system".into(),
|
sender: "system".into(),
|
||||||
text: "Commands: /peer <fp>, /info, /quit".into(),
|
text: "Commands: /alias <name>, /peer <fp|@alias>, /g <group>, /info, /quit".into(),
|
||||||
is_system: true,
|
is_system: true,
|
||||||
is_self: false,
|
is_self: false,
|
||||||
});
|
});
|
||||||
@@ -181,8 +181,25 @@ impl App {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if text.starts_with("/alias ") {
|
||||||
|
let name = text[7..].trim();
|
||||||
|
self.register_alias(name, client).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if text == "/aliases" {
|
||||||
|
self.list_aliases(client).await;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if text.starts_with("/peer ") {
|
if text.starts_with("/peer ") {
|
||||||
let fp = text[6..].trim().to_string();
|
let raw = text[6..].trim().to_string();
|
||||||
|
let fp = if raw.starts_with('@') {
|
||||||
|
match self.resolve_alias(&raw[1..], client).await {
|
||||||
|
Some(resolved) => resolved,
|
||||||
|
None => return,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
raw
|
||||||
|
};
|
||||||
self.add_message(ChatLine {
|
self.add_message(ChatLine {
|
||||||
sender: "system".into(),
|
sender: "system".into(),
|
||||||
text: format!("Peer set to {}", fp),
|
text: format!("Peer set to {}", fp),
|
||||||
@@ -544,6 +561,70 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn register_alias(&self, name: &str, client: &ServerClient) {
|
||||||
|
let url = format!("{}/v1/alias/register", client.base_url);
|
||||||
|
match client.client.post(&url)
|
||||||
|
.json(&serde_json::json!({"alias": name, "fingerprint": normfp(&self.our_fp)}))
|
||||||
|
.send().await
|
||||||
|
{
|
||||||
|
Ok(resp) => {
|
||||||
|
if let Ok(data) = resp.json::<serde_json::Value>().await {
|
||||||
|
if let Some(err) = data.get("error") {
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", err), is_system: true, is_self: false });
|
||||||
|
} else {
|
||||||
|
let alias = data.get("alias").and_then(|v| v.as_str()).unwrap_or(name);
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: format!("Alias @{} registered", alias), is_system: true, is_self: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn resolve_alias(&self, name: &str, client: &ServerClient) -> Option<String> {
|
||||||
|
let url = format!("{}/v1/alias/resolve/{}", client.base_url, name);
|
||||||
|
match client.client.get(&url).send().await {
|
||||||
|
Ok(resp) => {
|
||||||
|
if let Ok(data) = resp.json::<serde_json::Value>().await {
|
||||||
|
if let Some(fp) = data.get("fingerprint").and_then(|v| v.as_str()) {
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: format!("@{} → {}", name, fp), is_system: true, is_self: false });
|
||||||
|
return Some(fp.to_string());
|
||||||
|
}
|
||||||
|
if let Some(err) = data.get("error") {
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: format!("Unknown alias @{}: {}", name, err), is_system: true, is_self: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false });
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_aliases(&self, client: &ServerClient) {
|
||||||
|
let url = format!("{}/v1/alias/list", client.base_url);
|
||||||
|
match client.client.get(&url).send().await {
|
||||||
|
Ok(resp) => {
|
||||||
|
if let Ok(data) = resp.json::<serde_json::Value>().await {
|
||||||
|
if let Some(aliases) = data.get("aliases").and_then(|v| v.as_array()) {
|
||||||
|
if aliases.is_empty() {
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: "No aliases".into(), is_system: true, is_self: false });
|
||||||
|
} else {
|
||||||
|
for a in aliases {
|
||||||
|
let name = a.get("alias").and_then(|v| v.as_str()).unwrap_or("?");
|
||||||
|
let fp = a.get("fingerprint").and_then(|v| v.as_str()).unwrap_or("?");
|
||||||
|
self.add_message(ChatLine { sender: "system".into(), text: format!(" @{} → {}...", name, &fp[..fp.len().min(16)]), is_system: true, is_self: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => self.add_message(ChatLine { sender: "system".into(), text: format!("Error: {}", e), is_system: true, is_self: false }),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normfp(fp: &str) -> String {
|
fn normfp(fp: &str) -> String {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ pub struct Database {
|
|||||||
pub keys: sled::Tree,
|
pub keys: sled::Tree,
|
||||||
pub messages: sled::Tree,
|
pub messages: sled::Tree,
|
||||||
pub groups: sled::Tree,
|
pub groups: sled::Tree,
|
||||||
|
pub aliases: sled::Tree,
|
||||||
_db: sled::Db,
|
_db: sled::Db,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,10 +14,12 @@ impl Database {
|
|||||||
let keys = db.open_tree("keys")?;
|
let keys = db.open_tree("keys")?;
|
||||||
let messages = db.open_tree("messages")?;
|
let messages = db.open_tree("messages")?;
|
||||||
let groups = db.open_tree("groups")?;
|
let groups = db.open_tree("groups")?;
|
||||||
|
let aliases = db.open_tree("aliases")?;
|
||||||
Ok(Database {
|
Ok(Database {
|
||||||
keys,
|
keys,
|
||||||
messages,
|
messages,
|
||||||
groups,
|
groups,
|
||||||
|
aliases,
|
||||||
_db: db,
|
_db: db,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
127
warzone/crates/warzone-server/src/routes/aliases.rs
Normal file
127
warzone/crates/warzone-server/src/routes/aliases.rs
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
routing::{get, post},
|
||||||
|
Json, Router,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::errors::AppResult;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
pub fn routes() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/alias/register", post(register_alias))
|
||||||
|
.route("/alias/resolve/:name", get(resolve_alias))
|
||||||
|
.route("/alias/list", get(list_aliases))
|
||||||
|
.route("/alias/whois/:fingerprint", get(reverse_lookup))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_fp(fp: &str) -> String {
|
||||||
|
fp.chars()
|
||||||
|
.filter(|c| c.is_ascii_hexdigit())
|
||||||
|
.collect::<String>()
|
||||||
|
.to_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_alias(name: &str) -> String {
|
||||||
|
name.trim()
|
||||||
|
.to_lowercase()
|
||||||
|
.chars()
|
||||||
|
.filter(|c| c.is_alphanumeric() || *c == '_' || *c == '-')
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct RegisterRequest {
|
||||||
|
alias: String,
|
||||||
|
fingerprint: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register an alias. One alias per fingerprint, one fingerprint per alias.
|
||||||
|
async fn register_alias(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<RegisterRequest>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let alias = normalize_alias(&req.alias);
|
||||||
|
let fp = normalize_fp(&req.fingerprint);
|
||||||
|
|
||||||
|
if alias.is_empty() || alias.len() > 32 {
|
||||||
|
return Ok(Json(serde_json::json!({ "error": "alias must be 1-32 alphanumeric chars" })));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if alias is taken by someone else
|
||||||
|
if let Some(existing) = state.db.aliases.get(format!("a:{}", alias).as_bytes())? {
|
||||||
|
let existing_fp = String::from_utf8_lossy(&existing).to_string();
|
||||||
|
if existing_fp != fp {
|
||||||
|
return Ok(Json(serde_json::json!({ "error": "alias already taken" })));
|
||||||
|
}
|
||||||
|
// Same person re-registering — ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove old alias for this fingerprint (one alias per person)
|
||||||
|
if let Some(old_alias) = state.db.aliases.get(format!("fp:{}", fp).as_bytes())? {
|
||||||
|
let old = String::from_utf8_lossy(&old_alias).to_string();
|
||||||
|
state.db.aliases.remove(format!("a:{}", old).as_bytes())?;
|
||||||
|
tracing::info!("Removed old alias '{}' for {}", old, fp);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store both directions: alias→fp and fp→alias
|
||||||
|
state.db.aliases.insert(format!("a:{}", alias).as_bytes(), fp.as_bytes())?;
|
||||||
|
state.db.aliases.insert(format!("fp:{}", fp).as_bytes(), alias.as_bytes())?;
|
||||||
|
state.db.aliases.flush()?;
|
||||||
|
|
||||||
|
tracing::info!("Alias '{}' registered for {}", alias, fp);
|
||||||
|
Ok(Json(serde_json::json!({ "ok": true, "alias": alias, "fingerprint": fp })))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve an alias to a fingerprint.
|
||||||
|
async fn resolve_alias(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(name): Path<String>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let alias = normalize_alias(&name);
|
||||||
|
|
||||||
|
match state.db.aliases.get(format!("a:{}", alias).as_bytes())? {
|
||||||
|
Some(data) => {
|
||||||
|
let fp = String::from_utf8_lossy(&data).to_string();
|
||||||
|
Ok(Json(serde_json::json!({ "alias": alias, "fingerprint": fp })))
|
||||||
|
}
|
||||||
|
None => Ok(Json(serde_json::json!({ "error": "alias not found" }))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reverse lookup: fingerprint → alias.
|
||||||
|
async fn reverse_lookup(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(fingerprint): Path<String>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let fp = normalize_fp(&fingerprint);
|
||||||
|
|
||||||
|
match state.db.aliases.get(format!("fp:{}", fp).as_bytes())? {
|
||||||
|
Some(data) => {
|
||||||
|
let alias = String::from_utf8_lossy(&data).to_string();
|
||||||
|
Ok(Json(serde_json::json!({ "fingerprint": fp, "alias": alias })))
|
||||||
|
}
|
||||||
|
None => Ok(Json(serde_json::json!({ "fingerprint": fp, "alias": null }))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all aliases.
|
||||||
|
async fn list_aliases(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> AppResult<Json<serde_json::Value>> {
|
||||||
|
let aliases: Vec<serde_json::Value> = state
|
||||||
|
.db
|
||||||
|
.aliases
|
||||||
|
.scan_prefix(b"a:")
|
||||||
|
.filter_map(|item| {
|
||||||
|
item.ok().map(|(k, v)| {
|
||||||
|
let alias = String::from_utf8_lossy(&k[2..]).to_string();
|
||||||
|
let fp = String::from_utf8_lossy(&v).to_string();
|
||||||
|
serde_json::json!({ "alias": alias, "fingerprint": fp })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({ "aliases": aliases, "count": aliases.len() })))
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
mod aliases;
|
||||||
mod groups;
|
mod groups;
|
||||||
mod health;
|
mod health;
|
||||||
mod keys;
|
mod keys;
|
||||||
@@ -14,6 +15,7 @@ pub fn router() -> Router<AppState> {
|
|||||||
.merge(keys::routes())
|
.merge(keys::routes())
|
||||||
.merge(messages::routes())
|
.merge(messages::routes())
|
||||||
.merge(groups::routes())
|
.merge(groups::routes())
|
||||||
|
.merge(aliases::routes())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Web UI router (served at root, outside /v1)
|
/// Web UI router (served at root, outside /v1)
|
||||||
|
|||||||
@@ -301,7 +301,13 @@ async function pollMessages() {
|
|||||||
const aesKey = await fetchPeerKey(env.from);
|
const aesKey = await fetchPeerKey(env.from);
|
||||||
const ct = fromHex(env.ciphertext);
|
const ct = fromHex(env.ciphertext);
|
||||||
const text = await aesDecrypt(aesKey, ct);
|
const text = await aesDecrypt(aesKey, ct);
|
||||||
const fromLabel = formatFP(fromHex(env.from)).slice(0, 19);
|
let fromLabel = env.from.slice(0, 12);
|
||||||
|
// Try to resolve alias
|
||||||
|
try {
|
||||||
|
const ar = await fetch(SERVER + '/v1/alias/whois/' + env.from);
|
||||||
|
const ad = await ar.json();
|
||||||
|
if (ad.alias) fromLabel = '@' + ad.alias;
|
||||||
|
} catch(e) {}
|
||||||
const groupTag = env.group ? ' [' + env.group + ']' : '';
|
const groupTag = env.group ? ' [' + env.group + ']' : '';
|
||||||
addMsg(fromLabel + groupTag, text, false);
|
addMsg(fromLabel + groupTag, text, false);
|
||||||
continue;
|
continue;
|
||||||
@@ -383,9 +389,8 @@ async function enterChat() {
|
|||||||
await registerKey();
|
await registerKey();
|
||||||
addSys('Identity loaded: ' + myFingerprint);
|
addSys('Identity loaded: ' + myFingerprint);
|
||||||
addSys('Key registered with server');
|
addSys('Key registered with server');
|
||||||
addSys('DM: paste peer fingerprint above');
|
addSys('DM: paste peer fingerprint or @alias above');
|
||||||
addSys('Groups: /gcreate <name> · /gjoin <name> · /g <name> · /glist');
|
addSys('/alias <name> · /g <group> · /glist · /info · /clear');
|
||||||
addSys('Other: /info · /clear · /dm (switch back to DM mode)');
|
|
||||||
|
|
||||||
const savedPeer = localStorage.getItem('wz-peer');
|
const savedPeer = localStorage.getItem('wz-peer');
|
||||||
if (savedPeer) $peerInput.value = savedPeer;
|
if (savedPeer) $peerInput.value = savedPeer;
|
||||||
@@ -489,12 +494,36 @@ async function doSend() {
|
|||||||
if (!text) return;
|
if (!text) return;
|
||||||
|
|
||||||
// Commands
|
// Commands
|
||||||
if (text === '/info') { addSys('Fingerprint: ' + myFingerprint); return; }
|
if (text === '/info') {
|
||||||
|
const aliasResp = await fetch(SERVER + '/v1/alias/whois/' + normFP(myFingerprint));
|
||||||
|
const aliasData = await aliasResp.json();
|
||||||
|
const aliasStr = aliasData.alias ? ' (@' + aliasData.alias + ')' : '';
|
||||||
|
addSys('Fingerprint: ' + myFingerprint + aliasStr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (text === '/clear') { $messages.innerHTML = ''; return; }
|
if (text === '/clear') { $messages.innerHTML = ''; return; }
|
||||||
if (text === '/quit') { window.close(); return; }
|
if (text === '/quit') { window.close(); return; }
|
||||||
if (text === '/glist') { await groupList(); return; }
|
if (text === '/glist') { await groupList(); return; }
|
||||||
if (text === '/dm') { currentGroup = null; addSys('Switched to DM mode'); $peerInput.value = localStorage.getItem('wz-peer') || ''; return; }
|
if (text === '/dm') { currentGroup = null; addSys('Switched to DM mode'); $peerInput.value = localStorage.getItem('wz-peer') || ''; return; }
|
||||||
|
if (text === '/aliases') {
|
||||||
|
const resp = await fetch(SERVER + '/v1/alias/list');
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.aliases.length === 0) { addSys('No aliases registered'); }
|
||||||
|
else { for (const a of data.aliases) addSys(' @' + a.alias + ' → ' + a.fingerprint.slice(0,16) + '...'); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.startsWith('/alias ')) {
|
||||||
|
const name = text.slice(7).trim();
|
||||||
|
const resp = await fetch(SERVER + '/v1/alias/register', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ alias: name, fingerprint: normFP(myFingerprint) })
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.error) { addSys('Error: ' + data.error); }
|
||||||
|
else { addSys('Alias @' + data.alias + ' registered'); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (text.startsWith('/gcreate ')) { await groupCreate(text.slice(9).trim()); return; }
|
if (text.startsWith('/gcreate ')) { await groupCreate(text.slice(9).trim()); return; }
|
||||||
if (text.startsWith('/gjoin ')) { await groupJoin(text.slice(7).trim()); return; }
|
if (text.startsWith('/gjoin ')) { await groupJoin(text.slice(7).trim()); return; }
|
||||||
if (text.startsWith('/g ')) { await groupSwitch(text.slice(3).trim()); return; }
|
if (text.startsWith('/g ')) { await groupSwitch(text.slice(3).trim()); return; }
|
||||||
@@ -509,10 +538,20 @@ async function doSend() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// DM
|
// DM — resolve @alias if needed
|
||||||
const peer = $peerInput.value.trim();
|
let peer = $peerInput.value.trim();
|
||||||
if (!peer || peer.startsWith('#')) { addSys('Set a peer fingerprint or use /g <group>'); return; }
|
if (!peer || peer.startsWith('#')) { addSys('Set a peer fingerprint/@alias or use /g <group>'); return; }
|
||||||
localStorage.setItem('wz-peer', peer);
|
|
||||||
|
if (peer.startsWith('@')) {
|
||||||
|
const aliasName = peer.slice(1);
|
||||||
|
const resp = await fetch(SERVER + '/v1/alias/resolve/' + aliasName);
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.error) { addSys('Unknown alias @' + aliasName); return; }
|
||||||
|
peer = data.fingerprint;
|
||||||
|
addSys('Resolved @' + aliasName + ' → ' + peer.slice(0,16) + '...');
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem('wz-peer', $peerInput.value.trim());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await sendEncrypted(peer, text);
|
await sendEncrypted(peer, text);
|
||||||
|
|||||||
Reference in New Issue
Block a user