Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ All notable changes to SSHub are documented in this file.

### Added

- **Hardware security-key badges on the keys screen** (issue #80) - identities
whose key path carries the `ssh-keygen -t *-sk` `-sk`/`_sk` marker now show
`ed25519-sk`/`ecdsa-sk` (generic `sk` for unfamiliar bases) badges instead of
the base algorithm, so hardware-backed keys are distinguishable at a glance.
`ml-dsa` filenames no longer degrade to the `dsa` badge. Connect-time
presence/PIN handling stays deferred to the hardware PoC.
- **SSH certificates are first-class on identities** (issue #75) - an identity
can carry a certificate path (identity form, `sshub identity add/edit`, same
column the importer already filled). The keys tab badges each certificate
Expand Down
139 changes: 131 additions & 8 deletions src/tui/screens/keys.rs
Original file line number Diff line number Diff line change
Expand Up @@ -446,21 +446,30 @@ fn render_agent_info(
}

fn detect_key_type(identity: &Identity) -> String {
let path = identity
let Some(path) = identity
.private_key
.as_ref()
.map(|p| p.to_string_lossy().to_lowercase())
.unwrap_or_default();
else {
return "password".into();
};

if identity.private_key.is_none() {
"password".into()
} else if path.contains("ed25519") {
"ed25519".into()
// `ssh-keygen -t ed25519-sk|ecdsa-sk` names files `id_*_sk`; the delimiter
// is load-bearing (`task/` contains "sk" but no security key does).
let is_sk = path.contains("-sk") || path.contains("_sk");
if path.contains("ed25519") {
if is_sk { "ed25519-sk" } else { "ed25519" }.into()
} else if path.contains("ecdsa") {
"ecdsa".into()
if is_sk { "ecdsa-sk" } else { "ecdsa" }.into()
} else if is_sk {
// SK-marked but unfamiliar base (a future `ssh-keygen -t` type):
// still badge it as a security key rather than mislabel it.
"sk".into()
} else if path.contains("rsa") {
"rsa".into()
} else if path.contains("dsa") {
} else if path.contains("dsa") && !path.contains("mldsa") {
// `ml-dsa` (e.g. `id_mldsa44`) is not DSA; without the guard a future
// post-quantum filename degrades to the wrong concrete badge.
"dsa".into()
} else {
"key".into()
Expand Down Expand Up @@ -699,6 +708,120 @@ mod tests {
"row4: expected password status"
);
}
/// Oracle: `ssh -Q key` (OpenSSH's own registry of supported key
/// algorithms). Asserts the SK identifiers this file labels are real
/// OpenSSH key types, not invented strings. Read-only query: touches no
/// keys and no user config. Skips when no ssh binary is present.
fn openssh_knows_sk_key_types() -> bool {
let Ok(out) = std::process::Command::new("ssh")
.arg("-Q")
.arg("key")
.output()
else {
eprintln!("skipping oracle: no ssh binary");
return false;
};
if !out.status.success() {
eprintln!("skipping oracle: `ssh -Q key` failed");
return false;
}
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout
.lines()
.any(|l| l.trim() == "sk-ssh-ed25519@openssh.com"),
"oracle changed: OpenSSH no longer lists sk-ssh-ed25519"
);
assert!(
stdout
.lines()
.any(|l| l.trim() == "sk-ecdsa-sha2-nistp256@openssh.com"),
"oracle changed: OpenSSH no longer lists sk-ecdsa-sha2-nistp256"
);
true
}

#[test]
fn sk_key_paths_labelled_as_security_keys() {
if !openssh_knows_sk_key_types() {
return;
}
// (private-key path, expected card badge). The `-sk`/`_sk` filename
// markers are the `ssh-keygen -t ed25519-sk|ecdsa-sk` conventions.
for (path, expected) in [
("/home/u/.ssh/id_ed25519_sk", "ed25519-sk"),
("/home/u/.ssh/id_ecdsa_sk", "ecdsa-sk"),
("/home/u/.ssh/id_ed25519-sk", "ed25519-sk"),
("/home/u/.ssh/id_ecdsa-sk", "ecdsa-sk"),
("/home/u/.ssh/work_ed25519_sk", "ed25519-sk"),
("~/.ssh/id_ecdsa_sk", "ecdsa-sk"),
// SK-marked but unfamiliar base: still badged as a security key.
("/home/u/.ssh/my_sk_key", "sk"),
] {
assert_eq!(
detect_key_type(&identity(Some(path), false)),
expected,
"path: {path}"
);
}
}

#[test]
fn regular_key_type_labels_unchanged() {
for (path, expected) in [
("/home/u/.ssh/id_ed25519", "ed25519"),
("/home/u/.ssh/id_ecdsa", "ecdsa"),
("/home/u/.ssh/id_rsa", "rsa"),
("/home/u/.ssh/id_dsa", "dsa"),
("/home/u/.ssh/sshub_selectel-core", "key"),
// "task/" contains "sk" but no `-sk`/`_sk` marker: not a key.
("/home/u/task/id_ed25519", "ed25519"),
] {
assert_eq!(
detect_key_type(&identity(Some(path), false)),
expected,
"path: {path}"
);
}
assert_eq!(detect_key_type(&identity(None, true)), "password");
}

#[test]
fn unknown_key_types_degrade_to_generic_key() {
// Future/unknown algorithms must not surface as SK nor as a wrong
// concrete type; the card falls back to the generic badge.
for path in [
"/home/u/.ssh/id_mldsa44",
"/home/u/.ssh/mykey",
"/home/u/.ssh/id_xmss",
] {
assert_eq!(
detect_key_type(&identity(Some(path), false)),
"key",
"path: {path}"
);
}
}

#[test]
fn sk_card_renders_security_key_badge() {
let mut buf = Buffer::empty(Rect::new(0, 0, CARD_W, CARD_H));
let id = identity(Some("/home/u/.ssh/id_ed25519_sk"), false);
render_card(
&mut buf,
Rect::new(0, 0, CARD_W, CARD_H),
&id,
false,
None,
None,
test_styles(),
);
let row = row_text(&buf, 1, CARD_W);
assert!(
row.contains("ed25519-sk"),
"SK badge missing from card header: {row:?}"
);
}

fn cert_identity() -> Identity {
Identity {
Expand Down
Loading