#include "sync.h" #include "json.hpp" #include #include #include #include namespace ks { using Json = nlohmann::json; namespace { constexpr size_t Limit = 4 * 1024 * 1024; Bytes bytes(const Json &j) { auto s = j.dump(); return {s.begin(), s.end()}; } Json parse(const Bytes &b) { return Json::parse(b.begin(), b.end()); } Json configJson(const SyncConfig &c) { return {{"enabled", c.enabled}, {"provider", utf8(c.provider)}, {"server", utf8(c.server)}, {"mount", utf8(c.mount)}, {"path", utf8(c.path)}, {"authMount", utf8(c.authMount)}, {"username", utf8(c.username)}, {"keepLocalLayouts", c.keepLocalLayouts}}; } std::wstring credentialName(const SyncConfig &c) { return L"KSwitcher.Sync/" + c.server + L"/" + c.authMount + L"/" + c.username; } bool safePath(const std::wstring &s) { if (s.empty() || s.size() > 512 || s.front() == '/' || s.back() == '/' || s.find(L"//") != s.npos) return false; return s.find_first_not_of(L"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-/") == s.npos; } struct HttpHandle { HINTERNET h{}; ~HttpHandle() { if (h) WinHttpCloseHandle(h); } }; class OpenBao final : public SyncProvider { SyncConfig config; SyncTransport transport; std::wstring token; Json response(unsigned status, const Bytes &result, bool missing) { if (result.size() > Limit) throw std::runtime_error("OpenBao response is too large."); if (status == 404 && missing) { auto j = result.empty() ? Json::object() : parse(result); if (j.contains("data") && !j["data"].is_null()) throw std::runtime_error("Remote profile is deleted. Restore it in OpenBao first."); return nullptr; } if (status == 401 || status == 403) throw std::runtime_error("OpenBao denied access. Check login and profile policy."); if (status == 400) throw std::runtime_error("OpenBao rejected the write or its version changed. Sync again; check KV v2 configuration."); if (status < 200 || status >= 300) throw std::runtime_error("OpenBao HTTP error " + std::to_string(status) + "."); return result.empty() ? Json::object() : parse(result); } Json request(const wchar_t *method, const std::wstring &path, const Json *body = nullptr, bool missing = false) { if (transport) { auto reply = transport(method, path, token, body ? bytes(*body) : Bytes{}); return response(reply.status, reply.body, missing); } URL_COMPONENTS url{sizeof(url)}; url.dwHostNameLength = url.dwUrlPathLength = url.dwExtraInfoLength = (DWORD)-1; if (!WinHttpCrackUrl(config.server.c_str(), 0, 0, &url)) throw std::runtime_error("Invalid server URL."); std::wstring host(url.lpszHostName, url.dwHostNameLength); HttpHandle session{WinHttpOpen(L"KSwitcher Sync/1", WINHTTP_ACCESS_TYPE_AUTOMATIC_PROXY, WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0)}; if (!session.h || !WinHttpSetTimeouts(session.h, 5000, 5000, 5000, 5000)) throw std::runtime_error("Cannot initialize sync connection."); HttpHandle connection{WinHttpConnect(session.h, host.c_str(), url.nPort, 0)}; HttpHandle req{connection.h ? WinHttpOpenRequest(connection.h, method, path.c_str(), nullptr, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, WINHTTP_FLAG_SECURE) : nullptr}; DWORD redirects = WINHTTP_OPTION_REDIRECT_POLICY_NEVER; if (!req.h || !WinHttpSetOption(req.h, WINHTTP_OPTION_REDIRECT_POLICY, &redirects, sizeof(redirects))) throw std::runtime_error("Cannot initialize sync request."); std::wstring headers = L"Content-Type: application/json\r\n"; if (!token.empty()) headers += L"X-Vault-Token: " + token + L"\r\n"; std::string payload = body ? body->dump() : ""; bool sent = WinHttpSendRequest(req.h, headers.c_str(), (DWORD)-1, payload.empty() ? WINHTTP_NO_REQUEST_DATA : payload.data(), (DWORD)payload.size(), (DWORD)payload.size(), 0); if (!payload.empty()) SecureZeroMemory(payload.data(), payload.size()); if (!sent || !WinHttpReceiveResponse(req.h, nullptr)) throw std::runtime_error("OpenBao connection failed. Check network, HTTPS certificate and server address."); DWORD status{}, length = sizeof(status); if (!WinHttpQueryHeaders(req.h, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, WINHTTP_HEADER_NAME_BY_INDEX, &status, &length, WINHTTP_NO_HEADER_INDEX)) throw std::runtime_error("Cannot read OpenBao response."); Bytes result; ULONGLONG deadline = GetTickCount64() + 15000; for (;;) { unsigned char chunk[8192]; DWORD received{}; if (GetTickCount64() > deadline || !WinHttpReadData(req.h, chunk, sizeof(chunk), &received)) throw std::runtime_error("OpenBao response timed out."); if (!received) break; if (result.size() + received > Limit) throw std::runtime_error("OpenBao response is too large."); result.insert(result.end(), chunk, chunk + received); } return response(status, result, missing); } public: explicit OpenBao(const SyncConfig &c, SyncTransport t) : config(c), transport(std::move(t)) { validateSyncConfig(c); PCREDENTIALW credential{}; if (!CredReadW(credentialName(c).c_str(), CRED_TYPE_GENERIC, 0, &credential)) throw std::runtime_error("Sign in: no saved password for this server and user."); std::string password(reinterpret_cast(credential->CredentialBlob), credential->CredentialBlobSize); SecureZeroMemory(credential->CredentialBlob, credential->CredentialBlobSize); CredFree(credential); Json login{{"password", password}}; SecureZeroMemory(password.data(), password.size()); Json response; try { response = request(L"POST", L"/v1/auth/" + c.authMount + L"/login/" + c.username, &login); } catch (...) { auto &p = login["password"].get_ref(); SecureZeroMemory(p.data(), p.size()); throw; } auto &p = login["password"].get_ref(); SecureZeroMemory(p.data(), p.size()); token = wide(response.at("auth").at("client_token").get()); if (token.empty() || token.find_first_of(L"\r\n") != token.npos) throw std::runtime_error("Invalid OpenBao authentication response."); } ~OpenBao() override { if (token.empty()) return; // Sessions are scoped to a sync attempt; do not accumulate login tokens. try { request(L"POST", L"/v1/auth/token/revoke-self"); } catch (...) {} SecureZeroMemory(token.data(), token.size() * sizeof(wchar_t)); } SyncDocument read() override { auto j = request(L"GET", L"/v1/" + config.mount + L"/data/" + config.path, nullptr, true); if (j.is_null()) return {}; const auto &data = j.at("data"); if (!data.at("metadata").value("deletion_time", std::string()).empty() || data.at("metadata").value("destroyed", false)) throw std::runtime_error("Remote profile is deleted or destroyed."); auto payload = data.at("data"); if (payload.value("syncSchema", 0) != 1 && payload.value("syncSchema", 0) != 2) throw std::runtime_error("Unsupported remote sync format."); return {data.at("metadata").at("version").get(), bytes(payload.at("settings"))}; } uint64_t write(const Bytes &settings, uint64_t revision) override { Json body{{"options", {{"cas", revision}}}, {"data", {{"syncSchema", 2}, {"settings", parse(settings)}}}}; auto response = request(L"POST", L"/v1/" + config.mount + L"/data/" + config.path, &body); return response.at("data").at("version").get(); } }; } bool syncVisible(const std::filesystem::path &root) { return std::filesystem::exists(root / L"sync.json"); } SyncConfig loadSyncConfig(const std::filesystem::path &root) { SyncConfig c; if (!syncVisible(root)) return c; auto j = parse(readFile(root / L"sync.json", 16384)); c.enabled = j.value("enabled", false); c.keepLocalLayouts = j.value("keepLocalLayouts", false); c.provider = wide(j.value("provider", "openbao")); c.server = wide(j.value("server", "")); c.mount = wide(j.value("mount", "kswitcher")); c.path = wide(j.value("path", "personal/settings")); c.authMount = wide(j.value("authMount", "userpass")); c.username = wide(j.value("username", "")); return c; } void validateSyncConfig(const SyncConfig &c) { if (c.provider != L"openbao") throw std::runtime_error("Unsupported sync provider."); URL_COMPONENTS url{sizeof(url)}; url.dwHostNameLength = url.dwUrlPathLength = url.dwExtraInfoLength = url.dwUserNameLength = url.dwPasswordLength = (DWORD)-1; if (c.server.size() > 1024 || !WinHttpCrackUrl(c.server.c_str(), 0, 0, &url) || url.nScheme != INTERNET_SCHEME_HTTPS || !url.dwHostNameLength || url.dwUserNameLength || url.dwPasswordLength || url.dwExtraInfoLength || (url.dwUrlPathLength && std::wstring(url.lpszUrlPath, url.dwUrlPathLength) != L"/")) throw std::runtime_error("Server must be an HTTPS origin, for example https://bao.example.com (no path or credentials)."); bool userValid = !c.username.empty() && c.username.size() <= 256 && c.username != L"." && c.username != L".." && c.username.find_first_not_of(L"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.@") == c.username.npos; if (!safePath(c.mount) || !safePath(c.path) || !safePath(c.authMount) || !userValid) throw std::runtime_error("Use letters, numbers, underscores and hyphens in paths and login; / is allowed only in paths."); if (credentialName(c).size() > CRED_MAX_GENERIC_TARGET_NAME_LENGTH) throw std::runtime_error("Sync connection name is too long."); } void saveSyncConfig(const std::filesystem::path &root, const SyncConfig &c) { if (c.enabled) validateSyncConfig(c); atomicWrite(root / L"sync.json", bytes(configJson(c))); } void saveSyncPassword(const SyncConfig &c, const std::wstring &password) { validateSyncConfig(c); auto secret = utf8(password); if (secret.empty() || secret.size() > CRED_MAX_CREDENTIAL_BLOB_SIZE) throw std::runtime_error("Password is empty or too long."); auto target = credentialName(c); CREDENTIALW credential{}; credential.Type = CRED_TYPE_GENERIC; credential.TargetName = target.data(); credential.UserName = const_cast(c.username.c_str()); credential.CredentialBlob = reinterpret_cast(secret.data()); credential.CredentialBlobSize = (DWORD)secret.size(); credential.Persist = CRED_PERSIST_LOCAL_MACHINE; bool ok = CredWriteW(&credential, 0) != 0; SecureZeroMemory(secret.data(), secret.size()); if (!ok) throw std::runtime_error("Cannot save password in Windows Credential Manager."); } void forgetSyncPassword(const SyncConfig &c) { if (!CredDeleteW(credentialName(c).c_str(), CRED_TYPE_GENERIC, 0) && GetLastError() != ERROR_NOT_FOUND) throw std::runtime_error("Cannot remove saved sync password."); } std::unique_ptr makeSyncProvider(const SyncConfig &c, SyncTransport transport) { return std::make_unique(c, std::move(transport)); } namespace { Json canonicalProfile(Json); } Bytes sharedSyncSettings(const Settings &s) { auto j = parse(encodeSettings(s)); // Windows may enumerate the same installed layouts in a different order on // another PC. Binding order is not user intent (unlike the layout cycle). auto &bindings = j["layoutHotkeys"]; std::sort(bindings.begin(), bindings.end(), [](const Json &a, const Json &b) { return a.at("layoutId").get() < b.at("layoutId").get(); }); for (auto key : {"startWithSystem", "autostartAsAdministrator", "tesseractPath", "useTesseract", "historyEnabled", "maxItems", "maxTotalMegabytes", "maxImageMegabytes", "writeLog", "preventScreenLock"}) j.erase(key); return bytes(canonicalProfile(j)); } Settings restoreSyncSettings(const Bytes &shared, const Settings &local, const std::vector &layouts, bool keepLocalLayouts) { auto j = parse(shared), original = parse(encodeSettings(local)); if (!j.contains("snippetTombstones")) j["snippetTombstones"] = Json::array(); if (!j.contains("autoCorrectReplace")) j["autoCorrectReplace"] = false; if (!j.contains("autoCorrectTypos")) j["autoCorrectTypos"] = false; if (!j.contains("autoCorrectReplacements")) j["autoCorrectReplacements"] = Json::object(); auto allowed = parse(sharedSyncSettings(local)); if (!j.is_object() || j.value("format", "") != "kswitcher-settings" || j.value("schemaVersion", 0) != 1) throw std::runtime_error("Unsupported synchronized settings format."); for (auto it = j.begin(); it != j.end(); ++it) if (!allowed.contains(it.key())) throw std::runtime_error("Remote profile contains unsupported or local-only fields."); if (j.size() != allowed.size()) throw std::runtime_error("Remote profile is incomplete."); std::set installed; for (const auto &layout : layouts) installed.insert(utf8(layout.id)); std::set missing; for (const auto &id : j.at("layouts")) if (!installed.contains(id.get())) missing.insert(id.get()); for (const auto &key : j.at("layoutHotkeys")) if (!installed.contains(key.at("layoutId").get())) missing.insert(key.at("layoutId").get()); if (!keepLocalLayouts && !missing.empty()) { std::wstring message = L"Missing keyboard layouts on this PC: "; for (const auto &id : missing) { auto code = id.starts_with("win:") ? id.substr(4) : id; wchar_t language[128]{}; if (code.size() == 8 && code.find_first_not_of("0123456789abcdefABCDEF") == code.npos) GetLocaleInfoW(MAKELCID(LOWORD(std::stoul(code, nullptr, 16)), SORT_DEFAULT), LOCALE_SENGLISHLANGUAGENAME, language, 128); if (message.back() != L' ') message += L"; "; message += (language[0] ? std::wstring(language) : L"Unknown layout") + L" [" + wide(id) + L"]"; } message += L". Install these exact layouts, or enable 'Keep this PC's layouts' below and sync other settings."; throw std::runtime_error(utf8(message)); } if (keepLocalLayouts) for (auto key : {"layouts", "layoutHotkeys"}) j[key] = original.at(key); original.update(j); return decodeSettings(bytes(original), layouts); } #include "sync_merge.inl" SyncResult synchronize(SyncProvider &provider, const Bytes &base, const Bytes &local, SyncAction action, const std::function &validate, const std::vector &choices, bool keepLocalLayouts) { if (local.size() > Limit) throw std::runtime_error("Settings exceed the sync size limit."); if (validate) validate(local); auto remote = provider.read(); if (!remote.revision) { if (action != SyncAction::Upload) throw std::runtime_error("No remote profile. Choose Upload local to create it."); return {true, local, provider.write(local, 0), L"Uploaded new profile."}; } if (validate && action != SyncAction::Upload) validate(remote.settings); auto mergeLocal = local, mergeBase = base; if (keepLocalLayouts) { // Never upload a locally filtered projection over another PC's layout setup. const auto remoteJson = parse(remote.settings); auto preserve = [&](const Bytes &profile) { auto j = parse(profile); for (auto key : {"layouts", "layoutHotkeys"}) j[key] = remoteJson.at(key); return bytes(j); }; mergeLocal = preserve(local); if (!base.empty()) mergeBase = preserve(base); } SyncMerge merged; if (action == SyncAction::Automatic) merged = mergeSyncProfile(mergeBase, mergeLocal, remote.settings, choices); else { auto selected = bytes(normalizeProfile(parse(action == SyncAction::Download ? remote.settings : mergeLocal))); merged = {selected, selected, selected, {}}; } if (validate) { validate(merged.local); validate(merged.remote); } auto revision = remote.revision; if (parse(merged.remote) != parse(remote.settings) && action != SyncAction::Download) revision = provider.write(merged.remote, revision); return {true, merged.local, revision, merged.conflicts.empty() ? L"Synchronized. Server version " + std::to_wstring(revision) + L"." : L"Other changes synchronized. Resolve " + std::to_wstring(merged.conflicts.size()) + L" conflicting setting(s).", merged.base, merged.conflicts}; } Bytes encodeSyncConflicts(const std::vector &conflicts) { Json j = Json::array(); for (const auto &c : conflicts) j.push_back({{"key", c.key}, {"label", utf8(c.label)}, {"local", parse(c.local)}, {"remote", parse(c.remote)}}); return bytes(j); } std::vector decodeSyncConflicts(const Bytes &data) { std::vector result; for (const auto &c : parse(data)) result.push_back({c.at("key"), wide(c.at("label").get()), bytes(c.at("local")), bytes(c.at("remote"))}); return result; } Bytes encodeSyncChoices(const std::vector &choices) { Json j = Json::array(); for (const auto &c : choices) j.push_back({{"conflict", parse(encodeSyncConflicts({c.conflict})).at(0)}, {"useLocal", c.useLocal}}); return bytes(j); } std::vector decodeSyncChoices(const Bytes &data) { auto j = parse(data); if (!j.is_array() || j.size() > 10000) throw std::runtime_error("Invalid conflict choices."); std::vector result; for (const auto &c : j) result.push_back({decodeSyncConflicts(bytes(Json::array({c.at("conflict")}))).at(0), c.at("useLocal").get()}); return result; } std::wstring syncValueText(const Bytes &data) { auto j = parse(data); if (j.is_null()) return L"(not present)"; if (tombstone(j)) return L"(deleted)"; return wide(j.is_string() ? j.get() : j.dump(2)); } Bytes loadSyncBase(const std::filesystem::path &root, const SyncConfig &c) { auto path = root / L"sync-base.bin"; if (!std::filesystem::exists(path)) return {}; auto j = parse(unprotect(readFile(path, Limit + 65536))); auto connection = j.at("connection"); if (!connection.contains("keepLocalLayouts")) connection["keepLocalLayouts"] = false; return connection == configJson(c) ? bytes(j.at("settings")) : Bytes{}; } void saveSyncBase(const std::filesystem::path &root, const SyncConfig &c, const Bytes &base) { atomicWrite(root / L"sync-base.bin", protect(bytes({{"connection", configJson(c)}, {"settings", parse(base)}}))); } }