Initial scaffold for kestrove-core
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "kv-model"
|
||||
description = "Kestrove vault items, URL matching, TOTP and password generation."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
uuid.workspace = true
|
||||
thiserror.workspace = true
|
||||
rand.workspace = true
|
||||
zeroize.workspace = true
|
||||
hmac.workspace = true
|
||||
sha1.workspace = true
|
||||
sha2.workspace = true
|
||||
data-encoding.workspace = true
|
||||
url.workspace = true
|
||||
psl.workspace = true
|
||||
@@ -0,0 +1,76 @@
|
||||
//! Password and passphrase generation with the OS CSPRNG (uniform, no modulo bias).
|
||||
|
||||
use rand::{rngs::OsRng, seq::SliceRandom, Rng};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PasswordOptions {
|
||||
pub length: usize,
|
||||
pub lowercase: bool,
|
||||
pub uppercase: bool,
|
||||
pub digits: bool,
|
||||
pub symbols: bool,
|
||||
/// Excludes look-alikes such as `0O1lI|`.
|
||||
pub avoid_ambiguous: bool,
|
||||
}
|
||||
|
||||
impl Default for PasswordOptions {
|
||||
fn default() -> Self {
|
||||
Self { length: 24, lowercase: true, uppercase: true, digits: true, symbols: true, avoid_ambiguous: true }
|
||||
}
|
||||
}
|
||||
|
||||
const LOWER: &str = "abcdefghijklmnopqrstuvwxyz";
|
||||
const UPPER: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
const DIGITS: &str = "0123456789";
|
||||
const SYMBOLS: &str = "!@#$%^&*()-_=+[]{};:,.?/~";
|
||||
const AMBIGUOUS: &str = "0O1lI|";
|
||||
|
||||
/// Generates a password with at least one character from each enabled class.
|
||||
pub fn generate_password(opts: &PasswordOptions) -> String {
|
||||
let classes: Vec<Vec<char>> = [
|
||||
(opts.lowercase, LOWER),
|
||||
(opts.uppercase, UPPER),
|
||||
(opts.digits, DIGITS),
|
||||
(opts.symbols, SYMBOLS),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|(on, _)| *on)
|
||||
.map(|(_, set)| set.chars().filter(|c| !(opts.avoid_ambiguous && AMBIGUOUS.contains(*c))).collect())
|
||||
.collect();
|
||||
let classes = if classes.is_empty() { vec![LOWER.chars().collect()] } else { classes };
|
||||
let length = opts.length.clamp(classes.len().max(8), 128);
|
||||
let all: Vec<char> = classes.iter().flatten().copied().collect();
|
||||
|
||||
let mut rng = OsRng;
|
||||
let mut out: Vec<char> = classes.iter().map(|c| *c.choose(&mut rng).unwrap()).collect();
|
||||
while out.len() < length {
|
||||
out.push(all[rng.gen_range(0..all.len())]);
|
||||
}
|
||||
out.shuffle(&mut rng);
|
||||
out.into_iter().collect()
|
||||
}
|
||||
|
||||
/// Passphrase from a caller-supplied word list (ship the EFF large list, 7776 words ≈ 12.9 bits/word).
|
||||
pub fn generate_passphrase(words: &[&str], count: usize, separator: &str) -> String {
|
||||
let mut rng = OsRng;
|
||||
(0..count.max(4)).map(|_| *words.choose(&mut rng).unwrap()).collect::<Vec<_>>().join(separator)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn respects_classes_and_length() {
|
||||
for _ in 0..100 {
|
||||
let p = generate_password(&PasswordOptions::default());
|
||||
assert_eq!(p.chars().count(), 24);
|
||||
assert!(p.chars().any(|c| c.is_ascii_lowercase()));
|
||||
assert!(p.chars().any(|c| c.is_ascii_uppercase()));
|
||||
assert!(p.chars().any(|c| c.is_ascii_digit()));
|
||||
assert!(!p.chars().any(|c| AMBIGUOUS.contains(c)));
|
||||
}
|
||||
let digits_only = PasswordOptions { length: 6, lowercase: false, uppercase: false, symbols: false, ..Default::default() };
|
||||
assert_eq!(generate_password(&digits_only).len(), 8); // minimum length 8
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
use zeroize::Zeroize;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ItemKind {
|
||||
Login,
|
||||
SecureNote,
|
||||
Card,
|
||||
Identity,
|
||||
}
|
||||
|
||||
/// A stored WebAuthn credential (docs/05 "Passkeys").
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Zeroize)]
|
||||
pub struct Passkey {
|
||||
pub credential_id: Vec<u8>,
|
||||
pub rp_id: String,
|
||||
pub user_handle: Vec<u8>,
|
||||
pub user_name: String,
|
||||
/// PKCS#8 DER of the P-256 private key.
|
||||
pub private_key: Vec<u8>,
|
||||
pub sign_count: u32,
|
||||
pub created_at: i64,
|
||||
}
|
||||
|
||||
/// One item. Field-level structure is what makes three-way merges possible (docs/03).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Item {
|
||||
pub id: Uuid,
|
||||
pub kind: ItemKind,
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub username: Option<String>,
|
||||
#[serde(default)]
|
||||
pub password: Option<String>,
|
||||
#[serde(default)]
|
||||
pub urls: Vec<String>,
|
||||
/// `otpauth://` URI or a bare base32 secret.
|
||||
#[serde(default)]
|
||||
pub totp: Option<String>,
|
||||
#[serde(default)]
|
||||
pub notes: Option<String>,
|
||||
#[serde(default)]
|
||||
pub passkeys: Vec<Passkey>,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub favorite: bool,
|
||||
/// Android package names this login may fill (verified via Digital Asset Links when possible).
|
||||
#[serde(default)]
|
||||
pub android_apps: Vec<String>,
|
||||
pub created_at: i64,
|
||||
pub modified_at: i64,
|
||||
}
|
||||
|
||||
impl Item {
|
||||
pub fn new_login(title: impl Into<String>, now: i64) -> Self {
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
kind: ItemKind::Login,
|
||||
title: title.into(),
|
||||
username: None,
|
||||
password: None,
|
||||
urls: vec![],
|
||||
totp: None,
|
||||
notes: None,
|
||||
passkeys: vec![],
|
||||
tags: vec![],
|
||||
favorite: false,
|
||||
android_apps: vec![],
|
||||
created_at: now,
|
||||
modified_at: now,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Item {
|
||||
fn drop(&mut self) {
|
||||
self.password.zeroize();
|
||||
self.totp.zeroize();
|
||||
self.notes.zeroize();
|
||||
self.passkeys.zeroize();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! Plaintext data model. Instances only exist inside an unlocked core and are
|
||||
//! serialized to JSON before `kv_crypto::keys::seal_item`.
|
||||
|
||||
pub mod generator;
|
||||
pub mod item;
|
||||
pub mod matching;
|
||||
pub mod totp;
|
||||
|
||||
pub use item::{Item, ItemKind, Passkey};
|
||||
pub use matching::{match_url, MatchLevel};
|
||||
@@ -0,0 +1,70 @@
|
||||
//! URL matching for autofill (docs/05 "Matching rules").
|
||||
|
||||
use url::Url;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum MatchLevel {
|
||||
None = 0,
|
||||
/// Same registrable domain (eTLD+1), different host.
|
||||
Domain = 1,
|
||||
/// Same host, different port.
|
||||
Host = 2,
|
||||
/// Same scheme, host and port.
|
||||
Exact = 3,
|
||||
}
|
||||
|
||||
/// Compares a saved item URL with the page URL. Never matches across schemes,
|
||||
/// except that an `http` item may match an `https` page (an upgrade) but never the reverse.
|
||||
pub fn match_url(item_url: &str, page_url: &str) -> MatchLevel {
|
||||
let (Some(item), Some(page)) = (parse(item_url), parse(page_url)) else {
|
||||
return MatchLevel::None;
|
||||
};
|
||||
let scheme_ok = item.scheme() == page.scheme() || (item.scheme() == "http" && page.scheme() == "https");
|
||||
if !scheme_ok {
|
||||
return MatchLevel::None;
|
||||
}
|
||||
let (Some(ih), Some(ph)) = (item.host_str(), page.host_str()) else {
|
||||
return MatchLevel::None;
|
||||
};
|
||||
let (ih, ph) = (ih.to_ascii_lowercase(), ph.to_ascii_lowercase());
|
||||
if ih == ph {
|
||||
return if item.port_or_known_default() == page.port_or_known_default() && item.scheme() == page.scheme() {
|
||||
MatchLevel::Exact
|
||||
} else {
|
||||
MatchLevel::Host
|
||||
};
|
||||
}
|
||||
match (psl::domain_str(&ih), psl::domain_str(&ph)) {
|
||||
(Some(a), Some(b)) if a == b => MatchLevel::Domain,
|
||||
_ => MatchLevel::None,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse(s: &str) -> Option<Url> {
|
||||
let s = s.trim();
|
||||
// Saved URLs are often stored without a scheme ("example.com/login").
|
||||
if s.contains("://") {
|
||||
Url::parse(s).ok()
|
||||
} else {
|
||||
Url::parse(&format!("https://{s}")).ok()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use MatchLevel::*;
|
||||
|
||||
#[test]
|
||||
fn levels() {
|
||||
assert_eq!(match_url("https://example.com", "https://example.com/login"), Exact);
|
||||
assert_eq!(match_url("example.com", "https://example.com/"), Exact);
|
||||
assert_eq!(match_url("https://example.com:8443", "https://example.com"), Host);
|
||||
assert_eq!(match_url("https://example.com", "https://login.example.com"), Domain);
|
||||
assert_eq!(match_url("https://a.github.io", "https://b.github.io"), None); // public suffix
|
||||
assert_eq!(match_url("https://example.com", "https://example.com.evil.net"), None);
|
||||
assert_eq!(match_url("https://example.com", "http://example.com"), None); // no downgrade
|
||||
assert_eq!(match_url("http://example.com", "https://example.com"), Host); // upgrade allowed
|
||||
assert_eq!(match_url("https://example.co.uk", "https://other.co.uk"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
//! RFC 6238 TOTP. Accepts `otpauth://totp/...` URIs or bare base32 secrets.
|
||||
|
||||
use data_encoding::BASE32_NOPAD;
|
||||
use hmac::{Hmac, Mac};
|
||||
use thiserror::Error;
|
||||
use url::Url;
|
||||
|
||||
#[derive(Debug, Error, PartialEq, Eq)]
|
||||
pub enum TotpError {
|
||||
#[error("invalid TOTP secret or URI")]
|
||||
Invalid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Algorithm {
|
||||
Sha1,
|
||||
Sha256,
|
||||
Sha512,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Totp {
|
||||
pub secret: Vec<u8>,
|
||||
pub digits: u32,
|
||||
pub period: u64,
|
||||
pub algorithm: Algorithm,
|
||||
}
|
||||
|
||||
impl Totp {
|
||||
pub fn parse(input: &str) -> Result<Self, TotpError> {
|
||||
let input = input.trim();
|
||||
if input.starts_with("otpauth://") {
|
||||
let url = Url::parse(input).map_err(|_| TotpError::Invalid)?;
|
||||
if url.host_str() != Some("totp") {
|
||||
return Err(TotpError::Invalid);
|
||||
}
|
||||
let mut t = Totp { secret: vec![], digits: 6, period: 30, algorithm: Algorithm::Sha1 };
|
||||
for (k, v) in url.query_pairs() {
|
||||
match k.to_ascii_lowercase().as_str() {
|
||||
"secret" => t.secret = decode_b32(&v)?,
|
||||
"digits" => t.digits = v.parse().map_err(|_| TotpError::Invalid)?,
|
||||
"period" => t.period = v.parse().map_err(|_| TotpError::Invalid)?,
|
||||
"algorithm" => {
|
||||
t.algorithm = match v.to_ascii_uppercase().as_str() {
|
||||
"SHA1" => Algorithm::Sha1,
|
||||
"SHA256" => Algorithm::Sha256,
|
||||
"SHA512" => Algorithm::Sha512,
|
||||
_ => return Err(TotpError::Invalid),
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if t.secret.is_empty() || !(6..=10).contains(&t.digits) || t.period == 0 {
|
||||
return Err(TotpError::Invalid);
|
||||
}
|
||||
Ok(t)
|
||||
} else {
|
||||
Ok(Totp { secret: decode_b32(input)?, digits: 6, period: 30, algorithm: Algorithm::Sha1 })
|
||||
}
|
||||
}
|
||||
|
||||
pub fn code_at(&self, unix_time: u64) -> String {
|
||||
let counter = (unix_time / self.period).to_be_bytes();
|
||||
let mac = match self.algorithm {
|
||||
Algorithm::Sha1 => hmac_bytes::<Hmac<sha1::Sha1>>(&self.secret, &counter),
|
||||
Algorithm::Sha256 => hmac_bytes::<Hmac<sha2::Sha256>>(&self.secret, &counter),
|
||||
Algorithm::Sha512 => hmac_bytes::<Hmac<sha2::Sha512>>(&self.secret, &counter),
|
||||
};
|
||||
let offset = (mac[mac.len() - 1] & 0x0f) as usize;
|
||||
let bin = u32::from_be_bytes(mac[offset..offset + 4].try_into().unwrap()) & 0x7fff_ffff;
|
||||
let code = bin as u64 % 10u64.pow(self.digits);
|
||||
format!("{:0width$}", code, width = self.digits as usize)
|
||||
}
|
||||
|
||||
/// Seconds remaining in the current period.
|
||||
pub fn remaining(&self, unix_time: u64) -> u64 {
|
||||
self.period - unix_time % self.period
|
||||
}
|
||||
}
|
||||
|
||||
fn hmac_bytes<M: Mac + hmac::digest::KeyInit>(key: &[u8], msg: &[u8]) -> Vec<u8> {
|
||||
let mut m = <M as hmac::digest::KeyInit>::new_from_slice(key).expect("HMAC accepts any key length");
|
||||
m.update(msg);
|
||||
m.finalize().into_bytes().to_vec()
|
||||
}
|
||||
|
||||
fn decode_b32(s: &str) -> Result<Vec<u8>, TotpError> {
|
||||
let cleaned: String = s.chars().filter(|c| !c.is_whitespace() && *c != '=' && *c != '-').collect::<String>().to_ascii_uppercase();
|
||||
BASE32_NOPAD.decode(cleaned.as_bytes()).map_err(|_| TotpError::Invalid)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// RFC 6238 Appendix B.
|
||||
#[test]
|
||||
fn rfc6238_vectors() {
|
||||
let mk = |secret: &[u8], algorithm| Totp { secret: secret.to_vec(), digits: 8, period: 30, algorithm };
|
||||
let sha1 = mk(b"12345678901234567890", Algorithm::Sha1);
|
||||
let sha256 = mk(b"12345678901234567890123456789012", Algorithm::Sha256);
|
||||
let sha512 = mk(b"1234567890123456789012345678901234567890123456789012345678901234", Algorithm::Sha512);
|
||||
assert_eq!(sha1.code_at(59), "94287082");
|
||||
assert_eq!(sha256.code_at(59), "46119246");
|
||||
assert_eq!(sha512.code_at(59), "90693936");
|
||||
assert_eq!(sha1.code_at(1111111109), "07081804");
|
||||
assert_eq!(sha1.code_at(20000000000), "65353130");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_uri() {
|
||||
let t = Totp::parse("otpauth://totp/Ex:me?secret=GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ&issuer=Ex&digits=8").unwrap();
|
||||
assert_eq!(t.secret, b"12345678901234567890");
|
||||
assert_eq!(t.code_at(59), "94287082");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user