I use bookmark keywords all day. Type "jira" in the address bar, hit enter, and you're on the board. Firefox gives a keyword the top "Visit" suggestion in the URL bar, so it's faster than any search or history match, and it syncs to every machine you're signed in on.
When I wrote Passport Containers, a Firefox add-on for per-path container rules, I wanted to set the keyword right from the popup. Make a rule for the site, bookmark it, give it a keyword, done. The first two parts were easy. The keyword turned out to be the whole project.
There is no API
The WebExtensions bookmarks API gives you title, URL and parent folder. That's it. Keywords, tags and descriptions were all left out when the API was built, and the request to support bookmark keywords was filed in 2016. It got folded into a wider discussion about search and never came back out. I don't think it's coming.
My first version faked it. A bare word in the address bar turns into a search on your default engine, so Passport watched for search requests, and when the query was one of your keywords it cancelled the search and opened the page instead. That works, and it's still what happens if you don't sign in. But you don't get the "Visit" suggestion, you get "Search with Google", and your keywords live in the extension instead of in Firefox. It felt like a workaround, because it was.
Firefox Sync already knows how
Firefox Sync stores every bookmark as a record on Mozilla's server, and the record has a keyword field. When Firefox pulls a bookmark record down from the server, it applies whatever keyword is in it, the same as if you'd typed it in the bookmark dialog. So if something writes the keyword into the synced record, Firefox sets a real keyword on its own, on every device.
An extension can talk to Sync the same way any third-party Sync client does. It just needs the keys. That's where it gets awkward, because Sync is end-to-end encrypted with a key derived from your account password, and an extension should never see your password.
Signing in without a password
Mozilla accounts support OAuth with scoped keys. You open accounts.firefox.com, log in there, and the extension gets back an authorization code plus the Sync key, encrypted to a key pair the extension generated for that one login. Passport never sees the password, only the result.
It's a normal authorization-code flow with PKCE, with one extra parameter, keys_jwk, which is the public half of that key pair:
const kp = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
const pub = await crypto.subtle.exportKey("jwk", kp.publicKey);
const keysJwk = b64url(JSON.stringify({ kty: "EC", crv: "P-256", x: pub.x, y: pub.y }));
const q = new URLSearchParams({
client_id: CLIENT_ID,
scope: "https://identity.mozilla.com/apps/oldsync",
code_challenge_method: "S256",
code_challenge: challenge,
state,
access_type: "offline",
keys_jwk: keysJwk,
});
When you exchange the code, the token response comes back with a keys_jwe next to the access token. That's a JWE with ECDH-ES and AES-256-GCM, and after decrypting it with the private key you have the 64-byte Sync key, split into an encryption key and an HMAC key. I did the decryption by hand with WebCrypto, following RFC 7518. It's about 20 lines, but Concat KDF is the kind of thing where you get every byte right or nothing works, and there's no helpful error message along the way.
There's one catch I should be upfront about. Mozilla doesn't give out OAuth client ids for Sync to third parties, so Passport uses the public client id of an old Mozilla app. Its redirect address points at a page that no longer exists, so Passport catches the redirect request, takes the code out of the URL and cancels the navigation before it goes anywhere. The Sync key stays in local extension storage on that machine and never leaves it.
Writing the keyword
With the key in hand, the rest is the regular Sync 1.5 protocol. Swap the OAuth token for storage credentials at Mozilla's token server, fetch the collection keys from crypto/keys, then read the bookmark record, decrypt it, set the keyword, encrypt it again and put it back:
const rec = await getRecord(hawk, keys, "bookmarks", bookmarkId);
await putRecord(hawk, keys, "bookmarks", bookmarkId,
{ ...rec.data, keyword }, rec.modified);
The rec.modified at the end becomes an X-If-Unmodified-Since header, so if Firefox changed the bookmark in the meantime the write fails instead of overwriting it. The bookmark id in the extension API is the same guid Sync uses as the record id, which saved me a lot of mapping. Only the root folders have different names on each side.
A freshly created bookmark isn't on the server yet, because Firefox uploads it a few seconds later. Passport waits and retries a couple of times, and if the record still isn't there it writes the whole record itself and adds it to the parent folder's child list.
Getting Firefox to notice
At this point the keyword is on the server, but Firefox only picks it up on its next sync, which can take a while. I didn't want "wait up to ten minutes" in the UI.
Firefox syncs right away once enough has changed locally. It keeps a score, every bookmark change adds to it, and past a threshold it triggers a sync immediately. On a single-device account that's 1000 points, and a bookmark change is worth 301. So Passport keeps a small helper bookmark called "Passport sync" and edits its title four times in a row, adding and removing an invisible character. That's 1204 points, Firefox syncs, sees the new keyword on the server and applies it. From typing the keyword in the popup to seeing it in the address bar takes 5 to 10 seconds.
It's a slightly silly trick, and I spent a while looking for a cleaner one. Nothing I tried was as reliable.
Was it worth it
For me, yes. Being able to type a word anywhere and land in the right container on the right page is the reason I built the add-on in the first place. I also learned more about how Sync works than I ever planned to.
It's also a bit fragile, and I'd rather say that here than have someone find out the hard way. It relies on a client id Mozilla could retire, and on sync scoring that could change in any release. If it breaks, the old search-catching method still works without an account, so your keywords don't disappear, you just lose the native suggestion.
The code is on GitHub under MIT, and the Sync part is in src/sync/. If you're building something that needs bookmark data the extension API doesn't expose, it might save you a weekend. There's more about the add-on itself on the project page.
Originally posted on hrolgar.com.
Top comments (0)