feat: add notnet crate and YubikeySource, thread Tailscale into notstrap

- New crates/notnet: TailscaleOptions, ensure_connected(), auth key chain
  (TS_AUTHKEY env → YubiKey PIV 9d → interactive prompt), install via
  apt/brew/pacman or official install.sh, peer reachability verification
- notsecrets: YubikeySource implementing IdentitySource, reads age key from
  PIV slot 9c; gated behind optional `yubikey` feature
- notstrap: Tailscale step inserted between prerequisites and clone;
  BootstrapOptions.tailscale controls override/skip; NotstrapConfig gains
  optional [tailscale] section; dotfiles_repo is now Option<String> (Tailscale
  gitea_url takes precedence); identity chain adds YubikeySource before
  Bitwarden
- Updated workspace Cargo.toml: notnet added to members and workspace deps
- Integration tests: tailscale: Some(None) to skip Tailscale in all test opts
This commit is contained in:
Joseph O'Brien
2026-04-17 00:01:50 -04:00
parent dfdd9ca6d3
commit c7c074d48b
20 changed files with 797 additions and 6 deletions

View File

@@ -0,0 +1,72 @@
use crate::error::AgeError;
use crate::identities::Identity;
use crate::ports::IdentitySource;
/// Reads an age private key from YubiKey PIV slot 9c (Digital Signature slot).
///
/// Requires the `yubikey` feature to be enabled. When the feature is absent this
/// source is compiled out entirely — the struct still exists for type-level use but
/// `load()` always returns an error.
pub struct YubikeySource;
impl IdentitySource for YubikeySource {
fn name(&self) -> &str {
"yubikey-piv-9c"
}
fn load(&self) -> Result<Box<dyn Identity>, AgeError> {
load_impl()
}
}
#[cfg(feature = "yubikey")]
fn load_impl() -> Result<Box<dyn Identity>, AgeError> {
use crate::identities::x25519::X25519Identity;
use yubikey::{YubiKey, piv};
let mut yk = YubiKey::open().map_err(|e| AgeError::SourceError {
name: "yubikey-piv-9c".to_string(),
source: anyhow::anyhow!("cannot open YubiKey: {e}"),
})?;
// Slot 9c — Digital Signature slot, used here to store the age private key.
let data =
piv::read_object(&mut yk, piv::ObjectId::from(piv::SlotId::Signature)).map_err(|e| {
AgeError::SourceError {
name: "yubikey-piv-9c".to_string(),
source: anyhow::anyhow!("cannot read PIV slot 9c: {e}"),
}
})?;
let key = std::str::from_utf8(&data)
.map_err(|e| AgeError::SourceError {
name: "yubikey-piv-9c".to_string(),
source: anyhow::anyhow!("PIV slot 9c data is not valid UTF-8: {e}"),
})?
.trim()
.to_string();
if key.is_empty() {
return Err(AgeError::SourceError {
name: "yubikey-piv-9c".to_string(),
source: anyhow::anyhow!("PIV slot 9c is empty"),
});
}
let identity = X25519Identity::from_bech32(&key).map_err(|e| AgeError::SourceError {
name: "yubikey-piv-9c".to_string(),
source: anyhow::anyhow!("slot 9c content is not a valid AGE-SECRET-KEY-1: {e}"),
})?;
Ok(Box::new(identity))
}
#[cfg(not(feature = "yubikey"))]
fn load_impl() -> Result<Box<dyn Identity>, AgeError> {
Err(AgeError::SourceError {
name: "yubikey-piv-9c".to_string(),
source: anyhow::anyhow!(
"notsecrets was compiled without the `yubikey` feature; \
YubikeySource is unavailable"
),
})
}