diff --git a/hel/src/parser.rs b/hel/src/parser.rs index a6921de..2ce8f0a 100644 --- a/hel/src/parser.rs +++ b/hel/src/parser.rs @@ -38,17 +38,20 @@ peg::parser! { rule mname() -> Password = &(word() _ word() _ num()? mode() _ date()) pr:word() _ pn:word() _ pl:num()? pm:mode() _ pd:date() pc:comment()? { Password::new(Some(pr), pn, pl, pm, 99, pd, pc) } // prefix + name + [len]mode (no seq/date) -> defaults seq 99, date now - rule npname() -> Password = &(word() _ word() _ num()? mode()) pr:word() _ pn:word() _ pl:num()? pm:mode() - { Password::new(Some(pr), pn, pl, pm, 99, Date::now(), None) } + rule npname() -> Password = &(word() _ word() _ num()? mode()) pr:word() _ pn:word() _ pl:num()? pm:mode() pc:comment()? + { Password::new(Some(pr), pn, pl, pm, 99, Date::now(), pc) } rule sname() -> Password = &(word() _ num()? mode() _ date()) pn:word() _ pl:num()? pm:mode() _ pd:date() pc:comment()? { Password::new(None, pn, pl, pm, 99, pd, pc) } - rule nname() -> Password = &(word() _ num()? mode()) pn:word() _ pl:num()? pm:mode() - { Password::new(None, pn, pl, pm, 99, Date::now(), None) } - // prefix + name only -> defaults mode R, seq 99, date now - rule qpname() -> Password = &(word() _ word()) pr:word() _ pn:word() - { Password::new(Some(pr), pn, None, Mode::Regular, 99, Date::now(), None) } - rule qname() -> Password = &(word()) pn:word() - { Password::new(None, pn, None, Mode::Regular, 99, Date::now(), None) } + rule nname() -> Password = &(word() _ num()? mode()) pn:word() _ pl:num()? pm:mode() pc:comment()? + { Password::new(None, pn, pl, pm, 99, Date::now(), pc) } + // prefix + name (+ optional comment) -> defaults mode R, seq 99, date now. + // A `^parent` token is never a name, so a `^`-leading second word fails here and + // falls through to qname, which keeps it as the comment (the parent marker). + rule qpname() -> Password = &(word() _ word()) pr:word() _ pn:$(!"^" ['!'..='~']+) pc:comment()? + { Password::new(Some(pr), pn.to_string(), None, Mode::Regular, 99, Date::now(), pc) } + // name (+ optional comment) -> e.g. `github` or `github ^work` + rule qname() -> Password = pn:word() pc:comment()? + { Password::new(None, pn, None, Mode::Regular, 99, Date::now(), pc) } pub rule name() -> Password = name:(jname() / pname() / mname() / npname() / sname() / nname() / qpname() / qname())? {? match name { Some(n) => Ok(n), None => Err("failed to parse password description") } } @@ -107,7 +110,7 @@ peg::parser! { rule pass_cmd() -> Command<'input> = p:(pass_long_cmd() / pass_short_cmd()) { p } rule correct_cmd() -> Command<'input> = "correct" _ name:word() { Command::Correct(name) } rule uncorrect_cmd() -> Command<'input> = "uncorrect" _ name:word() { Command::Uncorrect(name) } - rule unpass_cmd() -> Command<'input> = "unpass" _ name:word() { Command::UnPass(name) } + rule unpass_cmd() -> Command<'input> = "unpass" name:(_ w:word() { w })? { Command::UnPass(name) } rule enc_cmd() -> Command<'input> = "enc" _ name:word() { Command::Enc(name) } rule rm_cmd() -> Command<'input> = "rm" _ name:word() { Command::Rm(name) } rule comment_cmd() -> Command<'input> = "comment" _ name:word() c:comment()? { Command::Comment(name, c) } @@ -245,6 +248,27 @@ add t3 C 99 2022-12-14 ); } + #[test] + fn parse_short_parent_test() { + // `name ^parent` (no mode/date): name is the name, ^parent stays in the comment + // for fix_hierarchy — it must NOT be read as prefix+name. + let p = command_parser::name("x ^acc").unwrap(); + assert_eq!(p.name, "x"); + assert_eq!(p.prefix, None); + assert_eq!(p.comment, Some("^acc".to_string())); + // `name [len]mode ^parent` keeps the parent in the comment too + let p2 = command_parser::name("x 20R ^acc").unwrap(); + assert_eq!(p2.name, "x"); + assert_eq!(p2.length, Some(20)); + assert_eq!(p2.mode, Mode::Regular); + assert_eq!(p2.comment, Some("^acc".to_string())); + // a real prefix + name (second word not ^-led) still parses as prefix+name + let p3 = command_parser::name("#W9 github").unwrap(); + assert_eq!(p3.prefix, Some("#W9".to_string())); + assert_eq!(p3.name, "github"); + assert_eq!(p3.comment, None); + } + #[test] fn parse_password_test() { assert_eq!( diff --git a/hel/src/repl.rs b/hel/src/repl.rs index 73ca961..b39a667 100644 --- a/hel/src/repl.rs +++ b/hel/src/repl.rs @@ -133,10 +133,14 @@ impl<'a> LKEval<'a> { Command::Set(key, value) => { to_history = false; self.cmd_set(&out, key, value); } Command::Pass(name, None) => self.cmd_pass(&out, &name, &None), Command::Pass(name, pass) => { to_history = false; self.cmd_pass(&out, &name, &pass); }, - Command::UnPass(name) => match self.state.lock().borrow_mut().secrets.remove(name) { + Command::UnPass(Some(name)) => match self.state.lock().borrow_mut().secrets.remove(name) { Some(_) => out.o(format!("Removed saved password for {}", name)), None => out.e(format!("error: saved password for {} not found", name)), }, + Command::UnPass(None) => { + self.state.lock().borrow_mut().secrets.clear(); + out.o("forgot all cached masters".to_string()); + }, Command::Correct(name) => self.cmd_correct(&out, name, true, None), Command::Uncorrect(name) => self.cmd_correct(&out, name, false, None), Command::Noop => { to_history = false; }, @@ -156,7 +160,7 @@ impl<'a> LKEval<'a> { " enc show the generated password\n", " gen[N] N numbered variants; name ends in G.. (all) or X.. (random)\n", " pass [pw] cache a master / override for an entry's subtree\n", - " unpass forget a cached password (unpass / = the root master)\n", + " unpass [name] forget a cached password (unpass / = root; unpass = all)\n", " correct trust this password's hash uncorrect untrust it\n", "\n", "catalog\n", diff --git a/hel/src/structs.rs b/hel/src/structs.rs index cd779a9..843e861 100644 --- a/hel/src/structs.rs +++ b/hel/src/structs.rs @@ -97,7 +97,7 @@ pub enum Command<'a> { Enc(Name), Gen(u32, PasswordRef), Pass(Name, Option), - UnPass(Name), + UnPass(Option), Correct(Name), Uncorrect(Name), PasteBuffer(String), @@ -153,7 +153,8 @@ impl<'a> std::fmt::Display for Command<'a> { Command::Gen(a, b) => write!(f, "gen{} {}", a, b.lock().borrow().to_string().trim()), Command::Pass(a, None) => write!(f, "pass {}", a), Command::Pass(a, Some(b)) => write!(f, "pass {} {}", a, b), - Command::UnPass(s) => write!(f, "unpass {}", s), + Command::UnPass(None) => write!(f, "unpass"), + Command::UnPass(Some(s)) => write!(f, "unpass {}", s), Command::Correct(s) => write!(f, "correct {}", s), Command::Uncorrect(s) => write!(f, "uncorrect {}", s), Command::PasteBuffer(s) => write!(f, "pb {}", s), diff --git a/helwasm/index.html b/helwasm/index.html index f37fa1c..bcd111e 100644 --- a/helwasm/index.html +++ b/helwasm/index.html @@ -66,8 +66,10 @@

Quick password

Type an account name and your master phrase. The password appears instantly, - masked. Click it to reveal, or press Copy. Store - remembers the name (never the password) in this browser. + masked; click it (or the master) to reveal. Press Copy, or + Store to remember the name (never the password). + Click Mark correct once and the right master shows in colour, + so a typo stays black.

@@ -85,6 +87,8 @@ After a name you can add a length and a mode. R is six memorable words (the default); C camel; H hex; B base64; D digits (U… = upper). For example, github 20R. + End a name with ^folder to derive it from a parent; you still type only + your one master and the whole chain is computed.

@@ -100,10 +104,11 @@
Generate

Quick password

- The same name and master always make the same password, generated live in - your browser. Nothing is saved unless you press Store, and - then only in this browser on your device, never on a server. The app never - communicates with a server at all. + The same name and master always make the same password, computed live in your + browser. Your master is kept in memory for this tab only, never written to + storage, and the app never talks to a server. Store saves + just the entry name; Mark correct saves only a one-way hash, + both on this device.

@@ -112,7 +117,7 @@
- +
@@ -120,6 +125,7 @@ + @@ -202,7 +208,13 @@ const CATALOG_KEY = "hel_catalog"; // ---- host imports (wasm calls these by bare name → must be globals) ---- - window.hel_get_password = () => (masterEl() ? masterEl().value : "") || ""; + // The master field is the single ROOT master. hel's read_master prompts "/" for + // the root and the parent's NAME when climbing a ^parent chain; by answering only + // the "/" prompt (and "" otherwise) we force hel to climb to the root and COMPUTE + // every intermediate parent from one master — exactly what the CLI does, and the + // same for the easy form and the console below, so both give identical results. + window.hel_get_password = (prompt) => + prompt === "/" ? (masterEl() ? masterEl().value : "") || "" : ""; window.hel_rnd_range = (s, e) => { if (e <= s) return s; const r = crypto.getRandomValues(new Uint32Array(1))[0] / 4294967296; @@ -243,45 +255,77 @@ return s; } - // ---- quick generate (live; stateless unless Store) ---- + // ---- quick generate ---- + // Pure sugar over the same engine the console drives: it only ever runs + // hel_command(...) and DERIVES what it shows from the engine, so you can switch + // to the console at any time and keep working on the same catalog, the same + // cached master, and the same correct hashes. No private form-only state — the + // master lives only in hel's in-memory `secrets` (never written to storage); + // only the one-way "correct" hash is persisted on the device. let lastSecret = ""; + let appliedMaster = null; // last master value pushed to the engine (debounce hint) + function refreshCorrectBtn(enabled, isCorrect) { + const b = $("#markCorrect"); + if (!b) return; + b.disabled = !enabled; + b.classList.toggle("is-correct", !!enabled && !!isCorrect); + b.textContent = enabled && isCorrect ? "Correct ✓" : "Mark correct"; + b.title = enabled && isCorrect + ? "This master is remembered as correct on this device. Click to forget it." + : "Remember this master as correct on this device, so the right master shows in colour."; + } function quickGen(silent) { const spec = $("#qname").value.trim(); - const master = masterEl().value; const sec = $("#qsecret"); const lenEl = $("#qlen"); sec.classList.remove("revealed"); - if (!spec || !master) { - sec.textContent = ""; - lenEl.textContent = ""; - lastSecret = ""; - return; - } // The entry name is NOT always the first token; a leading prefix // (like `*P0 test1 …`) means the name is the next word. Parse it. - const name = hel_parse_name(spec); + const name = spec ? hel_parse_name(spec) : ""; if (!name) { - sec.textContent = ""; - lenEl.textContent = ""; - lastSecret = ""; + sec.textContent = ""; sec.classList.remove("correct"); + lenEl.textContent = ""; lastSecret = ""; + refreshCorrectBtn(false, false); return; } - // hel caches the root master in secrets["/"]; clear it so the live result - // always reflects the current master field (not a stale cached value). - hel_command("unpass /"); + const master = masterEl().value; + // A new master in the field is authoritative: forget the cached chain so every + // parent recomputes from it. An empty field keeps whatever master is stored, so + // names still generate without re-typing it (the auto-`pass`). `unpass` (no arg) + // is the same command you can type in the console. + if (master !== appliedMaster) { + if (master.length) hel_command("unpass"); + appliedMaster = master; + } let out = hel_command("enc " + name); - if (/not found/.test(out)) { + if (/^error: name .* not found/m.test(out)) { // unstored leaf: ephemeral add/enc/rm hel_command("add " + spec); out = hel_command("enc " + name); hel_command("rm " + name); } - hel_command("unpass /"); const pw = stripNoise(out).pop() || ""; + // Colour = "is the master correct?" — derived from the engine so it reflects + // `correct /` / `unpass /` typed in the console too. `enc /` re-emits the root + // correctness check; needs secrets["/"], which the gen above just set. + const rootChk = hel_command("enc /"); + const haveRoot = !/^error:/m.test(rootChk); + const masterOK = haveRoot && !/warning: password \/ is not marked as correct/.test(rootChk); lastSecret = pw; sec.textContent = pw; + sec.classList.toggle("correct", !!pw && masterOK); lenEl.textContent = pw ? "len " + pw.length : ""; + refreshCorrectBtn(!!pw && haveRoot, masterOK); if (!pw && !silent) toast("No output"); } + function toggleCorrect() { + const b = $("#markCorrect"); + if (!b || b.disabled) return; + const wasCorrect = b.classList.contains("is-correct"); + // Store / remove the root-master hash on the device (same as the console). + hel_command(wasCorrect ? "uncorrect /" : "correct /"); + quickGen(true); + toast(wasCorrect ? "Master no longer marked correct" : "Master marked correct"); + } function quickStore() { const spec = $("#qname").value.trim(); if (!spec) return toast("Enter a name"); @@ -413,8 +457,11 @@ } }); $("#qsecret").addEventListener("click", () => $("#qsecret").classList.toggle("revealed")); + // Click the master field to show what you typed; click again to re-mask. + $("#master").addEventListener("click", () => $("#master").classList.toggle("revealed")); $("#copy").onclick = () => (lastSecret ? copyText(lastSecret) : toast("Nothing to copy")); $("#store").onclick = quickStore; + $("#markCorrect").onclick = toggleCorrect; // pass overlay wiring $("#passOk").onclick = submitPass; diff --git a/helwasm/pkg/helwasm_bg.wasm b/helwasm/pkg/helwasm_bg.wasm index c7bffb5..b2f5c9b 100644 Binary files a/helwasm/pkg/helwasm_bg.wasm and b/helwasm/pkg/helwasm_bg.wasm differ diff --git a/helwasm/style.css b/helwasm/style.css index 5cc370f..7c3a077 100644 --- a/helwasm/style.css +++ b/helwasm/style.css @@ -163,6 +163,10 @@ input:focus { border-color: var(--ocean); box-shadow: 0 0 0 3px rgba(27, 65, 97, strong password". Firefox uses the bundled disc font. */ input.mask { -webkit-text-security: disc; } @supports not (-webkit-text-security: disc) { input.mask { font-family: "text-security-disc", var(--mono); } } +/* Click the masked master field to reveal what you typed; click again to re-mask. */ +#master { cursor: pointer; } +input.mask.revealed { -webkit-text-security: none; } +@supports not (-webkit-text-security: disc) { input.mask.revealed { font-family: var(--mono); } } /* Buttons */ .btn { @@ -177,6 +181,10 @@ input.mask { -webkit-text-security: disc; } .btn.ghost { background: transparent; color: var(--ocean); } .btn.ghost:hover { background: rgba(27, 65, 97, 0.06); color: var(--ocean); border-color: var(--ocean); } .btn.small { padding: 7px 15px; font-size: 13px; } +/* "Mark correct" toggle: filled ocean once the master is remembered-correct. */ +.btn.small.is-correct { background: var(--ocean); color: var(--cream); border-color: var(--ocean); } +.btn.small.is-correct:hover { background: var(--deep); border-color: var(--deep); } +.btn:disabled { opacity: .4; cursor: not-allowed; pointer-events: none; } /* Quick-gen result row */ .result { @@ -185,7 +193,7 @@ input.mask { -webkit-text-security: disc; } border-radius: 12px; background: #fff; min-height: 58px; } .result .tag { font-family: var(--mono); font-size: 11px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted); } -.result .actions { margin-left: auto; display: flex; gap: 8px; } +.result .actions { margin-left: auto; display: flex; gap: 8px; flex-wrap: wrap; } .result .len { font-family: var(--mono); font-size: 11px; color: var(--muted); } /* Quick-gen result: dynamic font (scales down on narrow phones) and wraps so a long password stays inside the card. min-width:0 lets it shrink/wrap within @@ -202,7 +210,12 @@ input.mask { -webkit-text-security: disc; } font-size: 19px; color: var(--ink); cursor: pointer; user-select: text; white-space: pre; } -.secret.revealed { -webkit-text-security: none; color: var(--ocean); } +/* Reveal flips masking only; colour is reserved for correctness (below). */ +.secret.revealed { -webkit-text-security: none; } +/* Quick-gen result: ocean = this master is marked correct; otherwise ink ("black"). */ +.result .secret.correct { color: var(--ocean); font-weight: 500; } +/* Console keeps its old cue: a revealed secret turns ocean. */ +.console-out .secret.revealed { color: var(--ocean); } /* Firefox lacks -webkit-text-security: fall back to the disc webfont for masking (single-line result only; the console table is a WebKit/Blink concern). */ @supports not (-webkit-text-security: disc) {