mozsync/main.go

1125 lines
32 KiB
Go
Raw Normal View History

package main
import (
"crypto/aes"
"crypto/cipher"
"crypto/ecdh"
"crypto/hmac"
2026-09-30 04:35:05 -08:00
"crypto/md5"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
2026-09-30 04:35:05 -08:00
"os/exec"
"path/filepath"
2026-09-30 04:35:05 -08:00
"strconv"
"strings"
"sync"
"time"
"github.com/mappu/miqt/qt6"
"github.com/mappu/miqt/qt6/webengine"
)
// Borrows Firefox for Android's public OAuth client id, unlike Firefox
// Desktop's client which only works inside browser chrome.
//
// This client's registered redirect_uri used to be a real hosted page at
// lockbox.firefox.com that kept the auth code visible in the address bar.
// Mozilla discontinued the Lockwise app in Dec 2021 and that page now 301s
// straight to a marketing page, dropping the ?code=/&state= query string in
// the process - and since the redirect_uri is baked into this client id's
// registration, we can't just point it somewhere we control.
//
// So doLogin() opens the auth page in an embedded, off-the-record
// QWebEngineView (Qt6 WebEngine, via the miqt bindings) with a URL request
// interceptor watching for redirectHost. When that request is about to go
// out, we block it before it ever hits the network, read the code/state off
// its URL, and quit the Qt event loop - no fake TLS cert, no browser
// warning, no external browser process at all.
const (
clientID = "e7ce535d93522896"
scope = "https://identity.mozilla.com/apps/oldsync"
authURL = "https://accounts.firefox.com/authorization"
tokenURL = "https://oauth.accounts.firefox.com/v1/token"
tokenServerURL = "https://token.services.mozilla.com/1.0/sync/1.5"
redirectHost = "lockbox.firefox.com"
)
type credentials struct {
RefreshToken string `json:"refresh_token"`
KeyID string `json:"key_id"`
SyncKey string `json:"sync_key_b64"` // base64url of the 64-byte oldsync scoped key
}
func credentialsPath() string {
home, _ := os.UserHomeDir()
return filepath.Join(home, ".config", "mozsync", "credentials.json")
}
func main() {
2026-09-30 04:35:05 -08:00
usage := "usage: mozsync <login|dump|chrome-import <path>|falkon-import <path>>"
if len(os.Args) < 2 {
2026-09-30 04:35:05 -08:00
fmt.Fprintln(os.Stderr, usage)
os.Exit(1)
}
var err error
switch os.Args[1] {
case "login":
err = doLogin()
case "dump":
err = doDump()
2026-09-30 04:35:05 -08:00
case "chrome-import":
if len(os.Args) < 3 {
fmt.Fprintln(os.Stderr, usage)
os.Exit(1)
}
err = doChromeImport(os.Args[2])
case "falkon-import":
if len(os.Args) < 3 {
fmt.Fprintln(os.Stderr, usage)
os.Exit(1)
}
err = doFalkonImport(os.Args[2])
default:
2026-09-30 04:35:05 -08:00
fmt.Fprintln(os.Stderr, usage)
os.Exit(1)
}
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
// ---------- login (one-time browser-based OAuth setup) ----------
func doLogin() error {
verifier := randB64URL(32)
challenge := b64url(sha256sum([]byte(verifier)))
state := randB64URL(16)
priv, err := ecdh.P256().GenerateKey(rand.Reader)
if err != nil {
return err
}
pub := priv.PublicKey().Bytes() // uncompressed point: 0x04 || X(32) || Y(32)
x, y := pub[1:33], pub[33:65]
jwk := map[string]string{"crv": "P-256", "kty": "EC", "x": b64url(x), "y": b64url(y)}
jwkJSON, err := json.Marshal(jwk)
if err != nil {
return err
}
q := url.Values{}
q.Set("client_id", clientID)
q.Set("scope", scope)
q.Set("state", state)
q.Set("access_type", "offline")
q.Set("code_challenge_method", "S256")
q.Set("code_challenge", challenge)
q.Set("keys_jwk", b64url(jwkJSON))
q.Set("response_type", "code")
fullAuthURL := authURL + "?" + q.Encode()
code, gotState, err := runLoginWindow(fullAuthURL)
if err != nil {
return err
}
if code == "" {
return fmt.Errorf("login window closed before signing in")
}
if gotState != state {
return fmt.Errorf("state mismatch (got %q, want %q) - aborting", gotState, state)
}
var tokenResp struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
KeysJWE string `json:"keys_jwe"`
}
err = postJSON(tokenURL, map[string]string{
"client_id": clientID,
"grant_type": "authorization_code",
"code": code,
"code_verifier": verifier,
}, &tokenResp)
if err != nil {
return fmt.Errorf("token exchange failed: %w", err)
}
if tokenResp.KeysJWE == "" {
return fmt.Errorf("no keys_jwe in token response")
}
kid, kBytes, err := decryptKeysJWE(tokenResp.KeysJWE, priv)
if err != nil {
return fmt.Errorf("decrypting keys_jwe: %w", err)
}
creds := credentials{RefreshToken: tokenResp.RefreshToken, KeyID: kid, SyncKey: b64url(kBytes)}
if err := saveCredentials(creds); err != nil {
return err
}
fmt.Println()
fmt.Println("Saved credentials to", credentialsPath())
fmt.Println("This file grants ongoing access to your Mozilla account's sync data - keep it private (mode 0600).")
fmt.Println("Run `mozsync dump` to fetch your bookmarks.")
return nil
}
// ---------- embedded login window ----------
//
// runLoginWindow opens fullAuthURL in an off-the-record QWebEngineView and
// watches every outgoing request with a QWebEngineUrlRequestInterceptor.
// When one targets redirectHost, we block it (so it never actually hits
// Mozilla's broken redirect endpoint) and read code/state straight off its
// URL. Interception runs on Chromium's IO thread, not the Qt GUI thread, so
// the interceptor callback only ever touches a mutex-guarded plain Go value
// - a QTimer polling on the GUI thread is what actually calls
// QCoreApplication_Quit() once a result shows up.
func runLoginWindow(fullAuthURL string) (code, state string, err error) {
app := qt6.NewQApplication(os.Args)
defer app.Delete()
var (
mu sync.Mutex
result *struct{ code, state string }
)
profile := webengine.NewQWebEngineProfile() // no name -> off-the-record, nothing persisted
interceptor := webengine.NewQWebEngineUrlRequestInterceptor()
interceptor.OnInterceptRequest(func(info *webengine.QWebEngineUrlRequestInfo) {
u := info.RequestUrl()
if u.Host() != redirectHost {
return
}
info.Block(true)
vals, _ := url.ParseQuery(u.Query())
mu.Lock()
if result == nil {
result = &struct{ code, state string }{vals.Get("code"), vals.Get("state")}
}
mu.Unlock()
})
profile.SetUrlRequestInterceptor(interceptor)
view := webengine.NewQWebEngineView3(profile)
view.SetWindowTitle("Sign in to your Mozilla Account")
view.Resize(900, 700)
view.Load(qt6.NewQUrl3(fullAuthURL))
view.Show()
const timeout = 5 * time.Minute
deadline := time.Now().Add(timeout)
poll := qt6.NewQTimer()
poll.OnTimeout(func() {
mu.Lock()
r := result
mu.Unlock()
if r != nil || time.Now().After(deadline) {
qt6.QCoreApplication_Quit()
}
})
poll.Start(150)
qt6.QCoreApplication_Exec() // blocks until Quit(); also returns if the user closes the window
mu.Lock()
defer mu.Unlock()
if result == nil {
return "", "", nil // window closed / timed out without a captured redirect
}
return result.code, result.state, nil
}
func saveCredentials(c credentials) error {
path := credentialsPath()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, data, 0o600)
}
func loadCredentials() (credentials, error) {
var c credentials
data, err := os.ReadFile(credentialsPath())
if err != nil {
return c, err
}
err = json.Unmarshal(data, &c)
return c, err
}
// ---------- JWE (ECDH-ES + A256GCM), per RFC 7518 4.6 ----------
func decryptKeysJWE(jwe string, priv *ecdh.PrivateKey) (kid string, key []byte, err error) {
parts := strings.Split(jwe, ".")
if len(parts) != 5 {
return "", nil, fmt.Errorf("malformed JWE: expected 5 segments, got %d", len(parts))
}
headerB64, ivB64, ctB64, tagB64 := parts[0], parts[2], parts[3], parts[4]
headerRaw, err := base64.RawURLEncoding.DecodeString(headerB64)
if err != nil {
return "", nil, err
}
var header struct {
Enc string `json:"enc"`
Alg string `json:"alg"`
EPK struct {
X string `json:"x"`
Y string `json:"y"`
} `json:"epk"`
}
if err := json.Unmarshal(headerRaw, &header); err != nil {
return "", nil, err
}
if header.Alg != "ECDH-ES" || header.Enc != "A256GCM" {
return "", nil, fmt.Errorf("unexpected JWE alg/enc: %s/%s", header.Alg, header.Enc)
}
epkX, err := base64.RawURLEncoding.DecodeString(header.EPK.X)
if err != nil {
return "", nil, err
}
epkY, err := base64.RawURLEncoding.DecodeString(header.EPK.Y)
if err != nil {
return "", nil, err
}
epkBytes := append([]byte{0x04}, append(epkX, epkY...)...)
serverPub, err := ecdh.P256().NewPublicKey(epkBytes)
if err != nil {
return "", nil, err
}
shared, err := priv.ECDH(serverPub)
if err != nil {
return "", nil, err
}
cek := concatKDF(shared, header.Enc, 32) // A256GCM -> 256-bit CEK
iv, err := base64.RawURLEncoding.DecodeString(ivB64)
if err != nil {
return "", nil, err
}
ciphertext, err := base64.RawURLEncoding.DecodeString(ctB64)
if err != nil {
return "", nil, err
}
tag, err := base64.RawURLEncoding.DecodeString(tagB64)
if err != nil {
return "", nil, err
}
block, err := aes.NewCipher(cek)
if err != nil {
return "", nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", nil, err
}
aad := []byte(headerB64) // JWE AAD is the raw base64url header segment itself
plaintext, err := gcm.Open(nil, iv, append(ciphertext, tag...), aad)
if err != nil {
return "", nil, fmt.Errorf("GCM open failed: %w", err)
}
var scoped map[string]struct {
K string `json:"k"`
Kid string `json:"kid"`
}
if err := json.Unmarshal(plaintext, &scoped); err != nil {
return "", nil, err
}
sk, ok := scoped[scope]
if !ok {
return "", nil, fmt.Errorf("no scoped key for %s in keys_jwe payload", scope)
}
kBytes, err := base64.RawURLEncoding.DecodeString(sk.K)
if err != nil {
return "", nil, err
}
return sk.Kid, kBytes, nil
}
// concatKDF is the single-round NIST SP 800-56A Concat KDF used by JWE ECDH-ES
// direct agreement, with empty PartyUInfo/PartyVInfo (no apu/apv exchanged here).
func concatKDF(z []byte, algID string, keyLenBytes int) []byte {
algIDBytes := []byte(algID)
otherInfo := appendUint32(nil, uint32(len(algIDBytes)))
otherInfo = append(otherInfo, algIDBytes...)
otherInfo = appendUint32(otherInfo, 0) // PartyUInfo length (empty)
otherInfo = appendUint32(otherInfo, 0) // PartyVInfo length (empty)
otherInfo = appendUint32(otherInfo, uint32(keyLenBytes*8))
h := sha256.New()
h.Write(appendUint32(nil, 1)) // round counter; one round covers 256 bits
h.Write(z)
h.Write(otherInfo)
sum := h.Sum(nil)
return sum[:keyLenBytes]
}
func appendUint32(b []byte, v uint32) []byte {
return append(b, byte(v>>24), byte(v>>16), byte(v>>8), byte(v))
}
// ---------- Sync 1.5 storage records ----------
type syncKeyBundle struct {
EncKey []byte
HMACKey []byte
}
func decryptBSOPayload(payloadJSON string, bundle syncKeyBundle) ([]byte, error) {
var payload struct {
Ciphertext string `json:"ciphertext"`
IV string `json:"IV"`
HMAC string `json:"hmac"`
}
if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil {
return nil, err
}
mac := hmac.New(sha256.New, bundle.HMACKey)
mac.Write([]byte(payload.Ciphertext)) // HMAC covers the base64 ciphertext string itself
if hex.EncodeToString(mac.Sum(nil)) != payload.HMAC {
return nil, fmt.Errorf("HMAC verification failed")
}
iv, err := base64.StdEncoding.DecodeString(payload.IV)
if err != nil {
return nil, err
}
ct, err := base64.StdEncoding.DecodeString(payload.Ciphertext)
if err != nil {
return nil, err
}
if len(ct)%aes.BlockSize != 0 {
return nil, fmt.Errorf("ciphertext not a multiple of block size")
}
block, err := aes.NewCipher(bundle.EncKey)
if err != nil {
return nil, err
}
out := make([]byte, len(ct))
cipher.NewCBCDecrypter(block, iv).CryptBlocks(out, ct)
return pkcs7Unpad(out)
}
func pkcs7Unpad(b []byte) ([]byte, error) {
if len(b) == 0 {
return nil, fmt.Errorf("empty plaintext")
}
pad := int(b[len(b)-1])
if pad == 0 || pad > len(b) {
return nil, fmt.Errorf("invalid padding")
}
return b[:len(b)-pad], nil
}
func doDump() error {
creds, err := loadCredentials()
if err != nil {
return fmt.Errorf("no credentials found, run `mozsync login` first: %w", err)
}
masterKey, err := base64.RawURLEncoding.DecodeString(creds.SyncKey)
if err != nil || len(masterKey) != 64 {
return fmt.Errorf("stored sync key is invalid, re-run `mozsync login`")
}
masterBundle := syncKeyBundle{EncKey: masterKey[:32], HMACKey: masterKey[32:64]}
accessToken, err := refreshAccessToken(creds.RefreshToken)
if err != nil {
return fmt.Errorf("refreshing access token: %w", err)
}
hawkID, hawkKey, apiEndpoint, err := getStorageCredentials(accessToken, creds.KeyID)
if err != nil {
return fmt.Errorf("fetching storage credentials: %w", err)
}
keysPayload, err := storageGET(apiEndpoint, "/storage/crypto/keys", hawkID, hawkKey)
if err != nil {
return fmt.Errorf("fetching crypto/keys: %w", err)
}
var keysBSO struct {
Payload string `json:"payload"`
}
if err := json.Unmarshal(keysPayload, &keysBSO); err != nil {
return err
}
plain, err := decryptBSOPayload(keysBSO.Payload, masterBundle)
if err != nil {
return fmt.Errorf("decrypting crypto/keys: %w", err)
}
var keysRecord struct {
Default []string `json:"default"`
Collections map[string][]string `json:"collections"`
}
if err := json.Unmarshal(plain, &keysRecord); err != nil {
return err
}
bundleFor := func(collection string) (syncKeyBundle, error) {
pair := keysRecord.Default
if override, ok := keysRecord.Collections[collection]; ok {
pair = override
}
if len(pair) != 2 {
return syncKeyBundle{}, fmt.Errorf("no usable key bundle for collection %s", collection)
}
enc, err := base64.StdEncoding.DecodeString(pair[0])
if err != nil {
return syncKeyBundle{}, err
}
h, err := base64.StdEncoding.DecodeString(pair[1])
if err != nil {
return syncKeyBundle{}, err
}
return syncKeyBundle{EncKey: enc, HMACKey: h}, nil
}
bmBundle, err := bundleFor("bookmarks")
if err != nil {
return err
}
bmRaw, err := storageGET(apiEndpoint, "/storage/bookmarks?full=true", hawkID, hawkKey)
if err != nil {
return fmt.Errorf("fetching bookmarks: %w", err)
}
var bsos []struct {
ID string `json:"id"`
Payload string `json:"payload"`
}
if err := json.Unmarshal(bmRaw, &bsos); err != nil {
return err
}
var records []json.RawMessage
for _, bso := range bsos {
plain, err := decryptBSOPayload(bso.Payload, bmBundle)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: skipping record %s: %v\n", bso.ID, err)
continue
}
records = append(records, json.RawMessage(plain))
}
out, err := json.MarshalIndent(records, "", " ")
if err != nil {
return err
}
fmt.Println(string(out))
return nil
}
2026-09-30 04:35:05 -08:00
// ---------- chrome-import: merge a `mozsync dump` into a Chromium profile's Bookmarks file ----------
//
// Reads the flat dump array (Firefox Places Sync records) from stdin, and for
// each of Firefox's special roots that has children (toolbar, menu, unfiled,
// mobile), builds one labeled wrapper folder holding the full imported
// subtree and appends it into the corresponding Chromium root - bookmark_bar
// for toolbar, other for menu/unfiled, synced for mobile. Existing bookmarks
// are never touched or removed, only appended to, and the previous file is
// always backed up first. The Chromium file is read/written as a generic
// map[string]interface{} rather than a fixed struct so fields Chromium itself
// relies on (like a bookmark's meta_info) survive the round-trip untouched.
type ffRecord struct {
ID string `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
BmkURI string `json:"bmkUri"`
DateAdded int64 `json:"dateAdded"`
Children []string `json:"children"`
Deleted bool `json:"deleted"`
}
func doChromeImport(path string) error {
dumpBytes, err := io.ReadAll(os.Stdin)
if err != nil {
return fmt.Errorf("reading dump from stdin: %w", err)
}
var records []ffRecord
if err := json.Unmarshal(dumpBytes, &records); err != nil {
return fmt.Errorf("parsing dump JSON: %w", err)
}
byID := make(map[string]ffRecord, len(records))
for _, r := range records {
if !r.Deleted {
byID[r.ID] = r
}
}
if chromiumRunning() {
fmt.Fprintln(os.Stderr, "warning: Chromium appears to be running - it may overwrite this import on its")
fmt.Fprintln(os.Stderr, " next save. Fully quit Chromium and re-run this if the import doesn't stick.")
}
existing, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("reading %s: %w", path, err)
}
var cf map[string]interface{}
if err := json.Unmarshal(existing, &cf); err != nil {
return fmt.Errorf("parsing existing Chromium bookmarks file: %w", err)
}
roots, ok := cf["roots"].(map[string]interface{})
if !ok {
return fmt.Errorf("existing Chromium bookmarks file has no \"roots\" object")
}
bar, ok1 := roots["bookmark_bar"].(map[string]interface{})
other, ok2 := roots["other"].(map[string]interface{})
synced, ok3 := roots["synced"].(map[string]interface{})
if !ok1 || !ok2 || !ok3 {
return fmt.Errorf("existing Chromium bookmarks file is missing an expected root")
}
backupPath := path + ".mozsync-bak"
if err := os.WriteFile(backupPath, existing, 0o600); err != nil {
return fmt.Errorf("backing up existing bookmarks file: %w", err)
}
nextID := maxChromeID(roots) + 1
// Merge directly into each root's top level, matching existing items by
// (name, type): an existing bookmark's url is overwritten, an existing
// folder is recursed into and merged, and only genuinely new items are
// appended. No wrapper folder, and re-running this is idempotent instead
// of piling up a fresh dated folder each time.
mergeRoot := func(target map[string]interface{}, ffRootID string) {
root, ok := byID[ffRootID]
if !ok {
return
}
existingKids, _ := target["children"].([]interface{})
target["children"] = mergeChromeChildren(existingKids, byID, root.Children, &nextID)
}
mergeRoot(bar, "toolbar")
mergeRoot(other, "menu")
mergeRoot(other, "unfiled")
mergeRoot(synced, "mobile")
cf["checksum"] = computeChromeChecksum(bar, other, synced)
out, err := json.MarshalIndent(cf, "", " ")
if err != nil {
return err
}
tmpPath := path + ".mozsync-tmp"
if err := os.WriteFile(tmpPath, out, 0o600); err != nil {
return err
}
if err := os.Rename(tmpPath, path); err != nil {
return err
}
fmt.Println("Imported into", path)
fmt.Println("Backup of the previous file saved to", backupPath)
fmt.Println("Fully quit and relaunch Chromium to see the merged bookmarks.")
return nil
}
// mergeChromeChildren upserts each Firefox child (by title+type match) into
// an existing Chromium children array: url nodes get their url overwritten,
// folder nodes are recursed into, and anything with no match is appended as
// a freshly built subtree.
func mergeChromeChildren(existing []interface{}, byID map[string]ffRecord, ffChildIDs []string, nextID *int) []interface{} {
for _, childID := range ffChildIDs {
r, ok := byID[childID]
if !ok {
continue
}
switch r.Type {
case "bookmark":
if r.BmkURI == "" {
continue
}
existing = upsertChromeURL(existing, r, nextID)
case "folder":
existing = upsertChromeFolder(existing, r, byID, nextID)
}
}
return existing
}
func upsertChromeURL(existing []interface{}, r ffRecord, nextID *int) []interface{} {
for _, e := range existing {
m, ok := e.(map[string]interface{})
if !ok {
continue
}
name, _ := m["name"].(string)
t, _ := m["type"].(string)
if name == r.Title && t == "url" {
m["url"] = r.BmkURI
return existing
}
}
n := map[string]interface{}{
"id": strconv.Itoa(*nextID), "guid": newGUID(), "name": r.Title, "type": "url",
"url": r.BmkURI, "date_added": chromeTimestamp(r.DateAdded), "date_last_used": "0", "date_modified": "0",
}
(*nextID)++
return append(existing, n)
}
func upsertChromeFolder(existing []interface{}, r ffRecord, byID map[string]ffRecord, nextID *int) []interface{} {
for _, e := range existing {
m, ok := e.(map[string]interface{})
if !ok {
continue
}
name, _ := m["name"].(string)
t, _ := m["type"].(string)
if name == r.Title && t == "folder" {
kids, _ := m["children"].([]interface{})
m["children"] = mergeChromeChildren(kids, byID, r.Children, nextID)
return existing
}
}
if n := buildChromeNode(byID, r.ID, nextID); n != nil {
existing = append(existing, n)
}
return existing
}
func buildChromeNode(byID map[string]ffRecord, id string, nextID *int) map[string]interface{} {
r, ok := byID[id]
if !ok {
return nil
}
switch r.Type {
case "bookmark":
if r.BmkURI == "" {
return nil
}
n := map[string]interface{}{
"id": strconv.Itoa(*nextID), "guid": newGUID(), "name": r.Title, "type": "url",
"url": r.BmkURI, "date_added": chromeTimestamp(r.DateAdded), "date_last_used": "0", "date_modified": "0",
}
(*nextID)++
return n
case "folder":
myID := strconv.Itoa(*nextID)
(*nextID)++
var kids []interface{}
for _, childID := range r.Children {
if child := buildChromeNode(byID, childID, nextID); child != nil {
kids = append(kids, child)
}
}
n := map[string]interface{}{
"id": myID, "guid": newGUID(), "name": r.Title, "type": "folder",
"date_added": chromeTimestamp(r.DateAdded), "date_last_used": "0", "date_modified": "0",
}
if kids != nil {
n["children"] = kids
}
return n
default:
return nil // skip queries, separators, livemarks, etc.
}
}
func maxChromeID(roots map[string]interface{}) int {
max := 0
var walk func(n interface{})
walk = func(n interface{}) {
m, ok := n.(map[string]interface{})
if !ok {
return
}
if idStr, ok := m["id"].(string); ok {
if v, err := strconv.Atoi(idStr); err == nil && v > max {
max = v
}
}
if kids, ok := m["children"].([]interface{}); ok {
for _, k := range kids {
walk(k)
}
}
}
for _, v := range roots {
walk(v)
}
return max
}
// computeChromeChecksum reproduces Chromium's BookmarkCodec checksum: an MD5
// over (id, name[, url if a url node]) for every node, visited pre-order,
// across bookmark_bar/other/synced in that order. Best-effort - if a given
// Chromium version's algorithm has drifted, worst case is a one-time "your
// bookmarks were changed outside Chrome" notice on next launch; the data
// itself is unaffected either way.
func computeChromeChecksum(roots ...map[string]interface{}) string {
h := md5.New()
var walk func(n map[string]interface{})
walk = func(n map[string]interface{}) {
if n == nil {
return
}
if idStr, ok := n["id"].(string); ok {
io.WriteString(h, idStr)
}
if name, ok := n["name"].(string); ok {
io.WriteString(h, name)
}
if t, _ := n["type"].(string); t == "url" {
if u, ok := n["url"].(string); ok {
io.WriteString(h, u)
}
}
if kids, ok := n["children"].([]interface{}); ok {
for _, k := range kids {
if km, ok := k.(map[string]interface{}); ok {
walk(km)
}
}
}
}
for _, r := range roots {
walk(r)
}
return hex.EncodeToString(h.Sum(nil))
}
// chromeEpochDeltaMicros is the offset between the Unix epoch (1970-01-01)
// and the Windows/WebKit epoch (1601-01-01) that Chromium's bookmark
// timestamps are measured from, in microseconds.
const chromeEpochDeltaMicros = 11644473600000000
func chromeTimestamp(unixMs int64) string {
return strconv.FormatInt(unixMs*1000+chromeEpochDeltaMicros, 10)
}
func newGUID() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
}
func chromiumRunning() bool {
out, err := exec.Command("pgrep", "-f", "org.chromium.Chromium").Output()
return err == nil && len(strings.TrimSpace(string(out))) > 0
}
// ---------- falkon-import: merge a `mozsync dump` into Falkon's bookmarks.json ----------
//
// Falkon (unlike Chromium) stores bookmarks as plain JSON with no ids,
// checksum, or timestamp bookkeeping - just nested folders under three roots:
// bookmark_bar, bookmark_menu, other. Same safe-append/backup approach as
// chrome-import: existing bookmarks are never touched, only appended to.
func doFalkonImport(path string) error {
dumpBytes, err := io.ReadAll(os.Stdin)
if err != nil {
return fmt.Errorf("reading dump from stdin: %w", err)
}
var records []ffRecord
if err := json.Unmarshal(dumpBytes, &records); err != nil {
return fmt.Errorf("parsing dump JSON: %w", err)
}
byID := make(map[string]ffRecord, len(records))
for _, r := range records {
if !r.Deleted {
byID[r.ID] = r
}
}
if falkonRunning() {
fmt.Fprintln(os.Stderr, "warning: Falkon appears to be running - it may overwrite this import on its")
fmt.Fprintln(os.Stderr, " next save. Fully quit Falkon and re-run this if the import doesn't stick.")
}
existing, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("reading %s: %w", path, err)
}
var bf map[string]interface{}
if err := json.Unmarshal(existing, &bf); err != nil {
return fmt.Errorf("parsing existing Falkon bookmarks file: %w", err)
}
roots, ok := bf["roots"].(map[string]interface{})
if !ok {
return fmt.Errorf("existing Falkon bookmarks file has no \"roots\" object")
}
bar, ok1 := roots["bookmark_bar"].(map[string]interface{})
menu, ok2 := roots["bookmark_menu"].(map[string]interface{})
other, ok3 := roots["other"].(map[string]interface{})
if !ok1 || !ok2 || !ok3 {
return fmt.Errorf("existing Falkon bookmarks file is missing an expected root")
}
backupPath := path + ".mozsync-bak"
if err := os.WriteFile(backupPath, existing, 0o600); err != nil {
return fmt.Errorf("backing up existing bookmarks file: %w", err)
}
// Merge directly into each root's top level, matching existing items by
// (name, type): an existing bookmark's url is overwritten, an existing
// folder is recursed into and merged, and only genuinely new items are
// appended. No wrapper folder, and re-running this is idempotent instead
// of piling up a fresh dated folder each time.
mergeRoot := func(target map[string]interface{}, ffRootID string) {
root, ok := byID[ffRootID]
if !ok {
return
}
existingKids, _ := target["children"].([]interface{})
target["children"] = mergeFalkonChildren(existingKids, byID, root.Children)
}
mergeRoot(bar, "toolbar")
mergeRoot(menu, "menu")
mergeRoot(other, "unfiled")
mergeRoot(other, "mobile")
out, err := json.MarshalIndent(bf, "", " ")
if err != nil {
return err
}
tmpPath := path + ".mozsync-tmp"
if err := os.WriteFile(tmpPath, out, 0o600); err != nil {
return err
}
if err := os.Rename(tmpPath, path); err != nil {
return err
}
fmt.Println("Imported into", path)
fmt.Println("Backup of the previous file saved to", backupPath)
fmt.Println("Fully quit and relaunch Falkon to see the merged bookmarks.")
return nil
}
// mergeFalkonChildren upserts each Firefox child (by title+type match) into
// an existing Falkon children array: url nodes get their url overwritten,
// folder nodes are recursed into, and anything with no match is appended as
// a freshly built subtree.
func mergeFalkonChildren(existing []interface{}, byID map[string]ffRecord, ffChildIDs []string) []interface{} {
for _, childID := range ffChildIDs {
r, ok := byID[childID]
if !ok {
continue
}
switch r.Type {
case "bookmark":
if r.BmkURI == "" {
continue
}
existing = upsertFalkonURL(existing, r)
case "folder":
existing = upsertFalkonFolder(existing, r, byID)
}
}
return existing
}
func upsertFalkonURL(existing []interface{}, r ffRecord) []interface{} {
for _, e := range existing {
m, ok := e.(map[string]interface{})
if !ok {
continue
}
name, _ := m["name"].(string)
t, _ := m["type"].(string)
if name == r.Title && t == "url" {
m["url"] = r.BmkURI
return existing
}
}
n := map[string]interface{}{
"description": "", "keyword": "", "name": r.Title, "type": "url", "url": r.BmkURI, "visit_count": 0,
}
return append(existing, n)
}
func upsertFalkonFolder(existing []interface{}, r ffRecord, byID map[string]ffRecord) []interface{} {
for _, e := range existing {
m, ok := e.(map[string]interface{})
if !ok {
continue
}
name, _ := m["name"].(string)
t, _ := m["type"].(string)
if name == r.Title && t == "folder" {
kids, _ := m["children"].([]interface{})
m["children"] = mergeFalkonChildren(kids, byID, r.Children)
return existing
}
}
if n := buildFalkonNode(byID, r.ID); n != nil {
existing = append(existing, n)
}
return existing
}
func buildFalkonNode(byID map[string]ffRecord, id string) map[string]interface{} {
r, ok := byID[id]
if !ok {
return nil
}
switch r.Type {
case "bookmark":
if r.BmkURI == "" {
return nil
}
return map[string]interface{}{
"description": "", "keyword": "", "name": r.Title, "type": "url",
"url": r.BmkURI, "visit_count": 0,
}
case "folder":
var kids []interface{}
for _, childID := range r.Children {
if child := buildFalkonNode(byID, childID); child != nil {
kids = append(kids, child)
}
}
if kids == nil {
kids = []interface{}{}
}
return map[string]interface{}{
"children": kids, "description": "", "expanded": true, "expanded_sidebar": true,
"name": r.Title, "type": "folder",
}
default:
return nil // skip queries, separators, livemarks, etc.
}
}
func falkonRunning() bool {
out, err := exec.Command("pgrep", "-f", "org.kde.falkon").Output()
return err == nil && len(strings.TrimSpace(string(out))) > 0
}
func refreshAccessToken(refreshToken string) (string, error) {
var resp struct {
AccessToken string `json:"access_token"`
}
err := postJSON(tokenURL, map[string]string{
"client_id": clientID,
"grant_type": "refresh_token",
"refresh_token": refreshToken,
"scope": scope,
}, &resp)
if err != nil {
return "", err
}
if resp.AccessToken == "" {
return "", fmt.Errorf("empty access token in refresh response")
}
return resp.AccessToken, nil
}
func getStorageCredentials(accessToken, keyID string) (id, key, apiEndpoint string, err error) {
req, err := http.NewRequest("GET", tokenServerURL, nil)
if err != nil {
return "", "", "", err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("X-KeyID", keyID)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", "", "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
return "", "", "", fmt.Errorf("tokenserver returned %d: %s", resp.StatusCode, body)
}
var ts struct {
ID string `json:"id"`
Key string `json:"key"`
APIEndpoint string `json:"api_endpoint"`
}
if err := json.Unmarshal(body, &ts); err != nil {
return "", "", "", err
}
return ts.ID, ts.Key, ts.APIEndpoint, nil
}
func storageGET(apiEndpoint, path, hawkID, hawkKey string) ([]byte, error) {
fullURL := apiEndpoint + path
req, err := http.NewRequest("GET", fullURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", hawkHeader("GET", fullURL, hawkID, hawkKey))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
return nil, fmt.Errorf("storage server returned %d: %s", resp.StatusCode, body)
}
return body, nil
}
// ---------- Hawk request signing ----------
func hawkHeader(method, rawURL, id, key string) string {
u, _ := url.Parse(rawURL)
ts := time.Now().Unix()
nonce := randB64URL(6)
resource := u.Path
if u.RawQuery != "" {
resource += "?" + u.RawQuery
}
port := u.Port()
if port == "" {
port = "443"
}
normalized := fmt.Sprintf("hawk.1.header\n%d\n%s\n%s\n%s\n%s\n%s\n\n\n",
ts, nonce, method, resource, u.Hostname(), port)
mac := hmac.New(sha256.New, []byte(key))
mac.Write([]byte(normalized))
macB64 := base64.StdEncoding.EncodeToString(mac.Sum(nil))
return fmt.Sprintf(`Hawk id="%s", ts="%d", nonce="%s", mac="%s"`, id, ts, nonce, macB64)
}
// ---------- small helpers ----------
func postJSON(u string, body interface{}, out interface{}) error {
data, err := json.Marshal(body)
if err != nil {
return err
}
resp, err := http.Post(u, "application/json", strings.NewReader(string(data)))
if err != nil {
return err
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, respBody)
}
return json.Unmarshal(respBody, out)
}
func randB64URL(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return b64url(b)
}
func b64url(b []byte) string {
return base64.RawURLEncoding.EncodeToString(b)
}
func sha256sum(b []byte) []byte {
s := sha256.Sum256(b)
return s[:]
}