Hisar — Design
Status: Draft, Phase 0. This document is the source of truth for the security model, data model and sync protocol. Change it before changing the code.
1. Goals and non-goals
Goals
- End-to-end encrypted password manager for developers, run as a hosted service (
api.tryhisar.com): one server binary + Postgres. - Person-to-person sharing by email: anyone can share a vault they own with anyone else, and nobody can list the accounts on the server.
- Encrypted vault payloads. Normal client protocols do not send the master password, the Secret Key or vault decryption keys to the server. Plaintext metadata is listed in §2.3. Server-served browser code has a different trust boundary (§2.2).
- Offline-first native clients keep an encrypted copy of records they have synced. Offline reads and exports use that subset, which can be stale or incomplete (§9.1, §9.11).
- Shared vaults between people. Signing in from an Emergency Kit still requires the master password (§6). Replacing credentials is not recovery from a compromised old keyset (§5.7).
- An admin dashboard on every server at
/admin(§5.1). Its normal API offers no vault-content reader, password reset or recovery bypass. Its browser code handles admin factors and is served by the server, so users must trust that code when entering them. - Clients: desktop (first), browser extension, mobile (iPhone and Android), CLI.
- One implementation of the crypto, written in Go, shared by all clients.
Non-goals (for now)
- Billing, SSO/SCIM, enterprise policy. Organizations and teams exist (§5.11), without any of these.
- Open registration: sign-up stays invite-only (§5.1).
- Protecting an unlocked device from local malware (out of scope for every password manager).
- Hiding metadata from the server completely (see §2.3).
2. Threat model
2.1 Assets
Item contents (passwords, notes, TOTP seeds, passkeys, attachments), item metadata (titles, URLs), vault names, and the users' master passwords and Secret Keys.
2.2 Adversaries and expected outcome
| Adversary | Capability | Expected outcome |
|---|---|---|
| Network attacker | Reads/modifies traffic | TLS + OPAQUE: learns nothing, cannot impersonate either side |
| Server breach (DB dump) | Full copy of DB and database backups | Encrypted item contents, titles, URLs and vault names, plus plaintext account, sharing, organization, device and audit metadata (§2.3) and instance settings (§8.3). The dump alone does not supply vault decryption keys. An absent random Secret Key adds 128 bits of resistance to offline credential guessing |
| Malicious/compromised server (live) | Arbitrary responses and server-served browser code | Cannot directly open or fabricate authenticated vault ciphertext without a vault key, but AEAD does not prove freshness or completeness (§7.5). A changed admin page can capture factors entered there; a changed share-link page can capture the fragment key, passphrase and plaintext (§5.1, §5.9). Can substitute public keys during sharing (mitigated by fingerprint verification, §5.6). Can claim an invitation was accepted (§5.8) or an account is in a team (§5.11), so that owners' apps wrap vault keys to an account it controls: apps trust the server's records of who accepted and who is in which team, and only pin and show the keys they wrap to |
| Stolen locked device | Disk image | Needs master password; Secret Key on disk alone is not enough |
| Stolen unlocked device / local malware | Memory, keystrokes | Out of scope. Minimised by auto-lock and short key lifetime |
| Phishing site | Lures user to another host | Login matching uses the registrable domain by default, with scheme and port checks. Exact-host matching is optional, not exact-origin matching (§9.2). Sibling hosts can match, and manual selection of another login is not phishing protection |
| Another user | Legit account | Sees only vaults shared with them or given to their teams, and only the people they share a vault with, who invited them, or who are in an organization with them (§5.8, §5.11). Cannot learn whether an email has an account. An organization admin manages its rosters and gets no vault key for that; an admin who adds themselves to a team gets its vaults like any member, a roster change every member of the organization sees and the audit log records |
The ciphertext authentication boundary is in crypt.OpenItemPart. The browser trust boundaries are visible in the admin's callHisar and the share page's fragment-key and decryption code. Database theft does not by itself replace those pages. A live server compromise can.
2.3 Accepted metadata leakage
The server sees account names and emails, the emails people invite or share with, which users can access which vaults, item IDs and revisions, deletion flags, number of items per vault, ciphertext sizes, timestamps, device names and platforms, and audit events with IP addresses. Item titles, URLs and vault names are encrypted. Organizations and teams have plaintext names, rosters, roles, invited emails and vault assignments. These fields are defined in the base schema, share-invitation schema and organization schema.
For favorites (§4.7), the server sees that an account saved its preferences record, when and how large it is, not which items are starred. For share links (§5.9), the stored record exposes the creator, size, expiry, view limit, views and passphrase parameters, not the shared plaintext or link key. That storage property does not protect keys from a malicious serving page (§2.2). Website icons (§4.5) are fetched by each device from the website itself, so the websites see the device's IP address asking for their icon. Settings → Appearance turns that off.
3. Cryptographic primitives
All from the Go standard library or golang.org/x/crypto. No custom primitives.
| Purpose | Primitive | Go package |
|---|---|---|
| Password stretching | Argon2id | golang.org/x/crypto/argon2 |
| Key derivation | HKDF-SHA256 | crypto/hkdf |
| Symmetric AEAD | XChaCha20-Poly1305 (24-byte random nonce) | golang.org/x/crypto/chacha20poly1305 |
| Public-key wrapping | HPKE base mode, hybrid post-quantum KEM ML-KEM-768 + X25519, HKDF-SHA256, ChaCha20-Poly1305 (RFC 9180) | crypto/hpke |
| Signatures (key fingerprints, invites) | Ed25519 | crypto/ed25519 |
| Authentication | OPAQUE (RFC 9807), ristretto255 suite | github.com/bytemare/opaque |
| Randomness | OS CSPRNG | crypto/rand |
| Share links only (§5.9), which a browser decrypts with WebCrypto | AES-256-GCM (12-byte random nonce), PBKDF2-HMAC-SHA256 for the optional passphrase | crypto/aes, crypto/cipher, crypto/pbkdf2 |
Every ciphertext is versioned (v1) so primitives can be rotated later.
4. Key hierarchy
master password ──Argon2id──► k_pw ─┐
├─HKDF──► AUK (Account Unlock Key) ──decrypts──► keyset
Secret Key ───────HKDF────────► k_sk ┘ └──HKDF──► auth secret ──► OPAQUE login
keyset = { sym key K_user, KEM seed (ML-KEM-768+X25519), Ed25519 signing key }
└─ KEM public key ◄── HPKE-wraps ── vault key K_vault (one per vault, per member)
└─ XChaCha20-Poly1305 ── items, attachments keys
4.1 Secret Key
- 128 bits from
crypto/rand, generated on the user's first device. Never sent to the server. - Display format:
H1-AAAAAA-XXXXX-XXXXX-XXXXX-XXXXX-XXXXXX— version, 6-char account ID, 26 Crockford base32 chars. - Stored on each device in the OS keychain (macOS Keychain, Windows Credential Manager, libsecret, iOS Keychain, Android Keystore).
- Printed on the Emergency Kit together with the server URL and email. The A4 PDF is generated client-side (
core/emergencykit, gopdf with embedded Go fonts) and has a blank line for the master password, which is never printed. The QR payload is plain text,HISARKIT1followed by unpadded base64url UTF-8 JSON with the string fieldsserver,email, andsecretKey. Both native parsers reject malformed UTF-8 and UTF-16 or UTF-32 JSON, including encodings without a byte-order mark. Valid non-ASCII field values are preserved. The server is an http or https address with a host name and no user name or fragment; the generator and both parsers accept an internationalized host name as typed. The payload contains no colon and is not a URI. Scan it only inside Hisar. Copying, searching, or sharing the text still exposes the encoded Secret Key. iPhone and Android accept both this format and old printedhisar://sign-inkits. Old apps need manual entry for new kits, and old printed kits retain their custom-scheme dispatch risk. - Desktop saves the kit through a native save dialog: right after sign up (the Secret Key stays in memory until the user confirms or the app locks), and later from Settings after re-entering the master password. The CLI has
hisar emergency-kit [-o file]. Files are written with mode 0600, and the CLI never overwrites an existing file.
4.2 Deriving AUK and the auth secret
pw = NFKD(master password), UTF-8
salt = random 16 bytes, per account, stored on server (public)
k_pw = Argon2id(pw, salt, t=24, m=64 MiB, p=4, len=32)
k_sk = HKDF-SHA256(ikm=SecretKey, salt=accountID, info="hisar/v1/secret-key", len=32)
ikm = k_pw || k_sk
AUK = HKDF-SHA256(ikm, salt=nil, info="hisar/v1/auk", len=32)
authKey = HKDF-SHA256(ikm, salt=nil, info="hisar/v1/auth", len=32)
- Argon2id parameters are stored per account so they can be raised later. 64 MiB is chosen to fit inside the iOS AutoFill extension memory limit, so cost is tuned with
t. t=24 takes ≈ 270 ms on an M2 Pro; re-check on phones in Phase 4. - Clients enforce
MinArgon2ParamsandMaxArgon2Paramsincore/crypt. The accepted ranges remain 2 to 64 passes, 32 MiB to 1 GiB, and 1 to 16 lanes for existing accounts and exports. Values outside those bounds produce a readable error before derivation. The upper bound can still exhaust a constrained device. Lowering it requires a migration or an approved per-device resource policy. authKeyis the OPAQUE password input. OPAQUE's internal key-stretching function is set to identity because stretching already happened. A server that steals the OPAQUE record still needs the Secret Key to guess the password.- AUK and
authKeyare independent HKDF outputs: the server learns nothing about AUK from the login.
4.3 Keyset
Generated at signup on the first device:
K_user: 32 random bytes, encrypts the user's private data that is not in a vault: share link labels (§5.9) and the account preferences record with the favorites (§4.7).- Hybrid KEM keypair (ML-KEM-768 + X25519, derived from a 32-byte seed): receives vault keys. The hybrid stays secure as long as either half holds, so recorded traffic and DB dumps remain safe against a future quantum computer.
- Ed25519 keypair: signs the KEM public key bound to the account ID, and invites, so others can verify it (§5.6).
The keyset is stored as three 32-byte seeds (K_user, KEM seed, Ed25519 seed); the key pairs are derived from them.
The private half is serialised and encrypted:
encKeyset = XChaCha20-Poly1305(key=AUK, nonce=random24, aad="hisar/v1/keyset"||accountID, plaintext=keyset)
The server stores encKeyset, the KEM public key, the Ed25519 public key and the signature Ed25519.Sign("hisar/v1/kem-key" || accountID || kemPub).
4.4 Vault keys
- Each vault has a random 32-byte
K_vault. - For every member, the server stores
0x01 || HPKE.Seal(memberKEMPub, info="hisar/v1/vault-key" || vaultID || memberAccountID, K_vault). - Owner removal deletes the member's grant and marks
needs_rotationunder the same vault lock. First-party callers (session.RemoveVaultMember, including the apps and both CLI backends) complete rotation before reporting success. A rawDELETE /vaults/{id}/access/{accountID}leaves a persistent pending rotation for an owner's app to finish. Voluntary leave remains a separate non-rotating operation (§5.10).- The owner begins a
rotatetransition with protocol 1 and an explicit current generation. The server captures the item sequence, access revision, metadata revision and sealed name. Grant changes and organization entitlement changes advance the access revision. Renames advance the metadata revision. Every source page and the final commit check that snapshot. - The client reads at most 100 source records per page, with JSON below 8 MiB, and uploads bounded batches. It generates one new key and re-encrypts each page without collecting the entire vault in memory. Grants are uploaded separately in batches of at most 100. Every member's signed KEM key and the client's
MemberVerifiermust be accepted before wrapping. Tombstones are sealed with empty content without opening their old ciphertext, so malformed tombstones cannot prevent revocation. - Commit checks current owner authority, both directions of exact item-set equality, every base revision, and the exact remaining grant set and roles. It publishes the name, complete ciphertext set, grants, generation and sequences in one transaction, and clears
needs_rotation. Old-key history is replaced by one charged current revision per item, not appended as free history. A conflict publishes nothing and the client reloads and retries up to three times. The activation transaction is proportional to vault size. - Before commit, the old generation remains intact. During a removal's pending rotation, the server refuses new ciphertext, moves, renames and key delivery under the exposed generation. Ordinary rotations without pending revocation allow edits during staging and reject the resulting stale snapshot. A committed operation is a receipt for a lost commit response. Abort never undoes a commit.
- Post-removal confirmation and conflict reconciliation read the server directly. A readable offline cache with an older
needsRotation=falsemarker cannot prove that a new generation committed. An unreachable confirmation returns an explicit revocation-pending error. - A begin supersedes the same owner's earlier uncommitted operations on the vault and deletes their staged copies in the same transaction. An app killed or suspended mid-staging loses its operation ID, so its owner's next begin, not the idle lease, ends the abandoned copy. Only one replacement of a generation can commit, so the superseded operation could never have completed beside the new one. Commit and abort also delete their staged copies when they end the operation. A begin is refused while two other owners' operations on the vault are live or still hold staged copies; committed receipts and aborted operations never count. Temporary ciphertext admission is based on the captured source ciphertext plus 256 bytes per record and the maximum wrapped-key bytes for the current recipients. Identical uploads consume no additional capacity. A successful page or upload renews the one-hour idle lease. Commit retains its receipt for one hour. Cleanup runs in bounded batches on begin, abort and every 30 seconds, skips locked operations and deletes only expired operations and their staged copies.
- Staging has no aggregate item-count or request-size ceiling for the whole vault. The bounded legacy
POST /vaults/{id}/rotatestill has its 32 MiB, 50,000-item and 1,000-grant limits, and requires checkedaccessRevisionandmetadataRevision. New clients do not fall back to it when staging is unavailable or fails. - Copies the removed member downloaded before the rotation stay readable to them; that is unavoidable.
- The owner begins a
- Organization vaults (§5.11) use the same per-member wrapping: a team is a roster on the server, never a key. The server decides who is entitled to an organization vault from its teams; an owner's app wraps
K_vaultfor each entitled account that has no wrapped key yet, and when an account loses its entitlement the server deletes its wrapped key at once and marks the vault for rotation, which an owner's app then performs as above, keeping every remaining member and role.
These snapshot checks provide atomicity with an honest server. They do not prove malicious-server freshness or that opaque ciphertext was sealed with the claimed key by a deliberately malicious authorized writer.
4.5 Items
An item has two encrypted parts so lists and search don't decrypt every secret:
- overview: title, category, URLs, tags, icon, subtitle (username).
- details: fields, passwords, notes, TOTP seeds, passkeys, history of field values.
Categories. overview.category is the item's type, set when the item is created and kept by every edit:
| Category | Holds | Shown as |
|---|---|---|
login, or empty (items saved before categories) | username, password, websites, one-time password, passkeys, custom fields, notes | a login; with servers, the only category iOS AutoFill offers and the browser extension fills into login forms (addresses fill address forms, below) |
secure-note | notes and custom fields; saving moves a username, password, website or one-time password into custom fields (Username, Password, Website, One-time password; secrets concealed), so a note never hides a secret its form does not show and nothing is lost | a notes-first view with Copy and a secret reference (hisar://<vault>/<note>/notes) |
credit-card | the custom fields Cardholder, Type, Card number, Expiry (MM/YY), Security code, PIN, the billing address's Street, City, Region, Postal code and Country, and Billing address (a one-line address saved before cards had those), first and in that order, then other custom fields and notes; the number, security code and PIN are concealed; saving, and importing, move login data into custom fields as for a secure note | the brand and last four digits ("Visa •••• 4242"), the number and codes masked until revealed; lists show only the brand and last four digits |
identity | the custom fields First name, Middle name, Last name, Birth date (YYYY-MM-DD when given year first), Email, Phone, Street, City, Region, Postal code, Country, Address (a one-line address saved before identities had those), Passport number, Driver's license number and National ID number, first and in that order, then other custom fields and notes; the document numbers are concealed; saving and importing move login data into custom fields as for a secure note | the full name, the document numbers masked until revealed; lists show the name, or the email |
api-credential | the custom fields Service, Key ID, Secret, Host and Expiry (YYYY-MM-DD when given year first), first and in that order, then other custom fields and notes; the secret is concealed; saving and importing move login data into custom fields as for a secure note | the service and host, the secret masked until revealed, a warning once the expiry has passed; lists show the service and host |
address | the custom fields Street (one or more lines), City, Region, Postal code and Country (two letters written in capitals), first and in that order, then other custom fields and notes; nothing is concealed; saving and importing move login data into custom fields as for a secure note | the address's values; lists show the city and country, or the street's first line |
software-license | the custom fields Product, Version, License key, Licensed to, Email, Purchase date (YYYY-MM-DD when given year first) and Download URL, first and in that order, then other custom fields and notes; the license key is concealed; saving and importing move login data into custom fields as for a secure note | the version and licensed-to name, the license key masked until revealed, the download URL opened in the browser (http and https only); lists show the version and licensed-to name, or the email or product |
database | its own username and password, as a login's, and the custom fields Engine, Host, Port, Database and Options, first and in that order, then other custom fields and notes; a known engine is stored by its ID (postgresql, mysql, mariadb, sqlserver, oracle, mongodb, redis; aliases such as postgres and mssql are accepted), any other as typed; a host typed with its scheme or port is split as a server's | the engine, host, port, database and options, the login fields and the connection string, masked until revealed (for another engine, a custom field named Connection string stands in); lists show the engine, address and database name |
server | its own username, password, one-time password and websites (the admin console), as a login's, and the custom fields Host, Port and SSH key (the ID of an ssh-key item in the same vault), first and in that order, then other custom fields and notes; a host typed with its scheme or port (ssh://build.example.com:2222, db.example.com:5432) is split into host and port | the host and port, the login fields, the admin console opened in the browser and the linked SSH key, which opens; lists show the host, port and username; the extension and iOS AutoFill offer it on its admin console like a login |
ssh-key | §9.6 | §9.6 |
- Creating: desktop + → Secure note, Credit card, Identity, API credential, Address, Software license or Server, iOS + → New secure note, New credit card, New identity, New API credential, New address, New software license or New server (or a vault's Add item),
hisar add --type secure-note|credit-card|identity|api-credential|address|software-license|server. The backend refuses a category it does not know. - Credit cards keep their values as named custom fields rather than a details section of their own, so an app that does not know cards shows them as fields and keeps them when it edits the item, and
hisar get -f "card number"andhisar://<vault>/<card>/security codework as for any field.core/item/card.goholds the schema:ReadCard/WriteCardmap aCardto the fields (names matched ignoring case), andNormalizeCardwrites a known type the same way whatever its spelling ("amex", "mc"), fills an empty type in from the number, groups the number as printed on the card and writes the expiry asMM/YY; the desktop and iPhone editors apply the same formatting as the user types.DescribeCardnames the brand from the number's first digits (Visa, Mastercard, American Express, Discover, Diners Club, JCB, UnionPay, Maestro, Troy) and warns about a failed Luhn check, an unreadable expiry or an expired card; the editors show the warnings, and a card saves anyway. The editors' type picker follows the number until the user picks a different type, and then hints when the two disagree. Bitwarden'sbrandand 1Password's card type are imported as the type. Card autofill in the extension is not part of this. - Identities follow the same pattern (
core/item/identity.go):ReadIdentity/WriteIdentityandNormalizeIdentity, which trims the values and writes a year-first birth date asYYYY-MM-DD, keeping any other form as typed because04/12/1990may mean April or December. The generic part, a record struct mapped to named fields (NamedField,readRecord,writeRecord), iscore/item/schema.go; another type of this kind is a struct, its field list and aNormalizefunction. Identities have no warnings. An identity with a street, or two of city, region and postal code, is offered for address autofill in the browser extension. - Addresses follow it too (
core/item/address.go), and identities and cards keep their address in the same five fields: one helper builds the field list for all three, so the names and order match.Address.Normalizetrims the values and each street line, drops empty lines and writes a two-letter country in capitals ("us" from 1Password), keeping a country name as typed. Before this, identities and cards kept one free-text "Address" or "Billing address"; that value stays a named field, shown as "(old format)" in the editors while it is set, never parsed or rewritten on its own; Use as street moves it into the street. An older app shows the five fields as custom fields and keeps them. Names match ignoring case, so a custom field called "City" on an identity becomes its city; the 1Password importer renames a Driver License's issuingcountryandstatefor that reason.hisar addreads the street a line at a time, ending with an empty line. - API credentials follow it too (
core/item/apicredential.go). Their fields are what a script needs, so they work as secret references:hisar://Work/Stripe/secret,hisar://Work/Stripe/key%20ID,…/host, whichhisar runinjects and masks like any field (§9.4.1). The expiry shares the birth date's normalizing (NormalizeDate), andDescribeAPICredentialExpirywarns once its day has passed in the device's time zone; an expiry in another form, such as "never", is kept and gets no warning. The item's "Copy secret reference" button names the secret. - Software licenses follow it too (
core/item/license.go):hisar://Personal/Sketch/license%20keyresolves the key. The purchase date shares the birth date's normalizing. The download URL is a field, not the item's website, so the extension and iOS AutoFill never offer a license on that site; the apps open it in the browser when it is http or https, addinghttps://to one typed without a scheme. Search, in the apps and for coding agents, finds a license by product, version, licensed-to name and email, never by the key. A login with a "License key" field, which is how 1Password licenses were imported before, counts as a duplicate of a license with the same title and key, so importing the file again skips it. Licenses imported as logins before this type existed stay logins. - Databases (
core/item/database.go) keep their username and password as the item's own login data, as servers do, sohisar://Work/Prod db/passwordandhisar getwork as for a login; they have no website and AutoFill and the extension never offer them. Their connection string is never stored:Database.ConnectionStringbuilds it from the engine, host, port, database name and options and the item's username and password, so it always carries the current password. It is a URL,<scheme>://<user>:<password>@<host>:<port>/<database>?<options>, with the username and password escaped, an IPv6 host in brackets, and the port and parts left out when empty; the schemes arepostgresql,mysql(MySQL and MariaDB),sqlserver,oracle,mongodbandredis, and SQL Server names the database in the query (?database=…). An engine Hisar does not know, or a database without a host, has no connection string.connection stringis a built-in field for references,hisar get -fand Copy on a database, and coding-agent search lists its reference so agents never assemble one. Wherever no string is built (any other item, or a database with an engine Hisar does not know or no host) the label names a custom field instead, so a login imported with a "Connection string" field keeps it and a DB2 database can keep its JDBC URL in one; a built string always wins over such a field. Each named field resolves ashisar://Work/Prod db/hostand so on, sohisar runcan pass the whole string or its parts (§9.4.1). Lists and search show the engine, address and database name, coding agents search the host and database name, and summaries never carry the connection string or options. A 1Password database matches on import a login with its title and username, as such items were imported before this type existed; that login stays a login. - Servers (
core/item/server.go) keep their username and password as the item's own login data, sohisar://Work/Build/password,hisar get, coding-agent references and one-time passwords work as for a login, and their admin console is the item's website:Item.OffersLoginFillmakes logins and servers the items the extension and iOS AutoFill offer, and a save offer on the admin console sees the server's credentials as already saved. Passkeys stay in logins only. The host, port and linked key are named fields;hisar://Work/Build/hostresolves the host. The SSH key link holds the key item's ID and must name an SSH key of the server's own vault (SaveItemrefuses anything else), so everyone who sees the server sees the key; a link whose key was deleted is kept so the server stays editable, and the apps show it as missing. The server's details name the key's title and fingerprint; share links leave the link out. Lists show the address (host and port) and username, and search matches the address, host, port and username; coding agents search the host.hisar add --type serverlinks a key by its title. On import, a 1Password server's admin console becomes its website only when the console has no username or password of its own (or repeats the server's): a console with its own credentials stays custom fields, since as the website the extension would fill the server's password on it and a browser save there would overwrite that password. A server matches a login whose first website is its host and port on import, so a 1Password server imported as a login before this type existed is skipped; it stays a login. - Converting: a login with nothing but notes and custom fields (
item.HasOnlyNotes), such as one imported before secure notes existed, shows "Convert to secure note" (app.Service.ConvertToSecureNote, driven byItemDetail.canConvertToSecureNote). Nothing is converted without the user asking. - Older apps show a secure note as a login with only notes and keep its category when they edit it.
aad = "hisar/v1/item" || vaultID || itemID || revision || part (part ∈ {"overview","details"})
ct_part = XChaCha20-Poly1305(key=K_vault, nonce=random24, aad, plaintext=JSON(part))
One-time passwords (TOTP). details.oneTimePassword holds an otpauth://totp URI, in the encrypted details part.
- Saving: a pasted setup key or link is normalized into the URI (
core/totp): base32 secret, SHA-1/256/512, 6–8 digits, a period of 1–300 s. Anything else is refused. - Codes:
core/totpgenerates them per RFC 6238 and is tested against its Appendix B vectors. - Desktop:
GetOneTimeCodereturns the code with its expiry, and the item shows it with a countdown and asks for the next one when it expires.CopyField("one-time code")copies it. Item lists only carryhasOneTimePassword, never the secret. - CLI:
hisar get <item> -f code, or the secret referencehisar://<vault>/<item>/otpwithhisar readandhisar run(§9.4.1). - Browser extension:
logins.codereturns only the code and its expiry, for a login that matches the page (§9.2). The extension fills two-factor fields and split code boxes from the inline menu, shows codes in the popup, and copies the code after filling a login that has one. - iOS: the item shows the code with a countdown ring and copies it like other fields; the editor takes a setup key or link. The AutoFill extension offers one-time codes on iOS 18 and copies the code when it fills a password (§9.3).
- Import: one-time passwords from 1Password, Bitwarden and CSV exports become
details.oneTimePassword(§9.7).
Passkeys. details.passkeys holds the item's WebAuthn credentials (§9.8): [{credentialId, relyingPartyId, userHandle, userName, userDisplayName, privateKey, prfSecret?, createdAt}]. Binary values are base64 in the JSON; privateKey is the PKCS#8 DER of an ES256 (P-256) key.
- The overview carries only
passkeyRelyingPartyIds, the sites of the item's passkeys, so lists can mark an item as having a passkey without its details. - The PRF secret of a passkey Hisar created is not stored; it is derived from
privateKey(§9.8), so the stored fields are the same with or without PRF. A passkey imported through Credential Exchange (§9.7) keeps the exporting authenticator's hmac-secret credential (credWithUV) asprfSecret, and PRF uses it instead, so sites get the outputs they got before the import. Older apps ignore the field and would derive another secret. - Editing an item never changes its passkeys: the backend copies them (and the overview marker) from the cached item, so editors that show passkeys read-only (desktop, iOS) or not at all keep them. Only "Delete passkey" and the WebAuthn signals (§9.8) remove one; the signals also update user names.
Icons. overview.icon is the icon the user chose (core/item/icon.go): {preset, color}, one of 30 preset IDs (item.IconPresets: bank, card, wallet, …, heart) in one of 10 colors (item.IconColors, or none for the neutral tile), or {image}, a PNG of at most 128 × 128 pixels and 24 KiB, which wins over a preset. Without one, an item shows its automatic icon: a login's website icon (below), else its category's icon or its initials.
- Uploads:
PrepareItemIcon(core/itemicon.NormalizeImage) reads PNG, JPEG, GIF, WebP or ICO (its largest entry) up to 5 MiB and 4096 pixels a side, crops it to a centered square and scales it down (never up) to 128, 96 or 64 pixels until the PNG fits. SVG is refused.SaveItemrefuses an image outside the limits, anditem.NormalizeIcondrops unknown colors; an unknown preset ID, from a newer app, is kept and drawn as the category icon. - Size: 24 KiB is about 32 KiB of base64, inside the server's 64 KiB overview limit, so the server is unchanged. The icon is in the overview, so lists draw it without the details, it syncs, works offline, and export, Duplicate and Move carry it.
- Older apps decode the overview into a struct without
iconand drop it when they save the item; nothing else breaks. The Mac's and iPhone's preset lists are checked against core's by a test (mobile/hisarkit/iconpresets_test.go).
Website icons. A login (no category, or login) without an icon of its own shows its first https website's icon. It is never stored in the item, so the app never writes an item the user did not edit (no new revisions, no conflicts, and it works in vaults the user only reads), and it never reaches the server.
- Fetching (
core/itemicon.Fetcher): the device asks the website itself, never the Hisar server or a third-party icon service. It readshttps://<domain>/(at most 512 KiB), takes theapple-touch-iconandiconlinks of the head, largest declared size first, skips SVG anddata:links, and falls back to/favicon.ico; at most four images of at most 1 MiB each, 5 s per request and 10 s in all, three https-only redirects, no cookies and the User-AgentHisar. The image goes throughNormalizeImage. - Only public websites: the domain must be a public DNS name on port 443: IP literals,
localhost, single-label names and.local,.internal,.lan,.home.arpaare refused, and the dialer refuses any address that resolves to loopback, private, link-local, CGNAT or multicast space, judging NAT64 (64:ff9b::/96) and 6to4 (2002::/16) addresses by the IPv4 address they carry and refusing local-use NAT64 and Teredo outright, so a login for a router or an intranet console, or a name rebound to one, never makes the app connect into the local network. No proxy is used, so the address checked is the website's. - Cache (
app.GetWebsiteIcon): each device keeps{image, fetchedAt}per domain in the offline cache'swebsite_iconstable, sealed with keys derived from K_user (crypt.DeriveWebsiteIconKeys, HKDF infohisar/v1/website-icon-cachewith the account ID as salt): the record is XChaCha20-Poly1305 under AADhisar/v1/website-icon || lookup, and its keylookupis HMAC-SHA256 of the domain, so the cache file names no website. An icon is asked again after 30 days, a website without one after 7, and a failed fetch (offline, a timeout) not again in that session for an hour. Concurrent requests for a domain share one fetch. The keys are forgotten on lock; the cache file goes with the rest on sign-out. Icons are read from and written to the cache file only while the unlocked session has the cache attached (§9.1); after a failed attachment they stay in memory, so they never leave a cache without an account, which later launches would refuse. - Setting: Show website icons (Settings → Appearance on every app, a device setting,
websiteIconsDisabledin the state file, on by default). Turning it off forgets the cached icons. While locked, or for anything but a public https website,GetWebsiteIconreturns nothing. An icon request retains the fetch context authorized while the session is unlocked and icons are enabled. It checks that context before fetching and before caching or returning the result. Turning icons off cancels that context and separates later requests from pending fetches, even if icons are enabled again before a fetch finishes. - Privacy: fetching tells each website, from the device's IP address, that someone is looking at an item for it (§2.3); that is the price of the feature, and the switch turns it off.
Binding vault ID, item ID and revision in the AAD stops a malicious server from moving an item into another vault, swapping overview/details between items, or mixing parts of different revisions.
All || concatenations of variable-length fields are length-prefixed (4-byte big-endian length before each field), so field boundaries are unambiguous.
4.6 Attachments
Each file gets a random K_file. The file is chunked (64 KiB chunks) and each chunk is encrypted with K_file. The chunk index and an is-last flag go into the AAD. K_file is stored inside the item's details, so it inherits the vault key.
4.7 Favorites (the account preferences record)
Users star items, and Favorites at the top of the sidebar (Mac) and of the Vaults screen (iPhone) lists them across every vault.
Decision: favorites are per user, synced across the user's own devices, and end-to-end encrypted. Starring an item in a shared vault must not change the sidebars of the other people in the vault, so a favorite is not a field of the item overview. A list kept only on the device would not carry from the Mac to the iPhone, so it is not device state either. It lives in the account's own encrypted preferences record, which no one else can read.
-
Record. One per account:
JSON({"favoriteItemIds": [itemID, …]}), sealed withK_user(§4.3), which survives master password and Secret Key changes (§5.7):aad = "hisar/v1/account-preferences" || accountIDct = XChaCha20-Poly1305(key=K_user, nonce=random24, aad, plaintext=JSON(preferences))Keys a newer app adds are kept, as they are, by an older app that saves the record. The server stores
(account_id, revision, ciphertext)inaccount_preferencesand never learns which items are favorites; deleting the account deletes the row. -
Item IDs only. A favorite names the item ID, not the vault: moving items to another vault keeps their IDs (§5.10), so a moved favorite stays a favorite.
-
Sync.
GET /account/preferencesreturns{revision, ciphertext}(revision 0 and no ciphertext before the first save).PUT /account/preferences {baseRevision, ciphertext}stores revisionbaseRevision + 1and answers{revision}, or 409 when another device saved in between; the record is at most 1 MiB. A save pushesaccount-preferences-changedto the account's event streams (§7.2), and the apps sync and refresh as forvault-changed. -
Merging.
session.UpdateFavoritessends a change, the items to star and to unstar, on top of the record the device last read. On 409 it reads the record again and applies the same change to it, up to 5 times, so two devices that star different items at the same time keep both, and one device unstarring an item while another stars a different one keeps both changes. For the same item, the last save wins. A record that fails authentication, is too short, or has an unsupported envelope version never blocks a save. The save seals the record the device last read, with the change, over it. Writers must keep this record in a v1 envelope and put new data in new plaintext JSON fields. A future envelope change needs a new record or a minimum-app-version gate before any client writes it, because older apps replace records they cannot open. Local key-size errors and JSON errors in authenticated plaintext remain hard errors. -
Gone items. Favorites of items that were deleted, are in a vault the user left or lost, or no longer exist anywhere are left out of every list and never cause an error. They are dropped from the record lazily: the next time the Mac or the iPhone saves a favorite, it first syncs every vault and removes the IDs it found in none of them (not while offline).
-
Offline. The record is cached with the account's other encrypted records (§9.1), so favorites show offline; starring needs the server, like other changes.
-
Apps.
core/app:ItemDetail.isFavoriteon every item and summary,ListFavoriteItems(the starred items of every vault with each one's vault, without secrets) andSetItemFavorite. Readers can star items too: a favorite changes nothing in the vault. The Mac, the iPhone andhisarshow favorites as described in §9.1, §9.3 and §9.4. -
Import. Favorites the other password manager marked become the importing user's favorites (§9.7).
-
Export. The export (§9.11) holds vaults and items only, so favorites are not exported; the record stays with the account.
5. Accounts and devices
5.1 Invites
Sign-up is invite-only: there is no open registration. An invite is an email address plus a one-time token (the server stores its SHA-256 hash in invites), valid for 7 days. The sign-up link is https://<HISAR_PUBLIC_URL host>/invite/<token> (http only for a loopback host, in development); the token alone is the invite code the apps accept.
- Any signed-in user invites a person by email with
POST /invites {email}. The server emails the invite itself (§8.5); the response is always202 {"message": "Invitation sent"}and never contains the link or the code. When the email already has an account, nothing is created or sent and the answer is the same, so invites do not reveal who has an account. Invites from users always creatememberaccounts. - Without email configured on the server (§8.4),
POST /invitesanswers501 email is not configured on this server. - Rate limits: 10 invites per account and 20 per IP address per day.
- The operator creates invites with
hisar-server invite --email … [--role admin|member], which prints the link and needs no email. The audit log recordsoperator_invitewith the email and role and no account. - Admins manage invites in the admin dashboard's Invites, whatever the invite policy (§8.3).
GET /admin/inviteslists the pending invites with an email, newest first, each with the account that sent it (nullfor the operator's); an invite is pending until it is used or expires, one rule ininviteState. Itsidis the hex of the stored token hash, so it names the invite without being its code.POST /admin/invites {email, role, proof}creates anadminormemberinvite and, unlikePOST /invites, answers 409 "An account with this email already exists." because admins can see accounts. An admin-role invite requires a fresh OPAQUEproof {reauthenticationId, ke3}from the current session (§5.7), as promotion does.DELETE /admin/invites/{id}deletes a pending invite, whose link then shows the invalid-invite page.POST /admin/invites/{id}/resend {proof}replaces it in one transaction with a fresh one for the same email and role, sent by the admin, so the old link stops working. Resending an admin-role invite requires another fresh proof. Each successful master password check is consumed once, and the transaction rechecks the current session and proven OPAQUE record. Member-role creation and resend need no proof. Create and resend answer{invite, link, emailed, emailError}: the link appears only in that answer, for the admin to copy, and the server emails it at once when email is set up, reporting an SMTP failure instead of failing the request. Admin invites therefore remain available without SMTP. Scripts that issue or resend admin-role invites must supply a fresh proof; bearer-only requests receive 403. The audit log recordsadmin_invite_created,admin_invite_revokedandadmin_invite_resentwith the email and role. The setup invite is never listed, revoked or resent. - The setup invite creates the first admin from the browser. When
hisar-server servestarts with no account, it deletes any unused setup invite, creates a new admin invite without an email and logs one line,Open <HISAR_PUBLIC_URL>/admin/setup#<token> to create the first admin.The token sits in the URL fragment, so it never reaches the access log or aRefererheader; a restart replaces it. Whoever holds it signs up with the email they enter. Once the first account exists, from the setup page or fromhisar-server invite, the setup invite is deleted, so it cannot make a second admin.
An unused admin-role invite from an account is usable and pending only while its issuer is an active server admin. Redemption share-locks the issuer before locking the invite, so demotion and suspension serialize with signup. The public invite page, dashboard list, resend check, and pending count apply the same authority rule. Losing authority hides the invitation rather than cancelling it. It revives if authority returns before expiry. An account that already redeemed the invite remains an admin after its issuer loses authority.
A member sign-up token generated for a share or organization invitation records that exact source invitation.
Migration 00013_invitation_sources adds nullable foreign keys without guessing links for older tokens.
The migration first invalidates every existing unused sign-up invitation, including direct member, admin, and setup invitations. Old unused links must be replaced with fresh invitations. Existing accounts, their sessions, and used invitation records are unchanged. Membership source invitations remain, so their owners can issue fresh invitations without recreating the vault or organization. The upgrade requires every old server process to stop before migration and only updated binaries to run afterwards. A schema rollback does not restore the invalidated old links.
Deleting a source invitation invalidates all of its unused linked tokens in the same transaction. This includes cancellation, decline, pruning, and cascades from vault, organization, or issuer deletion. Minting, final signup redemption, and dashboard resend lock the source before the token, so cancellation cannot leave a newly minted token live. Pruning locks all eligible organization invitation rows, then all eligible share invitation rows, each in ID order, before any deletion can lock sign-up tokens. It deletes only those captured source IDs, matching account deletion's lock order. Dashboard resend preserves the exact source link. A delayed email can still arrive after cancellation, but its token no longer opens a valid invite page or permits signup. Used tokens do not cause account deletion. Unrelated direct sign-up tokens created after the migration retain their existing expiry. The ongoing token lifetime does not change. This covers the sign-up tokens of S-23. An accepted share no longer waits for completion without a deadline (S-16): its owner-signed intent expires within the 7-day invitation lifetime, and the server and the owner's app refuse to complete it after that.
Invite page. GET /invite/{code} (the link in the email) is a page rendered by hisar-server from an embedded template, in the share link page's style (§5.9), light and dark with the system, and usable at phone width.
- Valid invite: who invited the person (the inviter's name and email, which the email already told them; operator invites name nobody), the address the invite is for, and when it expires. Then Download for Mac (
https://tryhisar.com/download) and Download for Linux (https://tryhisar.com/download#linux), Open in Hisar (hisar://invite/<code>?server=<public origin>), the invite code with Copy, and for the terminal how to gethisar(on a Mac it comes with the app: Settings → Developer tools; on Linux the Hisar package installs it; on a server, the download page) andhisar signup <invite link>with Copy. The copy buttons appear only with JavaScript; without it the code is still selectable text. - Used, expired or unknown code, or a malformed one: one page, "This invite isn't valid", with the same status (404) and bytes, so the page tells nobody whether a code or an email exists. The lookup is one query whatever the state. Nothing on the page lists or looks up accounts.
- Headers: the share link page's set. The style and script are inlined and allowed by their SHA-256 hashes (
default-src 'none'; script-src 'sha256-…'; style-src 'sha256-…'; img-src data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'); there are no inline style attributes or event handlers.Referrer-Policy: no-referrerkeeps the code out of the download site's logs;Cache-Control: no-store,X-Robots-Tag: noindex, nofollow. Rendering escapes every value from the database, and the style and script are put in only around the rendered data, so a name can never become either. - Rate limit: 30 page loads per IP address, refilled at one every 2 seconds (429 with a plain-text page), like the public share link reads. Codes are 256 random bits, so the limit protects the server, not the codes.
- Opening the apps. The Mac (
CFBundleURLTypesinInfo.plist, Wails'ApplicationLaunchedWithUrlevent) and the iPhone (thehisarscheme it already registers for the Emergency Kit'shisar://sign-in) handlehisar://invite/<code>?server=<origin>: signed out, they open Create account with the invite filled in, as the bare code forapi.tryhisar.comor as<server>/invite/<code>for another server, which the form then shows ("Server: hisar.example.com"). Signed in or locked, they say to sign out first instead. Universal links (https://…/invite/…opening the app directly) wait for the Apple Developer membership (§12 #7).
Roles. accounts.role is admin or member. In the apps both are the same: any user invites (unless the server's invite policy says admins only, §8.3), shares and manages their own devices, and the apps neither show the role nor offer admin screens. Admins manage the server from the admin dashboard at /admin, and the operator's hisar-server command (invite, migrate) stays available on the server.
Admin dashboard. hisar-server embeds and serves a Vue app (admin/). An admin signs in with their email, Secret Key and master password. A Web Worker runs hisar-admin-wasm, core/session compiled to WebAssembly, so the normal protocol derives keys in the tab without sending either factor to the API. This protects against a database dump, not a server that replaces the page, worker or WebAssembly before factors are entered. The admin's callHisar passes those factors to the downloaded code. The sign-in creates a device named "Admin dashboard" (platform web/admin); the tab keeps only its session token, account ID, device ID and the admin's name in sessionStorage, so a reload stays signed in and a new tab asks again, and Sign out revokes that device. An account that is not an admin has its new device revoked at once and sees "This account is not an admin on the Hisar server." The server decides, never the dashboard: every /api/v1/admin route runs requireSession and then requireAdmin, and answers 403 admin only to any account that is not an admin (§8.2). An admin path with no route is 404 for everyone, like any other unknown path. Changing another account's role and deleting another account also need a fresh proof of the admin's own master password (below).
- Page:
GET /adminand every path below it answerindex.htmlwithCache-Control: no-store. The hashed files under/admin/assets/are cached for a year (immutable) and sent as the brotli or gzip copy the build wrote when the browser accepts it; the WebAssembly isapplication/wasm. A server built without the dashboard (a plaingo build) answers 404 "The admin dashboard was not built into this server.";make buildand the Docker image build it. - Headers:
Content-Security-Policy: default-src 'none'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self'; connect-src 'self'; img-src 'self' data:; worker-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'('wasm-unsafe-eval'compiles the WebAssembly and allows noeval),Referrer-Policy: no-referrer,X-Robots-Tag: noindex, nofollowandCross-Origin-Opener-Policy: same-origin.
Accounts. Accounts lists every account by email, 50 at a time (GET /api/v1/admin/accounts?query=&cursor=, keyset paging on the email; query keeps the accounts whose email or name contains it, ignoring case; a query or cursor longer than 320 characters, not UTF-8 or with a NUL character is 400): email, name, role, state (active or suspended), sign-up date, last activity (the latest last_seen_at of its devices), and how many devices are signed in and how many vaults it can open. Account (GET /api/v1/admin/accounts/{id}) adds every device the account signed in with, revoked ones included. The dashboard never sees vault names, items or keys, and the list carries no credentials. An admin acts on another account with:
- Suspend… (
POST …/{id}/suspend): setsaccounts.suspended_at, closes the account's event streams and drops its phone approvals. Its devices and sessions stay: every request of such a session, and the event stream, answers 403 "This account is suspended. Contact the admin of the Hisar server." (apitypes.AccountSuspendedMessage, whichapiclientturns intoErrAccountSuspendedand never confuses with another 403), and the heartbeat ends a stream that is still open. The Mac and iPhone apps lock on that answer and show the sentence when unlocking, keeping the Secret Key, the state and the local cache (never the sign-out of §5.7);hisarprints it and keeps its state. After reactivation the same devices work again with the same sessions, without the Emergency Kit; Sign out everywhere… is the separate action that removes them. Its share links stop opening and answer as if they were gone until the account is reactivated (§5.9). Its vaults, organization memberships and team access stay as they are, so its vaults keep working for everyone else. Signing in again with the right credentials answers the same 403 only after the OPAQUE proof, so the state is never shown to someone without the password; a wrong password gets the ordinary answer. The new session is created under a shared lock on the account row, so a suspension that commits meanwhile waits for it and then refuses it like the others. Reactivate (POST …/reactivate) clears the column; the account's devices reach the server again, and its share links that have not expired open again. Suspending a suspended account and reactivating an active one change nothing and add no audit event. The dashboard shows a suspended account's signed-in devices as Suspended, not Active. - Change role… (
PUT …/{id}/role {role, proof}) makes the accountadminormember; the next request of each of its sessions uses the new role. - Sign out everywhere… (
POST …/{id}/sign-out) revokes every device of the account and closes its streams; Sign out on one device (DELETE …/{id}/devices/{device}) revokes that device and closes the streams of its sessions. - Delete account… first fetches
GET …/{id}/deletion-preview, then sendsPOST …/{id}/delete {email, proof, planVersion}. It applies the same reviewed-plan transaction as own deletion (§5.7.1), organizations included. The admin's proof is bound toadmin-account-delete, the target account ID and the exact reviewed plan; an own-deletion or generic proof cannot substitute. The email says an admin of the Hisar server deleted the account.
Role changes and deletions carry proof, a re-authentication of the admin (§5.7 step 3): hisar-admin-wasm's reauthenticate runs POST /account/reauthenticate/v2 from the dashboard's session with the Secret Key and master password the dialog asks for, and the server accepts the proof once, from that session only (403 otherwise). An admin cannot suspend, delete or change the role of their own account (409 "Use Settings → Account in the Hisar app for your own account."), and no change made from the dashboard may leave the server without an active admin (409 "The Hisar server needs at least one admin."): suspension, deletion and demotion lock the account and every active admin in ID order, so two admins demoting each other at once cannot both succeed. Every change from the dashboard (suspend, reactivate, role change, sign-out of all or one device, deletion) checks in its own transaction, with the acting admin's and the account's rows locked in ID order, that the acting admin is still an active admin (403 "admin only" otherwise); a role change or deletion also runs the record and session checks of §5.7.1 on the admin's proof: 409 when the admin's master password or Secret Key changed after the proof, 401 when the admin's session ended. An admin who deletes their own account in the apps (§5.7.1) is not checked, so the operator then creates a new admin with hisar-server invite --role admin. The audit log records admin_account_suspended, admin_account_reactivated, admin_role_changed (with the role), admin_signed_out, admin_device_revoked and admin_account_deleted, each by the admin's account with detail.account set to the other account, and login_refused_suspended for a refused sign-in.
Setup. A fresh server is set up from /admin/setup#<token> in four steps, kept as setupState in the instance settings (§8.3) and never editable from Settings: needsAdmin, needsChecks, needsInvitePolicy, done. Each step's endpoint accepts only its own state and moves to the next; done is final, and a server that already had accounts when it first stored the state starts at done. GET /api/v1/setup answers the state to anyone, before sign-in; the other steps need the new admin's session.
- Prepare the first admin (name, email, master password):
hisar-admin-wasmprepares the exact signup request for the "Admin dashboard" device but does not finish registration yet. A revision-checked IndexedDB transaction durably stores the encrypted request and operation identity, without either Secret Key. The transient factor is not written to browser storage. - Save your Secret Key before creating the account: download the actual Emergency Kit and explicitly confirm it was saved. Only then does Continue finish the prepared request. After a reload, enter the master password and the independently saved kit key to resume that operation; a password alone cannot recover its factor from IndexedDB. A prepared operation whose kit was never saved can be discarded because no finish was sent. An uncertain finish cannot be discarded. Lost answers reconcile the exact prepared ciphertext, and incomplete Personal-vault setup remains visible and resumable rather than inviting a second signup. The server moves to
needsCheckswhen the first account exists. A certified never-created loser of an invite race is retired and shows the invalid setup-link message; an existing generated account ID is never certified uncommitted. - Check the server: the instance checks of §8.8; Continue goes on past warnings and never past a failed check, and the server checks again before it moves to
needsInvitePolicy. - Choose who can invite people (Anyone with an account or Admins only), saved as Settings → Sign-up saves it, and setup is
done. The dashboard opens on Overview, signed in.
Opening /admin/setup later shows the step the server is at to its signed-in admin, the sign-in screen before it, and once setup is done the dashboard itself, never the account form.
5.2 Signup (new user, first device)
- Client generates the Secret Key, salt and keyset, then derives AUK and
authKey. - OPAQUE registration prepares the exact randomized finish request, without creating the account.
- Before irreversible finish, the client durably stages an operation bound to origin, account ID and operation ID, separately from active session state. On native clients the password-encrypted request and factors have a second envelope whose random operation key is held by the OS secret service and verified by readback. A failed durable write sends no finish.
- Without protected OS storage, including the browser, the durable record contains the prepared request but neither new nor prior Secret Key. The client first saves an actual Emergency Kit and obtains explicit delivery acknowledgment. After restart the user supplies that independent kit key and password; local verification opens the exact prepared keyset before any finish. This is not a password-only persisted-factor fallback. A
hisarrun withHISAR_SECRET_KEYset has no protected storage either: as for the Secret Key itself, the operation key never reaches the OS keychain, andhisar signupandhisar account replace-secret-keyprint the new Secret Key once the change has committed. - Client uploads the prepared salt, Argon2 parameters,
encKeyset, public keys and signature, then creates the Personal vault under an ID chosen before step 3 and staged with the operation. Resuming setup first lists the account's vaults and creates that vault only when none is Personal; two resumes that both find none send the same ID, so the server refuses the second (409) and the resume confirms the vault by listing again. A lost answer or a repeated resume therefore never leaves two Personal vaults. Invitations sent before signup remain available to accept (§5.8). - Committed or uncertain partial setup returns a structured
pendingOperationId,setupStateand problems, without a method error that bridges would discard. The pending recovery screen stays available while signed out or locked. It can resume the exact operation and deliver its Emergency Kit. The default protected record is removed only after setup finishes and delivery is acknowledged; a no-keychain account retains visible manual-unlock metadata so the user can explicitly sign in with its kit. - Biometric onboarding may use AUK retained only in memory. Lock, sign-out, switching accounts and onboarding completion invalidate that offer; pending recovery does not install a session automatically.
A lost registration answer is reconciled using the staged factor and exact encrypted keyset; it never generates a replacement factor or treats observing old credentials as proof that finish failed. Existing active state and factors are not promoted until commit is known. Local cache, state or keychain finishing failures remain explicit partial setup, not secret-bearing errors. The recovery journal is <state path>.recovery.sqlite, independent of account cache reset, with durable transactions, bounded records and compare-and-swap revisions. No plaintext new factor is stored in the journal, state, logs or errors. A user-requested Emergency Kit is intentionally a secret-bearing export. Immutable Go and UI strings cannot be reliably wiped; this design makes no wipe claim.
The account form rates the master password with the same strength meter as the item editor (Weak, Fair, Good, Strong). It refuses fewer than 10 characters and passwords estimated below 28 bits (common words and patterns such as password1234), with "This password is too easy to guess. Add a few more words or characters."; a Weak password above that is allowed with a hint, because the Secret Key also protects the account. The server only checks the length.
The admin dashboard's setup page (§5.1) runs the same steps in its Web Worker as the "Admin dashboard" device. Its form refuses fewer than 10 characters and a repeated password that does not match; it has no strength meter.
5.3 Login on a new device
- User enters email, Secret Key (or scans the Emergency Kit QR code) and master password. The server is
api.tryhisar.comunless the user opens Use a different server…, which shows the Server field. A remembered or scanned server stays behind that button too, and the line "Signs in to <server>." names the server the form will use. Signing out (and a session the server ended) keeps only the server this device last signed in to (lastServerin the state file's device settings, whichState.Resetfills from the account's server on every sign-out, so sessions from before the field existed keep theirs too; reported as the signed-out status'sserver), never the email, so signing back in goes to the same server, a self-hosted one included. - Client fetches salt and params, derives
authKey, runs OPAQUE login and gets a session. A server answers an unknown email with a fake salt and record, so signing in to the wrong server fails like a wrong password; the error names the server asked ("incorrect email, master password or Secret Key for api.tryhisar.com; check that this is your account's server"). OPAQUE identities: client = account ID, server = the constant"hisar"(core/pake;bytemare/opaquev0.18.0 cannot decode key material with an empty identity). The server's OPAQUE key material and an HMAC key live inhisar-secrets.json, created on first start. - Client downloads
encKeysetand decrypts it with AUK. - The device registers (name, platform, public device key). Other devices see "new device signed in".
Origin-bound authentication
Login uses POST /auth/login/v2/start and POST /auth/login/v2/finish. Reauthentication uses POST /account/reauthenticate/v2, including credential replacement, account deletion, and admin password checks. The route selects version 2 before KE2. The client builds its context from its configured apiclient.Client.BaseURL before KE1. The server builds immutable login and reauthentication configurations at startup from operator-controlled HISAR_PUBLIC_URL. Request Origin, Host, and forwarded host headers never select the authentication origin.
The application context encodes hisar/v2/opaque, the canonical origin, and the purpose login or reauthenticate, in that order. Each field has a four-byte unsigned big-endian byte-length prefix. KE2 and KE3 authenticate this context. A different origin or purpose fails KE2 verification before the client sends KE3. Pending checks retain their purpose, account record hash, and, for reauthentication, the initiating session. They cannot finish through the other operation.
Deletion adds scope = {action, target, planVersion} before KE2. pake.WithReauthenticationScope derives a fresh configuration by versioned four-byte length framing of the trusted origin/purpose context, deletion action, canonical target account ID and decoded reviewed-plan digest. Both client and server use it before generating their handshake messages; changing only outer JSON fails KE2 verification. The server constructs a separate scoped server from unchanged key material and never mutates its shared configuration. The pending check retains the exact scope and server through mutation. Empty-scope generic reauthentication remains unchanged and one-use.
OPAQUE registration still uses hisar/v1/opaque, account ID as client identity, and hisar as server identity. Existing registration records authenticate under version 2 without rewriting them. Signup and credential replacement preserve the registration format, KDF inputs, encrypted keysets, Secret Keys, and offline readers.
core/pake.CanonicalOrigin defines the shared origin spelling:
- Only
httpandhttpsare accepted. Scheme and DNS host use lower-case ASCII. International DNS names usegolang.org/x/net/idna.LookupASCII conversion. - Omitted and explicit default ports are equivalent. HTTP port 80 and HTTPS port 443 are omitted. Other ports are decimal numbers from 1 through 65535, without leading zeros.
- IP literals use
net/netipcanonical spelling. IPv6 addresses have brackets. Zone identifiers, ambiguous numeric IPv4 spellings, trailing DNS dots, escaped hosts, credentials, empty hosts, invalid labels, queries, and fragments are rejected. - A path prefix is a transport-routing property, not part of the origin. For example,
https://example.com/hisarauthenticateshttps://example.comwhile requests retain/hisar. A reverse proxy must route that prefix consistently.
An updated server refuses the old login start, login finish, and reauthentication routes with an actionable update error. There is no legacy grace window or account-level fallback. Updated clients use only the version 2 routes and tell the user to update an unsupported server. They never retry the old protocol after an unsupported route, timeout, or failed KE2. Offline unlock and old encrypted account data remain usable.
For a legitimate server move, the operator explicitly changes HISAR_PUBLIC_URL and the user explicitly selects the new server in the sign-in settings or CLI configuration. Preserve the database and hisar-secrets.json. Existing registration records then authenticate the newly selected origin. A redirect, prelogin response, or server-supplied alias does not change the client's expected origin. This binding prevents cross-origin PAKE relay. It does not authenticate the user's first choice of server, make HTTP transport confidential, or protect factors typed into malicious code served by the intended origin.
5.4 Unlock (known device)
The device already has the Secret Key in the keychain and a local copy of encKeyset (the desktop cache, §9.1). Unlock works offline: derive AUK, decrypt the keyset. Online, the client takes the account record from the server and refreshes the cached copy.
Touch ID on macOS. The wrapping key is a generic password in the data-protection keychain, under the versioned service suffix .biometry-current-set.v1. SecAccessControl combines kSecAccessControlBiometryCurrentSet with kSecAttrAccessibleWhenUnlockedThisDeviceOnly. The local state still holds Seal(wrappingKey, AUK, "hisar/v1/biometric-unlock:" || accountID). The OS, rather than an app-only check, controls each key read. The evaluated context is used once for its prepared account and then invalidated. SSH approval contexts are not retained for wrapping-key reads: the approval window's Touch ID check of an unlocked Hisar, the phone's approval check and the export check wait for a biometric unlock or setup in progress, because the platform gives a prepared key read to the next prompt.
Migration from the login keychain. Only a successful password unlock can copy the old wrapping key to the protected service. The sealed state does not change. Hisar reads the new item with a fresh biometric context, checks its exact bytes and that it opens the sealed state, and only then retires the obsolete login-keychain wrapper. Interrupted writes, cancelled proof, and retirement failures preserve recoverable state and can be retried. Password unlock skips the migration when the key is already protected, and never waits for a pending biometric prompt: it leaves the migration pending for the next password unlock instead. Normal biometric unlock never reads the old service. Turning Touch ID off, a Touch ID unlock that turns itself off, and sign-out delete the protected wrapper and any login-keychain wrapper a pending migration has not retired. The user's Secret Key is not part of this migration.
Biometric setup, the post-sign-up offer, and credential-change resealing also perform OS authentication, key storage, and migration proof outside the service mutex. Each operation captures the account and session epoch under the mutex. The sign-up path copies its account unlock key before releasing that mutex, so a concurrent lock can wipe the retained sign-up key safely. A separate mutex serializes the platform's single-use authentication context, not logical lock. Before publishing a setup, Hisar checks the account, unlocked session epoch, and setup revision again. Lock, account switch, disabling setup, or a newer credential change invalidates the pending result. A cancelled prompt or failed protected readback returns an error without announcing activation. Once a setup has stored its key, that key has replaced the earlier wrapper, so a setup that does not publish deletes it and turns off any earlier setup it replaced rather than leave a sealed state the keychain no longer opens. A credential-change reseal that does not publish turns Touch ID off, because the sealed state opens the replaced account unlock key, but the committed change still finishes: a lock or disabled Touch ID still saves the recovery operation and password unlock time, and after a sign-out or account switch the pending recovery record still moves to delivery. Password unlock remains available, and no ordinary read falls back to a legacy wrapper.
Signing and fallback. Signed Mac builds require a matching macOS Developer ID provisioning profile with the app's keychain access group. scripts/sign-desktop-app.sh validates and embeds it and supplies the authorized entitlements. A missing profile or failed migration does not remove the stored setup or block password unlock. The lock screen and Security settings explain why Touch ID protection is pending. A changed biometric enrollment invalidates the protected key and requires the password and fresh setup. Signed-release behavior still needs a valid profile and a physical Touch ID Mac; unsigned tests do not prove OS enforcement.
Password policy and presentation:
- The master password is still required after every app launch and at least every 14 days (
LastPasswordUnlockAtin the state file). - While the process lives, the password interval also has a monotonic deadline. Backwards wall-clock observations more than five minutes below the process's highest observation and observed expiry require the password until the next password unlock. Smaller corrections do not move that highest observation or extend the deadline. Observed expiry clears the saved password-unlock timestamp. Between processes, the check still relies on the persisted password-unlock wall time rather than a persisted last-observed clock.
- In-window prompt: like 1Password, the lock screen embeds Apple's
LAAuthenticationView(LocalAuthenticationEmbeddedUI, macOS 12+) in Hisar's own window instead of the system dialog. The UI measures a slot on the lock screen and the app places the native view over it; "Use master password" cancels the evaluation (LAContext.invalidate). - Touch ID also approves SSH agent requests (§9.6). The iPhone unlocks with Face ID the same way, and Android with a strong biometric through
BiometricPrompt(§9.3).
5.5 Sessions
- After OPAQUE, the server issues an opaque random session token (256 bits). The server stores only its SHA-256 hash, together with the device ID and expiry (30 days by default, sliding; the session lifetime of §8.3).
- All API calls use
Authorization: Bearer <token>over TLS. - Devices can be listed and revoked by their owner.
- App authentication captures the local session epoch before remote work. Lock, including an already-locked request, sign-out and account switches invalidate earlier attempts. A late finish may update only its independently staged recovery operation: it cannot reopen or overwrite the current account, promote its active Secret Key, or return that factor in an error. Restart likewise requires explicit password-and-factor recovery, never automatic pending-account installation.
- The desktop store invalidates pending account responses when the session ends. Late sign-up and Secret Key replacement responses cannot restore unlocked status or expose an onboarding or replacement Secret Key after lock.
- CLI dispatch uses a request-scoped service view with the original epoch and the same shared state and mutex as the app. The unlocked-session guard checks that epoch inside each operation's mutex hold, before selecting keys or performing account mutations. Wails method signatures do not change. Callbacks and goroutines that outlive a request retain only shared state, never its request-scoped epoch. The auto-lock timer follows this rule.
5.6 Public key authenticity
Sharing a vault means wrapping K_vault to the recipient's KEM public key, which the server delivers. A malicious server could substitute its own key. Mitigations:
- Each user's KEM public key is signed with their Ed25519 key, bound to their account ID.
- Clients pin (trust on first use) every other member's Ed25519 key in their local state when they first share with them, which is when the owner's app completes an accepted share invitation (§5.8;
localstate.State.CheckMemberPin, used by the CLI and the desktop app). Completing a share, changing a member's role and key rotation stop if a pinned key changes; the desktop app also marks that person in its lists. - The UI shows a short security code per person (Settings → People and the vault's sharing panel) that the two people can optionally compare once, in person or over another channel: the first 8 bytes of SHA-256 of their Ed25519 public key as 4 groups of 4 uppercase hex digits, e.g.
3F2A 91C0 7B44 E1D8(crypt.FormatKeyFingerprint).
5.7 Changing the master password and replacing the Secret Key
Settings → Security on the Mac and the iPhone has Change master password… and Replace Secret Key…; the CLI has hisar account change-password and hisar account replace-secret-key [-o file]. Both need the current master password and a connection; the server never sees either secret.
- The client fetches the account (
GET /account), derives the current AUK andauthKeyfrom the current master password and Secret Key, and decrypts the keyset; a wrong password fails here, before anything is sent. The keyset's public keys must match the account's and the open session's. - It picks a new random salt, the Argon2 parameters (the defaults, never below what the account already uses) and, for a replacement, a new Secret Key with the same account ID. It derives the new AUK and
authKeyand re-encrypts the same keyset with the new AUK (§4.3).K_user, the KEM and signing keys, every vault key and every item stay as they are, so shared vaults keep working and other people's pinned keys (§5.6) do not change. - Re-authentication.
POST /account/reauthenticate/v2 {ke1, registrationRequest}(session; per-IP auth limit and 5 per account, refilled every 30 s) starts an origin-bound OPAQUE check against the current record with the currentauthKeyand, in the same call, answers the OPAQUE registration request for the newauthKey. The server keeps the check for one minute, bound to the session that started it and to a SHA-256 of the record it was checked against. It cannot finish on/auth/login/v2/finishand never creates a session. Secret Key replacement durably stages the exact new ciphertext, original finish proof and both factors before step 4, using the same protected-operation rules as signup (§5.2). The old active factor remains valid locally until commit is established. Without OS wrapping, both factors are omitted from the journal; the new kit must be saved and acknowledged before finish, and the old kit is retained until the outcome is known. PUT /account/credentials {reauthenticationId, ke3, change, record, salt, argon2, encKeyset}(changeismaster-passwordorsecret-key). A wrong proof answers 403 (never 401, which clients read as "signed out") and is audited asreauthentication_failed. In one transaction the server locks the account row, checks that the record is still the one the proof was made against (409 otherwise: changed on another device meanwhile) and that the session is still live, replaces the record, salt, parameters andencKeyset, revokes every other device and deletes their sessions. It closes their event streams at once, recordscredentials_changedand emails the account: "Your Hisar master password was changed" or "Your Hisar Secret Key was replaced", with the number of devices signed out and no secrets.- Only after commit evidence does the client update the cached account and active factor. The replacement remains a visible pending recovery operation until local finishing and Emergency Kit delivery succeed. A stale original proof can be refreshed against the exact prior ciphertext with the old factor, reusing a still-live device rather than registering duplicate devices. Biometric resealing uses the new AUK, subject to the current session epoch.
If the cached account cannot be replaced after a committed credential change, the app detaches and deletes that cache before attempting verified attachment. Local warnings remain in the successful structured result and pending setup, never an error containing a Secret Key. The old journal and staged factor survive lock or restart until exact reconciliation and explicit delivery acknowledgment. A local failure cannot roll back a committed remote change.
HisarKit preserves existing public method names and signatures and adds explicit pending-recovery methods and JSON result fields. Current mobile clients export the chosen operation's actual kit and acknowledge delivery; they do not depend on an ignored automatic temporary export. Pending kit export opens only the explicitly selected operation using its verified password and, without OS protection after restart, the user-held kit key. Installing an account additionally requires the captured current epoch: delivering an old operation's kit does not change the current account.
A lost credential-change answer is treated as committed only when the server holds the exact newly generated encrypted keyset. The lost answer's signed-out-device count remains unknown. Session and app results use -1 in the existing integer field, and the CLI and app display helpers say the count could not be recovered instead of reporting zero.
Proof-bearing changes lock the account's devices in ID order before its session rows, matching device revocation. This order also applies to account deletion, including an admin deleting another account. A revocation that commits first makes the old session unusable for the proof-bearing change.
Other devices. Every other device of the account is signed out by the change, so no device keeps working with the old secrets. The apps say so before the user confirms: "Your other devices will be signed out. Sign in on them with the new master password" (or "with your master password and the new Secret Key from the new Emergency Kit; the old Emergency Kit stops working"). A device learns it was signed out from a 401: its event stream ends at once, and an unlock or any request then gets 401. The desktop and iPhone backend then forgets the account on that device (forgetLocalAccount: the Secret Key and Touch ID key in the keychain, the encrypted cache, the session and pins in the state file) and shows the sign-in screen with "This device was signed out. If your master password or Secret Key was changed on another device, sign in with the new one." A device that stays offline can still be unlocked from its cache with the old password until it next reaches the server; that is the limit of an offline-first client.
Changing the master password or replacing the Secret Key re-encrypts the same keyset. In session.changeCredentials, accountCredential.keyset is the verified existing keyset, not a newly generated identity. Someone who holds the old factors and an old encKeyset can still recover its data, KEM and signing keys. Credential replacement therefore does not recover from that compromise or revoke copied vault keys. Full compromise recovery would also need new keys, independently authenticated identity re-establishment and new grants for affected vaults. Previously copied plaintext cannot be revoked.
5.7.1 Deleting an account
Mac Settings → Account → Delete account…, iPhone Settings → Account → Delete account (App Store guideline 5.1.1(v)) and hisar account delete (interactive; --email for scripts).
- Before confirming, the app obtains the authoritative
GET /account/deletion-preview, includingplanVersion. It lists sole-owned vault deletion, retained vault access removal, all vaults in organizations being deleted, organization succession and membership effects, affected invitations, links and devices. Local vault data supplies names only: incomplete or unavailable local lists never remove server effects, and an unknown name keeps its stable vault ID visible. Export is recommended first (§9.11). - Confirming requires the account email and an origin-bound OPAQUE proof whose scope is
{action: "account-delete", target: accountID, planVersion}.POST /account/delete {reauthenticationId, ke3, email, planVersion}rejects missing plans with a client-update instruction (400), wrong email with 400 and wrong or differently scoped proof with 403. Current clients never silently retry against an unreviewed plan. - Server. Preview and execution share one consequence builder. Its versioned SHA-256 digest covers exact identities, roles, successor selection, organization/team/vault memberships, the invitations and sign-up invites from or to the account (including the sign-up invites linked to a deleted invitation through its source, §5.1, which the deletion cancels with it), links and device state in canonical order, not only visible counts. Execution locks them, recomputes the digest before the first mutation and deletes exactly the invitations it locked; an invitation to the account's email created after that check is left, as if created just after the deletion. A changed effect returns
409 { error, deletionPlanChanged: true }; the UI clears prior confirmation and requests a fresh review, while the CLI requires a new invocation and confirmation. Every other deletion 409 (busy memberships, credentials changed after the proof, the last admin, the admin's own account) is a plain{ error }, and the apps and dashboard show its reason instead of asking for a review. Thus a vault reviewed as surviving cannot disappear merely because its co-owner left after preview. A membership, grant or device change that commits after the review is such a change, so an account that keeps changing them is suspended before an admin deletes it. Existing account-deletion cascades, attribution preservation, email and events remain. - Afterwards the people who shared a vault with the user get
vaults-changed, and the owners and recipients of the withdrawn invitationsshare-invitations-changed, so their apps drop the vault or the member at once (there is no other notice). The audit log recordsaccount_deletedwith the account ID only, and the account's email gets "Your Hisar account was deleted", which also says the encrypted backups are kept 30 days and the audit log up to a year (§8.6). - Retained shared and organization vaults are marked for rotation. Deletion denies future server access but cannot erase ciphertext or keys a former member already copied (§5.7, §5.11).
- The app forgets the account like a sign-out (§5.7) and returns to sign-in; the iPhone also removes its AutoFill identities.
- By an admin. The dashboard fetches the target's authoritative deletion preview and proves the admin's own password with
admin-account-delete, the exact target and reviewedplanVersion. The same locked transaction and mismatch refusal apply, without the target's proof. The audit isadmin_account_deletedand the notice identifies administrative deletion.
Deletion locks organizations and vaults in ID order before classifying what stays.
It locks the target's share and organization invitations before upgrading the account lock, so grants can finish their
invitation-then-account sequence. The upgrade blocks new memberships through their account foreign keys.
The upgrade uses NOWAIT, so a newly arrived foreign-key holder cannot close a lock cycle.
Final invitation deletes also lock their exact statement snapshot with NOWAIT, covering email-only invitations that
arrive after the account upgrade without waiting on a concurrent acceptance's account foreign key.
Before the final plan comparison, a NOWAIT write fence on invitations, signup invites, links and devices excludes email-only phantoms without waiting on a writer holding parent locks. Existing devices-before-sessions and organization-before-vault ordering is retained. These are honest-server transactional consent guarantees, not malicious-server freshness or first-contact identity guarantees.
If the organization refresh finds a newly joined organization locked, deletion releases its locks and retries within
the same three-attempt bound. The next attempt includes that organization in its initial lock set, where waiting is safe.
If a membership committed during lock acquisition introduced another organization or vault, deletion releases its locks
and restarts before making changes. It tries at most three times. Contention or repeated membership changes return 409,
with no partial deletion, asking the caller to retry. Accounts that keep joining organizations can make that 409 repeat,
so the admin route's 409 tells the admin to suspend the account first, which ends its requests. The final
classification therefore contains only locked vaults.
Keeping account key-share locks available during vault acquisition lets an in-flight grant finish without a lock cycle.
Creating a personal or shared vault or a new organization first share-locks its creator's account.
Accepting an organization invitation share-locks the accepting account after the organization and before the invitation.
These locks conflict with deletion's initial account lock, so the target's own requests cannot keep adding memberships
while deletion acquires its vault locks. Changes within an existing organization, including team membership and new
organization vaults, already serialize on its organization row. Key grants retain their compatible account foreign-key
locks, so already-accepted invitations can finish without inverting vault and account acquisition.
After taking its initial account lock, deletion refreshes the organization set and locks it with NOWAIT.
This includes organizations created or joined while the first organization-lock statement waited, without restarting
under a continuous stream of creations. It never waits for an organization while holding the account lock, since
an acceptance may already hold that organization and be waiting for the same account.
Admin settings saves lock the saving admin account before the settings row, matching deletion's attribution cleanup.
5.8 Sharing a vault by email
Sharing is person to person: the owner of a shared vault types an email address and a role. Nobody can list the accounts on the server, and the server answers every share the same way, so sharing cannot be used to find out who has an account.
- Invite (owner). An unlocked owner signs an immutable invitation and sends
POST /vaults/{id}/signed-share-invitations {email, role, intent}. Personal vaults cannot be shared. Organization vaults use their teams (§5.11). The response remains202 {"message": "Invitation sent"}whether the email has an account.crypt.ShareIntentbinds version 1, the canonical intended server origin, a random invitation UUID, vault ID, inviter account, normalized email, role, key generation, access revision, issue time, expiration, recipient account and recipient key hash, and optional parent hash.hisar/v1/share-intentuses the existing length-prefixed context encoding. Integers are decimal strings. The recipient hash covers both the verified signing and KEM public keys underhisar/v1/share-recipient.- A known contact can be bound after signed-key verification and the client's pin check (§5.6). An unknown email has no recipient binding yet. Its initial signature authorizes an invitation, not delivery to a future account.
- The server verifies the owner's signature before storing it in
share_invitations.intent. The signed lifetime may not exceed the server's invitation lifetime, 7 days, which is what the apps sign. A resend replaces the exact source row with a fresh UUID. It cannot renew or change an existing signed operation. Cancellation, decline, replacement, completion, and issuer revocation retire the old UUID. The retired-ID table prevents recreation after the visible source disappears. The store also refuses any operation whose signed expiry has passed, so a retired ID only needs to outlive that expiry, and the daily prune (§8.6) deletes it then. - The intent's origin must equal the canonical
HISAR_PUBLIC_URL(§5.3). An app connected through another address of the same server gets a 400 that names both addresses andHISAR_PUBLIC_URL, not the generic refusal of an unauthorized invitation. An owner's app connected through another address than the invitation's refuses to complete it and names the address it uses. - In the background, an existing recipient gets
share-invitations-changedand an email. An unknown recipient can get a linked sign-up invitation when server policy and SMTP permit it. Without SMTP, sharing still waits for the recipient to create an account another way. Rate limits remain 50 share invitations per account and 100 per IP address per day.
- Accept or decline (recipient).
GET /share-invitationsreturnsreceived: the pending, unexpired invitations to the caller's email, with the inviter's name and email, the role and the vault ID.POST /share-invitations/{id}/acceptmarks the invitation accepted by the caller's account; nothing secret is published.POST /share-invitations/{id}/declinedeletes it. Both notify the vault's owners withshare-invitations-changed. Until the share is completed the recipient has no access.GET /share-invitationsalso returnswaiting: the invitations the caller accepted that no owner's app has completed yet, with the inviter's name and email, the role, the vault ID andacceptedAt, so the recipient's app can say who it is waiting for and since when.- Telling the owner. A recipient already bound by owner intent can receive the key automatically from an unlocked owner's app. A newly accepted identity needs owner review first. If sharing remains pending, the server can email the inviter after 2 minutes. The timer is in memory, and the email contains no vault name.
- Complete (owner's app).
sentincludes the immutable signed intent and, after acceptance, the concrete recipient's account and signed public keys. Before wrapping, every client verifies the full context and signature against its own key or the inviter's verified, pinned signing identity. It checks that the inviter still owns the vault, the generation is current, rotation is not pending, and the accepted recipient exactly matches the signed account and key hash. A server signature check alone never authorizes client key delivery.- For an unknown recipient, the Mac, iPhone, and Android apps show Review recipient. The confirmation names the vault, role, email, account, and security code. The reviewed token binds that entire presentation and the concrete keys. Give access signs a final binding to the original intent hash. A changed review requires another review.
hisar invitations confirm <id>provides the same review in both CLI backends.--yesis explicit noninteractive confirmation. POST /share-invitations/{id}/completelocks the vault and exact accepted source, verifies the original and any final owner signature, checks the recipient and current generation, inserts the exact signed role and wrapped key, and consumes that source in one transaction. Repeating a consumed request returns a conflict and never restores a removed member. The signed access revision cannot be ahead of the current revision. Unrelated membership additions do not invalidate another pending recipient. Removing or demoting the inviter deletes and retires that inviter's sources, so restoring ownership cannot revive them.- Historical unsigned invitations never auto-deliver. They require the same native concrete-recipient confirmation and a new owner signature. Invalid signed payloads are rejected, not treated as unsigned history. New unsigned issuance is refused. Updated clients use the protected creation and completion routes and do not fall back to legacy operations. Existing ciphertext and cryptographic vectors remain readable and unchanged.
- A locked app can list accepted invitations but cannot wrap keys. After unlocking, a bound recipient can complete automatically and an unbound recipient appears for review. Client pins and optional out-of-band security-code comparison remain necessary. Owner signatures do not prove malicious-server freshness or authenticate a first-contact directory identity.
- The recipient's vault list shows one banner per
waitingentry: "Waiting for Ada's Hisar to finish sharing · accepted 2 h ago"; it disappears whenvaults-changedbrings the vault.
- For an unknown recipient, the Mac, iPhone, and Android apps show Review recipient. The confirmation names the vault, role, email, account, and security code. The reviewed token binds that entire presentation and the concrete keys. Give access signs a final binding to the original intent hash. A changed review requires another review.
- Cancel (owner).
DELETE /share-invitations/{id}(an owner of the vault) withdraws a pending or accepted invitation.
PUT /vaults/{id}/access/{accountID} cannot add a shared-vault member, even after an invitation was accepted. It changes existing access, while organization key delivery remains subject to team entitlements. New shared-vault access requires exact signed completion. Source cancellation and completion serialize under the vault lock, and completion preserves the invitation-before-account key-share lock order used by account deletion.
People. GET /members returns the caller and every account that shares at least one vault with them, with signed public keys; it is the member source for sharing, role changes, removal and rotation. There is no list of every account anywhere (API, desktop, iOS, CLI, extension); only the operator's hisar-server reads the database directly. The desktop's Settings → People shows those people with their security codes, the invitations the user sent (Cancel) and received (Accept, Decline), and the invite form; a banner above the vault list shows received invitations.
5.9 Share links
A share link hands one item, or a piece of text, to anyone, including people without a Hisar account: https://<server>/s/<id>#<key>. Browsers do not send the fragment in the HTTP request, so the stored ciphertext alone does not reveal the payload. The server-served page does read the fragment and decrypt the payload, which requires trusting that page (§2.2). Links expire and can be limited to a number of views.
What can be shared. The item's title and the fields the user picks: username, password, websites, notes and custom fields; by default the username and the password (those the item has). Never an SSH private key, a passkey or a one-time password secret: core/sharelink has no name for them and never copies them into a payload. A custom field whose value is an otpauth:// URI is a one-time password secret too (a login that becomes another category keeps its one-time password in such a field, §4.5), so it is neither offered nor shared when asked for by name. A text share has a title and one text field. Attachments are not supported.
Crypto (core/crypt/sharelink.go, core/sharelink). The recipient's browser decrypts with WebCrypto, which has neither XChaCha20-Poly1305 nor Argon2id, so share links use AES-256-GCM and PBKDF2 instead of the primitives of §3. Each content key encrypts exactly one message, so a random 96-bit nonce is safe.
linkKey = 32 random bytes (the URL fragment, base64url without padding)
contentKey = HKDF-SHA256(ikm = linkKey, salt = "", info = "hisar/v1/share-link/content", len = 32)
with a passphrase:
passphraseKey = PBKDF2-HMAC-SHA256(NFKD(passphrase), salt = random 16 bytes, iterations = 600 000, len = 32)
contentKey = HKDF-SHA256(ikm = linkKey || passphraseKey, salt = "", info = "hisar/v1/share-link/content-with-passphrase", len = 32)
ciphertext = 0x01 || nonce(12) || AES-256-GCM(contentKey, nonce, aad = "hisar/v1/share-link", JSON(payload))
payload = {version: 1, title, fields: [{kind, label, value, isConcealed}]}
kind ∈ {username, password, url, notes, custom, text}
- Without a passphrase the link alone opens it. With one, both are needed. A database-only reader without the link key cannot test passphrase guesses against the stored ciphertext. Someone holding the link and a copy of the server's row can guess the passphrase at the PBKDF2 cost, so the apps ask for at least 8 characters and say to send the passphrase another way. A malicious serving page can capture both inputs (§2.2).
- PBKDF2 rather than Argon2id: WebCrypto has PBKDF2 built in, while Argon2id would mean shipping a WebAssembly build inside the page. 600 000 iterations is OWASP's current advice for PBKDF2-HMAC-SHA256. The server hands out the salt and the iteration count, so the page and
core/sharelinkrefuse fewer than 600 000 or more than 10 000 000 iterations, and a salt that is not 16 bytes. - The JSON payload is at most 48 KiB, so the ciphertext stays below the server's 64 KiB.
- The creator's lists show what each link shares without the server learning it: the creating client also uploads a label,
XChaCha20-Poly1305(K_user, aad = "hisar/v1/share-link-label" || accountID, JSON{title, vaultID, itemID, fieldLabels, isText, linkKey})(§4.3), which only the creator's devices open. It holds the link key, so Copy link works later on any of the creator's devices; the passphrase is never stored.
Server.
| Endpoint | Body → result |
|---|---|
POST /share-links (session) | {ciphertext, encryptedLabel, expiresInSeconds, maxViews, passphrase: {salt, iterations} or null} → 201 {id, createdAt, expiresAt, maxViews, viewCount, hasPassphrase}. expiresInSeconds is 3600, 86400, 604800 or 2592000 (1 hour, 1 day, 7 days, 30 days); maxViews is 1–100, or 0 for unlimited; the ciphertext is 1 byte to 64 KiB and the label at most 4 KiB; 400 otherwise |
GET /share-links (session) | the caller's live links, newest first: [{id, encryptedLabel, createdAt, expiresAt, maxViews, viewCount, hasPassphrase}] |
DELETE /share-links/{id} (session) | revokes: 204 for the caller's own link, 404 for anything else |
GET /public/share-links/{id} (no session) | counts one view → {ciphertext, passphrase, expiresAt, maxViews, viewCount}; the last allowed view also deletes the link. 404 {"error": "not found"} for an ID that never existed or is malformed, for an expired, used-up or revoked link, and for a link of a suspended account (§5.1): the same status and body, so they cannot be told apart |
- IDs are 128 random bits chosen by the server, as 22 base64url characters.
- A view is counted by one
UPDATE share_links SET view_count = view_count + 1 WHERE id = $1 AND expires_at > now() AND (max_views = 0 OR view_count < max_views) RETURNING …. Postgres re-checks the condition after taking the row lock, so concurrent opens never get more views than allowed. When that view was the last one, the same transaction deletes the row. - An expired link is gone for readers at once;
hisar-serverdeletes expired rows every 10 minutes. Revoking deletes the row. - Table
share_links(migration00005_share_links); deleting an account deletes its links. - Rate limits: 100 new links per account per day and at most 500 live links per account (429); public reads 30 per IP address, refilled at one every 2 seconds (429). IDs cannot be guessed, so the read limit protects the server, not the links.
- The creator's event streams get
share-links-changed({}) when one of their links is created, viewed, used up or revoked, so lists refresh (§7.2). - The audit log records creating and revoking a link, with its ID only.
Recipient page. GET /s/{id} serves one static page embedded in hisar-server, the same bytes for every ID: serving it looks nothing up, counts nothing and reveals nothing.
- No third-party code. Its script and style are inlined and allowed by their SHA-256 hashes:
Content-Security-Policy: default-src 'none'; script-src 'sha256-…'; style-src 'sha256-…'; connect-src 'self'; img-src data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none', withReferrer-Policy: no-referrer,Cache-Control: no-store,X-Robots-Tag: noindex, nofollowandX-Frame-Options: DENY. - Reveal first. The page reads the key from the fragment but fetches nothing until the recipient clicks Reveal, so link previews in chat apps, mail scanners and crawlers, which load the URL without clicking, never use up a view. Reveal fetches the ciphertext (one view), asks for the passphrase when the link has one, and decrypts in the browser with WebCrypto. Once the server has answered with the ciphertext, the page replaces its address with one without the fragment (
history.replaceState), so the tab's back and forward history and a restored tab no longer hold the key; the browser's list of visited pages may still have the address as it was opened. - A wrong passphrase keeps the ciphertext in the page, so another try costs no view ("That passphrase doesn't open this link."). A missing or malformed key says the link is incomplete; a 404 says "This link has expired or was already viewed", without saying which.
- It shows the title and each field with Copy (concealed fields hidden until Show), websites as text, notes as preformatted text, and when the link expires and how many views are left, or "That was the last view: this link no longer works". Light and dark follow the system; it works on phones, with the keyboard and with screen readers (labelled controls, live status messages).
- Trust. The page comes from the server that stores the ciphertext, so a compromised server could serve a page that sends the key elsewhere. That is the limit of every web-based share link; recipients with
hisarcan open links without the page (hisar share-link open, below), and opening them in the desktop and iPhone apps is a follow-up. - The footer says "Shared with Hisar, the end-to-end encrypted password, SSH key and secrets manager for developers. Nothing you see here is stored by this page." without a link: there is no public product page to link to yet.
Desktop app.
- The item view has Share link… next to Edit (not for SSH keys without notes or fields). Its dialog offers the fields to share as checkboxes (username and password checked by default), Expires (1 hour, 1 day, 7 days, 30 days; 7 days by default), Views (1–100, 1 by default, or Unlimited) and an optional passphrase. Create link shows the link with Copy (through the backend, cleared after 90 s) and what it shares.
- The item view lists that item's live links. Shared links in the sidebar, under the vaults, lists all of them; the new item menu has Share text…. Each link shows its title, what it shares, views used, expiry and whether it needs a passphrase, with Copy link and Revoke.
- Lists refresh on
share-links-changed. Offline, creating and revoking are refused like other changes.
CLI. hisar share-link <item> [-v vault] [--field name]... [--expires 1h|1d|7d|30d] [--views 1-100|unlimited] [--passphrase] prints the link on stdout (by default username and password, 7 days, 1 view); --field is username, password, url, notes or a custom field's label, and --passphrase asks for it twice. hisar share-link --text [--title t] shares text read from stdin. hisar share-links lists the live links and hisar share-link revoke <id> revokes one. hisar share-link open <url> opens any share link in the terminal, without an account and without the web page: it counts a view, asks for the passphrase when the link has one, decrypts with core/sharelink and prints the title and fields. Through the app, creating a link always opens the approval dialog, even in an approved terminal session, because it hands secrets to people outside Hisar: "hisar in Terminal wants to create a share link for “GitHub”", with the expiry, the views and the fields; approving it also approves the terminal session. Standalone, the commands ask for the master password like the others.
5.10 Renaming, deleting and leaving vaults
Owners rename and delete vaults; anyone who was given a vault can leave it. The server checks every rule again and never sees a vault name or an item.
Personal creation serializes on the account row. Updated clients use POST /vaults/personal and do not fall back to an old server's creation route. A creation retry unwraps and returns the existing primary, rather than keeping the retry's unused generated key. The primary is the earliest Personal vault by creation time and ID. Existing extras retain their encrypted records and private Personal type, and are marked isPersonalExtra for explicit recovery. They can be moved or deliberately deleted. The primary stays nondeletable.
Rename (owner). PUT /vaults/{id}/name {encName, keyGeneration} stores the sealed name after checking current owner authority and the generation policy (§7.3) under the vault lock. A pending rotation refuses the rename. A changed name advances metadata_revision, invalidating an earlier staged replacement. Every member gets vaults-changed and shows the new name on its next sync. A Personal vault can be renamed too. Secret references (§9.4.1) that name the old name stop resolving, which the CLI says after a rename.
Delete (owner). Without a move, POST /vaults/{id}/delete {deleteItems, keyGeneration} deletes the vault and its contents in one transaction. The caller must be an owner, the vault must not be the designated primary Personal vault, and it must not be the caller's only vault. Preserved legacy Personal extras can be deliberately recovered or deleted. Live items require deleteItems: true, which the apps send only after the user types the vault's name.
With a move, session.DeleteVault uses a protocol-1 delete-move transition (§4.4), never one whole-vault movedItems request. Begin captures both vaults' generations, item sequences and authority/metadata revisions. The destination must be writable and neither vault may have a pending exposed-key rotation. The client reads bounded live-item pages and seals each item for the destination's key and vault ID with a new revision. Tombstones need no destination copy.
Commit locks the source organization when present and both vaults in ID order. It rechecks ownership, destination write access and both snapshots, then checks exact live-item coverage and base revisions. A live destination ID collision refuses the move. A destination tombstone can be replaced, preserving that destination's existing history. The transaction writes every destination item and history revision, charges any real history growth, advances the destination sequence and deletes the source. A concurrent edit or access change returns 409 and leaves both vaults unchanged. The client reloads and retries up to three times.
Source deletion cascades to grants, wrapped keys, items, tombstones, history and share invitations. The server records vault_deleted, sends vaults-changed to former members and vault-changed to destination members, and returns the committed transition receipt. The old aggregate delete-move route remains bounded to 32 MiB and 50,000 items. Updated callers never use it as a fallback.
Leave (member). DELETE /vaults/{id}/access/{own account ID} removes the caller's access and wrapped key (store.LeaveVault, one transaction). An owner may leave only while another owner remains (400 otherwise: "delete it, or make someone else an owner first"); a Personal vault has one owner and cannot be left. Leaving does not rotate the vault key: only owners can rotate, and the items the leaver already downloaded stay readable to them anyway, as after a removal (§4.4). The remaining members get vaults-changed; the audit log records vault_left.
Who loses access. Before deleting a shared vault the apps list its other members with their roles (GetVaultActions; never anyone else, §5.8). Their apps drop the vault on their next sync: listing vaults replaces the cached list (§9.1), and session.Session compares it with the cached one first. A vault that disappeared is reported once, with its name decrypted from the cache while the cached wrapped key still opens it (TakeAllRemovedVault), except the vaults this session deleted or left itself. The Mac and the iPhone show "“Work” is no longer available: it was deleted, or you no longer have access to it." above the vault list; the server does not say which, so neither does the notice. A device without a cache of that vault (signed in later) shows nothing.
Apps. core/app has GetVaultActions (whether the user may rename, delete or leave, the reason when not, the members who lose access and the vaults its items can move to), RenameVault, DeleteVault (move, or delete with items after the typed name; the backend checks the name again), LeaveVault and ListRemovedVaults, used by the Mac, the iPhone (through mobile/hisarkit) and hisar (§9.4). Deleting or leaving also turns the vault's "Available to agents" switch off (§9.4.2).
CLI. hisar vault rename <vault> [name], hisar vault delete <vault> [--move-to <vault> | --delete-items --confirm <vault name>] [-y], hisar vault leave <vault> [-y] and hisar vault create <name> (vault-create stays). In a terminal they ask what is missing: the new name; for a delete, the item count, "These people lose access:" with each member's name, email and role, then "What happens to its items?" with one "Move them to <vault>" line per writable vault (1, the default) and "Delete them with the vault", which asks "Type Work to delete it and its N items"; a move or an empty vault asks "(y/N)". Without a terminal nothing is asked: a vault with items needs --move-to or --delete-items --confirm <name>, and a move, an empty vault or a leave needs -y. A name typed or passed that is not the vault's exact name deletes nothing. Through the app the commands use vaults.actions, vaults.rename, vaults.delete and vaults.leave (§9.4); standalone they run the same session code.
Organization vaults. Members get an organization vault through their teams (§5.11), so it cannot be left, and nobody is removed from it directly: DELETE /vaults/{id}/access/{accountID} answers 400 "access to an organization vault comes from its teams" for it, and GetVaultActions says to ask an organization admin instead. Owners rename and delete it as any other vault; a delete that moves items may move them into, or out of, an organization vault.
5.11 Organizations, teams and organization vaults
An organization is a group of accounts, for example a company, that shares vaults through teams instead of person by person. Its admins manage who is in it, its teams and which teams get which vaults; they never get a vault key by being admins. An admin who adds themselves to a team gets its vaults from an owner's app like any other member, which every member sees in the team and the audit log records. Owners' apps deliver keys to whoever the server says is in a team (§2.2), verifying and pinning each recipient's keys as for a share. There is no admin recovery (§6) and no billing, SSO or policy.
Model.
- Organization: a name (server-visible, §2.3) and members, each
adminormember. Any signed-in user creates one withPOST /organizations {name}and is its first admin. An organization always keeps at least one admin. - Team: a name, unique in its organization regardless of case, and a subset of the organization's members. Only admins create, rename and delete teams and add or remove their members.
- Organization vault: a vault of type
organizationwithorganizationId. It is given to one or more teams of that organization, each with a vault role (owner,editor,reader). Organization and team names are 1 to 100 characters without control characters (apitypes.CleanOrganizationName). - Entitlement: an account is entitled to an organization vault while it is in at least one team given that vault; its role is the strongest of those teams' roles (
apitypes.RankVaultRole). Everyvault_accessrow of an organization vault belongs to an entitled account and has its entitled role; an entitled account without a row has no wrapped key yet and is a key request. One store function (reconcileOrganizationVaults) keeps this true: it runs at the end of every transaction that changes organization members, team members, team vaults or teams, deletes the rows of accounts that are no longer entitled and marks those vaultsneeds_rotation, and sets every other row's role to the entitled role. - Invariants, checked by the reconciliation: every vault keeps at least one
vault_accessrow with roleowner, so somebody can always deliver keys and rotate. A change that would leave an organization vault without one is refused with 409 "Nobody who can open this vault would be its owner. Give owner access to a team whose members already have the vault first." Account deletion follows §5.7.1. An organization vault has at most 1000 entitled accounts, independently of staging's bounded grant uploads. - Serialization. Every transaction that changes an organization, or the access of one of its vaults (key delivery, rotation, creating or deleting an organization vault), first locks the organization's row (
SELECT … FOR UPDATE), then the vault rows it touches in ID order, and only then checks rights, invariants and entitlements; item writes and moves lock only their vault rows, in ID order, since they change no access, so they wait for a rotation of the same vault but not for the rest of the organization. Concurrent changes to one organization therefore run one after the other, and no check is made against a state another transaction is changing. - Key generation. Each vault has
key_generation, 1 at creation, incremented by every rotation, and listed as the vault'skeyGeneration, so a key wrapped before a rotation is never delivered after it, and a name (§5.10) or an item sealed before one is never stored: item writes, item moves and vault deletions that move items carry the generation of the key their items are sealed with, check it while holding the vault rows a rotation locks too, and are refused otherwise (§7.3).
Organization vaults and keys.
- Create. A member of a team creates a vault for it:
POST /vaults {id, type: "organization", organizationId, teamId, encName, wrappedKey}, with the key wrapped to the creator as for any vault (§4.4). The team is given the vault asownerand the creator gets an owner row (403 when the caller is not in the team, 404 for a team of another organization). The other members of the team are key requests. - Give a vault to a team. Admins:
PUT /organizations/{org}/teams/{team}/vaults/{vault} {role}gives or changes,DELETEtakes it back; the vault must belong to the organization (404 otherwise). Roles change at once on the server; nothing is re-wrapped for a role change. - Deliver keys.
GET /vault-key-requestsreturns pending recipients and the vault generation, and lists the caller's own waiting vaults. An owner's app verifies and pins each recipient's signed KEM key, then sendsPUT /vaults/{id}/access/{accountID} {wrappedKey, role, keyGeneration}. Under the organization and vault locks, the server checks current owner authority, current entitlement and role, exact generation andneeds_rotation=false. A pending rotation therefore cannot deliver the retired key to a new recipient. Existing organization members keep their team-derived roles. - Rotate.
session.RotatePendingVaultscompletes pending rotations for shared and organization vaults through bounded staging (§4.4). Every current member and role is retained. The generation and access/metadata snapshot must still match at commit. The server already denies the removed account reads and writes, and refuses exposed-generation ciphertext and key delivery from remaining members until rotation completes.
Invitations. Joining an organization needs no key, so it is one step:
POST /organizations/{org}/invitations {email, role}(admins) answers202 {"message": "Invitation sent"}whether or not the email has an account, and works like a share invitation (§5.8): one per organization and email (a repeat replaces the role and expiry), 7 days,organizations-changedand an email to an existing account, a sign-up invite (§5.1) for any other address. Only the admin's own data is reported: their own email (400) or an existing member (409). It shares the share-invitation rate limits (50 per account and 100 per IP address per day).GET /organizationsreturns the caller's organizations (id,name,role,memberCount) andinvitations: the unexpired invitations to the caller's email, with the organization's name, the inviter's name and email and the role.POST /organization-invitations/{id}/acceptmakes the caller a member at once and deletes the invitation;/declinedeletes it.DELETE /organizations/{org}/invitations/{id}(admins) withdraws one.
Members and teams. GET /organizations/{org} (members only; 404 for anyone else) returns the organization, the caller's role, every member (account ID, name, email, role, signed public keys), every team with its member IDs and vaults (vaultId, role) and, for admins only, the pending invitations. Members of an organization see each other this way; nobody else does, and there is still no list of every account (§5.8).
| Change | Endpoint | Who |
|---|---|---|
| Rename, delete organization | PUT /organizations/{org}/name {name}, DELETE /organizations/{org} | admins; delete is refused (400) while the organization has vaults |
| Change a member's role | PUT /organizations/{org}/members/{accountID} {role} | admins; never the last admin (400) |
| Remove a member, leave | DELETE /organizations/{org}/members/{accountID} | admins remove others; anyone removes themselves, except the last admin while others remain and the last member (400: delete the organization instead) |
| Teams | POST /organizations/{org}/teams {name}, PUT …/teams/{team}/name {name}, DELETE …/teams/{team} | admins |
| Team members | PUT, DELETE /organizations/{org}/teams/{team}/members/{accountID} | admins; the account must be a member of the organization (404) |
Removing someone from the organization removes them from its teams. Every change that touches entitlements runs the reconciliation in the same transaction, under the organization lock, so it can be refused by the invariants (409).
Events and audit. Every change sends organizations-changed to the organization's members (and to the invited account for invitations); a change to entitlements also sends vaults-changed to the accounts whose access changed and to the owners of each affected vault, whose apps then deliver keys or rotate. The audit log records organization_created, organization_renamed, organization_deleted, organization_invitation_sent|accepted|declined|cancelled, organization_member_role_changed, organization_member_removed, organization_left, team_created, team_renamed, team_deleted, team_member_added, team_member_removed, team_vault_granted and team_vault_revoked.
Account deletion (§5.7.1). The account's vaults follow §5.7.1 unchanged, organization vaults included: one with no other owner is deleted with the account. Its memberships and team memberships go; when it was an organization's last admin, the remaining member who joined earliest becomes admin, and an organization left with no members is deleted with its teams and invitations. The organization invitations it sent are withdrawn, and those to its email are deleted; the remaining members of its organizations, the recipients of the withdrawn invitations and the members of every organization that had an invitation from or to it get organizations-changed. created_by, added_by and granted_by keep the deleted account's ID, without a foreign key, as vaults.created_by does (§5.7.1). Every organization vault that stays and that the account held a key to is marked needs_rotation in the same transaction, without the reconciliation's owner check, so its owners' apps rotate it as after a team removal; shared vaults are not marked, as in §5.7.1. The deletion preview lists the organizations (organizations: ID, name, deleted for those that go with the account, and newAdminName when another member becomes admin), and counts the organization invitations it sent and received in sentInvitationCount and receivedInvitationCount.
Apps. core/app completes key requests and pending rotations on every sync, next to accepted shares (§5.8), and has the organization calls for the UI; the iPhone reaches them through mobile/hisarkit. CLI: hisar orgs lists organizations and invitations; hisar org create|show|rename|delete|invite|cancel|accept|decline|role|remove|leave; hisar team create|rename|delete|add|remove; hisar vault create <name> --org <org> --team <team>; hisar vault give|take <vault> --org <org> --team <team> [-r role]. hisar orgs and hisar org show also deliver pending keys and rotate pending vaults, and say so.
Screens (Mac and iPhone, the same model and copy, the copy glossary, "Organizations").
- Organizations in Settings (Mac: a source-list section after People, with the number of invitations for you as a badge; iPhone: a Settings row that pushes the list). Top to bottom: Invitations for you ("Ada invited you to Acme as a member", the inviter's email, Decline and Accept); the organizations with your role and member count; Create organization… (a name, 1–100 characters). A received invitation also shows as a banner above the vault list, like a share invitation (§5.8), with the same two buttons.
- Organization (Mac: the section's detail pane after selecting one; iPhone: a pushed screen). The name and your role. Members: name, email, role (Admin or Member), security code, and the key-changed warning (§5.6); Teams: each team with its members and its vaults (the vault's name, or "A vault you can't open" when this device has no key for it) and each vault's role. Admins also get: Invite by email (email, role Member or Admin, Send invitation; every address gets the same "Invitation sent"), Invited (pending invitations with Cancel), per member Make admin / Make member and Remove from Acme…, New team…, per team Rename…, Delete team…, Add member (a picker of the organization's members not yet in the team), Remove per team member, Give a vault… (a picker of the organization's vaults, by name where this device can open them, and a role) and Take back per vault, Rename organization… and Delete organization… (refused with the server's reason while it has vaults). Everyone gets Leave Acme…. A refusal shows the server's sentence (for example the owner invariant, §5.11 Model) in an alert.
- Vault list. Organization vaults are grouped under their organization's name (Mac sidebar: a heading per organization after the other vaults; iPhone Vaults screen: a section per organization). The vault's role works as for any vault (§5.8): readers cannot edit, editors and owners can.
- New vault. The new-vault dialog (Mac) and screen (iPhone) have Where: "Shared vault" (the current behavior) or one entry per team the user is in ("Acme › Platform"); a team entry creates an organization vault for that team (
CreateOrganizationVault), and the footer says the team's members get it and it is managed in Settings → Organizations. - An organization vault's sharing panel (Mac) and sheet (iPhone) replace Invite by email and the member list with "Shared through teams in Acme": each team that has the vault, its role and its members. Admins get Give to a team… and Take back there; everyone else reads "Ask an admin of Acme to change who has this vault." The vault's Leave vault… shows core's reason (§5.10).
- Waiting for a key. For each organization with vaults the user is entitled to without a key (
ListWaitingVaultKeys), the vault list shows one line, "Waiting for an owner of a vault in Acme to give you its key" or "Waiting for the owners of 2 vaults in Acme to give you their keys", until the vaults arrive; a key Hisar could not deliver or a rotation it could not do shows as the vault list's warning, like a share that could not be completed (§5.8). An organization vault is never labelled "shared". - Account deletion (§5.7.1) lists the organizations the account is in: "Acme is deleted with your account" or "Ada becomes an admin of Acme".
6. Account recovery
Recovery is only through the Emergency Kit (§4.1). With the email, server and Secret Key printed on it, plus their master password, a user signs in on any device (§5.3). There is no other path: no admin recovery, no recovery links, no trusted contacts.
- Lost Secret Key: it is on the Emergency Kit, and on every device still signed in, where the desktop app saves a new Emergency Kit after the master password (Settings → Security → Emergency Kit) and the CLI has
hisar emergency-kit. A Secret Key that may have leaked is replaced from a signed-in device (§5.7). - Forgotten master password: it cannot be recovered by anyone, including Hisar. The server never sees it, and nothing the server or a device stores opens the keyset without it, so the data only that account can open is lost; shared vaults stay with their other members. The apps say so plainly when the account is created and on the Secret Key / Emergency Kit screen.
- Losing the Emergency Kit and every signed-in device loses the account as well.
Interrupted setup is distinct from forgotten-password recovery. The operation journal (§5.2) reconciles a particular already-prepared signup or factor replacement; it cannot recover a forgotten password or bypass the independent Secret Key. Native default recovery needs the OS-wrapped operation key and password. Without that OS protection, restart needs the explicitly user-saved Emergency Kit as well as the password because no factor is persisted in the journal. The CLI exposes hisar account pending list|resume|kit|acknowledge|discard; discard is restricted to prepared operations for which no finish may have been sent.
Admin recovery was removed. Earlier versions let one admin hold a recovery keyset that every recoverable vault key was wrapped to, and recover other users' accounts with one-time links. Migration 00004_remove_recovery deletes all of it: the tables recovery_keyset, vault_recovery, recovery_tokens and account_recovery and the column vaults.recovery_opt_out. Its down migration recreates only the empty structure; the deleted keyset, wrappings and pending recoveries cannot come back. The recovery endpoints are gone and answer 404. Older clients keep working: a 404 for the recovery keyset means "recovery is not set up" to them, so they stop wrapping vault keys to it.
7. Sync protocol
7.1 Model
- Each vault has a monotonic
seqcounter on the server. - Each item has an
id(UUIDv7, client generated), arevision(UUIDv7, client generated per edit), aseq(assigned by the server on write) and adeletedflag (tombstone). - The server stores the current version and keeps item versions as history for 90 days.
7.2 Pull
GET /api/v1/vaults/{vaultID}/changes?since={seq}&limit=500
→ { items: [ {id, revision, seq, deleted, overview, details, updatedAt, updatedBy} ], nextSeq, more }
A page ends after limit items or once its sealed overviews and details reach the soft 16 MiB ciphertext budget. The query checks the total before each item, so one item can cross the budget and the first item always fits. Current per-item limits keep ciphertext below 17.0625 MiB. Base64 and bounded JSON metadata keep the response below the apps' 64 MiB read limit. When a page ends at either bound, it answers more: true and nextSeq is its last item's seq.
The client applies changes to its local SQLite cache and stores nextSeq. Changes arrive in two ways: server push, and polling (every 60 s and on focus) as a fallback.
Push (server-sent events). GET /api/v1/events (session required) is a text/event-stream that tells clients when to sync. It carries no vault data, only IDs and sequence numbers.
event: vault-changedwith{"vaultID", "seq"}goes to every member of a vault after an item is written (with the newseq) or the key is rotated (seq0).event: vaults-changedwith{}tells an account that its list of vaults changed: it was given or lost access, created a vault, or was part of a vault before a rotation, a rename or a deletion, or that someone left it (§5.10).event: share-invitations-changedwith{}tells an account that its received or sent share invitations changed (§5.8): one was sent to it or withdrawn, or one it can complete was accepted or declined. The desktop backend then completes the accepted shares whose signed intent names the recipient, lists the others for review, and refreshes the UI.event: organizations-changedwith{}tells an account that an organization it belongs to changed (members, roles, teams, team vaults, name) or that an invitation to one was sent to it or withdrawn (§5.11).event: share-links-changedwith{}tells an account that one of its share links was created, viewed, used up or revoked (§5.9).event: account-preferences-changedwith{}tells an account that one of its devices saved the preferences record, such as a new favorite (§4.7).event: approval-requested,approval-answeredandapproval-resolvedwith{"requestId"}go to every stream of the account for phone approvals (§9.9). They carry no request or answer, only the ID.- The server sends a
: heartbeatcomment every 25 s and re-checks the stream's session each time, so logging out or revoking the device ends the stream. - Each write pushes the connection's write deadline forward, so the server's 30 s response write timeout does not cut the stream.
- An account may hold 8 streams. A subscriber whose 32-event buffer fills is closed, so the client reconnects and resyncs.
- The broker lives in one server process, which is what the deployment runs; several processes would need Postgres
LISTEN/NOTIFY. - The desktop backend keeps one stream open while unlocked. Its client has no overall request timeout, but closes a stream that sends nothing, not even a heartbeat, for 90 s. It reconnects with backoff from 1 s to 60 s, and stops on 401.
- The iOS app runs the same backend. iOS suspends it in the background, after which the old stream can be dead without the client noticing for up to 90 s, and the backoff may have grown to 60 s. So on returning to the foreground the app calls
ReconnectEvents, which drops the stream and opens a new one at once with the backoff reset. - On an event the backend syncs every vault incrementally (bursts collapse into one sync), so the SSH agent and the browser bridge see the change even when the window is hidden. It then emits
remote-change, and the UI runs its background refresh.
7.3 Push
PUT /api/v1/vaults/{vaultID}/items/{itemID}
If-Match: <base revision> (absent for new items)
{ revision, overview, details, deleted, keyGeneration }
→ 200 { seq } | 409 { current item } | 409 { error, keyChanged: true } | 400 { error }
On 409 { current item } the client merges (§7.4) and retries with the current revision as the new base. A tombstone (deleted: true) replaces an existing item, so one without If-Match is refused with 400 "invalid item".
keyGeneration identifies the vault key used for ciphertext. The server accepts an omitted or zero generation only for a never-rotated nonorganization vault at generation 1. Organization vaults and every rotated vault require the explicit current generation. This applies to item writes and tombstones, both halves of a move, rename, grants, rotation, the source of a deletion and its destination items. The reviewed import write uses the same generation check under its vault lock.
An omitted generation where one is required returns 400 { error: "update Hisar to change data in this vault" } and stores nothing. Desktop versions through 0.1.12 and CLI versions through 0.1.6 must be updated to change rotated vaults, even after reloading their current key. They can still read existing ciphertext. Explicit stale generations return a key-changed 409. Current clients reload and seal again; a real concurrent item edit remains a conflict and the user's edit is retained. No client-version header substitutes for the generation check, and no staging error authorizes a legacy fallback.
While needs_rotation is set, even a current generation cannot authorize new exposed-key ciphertext or a new key grant. Reads and the owner action needed to complete staged rotation remain available. Voluntary leave does not set that marker.
7.4 Conflict resolution (client side)
The server cannot read items, so merging happens on the client:
- Three-way merge per field using the local base version (from local history).
- If the same field changed on both sides: keep the remote value as current and the local value in the item's field history, and show a "conflict resolved" badge.
- If the merge cannot be done (e.g. one side deleted): keep both — restore the item and add a "(conflicted copy)" item.
7.5 Sequence rollback checks and their limits
forEachChangePagerejects a page whosenextSeqis below the requested sequence.recordSeqcompares the completed pull with the highest sequence held bySeqStore, when configured.- Cached pages are applied before the final
recordSeqcomparison. A rejected pull does not prove that its pages never reached the cache. - These checks do not authenticate a complete vault snapshot or establish that the server supplied the latest one. A malicious server can omit writes, replay valid old item ciphertext or misrepresent deletion without lowering the reported sequence. A fresh device has no prior high-water mark.
8. Server
8.1 Data model (Postgres)
accounts (id, email, name, role[admin|member], suspended_at, argon2_params, salt, opaque_record,
enc_keyset, x25519_pub, ed25519_pub, x25519_sig, created_at)
devices (id, account_id, name, platform, created_at, last_seen_at, revoked_at)
sessions (token_hash, account_id, device_id, expires_at)
invites (token_hash, email, role, created_by, created_at, expires_at, used_at)
share_links (id, account_id, ciphertext, encrypted_label, passphrase_salt, passphrase_iterations,
max_views, view_count, created_at, expires_at)
share_invitations (id, vault_id, email, role, invited_by, state[pending|accepted], accepted_by,
created_at, expires_at, accepted_at) -- unique (vault_id, email)
vaults (id, type[personal|shared|organization], organization_id, enc_name, seq, key_generation, needs_rotation, access_revision, metadata_revision, created_at)
vault_access (vault_id, account_id, wrapped_key, role[owner|editor|reader], granted_by)
vault_transitions (id, vault_id, actor_id, kind[rotate|delete-move], generation, item_seq, access_revision, metadata_revision, source_name, target_id, target_generation, target_seq, target_access_revision, target_metadata_revision, state[staging|committed|aborted], enc_name, item_count, byte_budget, staged_bytes, expires_at)
vault_transition_items (transition_id, id, base_revision, revision, deleted, overview, details)
vault_transition_access (transition_id, account_id, role, wrapped_key)
organizations (id, name, created_by, created_at)
organization_members (organization_id, account_id, role[admin|member], added_by, added_at)
organization_invitations (id, organization_id, email, role, invited_by, created_at, expires_at) -- unique (organization_id, email)
teams (id, organization_id, name, created_by, created_at) -- unique (organization_id, lower(name))
team_members (team_id, organization_id, account_id, added_by, added_at) -- the account is a member of the organization
team_vaults (team_id, vault_id, organization_id, role[owner|editor|reader], granted_by, granted_at) -- the vault is the organization's
items (vault_id, id, revision, seq, deleted, overview_ct, details_ct, updated_at, updated_by)
item_history (vault_id, id, revision, overview_ct, details_ct, updated_at, updated_by)
attachments (id, vault_id, item_id, size, chunk_count, storage_path)
audit_events (id, account_id, device_id, type, detail, ip, created_at) -- kept 1 year (§8.6); indexed by time, account and type
account_preferences (account_id, revision, ciphertext, updated_at) -- sealed with K_user (§4.7)
instance_settings (id = 1, settings jsonb, updated_at, updated_by) -- one row, the settings admins save (§8.3)
enc_name is the vault name encrypted with K_vault.
Phone approvals (§9.9) are not in Postgres: they live in the server process's memory for at most 4 minutes, like the event broker.
Session lookups slide expiry and record devices.last_seen_at in one statement, locking the device before its session.
A failed activity write rolls back the expiry change, fails authentication with a database error, and is logged.
Suspended sessions change neither expiry nor activity. Transactions that lock both devices and their sessions must
lock devices first, matching device revocation and account deletion.
8.2 API surface (v1)
| Area | Endpoints |
|---|---|
| Auth | POST /auth/register/{start,finish}, POST /auth/login/v2/{start,finish}, POST /auth/logout, GET /auth/prelogin?email= (salt, params). Legacy login routes refuse with an update error (§5.3) |
| Account | GET /account, POST /account/reauthenticate/v2, PUT /account/credentials (§5.7), GET /account/deletion-preview, POST /account/delete (§5.7.1), GET /account/preferences, PUT /account/preferences (§4.7) |
| Devices | GET /devices, DELETE /devices/{id} |
| People | GET /members (the caller and the people they share a vault with; public keys, signatures), POST /invites (any user, or only admins under the invite policy, §8.3; emailed) |
| Share invitations | POST /vaults/{id}/signed-share-invitations (owner; the signed intent of §5.8), GET /share-invitations (received, sent, waiting), POST /share-invitations/{id}/accept, POST /share-invitations/{id}/decline (recipient), POST /share-invitations/{id}/complete (owner; signed completion), DELETE /share-invitations/{id} (owner). POST /vaults/{id}/share-invitations remains for older apps and refuses any request without a valid owner signature with 400 |
| Organizations (§5.11) | GET /organizations, POST /organizations, GET /organizations/{org}, PUT /organizations/{org}/name, DELETE /organizations/{org}, POST /organizations/{org}/invitations, DELETE /organizations/{org}/invitations/{id}, POST /organization-invitations/{id}/accept, POST /organization-invitations/{id}/decline, PUT/DELETE /organizations/{org}/members/{accountID}, POST /organizations/{org}/teams, PUT /organizations/{org}/teams/{team}/name, DELETE /organizations/{org}/teams/{team}, PUT/DELETE /organizations/{org}/teams/{team}/members/{accountID}, PUT/DELETE /organizations/{org}/teams/{team}/vaults/{vaultID}, GET /vault-key-requests (pending, waiting) |
| Vaults | GET /vaults, POST /vaults, GET /vaults/{id}/members, PUT /vaults/{id}/access/{accountID}, DELETE /vaults/{id}/access/{accountID} (owner removal marks pending rotation; own-ID leave does not), bounded legacy POST /vaults/{id}/rotate, PUT /vaults/{id}/name, POST /vaults/{id}/delete, POST /vaults/{id}/recovery/name {review, encName} and POST /vaults/{id}/recovery/delete {review} (owner; review is the sealed name, the caller's wrapped key and the key generation the app could not open, compared under the vault's lock; 409 once any of them changed) |
| Vault transitions (§4.4) | GET /vaults/{id}/transitions (required protocol capability), POST /vaults/{id}/transitions (begin), GET /vaults/{id}/transitions/{operation} (status/receipt), GET/POST /vaults/{id}/transitions/{operation}/items (source page/upload), POST /vaults/{id}/transitions/{operation}/access (grant upload), POST /vaults/{id}/transitions/{operation}/commit, POST /vaults/{id}/transitions/{operation}/abort |
| Items | GET /vaults/{id}/changes, PUT /vaults/{id}/items/{itemID}, GET /vaults/{id}/items/{itemID}/history, POST /vaults/{id}/items/{itemID}/move (one transaction writes a source tombstone and the destination item; both generations follow §7.3, including the omission cutoff after rotation) |
| Attachments | PUT/GET /vaults/{id}/attachments/{attID}/chunks/{n} |
| Share links (§5.9) | POST /share-links, GET /share-links, DELETE /share-links/{id}; without a session GET /public/share-links/{id} (counts a view). The recipient page is GET /s/{id}, outside /api/v1 |
| Approvals (§9.9) | POST /approvals, GET /approvals (pending), GET /approvals/{id}, POST /approvals/{id}/answer, DELETE /approvals/{id} (resolve; the requesting device only) |
| Desktop updates | GET /desktop/updates/appcast.xml (Sparkle appcast), GET /desktop/updates/{version}/{file} (streams a release's update archive); any signed-in session, 404 when desktop updates are off (§9.1.1) |
| App versions (§8.7) | GET /version-check (no session): 204 when the server accepts the caller's version, 426 otherwise |
| Admin (§5.1) | GET /admin/server (the public URL, for Overview), GET /admin/health and POST /admin/health/secrets-backup (§8.8), GET/PUT /admin/settings and POST /admin/settings/test-email (§8.3), GET/POST /admin/invites, DELETE /admin/invites/{id} and POST /admin/invites/{id}/resend (§5.1), GET/POST /admin/setup/checks and POST /admin/setup/invite-policy (the setup steps, §5.1), GET /admin/accounts?query=&cursor=, GET /admin/accounts/{id}, POST /admin/accounts/{id}/suspend, POST /admin/accounts/{id}/reactivate, PUT /admin/accounts/{id}/role and POST /admin/accounts/{id}/delete (with a re-authentication proof), POST /admin/accounts/{id}/sign-out and DELETE /admin/accounts/{id}/devices/{deviceID} (§5.1 Accounts), GET /admin/audit, GET /admin/audit/types and GET /admin/audit.csv (§8.3). Every route under /admin needs a session of an admin account and answers 403 admin only to any other |
| Setup (§5.1) | GET /setup (no session): {"state"}, one of needsAdmin, needsChecks, needsInvitePolicy and done |
All under /api/v1. JSON bodies, base64url for binary fields. There is no recovery API (§6). Outside /api/v1 the server serves the pages GET /s/{id} (§5.9), GET /invite/{code} (§5.1), the admin dashboard GET /admin (§5.1), GET /privacy and GET /terms (§8.6).
8.2.1 Complete, bounded collection reads
History, people, devices, vaults, vault members, share links and invitations, organization lists/details and vault-key requests, admin invitations/account details, and account-deletion previews (an account's own and an admin's, GET /admin/accounts/{id}/deletion-preview) support Hisar-Collection-Pages: 1. Replies repeat that version and contain {version, records, nextCursor}; deletion previews also bind a snapshot and retain their plan version. A page contains at most 100 records and 8 MiB of serialized JSON. Team membership and vault-grant edges are separate records: a team's nested arrays cannot force one oversized record. Individual legal-size encrypted items retain their existing limits.
Cursors bind the account, device, route, ordered position and committed collection revisions. Each page uses a repeatable-read transaction. A concurrent relevant mutation answers 409 instead of continuing a mixed view. Revisions include entity incarnations, so deletion and recreation cannot reuse a cursor. The Go client and admin dashboard discard partial results and retry from the beginning at most three times; only a terminal complete result is published. Continuous writes may therefore cause an explicit conflict, never an infinite retry or a partial result presented as complete. The narrowly scoped public-directory revision is shared: an unrelated account profile, role or public-key change can invalidate a directory-derived traversal. It is not advanced by item writes, login credentials or device last-seen updates.
New clients accept a complete ordinary response from an old server only within a 64 MiB transport bound. Claimed page responses must validate their version, ordering, progress and snapshot. A new server answers a caller without the page header with the complete old typed result only when it fits 8 MiB; otherwise it answers 428 with an upgrade instruction. It never silently returns the first page as the entire collection. The 8 MiB HTTP budget is separate from the browser native-messaging limit.
8.2.2 Transactional growth budgets
Migration 00017 records existing counts without trimming data. Positive net growth is refused at these policy envelopes:
| Scope | Growth budget |
|---|---|
| Creator | 1,024 vaults; 128 organizations |
| Account | 256 signed-in devices |
| Organization | 1,024 teams; 4,096 members; 32,768 team memberships; 16,384 team-to-vault grants |
| Vault | 4,096 access grants; 1 GiB of history charges |
A refusal answers 507 Insufficient Storage with {"error": …}, a sentence that names the full budget and what to remove. It is deliberately not 409: the item, sharing, rotation and preferences calls read 409 as a conflict to merge or retry, so a budget refusal there would reach the user as "changed on another device" or be retried. Clients of every version show the sentence; the Go client returns it as apiclient.ResourceBudgetError. A scope's first usage is checked like every later one, so one transaction cannot create more than a budget from zero.
Every sign-in revokes the account's devices that have no live session before it adds its own. A session is only created with a new device and an expired session never comes back, so such a device can never be used again; a device signed out with logout or by session expiry therefore stops holding a place, and repeated sign-ins never lock an account out. The budget then refuses only a 257th device with a live session, and its sentence says to sign out devices in Settings > Devices from one still signed in.
A history revision costs its encrypted overview and details plus 256 bytes. That fixed charge conservatively accounts for row/index overhead; it is not a claim about compressed disk usage. The counts and byte envelopes bound growth rather than measured production demand. There is no second independent item-count limit. Existing over-budget data remains readable through every page, exportable and removable. One live-to-deleted transition remains possible over the history budget and is charged; repeated tombstone revisions are not exempt. Rotation must pass the same before/after history-growth check, not gain a blanket exemption. Daily retention pruning locks eligible vaults without waiting and skips busy writers until the next prune, preserving each writer's stable history baseline.
Entity triggers collect one transaction-private delta per affected scope, not one retained row per item. At commit a deferred flush applies quota locks in sorted resource/scope order, then collection-version locks in sorted scope order. No entity DML or new entity locks occur after these metadata locks. This prevents cross-vault rotations or cascades with shared recipients from introducing opposite metadata lock order. A refusal rolls back the whole transaction, including its queue, counters and versions, before events or mail are published. Deletions reclaim unused counter/version backing; incarnation IDs preserve stale-cursor detection when a scope is recreated.
8.3 Hardening
- Rate limiting per IP and per email on
preloginandloginendpoints.preloginreturns a deterministic fake salt for unknown emails so it doesn't reveal which accounts exist. - Bounded authentication state. Login and re-authentication share at most 16,384 unfinished exchanges, each expiring after 60 seconds. This accommodates about 273 unfinished starts/s for one full expiry window by arithmetic, not a measured throughput guarantee. Capacity exhaustion answers 429 without evicting a live exchange. Expired MAC and proof buffers are cleared. A one-second maintenance task reclaims expired state even without traffic.
- Bounded limiter state. Each limiter retains at most 65,536 identities; all API, service and approval limiters share a 262,144-identity process budget. Retained keys are 32-byte hashes. Each table has 64 independently reclaimable map shards and one indexed heap node per identity, not a growing queue of stale expirations. A shard guard of twice its expected occupancy (minimum 32) can refuse growth before the table cap. New identities receive no overflow burst. Known identities retain spent debt, including daily debt; an entry expires only after ten idle minutes and full refill. Increasing the auth burst reschedules existing debt rather than resetting it. Maintenance removes at most 1,024 due entries per table per second, so a full generic table can need 64 ticks after its deadlines. Maps rebuild below quarter occupancy and map/heap backing references disappear when empty; Go's normal garbage collector determines physical page release.
- Sizing. A one-run Go 1.26.10 retained-heap sample on arm64 accepted exactly 65,536 limiter identities, refused the next 65,536, and reported 17,544,016 bytes at capacity and 152,384 after reclamation. A synthetic pending sample (32-byte account IDs, 64-byte MACs and two 32-byte hashes) accepted 16,384, refused the next 1,000, and reported 7,135,848 bytes full and 369,000 after reclamation. These are noisy whole-process heap samples, not incremental allocations, RSS limits or production benchmarks. Plan at least 128 MiB of headroom for these tables at the default aggregate cap, separately from requests, cryptography, Go runtime and database memory, and measure the actual workload before raising capacities.
- No account enumeration:
POST /invites,POST /vaults/{id}/signed-share-invitationsandPOST /organizations/{org}/invitationsanswer the same whether or not the email has an account, and do the account-dependent work (events, invites, email) after answering. All are rate-limited per account and per IP (§5.1, §5.8, §5.11). - Request size limits. Strict JSON decoding (unknown fields rejected).
- Security headers on the invite page and the share link page (CSP, HSTS, no framing, §5.1, §5.9).
- Instance settings. Admins edit them in the dashboard under Settings: SMTP (Email, with Send test email…, which sends one message with the saved settings and shows the SMTP server's error; each admin may send 5 at once, then 5 a minute, and is answered 429 beyond that), the invite policy (Sign-up), the minimum app versions (App versions, §8.7), and the session lifetime (1–365 days, default 30), the auth burst (sign-in requests one IP may make before it is limited to one every 6 s; at least 1, no upper limit, default 10) and the retention of item history and expired invitations (1–3650 days) and of the audit log (30–3650 days), defaults in §8.6, under Security.
- Storage.
instance_settingsholds one JSON document (server/internal/settings):{"smtp": {"host", "port", "username", "password", "from"}, "minimumVersions": {"desktopMacOS", "iOS", "android", "cli", "extension"}, "invitePolicy": "anyone" | "admins", "sessionLifetimeDays", "authBurst", "retention": {"historyDays", "auditDays", "expiredInvitesDays"}, "setupState": "needsAdmin" | "needsChecks" | "needsInvitePolicy" | "done"}. Every field is optional: a missing one keeps its default, and fields this version does not know are kept.setupStateis the setup step of §5.1: only the setup endpoints move it, under the same row lock, and Settings neither shows nor edits it; a document without it starts atneedsAdminon a server without accounts and atdoneotherwise. A nonempty dashboard SMTP password is stored as{"version":1,"ciphertext":"<base64>"}. The key is HKDF-SHA256 of the separately stored server HMAC key with infohisar/v1/server-settings-key. XChaCha20-Poly1305 uses AADhisar/v1/server-settings/smtp.passwordand ciphertextversion(1) || nonce(24) || ciphertext+tag. The Store decrypts only into memory and encrypts every settings write. Migration 00018 converts legacy passwords transactionally before settings are loaded, even under environment overrides. SQL rejects later nonempty plaintext writes. A database-only backup lacks the encryption key, but the live server or a paired database and secrets backup can recover the password. Environment passwords are never persisted, and an override does not erase an older saved dashboard password.GET /admin/settingsanswers only{"set": true|false}for it. - Server secrets and recovery. Startup opens the database before creating authentication keys. Existing accounts or an enrolled fingerprint prohibit creating a missing secrets file.
operational_secretsstores the SHA-256 fingerprint of the OPAQUE material and HMAC key, encoded with labelhisar/v1/server-secrets-fingerprintand a big-endian 64-bit length before each field. Enrollment and SMTP migration commit together. An enrolled mismatch refuses startup. A legacy database has no earlier fingerprint, so first enrollment cannot establish that a supplied file is the original one. After enrollment, migration rollback is refused. Restore the matching pre-upgrade database and secrets backup to run an older binary. The deployment guide gives the paired backup and restore procedure. - The SMTP password stays with its server. A save that changes
smtp.host,smtp.portorsmtp.usernamewithout settingsmtp.passwordclears the stored password, so an admin cannot send the saved password to a host they choose; with a username, such a save is refused until the password is entered again ("Enter the SMTP password for the username. It must be entered again whenever the host, port or username changes."). The dashboard says so under Password as soon as one of those fields changes. - Precedence. A
HISAR_*variable (§8.4) wins over the stored value, which wins over the default. Email is one unit: once anyHISAR_SMTP_*variable is set, every Email field comes from the environment (a variable left empty gives the default, never the stored value), so a password from the environment is never combined with a host from the dashboard.GET /admin/settingsanswers each field's value, its source (default,databaseorenvironment) and its variable; the dashboard shows a field set by the environment read-only with "Set by HISAR_SMTP_HOST" (and Email with "Email is set by HISAR_SMTP_* variables on the server."), andPUTrefuses to change it ("Email is set by HISAR_SMTP_* variables on the server, so it cannot be changed here." for an Email field). OneValidateholds every bound; the server refuses to start when the effective settings fail it. - Applying.
PUT /admin/settingschanges only the fields it names, in one transaction on the locked row, then swaps one atomic snapshot that every request loads once; nothing needs a restart. A new auth burst applies to the per-IP limiter at once, the session lifetime to the next request of every session (sessions slide), and the retention to the next daily prune. The audit log recordsadmin_settings_changedwith the names of the changed fields, never their values. The supported topology is one server process: settings snapshots, pending authentication and limiter debt are process-local, not distributed. - Invite policy.
anyone(the default) oradmins. Underadmins,POST /invitesfrom a member answers 403 "Only admins can invite people to the Hisar server.", which the Mac and iPhone apps andhisar invitethrough the app show as it is. A share invitation or organization invitation to an email without an account still answers 202 exactly as before, so it still reveals nothing; the server just creates and emails no sign-up invite for it, and the invitation waits until that person gets an account some other way.
- Storage.
- Audit log for logins, device changes, invites (
invitefrom the apps,operator_invitefromhisar-server invite, andadmin_invite_created,admin_invite_revokedandadmin_invite_resentfrom the admin dashboard, §5.1), share invitations, sharing changes, credential changes, failed re-authentications, account deletions, renaming, deleting and leaving vaults (vault_renamed,vault_deletedwith the member count and the target vault and item count of a move,vault_left; IDs only), organization changes (§5.11), setup (§5.1:admin_settings_changedfor the invite policy, thensetup_completed), and admins' changes to other accounts (§5.1 Accounts, withdetail.account), kept 1 year (§8.6).- Types.
server/internal/service/audit.goholds every type the server records in one table, with its group and the label the dashboard shows; the service records only keys of that table, never a string, and a test checks that every key has its row. Stored names never change. - Reading it. Admins read the log in the dashboard under Audit log.
GET /admin/audit?account=&type=&group=&from=&to=&cursor=answers{events, nextCursor}: 100 events a page, newest first, paged by(created_at, id), so no event repeats from one page to the next.accountis an email (the account that has it now) or an account ID, which also finds a deleted account's events; such events show the ID and no email (accountEmailis null).typenames one type andgroupall types of a group (accounts,vaults,sharing,organizations,admin), not both;from(inclusive) andto(exclusive) are RFC 3339 times.GET /admin/audit/typeslists the groups and types for the filter. - Export.
GET /admin/audit.csvwith the same filters answerstext/csvas an attachment. The server reads it from the database 500 rows at a time, freeing the connection between batches, and sends each batch as it goes, so its memory does not grow with the log; the dashboard saves the whole file from browser memory. If the database fails partway, the server closes the connection before the last chunk, so the browser reports a failed download instead of a short file. Columns: ID, Time (UTC), Event (the label), Type, Account ID, Account email, Device ID, IP address, Details (the JSON detail). A field a spreadsheet would run as a formula (starting with=,+,-,@, tab or carriage return) gets a leading'. The audit log recordsadmin_audit_exportedwith the filters of the export.
- Types.
8.4 Deployment
- Single static binary
hisar-serverin a Docker image, with Postgres 17 alongside it in Docker Compose. - The image builds the admin dashboard (§5.1) before
go buildembeds it:hisar-admin-wasmwithGOOS=js GOARCH=wasm, then the Vite build in anode:24stage. - Schema migrations: goose (
pressly/goose), SQL files embedded in the binary, up and down sections, applied on start under a Postgres advisory lock or explicitly withhisar-server migrate up|down|status. - HTTP framework: Fiber v3 (fasthttp). It serves plain HTTP behind the reverse proxy; HTTP/2 and TLS are handled by the reverse proxy (nginx in the reference deployment).
- Served publicly over https behind nginx, which terminates TLS. A run of the GitHub Actions workflow "Release" with the app
server(CI and releases) tagsserver-<version>, and its workflow "Release server image" buildsdeploy/Dockerfilefor amd64 and arm64 and pushes it to the GitHub Container Registry asghcr.io/tosunlab/hisar-server:<version>and:sha-<commit>, built with that version (-X main.versionfrom the image'sVERSIONbuild argument;make docker VERSION=1.2.3does the same locally, and a build without a tag issha-<commit>); the run then creates the GitHub prereleaseserver-<version>. Its Publish job, once approved, points:latestand:<major>.<minor>at it and turns the prerelease into the latest release, which is what admin dashboards offer as a newer server (§8.8). A custom build (server-1.2.3-label) can't be published. - Attachments on local disk (later: S3-compatible storage).
- Instance settings (§8.3) are edited in the admin dashboard; these variables override them field by field, except Email, which any
HISAR_SMTP_*variable takes over as a whole, and the dashboard then shows the field (or all of Email) read-only. - Optional email (§8.5):
HISAR_SMTP_HOST,HISAR_SMTP_PORT(default 587),HISAR_SMTP_USERNAME,HISAR_SMTP_PASSWORDandHISAR_SMTP_FROM. With a host, the effective settings need a port, a sender address and, with a username, a password; the server refuses to start otherwise. Setting them keeps the SMTP password out of the database (§8.3). HISAR_AUTH_BURST: the auth burst of §8.3.- Authentication-state startup capacities:
HISAR_AUTH_PENDING_CAPACITY(default 16,384),HISAR_LIMITER_KEY_CAPACITY(65,536) andHISAR_LIMITER_TOTAL_CAPACITY(262,144). Each must be a positive integer; invalid values refuse startup. These bound retained state, not request throughput, and changes require a restart. - Desktop update source (§9.1.1):
HISAR_DESKTOP_UPDATE_GITHUB_REPOSITORY(defaulttosunlab/hisarfromcore/project;offturns updates off and the update endpoints answer 404), the optionalHISAR_DESKTOP_UPDATE_GITHUB_TOKEN(a fine-grained token with read-only Contents access to that repository, needed while it is private) andHISAR_DESKTOP_UPDATE_GITHUB_API_URL(defaulthttps://api.github.com, for GitHub Enterprise and tests). The same repository'sserver-<version>releases tell the admin dashboard about a newer server (§8.8);offturns that off too. - Optional minimum app versions (§8.7):
HISAR_MINIMUM_VERSION_DESKTOP_MACOS,HISAR_MINIMUM_VERSION_IOS,HISAR_MINIMUM_VERSION_ANDROID,HISAR_MINIMUM_VERSION_CLIandHISAR_MINIMUM_VERSION_EXTENSION, each a semantic version such as0.1.6; empty leaves the dashboard's value, which is no minimum by default. The server refuses to start with a value that is not a version. - Nightly
pg_dump+ attachments encrypted withageand copied off-server. Restore tested quarterly.
8.5 Email
server/internal/mail has a small Mailer interface with an SMTP implementation and a recording fake for tests.
- Transport. Port 465 uses implicit TLS; any other port must offer STARTTLS, which is used before authenticating. Plaintext is refused, so credentials never cross the network unencrypted; the only exception is a loopback host, which the end-to-end test's SMTP sink uses (
server/cmd/hisar-smtp-sink). Authentication is PLAIN over TLS. Each message has a 30 s deadline. - Messages. Plain text in UTF-8, no HTML, no images and no tracking; short and written for developers. Names from users are stripped of control characters before they go into a header.
- Invite: who invited them, the sign-up link (the invite page, §5.1: "Get Hisar and create your account") and the invite code ("Or paste this invite code under Create account in Hisar"), the expiry, and that the email can be ignored.
- Share invitation to an account: "<Name> shared a vault with you in Hisar", the role, and where to accept (Settings → People,
hisar invitations). - Share invitation without an account: the same, plus the sign-up link and code.
- Share accepted, to the inviter (§5.8, sent only if their app has not completed it after 2 minutes): "<Name> accepted your invitation in Hisar", that the server cannot say which vault, and to open and unlock Hisar on a device to finish sharing.
- Master password changed or Secret Key replaced (§5.7), to the account: when, how many other devices were signed out and how to sign in on them, and to contact support if it was not them.
- Account deleted (§5.7.1), to the deleted address: what was deleted, what stays with other owners, and the backup and audit log retention.
- Sending happens in the background after the API answered; failures are logged without the recipient's address.
8.6 Privacy policy and Terms
GET /privacy and GET /terms serve two static pages embedded in hisar-server (privacy.html, terms.html), linked from each other, from the store listings and from the desktop app's create-account screen. They describe the hosted service at api.tryhisar.com exactly as this document does: what the server stores (§2.3, §8.1), what it cannot read, the sub-processors (Hetzner Online GmbH in Helsinki, Finland, for hosting; Google for email) and the retention below. The operator is Alper Berber, the contact is support@tryhisar.com, and the terms are under Dutch law. Both pages get the share link page's headers minus scripts: Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none', cacheable for an hour.
Retention, as the pages state it. These are the defaults of the instance settings (§8.3); an operator may change them in the admin dashboard under Settings → Security, and the hosted service keeps them, because the privacy page states them:
- Account data, devices, vaults, items and invitations: while the account exists. Users delete their account in the apps or the CLI (§5.7.1), or by emailing support; an admin of the Hisar server can delete it from the admin dashboard (§5.1). Item history: 90 days. Share links: deleted at expiry, after the last view or when revoked (§5.9). A share invitation: until the share is completed, declined or cancelled; an organization invitation: until it is accepted, declined or cancelled (§5.11). Sign-up invites, pending share invitations and organization invitations that were not used:
hisar-serverdeletes them once a day when they expired more than 30 days ago. Accepted share invitations stay until an owner completes or cancels them; past their signed expiry an owner can only cancel them. Retired share invitation IDs (§5.8): deleted by the same daily run once their signed expiry has passed. - Audit log (
audit_events: account, device, event type, IP address and details: the device's name and platform for a login, the email for a sign-up, an invite, a share invitation or an organization invitation, otherwise IDs only): 1 year;hisar-serverdeletes older rows once a day. - Rate-limit counters, the event broker and phone approvals: server memory only, never written to disk, gone on restart (approvals after at most 4 minutes).
- The reverse proxy's access logs: 14 days. The encrypted backups (§8.4): 30 days.
8.7 Minimum app versions
The operator can require a minimum version per app, in the admin dashboard under Settings → App versions or with HISAR_MINIMUM_VERSION_* (§8.4), mostly for API compatibility: an app below it must update before it can be used. The minimum is independent of the latest version the update feed offers (§9.1.1); raising it is an explicit, rare decision.
- Headers. Every app sends
Hisar-PlatformandHisar-Versionwith every request, the event stream included. The platform is the one the device registers with:darwin/desktop(Mac app),ios/mobile(iPhone app),android/mobile(Android app),<os>/cli(hisar, any OS); the desktop app checks the browser extension and an app-modehisaron their behalf withbrowser/extensionand<os>/cli(below). Development builds (development, or no bundle version) send neither header. - Middleware.
requireMinimumVersionruns first on every/api/v1request, before rate limits, sessions and the handlers, so sign-in, sign-up andGET /version-checkanswer it too and a signed-out app knows. When the platform has a minimum and the version is below it, the answer is426 Upgrade Requiredwith{"error": "update required", "minimumVersion", "updateURL"};updateURLishttps://tryhisar.com/downloadfor the Mac, iPhone, Android andhisar, and the Chrome Web Store page for the extension. - What passes: requests without either header, from platforms without a minimum (Linux desktop, other callers), and versions that do not parse. So very old apps from before the headers, scripts and other callers keep working; only an app that says it is too old is refused.
/api/v1/desktop/updates/…always passes, so a refused Mac app can still update itself through Sparkle. The headers are the caller's own claim, so the minimum only tells honest apps to update; it is never an access control, and a protocol fix that must hold for every caller is enforced by the server on the request itself. - Versions are semantic versions compared by
core/appversion(also the updater's parser,desktopupdate.GetBuildNumber): a pre-release comes before its release (1.0.0-alpha-test<1.0.0; a custom build1.2.3-labelis below a minimum of1.2.3), build metadata is ignored. - Clients.
core/apiclientsends the headers whenClient.Versionis set and turns a 426 into*apiclient.UpgradeRequiredError(not "unreachable", so the offline cache never hides it), callingClient.OnUpgradeRequired.- Mac app: the backend records the requirement from any answer (
Status.updateRequired: current version, minimum, update URL), stops the event stream and emitsupdate-required, which shows the window. The app also asksGET /version-checkat launch. The UI then shows only a blocking "Update required" screen: "This server needs Hisar 0.2.0 or later. This Mac has 0.1.0.", Update now (Sparkle when it is available and signed in, otherwise the download page) and Check again (any answer but 426 clears the requirement, e.g. after the operator lowered the minimum). Meanwhile the SSH agent (signing and listing keys, which also shows the screen), the command-line socket (every request buthello, coding agents included) and the browser bridge (every paired request) answer at once with "Hisar needs an update: the server requires version 0.2.0 or later…" instead of waiting. - iPhone app: the same requirement through
hisarkit(SetAppVersionright afterNew,CheckMinimumVersion,EventListener.OnUpdateRequired), checked at launch and on returning to the foreground; a blocking "Update required" screen with Install the latest Hisar (the download page until TestFlight or the App Store exist) and Check again. The AutoFill extension says Hisar needs an update instead of offering logins. - Android app: the same calls and screen as the iPhone, with the minimum from Android app in the dashboard or
HISAR_MINIMUM_VERSION_ANDROID. The version ishisar.versionNameinmobile/android/gradle.properties, which debug builds send too. "This device has 0.1.0." ends the screen's sentence. While an update is required, AutoFill offers only "Unlock Hisar", and its screen says "Hisar needs an update. Open Hisar to update it." - hisar: standalone, a 426 prints
hisar: update required: the Hisar server needs hisar 1.0.0 or later, and this is 0.9.0.with where to download it, and exits with 1. In app mode it sendsclientPlatformandclientVersionwith every request; the app refuses withupdate-requiredwhen the app itself needs an update or when the server'sGET /version-checkrefuses thathisar(asked by the app, cached for an hour). - Browser extension:
hellocarriesextensionVersion; the app answersupdateRequired: {target: "app" | "extension", currentVersion, minimumVersion, updateURL}(the extension's version is checked likehisar's). The popup shows "Hisar needs an update" with Open Hisar, or "Update the Hisar extension" with Update extension; while the extension is too old it refuses fills itself, and the inline menu points to the popup.
- Mac app: the backend records the requirement from any answer (
- Offline: without an answer from the server nothing is refused and the cache keeps working; the check applies on the next contact. A requirement already known stays until the server accepts the version again or the app restarts (a restart checks again at launch).
8.8 Server health
Overview in the admin dashboard (§5.1) reads GET /api/v1/admin/health: {version, latestVersion, database: {ok, migrations}, counts: {accounts, suspendedAccounts, vaults, items, shareLinks, pendingInvites}, storageBytes, checks}.
- Version.
versionis the release the binary was built from (§8.4),devfor a local build.latestVersionis the highestserver-MAJOR.MINOR.PATCHrelease of the update repository (§8.4) when it is newer thanversion, read once an hour and skipping drafts, prereleases and custom builds; it is empty otherwise, for a version that does not parse, and when updates areoff. - Database.
okwhen every migration is applied; each migration has its name and when it was applied.storageBytesispg_database_size(current_database()). The counts are every account and the suspended ones among them (§5.1 Accounts), live items, share links that have not expired and sign-up invites neither used nor expired other than the setup invite, the ones Invites lists (§5.1). Counting items reads the whole items table, so one count runs at a time (requests that arrive meanwhile wait for it) and is reused for a minute: the item count may be up to a minute old, which Overview says under Usage. When the reads after the session lookup fail or take over 8 seconds, the answer is 503the server cannot reach its database; so is every session lookup that fails on the database, so apps treat an outage as an unreachable server and stay signed in. - Checks. One table in
server/internal/service(RunInstanceChecks),ok,warningorfailedwith a sentence each, run at once. The setup page's checks step (§5.1) runs the same table throughGET/POST /admin/setup/checksand offers I have a backup there too.public-url: the server fetches<HISAR_PUBLIC_URL>/healthz(5 s, no redirects) and expectsok. Failed whenHISAR_PUBLIC_URLis not an http or https URL, or when the URL answers anything else, so it leads to another server. When the fetch does not get through (DNS, connection, timeout), a warning instead: in Docker or behind a router without hairpin NAT a server cannot reach its own public URL even when it works, so the message says to open it from another device.https:HISAR_PUBLIC_URLuses https, or http on a loopback host (localhostor a loopback address,mail.IsLoopbackHost, the rule invite and email links follow); otherwise failed.proxy: warns when the request hasX-Forwarded-ForwithoutHISAR_TRUST_PROXY, or the reverse.migrations: failed while a migration is pending or the status cannot be read; reading it takes no migration lock.secrets-backup: warns "Back up hisar-secrets.json. Nobody can sign in without it." until an admin clicks I have a backup on Overview or in setup.POST /admin/health/secrets-backupstores the time assecretsBackupConfirmedAtin the instance settings document (§8.3), which Settings neither shows nor edits, swaps the snapshot, recordsadmin_secrets_backup_confirmedand answers the check, now ok; it stays ok across restarts.email: warns "Email is not set up. Invites from the apps will fail." without SMTP (§8.5).
9. Clients
All clients use core/ for crypto, the local store and sync.
9.1 Desktop — Wails v3 (Go + Vue/TypeScript)
- The Go side owns the unlocked keyset and vault keys (in
memguardlocked buffers). The web UI calls Go methods and receives only the decrypted data it asks for. - Local store: SQLite (
modernc.org/sqlite, pure Go) holding the server's ciphertext as-is plus sync state. Nothing is stored decrypted on disk.core/cachestores the account record (salt, Argon2 params,encKeyset, public keys), the vault list as the server returns it (wrapped keys, encrypted names), every live item's encrypted parts, each vault'sseqand the sealed account preferences record with the favorites (§4.7). The file isdesktop-cache.dbnext todesktop.json, mode 0600, and is deleted on sign-in, sign-up and sign-out.- Remotely fetched accounts replace the cache only after keyset verification.
AttachCachealso compares the fetched credential envelope with the one established by signup, unlock, or a committed credential change and checks public keys against the open keyset. A second account fetch cannot overwrite the last verified offline envelope with a forged one. - An open failure replaces the file only when SQLite reports corruption or an invalid database format. Access, I/O, and locking errors keep the snapshot and are logged. Hisar can continue online without that cache.
- Before use, the cache must contain only the signed-in account's account row, or no records at all. A foreign, mixed-account, or populated unbound cache is kept but never attached, including after restart if account-switch cleanup failed. Sign-in and sign-up log cleanup failures without suppressing the new Secret Key ceremony.
- A newly signed-in or registered session attaches its cache only after fetching and saving the account row succeeds, and an online unlock keeps its cache only when saving the fetched account row succeeds. Otherwise the session's vault reads stay online-only, so they cannot create populated unbound snapshots.
session.Session.Cachestores each fetched vault record. A record whose key and name open replaces the cached one, except a record of another key generation while the vault has cached items: the cache keeps the record those items are sealed with until a completed acquisition publishes the new generation's items together with its record, so restart and offline reads never pair a key with ciphertext it does not open. A record that does not open never replaces a verified one and is kept as a known omission, offline too, until a verified record replaces it; only a list in which every record opened makes the cache forget unlisted vaults. Item pages, the vault record and the completion checkpoint publish in one SQLite transaction after terminal success and rollback checks. An interrupted traversal keeps the previous published snapshot. A terminal empty traversal establishes completion at sequence zero. Metadata presence, an old positive sequence, and a local write do not establish completion. An acquisition without a cache reports the sequence its last page reached.sync_checkpointsextends schema version 1 without deleting old ciphertext. Existing rows have unknown completion until a successful full traversal. SQLite triggers invalidate completion after legacy item or sequence writes and generation changes. Updated sync pages carrykeyGeneration, captured with the bounded page in one repeatable-read database snapshot. A generation mismatch rolls back unpublished changes and reloads once. Missing generation refuses online sync rather than publishing unsupported completeness.ReadVaultsandReadItemsreturn healthy records and explicit issue IDs after bounded reload and retry; a vault that is listed but does not open stays an incomplete read, never "not found". An unreadable name has a UI placeholder, not an authoritative replacement name; exports carry its decryptable items in a recovery container markednameUnreadable, whichhisar importnever matches to a vault by name. Each issue carries the revision the user reviews: an item's revision, or a digest of the vault record. Recovery views show unreadable counts on every item listing, retry, name repair and explicitly confirmed deletion, each bound to that revision; renaming a vault whose name did not open is the same reviewed repair. Ordinary saves refuse a record that did not open. StrictListVaultsandListItemsrefuse partial results and acquisitions that never reached the end of a vault's changes; a name lookup in a partial inventory is an incomplete read. Favorites are pruned only when the server answered every vault and item read in full. Native credential identity replacement does not treat unreadable records as absence.- Consumers that use a record read the records that open and leave out the others, which stay issues in the app's view: AutoFill and the extension's fills, passkey sign-in, the launcher, coding agents' search, secret references and the SSH agent. Decisions that rest on a record's absence refuse a view missing a record: the extension's save offer ("new" or "changed"), passkey creation with an exclusion list, the iPhone's credential identity store, import duplicate checks, the extension's item list, the original list requests, favorites pruning and passkey signals.
- The app's view keeps each vault's read provenance with its items. A save into a vault not read yet leaves a list with no provenance, which the next reader that needs the vault reads in full. A vault whose items never synced to the end counts, for those absence decisions and for secret references by title, like one missing a record.
items.readcarries the provenance, andhisar lssays when a vault has not finished syncing. - The cache contains only records this device has downloaded. A cached vault entry does not establish that its items have finished syncing. A successful offline read or export is not proof of a complete or current server backup. See
syncVaultandexport.Collect. - The server counts as unreachable on a connection error or a 502/503/504 from the reverse proxy (
client.IsUnreachable). Then reads, unlock with the master password, Touch ID unlock and the master password check come from the cache, and the session reportsIsOffline. Any other error, such as 401 for a revoked device, is never hidden by the cache. - Offline, the app shows an "Offline" notice and refuses changes with a clear message. Edits are not queued; that would need the merge of §7.4. Background sync runs every 60 s and when the window gets focus, and stays quiet while an item is being edited.
- Auto-lock after idle time (chosen in Settings: 30 seconds, 1 minute, 5 minutes, 10 minutes by default, or 1 hour; a device preference kept across sign-out) and on screen lock/sleep. Clipboard cleared after 90 s, or when Hisar locks or the desktop app quits before then, in each case only while it still holds Hisar's copy (on Android the lock clear sees the clipboard only while Hisar is in front; the 90 s alarm still clears a copy made before a lock in the background).
- Idle means the Mac is idle. On macOS the idle time is the time since the last keyboard, mouse or trackpad input anywhere on the Mac (
CGEventSourceSecondsSinceLastEventType(kCGEventSourceStateHIDSystemState, kCGAnyInputEventType),desktop/systemidle), not only in Hisar's window. So a developer typing in a terminal or editor keeps Hisar unlocked, and the approvals they gave ("Don't ask again for 1 hour", remembered commit signing, command-line sessions) last as long as promised; a Mac nobody touches locks after the chosen time even while a script, coding agent or remembered approval keeps using Hisar. Requests (SSH agent signatures, the command-line tool, coding agents, browser fills) therefore never postpone auto-lock themselves: counting them would let unattended automation keep Hisar unlocked forever. The timer fires at the chosen time after the last reset and checks the system idle time; when the Mac saw input since, it waits for the rest of the interval and checks again. - On Linux the idle time comes from the session bus: GNOME's
org.gnome.Mutter.IdleMonitor.GetIdletime, elseorg.freedesktop.ScreenSaver.GetSessionIdleTime(KDE, Xfce and others), each call limited to 500 ms; a desktop that answers neither is not asked again for a minute. That call answers seconds, except on KDE, whose screen locker answers milliseconds; KDE is recognized by the same connection owningorg.kde.screensaver. Hisar dials its own session-bus connection (at most 2 s) and dials again once the bus drops it, so system idle comes back after a session-bus restart. - Where the system idle time is not available (a Linux desktop without either monitor, KDE on Wayland, which refuses, or a failed call), idle is measured as before: input in Hisar's window (
ReportActivity) and calls into the vault backend from the UI or the command-line tool. - Idle time counts sleep. Go's timers stop while the device sleeps (golang/go#24595), so
core/appalso keeps the moment auto-lock is due on a clock that counts sleep (CLOCK_BOOTTIMEon Linux and Android,CLOCK_MONOTONICon macOS and iOS, the wall clock elsewhere), and every status read, fill and other use of the unlocked session first locks once that moment has passed; the timer only locks promptly while the device is awake. So a phone that slept past its auto-lock time never fills through AutoFill or Credential Manager without an unlock, whatever the app's lifecycle saw. - Screen lock and sleep still lock at once (
background), whatever the idle time.
- Idle means the Mac is idle. On macOS the idle time is the time since the last keyboard, mouse or trackpad input anywhere on the Mac (
- Ships the native-messaging host for the browser extension and the
hisarCLI, which can use the app's session (§9.4). - Settings (⌘, or the gear) is a window-like dialog, about 880 × 620 points: a source list on the left with a Search settings field, the selected section on the right with its rows in rounded groups. Sections, in this order:
- General: Appearance and Color (§9.10); Open Hisar at login (§9.1.2); the version with Check for updates and Automatically check for updates (§9.1.1); Import from another password manager (§9.7).
- Security: Unlock with Touch ID, Auto-lock, the Emergency Kit with the Secret Key, Change master password… and Replace Secret Key… (§5.7).
- SSH & Git: the SSH agent's status with Use Hisar for SSH and the
~/.ssh/configsection folded under a disclosure, Remember SSH approvals (Up to 1 hour by default, or Until Hisar locks; §9.6), Compact SSH approvals, Approve requests on my smart device, and Remember commit signing (Always ask by default, or up to 10 minutes or 1 hour; §9.6). - Developer tools: the
hisarcommand-line tool (install, PATH, Let the command-line tool use Hisar, §9.4) and Coding agents (hisar mcpand the per-vault "Available to agents" switch, §9.4.2). - Browser: browsers the extension is ready in, paired browsers with Remove, Only fill on the exact host (§9.2).
- People: invitations for you (the source list shows their number as a badge), people with their security codes, invitations you sent, and Invite to Hisar in the header (§5.1), which explains that it creates a Hisar account and that vaults are shared from the vault's Share panel. Sharing a vault is Invite by email with Share in that panel (§5.8).
- Organizations: invitations to organizations (counted in the badge), your organizations, Create organization…, and each organization's members, teams, team vaults and admin actions (§5.11 Screens).
- Devices: signed-in devices with last activity, Revoke.
- Export: "Export your data" (§9.11): Encrypted export (recommended) with the export passphrase entered twice, or Plain JSON behind a warning box; Export with Touch ID… (or the master password field and Export… without Touch ID) opens a save dialog and says "Exported N items from M vaults to <path>."
- Account: name, email, server, Sign out and Delete account… (§5.7.1).
- Help: Contact support with the address
support@tryhisar.comas selectable text and Copy (one constant,desktop/frontend/src/support.ts), and Diagnostics: the Hisar version and build, the macOS version, the server URL and this Mac's device ID, with Copy diagnostics. Diagnostics never include secrets, names or other people's emails. The server does not report its version, and there is no public documentation URL yet, so neither is shown. - Search matches row titles, descriptions and keywords: the source list keeps only sections with a match, the selected section shows only its matching rows, and no match says so. ↑/↓ move between sections, ⌘F focuses the search, Esc closes. The last selected section is per-device UI state (the web view's local storage) and opens again next time.
- Favorites (§4.7): the first entry of the sidebar, above Vaults, with the number of favorites. It lists the starred items of every vault sorted by title, each with its vault's name, and searches like a vault; opening one shows it with its vault's actions (a reader's item stays read-only). Empty, it says "No favorites yet" and how to add one, and that only the user sees them. The star in the item's header (Add to Favorites, Remove from Favorites), ⌘D for the selected item and the item's context menu in any list toggle it; starred items show a small star in a vault's list. The list follows other devices live (§7.2).
- Item menu: right-clicking an item in any list opens a menu built from what applies to it: Open; Copy username, Copy password, Copy one-time code and the item's main value (card number, license key, API secret, a database's connection string, an SSH key's public key), which appear once the item's details load from the local cache, since list summaries carry no secrets; Open website (Open admin console for a server); Add to Favorites or Remove from Favorites; Share link… (once the details load, for an item with something to share), which opens the item and its share link dialog (§5.9); and, where the item's vault is writable, Edit, Duplicate (not for SSH keys), Move to… (when another vault is writable) and Delete…. Duplicate saves "GitHub copy" in the same vault with everything but its passkeys, since one passkey can't live in two items, and opens it. Move to… asks for a vault the user may add items to and moves the item there with
POST /vaults/{id}/items/{itemID}/move(§8.2): it keeps its ID, so it stays a favorite and ID-based references still find it, whilehisar://references by vault name need the new name; a server keeps its SSH key link, which shows as missing until a key from the new vault is chosen. Copying uses the item's own vault, so a favorite from another vault copies without being opened. Delete… asks "Delete “GitHub”?" (removed from all the user's devices, or from the vault for everyone with access) before deleting. Right-clicking a field row in an item's details offers that row's own buttons by name (Reveal or Hide, Copy, Copy secret reference, Open); with text selected in the row, the system menu appears instead. Menus take the arrow keys and Escape, and stay inside the window. - Item icons (§4.5): the editor's tile is a Change icon button opening a picker: Automatic (the website icon, or the initials or the category's icon), the 30 presets, a row of colors and Upload image… (PNG, JPEG, GIF or WebP, prepared by
PrepareItemIconand previewed before saving). Tiles in the list, Favorites, the detail panel and the editor draw the chosen image or preset, else a login's website icon, whichItemTileasksGetWebsiteIconfor once the tile is on screen, with at most four requests at a time and one per domain per session; until it arrives the initials stay, so the list never moves. - Vault menu (§5.10): right-clicking a vault in the sidebar, or ⋯ (Vault actions) in the item list's header, offers Rename… (owners), Delete vault… and Leave vault… (shared vaults). An action the user may not take opens a short dialog with the reason (for the Personal vault: why it can't be deleted) instead of a disabled item. Delete “Work”? lists "These people lose access:" for a shared vault and, when it has items, offers "Move its N items to [vault]" (the default) or "Delete the vault and its N items", which needs "Type Work to confirm" before Delete vault enables. Leave “Work”? says the user loses access to its items on all their devices and that an owner can invite them again. Afterwards the app selects the target vault (or Personal) and shows "Moved N items to Personal and deleted Work.", "Deleted Work." or "You left Work."
- Runs the SSH agent (§9.6) and handles Touch ID (§5.4).
- Sign in and create account: one tagline on both tabs ("Passwords, SSH keys and secrets for developers, end-to-end encrypted.") with factual bullets: sign-in shows the SSH agent (private keys never leave Hisar), the CLI (reads passwords and one-time codes and uses SSH keys through the agent) and end-to-end encryption; create account shows breach safety, the two secrets and the Emergency Kit. The Server field is behind Use a different server… (§5.3), and the account form has the strength meter (§5.2).
9.1.1 Automatic updates (macOS)
The app updates itself with Sparkle 2 through the user's own Hisar server, so the app never talks to a third party and a server's users only get builds its operator allows.
- Framework. Sparkle (pinned version and SHA-256 in
scripts/download-sparkle.sh) is downloaded from its GitHub release at build time, never committed, and embedded inHisar.app/Contents/Frameworks; the app links it with the rpath@executable_path/../Frameworks(Go build tagsparkle, set for every packaged macOS build;wails3 devand other platforms build without it and report updates as unavailable).scripts/sign-desktop-app.shsigns Sparkle's helpers, the framework and then the app, inside out, with the hardened runtime and a timestamp whenHISAR_SIGNING_IDENTITYnames an identity, ad-hoc otherwise. Releases use the team's Developer ID Application identity and are notarized: the release workflow imports the identity from the repository secretsMACOS_SIGNING_CERTIFICATE(the base64.p12) andMACOS_SIGNING_CERTIFICATE_PASSWORDinto a keychain it deletes when the job ends, andscripts/notarize-desktop.shnotarizes and staples the app (before the DMG and the Sparkle zip are made from it) and then the signed DMG with the App Store Connect API key inAPPLE_NOTARY_KEY(the base64.p8),APPLE_NOTARY_KEY_IDandAPPLE_NOTARY_ISSUER_ID. A release fails without them; a custom build without the certificate is ad-hoc and not notarized. - Trust. Every update archive is signed with an EdDSA (Ed25519) key;
SUPublicEDKeyinInfo.plistholds the public key, and withSUVerifyUpdateBeforeExtractionSparkle checks the signature before it even unpacks the archive and refuses it if it does not verify; the unpacked app must also be code signed (Sparkle allows moving from ad-hoc to a team identity, not dropping the signature). The private key never enters the repository: the release workflow reads it from the repository secretSPARKLE_PRIVATE_KEYinto a temporary file that it deletes when the job ends. The server only relays, so a compromised server can withhold updates or offer an older signed build (Sparkle never installs a lowerCFBundleVersion) but cannot ship code. - Feed.
Info.plisthas noSUFeedURL; the feed is<server>/api/v1/desktop/updates/appcast.xmlof the signed-in server, and Sparkle sends the session'sAuthorization: Bearer <token>header with the appcast request and the download. While signed out, update checks are skipped (a manual check says to sign in).SUEnableAutomaticChecksis on with a dailySUScheduledCheckInterval; Settings → General → Updates shows the version, has Check for updates and an Automatically check for updates switch, stored indesktop.json(it survives signing out). - Versions. Releases are
MAJOR.MINOR.PATCH, each part at most 999.CFBundleShortVersionStringis the version andCFBundleVersion, which Sparkle compares, is the build numberMAJOR × 1 000 000 + MINOR × 1 000 + PATCH(0.2.1 → 2001, 1.0.0 → 1000000), so it grows with every release. - Pipeline. A run of the workflow "Release" with the app
desktop(CI and releases) tagsdesktop-<version>, and its workflow "Release desktop" (GitHub-hosted macOS runner) builds the DMG and alsoHisar-<version>.zip(ditto -c -k --sequesterRsrc --keepParent), signs the zip with Sparkle'ssign_update, and writesupdate.json:{version, buildNumber, archive, edSignature, length, minimumSystemVersion, releaseNotes, releaseNotesFormat ("markdown" or "plain-text"), publishedAt}. The release notes are the tag's annotation message: the title and the changes since the previous release. The same run builds the Linux packages on amd64 and arm64 Linux runners (scripts/package-desktop-linux.sh, §9.1.3) withSHA256SUMS-linux-<arch>, and the Flatpak. None of them is inupdate.json, so the Mac update feed is unchanged. Once every build has finished, the run creates the GitHub Release of the tag as a prerelease, with all the files at once. The contract an update source reads:- Release: tag and release
desktop-<version>intosunlab/hisar, titled "Hisar <version>" ("… (custom build)" for a label). - Assets:
Hisar-<version>.dmg,Hisar-<version>.zip(the signed Sparkle archive, thearchiveinupdate.json), andupdate-unpublished.jsonuntil the version is published. - Published: approving the run's Publish job renames
update-unpublished.jsontoupdate.json, turns the prerelease into a release and marks it latest. A version is offered to apps only when its release has an asset named exactlyupdate.json; a source should also skip draft and prerelease releases and tags with a label, and must not rely on GitHub's single "latest" release, which every app's publish moves. - A published release is never rebuilt: every release is a new tag, and the run refuses a tag whose release is no longer a prerelease, so
update.jsonalways matches its zip. - Release evidence: CI tests the exact event SHA before tagging, with mandatory macOS tests for desktop. Builders use that SHA rather than the movable tag.
release-evidence.jsonbinds tests, source, workflow run, and every delivered file digest. Keyless attestations cover the final files after signing. Creation and publication verify repository, signer workflow, signer commit, source commit, and artifact bytes. Verify release artifacts gives the commands. This does not isolate an authorized builder that has been compromised. A custom build (desktop-1.2.3-label) omits the Sparkle zip and update document, keeps the DMG, Linux packages, and attested evidence, and requires the same exact-SHA tests. Its Mac build number is just below1.2.3so that a plain release updates it. It cannot be published, and servers ignore versions with a label.
- Release: tag and release
- Server. The server reads the contract above from the GitHub Releases of
HISAR_DESKTOP_UPDATE_GITHUB_REPOSITORY(defaulttosunlab/hisar) through the REST API, withHISAR_DESKTOP_UPDATE_GITHUB_TOKENas a bearer token when set (§8.4). It lists every page of releases (up to 1000), keeps thedesktop-MAJOR.MINOR.PATCHreleases that are neither draft nor prerelease and have anupdate.jsonasset, readsupdate.jsonof the newest ones, and builds an appcast of the newest 5 valid releases (newest first,sparkle:formatfor the notes), cached for 10 minutes. Each page is asked with itsETag(If-None-Match), so an unchanged list costs no rate limit, and anupdate.jsonasset is read once (a new upload gets a new asset ID). When GitHub fails the server keeps serving the cached appcast and retries after a minute; when GitHub answers 403 or 429 for its rate limit, it waits forRetry-AfterorX-RateLimit-Reset(at most an hour) before asking again. Enclosure URLs point back to the server (HISAR_PUBLIC_URL), so the download also needs a session and apps never talk to GitHub.GET /desktop/updates/{version}/{file}accepts only aMAJOR.MINOR.PATCHversion of a release in the appcast and that release's update archive (thearchiveitsupdate.jsonnames); the release's other assets are never served, since a private repository may hold files that are not for every account. It fetches the archive through the API (Accept: application/octet-stream, following the redirect to GitHub's storage) and streams it with itsContent-Lengthwithout buffering it; the token is never forwarded to another host on redirects. Both endpoints have their own per-IP rate limit.
Update notices (Linux). Sparkle is macOS only, so the Linux build uses updater.Notifier: it reads the same signed-in appcast, compares the newest sparkle:shortVersionString with the version scripts/package-desktop-linux.sh stamped (-X …/desktop/updater.stampedVersion, compared with core/appversion; a development build has none and checks nothing), and Settings → General → Updates shows "Hisar X is available" with Download, which opens the desktop-X GitHub release. It installs nothing: the user installs the new package. "Automatically check for updates" checks a minute after launch and then daily. UpdateStatus.isNotifyOnly, hasChecked, availableVersion and downloadURL carry it to the UI.
9.1.2 Staying available: open at login and the menu bar
ssh, git (push, fetch, commit signing) and hisar reach Hisar through ~/.hisar/agent.sock and ~/.hisar/cli.sock, which exist only while the app runs. An SSH client cannot start an app: with Hisar not running it fails at once (connect to agent … Connection refused, then Permission denied (publickey) or a signing error). So the app starts at login and keeps running when its window closes.
- Open at login. Settings → General → "Open Hisar at login", on by default. The first launch of a build with this switch registers the login item once and records that in
desktop.json(openAtLoginConfigured). After that Hisar registers only when the user turns the switch on; turning it off unregisters it and recordsopenAtLoginDisabled, so Hisar never registers itself again. Both are device preferences that sign-out keeps.- macOS 13 and later:
SMAppService.mainAppService(ServiceManagement through cgo). Settings shows what the system reports, not only the switch: on; "needs your approval" (Hisar was turned off in System Settings → General → Login Items) with Open Login Items (+[SMAppService openSystemSettingsLoginItems]); or not registered (removed there; turning the switch off and on registers it again). A development build that is not insideHisar.app, and macOS 12, show it as unavailable and never register. - Linux: an XDG autostart entry,
$XDG_CONFIG_HOME/autostart/com.tryhisar.hisar.desktop(~/.config/autostartby default), withExec=<the app's executable> --hidden. It counts as on while that file exists withoutHidden=trueorX-GNOME-Autostart-enabled=false; off removes the file.
- macOS 13 and later:
- Hidden launch. When the app is opened at login (macOS: the launch Apple event carries
keyAELaunchedAsLogInItem; Linux:--hidden) or byhisar(--hidden, §9.4), it opens no window. The SSH agent, the CLI socket and the browser bridge run, Hisar is locked as after every start, and the first request opens the approval window, which unlocks it (§9.6). - Closing the window keeps Hisar running. Closing the main window hides it; the sockets stay up and auto-lock works as before. The menu bar item (Wails system tray; the tower as a template image) has Open Hisar, Lock (Unlock… while locked and Sign In… while signed out, both open the window) and Quit Hisar. Clicking the Dock icon, opening Hisar.app again or, on Linux, starting the binary again shows the main window, never the hidden approval window; a second instance hands over to the running one (Wails single instance) instead of taking over the sockets.
- Dock icon. On macOS the app is an accessory app (no Dock icon, no app menu) while its main window is hidden and a regular app while it is shown; the activation policy follows every show and hide of the main window. The approval window shows without a Dock icon.
- Quitting is explicit: Hisar → Quit Hisar (⌘Q) or Quit Hisar in the menu bar item. Both stop the sockets. Sparkle's install-and-relaunch goes through the same terminate path (§9.1.1), and the relaunched app opens its window as after any normal launch.
- Linux desktops without a tray (GNOME without the AppIndicator extension) show no menu bar item; Hisar still keeps running, and starting it again shows the window.
9.1.3 Linux
The Linux app is the same Wails app on GTK 4 and WebKitGTK 6.0, for amd64 and arm64, in three forms:
.deband.rpm(scripts/package-desktop-linux.sh,desktop/build/linux/nfpm/nfpm.yaml; Ubuntu 24.04+, Debian 13+, Fedora 40+):/usr/bin/Hisar,/usr/bin/hisar,/usr/lib/hisar/hisar-browser-host, a desktop entry and its icon.- AppImage (the same script): the three binaries and an
AppRun, using the system's GTK 4 and WebKitGTK 6.0, for distributions with those but no.debor.rpm. It does not bundle WebKitGTK: WebKit starts its processes from the path it was built with, so a bundled copy would run the system's processes against its own library.appimagetool1.9.1 and the type-2 runtime 20251108 are pinned and checked by SHA-256. - Flatpak (
scripts/package-desktop-flatpak.sh,desktop/build/linux/flatpak/com.tryhisar.hisar.yml): built in the GNOME 51 SDK, run on the GNOME 51 runtime from Flathub, and released as a single-file bundle (flatpak install --user Hisar-….flatpak). Its sandbox may use~/.hisar,~/.ssh,~/.local/share/hisar,~/.local/bin,~/.config/autostartand the Chromium browsers' directories under~/.config, and talk to the Secret Service, the idle monitors, the tray watcher and notifications.
Neither an AppImage (mounted at a new path each launch) nor a Flatpak (inside its sandbox) has files the rest of the system can run, so at every start the app copies the browser host and hisar to ~/.local/share/hisar/bin with a hisar-app launcher that starts the package again (desktop/portable). The browser manifests, Settings → Command-line tool (which links ~/.local/bin/hisar to the copy), the login item (Exec=…/hisar-app --hidden), and hisar and the browser host when they start the app all use those copies. A Flatpak's login item goes into the user's own ~/.config/autostart, not its sandbox's.
CI installs the .deb and runs the AppImage, starts each hidden on Xvfb and checks that hisar and ssh-add reach it (scripts/test-desktop-linux-package.sh), and builds and installs the Flatpak in Flathub's builder image.
Works as on the Mac: the vault, the SSH agent on ~/.hisar/agent.sock with approvals, hisar through the app (peer credentials with SO_PEERCRED, terminal sessions from /proc), hisar run and hisar mcp, the browser extension in Chromium-based browsers (manifests under ~/.config; the host starts the app when it is not running), passkeys through the extension, phone approvals, open at login (XDG autostart, §9.1.2) and auto-lock on the session's idle time (§9.1, from GNOME's or the freedesktop idle monitor over D-Bus).
Differs:
- No biometric unlock.
desktop/biometrichas no Linux backend, so Hisar unlocks with the master password, and Settings says so. fprintd or polkit is a later option. - The Secret Key needs a Secret Service on the session bus (GNOME Keyring, KWallet). Without one, signing in fails with
localstate.ErrNoSecretService, which says to start one or setHISAR_SECRET_KEY. - Updates are announced, not installed (§9.1.1, "Update notices"): the user installs the new package.
- Approvals name programs, not apps. Linux has no Dock apps or code signatures, so an approval shows the process chain (
ssh ← zsh), an SSH session ortmux, without an app name or icon (§9.6). - The approval window is placed by the desktop: GTK 4 has no way for an app to move its own window (and Wayland forbids it), so
windowplacementdoes nothing on Linux. - No tray icon on GNOME without the AppIndicator extension (§9.1.2): Hisar keeps running, and starting it again shows the window.
- The Flatpak sees other programs as PID 0, in another PID namespace: it cannot read a caller's terminal session or executable. So
hisarthrough a Flatpak Hisar is approved for each connection on its own (a terminal approval does not carry over), SSH approvals show no program, and the browser socket serves a host it cannot see. Any of the user's processes outside the sandbox looks the same, so in the Flatpak the extension's pairing, which the user approves in Hisar, is the only check that the browser socket's caller is the extension; only a Flatpak Hisar accepts such a peer, and the other builds refuse one (a container, another sandbox). The.deb,.rpmand AppImage have none of these limits. The Flatpak owns no well-known bus names but its own: the tray registers with the StatusNotifierWatcher under its unique name. - Not covered: browsers installed as a Flatpak or a Snap (their sandbox cannot run a host from outside it) and Firefox (the extension has no Firefox build).
- The UI says "this computer" and "system tray" where the Mac's says "this Mac" and "menu bar".
9.2 Browser extension — TypeScript, MV3
- No crypto and no keys in the extension. It talks to the desktop app through the browser's native messaging API; the desktop app must be running (the host starts it when it is not).
- Pairing requires an unlocked Hisar at start and completion. The desktop shows a code that the user confirms in the extension. A lock, sign-out, or account change invalidates the pending attempt and hides its code. The native host manifest allows only our extension IDs.
- Autofill logins, and servers on their admin console, only when the page origin matches an item URL (below). Addresses belong to no site and are filled only on an explicit choice in the inline menu or popup (below). Never autofill automatically into cross-origin iframes. Fill on explicit user action by default.
- Detects new and changed logins and offers to save them.
- Chromium browsers first (Chrome, Brave, Edge, Arc, Vivaldi, Chromium), from one MV3 build. Firefox later from the same code; Safari needs a team-signed app (§12 #7). Distributed as an unlisted Chrome Web Store item.
Extension ID. The manifest's key holds the public key, so the ID is mmipiffoogblblocokimpemdopmdjghf both unpacked and from the store (the private key is uploaded once with the first store submission and is kept outside the repository). The native host manifest allows only chrome-extension://mmipiffoogblblocokimpemdopmdjghf/.
Chain. extension (service worker) ⇄ hisar-browser-host ⇄ Hisar desktop app:
- The browser starts
hisar-browser-host(shipped inHisar.app/Contents/MacOS/) with the caller's origin as its first argument and speaks native messaging over stdio: each message is a 32-bit length in native byte order followed by UTF-8 JSON. Hisar caps messages at 1 MiB in both directions. - The host connects to the desktop app on
~/.hisar/browser.sock(directory 0700, socket 0600) and relays frames unchanged, in the same framing. Its first frame is{"type":"connect","extensionOrigin":"chrome-extension://…/"}; the desktop refuses other origins. When the socket is missing, the host launches the app (macOS:open -g -b com.tryhisar.hisar; Linux:Hisar --hiddennext to the host, else/usr/bin/Hisar, in a session of its own) and retries for 10 s, then answers each request withdesktop-unavailable. - When the packaged host exists next to the app binary, the desktop accepts only connections whose peer process is that executable (peer PID → executable path, as the SSH agent does).
- The desktop installs the host manifest
com.tryhisar.hisar.json(type: stdio,path,allowed_origins) intoNativeMessagingHostsof every installed Chromium browser at launch, and shows the result in Settings. On Linux these are the user data directories under~/.config. Browsers installed as a Flatpak or a Snap are not covered: their sandbox cannot run a host installed outside it. Firefox is not either: the extension has no Firefox build.
Messages. Requests are {"id": "<uuid>", "type": "…", "token": "…", …parameters}; responses are {"id", "ok": true, "result": {…}} or {"id", "ok": false, "error": {"code", "message"}}. Error codes: invalid-request, not-paired, pairing-failed, signed-out, locked, offline, not-found, no-match, desktop-unavailable, update-required (§8.7), wrong-password (unlock.password), internal. Every type except hello, pair.start and pair.finish requires the pairing token.
| Type | Parameters | Result |
|---|---|---|
hello | instanceID, browser, extensionVersion | protocolVersion (1), isPaired, state, features, including save-confirmation for popup-owned credential saves. An older app announces fewer features. |
pair.start | instanceID, browser | {}; requires an unlocked app and shows a 6-digit code in the desktop window |
pair.finish | instanceID, code | token; requires the same unlocked session. The code is valid for 2 minutes and 5 attempts. |
status | — | state, email, isOffline |
unlock | — | {}; brings the desktop window forward (on the Touch ID screen when it is ready) |
unlock.password | password | state, email, isOffline; unlocks with the master password typed in the popup. wrong-password for a wrong one; after 5 wrong ones within 5 minutes from one paired browser, that browser waits out the 5 minutes (an attempt counts before its password is checked, so attempts sent at once over several connections cannot pass the limit) |
logins.match | url | logins: [{vaultID, itemID, title, username, vaultName, hasOneTimePassword}], sorted by title |
logins.fill | vaultID, itemID, url | username, password, hasOneTimePassword; the desktop checks the URL match again |
logins.code | vaultID, itemID, url | code, expiresAt (RFC 3339), period; the same URL match as logins.fill. The secret never leaves the desktop; not-found when the item has no one-time password |
logins.prepare | tabID, url, username, password | status (new, changed, unchanged), matching login metadata, and a one-use saveContext for a new or changed credential. The tab's earlier context is retired. Without tabID, update-required. Requires save-confirmation. |
logins.review | saveContext | {} if the pending candidate still belongs to the current unlocked session and pairing |
logins.check | legacy parameters | Always update-required. Old page-owned offers must not expose password equality. |
logins.save | saveContext, url, username, password, optional vaultID, itemID, title | vaultID, itemID. A new login defaults to Personal unless the user selects another writable vault. An update changes the exact reviewed item. Missing or stale confirmation is refused. |
vaults.list | — | vaults: [{vaultID, name, isPersonal}], the vaults the user may write to, the personal one first |
password.generate | optional length, digits, symbols | password |
addresses.list | — | addresses: [{vaultID, itemID, title, summary, source, vaultName}]: address items with any value, then identities with a fillable address (source address or identity), each sorted by title. summary is only the city and country; a card's billing address is not offered |
addresses.fill | vaultID, itemID | street (lines separated by \n), city, region, postalCode, country; not-found for anything addresses.list does not offer |
identities.list | — | identities: [{vaultID, itemID, title, summary, vaultName}], identities with a name, email or phone, by title; summary is only the name and email |
identities.fill | vaultID, itemID | firstName, middleName, lastName, email, phone, birthDate, and address (as addresses.fill) when it is fillable. Document numbers never leave the app |
cards.list | — | cards: [{vaultID, itemID, title, brand, last4, expiry, vaultName}], credit cards with a number, by title |
cards.fill | vaultID, itemID | cardholder, number (digits only), expiryMonth (2 digits), expiryYear (4 digits), securityCode, brand |
items.list | optional query, category, isFavoriteOnly | items: [{vaultID, itemID, category, title, subtitle, vaultName, url, isFavorite, hasOneTimePassword}] of every vault, by title; query matches what the app's search reads. Never a secret |
items.get | vaultID, itemID | The summary and fields: [{id, label, kind, value}] with kind text, secret, url, notes or one-time-code; a secret's and a code's value is left out. An SSH key's private key and passkeys are never listed |
items.reveal | vaultID, itemID, fieldID | value of that one field; for a one-time password, its current code |
items.code | vaultID, itemID | As logins.code, for any item with a one-time password, without a URL |
items.icon | url | png (base64) from the website icon cache, or nothing (§ website icons) |
items.favorite | vaultID, itemID, isFavorite | {} |
items.create | vaultID, item (category login, secure-note, credit-card or identity, title and that category's fields) | The new item's summary, as items.list gives it |
The desktop keeps paired browsers (instanceID, browser name, SHA-256 of the token, paired and last-used times) in its state file; Settings lists them with Remove. Signing out removes them.
URL matching (desktop, core/browserprotocol). Item URLs without a scheme are read as https://. A page matches an item URL when the ports match (default ports normalized) and the scheme is the same, or the item says http and the page is https. By default the hosts must share their registrable domain (eTLD+1 from the public suffix list, so accounts.google.com matches google.com, but two github.io sites do not); the Settings option "Only fill on the exact host" requires equal hosts. IP addresses and localhost always need equal hosts.
These are the rules in IsURLMatch. Registrable-domain matching includes sibling hosts even when different people operate them. Exact-host mode still permits a saved http URL on an https page under the scheme rule above, so it is not exact-origin matching. The extension's manual All logins choice is a separate user override, not a matching guarantee.
Android app links (core/item/androidapp.go, core/app/androidapps.go). A login or server can be linked to Android apps, so Android AutoFill offers it inside those apps. A link is the app's package name and the SHA-256 of its signing certificate, since a sideloaded app can take any package name. It is stored among the item's URLs as androidapp://<package>?sha256=<64 lower-case hex>: every app that predates links keeps the URL when it edits the login, and none fills it into a page, because only http and https URLs match pages. ItemDetail.URLs holds only the websites and ItemDetail.AndroidApps the links (the certificate as upper-case colon hex, as apksigner shows it); SaveItem keeps the stored links when the editor sends none, and AddAndroidApp ("Always use for this app") and RemoveAndroidApp change them. An androidapp:// URL without a valid certificate, such as a Bitwarden import, stays a website and never matches an app. ListLoginsForAndroidApp offers only logins linked to exactly that package and certificate; Hisar never guesses a site from a package name. Android's Digital Asset Links check reads ListLoginWebsites (each https-reachable host of the logins' websites and its registrable domain), fetches that host's assetlinks.json, and fills an app the site vouches for through the URL matching above. The Mac and iPhone show links read-only in the item.
Extension behaviour.
- Popup: when not paired, the pairing flow; when locked, a master password field (with an app that announces
unlock-password) and "Unlock in the Hisar app"; otherwise the vault (with an app that announcesvault; an older one gets only the logins matching the current tab) and tabs for Cards, Identities (and addresses) and the password generator (Copy, Fill into the focused field).- The vault view searches every vault (
items.list), with chips for All, Favorites, Logins, Cards, Identities and Notes. The logins matching the current tab are suggested above the rest, with Fill, Copy username and Copy password; logins filled most recently come first, in the same order the inline menu lists them and the fill shortcut takes the first of. A row opens the item: its fields, with secrets hidden until Reveal or Copy asksitems.revealfor that one field, Open website (http and https only), a favorite star, its one-time code, and Fill when it matches the tab. New item creates a login for the page (its site and a generated password filled in), a secure note, a card or an identity, in a vault the user may write to. - The arrow keys move between the search box and the rows, Enter opens a row, Ctrl or Cmd+C on a login row copies its password, and Escape goes back. The popup follows the system's light or dark appearance. A login with a one-time password also shows its current code with a countdown ring and Copy; the popup asks
logins.codeagain when the code expires. On a page with a one-time code field and no login form, Fill fills the code.
- The vault view searches every vault (
- Fill (popup, or the
fill-loginkeyboard shortcut for the first match): the service worker asks the desktop with the frame's URL and fills only the top frame and frames with the top frame's origin; it never fills a frame that is cross-origin to the page, even when the item would match. - Save confirmation belongs to the toolbar popup, never the page. A submission receives an immediate, uniform acknowledgement without waiting for password comparison. The content script renders no save offer, result event, or secret-dependent timing effect. A card security-code field is not captured as a login password.
- A new or changed credential puts a
+on the toolbar icon. Open Hisar to review the site, login, username, destination vault, and access scope. A new login has editable title and username fields and defaults to Personal. Choosing another vault explicitly shows that everyone with access can read the password. Not now discards the candidate. Never for this site persists the origin preference. - Each submission replaces the preceding candidate for that tab, including when the password is unchanged. A candidate expires after two minutes from capture, even while native preparation is pending. A worker timeout removes its raw password, authorization, and pending badge without waiting for popup access or navigation. A matching one-shot browser alarm wakes a suspended worker. Timers and alarms identify the capture generation, so an old expiry cannot remove a replacement. Worker startup removes orphaned pending badges and owned save alarms before processing candidates or badge updates. No candidate or password is serialized for restart recovery. A candidate can follow one immediate same-origin submission navigation within ten seconds, and keeps its badge through it. A later navigation, a different-origin navigation, tab closure, or worker restart discards it. Popup review and synchronous native dispatch still check the absolute deadline because browser scheduling can be delayed. A popup review that cannot reach the app (
desktop-unavailable: a timeout, a disconnect, Hisar not running) keeps the candidate until that deadline; any answer from the app that refuses it discards it. Page content and embedded extension frames cannot inspect or authorize pending saves. - The worker checks the source frame and document against browser-supplied runtime metadata and
webNavigation.getFrame. A late submission from an ended document cannot start a fresh candidate after navigation. Navigation and tab-closure events wait behind submissions for the startup cleanup, in the order the browser delivered them, so a woken worker still binds a submission to the navigation that follows it. - The app keeps at most 128 expiring, one-use save contexts in memory, at most one per tab of each paired browser:
logins.preparenames its tab, and the tab's next one retires the earlier context, so a page that submits again and again cannot fill the app's room for other tabs and browsers. A context binds the pairing token, URL, password, update username and destination, and session epoch. Its consumption authorizes only that candidate. The app rechecks the live browser pairing and session epoch under the mutex used to select the mutation's session. Lock, account change, unpairing, and re-pairing prevent stale writes. A failed save requires a fresh submission and explicit confirmation. No automatic retry uses a consumed context.
- A new or changed credential puts a
- Toolbar and menus: the toolbar icon otherwise counts saved logins for the tab, only over an open native connection. A count that answers after a later badge write for the tab, such as a new
+, is dropped. Switching tabs never starts Hisar. The right-click menu fills logins or suggested passwords and opens the inline menu. Ctrl+Shift+Space or Cmd+Shift+Space opens the popup. - Permissions:
nativeMessaging,storage,scripting,activeTab,tabs,webNavigationfor candidate invalidation,alarmsfor save-expiry wakeups,contextMenus,offscreen,clipboardWrite, and<all_urls>host access. Persisted extension data contains the instance ID, pairing token, never list, inline menu settings, last announced app features, and the latest 200 last-filled item IDs and times. Save alarm names contain only a tab ID and opaque capture generation. Candidate passwords and native save authorizations stay in worker memory, not extension storage. - Popup requests, including save review and authorization, require the exact extension-owned top-level
popup.html. They are refused from content scripts and the web-accessible inline-menu and passkey-prompt frames. The native app trusts the paired extension to enforce this UI boundary. The master password typed in the popup crosses native messaging to the app, which derives the unlock keys and clears its bytes. - Cards and identities: like addresses, they belong to no website, so the extension asks for one only when the user picks it in the inline menu or the popup, after the same visibility check, and fills only shown, enabled, empty fields of the page's own frames. A card form inside a payment provider's cross-origin frame (such as Stripe's) is therefore not filled.
- Clipboard: the popup copies with
navigator.clipboard.writeText, which a focused extension page may do without a clipboard permission. A service worker has no clipboard, so the copy after a fill goes through an offscreen document (offscreen.html, reasonCLIPBOARD) that copies withexecCommand("copy"); that needsclipboardWrite, because no user gesture reaches the offscreen document. The offscreen page accepts copy messages only from the extension itself without a tab, so content scripts cannot use it. The extension does not clear the clipboard afterwards (the desktop app's own 90 s clearing of what it copies is unchanged). - Frame rule in practice: a child frame counts as having the top frame's origin only when every frame between it and the top has that origin too (
location.ancestorOrigins). The content script checks this again before filling. - The service worker keeps one native port and reconnects on the next request after a disconnect, not on a timer, because each connection starts the host, and the host would relaunch Hisar after the user quit it.
- Inline menu: when a username or password field of a login form gets the focus, the content script puts a small Hisar icon inside the field's right edge, and opens a menu under the field on focus (setting "Open the menu when a login field is focused") or on a click on the icon or the down arrow.
- Where it appears: only in frames that the frame rule could fill, never on hidden, tiny, read-only or disabled fields, and never on Hisar's own elements.
- What it shows: up to 6 matching logins (title, username) and "More in Hisar…", which opens the popup. On the new-password fields of sign-up and change-password forms it shows "Use suggested password" (
password.generate), which fills every new-password field. Otherwise it shows "Pair with Hisar", "Unlock Hisar" (unlock, then polls), "Open Hisar", or "No logins for this site". - The menu is an extension page (
inline-menu.html) in an iframe inside a closed shadow root. Its URL comes from the service worker with the dynamic ID (web_accessible_resourceswithuse_dynamic_url). Login titles and usernames therefore never enter the page's DOM. - The menu page talks to the service worker directly, under a per-field session that the first menu frame claims. The content script only positions the icon and the frame (fixed position, updated every animation frame, highest z-index) and fills the field the menu belongs to when the service worker asks. Fills take the same path as the popup's: the desktop checks the URL again, and the frame rule applies.
- Clickjacking: before it asks the desktop for a password, the service worker asks the content script whether the menu has been visible and uncovered for 300 ms. The content script checks this on every frame: opacity, visibility, display and filters of the frame and its ancestors, its size and position, and
elementFromPointat the centre and corners. The menu page also ignores untrusted clicks and, where IntersectionObserver v2 exists, clicks before it has reportedisVisiblefor 300 ms. That second check catches overlays withpointer-events: none. - Keyboard: the content script passes trusted Arrow and Enter key presses to the menu through the service worker; Enter is taken only after an arrow key selected an entry. Escape, typing in the field, a click outside, losing focus, the field leaving the viewport and
pagehideclose the menu. - One-time codes: a field gets the menu with "one-time code" purpose when it has
autocomplete="one-time-code", or its name, id, placeholder, aria-label, title or label says otp, totp, 2fa, mfa, one-time, two-factor, 2-step, authenticator, or an authentication, verification, security, SMS or login code (a bare "code", "token" or "pin" counts only with a maxlength of 4–10 or a numeric input mode). Coupon, promo, postal, phone, card and captcha fields never count. Four to eight visible one-character inputs (maxlength=1) close together with no other field among them are one split code when there are six of them, or they are numeric, or one of them is marked as a code; the icon then sits beside the last box and the menu spans the boxes. The menu lists only the matching logins withhasOneTimePassword; choosing one askslogins.code(after the same visibility check as passwords) and fills the field, or types one digit into each box, with input and change events. A code longer than the boxes is not filled. - After any fill of a login with
hasOneTimePassword(popup, shortcut or inline menu), the service worker askslogins.codeand copies the code to the clipboard, as 1Password does, so it can be pasted on the next page. A failed copy never fails the fill. - Addresses: a text field gets the menu with "address" purpose when it is not part of a login form and belongs to an address form: a form (or form-less container) with a field whose
autocompletenames an address part (street-address,address-line1–3,address-level1and2,postal-code,country,country-name), or two kinds of address field found by their name, id, placeholder, aria-label or label (street, apartment, city, state or province, postal or ZIP code, country, in English and a few other languages; email, phone, company, search, coupon and web address fields never count). The menu lists up to 6 addresses fromaddresses.listwith "More in Hisar…"; choosing one asksaddresses.fillafter the same visibility check as passwords and fills the form. The popup's Addresses tab lists them too, and its Fill looks for an address form (the focused one, else the top frame's, else a same-origin frame's) before it asks for the address, so a page without one never receives it. A fill touches only the address fields of the anchored field's group (anautocompletesection-*,shippingorbillingtoken, or "billing" or "shipping" in the field's hints), only fields that are visible (not hidden, transparent or moved off the page, since a fill writes every empty field of the group and not only the chosen one), enabled, writable and empty, and lists whose option matches. The street goes whole into astreet-addressfield (its lines kept in a textarea, joined with commas in an input), or a line into eachaddress-linefield with the rest in the last one; a country list matches the saved code or name in English or the browser's language (Intl.DisplayNames), and a region list its value or text. There is no URL check: a paired extension can read any address, the same trust it already has withlogins.fill. - Cards: a field of a payment form (one with a card number field, by
autocompletecc-numberor a hint such as "card number") gets the menu with "card" purpose, before it is read as a login, since a security code may be a password field. It lists up to 6 cards fromcards.list(brand, last four digits, expiry); choosing one askscards.filland fills the number (into one field, or across split boxes), the name, the expiry (whole, as the field's placeholder or length asks, or month and year as fields or lists) and the security code. - Identities: a name, email, phone or birth date field (by
autocomplete, input type or hints, never a username, company or card holder) gets the menu with "identity" purpose when it is not a login field and belongs to a form with personal details (two kinds of such field, or one named byautocomplete), on a checkout or a sign-up form's non-password fields. Choosing one of up to 6 identities fromidentities.listasksidentities.filland fills those fields and the address form beside them. - Settings in the popup: "Show Hisar in login fields" and "Open the menu when a login field is focused", both on by default and kept in
chrome.storage.local.
9.3 Mobile — gomobile + native UI
gomobile bindcompilescore/into an iOS.xcframeworkand an Android.aar.- Native UI: SwiftUI and Kotlin/Jetpack Compose.
- System autofill: the iOS AutoFill Credential Provider extension, a second process that shares the app's state and encrypted cache through the app group; Android's
AutofillService, which runs in the app's own process on the app's backend. - Memory constraint: the iOS AutoFill extension must stay well below the system limit (Argon2 64 MiB).
iOS (Phase 4, first). mobile/hisarkit wraps the app backend (core/app, shared with the Mac app, which has no Wails import and compiles for ios/arm64), so the phone gets the same sign-in, offline cache, matching and sharing logic. make build-ios-framework runs gomobile bind -target=ios,iossimulator -iosversion=26.0 and writes mobile/build/HisarKit.xcframework (Swift module HisarKit, classes prefixed Hisarkit).
- gomobile passes only strings, numbers,
[]byteand interfaces, so lists and items cross as JSON strings with the desktop's types (vault.Status,VaultSummary,ItemDetail,browser.Login). HisarkitNew(stateDirectory, deviceName, secretKeyStore, biometric, clipboard, listener)keepshisar.jsonandhisar-cache.dbinstateDirectory, the app group container, so the app and the AutoFill extension share the state and the encrypted cache.- Methods:
Status,SignIn,SignUp(returns the status and the new Secret Key),Unlock,UnlockWithBiometric,EnableBiometricUnlock,DisableBiometricUnlock,Lock,SignOut,ReportActivity,ListAllVault,ListAllFavoriteItemandSetItemFavorite(§4.7),ListAllItem(no secrets),GetItem,SaveItem(item JSON in, item JSON out),DeleteItem,CopyField,GetOneTimeCode,GeneratePassword,ListAllLoginForService,GetLoginForService,GetOneTimeCodeForService, for sharing (§5.8)CreateVault(always a shared vault),ListAllPerson(the people the user shares a vault with),InvitePerson(the server emails a sign-up invite),ListAllVaultMember,ShareVault(sends a share invitation),ListAllShareInvitation(completes accepted shares first, as the desktop's sync does),AcceptShareInvitation,DeclineShareInvitation,CancelShareInvitation,ChangeVaultMemberRole,RemoveVaultMember(rotates the vault key, as on the desktop),GetVaultSharing(one shared vault's sharing screen: the vault, whether the user owns it, its members with role and security code, its sent invitations, and the completion errors for its accepted invitations; completes accepted shares first and refuses a personal vault), for passkeys (§9.8)ListAllPasskeyForRelyingParty,PreparePasskeyRegistration,CreatePasskey,AssertPasskeyandDeletePasskey, and for SettingsSetAutoLock(30, 60, 300, 600 or 3600 seconds, the desktop's choices, stored asautoLockSecondsinhisar.json;Statusreports it),ListAllDeviceandRevokeDevice(another device; this one signs out withSignOut),ExportEmergencyKit(password, directory)(checks the master password like an unlock, writesHisar Emergency Kit.pdfintodirectoryand returns the Secret Key and the file's path),CopySecretReference(hisar://<vault>/<item>/<field>, §9.4.1),IsMacApprovalAllowed/SetMacApprovalAllowed, for vaults (§5.10)GetVaultActions,RenameVault,DeleteVault(aVaultDeletionRequestas JSON),LeaveVaultandListRemovedVaults, for organizations (§5.11)ListAllOrganization(organizations and invitations),GetOrganization,CreateOrganization,RenameOrganization,DeleteOrganization,InviteToOrganization,CancelOrganizationInvitation,AcceptOrganizationInvitation,DeclineOrganizationInvitation,SetOrganizationMemberRole,RemoveOrganizationMember,LeaveOrganization,CreateTeam,RenameTeam,DeleteTeam,AddTeamMember,RemoveTeamMember,GiveVaultToTeam,TakeVaultFromTeam,CreateOrganizationVaultandListAllWaitingVaultKey(delivers pending vault keys and rotates pending vaults first), and for export (§9.11)GetExportOptionsandExportAllData(format, passphrase, password, directory)(Face ID first, or the master password when one is given; writes the file intodirectoryand returns its path). Item summaries and logins carryhasOneTimePassword, never the secret. - Phone approvals (§9.9):
ListAllPendingApproval(the open, unexpired requests of other devices, opened and checked, as JSON),ApproveApproval(requestID, isRemembered)(asksBiometric.Authenticatefirst),ApproveApprovalWithPassword(requestID, password, isRemembered)(checks the master password instead) andDenyApproval;isRememberedis the sheet's switch. - Interfaces implemented in Swift:
SecretKeyStore: the Secret Key in the Keychain, in a keychain access group shared with the extension.Biometric: Face ID or Touch ID.StoreKeywrites the biometric unlock key to a Keychain item withSecAccessControl.biometryCurrentSetandkSecAttrAccessibleWhenUnlockedThisDeviceOnly.Authenticateevaluates anLAContextthatLoadKeypasses askSecUseAuthenticationContext, so the user sees one Face ID prompt.Clipboard:UIPasteboard.EventListener: auto-lock notification,OnApprovalEvent(name, requestID)for the approval events of the event stream (§9.9), andOnRemoteChange()after the backend synced avault-changed,vaults-changed,account-preferences-changedorshare-invitations-changedevent (the desktop'sOnRemoteChange), so the app reloads its vaults, the received invitations and an open sharing sheet.
- Biometrics after launch: because iOS enforces the biometric check in the Keychain itself,
IsBiometricAllowedAfterLaunchis on and Face ID can unlock after a launch without the master password first. The 14-day password interval still applies. Auto-lock defaults to 5 minutes; Settings → Security changes it. - Android: Go there starts in UTC and reads neither every system root nor the CAs the user installed. So the app calls
SetTimeZonewith the device's IANA zone beforeNew(a zone change applies at the next launch), thenSetTrustedCertificateswith everyAndroidCAStorecertificate, system and user, as PEM (Go then trusts exactly those for the server and website icons), andSetBiometricKeyPreparer. The biometric key is a per-use AES-GCM Keystore key:PrepareStoreKeyorPrepareLoadKeybuilds its cipher, theAuthenticatethat follows puts that cipher in theBiometricPrompt'sCryptoObject, and theStoreKeyorLoadKeyafter it uses it, so each operation shows one prompt. After a master password or Secret Key change, Go shows that prompt once more to keep biometric unlock on; the iOS and Mac keychains store the new key without one. - One-time codes:
GetOneTimeCodeForServicechecks the match asGetLoginForServicedoes and returns only the code, expiry and period. When a password fill (list, QuickType or without user interaction) is for a login withhasOneTimePassword, the extension copies the current code to the pasteboard (localOnly, 90 s expiry) before completing the request. - AutoFill: the extension receives
ASCredentialServiceIdentifiers (a domain or a URL; bare domains are read ashttps://).ListAllLoginForServiceuses the same matching as the browser extension (§9.2), andGetLoginForServicechecks the match again before returning the password. Passkey requests carry a relying party ID instead, and only passkeys with exactly that RP ID are offered (§9.8). - Signing: until the Apple Developer Program membership (§12 #7), the app runs on the developer's own iPhone with a free Apple ID (re-signed every 7 days). App groups and the AutoFill extension need the paid membership on a device, so the extension is developed and tested in the simulator until then.
iOS app (mobile/ios).
- Project: XcodeGen (
project.yml, pinned 2.46.0) generatesHisar.xcodeproj, which is committed so Xcode opens it without XcodeGen; CI fails when the two differ. The team and the bundle identifier prefix live inConfiguration/Signing.xcconfig(or an ignoredLocal.xcconfig). The extension (.autofill), the app group (group.…) and the keychain group ($(AppIdentifierPrefix)….shared) are derived from the prefix and reach the code throughInfo.plistkeys. - Targets:
Hisar(app with the extension) andHisarAutoFillshareShared/as sources rather than a framework. HisarKit is a static framework, so each binary links its own copy of Go.HisarPersonal(scheme "Hisar Personal Team") is the same app for a free personal team: no extension, empty app group and keychain group, and its own bundle identifier (….personal) so the free team never registers the real one. With no app group the state goes to the app's Application Support folder, and Keychain items get no access group. - Threading: Swift wraps
HisarkitHisarbehind aVaultBackendprotocol. View models call it on a background queue, because Argon2 and network I/O block. Tests replace it with a fake. - Keychain: the Secret Key is
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnlyin the shared access group. The Face ID key is.biometryCurrentSet+WhenUnlockedThisDeviceOnly;Authenticatekeeps the evaluatedLAContextandLoadKeypasses it askSecUseAuthenticationContext, so the user sees one prompt. The state directory is excluded from backups. - Clipboard: copies are
localOnlywith a 90 sexpirationDate. Go's own 90 s clearing asksGetText, which answers fromchangeCountinstead of reading the pasteboard, so iOS never shows the paste banner for another app's text. - Lifecycle: going to the background records the time; returning after the auto-lock interval (Settings → Security, 5 minutes by default) locks. While the scene is inactive, a separate window at alert level covers everything, including sheets, so the app switcher snapshot shows only the logo. The lock screen asks for Face ID once per lock when it is ready.
- AutoFill: after every full sync and every save or delete, the app replaces the
ASCredentialIdentityStoreidentities: host + username for each login or server with a URL (a server's is its admin console), with record identifiervaultID/itemID, anASOneTimeCodeCredentialIdentity(label: the username, or the title) for each such login with a one-time password, and anASPasskeyCredentialIdentity(RP ID, user name, credential ID, user handle) for each passkey in a login. The extension declaresProvidesPasswords,ProvidesPasskeysandProvidesOneTimeCodes;prepareOneTimeCodeCredentialListlists only matching logins with a code, and one-time code requests complete withASOneTimeCodeCredentialand never hand out the password. Without user interaction the extension answers only when its process is still unlocked; otherwise it asks for interaction and unlocks with Face ID or the master password. The list shows the logins matching each service identifier; search also covers every other login, which is filled only on an explicit tap.hisarkitcallsdebug.FreeOSMemoryafter every key derivation, so the extension drops back from about 126 MB at the Argon2 peak to about 68 MB once unlocked (simulator debug build). - Deployment target: iOS 26.0 (Xcode 27, iOS 27 SDK), so the app uses the iOS 26 design directly and has no fallback UI for older systems.
- Interface: system components only; the app draws no chrome of its own. Light and dark follow the system, text uses Dynamic Type styles, every icon is an SF Symbol with an accessibility label, and the only custom colour is Hisar's accent (
AccentColor: graphite in light mode, the desktop's#eceef1in dark), set as the asset catalog's global accent colour.- A
TabViewwith the Liquid Glass tab bar: Vaults, Approvals (badge: the number of waiting Mac requests) and Settings, plus a search tab (Tab(role: .search)) that searches every item. Each tab is aNavigationStackwith a large title and the Liquid Glass navigation bar; actions are.toolbaritems andMenus. - Vaults: an
.insetGroupedList. Waiting Mac requests come first as a card (the Mac's name and a live countdown, the request's sentence, the key, Deny and Approve, and "N more requests" opening the Approvals tab); then, as on the Mac, a banner for each share invitation the user received ("Ada shared a vault with you as editor", the inviter's email, Decline and Accept; after accepting, the vault appears once the owner's Hisar completes the share); then Favorites (§4.7), the starred items of every vault with each one's vault name (swipe to remove; empty, "Star an item to keep it here, on all your devices. Only you see your favorites."), then the vaults with their item counts, and By type (Logins, SSH keys) across all vaults. The star in an item's toolbar and a leading swipe on a vault's item rows add or remove a favorite. Toolbar: Lock and a + menu (New login, New vault). Pull to refresh syncs, which also completes the shares people accepted and reloads the received invitations. - Approvals: the waiting requests with a live countdown; tapping one opens the approval sheet, swiping offers Deny. Empty, a
ContentUnavailableViewsays that requests from Hisar on the Mac appear here while Hisar is open and unlocked on this iPhone. - Approval sheet: a system sheet (medium and large detents) with the request's grouped
LabeledContentrows, the forwarded-request warning, the encryption note, and Approve with Face ID and Deny in the bottom toolbar; the master password field appears in the sheet when Face ID is unavailable. - Vault: the items, searchable, with + (writable vaults) in the toolbar. A shared vault has Share (owners) or Members (editors and readers) in the toolbar; a personal vault's Share explains in an alert that personal vaults are private and a shared vault is needed, the Mac's rule. A ⋯ menu has Rename…, Delete vault… and Leave vault… (§5.10); an action the user may not take shows its reason in an alert. Rename is an alert with a text field. Delete is a sheet: "These people lose access" for a shared vault, then "Move N items to another vault" (the first writable vault by default, with a Move to picker) or "Delete the vault and its N items" with "Type Work to confirm", and the destructive Delete vault; an empty vault needs one confirmation. Leave is a
confirmationDialog. When the open vault disappears, the screen goes back to the list. - Removed vaults: after every refresh the Vaults list shows, at the top, a dismissible row for each vault that disappeared ("“Work” is no longer available: it was deleted, or you no longer have access to it.").
- Sharing sheet (
GetVaultSharing, medium and large detents): for owners, Invite by email (email field, a rolePickerwith the Mac's roles Owner, Editor and Reader, and Send invitation; every address gets the same "Invitation sent", whether or not it has an account); a warning row when an accepted invitation could not be completed (e.g. "the signing key of … changed since you last shared with them; stopping"); Members, owners first, each with role and security code, and a key-changed warning; Invited, this vault's sent invitations ("Waiting for them to accept" or "Accepted, finishing on your next sync"), each with Cancel behind aconfirmationDialog. Tapping a member opens their detail: the security code to compare (withShareLinkfor the user's own code), and for owners a rolePickerand Remove from vault behind aconfirmationDialogthat says the vault is re-encrypted with a new key. Editors and readers see the members and codes only. The sheet reloads onOnRemoteChange, so a person who accepts appears without a refresh. - Completing shares: the iPhone runs the same Go code as the Mac (
session.CompleteAcceptedSharesthroughcore/app): on every sync, everyshare-invitations-changedevent, pull to refresh, the People screen and the sharing sheet, it verifies each accepted invitation's owner signature and recipient's signed keys, pins their signing key and refuses on a changed key. It wraps the vault key only for a recipient the owner's signed intent already names; any other accepted recipient waits for Review recipient (§5.8). Only an unlocked app does this; a locked iPhone leaves the share to the owner's next unlocked device. - Item icons (§4.5): the editor's icon opens a picker sheet with Automatic, the presets, the colors and Choose photo (
PhotosPicker). Library photos are often HEIC, which core does not decode, soAppModel.convertPhotoForIcondecodes the photo with UIKit, applies its orientation, scales it to at most 512 points and hands HisarKit'sPrepareItemIcona JPEG, or a PNG when it has transparency; a photo UIKit can't read shows an error. Lists and details draw the chosen icon, else a login's website icon, whichAppModelasks HisarKit for once per domain off the main thread and forgets on lock. Presets are SF Symbols (ChosenIcon.symbolByPreset) in system colors. - Item detail: grouped rows for username, password (reveal, copy), one-time code (with a progress ring), custom fields, websites, passkeys and notes. Copying plays a success haptic and shows the 90-second note; each copyable row's context menu also has Copy secret reference, and a quiet last row copies the password's reference.
- Settings (what of the Mac's settings makes sense on a phone): Account (name, email, server; Export data…, §9.11; Delete account with the preview, master password and typed email, §5.7.1; Secret Key and Emergency Kit, which asks for the master password, then shows the Secret Key and offers the Emergency Kit PDF through
ShareLink; the file is written to the app's temporary folder and deleted when the screen closes), Security (Face ID, Auto-lock picker 30 s · 1 min · 5 min · 10 min · 1 h, Lock now, the note that copied values clear after 90 seconds, Change master password and Replace Secret Key, which shows the new Secret Key and shares the new Emergency Kit PDF like the Account screen, §5.7), Mac approvals (the switch "Approve requests from my Mac", on by default, and the note that approving answers one request and never unlocks the Mac), People (invitations for you with a badge, invitations you sent, Invite to Hisar, which creates an account, and each person with their security code and a "key changed" warning), Devices (each signed-in device with platform and last activity, "This iPhone" first; another device can be signed out after a confirmation), AutoFill, Password generator, Help (Contact support with the address and Copy, Copy diagnostics with the app and iOS versions, the server URL and this iPhone's device ID), About, and Sign out. The SSH agent, command-line tool, browser extension, agent vault access, open at login, updates and import stay on the Mac. - Organizations (a Settings row): the same list, organization screen, invitations, banners, grouping, new-vault Where and organization-vault sharing sheet as the Mac (§5.11 Screens), as
Lists withNavigationLinks, sheets andconfirmationDialogs. - Sign in, create account and unlock:
Forms with the same copy as before (the Emergency Kit wording included), the Server field behind Use a different server… (§5.3), and after the new Secret Key the "Unlock with Face ID?" offer (§5.2); the lock screen shows the mark, the account's email, Unlock with Face ID and the master password field, and above them the accepted shares waiting for this unlock (§5.8).
- A
- Simulator limits: a build without a team is registered as a provider but iOS does not offer it, so UI tests drive the extension's screens through a debug-only preview in the app. Face ID is tested in the simulator by posting the biometric daemon's enrollment and match notifications.
Android app (mobile/android).
- Project: Gradle with two modules.
:modelis plain JVM with no Android dependency: HisarKit's JSON types,VaultBackend, the app model (a port of the iPhone'sAppModel, split into one model per area) and AutoFill's parser and matcher, so their tests run on the JVM against a fake backend.:appholds the Compose screens, the platform adapters and the AutoFill service.make build-android-aarrunsgomobile bind -target=android/arm64,android/amd64 -androidapi 29 -javapkg com.tryhisarand writesmobile/build/HisarKit.aar(Java classescom.tryhisar.hisarkit.HisarkitandHisar). The script refuses alibgojni.sowhose LOAD segments are not aligned to 16 KB, which devices with 16 KB pages require. The package iscom.tryhisar.hisar. - Versions and ABIs: Android 10 (API 29) and later, the same floor as Bitwarden and Dashlane; Android 10 is also the first version that hides the clipboard from apps in the background. The AAR has arm64-v8a and x86_64 (emulators). 32-bit ARM is out:
github.com/bytemare/ksfv0.5.0, whichbytemare/opaquebrings in, passesmath.MaxUint32to a%dformat inargon2id/argon2.go, and that untyped constant overflowsinton 32-bit targets, so the build fails there. - One process (D2):
HisarApplicationbuilds the process's one HisarKit backend on a background thread when the process starts. The app, the AutoFill service and its activity all use it, so unlocking either unlocks both, andhisar.jsonhas one writer and the account one event stream. The iPhone's extension is a second Go runtime with its own unlock instead. Fills do not postpone auto-lock, because login matching usesrequireUnlockedWithoutActivity.HisarKitBackendrefuses to run on the main thread, since Go blocks on the network, Argon2 and biometric prompts. The HisarKit calls that only Android makes are under Android above. - State and keys:
hisar.jsonand the cache are innoBackupFilesDir/Hisar(mode 0700). The Secret Key is a file innoBackupFilesDir, sealed with AES-GCM under one AES-256 Android Keystore key that needs no authentication (StrongBox when the device has it), with the account ID as AAD: readable after the first unlock and never leaving the device, as the iPhone's keychain item. Nothing is backed up:allowBackup="false", anddata_extraction_rules.xmlandbackup_rules.xmlexclude every domain from cloud backups and device transfers. A new device signs in with the Secret Key and master password. - Biometric unlock (D4): one per-use AES-256-GCM Keystore key per account, which needs a strong biometric for every use (
setUserAuthenticationRequired, and from API 30setUserAuthenticationParameters(0, AUTH_BIOMETRIC_STRONG)) and which a new enrollment destroys (setInvalidatedByBiometricEnrollment). ThroughSetBiometricKeyPreparer, each store and each load shows oneBiometricPromptwith the cipher in itsCryptoObject; the prompt's other button is "Use master password". After a new fingerprint,PrepareLoadKeyfails before any prompt, and Go turns biometric unlock off ("biometric unlock was turned off, unlock with your master password and turn it on again"). Changing the master password or replacing the Secret Key asks for one fingerprint to keep biometric unlock on, since a per-use key cannot be rewritten without a prompt. Approvals and export show a plain strong prompt with no key. - Clipboard (D5): a secret copy is marked sensitive (
android.content.extra.IS_SENSITIVE), so Android 13 and later show dots in their copy preview. Android has no expiry date for a clip and lets an app read the clipboard only while it has focus, so three things clear a copy: Go's 90 s timer while the process runs; an inexactAlarmManager.setAndAllowWhileIdlealarm at copy + 90 s, which also runs in a new process after Hisar was closed; and Hisar's window getting focus after that time. Hisar records the clip's timestamp, never its text. Because Android hides the clipboard from an app in the background, the alarm cannot see whether the clip is still Hisar's and clears whatever is there, including a newer copy another app made. In front, Hisar keeps a newer copy from another app. Measured on emulators: the alarm cleared at 133 to 158 s on API 37 and at 90 s on API 29, and it cleared another app's later copy on API 37. A WorkManager job at 90 s waited until the app was opened again on API 37, so Hisar does not use one. A force-stop cancels the alarm, so the copy then stays until the next time Hisar opens: on API 37,am force-stopremoved the alarm (pi_cancelledindumpsys alarm), the copy was still recorded 196 s after it was made, and opening Hisar cleared it. The copy notice therefore says "It clears in about 2 minutes" (copy glossary). Copies that are not secret, such as a secret reference or a website, are not marked and never cleared. The secure note body is not selectable, because the selection toolbar's Copy would write a plain clip that is never cleared; Copy notes is the way to copy it. - Screenshots and the app switcher (D6): Settings → Security → Allow screenshots, off by default in release builds and on in debug builds. Off, Hisar's windows are secure (
FLAG_SECURE), so screenshots, screen recordings and the app switcher show nothing of Hisar. From API 33 the app switcher never shows Hisar's content (setRecentsScreenshotEnabled(false)), and while the activity is not in front a cover with the logo hides the screen, as on the iPhone. - Network: release builds sign in and create accounts only over
https://: the sign-in model refuses anhttp://server or invite link before Go is asked ("This server's address starts with http://, so the connection would not be encrypted. Hisar signs in only over https://."). Debug builds accepthttp://, for a server on the developer's machine. This differs from the iPhone, which allowshttpto local addresses. Go trusts every certificate inAndroidCAStore, the user-installed CAs included, so a server behind a company's own CA works (D11). A CA the user installed can read the traffic to the server, but vault contents stay end-to-end encrypted and OPAQUE keeps the master password from the server. The network security config, which governs only Kotlin's own requests (Digital Asset Links), trusts the same CAs and allows cleartext only in debug builds. Client certificates are not supported, as on the iPhone. - Lifecycle:
AppViewModelfollows the whole process (ProcessLifecycleOwner): going to the background records the time, and returning after the auto-lock interval locks, as on the iPhone. The minimum version is checked at launch and on returning (§8.7). - AutoFill (D3, D10, D15):
HisarAutofillServicereads the form from the request'sAssistStructure(FillRequestParser): autofill hints first, then the HTML attributes, the input type and words in the view ID. On a web page only the fields under the node that carrieswebDomaincount. Each field belongs to the page (scheme and host) of the nearest node at or above it that carrieswebDomain: the input itself on WebView 145 and Chrome, the form on WebView 91 and 113. A browser can put several pages in one form, such as a bank's frame inside another site, so Hisar fills only the fields of one page, the page of the focused field (FillContext.getFocusedId), or the only page there is; a form spanning several pages with no focused field among them gets nothing. The datasets, the save info and the unlock target name only that page's fields. A page address is trusted only from a browser whose package name and signing certificate's SHA-256 are both on the privileged-browser allowlist the passkey provider uses (assets/privileged_browsers.json, read once byPrivilegedBrowserList). Any other app that reports a web domain, including one that takes a browser's package name with another certificate, is treated as that app (D10), so an app cannot get a site's logins by naming the site. Browsers that need compatibility mode are listed inres/xml/hisar_autofill_service.xml. Tor Browser, Opera GX, Ecosia and Kiwi are not on the allowlist, so Hisar fills them as apps: Search Hisar, the logins linked to them and those of sites that vouch for them, never a page's logins by its address. Chrome asks Hisar only after the user picks Autofill using another service in Chrome; Settings → AutoFill opens that page.- Locked (D3): a single "Unlock Hisar" suggestion ("Sign in to Hisar" when signed out), which opens the unlock screen and then fills. Android has no credential identity store, and Hisar writes no plaintext index of its own, so a locked Hisar shows no usernames. Unlocked, there is one suggestion per matching login with its username, plus "Search Hisar". Every suggestion carries its own authentication, so a password leaves Hisar only for the login the user picks, after Go checks the match again (
GetLoginForService,GetLoginForAndroidApp). From API 30 suggestions also appear in the keyboard. An AutoFill or passkey screen that is open when Hisar locks, auto-lock included, shows the lock screen and forgets the logins, passkeys, search, passkey-saving choices and user verification it held. A lock invalidates pending status and unlock results, credential reads, automatic code copies and sheet responses, so their pre-lock results cannot publish after it. Provider windows follow Allow screenshots as it changes, as the app's does. Provider work carries its lock generation through coroutine context. Password, code, assertion and unlock-completion callbacks check that generation at delivery. A confirmed passkey registration is different. If Go committed it before a lock, Hisar still delivers the public registration response so the site can use the stored passkey. - Native apps (D10): Hisar identifies the app by package name and the SHA-256 of its signing certificate (apps with several signers can search and fill, but are never linked). It offers first the logins linked to the app (Android app links, §9.2), then the logins of sites that vouch for the app in their Digital Asset Links. Hisar asks only the sites whose registrable domain's name is a segment of the package name (github.com for
com.github.android), because asking a site tells it that the vault has a login for it, registrable domains before other hosts. Password matching and native passkey checks fetchhttps://<site>/.well-known/assetlinks.jsonthrough the sameAssetLinksReader, never through Google's API. The reader has 8 workers, at most 64 queued reads, and one outstanding read per address. It keeps at most 128 definitive answers in a least-recently-used cache for up to one hour, evicting expired entries on access. Transport errors, timeouts, HTTP 5xx, 408 and 429 are not cached. The HTTP adapter refuses redirects and bodies over 256 KiB, uses 2 s connect/read timeouts and checks a 4 s deadline between body reads. These are not a hard DNS/TLS/task-lifetime guarantee. A request waits 4 s for its sites. Admitted reads can finish after that deadline and serve a later request. Addresses rejected by the full queue are retried as capacity becomes available within the deadline, or by the next request; overload is not cached as a denial and there is no candidate-count cutoff. Adelegate_permission/common.get_login_credsstatement naming the package and certificate vouches. A login picked through search in an app asks "Use GitHub in <app>?" with Always use for this app, which adds the app link, and Only this time. A slow admitted site does not block retries for the other candidates. The reader waits on admitted reads in slices of at most 25 ms and retries rejected addresses between slices. With no admitted read, it waits for a worker's queue-capacity notification or the request deadline instead of polling. - One-time codes: a one-time code field gets the codes of the matching logins. Filling a password of a login with a one-time password copies its current code ("One-time code copied. It clears in about 2 minutes.").
- Save (D15): a form with a password gets a
SaveInfo, so after a sign-in Android offers to save it to Hisar. Saving opens Hisar's editor, after an unlock if needed, with a new login in the Personal vault: named after the site or the app, with the site as its website, and linked to the app once saved. The typed values reach the app in memory (SavedLoginInbox), never on disk; the intent carries only an action, andMainActivityopens the waiting login whenever it comes to the front. Android 14 before QPR2 (March 2024) blocks the save sheet from opening Hisar when the page's app targets API 34 or later, so there the editor opens the next time the user opens Hisar, and the login is gone if the process died meanwhile. The iPhone app has no such offer. - Passkeys come from the credential provider (§9.8), not from the AutoFill service.
- The gear next to Hisar in the system's Autofill service screen opens Settings → AutoFill (
android:settingsActivity, an alias ofMainActivity), after an unlock if Hisar is locked. Settings → AutoFill always offers the system's settings: Passwords, passkeys & accounts from API 34, the Settings app's root before it, since API 29 to 33 have no public intent for the Autofill service page.
- Interface (D8): the iPhone app's screens, sections, copy and accessibility identifiers (as Compose test tags), drawn with Material 3 components restyled with Hisar's palettes (
ThemePalettes.kt, generated fromassets/themes/themes.jsonlike the Swift palettes) and never Android's dynamic color. The copy says "this device" and "biometrics" where the iPhone says "this iPhone" and "Face ID" (copy glossary). Sheets that fill the screen on the iPhone are pushed screens; touch-and-hold menus are dropdown menus. - Emergency Kit and export files: the sign-in screen scans the kit's QR code with CameraX and ZXing, which need no Google Play services; the camera permission is asked when scanning, and a refusal shows Open Settings. Settings saves the kit or an export through the system's file creator, prints the kit through the print dialog, and sends either to another app through the share sheet, as the iPhone's share sheet does. For a share, Hisar copies the file into
cache/shared/, the only folder itsFileProviderserves, and deletes the copy when the screen closes. - Tests: the iPhone's unit and UI tests are ported under their Swift names;
IosTestParityTestfails when one has no Android test, a rename or a reason (CLAUDE.md, Tests). Instrumented tests run on an emulator against a realhisar-server, the sharing partner andhisar-ui-test-mac, which plays the Mac for phone approvals (§9.9). CI runs them on API 37.
9.4 CLI
hisar is the command-line client for the terminal and for scripts: vaults (vaults, vault create|rename|delete|leave, §5.10), items (ls, get, add, edit, rm), favorites (favorite <item> [-v vault] and unfavorite print Added "GitHub" (Personal) to Favorites. and Removed … from Favorites.; ls --favorites lists them in every vault with a VAULT column, or in the vault -v names; §4.7), the generator, import of other password managers, Hisar exports and .env files (§9.7), export (export, §9.11), sharing (share <vault> <email> [-r role], unshare, people, invitations [accept|decline|cancel <id>], §5.8), invites (invite <email>), organizations (orgs, org create|show|rename|delete|invite|cancel|accept|decline|role|remove|leave, team create|rename|delete|add|remove, vault create <name> --org <org> --team <team>, vault give|take <vault> --org <org> --team <team> [-r role]; organizations and teams are named by name, ignoring case, or by ID, members by email; orgs and org show first deliver pending vault keys and rotate pending vaults and say so; §5.11), the account (account change-password, account replace-secret-key [-o file], account delete, standalone only because they need the master password and the Secret Key, §5.7, §5.7.1), share links (share-link, share-links, §5.9), devices, read and run, which hand secrets to scripts and commands through secret references (§9.4.1), and mcp, the server that lets coding agents use Hisar (§9.4.2). It works in one of two modes:
Selecting objects. IDs take priority over names. A unique case-insensitive vault name or exact item title still works.
Duplicate exact names or titles now fail instead of selecting the first result. Item-title fragments also require a unique match.
Errors list candidate IDs and vault metadata, with use-ID guidance. This changes scripts that relied on list order.
Discover the intended vault with hisar vaults, then list its metadata with hisar ls -v <vault-ID> or filter by --tag.
Use its item ID for get, edit, rm, favorites, and other selected-item commands. Raw field output is unchanged.
MCP consumers use list_vaults and search_items, then use the returned IDs or ready-made field references.
Generated references use IDs wherever colliding vault names, item titles, or names equal to another object's ID would be ambiguous.
- Through the desktop app, like 1Password's CLI integration:
hisarasks the running Hisar app for what each command needs over~/.hisar/cli.sock. It has no session, state file or keys of its own, and nothing is unlocked in thehisarprocess. - Standalone: its own sign-in, with the session in
state.json(HISAR_STATE) and the Secret Key in the OS keychain (HISAR_SECRET_KEYoverrides it); every command that decrypts asks for the master password. This is the mode on servers, where no app is installed, and whenever the app does not answer (see "Starting the app").
Choosing the mode. --standalone (before the command) or HISAR_STANDALONE=1 forces standalone. Otherwise hisar connects to the socket (HISAR_CLI_SOCKET overrides the path, for the app and the CLI) and sends hello. It uses the app when the app answers. When the socket is missing or refuses the connection, hisar starts the app and waits for it (below). It falls back to standalone when no app is installed, when the app answers integration-disabled, or when the started app did not answer but a standalone sign-in exists. An error after the app answered (denied, signed out, offline) is reported; hisar never retries it standalone. hisar status prints the mode, why that mode was chosen, and the account (email on server, and whether the app is locked). Missing arguments are asked for interactively; flags are for scripts. Standalone, hisar invitations completes accepted shares after asking for the master password, as the app does on every sync. signup, login and logout manage the standalone sign-in, and emergency-kit needs the Secret Key, which the app never hands out; in app mode it stops with a message that points to the app or to --standalone. The removed recover and recovery-* commands only print account recovery was removed; sign in with your Emergency Kit and exit with an error. hisar --version prints the version stamped at build time (-X main.version), which it also sends to the server (§8.7).
Starting the app. SSH clients cannot start Hisar, but hisar can. When the socket is missing or refuses the connection, a command that can run through the app (every command except signup, login, logout and emergency-kit, which then run standalone as before) starts the app hidden (§9.1.2) and prints "Starting Hisar…" on stderr. It does not when the SSH agent socket (HISAR_SSH_AGENT_SOCKET or ~/.hisar/agent.sock) accepts a connection: then the app is running with the command-line switch off, and starting it again would only show its window.
- macOS:
open -g -a <Hisar.app> --args --hiddenwhenhisaris the binary bundled in aHisar.app(also through the installed symlink), otherwiseopen -g -b com.tryhisar.hisar --args --hidden.openfailing means no app is installed. - Linux: the first executable
Hisarnext tohisar, on thePATH, in/usr/local/binor in/usr/bin, started with--hiddenin its own session with its output discarded. None found means no app is installed.
It then dials the socket and sends hello every 200 ms for up to 10 s. Once the app answers, the command runs through it. If no app is installed, hisar runs standalone as before. If the app was started but did not answer in time, hisar runs standalone when a standalone sign-in exists; otherwise the command fails with "Hisar isn't running. Open the Hisar app and try again." instead of a connection error. HISAR_NO_LAUNCH=1 (for scripts and CI) never starts the app and keeps the earlier behaviour: standalone whenever the app does not answer.
Bundled in the macOS app. Hisar.app/Contents/Helpers/hisar is a universal (arm64 + x86_64) build of the CLI. It cannot be Contents/MacOS/hisar: the default macOS file system ignores case, so that is the app's own executable Hisar. It is built with CGO_ENABLED=0, stamped with the app version and signed by scripts/sign-desktop-app.sh before the app, and it updates with the app through Sparkle (§9.1.1). Settings → Developer tools → Command-line tool:
- Shows whether
hisaris installed (a symlink to the bundled binary) and where, and whether the firsthisaron the user'sPATHis this app's. Apps started from Finder get a minimalPATH, so the app reads the user's from their login shell ($SHELL -l -i -c, 3 s timeout, cached for the session) and falls back to/etc/paths,/etc/paths.dand its ownPATH. - Install command-line tool creates the symlink
hisar→ the bundled binary in/opt/homebrew/binwhen that directory exists and is writable, else/usr/local/binwhen it exists and is writable, else~/.local/bin(created, 0755), and then shows a one-line hint when that directory is not on thePATH. It never asks for an administrator password. An existinghisarthere that is a symlink into aHisar.app(an older or moved copy) is replaced; anything else, a file or a symlink to somewhere else, is never touched, and Settings shows what is there. - Uninstall removes only
hisarsymlinks in those three directories that point into aHisar.app. - Let the command-line tool use Hisar (on by default; stored in
desktop.jsonand kept on sign-out). Off closes and removes the socket, sohisarfalls back to standalone (it sees the app running on the SSH agent socket and does not start it again); a request that still arrives is answeredintegration-disabled. - Development builds have no bundled binary; the section says so and offers no install.
Installed by the Linux package. The .deb and .rpm (scripts/package-desktop-linux.sh, desktop/build/linux/nfpm/nfpm.yaml) install the app at /usr/bin/Hisar, the CLI at /usr/bin/hisar and the browser host at /usr/lib/hisar/hisar-browser-host, so hisar is on the PATH without a symlink and finds the app at /usr/bin/Hisar (above). Settings → Command-line tool shows "Installed with the Hisar package at /usr/bin/hisar" and whether the first hisar on the PATH is that one, with no Install or Uninstall: the package manager adds and removes it. A development build that is not installed finds the browser host next to its own executable.
Socket. ~/.hisar/cli.sock, socket 0600 in the same 0700 directory as the SSH agent and browser sockets, served while the app runs and the switch is on. The app accepts only peers with its own user ID (LOCAL_PEERCRED on macOS, SO_PEERCRED on Linux) and closes any other connection without an answer. Frames are a 32-bit big-endian length followed by UTF-8 JSON, at most 16 MiB. Requests are {"id", "type", …parameters}; responses {"id", "ok": true, "result"} or {"id", "ok": false, "error": {"code", "message"}}. Error codes: invalid-request, integration-disabled, signed-out, locked, denied, offline, not-found, update-required (§8.7), internal. Shared types live in core/cliprotocol.
| Type | Parameters | Result |
|---|---|---|
hello | — | protocolVersion (1), state (signed-out, locked, unlocked), email, server, isApproved (for this caller's approval scope, or for the agent or launcher connection), features (what the app understands beyond the protocol version: secure-notes, credit-cards, identities, favorites, api-credentials, tags, addresses, software-licenses, servers, databases, organizations, then every launcher request type, §9.4.3; an older app sends none); never needs an approval |
account | — | accountID, name, email, role, server |
vaults.list | — | vaults: [{id, name, type, role, itemCount}] |
vaults.create | name | vault |
items.list | vaultID | items: summaries {id, revision, category, title, username, urls, tags, hasOneTimePassword} (tags only from an app whose hello lists tags, so hisar ls --tag refuses an older app rather than matching nothing) |
items.get | vaultID, itemID | item: the summary plus password, notes, fields, passkeys (relyingPartyID, userName), publicKey, fingerprint |
items.code | vaultID, itemID | code, expiresAt (RFC 3339), period |
items.save | vaultID, item (id and revision for an update; title, username, urls, password, notes, optional fields, optional tags (hisar sends back the tags it read), optional category of a new item: login or secure-note, sent only to an app whose hello lists secure-notes, since an older app ignores it and saves a login) | item. An update keeps everything else of the item: category, one-time password, passkeys, SSH key, the fields unless fields is sent (.env import) and the tags unless tags is sent |
vaults.actions | vaultID | vault, canRename, canDelete, deleteBlockedReason, canLeave, leaveBlockedReason, membersLosingAccess (name, email, role), targetVaults (§5.10) |
vaults.rename | vaultID, name | vault |
vaults.delete | vaultID, vaultDeletion (moveToVaultID, or isDeletingItems with confirmedName, the vault's name as typed) | movedCount |
vaults.leave | vaultID | {} |
export | export (format: encrypted or plain, passphrase, absolute new path, acceptProvenance: true) | path, vaultCount, itemCount, provenance. Requires the advertised export-provenance feature and explicit caller acknowledgement. The app writes the private file itself and always asks first (§9.11) |
vaults.read, items.read | vaultID for items | Healthy records plus issues; items.read also returns provenance, the source and completed sync of the vault's items. The updated CLI requires read-issues, prints the issue IDs and says when a vault has not finished syncing. Original strict list requests refuse partial output, and items of a vault that never synced to the end, for legacy peers and reconciliation consumers |
items.delete | vaultID, itemID | {} |
favorites.list | — | favorites: [{vaultID, itemID}], the user's starred items in the vaults they can open (§4.7) |
favorites.add, favorites.remove | vaultID, itemID | {} |
items.import | vaultID, importedItems (at most 10 parsed items; an app that announces favorites stars the ones with IsFavorite) | savedCount, failures [{title, message}] |
people.list | — | people: [{accountID, name, email, role, securityCode, isCurrentUser, isKeyChanged}], the people the user shares a vault with (§5.8) |
vaults.share | vaultID, email, role | {}; sends a share invitation (§5.8) |
vaults.unshare | vaultID, accountID | {} (rotates the vault key, §4.4) |
invite | email | {}; the server emails the invite (§5.1) |
invitations.list | — | received, sent as in the app; listing first completes accepted shares |
invitations.accept, invitations.decline, invitations.cancel | invitationID | {} |
shareLinks.create | vault (name or ID), query (item title or ID, matched as by hisar get), shareLink (fields, expiresInSeconds, maxViews, passphrase; or text and title) | shareLink {id, url, title, fieldLabels, expiresAt, maxViews}; always asks first (§5.9) |
shareLinks.list | — | shareLinks: [{id, title, fieldLabels, isText, createdAt, expiresAt, maxViews, viewCount, hasPassphrase}] |
shareLinks.revoke | shareLinkID | {} |
devices.list | — | devices |
devices.revoke | deviceID | {} |
secrets.resolve | references (at most 500 hisar:// references), commandLine (the command hisar run starts, as its arguments; empty for hisar read) | values, in the order of references; fails as a whole with not-found or invalid-request and a message that names every reference that failed and why (§9.4.1) |
agent.hello, agent.* | — | requests of hisar mcp, only on an agent connection (§9.4.2) |
launcher.hello, launcher.* | — | requests of a launcher such as Raycast, only on a launcher connection (§9.4.3) |
The app answers with the bound app.Service methods, so the CLI gets the same checks, offline behaviour and errors as the UI. It never sends the master password, the Secret Key, account or vault keys, SSH or passkey private keys, or one-time password secrets: items carry what hisar get prints, and items.code and otp references return only the current code. A private key reference is refused (§9.4.1); SSH keys are used through the SSH agent (§9.6). hisar parses import files itself and sends the parsed items in batches of 10.
Approval.
- Approvals are per caller scope, worked out once when the connection opens. From the peer PID (
LOCAL_PEERPID,SO_PEERCRED) the app reads the session ID (getsid), the controlling terminal device and the start time of the session leader.- A caller with a controlling terminal is approved per terminal session, so a session ID that is reused after the terminal closed does not inherit an approval. The terminal app and its icon come from
desktop/appinfo, starting from the parent ofhisar, because the bundledhisaritself lives insideHisar.app. - A caller without a terminal (a GUI app, a launcher, a launchd job) is approved per app: the app its process chain resolves to (
desktop/appinfo), keyed by bundle path and Team ID, and only when macOS verified the bundle's code signature together with its sealed resources (Apple's or a developer's), so an executable added to a signed bundle gets no identity. The result is cached per bundle until anything in it changes on disk; the first check of a large bundle takes from a fraction of a second (Raycast, about 0.2 s) to seconds (Xcode). Every GUI process of the user shares launchd's session 1 and has no terminal, so keying them by session would let one approval cover all of them. - Any other caller without a terminal (unsigned, a signature that does not verify, no app at all, Linux) is approved for its own connection only; the approval ends when the connection closes.
- The dialog says what an approval covers: "this terminal session", "<App> without a terminal, until 10 minutes after its last request", or "this connection only".
- A caller with a controlling terminal is approved per terminal session, so a session ID that is reused after the terminal closed does not inherit an approval. The terminal app and its icon come from
- Every request except
helloneeds an approval for its scope. The first one opens the approval dialog of the SSH agent (§9.6) in its full layout with "hisar in Terminal wants to use Hisar", Terminal's icon with the Hisar badge, and the program path and terminal device in the detail box; there is no "Don't ask again" checkbox and never the compact layout. On a Mac with Touch ID, Touch ID starts at once in the dialog; without it, Approve approves. - If Hisar is locked, the same window unlocks it: with Touch ID when Touch ID unlock is ready (§5.4; the one Touch ID check approves and unlocks), otherwise with the master password typed into the window. A wrong password keeps the request open with a message. Signed out, requests fail with
signed-outand no window. - Deny, Escape or closing the window fail the request with
denied, andhisarprints "Hisar denied the request (it was declined)". No answer before the dialog's deadline, 60 s or 2 minutes when the iPhone can answer (§9.9), fails it the same way with "Hisar denied the request (it was not answered within a minute)" or "… within 2 minutes)". Requests from one scope that arrive while its approval is open wait for that one window. - An approval lasts until 10 minutes after the scope's last request (a connection's also ends when it closes), or until Hisar locks, signs out or quits.
exportalways opens the dialog, even in an approved session, like a share link (§5.9): "hisar in Terminal wants to export all your vaults", with "Encrypted with an export passphrase · <path>" or "Plain JSON, not encrypted · <path>" and, for plain JSON, the warning "The file will hold every password, one-time code secret and SSH private key in plain text." Touch ID, the master password or the iPhone (§9.9) answer it.- Same-user code that runs inside an approved terminal session, or in an approved app, can use Hisar through
hisaruntil the approval ends, as with 1Password; code outside that scope needs its own approval. - When the request that opens the dialog is
secrets.resolve, the dialog says what it is for: "hisar in Terminal wants to run a command with 3 secrets" (hisar run) or "wants to read 1 secret" (hisar read). The detail box shows the commandhisar runstarts, shell-quoted and verbatim, its program as it was given (a script named./gitreads./git, andhisar mcpgives the full path it looked up): a long command wraps and scrolls, never cut, and no literal in it is masked, so nothing it runs is hidden. The dialog shows only on the user's own Mac and phones (a phone request is end-to-end encrypted, §9.9), and a literal written into a command came from whoever wrote it, who already has it, so masking it protects nothing; every rule that masked literals by their names either hid code (token=os.removein a Python script) or showed the rest of a credential it cut short. Onlygit's own credentials are hidden, ingit's arguments alone, as in the SSH dialog (§9.6); a shell or any other program that runsgitis shown as written. The phone shows the same command; one too long for a phone request stays on the Mac, which says so (§9.9). Then the secrets as "vault › item › field" (names when Hisar is unlocked, the reference as written while it is locked; the first 4, then "and N more"), and the terminal device. The approval still covers the whole terminal session: references that are read later in an approved session, byhisar run,hisar reador any otherhisarcommand, do not ask again.
Standalone releases. A run of the workflow "Release" with the app cli (CI and releases) tags cli-<version>, and its workflow "Release CLI" builds hisar on a Linux runner for linux/amd64, linux/arm64, darwin/amd64, darwin/arm64, windows/amd64 and windows/arm64 (CGO_ENABLED=0, version from the tag) and packs hisar-<version>-<os>-<arch>.tar.gz (.zip with hisar.exe for Windows) and SHA256SUMS (scripts/package-cli-release.sh). The run checks that every file in SHA256SUMS is there and matches, then creates the GitHub prerelease cli-<version> with all of them; its Publish job, once approved, turns it into the latest release (not for a custom build cli-1.2.3-label).
9.4.1 Secret references, hisar read and hisar run
Developers, scripts and coding agents name secrets with references instead of copying values into configuration. hisar run resolves those references into the command's environment and masks recognized values in its output. It does not create a resolved env file, but the command can write or disclose its values. core/secretreference parses, encodes and resolves references for the app and for standalone hisar.
Syntax. hisar://<vault>/<item>/<field>, for example hisar://Personal/GitHub/password, hisar://Work/AWS%20prod/secret%20access%20key or an API credential's hisar://Work/Stripe/secret.
- Exactly three segments, none empty. The scheme is case-insensitive; nothing follows the field (
?and#are part of a name unless encoded). - Segments are percent-decoded (
%2Ffor/,%25for%); a malformed escape or a result that is not UTF-8 is an error. Spaces may also be written as they are, sohisar://Work/AWS prod/secret keyworks in quotes. - Encoding (the desktop app's "Copy secret reference") keeps ASCII letters, digits,
-,.,_,~and every non-ASCII character as they are, and percent-encodes every other byte, so a reference is one shell word and one unquoted dotenv value.
Resolution.
- Vault: the vault whose ID equals the segment, else the vault whose name equals it ignoring case (Unicode case folding). Item: the same with the item's ID or title, within that vault. No substring matches, unlike
hisar get. - Two vaults with that name, or two items with that title in the vault, are an error that says so and lists the IDs to use instead.
- While a vault's key or name does not open (§9.1), that vault may have any name, so a reference by vault name is an error that says to use the vault's ID; a reference by vault ID still resolves, also into a vault whose name did not open. For agents, only a vault available to them counts. The references Hisar hands out ("Copy secret reference", launchers, agents' search) name every vault by ID meanwhile.
- Likewise, while an item of a vault does not open or the vault's items never synced to the end, a missing item may have any title, so a reference by item title in that vault is an error that says to use the item's ID; a reference by item ID still resolves, and the references Hisar hands out name that vault's items by ID meanwhile.
- Field, ignoring case:
password,username,notes,url(the first website),otp(alsoone-time codeandcode: the current one-time code, generated when the request is answered),public key,fingerprint,connection string(a database's, built from its fields; on other items a custom field with that label); anything else is the custom field with that label. A built-in label always means the built-in field; two custom fields with the same label are an error. private keyis refused for every item: SSH private keys never leave Hisar, so the reference fails with "SSH private keys never leave Hisar; use the Hisar SSH agent instead". The label stays reserved, so a custom field labelledprivate keycannot be read through a reference either.- A missing vault, item or field, and an empty value, are errors. Every error names the reference and says why (
hisar://Personal/GitHub/passwrd: item "GitHub" has no field "passwrd"); a value is never part of a message. All references of a request are checked, and the error lists every one that failed.
hisar read <reference> [--no-newline] prints one value to stdout, for scripts. It adds a newline only when stdout is a terminal, and never with --no-newline. Nothing is masked: the caller asked for the value.
hisar run [--env-file path]... [--no-masking] -- <command> [arguments...]
- Collecting. Every variable of
hisar's environment whose whole value is a reference (it starts withhisar://), and every variable of the--env-filefiles, given in order. A later file overrides an earlier one; the real environment overrides the files, except a real variable whose value is itself a reference, which a file overrides. Values that are not references pass through as they are. - Env files use dotenv syntax:
KEY=valuelines,#comments on their own line or after whitespace in an unquoted value, blank lines, an optionalexportprefix, keys[A-Za-z_][A-Za-z0-9_]*. Values are unquoted (trimmed), single-quoted (literal) or double-quoted (\n,\r,\t,\",\\and\$escapes); quoted values may span lines. There is no variable expansion. A syntax error names the file and line, never the value. - Resolving. All references go to the app in one
secrets.resolverequest, which asks for at most one approval, with the command line, and resolve all or nothing. With no references,hisar runprintshisar: no secret references foundon stderr, contacts neither the app nor the server, and runs the command. - Starting. The command gets
hisar's environment with the env files applied and every reference replaced by its value. It is looked up onhisar'sPATHbefore anything is resolved: a missing command exits 127 and one that is not executable 126, without asking for an approval. stdin is passed through as it is, so the command can read the terminal or a pipe. - Masking (the default). stdout and stderr go through pipes.
SecretMaskerreplaces recognized exact byte sequences with<concealed by hisar>, including overlapping values. It holds a possible secret prefix across writes, butCopyMaskedreleases that prefix after 100 ms without a continuation or when the stream ends. A slowly split value can therefore escape masking. Values shorter than 4 bytes are not masked, because they would garble ordinary output;hisar runnames those variables on stderr. Because the command writes into a pipe, it sees no terminal: colours, progress bars and interactive prompts may change, andisattychecks fail. --no-maskinggives the commandhisar's own stdout and stderr, so it keeps the terminal and its behaviour, and its output is not changed.- Signals. SIGINT, SIGTERM, SIGHUP and SIGQUIT are forwarded to the command. The command shares
hisar's process group, so whenhisaris the terminal's foreground job, the terminal already sends SIGINT and SIGQUIT (Ctrl-C, Ctrl-) to both, andhisardoes not forward those a second time; tools such asdocker composetreat a second Ctrl-C as "stop now". SIGTERM and SIGHUP are always forwarded. - Exit code. The command's exit code, or 128 + the signal number when a signal ended it. Errors of
hisaritself (a bad reference, a denied approval, a syntax error in an env file) exit 1 and run nothing. - After the command exits,
hisarwrites what is left of its output and exits. A background process that the command started and that keeps the pipes open gets 1 s more; output after that is lost.
Limits. The command gets the real values and can do anything with them: write them to a file, send them over the network, or print them encoded, reversed or in pieces, none of which masking catches. Output written straight to /dev/tty is not masked. Masking is a guard against accidental display in terminals, logs and coding agents' transcripts, not a sandbox. Values sit in the command's environment, which other processes of the same user may be able to read (/proc/<pid>/environ on Linux). An approved terminal session can read any secret through hisar until the approval ends (§9.4).
Standalone, hisar read and hisar run ask for the master password like other commands and resolve the references in the hisar process with the same rules; the password is read from stdin when it is not a terminal, before the command starts.
Desktop. Every field of an item that a reference can name (username, password, one-time code, custom fields; for SSH keys the fingerprint and public key) has a secondary "Copy secret reference" button (a terminal icon, tooltip "Copy secret reference — use it in a terminal with hisar run or hisar read") next to its copy button. It copies hisar://<vault>/<item>/<field>, encoded as above, with the vault's name and the item's title, or their IDs when the name is shared by another vault, or the title by another item in the vault. A custom field whose label is a built-in label or is used twice in the item cannot be referenced, and the button says why. A reference is not a secret, so the clipboard is not cleared after 90 s. The SSH key view shows a "Private key" row that says it never leaves Hisar, without the key and without a reference button.
9.4.2 Coding agents: hisar mcp
hisar mcp is a Model Context Protocol server on stdio for coding agents. Agents find references from metadata and pass them to commands. Output masking reduces accidental disclosure, not the command's authority over granted values (§9.4.1). hisar mcp --no-reveal omits get_secret and get_one_time_code, removing direct value-returning tools. It does not prevent a command from disclosing secrets through MCP output or another channel. The omp plugin and the Claude Code, Codex and Hermes servers Hisar connects start it that way. The advertised tools and instructions are in mcpserver.NewServer.
Connecting. Settings → Developer tools → Coding agents has a row per harness (omp, Claude Code, Codex, Hermes) with Connect and Disconnect; each connects the hisar inside Hisar.app as hisar mcp --no-reveal. omp: omp plugin link with the plugin inside Hisar.app, whose MCP server is that hisar, and omp plugin uninstall @tryhisar/omp-plugin. Claude Code: claude mcp add --scope user --transport stdio hisar -- <Hisar.app's hisar> mcp --no-reveal, and claude mcp remove hisar --scope user. Codex: Hisar writes [mcp_servers.hisar] with command, args, startup_timeout_sec = 15 and tool_timeout_sec = 4200 into $CODEX_HOME/config.toml (atomically, keeping every other byte; codex mcp add cannot set timeouts, and Codex's default 60-second tool timeout is shorter than an approval and a command), checks it with codex mcp get hisar --json, and disconnects with codex mcp remove hisar. Hermes: Hisar asks hermes config path which config.yaml it reads (run with the login shell's HERMES_HOME, which an app started from Finder does not get, set empty when the shell has none so that a value the app inherited does not apply; Hermes adds the profile hermes profile use made sticky), adds hisar under mcp_servers with command, args, timeout: 4200 and connect_timeout: 60 (Hermes cancels a call at its timeout, and 0 would cancel every call at once), and with env: {HISAR_CLI_SOCKET: …} when the app serves another socket than ~/.hisar/cli.sock, since Hermes gives MCP servers only a few of its variables. The edit changes only the lines of that entry, placed by a YAML parser, and the edited file is parsed again: an edit that would change anything else (a file whose servers are in flow style, a list, two documents) is refused, and the row shows why. Hisar reads mcp_servers as Hermes does, with merges resolved, and tells no state for a file it cannot read that way: a key written twice in one mapping (Hermes reads the last), an alias used as a key anywhere (*name: value, which can repeat a key), a top level that merges another mapping (<<: *base), or an anchored mcp_servers. Hisar then checks the entry with hermes config get mcp_servers.hisar --json --raw and takes it out again when Hermes reads it otherwise; when the file changed after Hisar wrote it, Hisar leaves it as it is and says its entry may still be there. Disconnect removes those lines, and mcp_servers when nothing else is under it, a comment of the user's included. A new Hermes session or /reload-mcp loads it. Both files are written to a temporary file beside them and renamed over them. Right before the rename Hisar reads the file again and writes nothing when its link, the file itself, its type, mode or content changed since Hisar read it ("Codex's config.toml changed while Hisar was connecting. Try again"). Codex and Hermes take no lock Hisar could share, so the check is optimistic: a change made between it and the rename is lost. A config.toml or config.yaml that is a link stays one, and the file it points to changes; a link to a file that does not exist is refused ("Codex's config.toml links to <target>, which does not exist. Fix the link and try again"), since replacing it would cut the user's link and creating its target would write into a folder that may no longer hold their dotfiles. A hisar server is Hisar's only as Connect writes it. For Claude Code that is Hisar's command and arguments with nothing beside them but the stdio type and an empty env. For Codex it is a table in config.toml with exactly those four keys and values and no table below it, which Codex reports running that way: enabled, with no env, env_vars, cwd, enabled_tools or disabled_tools, and Hisar's timeouts when it reports them. For Hermes it is an entry written in mcp_servers with exactly Hisar's keys and values, in any layout Hermes' own rewrite of the file gives it. Any other hisar server, Hisar's with a setting added or changed included, and one merged into Hermes' mcp_servers from an anchor, is a conflict and is never touched. Both agents pass the server's instructions to the model; neither shows progress notifications.
Server. hisar mcp uses the official Go SDK (github.com/modelcontextprotocol/go-sdk, cli/mcpserver) and always works through the desktop app: there is no standalone mode, because the app owns the approvals. It connects to ~/.hisar/cli.sock on its first tool call (not at start, so an agent that never uses Hisar never opens a dialog), keeps that one connection, and sends its requests one at a time; after a lost connection the next call connects again, which needs a new approval. Server name hisar, title "Hisar", the CLI's version, and instructions for the model: find references with search_items and use them with run_with_secrets; for a command that needs live output, interactive input or the agent's own shell environment, run NAME=hisar://… hisar run -- <command> (or hisar run --env-file .env -- <command>) in the agent's shell instead; never print a secret or ask the user to paste one; if hisar in the shell says a sandbox blocks its socket (§9.4.4), use run_with_secrets or ask the user to let the command run outside the sandbox; get_secret (without --no-reveal) is for when the value itself must be shown. The tools and the shell fallback come within the first 512 characters, which Codex advises keeping self-contained. If the app is not running, does not let the command-line tool use it, or is signed out, a tool fails with a message the model can relay.
Agent visibility. Each vault has an "Available to agents" switch, per device, kept in desktop.json (agentVaults: the vault IDs; Reset at sign-out clears it). It is off for every vault until the user turns it on, in Settings → Developer tools → Coding agents, which lists the vaults with the switch and the line "Agents can find items in these vaults and use their secrets after you approve; other vaults stay invisible to them." Agent requests only ever see vaults with the switch on; every other vault is absent, never "forbidden": list_vaults leaves it out, search_items never finds its items, and a reference into it fails exactly like a vault that does not exist. With no vault available, the tools say how to turn one on.
Tools. Inputs and outputs are JSON with schemas; results are also returned as text.
| Tool | Input | Output |
|---|---|---|
list_vaults | — | vaults: [{id, name, itemCount}] of the agent-visible vaults; message when there are none |
search_items | query (matches title, username, URLs and vault name, ignoring case; empty lists everything), optional vault (name or ID) | items: [{id, vaultId, vaultName, title, type (login, ssh-key, …), username, urls, hasNotes, hasOneTimeCode, fields: [{label, reference}]}], at most 50 with totalCount. Never a value and never the notes, which may hold secrets; fields lists the referenceable fields (username, password, one-time code, custom fields, public key, fingerprint) with their hisar:// reference, built as "Copy secret reference" does (§9.4.1) |
run_with_secrets | command (argv array; no shell) or shell (a command line for /bin/sh -c), exactly one; the secrets from env (map of variable name → hisar:// reference), envFiles (dotenv files, relative to the working directory) and stdin (literal text or one hisar:// reference), at least one secret among them; optional workingDirectory, optional timeoutSeconds (default 120, at most 3600) | exitCode, stdout, stderr, isStdoutTruncated, isStderrTruncated, durationSeconds, isTimedOut, warnings |
get_one_time_code (not with --no-reveal) | item: a hisar:// reference to any field of the item, or the item's ID | code, secondsRemaining, period |
get_secret (not with --no-reveal) | reference | value |
sign_in (Hisar for Mac) | pid and window_id of a browser window, as cua-driver's list_windows reports them; optional login, an item ID | outcome, cleanup, website, browser, login (id, vaultId, vaultName, title, username), steps (filled, submitted), message (§9.4.5) |
run_with_secretsruns likehisar runwith masking always on, in thehisar mcpprocess. Everything that can fail is checked before anything is asked: the command is looked up onPATH, the env files are read, and every reference amongenv, the env files andstdinis parsed. The environment is built ashisar run --env-filebuilds it (cli/internal/dotenv):hisar mcp's environment, then the env files in order (a value that is a whole reference becomes its secret, other values are set as they are, and an inherited variable wins over the files unless its value is a reference), thenenv, which wins over both. Astdinreference is sent as the command's standard input and counts as a secret; withoutstdinthe command reads nothing. The approval shows everything the command runs with, since a literalstdinscript or a value such asNODE_OPTIONS=--require ./x.jsorBASH_ENV=x.shsteers it as much as its arguments: its command line is the shell command that does the same, the argv (/bin/sh -c <shell>in shell mode) afterenv NAME=value …for each value the env files set that is not a reference and differs from the inherited one, in environment order, then a literalstdinas a quoted here-document (<<'HISAR_STDIN', its delimiter lengthened with_until the text does not contain it; the here-document ends the text with a newline) or astdinreference as< hisar://…. It also lists every secret, including those from env files andstdin.agent.secrets.resolvecarries them asstdinandenvironment(NAME=value), which an app announces with the featureagent-run-inputs; an older app would approve the command without them, sohisar mcprefuses a call with a literalstdinor literal env-file values before anything is asked: "This Hisar app can't show the command's standard input or environment in its approval. Update Hisar to run this command." Other calls work with any app. The command runs in its own process group; on Windows, in a job object it joins before it runs, so its children are in it too, and the job ends them ifhisar mcpexits or crashes while the command runs (JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, turned off once the command finished, so a background process it left keeps running as on Unix). Recognized exact values of at least 4 bytes are replaced with<concealed by hisar>before output is kept. Transformed values and other disclosure channels remain outside masking. stdout and stderr are each kept up to 64 KiB (the first and last 32 KiB with "[… N bytes not shown …]" between them). At the timeout the group gets SIGTERM, then SIGKILL after 5 s (on Windows every process in the job ends at once), andisTimedOutis set. Variable names are[A-Za-z_][A-Za-z0-9_]*, at most 100 secrets per call; everyenvvalue must be a reference. A value shorter than 4 bytes is not masked andwarningssays so.- direnv. When
direnvis onPATHanddirenv status --jsonin the working directory reports an allowed.envrc(in that directory or above it), the command runs asdirenv exec <directory> <command>, so it gets the project's environment as in the user's shell, and direnv, nothisar mcp, looks the program up on the project'sPATH; the approval shows that command line. A blocked.envrcor none runs the command directly. - Progress. When the tool call carries a
progressToken,hisar mcpsendsnotifications/progress: "Waiting for approval in Hisar" before it asks the app, then every 500 ms the output that arrived since the last one, stdout and stderr together, at most 16 KiB per notification. Progress and final results receive the same masker's output, including anything it fails to conceal.progressStreamdoes not provide an independent secret filter. get_secretrefusesprivate keylike every reference (§9.4.1). A reference outside the agent-visible vaults fails as not found.
App protocol. The agent requests are on the CLI socket (§9.4). A connection becomes an agent connection with agent.hello (agentName, agentTitle, agentVersion: the MCP client's clientInfo from initialize); after that it may send only hello and agent.* requests, and other connections may not send agent.* requests (invalid-request).
| Type | Parameters | Result |
|---|---|---|
agent.hello | agentName, agentTitle, agentVersion | {}; never needs an approval |
agent.vaults.list | — | vaults: [{id, name, itemCount}] |
agent.items.search | query, optional vault | items, totalCount |
agent.secrets.resolve | references (at most 100), commandLine, workingDirectory | values |
agent.items.code | references (one) or itemID | code, expiresAt, period |
agent.secrets.reveal | references (one) | values |
agent.signin | window (pid, windowId), optional itemID | outcome, cleanup, website, browser, login, steps, message (§9.4.5); refusals have the code sign-in-refused |
Approval model. The app checks every request; hisar mcp only relays.
- The agent connection needs a first approval, like a terminal session (§9.4), but the dialog names the agent: "Claude Code wants to use Hisar", subtitle "via hisar mcp in Terminal". The agent's name is its
clientInfotitle, else a known display name for itsclientInfoname (claude-code→ Claude Code,cursor-vscode→ Cursor,codex-mcp-client→ Codex, …), else the name itself (printable characters, at most 60), else "A coding agent". The app around it (in Terminal) comes fromdesktop/appinfo, starting from the parent ofhisar mcp, and is left out when it has the agent's name. The detail box shows thehisarpath and terminal device. The agent's name is what it says about itself; the app and path come from the system. The approval lasts until 10 minutes after the connection's last request, until the connection closes, or until Hisar locks, signs out or quits. It is keyed by the connection, so it never covers a terminal session, and a terminal approval never covers an agent. - After that,
list_vaultsandsearch_itemsneed nothing more. run_with_secretsandget_one_time_codeask per call unless a use grant covers the secrets. The dialog says "Claude Code wants to run a command with 2 secrets" with the command (shell-quoted and whole, as in §9.4.1, with its literal env-file values andstdinas above), the working directory and the secrets as "vault › item › field", or "wants the one-time code of “GitHub”". A longer use approval permits other commands with those same secrets until the grant ends.get_secretasks per field unless a reveal grant covers it. The dialog says "Claude Code wants to read “Stripe › api key”" and warns "The value will be visible to Claude Code and can end up in its transcript and logs." Reading is never covered by a use grant, and the reverse.sign_inasks per call with the website, the browser and the login (§9.4.5).- Each of these dialogs has a duration menu, Only this time by default; a longer choice is a grant for exactly what the dialog names, which later calls use without asking (§9.4.5, Grants).
- When the first request of a connection is one of these, its dialog also approves the connection ("Approving also lets Claude Code list and search the vaults available to agents"), so the user sees one dialog, not two.
- References are checked before anything is asked: a reference that does not resolve among the agent-visible vaults, or
private key, fails without a dialog. While Hisar is locked the dialog unlocks it first, as for the CLI (§9.4), with the references as written; they are checked after the unlock. - Touch ID, the master password and the phone (§9.9) answer these dialogs, and a click alone never does: an agent that controls the screen could click Approve, so on a Mac without Touch ID the dialog takes the master password. The phone request is a
clirequest whose app is "Claude Code (via hisar mcp) in Terminal"; the phone cannot choose a duration, so its approval is for this time only. - A denial, Deny, Escape or the timeout fails the tool with "The user denied access in Hisar." (or "… did not answer Hisar's approval dialog in time."), which the model can relay.
Inside an agent harness. The process chain identifies an agent harness (§9.4.4). Its live session keys connection approvals and session-scoped grants, so MCP connections and ordinary hisar commands inside that harness process share them. A recognized harness's longer grants are saved across sessions only when Hisar knows its program and can use the Keychain-backed head store (§9.4.5). The dialog names the harness and its host app ("omp in Tern wants to use 1 secret"), not the MCP client's own name.
Activity log. Hisar has no local activity or audit view yet, and the server's audit log (§8.3) must not learn which items an agent used, so agent requests are not recorded; an on-device activity log is a follow-up.
Limits. The command receives the real values and can write them to a file, send them over the network or print them encoded. Masking is not containment and --no-reveal does not make arbitrary commands safe. Approve only commands you trust with those secrets. An agent with a shell can also run hisar itself under the applicable approvals (§9.4.1, §9.4.4). A grant (§9.4.5) lets the agent run any command with its secrets until it ends.
9.4.3 Launchers: the Raycast extension
raycast/ is a Raycast extension, installed in Raycast's development mode and not published to the Raycast Store. It matches the 1Password extension's features: it searches Hisar, copies and pastes usernames, passwords and one-time codes, opens or edits an item in the app, shares an item through a share link (§5.9), generates passwords and locks Hisar. It talks to the desktop app on the CLI socket (§9.4) directly from Node, without hisar, and never keeps vault data on disk: items live in memory for as long as a command's view is open; only the filter selection and the details toggle are remembered.
Commands.
- Search Hisar lists every item of every vault the user can open, Favorites first, then a section per vault, each row with a star for favorites and the vault's name. One dropdown filters by vault or by category; search also matches the username, the vault's name and the website's host. Return runs the primary action and ⌘↩ the secondary one (preferences, each one of: open in Hisar, open in browser, copy or paste the username, password or one-time code; an action the item lacks falls through to the next). The other actions, with 1Password's shortcuts: Open in Hisar ⌘⇧O, Edit in Hisar ⌘E, Open in Browser ⌥↩, Copy Username ⌘⇧C, Copy Password ⌘⌥C, Copy One-Time Code ⌘⌃C, Paste Username ⌘⇧V, Paste Password ⌘⌥V, Paste One-Time Code ⌘⌃V, Share Item… ⌘⇧S (fields, expiry and views, then the link is copied), Copy Reference ⌘⇧R (a
hisar://reference of a field), Show Details ⌘Y and Reload ⌘R. - My Vaults lists the vaults with their item counts; Return opens a vault's items.
- One-Time Codes lists the items with a one-time code and the current code with its countdown.
- Generate Password copies a password from the app's generator (24 characters, digits and symbols); Generate Password with Options shows the password, generates again when the length, digits or symbols change (⌘R), and copies (⌘⇧C) or pastes it.
- Lock Hisar locks the app; it never starts the app to lock it.
- The preference "Close Raycast after copying" (on by default) decides whether a copy closes Raycast or shows a toast.
- The approval dialog takes the focus, so Raycast hides its window, and Raycast does not let a command launch itself again. Once the user answered a launcher's dialog (not after a timeout or a closed connection), Hisar opens the launcher's app again (
open -a <bundle>), which brings back Raycast's window with the command and its loaded list. The approval window is on every Space (CanJoinAllSpaces), so every approval, SSH and command-line ones too, shows on the Space the user is on, and answering it never switches to the Space of Hisar's main window.
Protocol. After hello, launcher.hello turns the connection into a launcher connection. It may then send only launcher.* requests, and other connections may not send them (invalid-request). The app announces every launcher request type in hello's features, and the extension checks the exact type before sending it, so an older app turns off only the commands that need what it lacks ("Update Hisar").
| Type | Parameters | Result |
|---|---|---|
launcher.hello | — | isApproved; refused with denied from a caller with a terminal or without an app macOS verified |
launcher.items.list | — | vaults [{id, name}] sorted by name, items [{id, vaultID, title, category, username, urls, tags, isFavorite, hasPassword, hasOneTimeCode, references: [{field, reference}]}]; no value and no notes |
launcher.items.code | vaultID, itemID | code, expiresAt, period |
launcher.items.password | vaultID, itemID | password, for one paste |
launcher.items.copy | vaultID, itemID, field (username, password or one-time code) | clearAfterSeconds; the app copies the value, so it never crosses the socket |
launcher.items.open | vaultID, itemID, optional isEditing | {}; the app's window comes forward with the item selected, or its editor open in a vault the user can edit |
launcher.items.share | vaultID, itemID, shareLink (fields, expiresInSeconds, maxViews; no fields shares the username and password) | shareLink with its url, as shareLinks.create |
launcher.passwords.generate | optional generator (length, digits, symbols) | password |
launcher.lock | — | {} |
core/cliprotocol/testdata/launcher.json holds an example of every exchange; the Go tests and the extension's tests both read it.
Approval.
- A launcher is approved per app (§9.4: bundle path and Team ID of a verified signature), in a memory separate from the command line's, so approving Raycast as a launcher never approves
hisarrun from Raycast, or the reverse. - The first request that reads the vaults, copies or opens an item opens the approval dialog: "Raycast wants to search Hisar and copy or paste passwords and one-time codes", with Raycast's icon and a note that it cannot change or export anything and that every share link asks first. Touch ID, the master password or the iPhone (§9.9, kind
launcher) answer it, and a locked Hisar unlocks. launcher.items.shareasks every time, even while Raycast is approved, likehisar share-link: "Raycast wants to create a share link for “GitHub”", with the fields, the expiry and views, and the warning that anyone with the link can read them. Approving it creates that one link and approves nothing else: Raycast's general approval still needs its own dialog. A denial runs nothing. The iPhone shows it as one request, without the "until Hisar locks" line.- The approval lasts until Hisar locks, signs out or quits, or the command-line switch goes off. Settings → Developer tools → Command-line tool → Launchers lists the approved apps with Revoke.
launcher.passwords.generateandlauncher.lockneed neither an approval nor a sign-in: generating reads nothing, and asking before a lock would unlock Hisar.- Raycast runs every extension in one Node process without an OS sandbox, so once Raycast is approved any of its extensions can send these requests until Hisar locks. That Node runtime lives in Raycast's folder in
~/Library/Application Support, outside the signed bundle, so the approval trusts the user's own account as much as Raycast does. App identity rests on the bundle's code signature on disk, not on the running process's.
Clipboard. Every copy the Mac app makes, from its own windows and for launchers, is marked org.nspasteboard.ConcealedType and org.nspasteboard.TransientType, so clipboard managers skip it (desktop/pasteboard). The 90-second clear reads the pasteboard back only while it still holds Hisar's own copy (its change count), so it does not read what another app copied since; reading and clearing are two steps, so a copy made by another app in the instant between them can still be cleared. A paste needs the value in Raycast: the extension reads one password or code, closes its window and pastes. Generated passwords are copied by Raycast itself with concealed.
9.4.4 Agent harnesses
An agent harness is a program that runs a coding agent and the commands it asks for, such as omp, Claude Code, Codex or Hermes. A request from a process that descends from a harness gets agent rules (§9.4.2) on the CLI socket, whatever it is: hisar mcp, or an ordinary hisar command the agent runs in its shell. The rule covers those descendants only.
Finding the harness. core/agentharness holds the table of harnesses. Each row names the harness and the ways to recognize its process: a code signature (signing identifier and team), an executable name (the executable's file name or the first argument), a version folder (the folder a native install keeps its executables in, each named after its version; the executable or the first argument directly inside it matches), a script a runtime runs, or an argument inside a package. A row may also name the argument prefix of the harness's own workers.
| Harness | Signature (identifier, team) | Executable name | Version folder | Script | Package |
|---|---|---|---|---|---|
| omp | omp-darwin-arm64, VTLHGQC72S (Tern's) | omp | …/Tern/omp/versions | omp run by Bun | @oh-my-pi/pi-coding-agent |
| Claude Code | com.anthropic.claude-code, Q6L2SF6YDW | claude | …/claude/versions | claude run by Node | @anthropic-ai/claude-code |
| Codex | codex, 2DC432GLL2 | codex, and codex-<target> for aarch64 and x86_64, apple-darwin and unknown-linux-musl | codex run by Node | @openai/codex | |
| Hermes |
Hermes is unsigned Python, so its row names a Python module instead: a process whose executable or first argument is a Python (python, python3, python3.14) running -m hermes_cli.main, or -c with source that has the line from hermes_cli.main import main. The second is the launcher Hermes' installer writes, which hermes and Hermes Desktop run; the first is a development backend. Any other Python, a script named hermes_cli/main.py included, is no match.
omp's workers carry an argument starting with __omp_worker_. Codex's release archives name the native executable after its target, and Homebrew's codex cask installed that file in most releases before 0.146.1, which installs the archive with bin/codex instead; the link the cask puts on the PATH is named codex either way, but a process started by the file's own path has only the target's name. A native install started by its own path, as an MCP client that resolved the link does, has that path as its first argument too: without a signature, which the CLI does not read, only the version folder names omp in Tern or Claude Code's native install. The names match any program called claude or codex, with no further guard: a program that only shares the name gets agent rules, which are stricter than a terminal's (hidden vaults, a dialog per secret, no standalone sign-in), and carries no checked mark, while a guard such as a required install path would miss installs nobody measured (a source build). The app reads the caller's chain from the kernel (desktop/appinfo, with kern.proc.pid and kern.procargs2 on macOS and /proc on Linux) as a snapshot of process instances: each with its PID, start time, parent, path, arguments, working directory and, on macOS, the signing identifier and team of the running process when a certificate Apple issued signs it (SecCodeCopyGuestWithAttributes with the PID, then SecCodeCheckValidity against anchor apple generic; ad hoc and self-signed code has none). The kernel has already checked every page the process loaded, so this took 10 to 30 ms per process; verifying the file instead hashes all of it, which took 1.0 s for Claude Code 2.1.302 (268 MB) and 1.9 s for Codex 0.162.1 (247 MB) and, with the rest of the chain, made the first request from inside Claude Code run past hisar's 5-second wait for hello. After the walk it reads every process again; a process that is gone or exited, has another start time or parent, a parent that started after its child, or a walk longer than 64 processes makes the chain unknown, so a signature read from a PID that was reused during the walk never counts. The nearest harness process is the agent session: its PID and start time, so a reused PID is another session, and its working directory as the project. A worker is not a session of its own: it belongs to the session of the harness process that started it.
Scope. The agent session comes first in the approval scope (§9.4), before the terminal session and the app. A caller whose chain is unknown may hold a harness, so it gets neither the command line's rules nor a launcher's: hello answers, and every other ordinary request and launcher.hello fail with "Hisar couldn't check which program is asking. Try again." (denied). Reading the chain again on the next connection usually works. Its agent.hello (hisar mcp) keeps agent rules, approved for its connection only.
- Ordinary commands inside a harness get agent rules.
hisar run(secrets.resolvewith a command) asks for a use grant per secret;hisar read(secrets.resolvewithout one) andhisar get(items.get) ask for a reveal grant per item, with the reveal warning.vaults.listanditems.listshow only the vaults open to coding agents and need the agent's approval; a hidden vault fails like one that does not exist, and its references fail before any dialog.hello(hisar status) answers as before. Every other request fails with "<harness> can use secrets through hisar run. Change items in the Hisar app." (such as "omp can use…",denied). - Sessions and grants.
agentharness.Sessionsis the registry shared by the CLI socket and SSH agent (§9.6). The first request starts a session keyed by the harness process instance, with an ID that never repeats. Another process or a session started after Revoke gets a new ID. An answer for an ended session records nothing. Session-scoped secret grants end with the session, but survive lock. Program-scoped saved grants survive sessions, lock and Hisar restarts (§9.4.5). The registry watches for exit withkqueueNOTE_EXITon macOS, a pidfd on Linux, and polling elsewhere. Exit or session revoke drops session-scoped grants, SSH approvals and remembered commit signing, cancels open dialogs, and closes connections. It does not revoke program-scoped saved grants. Both sockets check the harness instance before reusing an approval or grant, completing approval, or signing. Lock ends connection and SSH approvals and current request authorization, not secret grants. - Launchers.
launcher.hellois refused inside a harness. - Agent hosts. The app a harness runs in (Tern, Terminal) becomes an agent host, kept in
desktop.json(agentHosts, kept on sign-out). Recording it ends its app approvals of the command line and of launchers, and denies a dialog for its app approval that is still open when the user answers it. From then on its callers without a terminal and without a harness in their chain are approved per connection, and none of them is a launcher. A connection from the app opened before that is held to the same rules from its next request: it is approved per connection, and as a launcher it is refused. - The CLI reads its own chain with the same table (without signatures) before it would sign in on its own: inside a harness
--standalone,HISAR_STANDALONE=1, and an app that does not answer fail with "hisar runs inside <harness>, so it uses the Hisar app only." (such as "inside omp"), and with an unknown chain with "hisar couldn't check which program runs it, so it uses the Hisar app only. Try again."hisar statusstill answers, withmode: none (<why the app did not answer>). A connection to the socket that fails for permission (EPERMorEACCES) is a sandbox around the shell, such as Codex's:hisarstarts no app and, where it would have reported the refusal above or "not logged in", says "hisar can't reach the Hisar app: this shell's sandbox blocks its socket. Run the command outside the sandbox, or use the run_with_secrets tool."
What the user sees. The approval window names the harness and its host, "omp in Tern wants to use 1 secret". It shows the host app's icon and the project's directory name, with the full path on hover. A checked mark means the process matched by code signature. Matches by name, path or script have no checked mark. An agent outside a harness is labeled with the name its MCP client gave.
Settings → Developer tools → Coding agents lists live agent sessions that used either socket, with the harness, host, folder, first request time and session-scoped grants. Event agent-sessions-changed updates the list. Revoke ends that session's scoped grants, SSH approvals, remembered commit signing, open dialogs and connections. Its next request starts a new session. Program-scoped saved grants remain until separately revoked under Allowed without asking.
The agent-host list has Forget, which removes the app from agentHosts. Its callers regain app scope until a harness runs in it again.
Chains measured on macOS:
- omp 18.12.3 in Tern 0.9.4, the
bashtool:hisar←sh← omp (…/Tern/omp/versions/18.12.3, first argumentomp, signed byVTLHGQC72S) ←tern daemon← launchd. Children of omp have no controlling terminal. - omp, a managed service: the command ← omp's worker broker (the same signed executable, first argument
…/versions/18.12.3 __omp_worker_daemon_broker) ← omp ←tern daemon. Each omp session starts its own broker, and the broker may start more workers (__omp_worker_text_predict). A worker belongs to the session of the omp that started it, so a service shares the session and its grants with thebashtool. Any other omp is its own session, even a direct child of omp, such as one started withsh -c 'exec omp'. - omp's PTY path and client terminals were not measured.
- omp installed with
bun addand started through its bin link runs asbun ./node_modules/.bin/omp; its executable is Bun's. - Claude Code 2.1.302 (native install), the Bash tool:
hisar←/bin/zsh -c …← Claude Code (~/.local/share/claude/versions/2.1.302, first argument the~/.local/bin/claudelink, signedcom.anthropic.claude-codebyQ6L2SF6YDW) ← whatever started it (measured: a shell of omp in Tern, so Claude Code, the nearer harness, is the session).hisar mcpis a child of Claude Code too, so the Bash tool and the MCP tools share one session. The shell has no controlling terminal even when Claude Code has one. An npm install runs asnode …/@anthropic-ai/claude-code/cli.js(not measured). - Codex 0.162.1 (npm install),
codex exec:hisar←/bin/zsh -lc …(or the command itself, when zsh replaces itself with it) ← the nativecodex(…/@openai/codex-darwin-arm64/vendor/aarch64-apple-darwin/bin/codex, signedcodexby2DC432GLL2) ←node /opt/homebrew/bin/codex← whatever started it.hisar mcpis a child of the nativecodex, so the shell and the MCP tools share its session; the Node launcher matches too, but the native process is always nearer. Children have no controlling terminal. The Codex app'scodex app-server --managed-daemonruns under acodex app-server daemonof the same executable; the app-server, the nearer one, is the session (its processes measured, commands under it not). - Hermes 0.22.2 (its installer's managed install), the MCP server:
hermes chatandhermes -t computer_use,hisar chat:hisar mcp←~/.hermes/tools/python-3.14.7+…/bin/python3 -I -c '<launcher>' chat← whatever started it (measured: omp's broker);~/.local/bin/hermesand the launcher areshscripts thatexec, so neither appears.hermes chat --tui:hisar mcp←python3 -m tui_gateway.entry←node …/ui-tui/dist/entry.js← the launcher's Python, which is the session. Hermes Desktop (an ad hoc signed local build):hisar mcp← the launcher's Python withserve --host 127.0.0.1 --port 0←Hermes.app/Contents/MacOS/Hermes. Hermes' shell commands were not measured.hermes -t <toolsets>starts the MCP servers its list names, and all of them when the list holdsallor*or no name at all, as-t ''(read inhermes_cli/mcp_startup.py, not run), so-t computer_usealone starts nohisar.
What the rule does not cover.
- An approved secret is readable by the agent.
hisar runputs the value in its child's environment, where the agent's own command can print it encoded or send it anywhere; masking matches only literal values of 4 bytes or more (§9.4.1). The dialog shows the full command, so the user judges each use. - Work handed to another program leaves the chain. A double fork or a launchd job: inside an agent host the caller is approved per connection; elsewhere it resolves as a background process with connection scope.
tmux send-keysinto a pane the user owns, or AppleScript that types into a terminal: the command runs in the user's terminal session and can use its approval, and nothing tells it apart from the user's typing.open -a,launchctlorssh localhost: the chain has no omp and belongs to the opened app, launchd orsshd. A script the agent writes and the user runs later runs with the user's own approvals. - omp's subagents share one process, so they share its grants; so do Claude Code's subagents, every thread of one Codex app-server, and every chat one Hermes Desktop backend serves.
- A harness that is renamed and re-signed is not recognized; the table matches what omp, Claude Code, Codex and Hermes ship as.
- Codex's sandbox. Codex runs shell commands in a Seatbelt sandbox that refuses every Unix socket, so
hisarthere cannot reach the app (it says so; see The CLI), unless the user lets that command run outside the sandbox.hisar mcpruns outside it, sorun_with_secretsworks, and the commands it runs run outside Codex's sandbox too, once the user approves them in Hisar's dialog, which shows the whole command and its working directory. Codex's own approval policy for MCP tool calls still applies before Hisar is asked.
9.4.5 Agent sign-in to websites
A computer-use agent, such as Hermes' computer_use or an MCP client with cua-driver's native window tools, drives the user's browser through screenshots, clicks and typing. Typing a password would put it in tool arguments, transcripts and logs. Instead, the agent calls sign_in on hisar mcp with the window it controls. Hisar for Mac observes the destination, asks unless a grant covers it, fills and submits the login, then attempts to clear remaining entries and reports the cleanup evidence.
The agent receives the outcome and login metadata, never the password or one-time code. It cannot supply an unverified destination URL. Hisar reads the destination from the browser and binds the approved operation to that origin. Use native window controls for this workflow, not a CDP browser tool. Hisar refuses debuggable browsers.
core/signin holds the policy and the transaction (Signer.Inspect, Signer.Run) behind a Driver; desktop/axfill is the macOS driver (Objective-C over the Accessibility API); core/app/agentsignin.go finds the logins and reads the approved one; core/clisocket/agentsignin.go asks and keeps grants. Elsewhere axfill.New returns nil and sign_in fails with "Signing in to websites works only in Hisar for Mac."
Flow.
sequenceDiagram
participant Agent as Agent (model and harness)
participant Cua as cua-driver
participant MCP as hisar mcp
participant App as Hisar for Mac
participant Browser
participant User
Agent->>Cua: list_windows
Cua-->>Agent: pid, window_id
Agent->>MCP: sign_in(pid, window_id, login?)
MCP->>App: agent.signin on cli.sock
App->>Browser: read the window through Accessibility
Browser-->>App: process, frames, fields, buttons
App->>App: browser by code signature, not debuggable, sign-in form, origin, matching logins
App->>User: dialog with website, browser, login and duration
User-->>App: Touch ID, master password or phone
App->>Browser: read again, same process, browser and origin
App->>App: read the login, same revision and vault visibility
loop each step while the page stays on the approved origin
App->>Browser: enter, count characters, press submit
App->>Browser: watch the page, dismiss the save-password prompt
end
App->>Browser: clear what may remain, watch for the prompt
App-->>MCP: outcome, cleanup, website, browser, login metadata, steps
MCP-->>Agent: result without a secret
Setting. Settings → Developer tools → Coding agents → Website sign-in, "Let agents sign in to websites in your browser", is off by default and kept per device (agentSignIn in desktop.json, kept on sign-out). Turning it on asks macOS for the Accessibility permission for Hisar (AXIsProcessTrustedWithOptions with the prompt). A call while it is off or the permission is missing fails with a sentence that says which.
The destination, as Hisar observes it. Inspect reads the window window_id names and refuses unless all of these hold:
- The window server lists that window as an ordinary window (layer 0) of
pid, on screen: a window on another Space or minimized is refused, since its page cannot be read. The driver maps the window ID to the accessibility window itself, never by its frame. - The process matches a browser in
core/signin/browsers.goby the system-verified signature of the running process (§9.4.4). The rows are Brave Browser (com.brave.Browser,KL8N8XSYF4), Google Chrome (com.google.Chrome,EQHXZ8M8AV) and Microsoft Edge (com.microsoft.edgemac,UBF8T346G9). Adding a browser requires a successful sign-in throughdesktop/axfill'sTestIntegrationSignIn. Tested with Brave, Chrome 156 and Edge 156 on macOS 26. Safari and Arc are not supported. The driver has only Chromium's accessibility-value entry and clear path, with a separate Return-submit action. - The browser accepts no remote debugging: its arguments have neither
--remote-debugging-portnor--remote-debugging-pipe, and its profile folder (--user-data-dir, else the row's default under~/Library/Application Support) has noDevToolsActivePortfile whose port accepts a TCP connection on 127.0.0.1 within 300 ms. Arguments that cannot be read, or a relative--user-data-dirwithout the process's working directory, count as debuggable.--load-extensiondoes not count. - The top document is a website, and the page shows a sign-in form (below). The form's frame has an http or https URL, whose origin is the scheme, the lower-case host (internationalized names in punycode) and the port without the scheme's default. It must be https, or http on
localhostor a loopback address, and every frame above the form's must have the same origin, so a sign-in form another site frames is refused.
Chromium builds its web accessibility tree for assistive apps that ask. When the tree is not ready, the driver sets AXManualAccessibility and AXEnhancedUserInterface on the browser process. It returns each field's derived isSecure flag, role description as inputType, label, DOM identifier, placeholder, editable state, visibility flag and character count, never its text. Visibility requires an at-least-8-by-8-point frame whose midpoint lies in the top web area. It does not require the entire field frame to lie inside it.
The form. findForm takes the first frame in document order with a sign-in step, using only visible, editable fields. AXSecureTextField identifies the password. The last text field before it that is not a code field is the username.
A frame with two secure fields is treated as a sign-up or change-password form and refused. A nonsecure field that calls itself a password is also refused. The classifier checks its label, identifier and placeholder for words including "password", "passwort", "contraseña", "mot de passe", "wachtwoord" and "senha".
A code field is nonsecure and names a code, with words including "code", "otp", "one-time", "2fa", "mfa", "totp", "verification", "authenticator", "digit" or "token". An exclusion list rejects words such as "postal", "zip", "promo", "coupon" and "invite". A run of 4 to 8 adjacent code fields is one field per digit.
A username-only step needs a sign-in button. The submit control is the first visible button whose title is or starts with a sign-in word, such as "sign in", "log in", "next", "continue", "submit" or "verify". Titles that name another route, such as "with", "passkey", "sso", "sign up", "create" or "forgot", are excluded. Without a submit control, Return in the last filled field submits.
Geometry cannot distinguish a transparent field from one the user sees. "Visible" means only the geometric checks above. The invisible page fixture records this limit.
Matching logins. Candidates are OffersLoginFill items in agent-visible vaults whose websites match the observed origin through browserprotocol.IsURLMatch. Only fill on the exact host requires the same host. With it off, hosts under the same registrable domain match. Ports must match, and a saved http website also matches an https page.
Candidates are sorted by title, then username. The optional login argument selects one item and fails if it is not saved for the origin. If no login matches, the request fails with a message that tells the agent not to ask for the password.
Approval. The dialog (§9.4.2) says "{agent} wants to sign in to {origin}". It shows the origin, browser, login's vault, title and username. With several candidates the user picks one on the Mac. That dialog never goes to the phone.
Every agent dialog requires Touch ID, the master password or a verified phone answer (RequiresPresence). A click alone cannot approve it. Without Touch ID the dialog takes the master password. The Touch ID reason names the origin, username and duration, such as allow "Hermes in Terminal" to sign in to https://tosunlab.com as user@example.com for 1 day. Changing the login or duration cancels that check and starts a new one.
Agent checks use the native-context serialization of §5.4. Changing a choice during a locked approval restarts protected-key preparation as well as the prompt.
The phone can approve only one login, only for this time, and only while Hisar is unlocked. The dialog's deadline is normally 60 seconds, or two minutes when the request was also sent to a phone. hisar mcp waits up to five minutes for the app.
While Hisar is locked, PrepareAgentSignIn returns the destination without logins, and the dialog unlocks Hisar ("Hisar shows the login saved for this website after you unlock it"). Hisar then observes the window again: a destination that differs in window, process instance, browser or origin is refused ("The window changed while the user was approving …"), and with several logins the user is asked once more to pick one.
Grants. A dialog that names secrets, for run_with_secrets, get_one_time_code, get_secret and sign_in, has a duration menu: Only this time (the default), For 1 hour, For 1 day and Always. A longer choice is a grant that covers exactly what the dialog named: for a run, each secret (vault, item, field); for a reveal, that one field; for a code, that item's code; for a sign-in, that item at exactly that origin (signin/{vault}/{item}/{origin}). Use, reveal and sign-in grants never cover one another. Who holds a grant:
- A recognized harness (§9.4.4) with a known
Programand usable head store holds saved grants across its sessions. Only sessions running that program may use them. The program is the matched code signature, or else the executable's kernel-reported path. Interpreted agents also include their entry. Node and Bun usescript:with the resolved absolute script path, orsource:with the SHA-256 of code passed through-eor-p. Python started as-I -c {code}, as Hermes' installer does, usessource:and that code's SHA-256. A relative script path, another option first,-m, a Node or Bun preload, loader or env-file option anywhere, a second-e, or Python without-Ileaves the program unknown. A replaced script,bunfig.tomlpreload orNODE_OPTIONScan change code without changing this identity and is outside these checks. - Otherwise, the agent session (§9.4.4) or, outside a harness, the connection holds it, and it ends with them. The menu then offers only Only this time and For 1 hour.
core/app/agentgrants.go seals eligible saved grants into desktop.json (sealedAgentGrants). The JSON holds the harness ID and name, program, kind, vault and item IDs, field label, origin and expiry. It holds no vault or item names. crypt.SealAgentGrants uses XChaCha20-Poly1305 under K_user (§4.3), with AAD hisar/v1/agent-grants and the length-prefixed account ID. Only the unlocked account can create grants that open.
The sealed document includes a fresh random 32-byte head. desktop/granthead keeps the same head in a login keychain generic password item, service "Hisar agent grants", account head. A copy with its own HISAR_DESKTOP_STATE uses a service suffix. The store opens the keychain by its file path, not the search list.
A save deletes and adds the item, so its default access list trusts the creating Hisar. Reading the head from another program prompts for Keychain access. Another program can overwrite or delete it without asking, which only drops grants. The head is written before the state file, so a crash between writes drops grants rather than reviving older ones.
At unlock, Hisar compares the stored and sealed heads. An invalid sealed document, restored older copy, missing or unreadable head, or mismatch drops all saved grants. Without a usable head store, including on Linux, the menu offers only Only this time and For 1 hour. The hour ends with the session or connection.
A grant ends at expiry, sign-out or Revoke under Settings → Developer tools → Coding agents → Allowed without asking. A session-scoped or connection-scoped grant also ends with that session or connection. Lock keeps grants, but none is usable while locked.
cleanup-failed revokes the sign-in grant for that login and origin. While locked, Hisar cannot rewrite one sealed grant without the account key, so it drops all saved grants. A failed removal while unlocked also drops the saved list. It clears the in-memory list and sealed state and attempts both head deletion and state save, even if one fails. A storage failure reaches the agent as a tool error with the cleanup warning. If both durable writes fail, disk state may still contain grants after an app restart. The running app does not reopen them.
A sign-in skips the dialog only while Hisar is unlocked, the harness instance still runs and exactly one candidate has a grant for the origin. Every grant is checked against request authorization. Lock, sign-out, another account, the command-line switch going off or session end invalidates authorization. A stale approval or grant lookup cannot authorize the request.
Agent lookup, secret reads, sign-in preparation and entry, and saved-grant writes carry the app session epoch captured when the socket request arrives. They check it under the service mutex, in addition to authorization and approved-item identity. An explicit lock while already locked also ends that epoch. Unlocking cannot revive its request. Before returning an answer, the app checks the arrival epoch again and drops a result from an ended session. Cleanup-failure grant revocation still runs before a stale sign-in result is dropped.
The transaction. Signer.Run runs one sign-in at a time on the Mac; a call waits for its turn only after its approval, and only while its request lasts.
- Revalidate. It reads the window again and refuses with "The window changed while the user was approving …", entering nothing and reading no secret, unless the window still belongs to the same process instance (PID and start time), the browser is the same row and still not debuggable, and the page shows a sign-in form whose frame has exactly the approved origin.
- Read the login. Under the app's lock, the request must still run, authorization must hold and Hisar must be unlocked. The item must have the approved revision, remain agent-visible and still match the origin. Otherwise Hisar refuses without entry. The password and
otpauth://URI becomesignin.Secretvalues. Everyfmtverb prints these as‹concealed by hisar›, and they refuse to marshal. Plaintext stays in the app and native driver, outside the agent protocol. The transaction also reveals values to parse the authenticator URI, split a generated code and compare character counts. A one-time code is generated for its step. A split field with a different box count stops the sign-in. - Each step. Chromium receives each part through
AXValue, which fires input events. Go checks the process instance immediately before the native call. The native call rechecks window ownership, element ancestry and frame origins, and that a field read as secure is still secure. These are separate observations, not an atomic process-instance check. They do not eliminate a race between checks and browser action. After entry, a nonzero character count that differs from the entered value fails the step. Zero is inconclusive because a hidden page can read back empty. Hisar presses submit or posts Return with the last filled field focused. - Await. Hisar polls every 100 ms under a 15-second deadline after each submit, dismissing recognized save-password prompts. When fields disappear, another origin ends the call
stopped. A next step on the approved origin continues it. After three seconds with no next step, the outcome issubmittedafter a password or code, orstoppedafter a username alone. A new field for a part not yet entered can continue the call in place. A form that retains submitted fields until the deadline, or asks again for a submitted password or code, endsnot-accepted. Another origin needs a new call and its own approval or grant.
Lock and request cancellation stop the sign-in. Cleanup runs without the original cancellation, with a separate five-second timeout. Browser Accessibility calls can finish after either the 15-second post-submit deadline or the cleanup timeout. Neither is a strict wall-clock or retention bound.
Cleanup and its evidence. Hisar records each entered field and its document (AXWebArea). Cleanup retries window reads under its own timeout and reports the weakest evidence across the fields:
page-leftmeans the document containing a field left through navigation or window close.fields-clearedmeans Hisar cleared a field still in the tree and read back zero characters. This is not an independent check that the DOM value is empty.fields-removedmeans the field left the accessibility tree while its document stayed. The page may retain its value. Hisar also attempts to clear matching fields rebuilt by Chromium, but that does not strengthen this evidence.
The prompt watch lasts until 1.5 seconds after the last submit. The driver enumerates non-standard top-level AX windows and direct AXSheet children of standard browser windows. It does not recursively scan every popover or nested sheet. Only a prompt with an English "password" title or a secure field is recognized.
Hisar presses only the prompt's close control, identified by Chromium's view classes and its position near the top-right corner. It never presses Save, Update, Never or the eye, and never sends Escape. Chromium ignores a press during the double-click interval after a bubble appears, so the driver waits through that interval.
Evidence becomes unverified if a clear fails, a prompt remains open a second after first detection, prompt enumeration fails, or the window cannot be reread or belongs to another process. The outcome becomes cleanup-failed, revocation runs as described above, and the agent is told to stop and inform the user.
Chromium can stop exposing a page's accessibility tree while the browser window remains open. If this happens after entry and Hisar cannot verify cleanup, the agent receives cleanup-failed with unverified evidence. It must stop and tell the user to check the browser window, not retry sign-in.
A refusal after entry requires page-left or fields-cleared. Weaker evidence becomes cleanup-failed. An ordinary outcome can still carry fields-removed. Both fields-removed and cleanup-failed can leave values present after return, with no Hisar-enforced retention limit.
What the agent receives. agent.signin, feature agent-sign-in, returns outcome, cleanup evidence as cleanup, observed website and browser, and login metadata (id, vaultId, vaultName, title, username). steps lists the filled parts (username, password, one-time code) and whether each step was submitted. message tells the agent what to do next. Refusals use sign-in-refused with a full message (troubleshooting).
hisar mcp returns structured content and message text, or a tool error, with no sign-in progress notifications. No password, one-time code or authenticator secret crosses the socket. An ended authorization keeps the denied protocol code for old clients, but current hisar mcp describes a stopped request, not a user denial.
Channels a secret could reach the model. Hisar does not send a sign-in password or one-time code to the agent. Browser entries, page-script copies and captures are separate channels. The table distinguishes checks and timeout mechanisms from deletion guarantees.
| Channel | Enforced | What remains |
|---|---|---|
sign_in's result, errors and progress | The result type has no value field; refusals are fixed sentences that name origins, window and process IDs, titles and usernames; no progress is sent. The value stays in the app process. | The login's title and username reach the model, as search_items already shows them. |
hisar mcp's stderr, which Hermes keeps in logs/mcp-stderr.log | Sign-in passwords, codes and authenticator secrets never reach hisar mcp or its stderr. The app can log grant-storage failures, not credential values. | None from Hisar's sign-in credential transport. |
| Hermes' or another harness's transcript, logs and caches | They receive only what hisar mcp returns. | A screenshot or accessibility capture the agent takes during the sign-in, which the harness keeps like any other (Hermes keeps captures in its cache). |
| cua-driver's arguments and recordings | Hisar never calls cua-driver; no value is ever a cua-driver argument. | A cua-driver recording (trajectory or video) that runs during the sign-in records what the screen and the accessibility tree show. |
| Screenshots and screen recording | Password entry requires AXSecureTextField, checked on observation and before writing. Hisar uses a 15-second post-submit deadline and separate five-second cleanup timeout. Accessibility calls may overrun them. | Browsers normally mask secure fields with dots, revealing length. A code remains visible while the page retains it. fields-removed and cleanup-failed can leave values after return with no enforced retention limit. Captures have no Hisar-enforced deletion limit. |
| Accessibility reads of the page | The driver never returns a field's text, only its character count. | Any accessibility client of the user, cua-driver's included, can read the fields while they hold the entry; what macOS and Chromium expose for a secure field is theirs. |
| DevTools protocol and the page's DOM | Remote-debugging switches and an accepting profile DevToolsActivePort are checked at inspection and after approval. A detected debuggable browser is refused. | Debugging enabled later, site scripts and profile extensions can read the page. Script copies have no Hisar-enforced deletion limit. |
| The page's show-password button | A field that is no longer secure before writing is refused. Hisar uses the step's submit control, not the page's reveal control. | A later reveal by the page or another tool call can expose the password. Hisar does not pause other calls, and cleanup is not guaranteed to remove the value. |
| The browser's offer to save the password | After submit, Hisar checks non-standard top-level AX windows and direct sheets of standard windows. It recognizes an English "password" title or secure field, presses only close, and reports cleanup-failed if a detected prompt persists. | Nested sheets, popovers outside that enumeration, and prompts with neither cue can remain undetected. Retained passwords have no enforced persistence bound. |
| The browser's form history | Hisar's page cleanup does not clear browser-owned profile storage. | In the measured run, Microsoft Edge retained a submitted one-time code after use in its profile's Web Data database, table autofill_edge_field_values. Brave and Chrome were not observed doing so in that run. This is browser storage, not agent output. |
| Clipboard | Sign-in never uses the clipboard. | None. |
| Phishing | The origin comes from the browser's own document URL, never from the agent; https only (http on loopback); every frame above the form has the same origin; logins match only their saved websites; the approval, the grant and every continued step are bound to that exact origin; each action rechecks the window, the process and the element's frame origins. | With Only fill on the exact host off, any host under the login's registrable domain matches, so a hostile subdomain of that domain gets the login. A site that serves an attacker's script on its own origin gets it too. |
| Approval reuse | A grant covers one login and exact origin. Saved grants require the same program and live harness instance. Scoped grants end with their session or connection. Sealed grants are bound to the Keychain head. cleanup-failed revokes the grant, dropping the saved list if necessary and surfacing storage failures. | Until revoked, a grant permits sign-in without asking. If both head deletion and state save fail, disk grants can survive restart, though the running app refuses them. A replaced script or changed environment such as NODE_OPTIONS is outside program-identity checks. |
| Self-approval | A click never approves: every agent dialog needs Touch ID, the master password or a phone answer, and the Touch ID reason names the origin, the username and the duration, restarting when they change. | None beyond what the user approves. |
| Hisar's own window | Out of scope. | An agent that drives Hisar's own window while Hisar is unlocked can reveal or copy what the window shows. |
| Code that runs as the user | Out of scope. | Any program of the user can read the browser through Accessibility, take screenshots, read the harness's files or drive the browser itself. |
Local verification. make test-agent-sign-in builds a throwaway desktop app and browser profiles. It requires the built-in display alone, with a visible Space and another non-fullscreen Space for the hidden-window refusal case. Browser launch coordinates and initial-frame admission use that display. The harness may activate its own apps and approvals; foreground application and mouse movement are not guarded. The runner temporarily disables only yabai's runtime mouse_follows_focus setting. Its cleanup trap restores the recorded value on every exit path and verifies the readback. It never edits yabai's configuration file. Production window behavior is unchanged. Approvals use Accessibility elements and key events posted to the dev app's PID. The driver uses a private socket with its cursor overlay disabled. Run the short Chrome dry run before full scenario runs.
The scripted Layer A client selects its owned browser window before each scenario rather than depending on whichever app was last in front. Owned browser windows are made floating and placed at the declared built-in-display frame before admission, so automatic tiling cannot override their launch geometry. No global window-manager rule is changed. The client retries a sign_in call at most once after the documented pre-entry refusal that the window shows no website or about:blank. Chromium can expose its window title before its page's accessibility tree appears. The retry follows the refusal's instruction to call again; it does not change production behavior or repeat entry. The table and each scenario's pageRefusalRetries report field record the retry count. Other refusals, transport errors, rejected passwords and cleanup-failed outcomes are never retried.
For real Hermes sessions, AGENT_SIGN_IN_LAYER=B AGENT_SIGN_IN_BROWSERS=Chrome make test-agent-sign-in runs the installed Hermes 0.22.2 launcher with a private HOME and HERMES_HOME. It reads the mode-600 Anthropic key file named by AGENT_SIGN_IN_MODEL_ENV, which defaults to /tmp/hisar-e2e/model.env. Before Settings connects Hermes, the harness verifies the login shell's config destination. A sandbox denies writes to the user's .hermes directory, whose config hash must remain unchanged. Computer use uses the installed CuaDriver in standard permission mode with overlays disabled. The runner starts a daemon at the disposable home's default socket because automatic app launch did not create that isolated endpoint in local verification. Hermes' own MCP client connects to that daemon. The harness runs hermes computer-use status and doctor before the scenarios. Each scenario requires real computer_use captures and Hisar's sign_in tool call. The wrong-password scenario also records a background resubmission to check cleared fields. The scanner includes Hermes' SQLite state, transcripts, logs, screenshots and recording artifacts. Cleanup deletes the private profile and its key copy. Delete the one-time input key file after the final run.
9.5 Go memory handling
Go's garbage collector can copy memory and strings are immutable, so secrets cannot be reliably wiped. Rules:
- Keys and secrets are
[]byte, neverstring, insidecore/. - Long-lived keys (AUK, keyset, vault keys) live in
memguardenclaves and are opened only for the duration of an operation. - Plaintext item data is zeroed after use where possible, and the lock action drops all references.
Keyset.Wipe clears the user key, both keyset seeds, the Ed25519 private key, and the derived seed exposed by the
hybrid HPKE key's Bytes method. Lock also clears the app's vault keys, cached and pending-import passkey private keys
and PRF secrets, and onboarding unlock material. Decrypted item strings, copies already returned to callers or UIs,
and expanded ML-KEM and X25519 private state hidden inside Go's HPKE implementation cannot be cleared through these
APIs. Dropping references does not guarantee physical erasure of those copies.
9.6 SSH agent (MVP)
Like 1Password, the desktop app is an SSH agent for keys stored in vaults, so private keys never sit in ~/.ssh.
SSH Key items
-
Item category
ssh-key(item.Overview.Category). The overview holds the title, the public key as anauthorized_keysline (publicKey), the SHA-256 fingerprint and the key type (ssh-ed25519,ssh-rsa,ecdsa-sha2-*); the details hold the private key as an unencrypted OpenSSH PEM (privateKey), protected by the vault encryption like every other secret. -
Generate inside the app (Ed25519 by default, RSA 4096 if requested), or import an existing OpenSSH, PKCS#1, SEC 1 or PKCS#8 key. Passphrase-protected keys are decrypted on import and stored without the passphrase.
-
The UI never receives a private key:
ItemDetailcarries only the category, public key, fingerprint and key type. Editing an SSH key changes only its title and notes; the backend keeps the key material from the cached item. -
The detail view shows the key type, fingerprint and public key with "Copy public key" (copied through the backend and cleared after 90 seconds) for
authorized_keys, GitHub and so on. -
Add to GitHub / Add to GitLab copy the public key and open
https://github.com/settings/ssh/neworhttps://gitlab.com/-/user_settings/ssh_keys, with the hint to paste it and choose the key type there (GitHub: "Authentication Key" for SSH, again as "Signing Key" for commit signing; GitLab: "Authentication & Signing"). -
Set up commit signing unfolds a panel with the exact commands, each with a copy button, and Copy all:
git config --global gpg.format sshgit config --global user.signingkey "key::<key type> <base64 key>"git config --global commit.gpgsign truemkdir -p ~/.ssh && echo "$(git config --get user.email) namespaces=\"git\" <key type> <base64 key>" >> ~/.ssh/allowed_signersgit config --global gpg.ssh.allowedSignersFile ~/.ssh/allowed_signersThe key is the public key without its comment, so the commands need no further quoting. Hisar never runs them: the user pastes them into a terminal, and the
allowed_signersline letsgit log --show-signatureverify the user's own signatures. Signing then goes through the agent and asks as described under Approval.
Agent
-
Implements the OpenSSH agent protocol with
golang.org/x/crypto/ssh/agent, including thesession-bind@openssh.comextension (OpenSSH 8.9 and later,PROTOCOL.agent):string hostkey, string session_id, string signature, bool is_forwarding. Asssh-agentdoes, the agent verifies the signature of the session ID with the host key (any algorithm the key allows), keeps at most 16 bindings per connection, accepts the same binding again but refuses one for the same session with a different host key or flag, and refuses any binding after one for user authentication (is_forwardingfalse). A user authentication request (publickey, orpublickey-hostbound-v00@openssh.comwith its host key) on a connection whose last binding is for authentication must carry that binding's session ID and host key. Clients that never send it (older OpenSSH, other SSH libraries,ssh-keygen) get the behaviour without it: no verified destination, and the request counts as local. -
Listens on
~/.hisar/agent.sock(directory 0700, socket 0600;HISAR_SSH_AGENT_SOCKEToverrides the path). It starts with the app and stops when the app quits; the app opens at login and keeps running without a window (§9.1.2), because an SSH client cannot start it. If the socket cannot be created (for example another agent is listening there), Settings shows the error. -
Offers the SSH keys of every vault the user can access while Hisar is unlocked; personal vault keys come first. A request from inside an agent harness (§9.4.4) gets only the keys of the vaults open to coding agents (§9.4.2), and a signature with another key fails as one the agent does not hold (see Agent sessions). Locked, a request for keys or a signature opens the approval dialog, which unlocks Hisar (see Approval); signed out, it offers no keys. It is read-only: requests to add or remove keys, and the agent's lock/unlock requests, are refused.
-
Settings → SSH & Git shows the status and, only when the user clicks "Use Hisar for SSH", adds this section to the top of
~/.ssh/configafter copying the file to~/.ssh/config.hisar-backup-<timestamp>(the file is created with mode 0600 and~/.sshwith 0700 if missing):# Added by Hisar: use the Hisar SSH agentHost *IdentityAgent "~/.hisar/agent.sock"# End of Hisar sectionNothing changes if the start line is already present. "Remove from SSH config" backs the file up again and deletes the lines between the markers; backups never overwrite each other, and a symlinked config is written through to its target.
-
Servers allow about 6 key attempts per connection (
MaxAuthTries). With more keys, the user pointsIdentityFilein~/.ssh/configat the exported public key to pick one per host; the agent still does the signing. -
Works for
gitover SSH and for SSH commit signing (gpg.format ssh), since both go through the agent.
Approval
Every use of a key asks, unless an approval for the same key and app is remembered. The dialog says who asks, for what and where, like "Terminal wants to push to git.tryhisar.com".
-
The app. Every approval (SSH sign-in and push, commit and tag signing, the command-line tool,
hisar run,hisar mcp, share-link creation from the command line) names the GUI app responsible for the request, so "iTerm2 wants to sign a commit" rather than "ssh-keygen wants to sign a commit". The agent and the command-line socket read the peer PID (LOCAL_PEERPIDon macOS,SO_PEERCREDon Linux), anddesktop/appinforesolves it (appinfo.Resolver):- It reads up to 16 processes from the peer to its oldest ancestor before
launchd(path from the kernel, arguments and working directory). - The first process, nearest first, whose outermost
.appbundle is a running app with a Dock presence (NSRunningApplication, activation policy regular) is the app; so is the app macOS holds responsible for a process (responsibility_get_pid_responsible_for_pid, which maps Electron and VS Code helpers and what they start to the app).Code Helper (Plugin).appinsideVisual Studio Code.appresolves to Visual Studio Code; Cursor, Zed, JetBrains IDEs, Terminal, Ghostty, Warp, WezTerm, Alacritty and kitty come out as themselves. Developer tools and toolchains are walked past: anything under/Library/Developer/or*.app/Contents/Developer/(/usr/bin/gitis anxcrunshim that runs Xcode'sgit), programs outside bundles and Hisar's own helpers (Hisar.app/Contents/Helpers/,Hisar.app/Contents/MacOS/hisar-*). Without a Dock app in the chain, the nearest bundle that is not a developer tool counts (a menu bar app). - A chain whose oldest process left its parent behind (its parent is
launchd) is followed further: iTerm2 runs its shells underiTermServer-<version>in~/Library/Application Support/iTerm2/, a daemon that outlives iTerm2's restarts and whose parent islaunchd, so the parent walk alone never reached iTerm2 and the dialog showedssh-keygen. A daemon outside any bundle whose code signature is valid and carries a Team ID belongs to the one running Dock app signed with the same Team ID (iTermServer and iTerm2 are both signed by H7V7XYVQ7D); with none or several, it does not count. Atmuxorscreenserver leads to its clients: when all runningtmux(orscreen) clients resolve to one app, that app, withtmuxlast in the chain; otherwise "tmux". Ansshd(orsshd-session) in the chain makes it "an SSH session from <host>" (the remote address of the session's TCP socket, read withproc_pidfdinfo), or "an SSH session". Anything else withlaunchd,cronorlaunchd-started jobs above it is "a background process"; without a parent chain it is "a process without an app".
- The headline name and icon come from the bundle on disk, never from
argv[0], the environment or the request: the name Finder shows, or the bundle'sCFBundleDisplayName/CFBundleNamewhen that extends it (iTerm.app is "iTerm2"; Visual Studio Code.app's "Code" does not count), andNSWorkspace iconForFile:rendered as a PNG, both cached per bundle path. The executable path comes from the kernel (proc_pidpath, symlinks resolved), not from the path the process was started with. The code signature of the bundle (SecStaticCodeCheckValiditywithout resources, cached per bundle path and modification time) gives the signer line "<App> is signed by Apple", "<App> is signed by <developer> ()", "<App> isn't signed" or "<App>'s signature isn't valid"; the last two show a warning, so a fake "iTerm2" with a copied icon but no valid signature stands out. - The chain under the app is listed by program name (from the kernel's executable path) nearest first, for example
ssh-keygen ← git ← zsh, without the app's own processes,loginand the daemon used in step 3; each program's arguments (withgit's credentials hidden as for the command) are in its tooltip. - The bundle path is also the app approvals are remembered for (below).
- It reads up to 16 processes from the peer to its oldest ancestor before
-
The purpose. From the same process chain (path, arguments and working directory of each process; never the environment) and the signed data, the approver derives:
ssh … git-receive-pack '<repository>'(orgit-lfs-authenticate … upload): "push to <host>";git-upload-pack,git-upload-archiveor LFSdownload: "pull from" when agit pullis above it, "clone from" forgit clone, else "fetch from";- any other
sshto a host: "sign in to <host>"; - signed data in the SSHSIG format (
ssh-keygen -Y sign) with the namespacegit: "sign a commit", or "sign the tag <name>".gitpasses the commit or tag tossh-keygen -Y sign -n git -f <key> <file>in a temporary file; the approver reads it (a regular file of at most 64 KiB, the last argument of anssh-keygen -Y signprocess in the chain) for the subject line, and the branch from the repository'sHEAD. Without it, the dialog says "a commit"; - anything else: "use your SSH key", with the destination when a session binding names one.
- The host comes from the
ssharguments (the destination,-p,-o Port=,ssh://host:port, port 22 by default), else from the session binding (the host key looked up in~/.ssh/known_hostsand/etc/ssh/ssh_known_hosts,[host]:portentries included), else the host key fingerprint. The command is the outermostgitprocess's arguments (orssh's when there is nogit), quoted like a shell would, at most 160 characters. Onlygit's own credentials are replaced by•••, and only in agitprocess's arguments: the value of a-coption whose name ends in a part that mentions a token, password, secret, header or auth (http.extraHeader,github.token), never an alias's value (-c alias.<name>=…,git config alias.<name> …), which runs; and a URL's password made only of characters that are never shell syntax, so a mask hides no command whengithands an argument to a shell (an alias,git bisect run). Every other program's arguments, a script included, are shown as written (§9.4). The repository is the working directory of thegitprocess next tosshorssh-keygen(git runs in the top directory of the work tree), with$HOMEshown as~.
-
The dialog (
?window=ssh-approval, a window created hidden at launch, reused, always on top, 480 points wide, its height fitted to the content, about 400): the title "<App> wants to <purpose> <host>", the subtitlewith your SSH key "<key title>", the app's icon with a small Hisar badge, under the title a line with the process chain (ssh-keygen ← git ← zsh) and the app's signer line ("iTerm2 is signed by Apple", "iTerm2 is signed by George Nachman (H7V7XYVQ7D)"; a red warning line for an unsigned app ("iTerm2 isn't signed") or one whose signature does not verify ("iTerm2's signature isn't valid")), a two-line monospace detail box (the command, e.g.git push origin main; then<repository> · <host>:<port>; for commit signing the subject line, then<repository> · <branch>), the checkbox "Don't ask again for <App> for 1 hour" (or "until Hisar locks", after the Settings choice; checked by default), and a footer bar with the Touch ID glyph (Apple'sLAAuthenticationView, embedded throughbiometric.WindowPromptover a slot the page measures), "Touch ID to approve" / "or use your master password", and Deny. Touch ID starts as soon as the dialog shows a request, so touching the sensor approves without a click, as in 1Password; the reason isallow "<app>" to <purpose> <host>(one Touch ID prompt at a time). If it fails, the dialog stays open with a message and Try again restarts it. "or use your master password" shows a password field instead (Return submits it; the password is checked against the account, offline from the cache). Without Touch ID, an Approve button approves. Escape, Deny and closing the window deny, and cancel a Touch ID check in progress. The checkbox can be changed while Touch ID waits; its value when the request is approved counts. -
Compact approvals (opt-in: Settings → SSH & Git → "Compact SSH approvals", off by default, stored in
desktop.jsonand kept on sign-out): a card about 380 points wide at the top right of the display under the pointer, like a notification (16 points inside the visible area: below the menu bar and notch, clear of a Dock on the right; when its content changes height the top-right corner stays put): the app's icon, "<App> · <action>",<host> with "<key title>", the warning line of an unsigned app, and inline "Touch ID to allow for 1 hour" (or "until Hisar locks"; for a commit, "Touch ID to sign") and Deny. Clicking the card expands it into the full dialog in the middle of the same display; the full dialog otherwise opens in the middle of the display under the pointer. Only requests that need nothing else use it: Hisar unlocked, Touch ID available, not forwarded; the others always open the full dialog. -
Commit signing asks once per signature by default, and is never covered by a remembered SSH approval: the dialog shows a "Sign a commit" (or "Sign a tag") badge and no checkbox.
-
Remembered commit signing (opt-in: Settings → SSH & Git → "Remember commit signing": Always ask by default, Up to 10 minutes or Up to 1 hour;
signingRememberSecondsindesktop.json, a device preference kept on sign-out). Only with the setting on does the commit signing dialog show the checkbox "Don't ask again for <App> in <repository> for 10 minutes" (or 1 hour), unchecked every time: the setting is the deliberate opt-in, and the checkbox scopes each use of it. Checked when the signature is approved, later commit signatures are signed without asking while all of these match:- the app: the bundle path that app attribution resolved (above), or the agent session for a request from inside an agent harness (see Agent sessions). A request without a resolved app ("a background process", an SSH session, tmux with clients in several apps, a program outside any bundle) and outside a harness never offers it, so it never spans apps or reaches a process the user cannot see;
- the repository: the working directory of the
gitprocess next tossh-keygen(the purpose's repository); a signature without one never offers it; - the key fingerprint.
It lasts the chosen time from the approval, not from the last use, and ends earlier when Hisar locks (the lock hook calls the approver's
ClearAllRememberedSigningnext to the agent'sClearAllApproval) or when the setting changes. Tag signatures, forwarded requests and keys set to "ask every time" always ask. Agit rebase(likewisecherry-pickoram) withcommit.gpgsignrunsssh-keygen -Y signonce per commit, each a child of the samegitin the same work tree and app, so one approval with the checkbox covers the whole run; without the checkbox it asks per commit as before. The compact card never offers it (its inline Touch ID signs once); expanding the card shows the checkbox. When Hisar is locked, the unlock dialog for a commit signature offers it too, since the signature names its key. -
Locked. When Hisar is locked and the user is signed in, a request for keys or a signature opens the dialog with "Unlock Hisar to use your SSH keys" instead of failing, and the dialog unlocks Hisar the way it does for the command-line tool (§9.4): with Touch ID when Touch ID unlock is ready (§5.4; one Touch ID check unlocks and approves), otherwise with the master password typed into the dialog (Return submits; a wrong password keeps the request open with a message). The unlock approves that agent connection, so the signature that follows on it does not ask again, and the checkbox is remembered for the key that then signs. For handed-off work (below) the unlock covers only that first signature: the connection's next one asks, even when its app became an agent host after the unlock. The unlock's approval ends when Hisar locks, signs out or switches account, and a signature whose approval was still open then is refused, since its key was read before. Deny or the timeout leave that connection without keys, as before. Signed out, the agent offers no keys and shows nothing.
-
Forwarded requests. A connection with a binding whose
is_forwardingis true is a request from a remote host through agent forwarding (ssh -A): the localsshbinds the forwarded connection to the host it connected to. The dialog then shows a red banner "Forwarded from another computer — this request comes from <host> through agent forwarding, not from this Mac" (the host of the first forwarding binding fromknown_hosts, else the localssh's destination, else the host key fingerprint), offers only "Allow once" (no checkbox, never remembered), and Deny is the default button: Return denies, and Touch ID starts only after "Allow once". It never uses the compact layout. The docs recommendProxyJumpover agent forwarding. -
Remembering. "Don't ask again" remembers the approval per (key fingerprint, app): the app bundle's path, or the program's path when there is no app; a program without a known path is never remembered. It lasts one hour (
DefaultApprovalLifetime) or until Hisar locks, whichever comes first; the vault backend calls the agent'sClearApprovalsfrom its lock hook. Remember SSH approvals (Settings → SSH & Git: Up to 1 hour by default, or Until Hisar locks;sshRememberSecondsindesktop.json, 3600 or -1, a device preference kept on sign-out) drops the hour: with Until Hisar locks the agent's approval lifetime is 0 and a remembered approval ends only when Hisar locks, signs out or quits. The dialog's checkbox, the compact card and the phone's switch say the chosen period (rememberPeriodin thessh-approvalevent: "for 1 hour" or "until Hisar locks"). Changing the setting applies to approvals already remembered. Auto-lock counts input anywhere on the Mac (§9.1), so the hour holds while the user works in other apps. Forwarded requests, commit and tag signatures (see Remembered commit signing), and keys set to "ask every time" (SetAllAlwaysAskKeyID; no UI yet) are never remembered here. -
Agent sessions (§9.4.4). The agent reads its caller's chain once per connection, as the command-line socket does (
agent.PeerResolveroverappinfo.Resolver.ReadChain), and passes the result insshagent.Peer. A chain that cannot be read (it changed during the walk, or the peer's process is unknown) is refused: the agent closes the connection, so the caller gets no keys and no signature, as the command-line socket refuses an unknown chain. A caller inside a harness joins the harness's live session inagentharness.Sessions, the one registry both sockets share (§9.4.4):- Inside a harness. The dialog names the harness and its app, "omp in Tern wants to sign in to github.com", and "Don't ask again" remembers per (key fingerprint, live session ID), so another omp, omp after a restart, or omp after Settings revoked its session, asks again, and the user's own approvals for Tern do not cover omp. Such an approval lasts one hour at most (
AgentSessionApprovalLifetime), also under Until Hisar locks, which its dialog never offers; it ends at lock and when the session ends, by the harness's exit or a revoke in Settings. The session's end also closes its SSH connections and cancels their open dialogs, and a signature is refused once the session ended or its harness no longer runs, even right after its approval. Remembered commit signing covers (live session ID, repository, key). The app the harness runs in is recorded as an agent host through the command-line socket (clisocket.Server.RecordAgentHost), as when a harness callshisarthere, so its app approvals of the command line and of launchers end the same way. - Keys. The key source sees the session in the request's context (
sshagent.GetAgentSession) and lists only keys in vaults open to coding agents, sossh-add -lshows those and a signature with another key fails. When a session gets no key at all, Hisar shows one system notification per session, "omp asked for an SSH key. No key is in a vault open to coding agents.", and clicking it opens Settings → Developer tools → Coding agents. - Handed-off work. A caller without a terminal in an agent host, whose chain has no harness, may be work an agent handed off, such as a double fork under launchd. No remembered approval of its app or program covers it, for sign-ins or commits, and its dialog has no checkbox. Whether its app is an agent host is checked on every request, so a connection opened before the app hosted a harness loses the app's remembered approval once it does. A caller with a terminal, such as the user's own Tern pane, keeps the approvals above.
- Inside a harness. The dialog names the harness and its app, "omp in Tern wants to sign in to github.com", and "Don't ask again" remembers per (key fingerprint, live session ID), so another omp, omp after a restart, or omp after Settings revoked its session, asks again, and the user's own approvals for Tern do not cover omp. Such an approval lasts one hour at most (
-
Approving on a phone. While Hisar is unlocked, the dialog is also sent to the account's phones and its footer adds "or approve on your smart device" (§9.9); a request too long for a phone stays on the Mac and the footer says so instead. A phone's approval counts as a Touch ID approval, a phone's Deny as Deny. Whether a phone's approval is remembered is the choice made on the phone, where the request shows it as a switch ("Also allows iTerm2 for 1 hour"), never the Mac's checkbox (§9.9).
-
Events. The backend emits
ssh-approval(with a random request ID and the request as shown),ssh-approval-failedandssh-approval-closed, and waits forRespondSSHApproval(requestID, isAuthorized, isRemembered)orRespondApprovalWithPassword(requestID, password);SetSSHApprovalRememberedfollows the checkbox,SetSigningRemember(seconds)stores the Settings choice (0, 600 or 3600; the status reportssigningRememberSeconds),SetSSHRemember(seconds)stores how long SSH approvals are remembered (3600 or -1; the status reportssshRememberSeconds) andFitSSHApprovalWindow(isCompact, height)sizes and places the window (centered, or at the top right). Each request has its own hard deadline, set when it arrives: 60 seconds, or 2 minutes when the iPhone can answer (§9.9). The deadline runs in the backend whatever the window does: shown, hidden, still waiting behind earlier requests, or not drawn at all because the screen is locked or asleep. At the deadline, or as soon as the client disconnects (the agent and the command-line socket read the connection ahead of the request they serve, so they see the peer close while a request waits, also behind bytes it sent after the request; a peer with more than 16 MiB unread is cut off), the request is denied, a Touch ID check in progress for it is cancelled, it leaves the window and its phone request is withdrawn. The window shows requests one at a time in the order they arrived; a stuck request holds the ones behind it at most until its own deadline, and each of them is already on the phone, which can answer them in any order. The window hides when no request is left. Showing the window and sending its events never hold up a request: they run in order on their own goroutine.ListAllPendingSSHApprovallets the window show requests that arrived before it loaded. The same dialog handles the command-line tool's requests (§9.4).
Implementation
core/sshagentimplementsagent.ExtendedAgentover two interfaces: a key source and an approver. It has no UI dependency and is tested in CI with an in-memory approver and a realx/crypto/sshclient and server.core/appstores SSH Key items (GenerateSSHKey,ImportSSHKey) and provides the key source,app.SSHKeySource. It is a separate type from the boundapp.Service, so no method callable from the UI returns private keys.desktop/agentholds the approver (Touch ID or in-app, with the Wails event emitter injected), the purpose detection, the~/.ssh/configediting and the bound status service. It does not import Wails and is unit-tested with fake process trees;main.gowires it to Touch ID, the window and the socket.desktop/appinforeads the process chain (proc_pidpathfor the executable,kern.procargs2for the arguments,proc_pidinfofor the working directory on macOS;/procon Linux) and resolves the responsible app (appinfo.Resolverover an injectedappinfo.System, so the attribution rules are unit-tested with fake process tables; the AppKit and Security code builds only fordarwin && !ios && cgo, becausedesktop/agentis also linked into the iPhone framework). Linux has no Dock apps or signatures: it shows SSH sessions,tmux, background processes and bundles found in the chain.session-bind@openssh.comis tested with fixtures recorded from a real OpenSSH client andsshd(core/sshagent/testdata/openssh: a direct login, a forwarded agent with a second hop, andssh-keygen -Y sign -n git), whichHISAR_RECORD_OPENSSH_FIXTURE=1 go test -run TestRecordOpenSSHFixture ./sshagent/records again wheressh,ssh-keygenandsshdare installed, and with hand-built messages for the error cases.- The end-to-end test generates a key in a vault, serves it with the agent on a Unix socket and logs in to an in-process
x/crypto/sshserver with a real SSH client; after Lock the agent lists no keys. - Linux uses the same Unix socket; Windows (named pipe
\\.\pipe\openssh-ssh-agent) comes later. The CLI getshisar ssh-key generate|import|list|public-key.
9.7 Import from other password managers
core/importer turns an export into items. It is pure Go: it reads only the bytes it is given, never logs, and its errors carry positions (CSV line, JSON byte offset) but never values from the file.
| Format | Detected by | Imported | Not imported (reported with a reason) |
|---|---|---|---|
1Password .1pux (ZIP with export.data) | ZIP signature | Login and Password items with extra login fields and section fields (concealed fields stay concealed); Secure Notes; Wireless Router and Email Account items as logins with their fields; Databases as databases (the type as the engine, mssql as SQL Server and others such as DB2 kept as typed, the server as the host, split into host and port, port, database and connection options as the database's fields, the SID as the database name when there is none (as Oracle names its database) and otherwise a custom field, the username and password as the item's own, alias as a custom field); Servers as servers (the URL as the host, split into host and port, the username and password as the item's own, the admin console URL as its website unless the console has a username or password of its own, which keeps the console URL, username and password as custom fields; a console username or password repeating the server's is dropped; the hosting provider details as custom fields); Software Licenses as software licenses (the title as the product; version, license key, licensed to, registered email, purchase date and download page as the license's fields, a website as the download URL when it has no download page; company, publisher, price and order details as custom fields, and a username or password as custom fields); API Credentials as API credentials (username as the key ID, credential as the secret, hostname as the host and expires as the expiry; type, filename and valid from as custom fields; Service stays empty, since the title names the service); Credit Cards (cardholder, number, expiry, verification number, PIN and billing address as the card's fields, the address's street, city, state, ZIP and country as Street, City, Region, Postal code and Country, the rest as custom fields); Identity, Passport, Driver License and Social Security Number items as identities (name, birth date, email, the first phone number, address and the document's number as its passport, license or national ID number, the address split into its fields as for a card, an address that is only text kept as the one-line Address, a document's single full name split into first and last name, a license's country and state as Issuing country and Issuing state, the rest such as expiry date or license class as custom fields); SSH Keys with a private key | documents and other personal records; archived items; attachments |
| 1Password CSV | header with Title, OTPAuth and Archived | title, URL, username, password, one-time password, tags, notes | archived rows |
| Bitwarden JSON (not encrypted) | JSON with items | logins with URIs allowed for matching (rules below), notes, custom fields (hidden ones concealed, linked ones dropped), TOTP and concealed previous-password fields; secure notes; cards (cardholder, number, expiry and security code; the brand comes from the number); identities (names, email, phone, address lines 1 to 3 as the street's lines, city, state as the region, postal code and country, passport, license and SSN as the national ID number; title, company and username as custom fields); SSH keys with custom fields; folder as tag | encrypted exports (the whole file is refused), passphrase-protected SSH keys, attachments and passkeys; broader URI matching and lost master-password re-prompt policy are reported |
| Bitwarden CSV | login_username/login_password/login_uri columns | as JSON, without hidden-field markers | cards and identities (the CSV leaves out their details; export as JSON), rows of other types |
| LastPass CSV | header with extra, grouping and url | logins (URL, username, password, totp, notes from extra); the folder (grouping) as one tag, nested folders' \ written as / ((none) is no folder); fav; http://sn rows as secure notes, and typed notes (NoteType: lines in extra, core/importer/lastpass.go) as their Hisar type: Credit Card → card (LastPass's "January,2027" expiry read as 01/27), Address → identity when it has a name, email, phone or birthday, else an address item (phones from LastPass's JSON as text), Server → server (host:port split), Database → database (engine matched; SID as the name when Database is empty), Software License → software license (the item name as the product), SSH Key → ssh-key (opened with its Passphrase; the host and date go to its notes). Values a type does not use, and every value of the other types (Bank Account, Driver's License, Passport, Wi-Fi Password, custom types…), become custom fields of the item or of a secure note with a Type field; passwords, PINs, security codes, numbers and keys concealed; empty dates (, and ,,) are left blank; everything after Notes: is the notes | rows with no content; http://group folder rows; an SSH Key note whose key can't be read becomes a secure note instead |
| CSV (Chrome, Edge, Firefox, Safari and others) | a header row naming the columns | title (or the website's host), username, password, URL, notes, one-time password; other columns become fields. Comma, semicolon or tab separated; BOM, CRLF and quoted multiline values | rows with no content |
| Hisar export, encrypted or plain JSON (§9.11; CLI) | JSON with format hisar-encrypted-export or hisar-export | every item of every vault, vault by vault (below) | — |
| Credential Exchange (FIDO CXF 1.x; iPhone and Android) | handed over by iOS or by Android's credential transfer, not a file (below) | logins (basic-auth, generated-password, the scope's URLs and Android apps, totp, note, custom-fields, tags, favorite) with their passkeys; cards; API keys; identities from a person's name, passport, driver's license and ID document with the first address; addresses; Wi-Fi as a secure note with fields; SSH keys (PKCS#8, as with other SSH keys); a record on a login becomes custom fields | passkeys whose key is not P-256 (named with the exporting app to keep using) or that are not valid (public-suffix RP ID, empty user handle), credential types Hisar has no place for (named in the reason), a scope's Android app without the SHA-256 of its signing certificate or on an item that is not a login (named), item references (silently) |
.env (CLI) | the file name, or -format dotenv | the chosen variables as fields of one item; the file is rewritten to secret references (below) | empty values and existing references |
Migration preserves supported fields, not every source manager's features or metadata. The existing preview's omitted entries include both skipped items and values omitted from otherwise imported items. Each names the source item and the missing data. Attachments and Bitwarden passkeys are not imported. Do not retire the source manager until sign-in methods and recovery material have been checked. Provider associations and unrecognized structured metadata are not yet inventoried and can still be lost.
Source access boundaries stay separate through parsing, preview, and storage. importer.Result.Groups holds a stable source ID,
the source label, its private status, and its items. A 1PUX group is one account's vault. A Bitwarden JSON group is one organization
and exact sorted collection-ID set, including organization items without a collection. An item in several collections is not
copied into destinations with different access. A Credential Exchange group is one CXF account and the exact set of collections
(nested ones included, and LinkedItem.account links into another account) that list the item; an item in no collection is in its
account's own group. CXF collections organize items and grant no access, so every CXF group is labelled private or unspecified,
never shared. Formats without access metadata remain usable as a private or unspecified group.
Exact repeat removal is local to each source group.
Every group initially targets the account's Personal vault, regardless of the currently selected vault. Desktop previews let the
user map each group independently. Shared destinations show current member identities and roles before the user starts the import.
Credential Exchange on iPhone and Android uses the same Personal default. With one group the sheet keeps its single vault choice;
with several, each group has its own vault choice (MapImportGroup, HisarKit mapImportGroup) and shows its destination's members,
and one choice is never applied to every group.
Imports never create sharing grants or auto-create source vaults.
Each import item carries the reviewed destination's scope hash, computed from its vault ID and sorted account-ID/role pairs.
PUT /vaults/{vault}/items/{item}/import requires this scope. The store compares it while holding the same vault row lock that
access changes and item writes take, then checks write permission and key generation before persisting the item.
A membership change stops remaining writes and keeps the source file. Already committed items stay saved. The route refuses a
changed scope with 412 Precondition Failed, not 409, so no client reads it as an item conflict; the Go client returns
session.ErrImportScopeChanged, which pauses the import and says the destination's members changed.
Stop the paused import and preview the source again to review the new scope. Later sharing can still expose previously imported data.
An old server has no protected import route and refuses it. CLI app mode requires the import-scope feature and sends
Request.importScope; apps refuse legacy import requests without a reviewed scope.
-
A
.1puxmust haveexport.attributeswith a supported version from 1 through 3. Missing, malformed or newer metadata is refused. SSH items keep their sibling fields, tags, notes and URLs. A Server's admin-console URL becomes a website only when both console credentials exactly match the host's credentials, including blanks. Otherwise its console URL and credentials stay fields. -
1Password and Bitwarden password histories become concealed fields named "Previous password" with the date when available. When full item contents are available, duplicate matches with different passwords are labelled "different password" on the preview, rather than implying that the saved password is identical. CLI app mode receives only item summaries, so it reports that duplicate contents could not be compared instead.
-
Bitwarden Never and Regular expression URIs become custom fields instead of autofill websites. Host, Starts with and Exact match URIs remain websites, with a warning that Hisar's matching is broader. The per-item master-password re-prompt policy is also reported as unsupported in JSON and CSV imports.
-
CSV reads are bounded at 100 MiB, including pipes and files that grow. Rows are limited to 1000 columns before field allocation. Overflow cells become concealed "Column N" fields. Repeated password columns and secret-named columns are concealed. Secret and overflow cells retain exact nonempty bytes, including whitespace-only secrets and nonbreaking spaces. Nonsecret extra columns discard whitespace-only cells but preserve nonblank values as written.
-
LastPass typed notes preserve password and secret bytes, including whitespace-only and comma-only secrets. A leading-space line continues the preceding field only inside a PEM block. The comma sentinel applies only to date-labelled fields. API Key, Recovery Code and Token fields are concealed, including siblings of successfully imported SSH keys.
-
Credential Exchange preserves secret bytes, including whitespace-only values. Named-record normalization preserves API-key secrets, card PINs and CVVs, identity document numbers and license keys on import and every save, matching login passwords. Browser card filling trims surrounding CVV whitespace without changing its stored bytes. PINs are not filled. Nonsecret display fields still trim whitespace, and card numbers retain their existing formatting. Basic authentication wins over a generated password regardless of their order, and the generated value remains a concealed field. Wi-Fi passwords, card numbers, CVVs, PINs and API keys are concealed by their meaning, regardless of the source field type. Additional identity numbers and full names remain custom fields rather than disappearing.
-
Imported passkey PRF
credWithUVmaterial is retained for lengths from 16 through 64 bytes. DiscardedcredWithoutUV, unsupported PRF material, credential extension data such ascredBlobandlargeBlob, and TOTP parameters Hisar cannot generate are reported as omissions. CXF item references remain ignored without a per-item omission. -
Secure notes are saved as
secure-noteitems (§4.5): 1Password Secure Notes, Bitwarden secure notes (JSON type 2 and CSVtypenote) and LastPasshttp://snrows (without a note type Hisar has an item for), even with only custom fields or nothing but a title, and any other entry with nothing but notes (and custom fields). A source note that comes with a username, password or website stays a login, so they are shown. -
An entry that repeats an earlier one exactly in the same source group is reported, not imported twice. Copies in different source access groups remain separate. Items already in the chosen target vault are matched by title, username and the first website (case and
www./scheme ignored; SSH keys by fingerprint) and skipped unless the user includes them. -
One-time passwords (1Password
totpfields andotpauth://values, Bitwardenlogin.totp, a CSVotpauth/totp/otpcolumn) go throughtotp.Normalizeintodetails.oneTimePassword(§4.5). One that does not parse, such as a Steam code, or a second one on the same item, stays a concealed field. Recognized OTP query parameter names are percent-decoded before malformed or repeated parameters are rejected. A recognized name immediately followed by an invalid percent escape is also rejected rather than silently selecting defaults, including when the name contains earlier valid escapes. Classification decodes the prefix before the first invalid escape by position. Unknown malformed parameters remain tolerated. -
SSH private keys go through
sshagent.ImportPrivateKeyand are stored as OpenSSH keys, as in §9.6. -
Passkeys come only through Credential Exchange: 1Password, for one, leaves them out of
.1puxand CSV. A CXF passkey'skeyis PKCS#8 (an EC key in SEC 1 is accepted too) and is stored as PKCS#8 afterpasskey.CheckImportedPasskeychecks the RP ID, the credential ID and user handle lengths and the P-256 curve; itshmacCredentials.credWithUVbecomesprfSecret(§4.5). A login without a website getshttps://<rpId>, and one without a username the passkey's user name.StartImportleaves out a passkey whose credential ID the target vault already has, so importing again adds none twice, and skipping duplicates still adds a duplicate login's new passkeys to the login the vault has (ImportDuplicate.passkeyCountsays how many): passkeys exported after their logins were imported from a file arrive in those logins. -
The desktop 1PUX instructions, desktop preview and CLI import preview warn that 1Password desktop
.1puxexports do not include passkeys, and tell users to keep 1Password until those passkeys have moved separately. iPhone and Android use Credential Exchange rather than file import. -
iPhone (Credential Exchange): the AutoFill extension declares
SupportsCredentialExchangeandSupportedCredentialExchangeVersions["1.0"], and the app lists theASCredentialExchangeActivityuser activity. When the user exports from another app (1Password on iOS: Settings → Export → Hisar), iOS opens Hisar with an import token; once Hisar is unlocked,ASCredentialImportManager.importCredentials(token:)returnsASExportedCredentialData, which the app encodes withJSONEncoder(dates as seconds since 1970; Apple's encoder writes the CXF JSON, binary values as base64url) and hands toapp.PrepareCredentialExchangeImport(hisarkitPrepareCredentialExchangeImport). The preview, vault choice, duplicates and batches then work as on the desktop; the data is held only in memory. The token expires, so a user who was signed out runs the export again. -
Android (Credential Exchange): Hisar is the importer of Credential Manager's credential transfer (
androidx.credentials.providerevents1.0.0-beta01, the newest release; none is stable yet, and the-play-servicesartifact routes it through Google Play services). Settings → Account → "Import from another app…" callsProviderEventsManager.importCredentialswith every CXF credential typecore/importerreads (all butfileanditem-reference) and no known extensions; Google Play services lists the apps that registered anExportEntryfor those types, the user picks one, and that app writes the CXF JSON (responseJson), which goes toapp.PrepareCredentialExchangeImportas on the iPhone. Unlike iOS, Android cannot hold the transfer back until Hisar is unlocked: JSON that arrives after Hisar locked (the user was in the other app) waits in memory for the unlock, and signing out drops it. Closing the system's list or the other app says nothing; each otherImportCredentials*Exceptionhas its own sentence. The library hands the JSON over in a file under the app's cache (import_export_temp) and deletes it once read; Hisar also deletes that folder when it starts. The preview, vault choice, duplicates and batches work as on the iPhone (CredentialImportModel,CredentialImportScreen). -
A CXF scope's
androidAppsentry (bundleIdand the base64urlcertificate.fingerprintwithhashAlgsha256) becomes an Android app link of the login (§9.2), after its websites; a passkey's site is still added ashttps://<rpId>when the scope has no URL. Hisar needs the certificate's SHA-256, so an app without one (or with onlysha512) is reported, as is an app on an item that does not fill logins. -
An imported SSH key uses its item title as the key comment. Source comments are not copied. A differing nonempty CXF
keyComment, OpenSSH private-key comment, or BitwardenpublicKeycomment is reported as an omission and keeps the source. Encrypted OpenSSH comments cannot be inspected by the importer, so those imports report an unverified-comment omission and keep the source too. -
Desktop (
core/app/import.go): the file comes from a native open dialog; the parsed items stay in memory only until the import finishes, is cancelled or Hisar locks. Items are saved in batches of 10 called by the UI, so it can show progress, and connection, permission and rate-limit errors pause the import instead of failing every remaining item. Core computesImportProgress.leftBehindCountfrom failed items, parser omissions other than exact in-file repeats, and skipped vault duplicates with different content. Desktop offers deletion only when this count is zero, and core enforces the same condition. Duplicate content comparison excludes account favorites, compares SSH keys by their public key rather than randomized OpenSSH encoding, and checks that imported passkeys are present after merging. A merged passkey does not count as left behind unless other imported content differs or the save fails. Exact repeats and identical vault duplicates do not prevent deletion. Core and CLI share the importer's failure-plus-omission count without changing the socket protocol. The CLI does not merge passkeys. -
Favorites (§4.7): 1Password's
favIndex(.1pux, a favorite above 0) andFavoritecolumn (CSV,true), Bitwarden'sfavorite(JSONtrue, CSV1) and LastPass'sfavcolumn (1) mark imported items, which become the importing user's favorites once they are saved: one save of the preferences record for the whole import on the Mac and the iPhone, one per batch through the app and once standalone inhisar. A repeated entry keeps the mark of either copy. Saving the favorites never fails the import. -
CLI:
hisar import <file> [-v vault] [-format f] [-y] [-include-duplicates]parses the file before asking for the master password and asks before saving and before deleting the file;-yimports without questions and never deletes. Standalone mode compares duplicate contents using full items. App mode matches only the available summary metadata, does not compare passwords or other omitted content, and counts every skipped duplicate as left behind. It keeps the source without a deletion offer whenever a skipped duplicate could not be compared. SSH fingerprints are absent from app summaries, so those summaries cannot identify duplicate SSH keys. For password-manager files and Hisar exports,--dry-runprints source group IDs, destinations, and current member scope without writing. Repeat--map '<source-group-ID>=<destination-vault-ID>'to map individual groups. Unmapped groups default to Personal, including with-y.-vis an explicit destination only for a single source group; multi-group files require individual mappings instead of flattening.-yaccepts those explicit mappings without prompts, still prints their current access scope, and never deletes the source.
Hisar's own export (§9.11, CLI only). hisar import recognizes both formats by their format field unless another -format is specified. An encrypted export asks for its passphrase before contacting the backend. A wrong passphrase writes nothing. Interactive restore suggests a writable destination with the same stable vault ID and type, then shows its current members for explicit confirmation. A matching name never supplies destination authority. Missing IDs default to Personal. Noninteractive -y restores default every unmapped source vault to Personal, even if a shared destination has the same ID. Use --map '<exported-vault-ID>=<destination-vault-ID>' to explicitly restore a shared group. A single-group export also accepts an explicit unambiguous name or ID with -v. Restore creates no vaults or sharing grants. Items retain the export's supported fields and receive new IDs. Plain exports are offered for deletion only under the retention checks below; encrypted exports are kept.
The plain-export deletion offer requires every queued item to be saved and no skipped duplicate with unsaved content. Standalone mode uses full-content comparison. App mode keeps the source whenever a skipped duplicate could not be compared.
.env files (CLI). hisar import .env [--vault v --item i] [--keys K1,K2] [--dry-run] [-y] moves secrets out of a dotenv file into one item and leaves references behind, for hisar run --env-file (§9.4.1). A file named .env, .env.* or *.env, or -format dotenv, is read as dotenv with the syntax of §9.4.1.
The plan shows the destination's current members before saving. Both new items and updates carry the reviewed import scope,
so a changed member list stops the save before the dotenv file is rewritten. An offline --dry-run with explicit vault and
item names still contacts no backend and states that destination membership was not checked.
- Choosing. Every variable with a value is a candidate; empty ones and values that are already
hisar://references are kept as they are. Values that look like configuration start unchosen:PORT,HOST,NODE_ENVand similar names, names ending in_PORT,_HOST,_ENV,_MODE,_LEVEL,_REGION,_TIMEOUT,_VERSION,_DEBUG,_ENABLED,_NAME,_URL,_URI,_ENDPOINT,_DOMAIN,_ORIGIN,_ADDRor_ADDRESS, numbers,true/false-like switches, and URLs without credentials. A name with a word such as SECRET, TOKEN, PASSWORD, PASS, KEY, PRIVATE, CREDENTIAL, AUTH, SALT, DSN or WEBHOOK, a URL with a password or a credential-like query parameter, and everything else start chosen. In a terminal the list shows[x]/[ ]per variable with the reason and takes numbers to switch them;--keyschooses exactly those names. - Where. In a terminal it asks for the vault (the writable ones, Personal first) and the item name (the directory's name, plus the environment for
.env.production); otherwise--vaultand--itemare required. An existing item with that title is updated (a field with the same label is replaced, others are kept); otherwise a login with only these fields is created. Each value becomes a concealed field labelled with the variable's name;PASSWORD,USERNAME,NOTESandURLfill the built-in field a reference with that name reads, andOTP,CODEandFINGERPRINTare refused because a reference with that name means something else. Before saving, every chosen reference is resolved against the item as it will be saved. - Rewriting. Each chosen line becomes
KEY=hisar://<vault>/<item>/<KEY>, encoded like "Copy secret reference" (the vault's ID when its name is shared by another vault). Everything else stays byte for byte: comments, blank lines, order,exportprefixes, indentation, the original quotes around the new value, a comment after the value, unchosen lines, CRLF line endings and a byte order mark. A multiline quoted value becomes one line. A name set more than once is rewritten everywhere and stores the last value, the onehisar runreads, and the plan says so when the values differed. The file is written to a temporary file in the same directory with the original mode and renamed over it (through a symlink, to its target), so it is never half-written and no copy of the old content is kept. The item is saved before the file is rewritten; if the rewrite fails, the message says the values are already stored. - Before storing dotenv values and immediately before replacing the file, Hisar checks the resolved target path, permissions and SHA-256 of its content against the import plan. A stale or redirected path is refused rather than overwritten. An editor's atomic save with identical bytes and permissions remains valid. This is not a filesystem compare-and-swap guarantee against another writer racing the final rename.
- Plan. The command prints what it will store and every rewritten line before it asks "Continue?" (
-yskips it; without a terminal-yis required).--dry-runprints the plan and writes nothing; with--vaultand--itemit contacts neither the app nor the server. Afterwards it prints "Run a command with them: hisar run --env-file .env -- <command>".
9.8 Passkeys (WebAuthn)
Hisar is a passkey provider for Chromium browsers: the extension takes over navigator.credentials.create and get for public-key credentials, and the desktop app holds the keys and signs. Private keys never leave the desktop's Go backend; neither the extension nor the UI sees them. On iOS the AutoFill extension is a passkey credential provider for Safari and apps, with the same authenticator and the passkeys already stored in login items; there the keys stay in the Go backend linked into the app and the extension (see iOS below).
Authenticator
| Property | Value |
|---|---|
| Algorithm | ES256 only (COSE -7, P-256). A create whose non-empty pubKeyCredParams lacks -7 is left to the browser |
| AAGUID | fb041679-042a-49ae-b616-8779e2ac14ce, fixed for Hisar |
| Credential ID | 32 random bytes; every credential is discoverable (stored with its user handle) |
| Sign count | always 0, as for other synced passkeys; relying parties then skip the counter check |
| Flags | UP, BE and BS; AT on create; UV only after fresh native verification for this ceremony |
| Attestation | none (fmt: "none", empty attStmt), whatever the relying party asks |
| Extensions | prf (below); credProps reports {rk: true} when asked on create; others are ignored |
| Transports | ["hybrid", "internal"], attachment platform |
- User presence and verification. A browser ceremony needs a confirmation in Hisar's extension prompt. A native ceremony uses the user's choice in the system credential UI. Presence does not establish verification. The extension and native callers carry the RP's
required,preferredordiscouragedpolicy to Go. Forrequiredandpreferred, Go invokes the trusted native verifier after selection and before private-key use. The desktop asks for Touch ID or verifies the master password in Hisar's approval window. If Hisar locks while that window is open, the request ends there, without unlocking Hisar or prompting. Native platforms use the device-owner callbacks described below. A declined or unavailable check returns an error, never a credential with fabricated UV.discouragedwithout a check sets UV=0. An earlier app unlock does not authorize the final ceremony. - Authorization lifetime. The decoded request, selected credential or destination, and PRF input remain fixed during verification. The result belongs only to that invocation. Go checks the account's session epoch, native request cancellation, browser pairing and request lifetime again under the signing or save mutex. Evidence expires after two minutes and is consumed once. A lock, account switch, dismissal, navigation or request replacement cannot reuse a late callback. No page or extension request can assert a
userVerifiedboolean. - Peer cutover. The app advertises
passkey-verification-v2inhello. Updated extensions refuse a Hisar passkey ceremony when the host lacks it. Updated hosts require an explicit policy and ceremony ID, and reject policy-unaware peers withupdate-required. HisarKit retains its released exported signatures.CreatePasskeyrequiresuserVerificationin its JSON. Native assertion callers useAssertPasskeyWithPolicy; the oldAssertPasskeyentry refuses with an update instruction. - PRF verification. Releasing PRF output requires fresh verification even when the RP asks for
discouraged. A capability probe that returns only support information does not establish UV or release PRF output. core/passkeybuilds the authenticator data, the COSE key and the attestation object (CTAP2 canonical CBOR, hand-written for these few shapes) and signs withcrypto/ecdsa(ASN.1 DER signature overauthenticatorData || SHA-256(clientDataJSON)).
Origin and relying party ID
- The origin never comes from the page. The service worker takes it from the message sender: the calling frame's
sender.origin(which must equal the origin ofsender.url), and the top-level origin fromsender.tab.url. - Only
https:origins, andhttp://localhostorhttp://*.localhost, as in browsers. - The RP ID defaults to the origin's host. An explicit RP ID must equal the host or be a suffix of it at a label boundary, and must not be a public suffix (
golang.org/x/net/publicsuffix, as for URL matching in §9.2); for IP addresses it must equal the host. The desktop checks this on every passkey request, so the extension never decides it alone. - Frames: in the top frame and in frames with the top frame's origin (the frame rule of §9.2),
crossOriginis false. A cross-origin frame is served only when its permissions policy allowspublickey-credentials-get(or-create), which the isolated content script checks withdocument.featurePolicy.allowsFeature; thencrossOriginis true andtopOriginis the tab's origin. Otherwise the browser decides. - The desktop builds
clientDataJSONas{"type","challenge","origin","crossOrigin"[,"topOrigin"]}in this order, with the challenge in base64url without padding. - On Android, browsers send their own client data hash; a native app calling through Credential Manager sends none, so hisarkit's
BuildPasskeyClientDatabuildsclientDataJSONwith the originandroid:apk-key-hash:and the unpadded base64url SHA-256 of the app's signing certificate (BuildAndroidAppOrigin), and adds"androidPackageName"aftercrossOrigin, as Android's sample provider does. Checking that the app may speak for the RP ID is the Android app's job (Digital Asset Links).
Creating a passkey
- The prompt shows "Save passkey" for the user name and site, with matching writable logins (§9.2) and "New login". Only a matching username in the account's primary Personal vault may be preselected. Otherwise "New login" in Personal stays selected, even when a shared login matches. Shared destinations remain explicit choices and visibly warn that other vault members can sign in with the passkey. The backend supplies primary-Personal identity by vault ID, not its display name.
- A new login is created in the Personal vault, titled by the RP ID without
www., with the user name as its username andhttps://<rpId>as its URL. - Saving to a login that already has a passkey with the same RP ID and user handle replaces it, as WebAuthn requires for discoverable credentials.
excludeCredentials: when Hisar already has one of the listed credentials for the RP ID, the prompt says so; "OK" rejects withInvalidStateError, and "Use another device or browser" falls back. The desktop refuses such a create too.
Signing in
- The prompt lists Hisar's passkeys whose RP ID equals the request's (title, user name), only those in
allowCredentialswhen it is not empty. Choosing one signs. With no matching passkey, Hisar steps aside and the browser handles the request without a Hisar prompt. - Conditional mediation (
mediation: "conditional", passkey autofill): when Hisar is unlocked and has passkeys for the RP, the request stays pending, and focusing an input whoseautocompletecontainswebauthnopens the prompt, at the top right like the modal one so it does not cover the inline menu under the field. Otherwise the browser's own conditional request runs. Conditional requests have no timeout.
Extension
passkey-main.js, a content script in the MAIN world atdocument_startin all frames, replacesnavigator.credentials.createandget,PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(true when the browser's is, or when this browser is paired with a signed-in Hisar) andisConditionalMediationAvailable(true). Requests withoutpublicKeygo to the browser untouched.- It turns the options into JSON (BufferSources as base64url) and dispatches a
CustomEventwith a JSON string ondocument.passkey-content.js, an isolated-world content script atdocument_start, forwards it to the service worker aspasskey.startand answers with another event. Only strings cross between the worlds. A page that fakes these events gains nothing it could not get by callingnavigator.credentialsitself, and the origin still comes from the sender. - The service worker validates the request (challenge 1–1024 bytes, user ID 1–64 bytes, at most 64 listed credentials of at most 1023 bytes, names at most 256 characters) and answers
fallback,prompt(with a session and the prompt URL) or an error name. - Falls back to the browser without a prompt when the native host or Hisar is unavailable, the browser is not paired, Hisar is signed out, the request is not for ES256 or asks for a
cross-platformauthenticator, Hisar has no matching passkey (get), or the desktop refuses the request (for example an RP ID that does not fit the origin, which the browser then rejects itself). - Locked: the prompt shows "Unlock Hisar" (as the inline menu:
unlock, then polling) and continues once Hisar is unlocked. Closing it while locked falls back to the browser. - Prompt: an extension page (
passkey-prompt.html,web_accessible_resourceswithuse_dynamic_url) in an iframe in a closed shadow root at the top right of the calling frame. It uses the inline menu's style and its clickjacking defences (§9.2): before every action that reaches the desktop, the service worker asks the content script whether the prompt was visible and uncovered for 300 ms; the page ignores untrusted clicks, and uses IntersectionObserver v2 where available. It always offers "Use another device or browser", which calls the browser's original implementation with the same options. Closing it (×, Escape) rejects withNotAllowedError. - Timeouts and aborts: the content script rejects with
NotAllowedErrorafter the request'stimeout(clamped to 30 s–10 min, 5 min by default). An abortedsignalcloses the prompt and rejects with the signal's reason. - Signals and capabilities: the same script defines or wraps
PublicKeyCredential.signalUnknownCredential,signalAllAcceptedCredentials,signalCurrentUserDetailsandgetClientCapabilities(below). - Result: the MAIN-world script builds the credential with
PublicKeyCredential.prototype(andAuthenticatorAttestationResponse.prototypeorAuthenticatorAssertionResponse.prototype) as prototypes and own properties, soinstanceofchecks pass:id,rawId,type,authenticatorAttachment,response(clientDataJSON;attestationObject,getAuthenticatorData(),getPublicKey()as SPKI DER,getPublicKeyAlgorithm()andgetTransports()for create;authenticatorData,signatureanduserHandlefor get),getClientExtensionResults()andtoJSON(). PRF results are ArrayBuffers ingetClientExtensionResults()and base64url intoJSON().
PRF extension (WebAuthn PRF extension)
Each passkey Hisar created has a PRF secret that is derived from its private key, not stored, so passkeys created before PRF support have it too and every Hisar client (iOS later) derives the same value. A passkey imported through Credential Exchange uses its stored prfSecret (§4.5) instead of the derived one:
d = the P-256 private scalar of item.Passkey.privateKey, 32 bytes big-endian
prfSecret = HKDF-SHA256(IKM = d, salt = "" (empty), info = "hisar passkey prf v1", L = 32)
prf(input) = HMAC-SHA256(key = prfSecret, SHA-256("WebAuthn PRF" || 0x00 || input))
prf(input) applies the salt transformation the spec defines for hmac-secret to the page's input; these concatenations are the spec's plain byte concatenations, not length-prefixed (§4.5). core/passkey holds the derivation; the secret and the key never leave the desktop's Go backend.
- Create: when the request has
extensions.prf, the result reportsprf: {enabled: true}; when it hasprf.eval, alsoresults(first, andsecondwhen given) for the new credential.prf.evalByCredentialon create rejects withNotSupportedError. - Get:
prf.evalandprf.evalByCredential(a record of base64url credential IDs). The MAIN-world script checks them as the spec's client does: a non-emptyevalByCredentialwith an emptyallowCredentialsrejects withNotSupportedError; a key that is empty, not base64url, or not the ID of anallowCredentialsentry rejects withSyntaxError. The desktop checks this again and evaluates only for the credential that signs:evalByCredential[credentialId]when present, elseeval. The result hasprf: {results: {first[, second]}}, orprf: {}when no input applies. - Inputs are 0–1024 bytes; a larger one leaves the request to the browser.
- When the desktop is older and answers without
prf, the page getsprf: {enabled: false}on create andprf: {}on get.
Signals (WebAuthn signal methods)
signalUnknownCredential({rpId, credentialId}),signalAllAcceptedCredentials({rpId, userId, allAcceptedCredentialIds})andsignalCurrentUserDetails({rpId, userId, name, displayName})reject withTypeErrorwhen a required member is missing or an ID is not base64url. The service worker then checksrpIdagainst the calling frame's origin from the message sender (as for create and get): it must equal the host or be a suffix of it at a label boundary with at least two labels (IP addresses: equal). Otherwise the promise rejects withSecurityError. The browser's own method, which the script also calls when it exists, applies the public suffix list, and the desktop checks again withResolveRelyingPartyID. Valid signals resolve withundefinedwithout waiting for Hisar.- The service worker forwards a valid signal only when it already has a connection to Hisar and is paired: a signal never starts the native host or Hisar. Signals are best effort, so nothing reports back to the page.
- The desktop acts only when unlocked (without counting as activity for auto-lock); locked or signed out, it ignores the signal. It touches only passkeys whose RP ID equals the checked one, in login items of vaults the user can write:
- unknown credential: removes that passkey from its item;
- all accepted credentials: removes the passkeys with that RP ID and user handle whose credential ID is not in the list (an empty list removes all of them, as the spec says);
- current user details: sets
userNameanduserDisplayName(trimmed, at most 256 characters) of the passkeys with that RP ID and user handle.
- Changes go through the normal item save (new revision, synced, kept in the item history like any edit), without a prompt, and only when something changed, so sites that signal on every sign-in cause no writes. Nothing else in the item changes: an item whose only content was the passkey stays, without it.
Client capabilities
PublicKeyCredential.getClientCapabilities() returns the browser's capabilities (when the browser has the method) merged with Hisar's: signalUnknownCredential, signalAllAcceptedCredentials and signalCurrentUserDetails are always true (the script implements them); passkeyPlatformAuthenticator, userVerifyingPlatformAuthenticator, conditionalGet and extension:prf are true when the browser's are, or when Hisar is available (the same check as isUserVerifyingPlatformAuthenticatorAvailable: paired, connected, signed in, never starting the native host). conditionalCreate and every other capability stay as the browser reports them, since Hisar leaves conditional create to the browser.
Messages (extension ⇄ desktop, §9.2 framing, all need the token). Parameters are in a nested passkey object; binary values are base64url without padding.
| Type | passkey parameters | Result |
|---|---|---|
passkeys.list | origin, relyingPartyId, allowCredentials | passkeys: [{credentialId, vaultID, itemID, title, userName, userDisplayName}] |
passkeys.prepare | origin, relyingPartyId, excludeCredentials | isExcluded, logins (writable logins matching the origin) |
passkeys.create | origin, topOrigin, isCrossOrigin, relyingPartyId, userHandle, userName, userDisplayName, challenge, excludeCredentials, optional vaultID + itemID, optional prf ({eval?: {first, second?}}) | credentialId, clientDataJSON, attestationObject, authenticatorData, publicKey, publicKeyAlgorithm, vaultID, itemID, and prf ({enabled: true, results?: {first, second?}}) when asked |
passkeys.get | origin, topOrigin, isCrossOrigin, relyingPartyId, challenge, allowCredentials, credentialId, optional prf ({eval?, evalByCredential?}) | credentialId, clientDataJSON, authenticatorData, signature, userHandle, and prf ({results?: {first, second?}}) when asked |
passkeys.signalUnknownCredential | origin, relyingPartyId, credentialId | changedPasskeyCount |
passkeys.signalAllAcceptedCredentials | origin, relyingPartyId, userHandle, allAcceptedCredentialIds | changedPasskeyCount |
passkeys.signalCurrentUserDetails | origin, relyingPartyId, userHandle, userName, userDisplayName | changedPasskeyCount |
The desktop checks the RP ID against the origin on each request, applies allowCredentials and excludeCredentials again, and signs only with a credential whose RP ID equals the request's. The signal requests answer changedPasskeyCount: 0 when Hisar is locked or signed out.
Desktop app and CLI
core/app/passkey.gofinds, creates and signs. These are unexported methods or plain functions, so nothing bound to the UI returns a private key.ItemDetail.passkeyscarries{credentialId, relyingPartyId, userName, userDisplayName, createdAt}.- The item view lists the passkeys (site, user name, created) with Delete (
DeletePasskey(vaultID, itemID, credentialId), after a confirmation). The editor shows them read-only and the backend keeps them. hisar getprintspasskey: <rpId> (<user name>)per passkey, never the key.
iOS (AutoFill credential provider)
- The extension declares
ProvidesPasskeysnext toProvidesPasswordsandProvidesOneTimeCodes. iOS checks the site's or app's associated domain itself and hands the provider the RP ID and onlyclientDataHash, neverclientDataJSONor the origin. Hisar signs over the hash it is given. - Same authenticator:
core/passkeyhasCreateCredentialWithClientDataHashandSignAssertionWithClientDataHashnext to the browser functions, which build and hash their ownclientDataJSONand then share the same code for the key, the authenticator data, the attestation object and the signature. AAGUID, flags, sign count 0,noneattestation and ES256 are as in the table above. "none" attestation does not sign the hash, but a create still needs a 32-byte one. - RP ID check:
passkey.ValidateRelyingPartyID: a lower-case domain name of at most 253 characters, no IP address, port, path or wildcard, and not a public suffix (which also refuses single-label names such aslocalhost). There is no origin to compare it with; iOS has already bound it. - Go API (
core/app/platformpasskey.go, plain functions, wrapped byhisarkit; binary values base64url):ListPasskeys(every passkey in a login, with its user handle, for the identity store),ListPasskeysForRelyingParty(rpId, allowedCredentialIDs),PreparePasskeyRegistration(rpId, excludedCredentialIDs)(isExcludedand the writable logins matchinghttps://<rpId>by the §9.2 rules),CreatePasskeyForRelyingParty({relyingPartyId, userName, userDisplayName, userHandle, clientDataHash, excludeCredentials, vaultID, itemID})→credentialId,attestationObject,authenticatorData,vaultID,itemID, andSignPasskeyForRelyingParty(rpId, credentialId, clientDataHash)→credentialId,authenticatorData,signature,userHandle. The private key never crosses into Swift. Saving follows the desktop: the chosen login must be writable and match the RP, a passkey with the same RP ID and user handle in it is replaced, and with no login a new one is created in the Personal vault. - Identities: with the password identities (§9.3) the app publishes an
ASPasskeyCredentialIdentity(RP ID, user name, credential ID, user handle, record identifiervaultID/itemID) for each passkey after every sync, save, delete and passkey delete. The extension also saves the identity of a passkey it just created, and removes the one it replaced in the same login (same RP and user handle), before it completes the registration, so the new passkey is offered at once instead of after the app's next refresh. - Signing in:
prepareCredentialList(for:requestParameters:)lists the passkeys for the request's RP ID (only those inallowedCredentialswhen it is not empty) above the matching logins. Choosing one signs and completes withASPasskeyAssertionCredential. For a passkey chosen in the system sheet or QuickType bar,provideCredentialWithoutUserInteractionsigns only while the extension's process is still unlocked and the request'suserVerificationPreferenceisdiscouraged; otherwise it returnsuserInteractionRequiredandprepareInterfaceToProvideCredentialunlocks with Face ID or the master password, then signs. A passkey iOS offered that Hisar no longer has cancels withASExtensionError.credentialIdentityNotFound. - User verification. Go calls the registered
DeviceOwnerPasskeyVerifierfor the final immutable request. It creates a freshLAContext, disables Touch ID reuse, and evaluates.deviceOwnerAuthenticationwith Face ID, Touch ID or the passcode. The callback's success is the only source of UV. The model no longer forwards a verification boolean or treats an earlier app unlock as evidence.requiredandpreferredneed this check.discouragedsigns with UV=0. A verification refusal terminates the provider request. Dismissal or request replacement invalidates in-flight authorization. - Creating:
prepareInterface(forPasskeyRegistration:)unlocks if needed and shows "Save passkey" with the user name, site, matching writable logins and "New login". Only a username match in primary Personal may be preselected; a shared match never displaces "New login" in Personal. Shared choices show their membership scope and require selection. A request whose non-emptysupportedAlgorithmslacks ES256 is cancelled withASExtensionError.failed. The request carriesexcludedCredentials: when Hisar has one of them, the screen says so and "OK" cancels withASExtensionError.matchedExcludedCredential; Go refuses such a create too. The result completes withASPasskeyRegistrationCredential. - App: the item view lists the passkeys (site, user name, created) with Delete after a confirmation, as on the desktop. The editor does not show them and the backend keeps them (§4.5).
- Testing: Go tests run both ceremonies from a
clientDataJSONthat go-webauthn builds and the test hashes, and verify them with go-webauthn. The simulator does not offer a build without a team as a provider, so the UI tests drive registration and sign-in through the debug-only AutoFill preview (§9.3) and check the attestation and the ES256 signature overauthenticatorData || clientDataHashwith CryptoKit. The real Safari flow needs the paid team (§12 #7) and is not verified yet.
Android (Credential Manager provider, API 34 and later)
- Hisar is a credential provider for passkeys and passwords in the app's process, on the same backend as the app (§9.3), with the same Go functions as the iPhone. Turning it on is Android's Settings → Passwords, passkeys & accounts, which Settings → AutoFill opens.
- Who is asking: a caller that sets an origin is a browser and must be on the privileged-browser allowlist (Google's list plus the community list Bitwarden and KeePassDX ship). For a browser on the list,
CallingAppInfo.getOrigingives the page's origin; Hisar checks the RP ID against it (ResolvePasskeyRelyingPartyID), signs over the browser's 32-byteclientDataHashand leavesclientDataJSONout of its answer. A browser that is not on the list (getOriginthrows), or that sends no hash, gets nothing. Any other caller is an app, named by its package and the SHA-256 of its signing certificate; an app with several signers gets nothing. An app gets passkeys only when the RP ID's site vouches for that package and certificate inhttps://<RP ID>/.well-known/assetlinks.json, with theget_login_credscheck AutoFill uses (§9.3), since Credential Manager itself offers every passkey of an RP ID to any app that names it. The RP ID first passes the iPhone's RP ID check above (ValidatePasskeyRelyingPartyID), so an app cannot point that request at another host or path. Hisar ignores any hash an app sends, builds the client data withBuildPasskeyClientDataand the originandroid:apk-key-hash:…(BuildAndroidAppOrigin), and returns thatclientDataJSON. - Signing in: Hisar answers the sheet's request without a screen: one entry per passkey for the RP ID, only those in
allowCredentialswhen it is not empty. Credential Manager asks the provider at every request, so a passkey Hisar just created is offered on the very next one; there is no identity store to update as on the iPhone. A password request gets the logins that match the browser's origin or the app, as AutoFill matches them (§9.3). A locked Hisar offers one "Unlock Hisar" action and returns the entries after the unlock; a signed-out Hisar, or one that needs an update, offers nothing. A login picked while Hisar is unlocked is handed out without a screen, after it is matched against the caller again. A picked passkey is signed without a screen while Hisar is unlocked and the user needs no verification (below); otherwise an unlocked Hisar shows only the device owner prompt over the caller, and a locked one its lock screen, then signs. A picked passkey Hisar no longer has fails withNoCredentialExceptioninstead of falling back to a list. DestroyingCredentialActivityends its passkey ceremony and prompt, so the activity handles rotation, folding, resizing and dark mode itself instead of being recreated, and a request survives them. - User verification. Go calls the registered native verifier for each concrete
requiredorpreferredceremony.BiometricPromptasks forBIOMETRIC_STRONG | DEVICE_CREDENTIALbefore signing or saving. Only a successful callback for that invocation supplies UV. Declining fails the provider request. A device without a supported screen lock cannot supply required verification and receives an error.discourageduses the sheet's presence confirmation and sets UV=0 without another check. An app unlock or a mutable pending-intent extra never authorizes a later challenge. Hisar shows the prompt itself because the provider supports API 34, before Credential Manager's API 35BiometricPromptData. - Creating: Hisar offers to save, under the account's email, only when the caller passes the checks above,
pubKeyCredParamsis empty or includes ES256 (-7), and Hisar is signed in and needs no update; a request without ES256 that still reaches Hisar fails withNotSupportedError. WhenexcludeCredentialsnames a passkey Hisar has, the screen says "Hisar already has a passkey for this account. Sign in with it instead." and OK refuses the request withInvalidStateError. Otherwise only a matching username in primary Personal may be preselected. A shared-only match leaves "New login" in Personal selected; shared choices visibly describe membership scope and require selection. The registration answer hasattestationObject,transports ["internal", "hybrid"],authenticatorAttachment "platform"and thecredPropsresultrk: true. A caller that may not use the RP ID getsSecurityError, on create and on get. - Chrome reaches the provider on its default autofill setting; "Autofill using another service" is needed only for password AutoFill. The API 34 emulator image's Chrome 113 never calls third-party providers, so the passkey UI test there uses a native caller app.
Not supported yet: WebAuthn extensions other than prf and credProps (large blob, …), Firefox and Safari on the Mac, on iOS the WebAuthn extension inputs of iOS 18 (extensionInput: PRF, large blob), and on Android the prf extension in Credential Manager requests.
9.9 Approving on the phone
The Mac's approval dialog (SSH agent, §9.6, and command-line tool, §9.4) can also be answered on the user's iPhone or Android phone, for example while coding agents run on the Mac and the user is away from its keyboard.
Rules
- The phone answers approve or deny for one specific request, nothing else. It never unlocks the Mac's Hisar, and no unlock key, password or vault key leaves a device: the answer is a signed decision.
- Only while Hisar on the Mac is unlocked. A locked Mac still needs Touch ID or the master password at the Mac, and its dialog is not sent to the phone. When the Mac locks, it withdraws its open phone requests and ignores their answers.
- Only while Hisar is open and unlocked on the phone, in the foreground. There are no push notifications yet: they need the Apple Developer Program (§12 #7) and come later.
- The Mac sends a request as soon as it receives it, before the dialog is shown and in parallel with it, if Settings → SSH & Git → Approve requests on my smart device is on (default on; stored in
desktop.json, a device preference kept on sign-out) and the account has at least one phone (GET /devices, a platform ending in/mobile:ios/mobileorandroid/mobile,apitypes.IsPhonePlatform, read when Hisar unlocks; the same list gives the Mac its own device ID). A list older than 5 minutes is read again in the background while the last one is used; when no list is known yet, or the last one had no phone and is stale, the request waits for a fresh list for at most 3 seconds, so the first request after unlock or after the Mac wakes is not kept from the phone by an old answer. A refresh that fails or hangs keeps the last list. - Only a request that fits: the sealed request is at most 16 KiB, and the command is never cut to fit (§9.4), since a shortened command could hide what it runs. A request that does not fit even without the app's icon (a command of several thousand characters) is not sent; the dialog says "This request is too long to approve on your phone." where it would say "or approve on your smart device" (
isTooLongForPhonein thessh-approvalevent), and keeps the Mac's own 60-second deadline.PhoneApprover.StartPhoneApprovalreports it asapproval.ErrSealedTooLargebefore posting anything. - Whichever answers first wins: Touch ID or the master password on the Mac, Deny on the Mac, the phone, or the dialog's timeout. A dialog that the phone can answer waits 2 minutes instead of 60 seconds (
PhoneApprovalTimeout), to leave time to reach for the phone and open Hisar. The Mac then withdraws the request (DELETE /approvals/{id}), which sendsapproval-resolved, and the phone closes its sheet. - A phone approval counts as a Touch ID approval, except for remembering: the phone shows what approving would also allow ("Also allows iTerm2 for 1 hour") as a switch that starts as the Mac's checkbox starts (on for SSH, off for remembered commit signing and for agent secrets), and only the choice signed into the phone's answer counts; the Mac's checkbox does not. An answer without that choice, from a phone built before it, is never remembered. Commit and tag signing ask once per signature unless remembered commit signing is on and chosen (§9.6), and a forwarded request (§9.6) is allowed once. A phone's Deny is Deny.
Request (core/approval, sealed by the Mac)
request = JSON {requestId, requesterDeviceId, requesterDeviceName, createdAt, expiresAt,
kind ("ssh" | "cli" | "launcher"), appName, purpose, host, commandLine, repositoryPath,
projectPath, keyName, isForwarded, forwardedFromHost, terminalName, secrets, appIcon, appWarning,
rememberLabel, isRememberedByDefault}
sealedRequest = 0x01 || HPKE.Seal(accountKEMPub, info = "hisar/v1/approval-request" || accountID || requestId, request)
requestIdis 128 random bits as 32 lower-case hex digits.expiresAtis the dialog's own deadline, so the request stays answerable exactly as long as the Mac waits;expiresAt - createdAtis at most 3 minutes. Times are RFC 3339 UTC with milliseconds.purposeis the dialog's sentence ("push to git.tryhisar.com", "sign a commit", "use Hisar", "run a command with 3 secrets"); the other fields are the dialog's detail lines, empty when the dialog has none. Forsecrets.resolve(§9.4.1),commandLineis the commandhisar runstarts andsecretslists the secrets as the dialog does, at most 4 and then "and N more"; it is left out of other requests.appNameis the app as the dialog names it (from its bundle);appIconis its icon as a 32-pixel PNG (base64 in the JSON) when that is at most 6 KiB, so the sealed request stays well under its 16 KiB limit, and is left out otherwise or when the sealed request would still exceed it;appWarningis the dialog's signature warning, if any. The phone shows the icon in place of its generic symbol and the warning as a detail row.rememberLabelis set only when approving can be remembered: the sentence for the phone's switch ("Also allows iTerm2 for 1 hour", "Also signs commits for iTerm2 in ~/src/hisar for 10 minutes", "Also allows these secrets for Claude Code for 1 hour"), andisRememberedByDefaultis the Mac checkbox's starting value.projectPathis the folder the coding agent's harness works in (omp's project, §9.4.4), with the home folder as~, on every request of an agent inside a harness: running a command, reading a field, a one-time code, connecting. It is left out of every other request. The phone shows it as a Project detail row;repositoryPathstays the command's own working directory, so a phone older thanprojectPathstill shows where the command runs.- Sealing to the account's own KEM public key (§4.3; the same HPKE as vault keys, §4.4) lets any unlocked device of the account open it, and the server cannot. The info binds the account and the request ID, so the server cannot move a request to another ID or account.
- Requests and answers are decoded leniently: unknown fields are ignored, so a Mac or phone that is one version behind still opens them. New fields must therefore be optional, and an older device must stay safe without them; the fields that are present are still checked (JSON types, one message with nothing after it, IDs, kind, lifetime, expiry, size). Ignoring them is safe because the answer's signature binds the SHA-256 of the exact sealed request and each side acts only on fields it understands. Phones built before this rule refuse requests with fields they do not know, such as
appIcon.
Answer (signed and sealed by the phone)
requestHash = SHA-256(sealedRequest) the exact bytes the server relayed
signed = "hisar/v1/approval-answer" || accountID || requestId || requestHash || decision ("approve" | "deny")
|| answeringDeviceId || answeringDeviceName || answeredAt
[|| remember ("remember" | "once")] only when the answer carries isRemembered
signature = Ed25519.Sign(account signing key, signed)
answer = JSON {requestId, decision, answeringDeviceId, answeringDeviceName, answeredAt, requestHash, signature,
isRemembered (optional)}
sealedAnswer = 0x01 || HPKE.Seal(accountKEMPub, info = "hisar/v1/approval-answer-envelope" || accountID || requestId, answer)
The concatenations are length-prefixed (§4.5). Before it shows a request, the phone opens it and checks that its ID is the one the server named, that it lasts at most 3 minutes, that it has not expired by the phone's clock, and that it did not come from the phone itself. It signs only what it showed, because the hash covers the sealed request byte for byte. Sealing the answer as well keeps the decision and the device names from the server.
isRemembered is the phone's switch. A phone includes it, and the extra signed part, only for a request with a rememberLabel; an older Mac never sends one, so it still verifies the answers of newer phones. The Mac verifies the signature with the extra part when the answer has the field and without it otherwise, so the field cannot be added, removed or flipped without breaking the signature; an answer without it (an older phone) counts as "once". The Mac also ignores isRemembered for a request it did not offer to remember.
The Mac accepts an answer only if
- it opens with the account's KEM key, and its
requestIdis a request the Mac is waiting for; - the signature verifies with the account's own signing public key, taken from the unlocked keyset, which was checked against the account record at unlock (§5.4); a key the server hands out later is never used;
requestHashequals the hash of the sealed request the Mac itself posted;- it arrives before
expiresAtby the Mac's clock; - it is the first valid answer for that request: single use, so a replayed answer, or one for a finished request, is refused;
answeringDeviceIdis not the Mac's own device ID.
core/approval implements these checks (SealRequest, OpenRequest, CreateAnswer, Tracker) on top of core/crypt (SealApprovalRequest, SignApprovalAnswer, VerifyApprovalAnswer, …), which has test vectors for the sealed request and the signature.
What the server can and cannot do. It relays sealed blobs. It sees the account, the requesting session's device, the request ID, sizes and times; never the description, the decision or the answering device. It cannot forge or change an approval, because it has no account signing key. It cannot reuse an answer for another request (the request ID and hash are signed) or later (expiry, single use). It can withhold, delay or drop requests and answers, and it can fill a request's single answer slot with garbage; all of that only means the Mac's own dialog decides, like any denial of service.
Server relay (server/internal/service/approvals.go, in memory)
| Endpoint | Body → result |
|---|---|
POST /approvals | {requestId, sealedRequest, expiresAt} → 201. 400 for a bad ID, an empty sealed request or one over 16 KiB, or an expiry that is not within the next 3 minutes; 409 for a duplicate ID; 429 when the account has 16 open requests. Sends approval-requested |
GET /approvals | [{requestId, sealedRequest, requesterDeviceId, createdAt, expiresAt}]: the account's requests that are not answered, resolved or expired |
GET /approvals/{id} | {requestId, sealedRequest, sealedAnswer, isResolved, expiresAt}; 404 for an unknown request or another account's |
POST /approvals/{id}/answer | {sealedAnswer} (at most 16 KiB) → 204. 404 unknown, 410 expired or resolved, 409 already answered, 403 from a session of the requesting device. Sends approval-answered |
DELETE /approvals/{id} | → 204, only from a session of the requesting device (403 otherwise); marks it resolved and sends approval-resolved |
- Every endpoint needs a session and sees only the session's account. Each account has a token bucket of 2 requests per second with a burst of 60 across these endpoints (429).
- A request is kept until 60 s after its expiry, so at most 4 minutes after it was posted, and is never served after that; every new request, and a sweep every 60 s, drops the expired ones, so one stays in memory at most about 5 minutes.
Mac (core/app/phoneapproval.go, desktop/agent)
- When a request that needs no unlock arrives, the approver asks
PhoneApprover.IsPhoneApprovalAvailable(unlocked, the switch on, and a phone on the account; refreshed as above, and not gated on the vault's last sync having been offline, since posting is the test); when it is, the vault backend posts the sealed request in the background right away, before the dialog is shown (a failed post is tried twice more, 2 s apart, while the request waits; a 409 means an earlier try arrived), the dialog's event hasisPhoneApproval, and the footer adds "or approve on your smart device". Every queued request is posted on its own, so the phone can answer a request that waits behind another one in the Mac's window. - On
approval-answeredfor one of its requests, the backend fetches the answer, checks it (above) while holding the session, and hands the decision to the waiting dialog. An approval or a denial also cancels a Touch ID check in progress. An invalid answer is ignored and the dialog stays open.
iPhone (mobile/hisarkit, mobile/ios)
- The phone does not need to be open when the Mac asks: the Mac posts the request whenever the account has a phone, and the request waits on the server until the dialog's deadline.
- While unlocked and in the foreground, the app lists the open requests right at unlock (before the vault sync), on returning to the foreground, on
approval-requested, and every 15 s as a fallback for a missed event. The first appears as a card at the top of the Vaults tab: "· Expires in …", the request's sentence, the key, and Deny and Approve (Approve asks for Face ID as in the sheet); the Approvals tab lists them all with their badge count on the tab. - Settings → Mac approvals → Approve requests from my Mac (on by default;
phoneApprovalDisabledin the phone'shisar.json, kept on sign-out) turns this off for this iPhone:ListAllPendingApprovalthen returns nothing,ApproveApprovalrefuses, and the Mac's dialog decides on its own. - A request that arrives while the app is in the foreground also opens the sheet at once; requests found at unlock or on returning to the foreground are only listed. Closing the sheet keeps the request in the list.
- The sheet shows an icon for the kind, "Approve on
?", the description rows (app, action, project, command, repository, server, key, secrets, forwarded from, terminal), the note "End-to-end encrypted. Only your devices can read this request. Approving lets this one request continue; it does not unlock Hisar on your Mac.", Approve with Face ID and Deny. A request with a rememberLabeladds a switch with that sentence under the header, before the details, starting atisRememberedByDefault; its value when the user approves is signed into the answer. The Vaults card has no switch, so its Approve answers with the switch's starting value (isRememberedByDefault: remembered for an SSH request, once for commit signing and agent secrets); its footer says so ("Also allows Terminal for 1 hour. Tap the request to approve it once. It never unlocks Hisar on your Mac.", or "Approving answers this one request. …" when the default is once), and the sheet is where to change it. - A
launcherrequest (§9.4.3) is titled "is asking to let <app> use Hisar", with a magnifying glass, the app and a "Lasts: Until Hisar locks on " row, and the note "Approving lets <app> use Hisar until it locks on your Mac; it does not unlock Hisar there." A phone older than this kind cannot open such a request and never shows it; the Mac's dialog still answers it. - Approve needs Face ID (or Touch ID) first; when biometrics are unavailable or the user picks the fallback, the sheet asks for the master password, which is checked like an unlock. Deny needs neither.
- A request leaves the list, and its sheet closes, on
approval-answeredorapproval-resolved, atexpiresAt, and when the app locks. Expired, answered or withdrawn requests are never shown. - UI tests drive the sheet and the list through debug-only previews (
-HisarApprovalPreview,-HisarApprovalBannerPreview), like the AutoFill preview (§9.3). The cross-device flow is tested end to end in Go: a desktop backend and a HisarKit backend signed in to the same account, where the phone approves an SSH signature, a denied one fails, a forged answer is refused, and a phone app that starts only after the Mac asked still finds and approves the request.
Android (mobile/hisarkit, mobile/android)
- The same behavior as the iPhone through the same HisarKit calls, in
ApprovalsModel: the list at unlock, on returning to the foreground, onapproval-requestedand every 15 s; the card on Vaults, the Approvals tab with its badge, and the sheet, which opens at once for a request that arrives while Hisar is in front. Approve asks for a strong biometric (Approve with biometrics), or the master password when biometrics are unavailable. Settings → Mac approvals → Approve requests from my Mac turns it off for this phone. - UI tests drive the card, the tab and the sheet on a fake backend seeded with made-up requests, and assert the answer the backend received.
PhoneApprovalUITests.testApproveAndDenyRealRequestsFromTheMacanswers real requests frommobile/cmd/hisar-ui-test-mac, which plays the Mac withcore/app: it signs up, sends an SSH request and a command-line request, verifies each answer and saves it in the Personal vault, where the test reads it back.
9.10 Themes
Each device picks its own look from two settings; nothing is synced.
- Appearance: System (the default, follows macOS or iOS light and dark), Light or Dark.
- Color: Neutral (the default; Dark Neutral is the original Mac look), Blue (
#2356c4), Purple (#6a3dcc) or Green (#1b7348). In light, a colour tints the chrome by 3–6 % (sidebar, item list, raised surfaces, borders) and the selection by 12–15 %, and becomes the accent of buttons, switches, focus rings and the selected icons; content stays on white. In dark, surfaces stay near-black with the same small tint, and the accent is a lighter shade of the hue (Blue#7ea6f8, Purple#ad8ff5, Green#5cc48f) with near-black text on it. Green keeps the separate success green, so a "running" dot never reads as the accent. - Single source:
assets/themes/themes.jsonholds the tokens: a full set per appearance (base.light,base.dark, which are Neutral) and per colour only the tokens it changes.make generate-themes(node scripts/themes/generate.ts, no dependencies) writesdesktop/frontend/src/themes.css,admin/src/themes.css,mobile/ios/Shared/Views/ThemePalettes.swiftandmobile/android/model/src/main/kotlin/com/tryhisar/hisar/model/theme/ThemePalettes.kt; all are committed, and the frontend tests fail when they differ from what the JSON generates. - Contrast: the same frontend tests compute the WCAG 2 contrast ratio for every colour × appearance and fail below the minimum: 4.5:1 for primary and secondary text on the background, sidebar, item list, surfaces, hover and selection, for muted text on the background, sidebar and surfaces (a selected row shows its subtitle in secondary text), for accent text, status text (danger, warning, success), text on the accent, on solid danger and in error and warning boxes, initials on every tile colour, and, on the iPhone, for the tint, the secondary label and the warning amber on the grouped background and the cells and for text on the tint; 3:1 for focus rings, the accent as a control colour and accent icons on the selection.
- Mac:
themes.cssdefines the variables for[data-color=…][data-appearance=light|dark], and fordata-appearance=systeminsideprefers-color-schememedia queries. Every window (main, Settings, lock and sign-in screens, the SSH, CLI and agent approval dialogs, full and compact) sets both attributes on<html>fromGetTheme, and follows thetheme-changedevent thatSetThemeemits after saving, so a choice applies everywhere at once. Components use only these variables; the logo keeps its own colours. The choice is saved indesktop.json(appearance,color, kept on sign-out); a copy in the web view's local storage only spares a new window a flash of the default theme. The app also setsNSApp.appearance(Aqua, Dark Aqua or none for System), so the title bar, Touch ID sheets and file dialogs match. Settings → General → Appearance has a System · Light · Dark segmented control and four colour swatches drawn with each colour's own tokens; a click previews at once and a failed save goes back. - iPhone: Settings → Appearance is a native list: Appearance with checkmarks (System, Light, Dark;
preferredColorScheme) and Color with a swatch and a checkmark per colour. The colour sets the tint and, for Blue, Purple and Green, a lightly tinted grouped background (dark: near-black with a trace of the hue); cells, bars and Liquid Glass stay the system's. Tints are the darker, accessible shades above, notsystemBlueand friends, and secondary labels are a solid#6c6c70(dark#98989f) instead of the translucent system grey, which falls below 4.5:1 on the tinted backgrounds. Warnings (a request's countdown, key changes) use an amber (#8f5b00, dark#d9a33c) instead of the system orange, which is about 2:1 on white.GetThemeandSetThemeinmobile/hisarkitstore the choice inhisar.jsonin the app group, so the AutoFill extension reads the same theme when it opens. - Android: Settings → Appearance has the same two choices.
ThemePalettes.ktcarries the iPhone's tokens as ARGB values, andHisarThememaps them onto Material 3 (the tint as the primary colour, the grouped background, the secondary label, the warning amber), never Android's dynamic color. The status and navigation bar icons follow the chosen appearance.
9.11 Export
Users can export the data this device can read, including an offline rescue copy when the server is permanently unavailable. Export consults no plan, subscription, or server-side switch. A cached copy can omit changes since completed device syncs and cannot recover records the device never acquired. A collection of vault snapshots is not an atomic account backup or proof of malicious-server freshness. Export collects the vaults and items the session can read, including shared vaults, through export.Collect. The format includes vault names and all supported item types, including one-time password secrets, SSH private keys, passkeys and fields (§9.7). Attachments are not supported yet.
The result and portable document carry provenance. The source is online, cached, or mixed, with per-vault completed sync timestamps and explicit omissions. A vault with no completed sync is not presented as a successful empty vault. Known legacy cached records remain exportable with unknown-coverage omissions. Unreadable records are identified while healthy records remain exportable. Desktop, iOS, Android, and CLI messages label cached or partial copies and show their known omissions.
Plain documents and encrypted envelopes retain version 1. The optional provenance field is inside the authenticated plaintext of an encrypted export. Existing version-1 files remain readable. Older importers may ignore this optional metadata, so recovery completeness requires an updated reader. Updated hisar import displays provenance before writing and retains a source file with cached or partial coverage. This change does not add streaming or a new cryptographic format.
Plain JSON (core/export, format: "hisar-export"):
{ "format": "hisar-export", "version": 1, "exportedAt": RFC 3339,
"account": { "email", "server" },
"vaults": [ { "id", "name", "type": "personal" | "shared",
"items": [ { "id", "overview": item overview (§4.5), "details": item details (§4.5) } ] } ] }
Binary values (passkey IDs and keys) are base64 as in the item JSON. It is not encrypted: every app shows "Plain JSON is not encrypted. Anyone who gets the file can read every password, one-time code secret and SSH private key in it. Delete it when you are done." and asks before writing one.
Encrypted export (format: "hisar-encrypted-export"), the default, sealed with an export passphrase the user picks (at least 10 characters, entered twice in the apps; nobody, Hisar included, can open the file without it):
{ "format": "hisar-encrypted-export", "version": 1,
"kdf": { "algorithm": "argon2id", "salt": base64 (16 bytes), "iterations": 24, "memoryKiB": 65536, "parallelism": 4 },
"cipher": "xchacha20-poly1305",
"ciphertext": base64 of 0x01 || nonce (24) || XChaCha20-Poly1305(exportKey, nonce, aad, JSON of the plain export) }
stretched = Argon2id(NFKD(passphrase), salt, iterations, memoryKiB, parallelism, len = 32)
exportKey = HKDF-SHA256(ikm = stretched, salt = nil, info = "hisar/v1/export-key", len = 32)
aad = "hisar/v1/export"
- The file supplies the parameters, so readers refuse anything outside
MinArgon2Params…MaxArgon2Params(§4.2) and a salt that is not 16 bytes before deriving anything. The existing 1 GiB maximum remains accepted for compatibility, so an accepted file can still exceed a constrained device's memory. - A wrong passphrase, a changed byte, salt or ciphertext all fail the AEAD and read as "that passphrase doesn't open this export"; an unknown
kdf.algorithmorcipher, or aversionother than 1, is refused with its own message. A future version adds a newversionand keeps reading 1.
Mac. Settings → Export (§9.1). Touch ID approves the export (Biometric.Authenticate("export your Hisar data")), or the master password when Touch ID is unavailable; then a save dialog proposes "Hisar export <date>.hisar" or "Hisar export <date> (not encrypted).json", and the file is written to a temporary file with mode 0600 and renamed into place.
iPhone. Settings → Account → Export data…: a sheet with the format picker, the passphrase twice or the plain JSON warning, and Export with Face ID (the master password field when Face ID is unavailable). The file is written into a new folder in the app's temporary directory and offered with Save or Share Export (ShareLink); the folder is deleted when the sheet closes.
Android. Settings → Account → Export data…: a pushed screen with the same format picker, passphrase fields and warning, and Export with biometrics (Export with the master password field when biometrics are unavailable). Save Export hands the file to the system's file creator and Share Export to the share sheet, as the iPhone's Save or Share Export does; the app deletes its own copies when the screen closes.
CLI. hisar export [-o file] [--format encrypted|plain] [-y] writes hisar-export-<date>.hisar (or .json) in the current directory unless -o names another file, and never replaces one. In a terminal it asks for the format, then the passphrase twice; a script pipes the passphrase on one line, and a plain export needs -y. Through the app the app writes the file after its approval dialog (§9.4), which asks every time; standalone the command asks for the master password like the others.
Both CLI export writers and the CLI Emergency Kit writer sync and close the new file before reporting success. They share the same finalizer. A write, sync or close error removes the failed output.
In the apps (core/app), every export and Emergency Kit file is prepared with mode 0600 beside its destination. The final session check and publication share one hold of the service mutex. A lock, sign-out or account switch before publication removes the temporary output and returns an error without changing the destination. Save-dialog exports and kits use rename to replace the selected file atomically. The app's CLI writer instead links the prepared file into place, which fails if the destination exists. On filesystems without hard links, including FAT and exFAT, it deletes the temporary file, exclusively creates the destination and writes the encoded bytes already held in memory through the open file descriptor. Only one file's worth of free space is needed. This fallback holds the mutex during the write, sync and close, and can delay locking for that duration. Failed-output cleanup takes the file's identity from the descriptor after writing and syncing, because FAT and exFAT can change the inode on the first write. It checks that identity before removing the destination so it does not delete another writer's replacement. Hisar always removes its randomly named temporary file on return. A temporary file can still be written after a lock that came during encoding; it is removed before the error is returned.
10. Repository layout
The package map, the rules for where new code goes and the build commands are in CLAUDE.md.
core/ Go module: crypto, wire types, the API client, sessions, the app backend shared by macOS, iOS and Android (core/app)
server/ Go module: hisar-server (internal/api speaks HTTP, internal/service holds the rules, internal/store is Postgres)
admin/ The admin dashboard (Vue, TypeScript), served by hisar-server at /admin; its crypto is server/cmd/hisar-admin-wasm
cli/ Go module: hisar, standalone or through the desktop app; mcpserver/ hisar mcp
desktop/ Wails v3 app: the Wails main package and OS integration (SSH agent approvals, browser host, login item, Touch ID, updates), frontend/ UI
mobile/ hisarkit, the gomobile framework of the phone apps, on top of core/app; ios/ the Swift app; android/ the Kotlin app
extension/ Browser extension (TypeScript)
docs/ Design and decisions
assets/ Logo sources and themes/themes.json, the colour tokens of every theme (§9.10)
deploy/ Dockerfile, Docker Compose (Postgres + server), nginx site, deployment notes
scripts/ Packaging, signing, the end-to-end test of the built server and CLI, and the phone apps' UI test runners
go.work Ties the Go modules together for local development
11. Roadmap
| Phase | Scope | Exit criteria |
|---|---|---|
| 0 | This document, repo skeleton, CI | Design reviewed and agreed |
| 1 | core/crypt with test vectors, OPAQUE auth, server (accounts, vaults, items, sync), CLI, Docker deployment, CI (unit and database tests, end-to-end test of the built binaries, image build) | Two CLI clients on two machines sync a shared vault through a self-hosted server |
| 2 (MVP) | Desktop app (macOS first): signup + Emergency Kit, unlock, Touch ID unlock (§5.4), items, generator, search, personal and shared vaults, invites, vault key rotation, auto-lock, SSH agent with SSH Key items and Touch ID approval (§9.6), local encrypted cache. CI builds the desktop app | Family uses it daily on macOS; ssh and git use keys from Hisar |
| 3 | Browser extension via native messaging: fill, save, generate | Daily logins without copy-paste |
| 4 | Mobile apps with system autofill | Logins on phones |
| 5 | TOTP, passkeys, attachments, Watchtower (reused/weak/breached via HIBP k-anonymity), import from 1Password/Bitwarden/CSV, SSE push sync | — |
| 6 | Person-to-person sharing by email (§5.8), emailed invites (§5.1, §8.5), Settings → People | No list of all accounts anywhere |
| 7 | Secret references, hisar read and hisar run (§9.4.1), "Copy secret reference" in the desktop app, hisar mcp for coding agents with per-vault agent visibility (§9.4.2), end-to-end encrypted share links for people without a Hisar account (§5.9; iPhone later) | Developers and coding agents run commands with secrets they never see |
| 9 | Rename, delete and leave vaults on the Mac, the iPhone and the CLI (§5.10); encrypted and plain JSON export and their import (§9.11, §9.7); .env import that leaves secret references behind (§9.7) | Users can take all their data out at any time and bring it back; a .env file holds only references |
| 10 | Organizations, teams and organization vaults on the server, in core, in the CLI, and on the Mac and the iPhone (§5.11) | A team member added on the server gets the team's vaults from an owner's app, and one removed loses them at once and gets a rotated key; people do all of it from the Mac, the iPhone or hisar |
12. Decisions
| # | Question | Decision |
|---|---|---|
| 1 | Argon2id parameters | t=24, m=64 MiB, p=4 (≈ 270 ms on M2 Pro); re-check on phones in Phase 4. Target ≈ 0.5–1 s on the slowest device |
| 2 | Wails version | v3, pinned beta (v3.0.0-beta.23; no stable v3 exists yet). Upgrade deliberately, one beta at a time, with the macOS and Linux CI builds as the gate |
| 3 | Server domain | api.tryhisar.com, public: the API, invite and share links, and the privacy policy. tryhisar.com serves no page yet, so the apps and pages do not link to it |
| 4 | Account recovery | Emergency Kit only (§6). Admin recovery was removed; a forgotten master password is unrecoverable |
| 5 | Item history retention | 90 days |
| 6 | Fingerprint check (§5.6) | Optional: a 16-hex-digit security code per person, shown in the desktop app's sharing panel and Settings → People. Key pinning and key-change refusals are always on |
| 7 | Apple Developer Program membership (code signing and notarization; required for biometric Keychain items and Gatekeeper) | Team 589NVZWDWZ signs and notarizes desktop releases with Developer ID (§9.1.1). The biometric implementation and build wiring now require a matching macOS Developer ID provisioning profile with a keychain access group (§5.4). Profile installation and signed Touch ID hardware verification remain external prerequisites. Universal links remain separate work |
| 8 | Product model | A hosted service, not one family per server: person-to-person sharing by email that the recipient accepts, no account enumeration, invites emailed by the server over SMTP, sign-up stays invite-only. Any user may create an organization and share vaults through its teams (§5.11); organization and team names are visible to the server. Admins manage the server from the admin dashboard at /admin (§5.1), never from the apps. Account recovery is only through the Emergency Kit (§6) |
| 9 | Favorites: per item for everyone in the vault, or per user | Per user, synced across the user's devices and end-to-end encrypted in the account preferences record (§4.7): starring a shared item must not change other people's sidebars, and a device-only list would not carry from the Mac to the iPhone |
| 10 | Organization vault keys: a team keypair (wrapped to each member, vault keys wrapped to the team) or per-member wraps | Per-member wraps, as for every vault (§4.4, §5.11): no new primitive, label or trust root, unlock and sync unchanged; adding a member needs an owner's app to wrap each vault key, removing one rotates each vault. Admins manage rosters only and get no key by being admins |