355 lines
19 KiB
C++
355 lines
19 KiB
C++
#include "sync.h"
|
|
#include "json.hpp"
|
|
#include <winhttp.h>
|
|
#include <wincred.h>
|
|
#include <set>
|
|
#include <algorithm>
|
|
|
|
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<char *>(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<std::string &>(); SecureZeroMemory(p.data(), p.size()); throw; }
|
|
auto &p = login["password"].get_ref<std::string &>(); SecureZeroMemory(p.data(), p.size());
|
|
token = wide(response.at("auth").at("client_token").get<std::string>());
|
|
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<uint64_t>(), 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<uint64_t>();
|
|
}
|
|
};
|
|
}
|
|
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<wchar_t *>(c.username.c_str());
|
|
credential.CredentialBlob = reinterpret_cast<BYTE *>(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<SyncProvider> makeSyncProvider(const SyncConfig &c, SyncTransport transport)
|
|
{
|
|
return std::make_unique<OpenBao>(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<std::string>() < b.at("layoutId").get<std::string>();
|
|
});
|
|
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<Layout> &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<std::string> installed;
|
|
for (const auto &layout : layouts) installed.insert(utf8(layout.id));
|
|
std::set<std::string> missing;
|
|
for (const auto &id : j.at("layouts"))
|
|
if (!installed.contains(id.get<std::string>())) missing.insert(id.get<std::string>());
|
|
for (const auto &key : j.at("layoutHotkeys"))
|
|
if (!installed.contains(key.at("layoutId").get<std::string>())) missing.insert(key.at("layoutId").get<std::string>());
|
|
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<void(const Bytes &)> &validate, const std::vector<SyncChoice> &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<SyncConflict> &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<SyncConflict> decodeSyncConflicts(const Bytes &data)
|
|
{
|
|
std::vector<SyncConflict> result;
|
|
for (const auto &c : parse(data)) result.push_back({c.at("key"), wide(c.at("label").get<std::string>()), bytes(c.at("local")), bytes(c.at("remote"))});
|
|
return result;
|
|
}
|
|
Bytes encodeSyncChoices(const std::vector<SyncChoice> &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<SyncChoice> decodeSyncChoices(const Bytes &data)
|
|
{
|
|
auto j = parse(data);
|
|
if (!j.is_array() || j.size() > 10000) throw std::runtime_error("Invalid conflict choices.");
|
|
std::vector<SyncChoice> result;
|
|
for (const auto &c : j) result.push_back({decodeSyncConflicts(bytes(Json::array({c.at("conflict")}))).at(0), c.at("useLocal").get<bool>()});
|
|
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<std::string>() : 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)}})));
|
|
}
|
|
}
|