mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
* feat(browser): save and fill passwords in Orca's browser (experimental) Off by default under Settings > Experimental > Saved passwords. * fix(browser): secure saved passwords and fix login lifecycles * fix(browser): keep the guest preload self-contained so saved passwords load The shared uuid import split into a chunk the sandboxed preload can't require, so the whole preload failed on every page. Element tokens now come from crypto.getRandomValues. * fix(browser): fill a saved login on mouse press so clicking a row works Pressing Orca's UI blurs the page field, which closed the list before the click landed. * feat(browser): manage saved passwords from a Passwords section in Settings › Browser Replaces the Manage passwords dialog with an inline section: save and Touch ID switches, import menu, a searchable list grouped by site with last-used times and hover actions in a capped scroll box, delete confirmations, and an Add/Edit dialog. * feat(browser): turn saved passwords on for everyone instead of behind Experimental Drops the experimental flag, its Experimental toggle and the badge; the Passwords section in Settings › Browser now shows on desktop for the local host without opting in. With no toggle, the guest disable message and the apply-to-open-pages step had nothing left to trigger them. * fix(browser): refuse protected password actions when Touch ID is set up but unavailable, and keep fills on the matched page Touch ID lockout or a closed lid made canPromptTouchID() false, which counted as a Mac without Touch ID, so show, copy and protected fill went through unchecked. Enrollment from bioutil now tells the two apart. A fill also kept no hold on the page across the Touch ID prompt, so a page that navigated meanwhile got the password; it now goes only to the frame and origin it was matched for. * fix(browser): keep a browser profile when its saved logins can't be deleted Profile deletion removed the profile first and only logged a failed vault delete, so the encrypted vault could stay on disk with nothing left to retry it. The vault now goes first, and the profile stays (deletion reports failure) when it can't.
113 lines
4.2 KiB
TypeScript
113 lines
4.2 KiB
TypeScript
/**
|
|
* Per-field rules for credential form classification, after Chromium's local password-form
|
|
* parser (form_data_parser.cc, BSD) and Bitwarden's newsletter rule. Pure: no DOM access.
|
|
*/
|
|
|
|
/** What the classifier needs to know about one `<input>`, read once by the DOM layer. */
|
|
export type PasswordFieldDescriptor = {
|
|
/** The input's `type` property, lowercased (absent or unknown types read as 'text'). */
|
|
type: string
|
|
/** True when the input has ever been `type=password`, so show-password toggles stay passwords. */
|
|
wasPassword: boolean
|
|
name: string
|
|
id: string
|
|
autocomplete: string
|
|
placeholder: string
|
|
ariaLabel: string
|
|
labelText: string
|
|
/** Typed or filled value; '' when the user has not typed into it. */
|
|
value: string
|
|
/** Rendered with a non-empty box, not display:none or visibility:hidden. */
|
|
visible: boolean
|
|
disabled: boolean
|
|
}
|
|
|
|
const TEXT_LIKE_TYPES = new Set(['text', 'email', 'number', 'search', 'tel', 'url'])
|
|
const OTP_PATTERN =
|
|
/one.?time|\b(otp|otc|totp|sms|2fa|mfa)\b|verif(y|ication)?.?code|two.?factor|2.?factor|security.?code/i
|
|
const SSN_PATTERN = /ssn|social.?security/i
|
|
const CVC_PATTERN = /cvc|cvv|csc|card.?(verification|security)/i
|
|
const USERNAME_WORDS_PATTERN = /user.?name|login|email|e-mail|account|userid|user.?id|identifier/i
|
|
const SEARCH_TOKENS = new Set(['search', 'query', 'find'])
|
|
const NEWSLETTER_PATTERN = /newsletter|subscri(be|ption)|mailing.?list/i
|
|
const USERNAME_STEP_PATTERN = /sign.?in|log.?in|continue|next/i
|
|
const USERNAME_PLACEHOLDER_VALUES = new Set(['email', 'username', 'username or email'])
|
|
|
|
/** The role-bearing token of an autocomplete attribute: its last token, ignoring `webauthn`. */
|
|
export function autocompleteRoleToken(autocomplete: string): string {
|
|
const tokens = autocomplete.trim().toLowerCase().split(/\s+/).filter(Boolean)
|
|
if (tokens.at(-1) === 'webauthn') {
|
|
tokens.pop()
|
|
}
|
|
return tokens.at(-1) ?? ''
|
|
}
|
|
|
|
export function isPasswordLikeField(field: PasswordFieldDescriptor): boolean {
|
|
return field.type === 'password' || field.wasPassword
|
|
}
|
|
|
|
/** The cheap first gate, so the DOM layer skips layout reads for checkboxes, hidden inputs and the like. */
|
|
export function isCredentialInputType(type: string, wasPassword: boolean): boolean {
|
|
return type === 'password' || wasPassword || TEXT_LIKE_TYPES.has(type)
|
|
}
|
|
|
|
export function isCandidatePasswordFormField(field: PasswordFieldDescriptor): boolean {
|
|
if (field.disabled || !isCredentialInputType(field.type, field.wasPassword)) {
|
|
return false
|
|
}
|
|
const role = autocompleteRoleToken(field.autocomplete)
|
|
if (role === 'one-time-code' || role.startsWith('cc-')) {
|
|
return false
|
|
}
|
|
// Why word-split: `\b` alone misses `otp_code` and `otpCode`.
|
|
const nameAndId = words(`${field.name} ${field.id}`).join(' ')
|
|
return (
|
|
!OTP_PATTERN.test(nameAndId) && !SSN_PATTERN.test(nameAndId) && !CVC_PATTERN.test(nameAndId)
|
|
)
|
|
}
|
|
|
|
export function matchesUsernameWords(field: PasswordFieldDescriptor): boolean {
|
|
return [field.name, field.id, field.labelText, field.placeholder, field.ariaLabel].some((text) =>
|
|
USERNAME_WORDS_PATTERN.test(text)
|
|
)
|
|
}
|
|
|
|
/** Splits `searchInput`, `site-search` and `q_find` alike into lowercase words. */
|
|
function words(text: string): string[] {
|
|
return text
|
|
.split(/[^A-Za-z0-9]+|(?<=[a-z])(?=[A-Z])/)
|
|
.filter(Boolean)
|
|
.map((word) => word.toLowerCase())
|
|
}
|
|
|
|
export function isSearchField(field: PasswordFieldDescriptor): boolean {
|
|
if (field.type === 'search') {
|
|
return true
|
|
}
|
|
return [field.name, field.id, field.placeholder].some((text) =>
|
|
words(text).some((word) => SEARCH_TOKENS.has(word))
|
|
)
|
|
}
|
|
|
|
export function isNewsletterForm(
|
|
text: string,
|
|
fields: readonly PasswordFieldDescriptor[]
|
|
): boolean {
|
|
return (
|
|
NEWSLETTER_PATTERN.test(text) ||
|
|
fields.some((field) => NEWSLETTER_PATTERN.test(`${field.name} ${field.id}`))
|
|
)
|
|
}
|
|
|
|
export function mentionsUsernameStep(text: string): boolean {
|
|
return USERNAME_STEP_PATTERN.test(text)
|
|
}
|
|
|
|
/** True for a username field value a fill may overwrite: empty, or a page's stand-in text. */
|
|
export function isPlaceholderUsernameValue(value: string): boolean {
|
|
const normalized = value.trim().toLowerCase()
|
|
return (
|
|
normalized === '' || USERNAME_PLACEHOLDER_VALUES.has(normalized) || normalized.startsWith('@')
|
|
)
|
|
}
|