Initial scaffold for kestrove-core

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-15 18:30:14 +09:00
co-authored by Claude Opus 5.5
commit 462fbbc523
34 changed files with 2063 additions and 0 deletions
+21
View File
@@ -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
+76
View File
@@ -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
}
}
+85
View File
@@ -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();
}
}
+10
View File
@@ -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};
+70
View File
@@ -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);
}
}
+117
View File
@@ -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");
}
}