crates/gunnar-cli/src/crypt_key.rs
//! `gunnar crypt key …`: the person's ONE encryption key (CRYPT-YUBI,
//! 2026-10-26), kept at the forge as its public half and one wrap per unlock
//! method (`gunnar_crypt::userkey`), over the forge's SSH door
//! (`gunnar-user-key`, `gunnar_crypt::door`).
//!
//! ```text
//! gunnar crypt key status your key and its unlock methods
//! gunnar crypt key new [--classic] the first time: a key (quantum-safe unless
//! --classic), wrapped to your SSH key
//! gunnar crypt key add-ssh [PUB] one more SSH key opens it
//! gunnar crypt key add-pq [PUB] your `gunnar crypt keygen` key opens it
//! gunnar crypt key add-security-key --rp-id HOST a FIDO2 security key opens it (CLI and browser)
//! gunnar crypt key add-recovery-code a printed code opens it
//! gunnar crypt key remove ID delete one method's wrap
//! gunnar crypt key repin --fingerprint FP trust the key the forge holds now (after checking it)
//! ```
//!
//! Adding or removing a method changes this one wrap at the forge and no
//! repository. Every repository you are a member of is wrapped to the public
//! half, so any one method opens all of them.
use std::path::PathBuf;
use anyhow::{anyhow, bail, Result};
use gunnar_crypt::door::{Answer, MethodLine, Request};
use gunnar_crypt::members::Level;
use gunnar_crypt::userkey::{self, recovery_code, security_key, UserKey, UserKeyError};
use crate::cli::CryptKeyCommand;
use crate::crypt::{door_exec_seen, identities, ssh_door, Repo};
/// How to open the key: the key files to try, and the other methods asked for.
#[derive(Debug, Default, Clone)]
pub struct Openers {
/// `-i`: SSH key files or a `gunnar crypt keygen` file (default: the usual ones).
pub identity: Vec<PathBuf>,
/// `--security-key`: a FIDO2 security key, over USB.
pub security_key: bool,
/// `--recovery-code`: asked on the terminal.
pub recovery_code: bool,
}
/// Ask the forge's door about the caller's own key. The SSH host key the door
/// answered with must be the one this machine pinned for that forge (RA5-4).
pub(crate) fn door(local: &Repo, request: &Request) -> Result<Answer> {
let (answer, host_key) = door_seen(local, request)?;
pins::host_key_is_pinned(local, &host_key)?;
Ok(answer)
}
/// [`door`] without the host key check: the answer, and the host key it came with.
fn door_seen(local: &Repo, request: &Request) -> Result<(Answer, String)> {
let origin = local.git(&["remote", "get-url", "origin"]).map_err(|e| {
anyhow!(
"your encryption key is kept at the forge, and this clone has no origin to ask ({e:#}): \
add the forge as origin first (git remote add origin <ssh url>)"
)
})?;
let (target, port) = ssh_door(&origin).ok_or_else(|| {
anyhow!("your encryption key is kept at the forge, and origin is not an SSH URL ({}); set origin to the forge's SSH address", origin.trim())
})?;
let (said, host_key) = door_exec_seen(local, &target, port, None, &request.line())?;
match said {
Ok(said) => Ok((
Answer::parse(&said).ok_or_else(|| {
anyhow!("the forge at {target} answered something that is no encryption key")
})?,
host_key,
)),
Err(why) => bail!("the forge at {target}: {why}"),
}
}
/// The caller's key at the forge, or the sentence that says how to make one.
/// The public half is checked against this machine's pin (RA4-1).
pub(crate) fn mine(local: &Repo) -> Result<Answer> {
let (a, host_key) = door_seen(local, &Request::Get)?;
let Some(public) = a.public_key.as_deref() else {
bail!(
"you have no encryption key yet: make one with `gunnar crypt key new` (it is wrapped to \
your SSH key), or add a security key on your Account page in the console"
);
};
pins::check_own(local, public, &host_key)?;
Ok(a)
}
/// **Pinned encryption keys** (RA4-1, trust on first use): this machine
/// remembers the person's OWN encryption public key the first time it makes or
/// sees it, and every member key it was told to trust, per forge host, in
/// `$XDG_CONFIG_HOME/gunnar/encryption-pins` (else `~/.config/gunnar/…`, mode
/// 0600). A forge answer that differs is refused by name: a server that swapped
/// a person's key and its wrap would otherwise get every new ring wrapped to its
/// own key.
///
/// **The own pin is ONE per forge host, never looked up by the forge's word**
/// (RA5-4). It holds the forge's SSH host key, the account name the forge said
/// the first time, and the key: a forge that later signs in with another host
/// key, answers `gunnar-whoami` with another name, or hands out another key is
/// refused by name, and so is a name that is not the operator's own
/// `gunnar.account` (git config) when one is set. Only `gunnar crypt key repin
/// --fingerprint <the new key's>` takes a changed one.
pub mod pins {
use std::path::PathBuf;
use anyhow::{anyhow, bail, Context as _, Result};
use crate::crypt::{ssh_door, Repo};
/// Where the pins live.
pub fn path() -> Option<PathBuf> {
let base = std::env::var_os("XDG_CONFIG_HOME")
.filter(|d| !d.is_empty())
.map(PathBuf::from)
.or_else(|| {
std::env::var_os("HOME")
.filter(|h| !h.is_empty())
.map(|h| PathBuf::from(h).join(".config"))
})?;
Some(base.join("gunnar").join("encryption-pins"))
}
/// The forge host this clone's origin names (without a user or a port).
pub(crate) fn forge(local: &Repo) -> Result<String> {
let origin = local
.git(&["remote", "get-url", "origin"])
.map_err(|_| anyhow!("this clone has no origin"))?;
let (target, _) = ssh_door(&origin)
.ok_or_else(|| anyhow!("origin is not an SSH URL ({})", origin.trim()))?;
Ok(target.rsplit('@').next().unwrap_or(&target).to_string())
}
/// The whole pin file; empty when there is none yet. Any other read error
/// is an error: an unread file is never written over.
fn text() -> Result<String> {
let Some(p) = path() else {
return Ok(String::new());
};
match std::fs::read_to_string(&p) {
Ok(t) => Ok(t),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
Err(e) => {
Err(e).with_context(|| format!("read {} (the pinned encryption keys)", p.display()))
}
}
}
/// The pinned key of `(forge, who)`, `who` a member name.
fn read(forge: &str, who: &str) -> Result<Option<String>> {
let text = text()?;
Ok(text.lines().find_map(|l| {
let mut w = l.split_ascii_whitespace();
match (w.next(), w.next(), w.next()) {
(Some(f), Some(n), Some(k)) if f == forge && n == who => Some(k.to_string()),
_ => None,
}
}))
}
/// Write the pin file with `keep` lines kept and `line` added.
fn rewrite(keep: impl Fn(&str) -> bool, line: &str) -> Result<()> {
let p = path().ok_or_else(|| anyhow!("no HOME to keep pinned keys in"))?;
let mut text: String = text()?
.lines()
.filter(|l| keep(l))
.map(|l| format!("{l}\n"))
.collect();
if let Some(dir) = p.parent() {
std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
}
text.push_str(line);
text.push('\n');
let tmp = p.with_extension("tmp");
let _ = std::fs::remove_file(&tmp);
crate::crypt::write_private(&tmp, text.as_bytes())?;
std::fs::rename(&tmp, &p).with_context(|| format!("write {}", p.display()))
}
fn write(forge: &str, who: &str, public: &str) -> Result<()> {
rewrite(|_| true, &format!("{forge} {who} {}", public.trim()))
}
/// **The own pin of one forge host**: its SSH host key, the account the forge
/// named, the encryption key. `host_key` is empty for a pin made before RA5-4
/// (`<forge> self:<name> <key>`), taken over the first time it is checked.
#[derive(Debug, Clone, PartialEq, Eq)]
struct Own {
host_key: String,
name: String,
public: String,
}
const OWN: &str = "self";
fn read_own(forge: &str) -> Result<Option<Own>> {
let text = text()?;
let mut legacy = None;
for l in text.lines() {
let w: Vec<&str> = l.split_ascii_whitespace().collect();
match w.as_slice() {
[f, OWN, algo, fp, name, key] if *f == forge => {
return Ok(Some(Own {
host_key: format!("{algo} {fp}"),
name: name.to_string(),
public: key.to_string(),
}))
}
[f, who, key] if *f == forge && legacy.is_none() => {
if let Some(name) = who.strip_prefix("self:") {
legacy = Some(Own {
host_key: String::new(),
name: name.to_string(),
public: key.to_string(),
});
}
}
_ => {}
}
}
Ok(legacy)
}
fn write_own(forge: &str, own: &Own) -> Result<()> {
rewrite(
|l| {
let w: Vec<&str> = l.split_ascii_whitespace().collect();
!matches!(w.as_slice(), [f, OWN, ..] if *f == forge)
&& !matches!(w.as_slice(), [f, who, _] if *f == forge && who.starts_with("self:"))
},
&format!(
"{forge} {OWN} {} {} {}",
own.host_key,
own.name,
own.public.trim()
),
)
}
/// Who the forge says you are, and the host key it said it with.
fn whoami(local: &Repo) -> Result<(String, String)> {
crate::crypt::door_whoami_seen(local)
.map_err(|why| anyhow!("the forge could not say who you are: {why}"))
}
/// The operator's own word for who they are at the forge: `git config gunnar.account`.
fn configured(local: &Repo) -> Option<String> {
local
.git(&["config", "gunnar.account"])
.ok()
.map(|n| n.trim().to_string())
.filter(|n| !n.is_empty())
}
const REPIN: &str = "If it was changed on purpose, check the new key's fingerprint on your Account page, then run \
`gunnar crypt key repin --fingerprint <it>`";
/// **The forge signed in with the host key this machine pinned for it**, or
/// there is no own pin for it yet. Refused by name otherwise.
pub(crate) fn host_key_is_pinned(local: &Repo, host_key: &str) -> Result<()> {
let forge = forge(local)?;
match read_own(&forge)? {
Some(p) if !p.host_key.is_empty() && p.host_key != host_key => bail!(
"the forge at {forge} signed in with another SSH host key than the one this machine pinned \
(pinned {}, now {host_key}): refused, it may not be your forge. Nothing was wrapped to \
it. {REPIN}",
p.host_key
),
_ => Ok(()),
}
}
/// The name the forge gave, checked against `gunnar.account` and against the
/// host key the first answer came with.
fn named(local: &Repo, forge: &str, host_key: &str) -> Result<String> {
let (name, again) = whoami(local)?;
if again != host_key {
bail!(
"the forge at {forge} answered with two SSH host keys ({host_key}, then {again}): refused. \
Nothing was wrapped"
);
}
if let Some(mine) = configured(local) {
if mine != name {
bail!(
"the forge at {forge} says you are {name}, and gunnar.account says {mine}: refused, \
nothing was pinned or wrapped. Fix gunnar.account, or ask the forge's admin who \
your SSH key signs in as"
);
}
}
Ok(name)
}
/// The caller's OWN key, as `host_key`'s forge answered it: refused when the
/// host key, the forge's name for the caller, or the key differs from the pin;
/// pinned the first time (said, with its fingerprint).
pub(crate) fn check_own(local: &Repo, public: &str, host_key: &str) -> Result<()> {
own(local, public, host_key, true)
}
/// **The caller's REPLACED key** (CRYPT-PQ-REKEY): the pin of the old one
/// gives way to the key this machine just made and the forge took.
pub(crate) fn replace_own(local: &Repo, public: &str, host_key: &str) -> Result<()> {
let forge = forge(local)?;
let name = named(local, &forge, host_key)?;
write_own(
&forge,
&Own {
host_key: host_key.to_string(),
name,
public: public.trim().to_string(),
},
)
}
/// Is `public` the caller's own key, as this machine pinned it?
pub(crate) fn is_own(local: &Repo, public: &str) -> bool {
forge(local)
.ok()
.and_then(|f| read_own(&f).ok().flatten())
.is_some_and(|o| o.public == public.trim())
}
/// The caller's own NEW key, pinned at once.
pub(crate) fn pin_own(local: &Repo, public: &str, host_key: &str) -> Result<()> {
own(local, public, host_key, false)
}
fn own(local: &Repo, public: &str, host_key: &str, say_first: bool) -> Result<()> {
let forge = forge(local)?;
let name = named(local, &forge, host_key)?;
let now = Own {
host_key: host_key.to_string(),
name,
public: public.trim().to_string(),
};
let Some(pinned) = read_own(&forge)? else {
write_own(&forge, &now)?;
if say_first {
eprintln!(
"pinned your encryption key on this machine: {} for {} at {forge} (host key {host_key}; \
the first time this machine sees it; compare it with your Account page)",
gunnar_crypt::door::fingerprint(public),
now.name
);
}
return Ok(());
};
if !pinned.host_key.is_empty() && pinned.host_key != now.host_key {
host_key_is_pinned(local, host_key)?;
}
if pinned.name != now.name {
bail!(
"the forge at {forge} now says you are {}, and this machine pinned you as {}: refused, \
the forge does not pick whose key this machine trusts. Nothing was wrapped to it. {REPIN}",
now.name,
pinned.name
);
}
if pinned.public != now.public {
bail!(
"the forge at {forge} answered an encryption key that is NOT the one this machine \
pinned for you (pinned {}, answered {}): the key at the server was changed. Nothing \
was wrapped to it. {REPIN}",
gunnar_crypt::door::fingerprint(&pinned.public),
gunnar_crypt::door::fingerprint(public)
);
}
if pinned.host_key.is_empty() {
write_own(&forge, &now)?;
}
Ok(())
}
/// **`gunnar crypt key repin --fingerprint FP`**: trust the key the forge
/// holds for you now, under the host key and the name it answers with now,
/// when `confirmed` is that key's fingerprint. What changed is said.
pub(crate) fn repin(
local: &Repo,
public: &str,
host_key: &str,
confirmed: &str,
) -> Result<String> {
let forge = forge(local)?;
let fp = gunnar_crypt::door::fingerprint(public);
if confirmed.trim() != fp {
bail!(
"the fingerprint you gave is not the one of the key the forge at {forge} holds for you \
({fp}): nothing was re-pinned"
);
}
let name = named(local, &forge, host_key)?;
let now = Own {
host_key: host_key.to_string(),
name,
public: public.trim().to_string(),
};
let before = read_own(&forge)?;
write_own(&forge, &now)?;
let mut changed = Vec::new();
if let Some(b) = &before {
if b.host_key != now.host_key {
changed.push(format!(
"host key {} → {}",
if b.host_key.is_empty() {
"(none)"
} else {
b.host_key.as_str()
},
now.host_key
));
}
if b.name != now.name {
changed.push(format!("account {} → {}", b.name, now.name));
}
if b.public != now.public {
changed.push(format!(
"key {} → {fp}",
gunnar_crypt::door::fingerprint(&b.public)
));
}
}
Ok(format!(
"re-pinned your encryption key at {forge}: {fp}, account {}, host key {}{}",
now.name,
now.host_key,
if changed.is_empty() {
" (nothing changed)".to_string()
} else {
format!(" ({})", changed.join("; "))
}
))
}
/// **Another member's key, as the forge handed it out.** Taken when it is
/// the pinned one, or when `confirmed` is its fingerprint (the member read
/// it out); a pinned key that differs is refused by name.
pub(crate) fn check_member(
local: &Repo,
name: &str,
public: &str,
confirmed: Option<&str>,
) -> Result<()> {
let forge = forge(local)?;
let fp = gunnar_crypt::door::fingerprint(public);
match read(&forge, name)? {
Some(pinned) if pinned == public.trim() => Ok(()),
Some(pinned) => bail!(
"the forge at {forge} answered an encryption key for {name} that is NOT the one this \
machine pinned (pinned {}, answered {fp}): refused. Ask {name} which key is theirs",
gunnar_crypt::door::fingerprint(&pinned)
),
None if confirmed.map(str::trim) == Some(fp.as_str()) => write(&forge, name, public),
None if confirmed.is_some() => bail!(
"the fingerprint you gave is not the one of the key the forge holds for {name} ({fp}): refused"
),
None => bail!(
"the forge at {forge} holds this encryption key for {name}: {fp}. Check it with {name} \
(their `gunnar crypt key status` or Account page shows it), then run again with \
--fingerprint {fp}"
),
}
}
/// **A member's quantum-safe key handed over directly** (`gunnar crypt level
/// quantum-safe --key NAME=…`): it replaces the pin of the key they had in
/// the classic repository. The key it replaced, when there was another.
pub(crate) fn replace_member(local: &Repo, name: &str, public: &str) -> Result<Option<String>> {
let forge = match forge(local) {
Ok(f) => f,
Err(_) => return Ok(None),
};
let before = read(&forge, name)?;
if before.as_deref() == Some(public.trim()) {
return Ok(None);
}
rewrite(
|l| {
let w: Vec<&str> = l.split_ascii_whitespace().collect();
!matches!(w.as_slice(), [f, n, _] if *f == forge && *n == name)
},
&format!("{forge} {name} {}", public.trim()),
)?;
Ok(before)
}
/// A member key handed over directly (`--key`): pinned as theirs.
pub(crate) fn pin_member(local: &Repo, name: &str, public: &str) -> Result<()> {
let forge = match forge(local) {
Ok(f) => f,
Err(_) => return Ok(()),
};
match read(&forge, name)? {
Some(p) if p == public.trim() => Ok(()),
Some(p) => bail!(
"this machine pinned another encryption key for {name} ({}); the one given is {}",
gunnar_crypt::door::fingerprint(&p),
gunnar_crypt::door::fingerprint(public)
),
None => write(&forge, name, public),
}
}
}
/// Where a security key's secret comes from: the USB key, or (in tests) a
/// software authenticator through the same CTAP2 client.
pub trait SecurityKeys {
/// The `hmac-secret` output of `credential` (bound to `rp_id`) for gunnar's
/// fixed salt: the browser's WebAuthn `prf` output for the same credential.
fn prf(&mut self, rp_id: &str, credential: &[u8]) -> Result<[u8; 32]>;
}
/// The first FIDO2 key on USB.
pub struct UsbKeys;
impl SecurityKeys for UsbKeys {
fn prf(&mut self, rp_id: &str, credential: &[u8]) -> Result<[u8; 32]> {
let p = crate::fido2::hmac_secret(
rp_id,
&[credential.to_vec()],
&security_key::hmac_secret_salt(),
)?;
Ok(p.output)
}
}
/// **Open the caller's key** with whatever `how` allows: every SSH or keygen
/// key file against the SSH and age-pq wraps, a security key against the
/// security-key wraps, a recovery code against the recovery wraps.
///
/// A security key's v1 (X25519) wrap that opens is re-wrapped to v2 at the
/// forge on the way (CRYPT-PQ-SK): the same method, now quantum-safe. A forge
/// that refuses the re-wrap does not stop the unlock; it is said.
pub(crate) fn open(local: &Repo, answer: &Answer, how: &Openers) -> Result<UserKey> {
let (key, rewrap) = open_with_rewrap(answer, how, &mut UsbKeys)?;
if let Some(line) = rewrap {
match door(local, &Request::Add(line)) {
Ok(_) => eprintln!(
"re-wrapped your security key's copy of your encryption key to v2 (symmetric, quantum-safe)"
),
Err(e) => eprintln!(
"warning: your security key's copy is still the X25519 (v1) wrap; the forge did not take its v2 re-wrap: {e:#}"
),
}
}
Ok(key)
}
/// [`open`], with the security keys `keys` gives.
pub fn open_with(answer: &Answer, how: &Openers, keys: &mut dyn SecurityKeys) -> Result<UserKey> {
open_with_rewrap(answer, how, keys).map(|(key, _)| key)
}
/// [`open_with`], and the v2 re-wrap of the security-key method that opened
/// when its wrap was v1 (CRYPT-PQ-SK): the line to add at the forge, which
/// replaces the v1 one.
pub fn open_with_rewrap(
answer: &Answer,
how: &Openers,
keys: &mut dyn SecurityKeys,
) -> Result<(UserKey, Option<MethodLine>)> {
let public = answer.public_key.clone().unwrap_or_default();
// A wrap the server holds may be bogus (RA4-14): it is skipped, named, and the rest tried.
let mut skipped: Vec<String> = Vec::new();
let skip = |m: &MethodLine, why: &dyn std::fmt::Display, skipped: &mut Vec<String>| {
skipped.push(format!(
"unlock method {} ({}, {:?}): {why}",
m.id, m.kind, m.label
));
};
let said = |base: String, skipped: &[String]| -> anyhow::Error {
match skipped.is_empty() {
true => anyhow!("{base}"),
false => anyhow!(
"{base}. Skipped, because they do not hold your key: {}",
skipped.join("; ")
),
}
};
if how.security_key {
let sks: Vec<&MethodLine> = answer
.methods
.iter()
.filter(|m| m.kind == "security-key")
.collect();
if sks.is_empty() {
bail!("no security key opens your encryption key yet: add one with `gunnar crypt key add-security-key` or on your Account page");
}
let mut last_device = None;
for m in sks {
let Some(cred) = b64url_decode(&m.method_ref) else {
skip(m, &"it names no credential", &mut skipped);
continue;
};
let prf = match keys.prf(&m.rp_id, &cred) {
Ok(p) => gunnar_crypt::zeroize::Zeroizing::new(p),
Err(e) => {
last_device = Some(format!("{e:#}"));
continue;
}
};
match userkey::open_with_security_key(m.wrap.as_bytes(), &prf, &cred, &public) {
Ok((k, userkey::SecurityKeyWrap::V2)) => return Ok((k, None)),
Ok((k, userkey::SecurityKeyWrap::V1)) => {
let wrap = k
.wrap_to_security_key_v2(&prf, &cred)
.map_err(|e| anyhow!("{e}"))?;
let line = MethodLine {
id: String::new(),
added_ms: 0,
wrap,
..m.clone()
};
return Ok((k, Some(line)));
}
Err(UserKeyError::WrongKey) => {}
Err(e) => skip(m, &e, &mut skipped),
}
}
let base = match last_device {
Some(why) => format!("{} (the security key said: {why})", UserKeyError::WrongKey),
None => UserKeyError::WrongKey.to_string(),
};
return Err(said(base, &skipped));
}
if how.recovery_code {
let codes: Vec<&MethodLine> = answer
.methods
.iter()
.filter(|m| m.kind == "recovery-code")
.collect();
if codes.is_empty() {
bail!("no recovery code opens your encryption key");
}
let typed = gunnar_crypt::zeroize::Zeroizing::new(
rpassword::prompt_password("Recovery code: ")
.map_err(|e| anyhow!("read the recovery code: {e}"))?,
);
for m in codes {
match userkey::open_with_recovery_code(m.wrap.as_bytes(), &typed, &public) {
Ok(k) => return Ok((k, None)),
Err(UserKeyError::WrongKey) => {}
Err(e @ UserKeyError::BadRecoveryCode) => bail!("{e}"),
Err(e) => skip(m, &e, &mut skipped),
}
}
return Err(said(UserKeyError::WrongKey.to_string(), &skipped));
}
let ids = identities(&how.identity)?;
let mut tried: Vec<String> = Vec::new();
for m in answer
.methods
.iter()
.filter(|m| m.kind == "ssh" || m.kind == "age-pq")
{
for (path, id) in &ids {
match userkey::open(m.wrap.as_bytes(), id.as_ref(), &public) {
Ok(k) => return Ok((k, None)),
Err(UserKeyError::WrongKey) => tried.push(path.display().to_string()),
Err(e) => {
skip(m, &e, &mut skipped);
break;
}
}
}
}
tried.sort();
tried.dedup();
if tried.is_empty() && skipped.is_empty() {
bail!(
"no SSH key opens your encryption key: open it with your security key (`--security-key`) \
or your recovery code (`--recovery-code`), then `gunnar crypt key add-ssh`"
);
}
Err(said(
format!(
"{} (tried {}). Pass -i <the SSH key file you added>, `--security-key`, or `--recovery-code`",
UserKeyError::WrongKey,
tried.join(", ")
),
&skipped,
))
}
/// The caller's PUBLIC half, making the key first when they have none: a new
/// key, wrapped to `ssh_public` (default `~/.ssh/id_ed25519.pub`).
pub(crate) fn public_or_new(
local: &Repo,
level: Level,
ssh_public: Option<&str>,
) -> Result<String> {
let (a, host_key) = door_seen(local, &Request::Get)?;
pins::host_key_is_pinned(local, &host_key)?;
if let Some(public) = a.public_key {
pins::check_own(local, &public, &host_key)?;
return Ok(public);
}
let made = create(local, level, ssh_public)?;
eprintln!(
"made your encryption key ({made}); your SSH key opens it. Add a second way to open it — a \
security key, or `gunnar crypt key add-recovery-code` — so losing one key loses nothing"
);
Ok(made)
}
/// A new key at `level` and its first method: a wrap to `ssh_public` (default
/// `~/.ssh/id_ed25519.pub`).
fn new_key_for_ssh(level: Level, ssh_public: Option<&str>) -> Result<(UserKey, MethodLine)> {
let ssh = match ssh_public {
Some(k) => crate::crypt::read_key(k)?,
None => crate::crypt::default_public_key()?,
};
let key = UserKey::generate(level).map_err(|e| anyhow!("{e}"))?;
if key.level() == Level::QuantumSafe {
// The SSH key is the first method; its wrap of a quantum-safe key is classical, said
// plainly, and the key itself stays quantum-safe (CRYPT-PQ-DEFAULT).
eprintln!("note: {}", userkey::SSH_WRAP_IS_CLASSICAL);
}
let method = MethodLine {
kind: "ssh".into(),
label: ssh
.split_ascii_whitespace()
.nth(2)
.unwrap_or("ssh key")
.to_string(),
method_ref: key_id(&ssh),
wrap: key.wrap_to_ssh(&ssh).map_err(|e| anyhow!("{e}"))?,
..MethodLine::default()
};
Ok((key, method))
}
fn create(local: &Repo, level: Level, ssh_public: Option<&str>) -> Result<String> {
let (key, method) = new_key_for_ssh(level, ssh_public)?;
let (a, host_key) = door_seen(
local,
&Request::Create {
public_key: key.public(),
method,
},
)?;
pins::host_key_is_pinned(local, &host_key)?;
if a.public_key.as_deref() != Some(key.public().as_str()) {
bail!("the forge kept another encryption key than the one made here: refused");
}
pins::pin_own(local, &key.public(), &host_key)?;
Ok(key.public())
}
/// `ssh-ed25519 AAAA…` without the comment.
fn key_id(line: &str) -> String {
line.split_ascii_whitespace()
.take(2)
.collect::<Vec<_>>()
.join(" ")
}
/// Run one `gunnar crypt key` verb.
pub fn run(command: CryptKeyCommand) -> Result<()> {
let local = Repo::discover()?;
match command {
CryptKeyCommand::Status => {
let (a, host_key) = door_seen(&local, &Request::Get)?;
pins::host_key_is_pinned(&local, &host_key)?;
match &a.public_key {
None => println!("no encryption key yet: `gunnar crypt key new` makes one."),
Some(p) => {
pins::check_own(&local, p, &host_key)?;
let quantum_safe = gunnar_crypt::members::classify(p)
== Ok(gunnar_crypt::members::KeyKind::AgePq);
println!(
"encryption key {p} ({})\nfingerprint {}",
if quantum_safe {
"quantum-safe key"
} else {
"classic key: for classic repositories only"
},
gunnar_crypt::door::fingerprint(p)
);
for m in &a.methods {
// A quantum-safe key's methods say how strong their own wrap is.
let strength = match (
quantum_safe,
userkey::wrap_is_quantum_safe(&m.kind, &m.wrap),
) {
(false, _) => "",
(true, true) => " (wrap: quantum-safe)",
(true, false) => " (wrap: not quantum-safe)",
};
println!(" {} {:<13} {}{strength}", m.id, m.kind, m.label);
}
for r in &a.retired {
println!(
" retired {} (replaced: it opens only what was wrapped to it before; Gunnar never takes it again)",
gunnar_crypt::door::fingerprint(r)
);
}
if a.methods.len() < 2 {
println!("Only one way opens it. Add another — a second security key or a printed recovery code — so losing one loses nothing.");
}
}
}
Ok(())
}
CryptKeyCommand::New { classic, ssh_key } => {
let level = if classic { Level::Classic } else { Level::NEW };
if door(&local, &Request::Get)?.public_key.is_some() {
bail!("you have an encryption key already; `gunnar crypt key status` shows it (a new one would orphan every repository wrapped to it)");
}
let made = create(&local, level, ssh_key.as_deref())?;
println!("made your encryption key {made}; your SSH key opens it.");
Ok(())
}
CryptKeyCommand::AddSsh { key, open } => {
let ssh = match key.as_deref() {
Some(k) => crate::crypt::read_key(k)?,
None => crate::crypt::default_public_key()?,
};
add(&local, &open.into(), |k| {
if k.level() == Level::QuantumSafe {
eprintln!("note: {}", userkey::SSH_WRAP_IS_CLASSICAL);
}
Ok(MethodLine {
kind: "ssh".into(),
label: ssh
.split_ascii_whitespace()
.nth(2)
.unwrap_or("ssh key")
.to_string(),
method_ref: key_id(&ssh),
wrap: k.wrap_to_ssh(&ssh).map_err(|e| anyhow!("{e}"))?,
..MethodLine::default()
})
})
}
CryptKeyCommand::AddPq { key, open } => {
let pq = match key.as_deref() {
Some(k) => crate::crypt::read_key(k)?,
None => crate::crypt::default_pq_public_key()?,
};
add(&local, &open.into(), |k| {
Ok(MethodLine {
kind: "age-pq".into(),
label: "gunnar crypt keygen".into(),
method_ref: pq.trim().to_string(),
wrap: k.wrap_to_pq(&pq).map_err(|e| anyhow!("{e}"))?,
..MethodLine::default()
})
})
}
CryptKeyCommand::AddSecurityKey { rp_id, label, open } => {
let made = crate::fido2::make_credential(&rp_id)?;
eprintln!("touch the security key again to read its secret");
let prf = crate::fido2::hmac_secret(
&rp_id,
std::slice::from_ref(&made.credential_id),
&security_key::hmac_secret_salt(),
)?;
add(&local, &open.into(), |k| {
Ok(MethodLine {
kind: "security-key".into(),
label: label.clone().unwrap_or_else(|| "security key".into()),
method_ref: b64url_encode(&made.credential_id),
rp_id: rp_id.clone(),
// v2 (CRYPT-PQ-SK): symmetric under HKDF of the PRF output, quantum-safe.
wrap: k
.wrap_to_security_key_v2(&prf.output, &made.credential_id)
.map_err(|e| anyhow!("{e}"))?,
..MethodLine::default()
})
})
}
CryptKeyCommand::AddRecoveryCode { open } => {
let code = recovery_code::generate();
add(&local, &open.into(), |k| {
Ok(MethodLine {
kind: "recovery-code".into(),
label: "printed recovery code".into(),
wrap: k.wrap_to_recovery_code(&code).map_err(|e| anyhow!("{e}"))?,
..MethodLine::default()
})
})?;
println!(
"Your recovery code (shown once; print it or write it down, and keep it apart from your keys):\n\n {}\n\nIt opens your encryption key with `gunnar crypt unlock --recovery-code`.",
*code
);
Ok(())
}
CryptKeyCommand::Repin { fingerprint } => {
// The host key is NOT checked here: a changed one is what a re-pin takes.
let (a, host_key) = door_seen(&local, &Request::Get)?;
let Some(public) = a.public_key else {
bail!("the forge holds no encryption key for you: nothing to pin (`gunnar crypt key new` makes one)");
};
println!("{}", pins::repin(&local, &public, &host_key, &fingerprint)?);
Ok(())
}
CryptKeyCommand::Replace {
classic,
ssh_key,
yes,
} => replace(
&local,
if classic { Level::Classic } else { Level::NEW },
ssh_key.as_deref(),
yes,
),
CryptKeyCommand::Remove { id } => {
let a = door(&local, &Request::Remove(id.clone()))?;
println!(
"removed unlock method {id}; {} left. No repository changed.",
a.methods.len()
);
Ok(())
}
}
}
/// **`gunnar crypt key replace --yes`** (CRYPT-PQ-REKEY): a new key, wrapped to the
/// SSH key, in place of the old key and all its methods; the forge retires the old one.
fn replace(local: &Repo, level: Level, ssh_public: Option<&str>, yes: bool) -> Result<()> {
if !yes {
bail!(
"`gunnar crypt key replace` makes a NEW encryption key and retires yours for good: every \
unlock method of the old key goes, and each encrypted repository you are a member of takes \
new content again only after a member re-keys it to your new key (`gunnar crypt rekey \
<your name> --from-server`). Run it again with --yes"
);
}
let (a, host_key) = door_seen(local, &Request::Get)?;
pins::host_key_is_pinned(local, &host_key)?;
let Some(old) = a.public_key else {
bail!("you have no encryption key to replace: `gunnar crypt key new` makes one");
};
let (key, method) = new_key_for_ssh(level, ssh_public)?;
let (after, host_key) = door_seen(
local,
&Request::Replace {
public_key: key.public(),
method,
},
)?;
pins::host_key_is_pinned(local, &host_key)?;
if after.public_key.as_deref() != Some(key.public().as_str()) {
bail!("the forge kept another encryption key than the one made here: refused");
}
pins::replace_own(local, &key.public(), &host_key)?;
let me = crate::crypt::door_whoami(local).unwrap_or_else(|_| "<your name>".into());
println!(
"replaced your encryption key: {} is retired, {} is yours now ({}), opened by your SSH key.\n\
The retired key opens only what was wrapped to it before; Gunnar never takes it again, and \
refuses a push to a repository whose members file still names it.\n\
Next, in each encrypted repository you are a member of, a member with an unlocked clone (you \
too, in a clone that is still unlocked) runs\n\n gunnar crypt rekey {me} --from-server \
--fingerprint {}\n\nand pushes. Add a second way to open the new key (a security key, or \
`gunnar crypt key add-recovery-code`).",
gunnar_crypt::door::fingerprint(&old),
gunnar_crypt::door::fingerprint(&key.public()),
if key.level() == Level::QuantumSafe { "quantum-safe" } else { "classic" },
gunnar_crypt::door::fingerprint(&key.public()),
);
Ok(())
}
/// Open the key, wrap it to one more method, add it at the forge.
fn add(
local: &Repo,
how: &Openers,
method: impl FnOnce(&UserKey) -> Result<MethodLine>,
) -> Result<()> {
let a = mine(local)?;
let key = open(local, &a, how)?;
let line = method(&key)?;
let kind = line.kind.clone();
let after = door(local, &Request::Add(line))?;
println!("added a {kind} unlock method; {} in all. No repository changed: every one opens with any of them.", after.methods.len());
Ok(())
}
impl From<crate::cli::KeyOpenArgs> for Openers {
fn from(a: crate::cli::KeyOpenArgs) -> Openers {
Openers {
identity: a.identity,
security_key: a.security_key,
recovery_code: a.recovery_code,
}
}
}
const B64URL: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
/// base64url without padding (WebAuthn's credential id encoding).
pub fn b64url_encode(bytes: &[u8]) -> String {
let mut out = String::new();
for chunk in bytes.chunks(3) {
let n = chunk
.iter()
.enumerate()
.fold(0u32, |acc, (i, b)| acc | (u32::from(*b) << (16 - 8 * i)));
for i in 0..=chunk.len() {
out.push(B64URL[((n >> (18 - 6 * i)) & 63) as usize] as char);
}
}
out
}
/// The inverse of [`b64url_encode`]; `None` for anything else.
pub(crate) fn b64url_decode(text: &str) -> Option<Vec<u8>> {
let mut out = Vec::new();
let (mut acc, mut bits) = (0u32, 0u32);
for c in text.trim().bytes() {
let v = B64URL.iter().position(|x| *x == c)? as u32;
acc = (acc << 6) | v;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push((acc >> bits) as u8);
}
}
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base64url_round_trips_every_length() {
for n in 0..40u8 {
let bytes: Vec<u8> = (0..n).map(|i| i.wrapping_mul(37)).collect();
assert_eq!(b64url_decode(&b64url_encode(&bytes)).unwrap(), bytes);
}
assert_eq!(b64url_encode(b"\xfb\xff"), "-_8");
assert_eq!(b64url_decode("a+b"), None);
}
}