Passkeys for the browser extension, tags in item lists

The browser bridge answers passkey_query/get/create/result/cancel: the page's
RP ID is checked against the tab origin (no public suffixes), every request
waits for the user to approve it in the app, and the extension polls for the
result. Tags are normalised on save and included in item summaries.
confirm_master_password verifies the user where there are no biometrics.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-24 09:28:51 +07:00
co-authored by Claude Opus 5.5
parent d9756d34c1
commit 8f0743d964
9 changed files with 672 additions and 8 deletions
+41
View File
@@ -80,6 +80,30 @@ impl Item {
}
}
/// Longest tag kept, in characters.
pub const MAX_TAG_LEN: usize = 64;
/// Most tags kept on one item.
pub const MAX_TAGS: usize = 32;
/// The tags as every app stores them: trimmed, without a leading `#`, inner whitespace collapsed,
/// at most [`MAX_TAG_LEN`] characters, case-insensitive duplicates dropped (first spelling wins),
/// in the given order, at most [`MAX_TAGS`].
pub fn normalize_tags<S: AsRef<str>>(tags: &[S]) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for t in tags {
let t = t.as_ref().trim().trim_start_matches('#');
let t: String = t.split_whitespace().collect::<Vec<_>>().join(" ").chars().take(MAX_TAG_LEN).collect();
if t.is_empty() || out.iter().any(|o| o.to_lowercase() == t.to_lowercase()) {
continue;
}
out.push(t);
if out.len() == MAX_TAGS {
break;
}
}
out
}
impl Drop for Item {
fn drop(&mut self) {
self.password.zeroize();
@@ -88,3 +112,20 @@ impl Drop for Item {
self.passkeys.zeroize();
}
}
#[cfg(test)]
mod tag_tests {
use super::*;
#[test]
fn tags_are_normalised() {
assert_eq!(
normalize_tags(&[" #Work ", "work", "", " family trip ", "#", "Bank"]),
["Work", "family trip", "Bank"]
);
let long = "x".repeat(100);
assert_eq!(normalize_tags(&[long.as_str()])[0].chars().count(), MAX_TAG_LEN);
let many: Vec<String> = (0..50).map(|i| format!("t{i}")).collect();
assert_eq!(normalize_tags(&many).len(), MAX_TAGS);
}
}
+3 -1
View File
@@ -7,5 +7,7 @@ pub mod matching;
pub mod passkey;
pub mod totp;
pub use item::{Item, ItemKind, Passkey};
pub use item::{normalize_tags, Item, ItemKind, Passkey, MAX_TAGS, MAX_TAG_LEN};
#[cfg(feature = "psl")]
pub use matching::registrable_domain;
pub use matching::{match_url, MatchLevel};
+6
View File
@@ -37,6 +37,12 @@ pub fn match_url(item_url: &str, page_url: &str) -> MatchLevel {
same_registrable_domain(&ih, &ph)
}
/// The registrable domain (eTLD+1) of `host`, or `None` for a public suffix such as `com` or `github.io`.
#[cfg(feature = "psl")]
pub fn registrable_domain(host: &str) -> Option<&str> {
psl::domain_str(host)
}
#[cfg(feature = "psl")]
fn same_registrable_domain(a: &str, b: &str) -> MatchLevel {
match (psl::domain_str(a), psl::domain_str(b)) {