nu
This commit is contained in:
parent
e8864cc853
commit
1d42a9a623
9 changed files with 2122 additions and 542 deletions
|
|
@ -2,7 +2,7 @@ FROM drogonframework/drogon:latest
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install additional dependencies including redis-plus-plus dev package if available
|
# Install additional dependencies including GPG for PGP verification
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y \
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
postgresql-client \
|
postgresql-client \
|
||||||
|
|
@ -12,6 +12,8 @@ RUN apt-get update && apt-get install -y \
|
||||||
libhiredis-dev \
|
libhiredis-dev \
|
||||||
curl \
|
curl \
|
||||||
libssl-dev \
|
libssl-dev \
|
||||||
|
gnupg \
|
||||||
|
gnupg2 \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Try to install redis-plus-plus from package manager first
|
# Try to install redis-plus-plus from package manager first
|
||||||
|
|
@ -87,6 +89,10 @@ RUN mkdir -p /app/uploads/avatars && \
|
||||||
chown -R 65534:65534 /app/uploads && \
|
chown -R 65534:65534 /app/uploads && \
|
||||||
chmod -R 755 /app/uploads
|
chmod -R 755 /app/uploads
|
||||||
|
|
||||||
|
# Create a temporary directory for GPG operations
|
||||||
|
RUN mkdir -p /tmp/pgp_verify && \
|
||||||
|
chmod 777 /tmp/pgp_verify
|
||||||
|
|
||||||
# Ensure libraries are available
|
# Ensure libraries are available
|
||||||
ENV LD_LIBRARY_PATH=/usr/local/lib:$LD_LIBRARY_PATH
|
ENV LD_LIBRARY_PATH=/usr/local/lib:$LD_LIBRARY_PATH
|
||||||
|
|
||||||
|
|
@ -94,6 +100,8 @@ ENV LD_LIBRARY_PATH=/usr/local/lib:$LD_LIBRARY_PATH
|
||||||
RUN echo '#!/bin/bash\n\
|
RUN echo '#!/bin/bash\n\
|
||||||
echo "Checking library dependencies..."\n\
|
echo "Checking library dependencies..."\n\
|
||||||
ldd ./build/streaming-backend\n\
|
ldd ./build/streaming-backend\n\
|
||||||
|
echo "Checking GPG installation..."\n\
|
||||||
|
gpg --version\n\
|
||||||
echo "Ensuring upload directories exist with proper permissions..."\n\
|
echo "Ensuring upload directories exist with proper permissions..."\n\
|
||||||
mkdir -p /app/uploads/avatars\n\
|
mkdir -p /app/uploads/avatars\n\
|
||||||
chown -R 65534:65534 /app/uploads\n\
|
chown -R 65534:65534 /app/uploads\n\
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
#include "UserController.h"
|
#include "UserController.h"
|
||||||
#include "../services/DatabaseService.h"
|
#include "../services/DatabaseService.h"
|
||||||
#include <drogon/MultiPart.h>
|
#include <drogon/MultiPart.h>
|
||||||
|
#include <drogon/Cookie.h>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <random>
|
#include <random>
|
||||||
#include <sstream>
|
#include <sstream>
|
||||||
|
|
@ -51,18 +52,46 @@ namespace {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper to set httpOnly auth cookie
|
||||||
|
void setAuthCookie(const HttpResponsePtr& resp, const std::string& token) {
|
||||||
|
Cookie authCookie("auth_token", token);
|
||||||
|
authCookie.setPath("/");
|
||||||
|
authCookie.setHttpOnly(true);
|
||||||
|
authCookie.setSecure(false); // Set to true in production with HTTPS
|
||||||
|
authCookie.setMaxAge(86400); // 24 hours
|
||||||
|
authCookie.setSameSite(Cookie::SameSite::kLax);
|
||||||
|
resp->addCookie(authCookie);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to clear auth cookie
|
||||||
|
void clearAuthCookie(const HttpResponsePtr& resp) {
|
||||||
|
Cookie authCookie("auth_token", "");
|
||||||
|
authCookie.setPath("/");
|
||||||
|
authCookie.setHttpOnly(true);
|
||||||
|
authCookie.setMaxAge(0); // Expire immediately
|
||||||
|
resp->addCookie(authCookie);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
UserInfo UserController::getUserFromRequest(const HttpRequestPtr &req) {
|
UserInfo UserController::getUserFromRequest(const HttpRequestPtr &req) {
|
||||||
UserInfo user;
|
UserInfo user;
|
||||||
std::string auth = req->getHeader("Authorization");
|
|
||||||
|
|
||||||
if (auth.empty() || auth.substr(0, 7) != "Bearer ") {
|
// First try to get from cookie
|
||||||
return user;
|
std::string token = req->getCookie("auth_token");
|
||||||
|
|
||||||
|
// Fallback to Authorization header for API clients
|
||||||
|
if (token.empty()) {
|
||||||
|
std::string auth = req->getHeader("Authorization");
|
||||||
|
if (!auth.empty() && auth.substr(0, 7) == "Bearer ") {
|
||||||
|
token = auth.substr(7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token.empty()) {
|
||||||
|
AuthService::getInstance().validateToken(token, user);
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string token = auth.substr(7);
|
|
||||||
AuthService::getInstance().validateToken(token, user);
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -168,9 +197,10 @@ void UserController::login(const HttpRequestPtr &req,
|
||||||
[callback, username](bool success, const std::string& token, const UserInfo& user) {
|
[callback, username](bool success, const std::string& token, const UserInfo& user) {
|
||||||
if (success) {
|
if (success) {
|
||||||
LOG_INFO << "Login successful for user: " << username;
|
LOG_INFO << "Login successful for user: " << username;
|
||||||
|
|
||||||
Json::Value resp;
|
Json::Value resp;
|
||||||
resp["success"] = true;
|
resp["success"] = true;
|
||||||
resp["token"] = token;
|
// Don't send token in body for cookie-based auth
|
||||||
resp["user"]["id"] = static_cast<Json::Int64>(user.id);
|
resp["user"]["id"] = static_cast<Json::Int64>(user.id);
|
||||||
resp["user"]["username"] = user.username;
|
resp["user"]["username"] = user.username;
|
||||||
resp["user"]["isAdmin"] = user.isAdmin;
|
resp["user"]["isAdmin"] = user.isAdmin;
|
||||||
|
|
@ -179,8 +209,11 @@ void UserController::login(const HttpRequestPtr &req,
|
||||||
resp["user"]["bio"] = user.bio;
|
resp["user"]["bio"] = user.bio;
|
||||||
resp["user"]["avatarUrl"] = user.avatarUrl;
|
resp["user"]["avatarUrl"] = user.avatarUrl;
|
||||||
resp["user"]["pgpOnlyEnabledAt"] = user.pgpOnlyEnabledAt;
|
resp["user"]["pgpOnlyEnabledAt"] = user.pgpOnlyEnabledAt;
|
||||||
resp["user"]["colorCode"] = user.colorCode; // Use colorCode for consistency with database field name
|
resp["user"]["colorCode"] = user.colorCode;
|
||||||
callback(jsonResp(resp));
|
|
||||||
|
auto response = jsonResp(resp);
|
||||||
|
setAuthCookie(response, token);
|
||||||
|
callback(response);
|
||||||
} else {
|
} else {
|
||||||
LOG_WARN << "Login failed for user: " << username;
|
LOG_WARN << "Login failed for user: " << username;
|
||||||
callback(jsonError(token.empty() ? "Invalid credentials" : token, k401Unauthorized));
|
callback(jsonError(token.empty() ? "Invalid credentials" : token, k401Unauthorized));
|
||||||
|
|
@ -195,6 +228,22 @@ void UserController::login(const HttpRequestPtr &req,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void UserController::logout(const HttpRequestPtr &req,
|
||||||
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||||
|
try {
|
||||||
|
Json::Value resp;
|
||||||
|
resp["success"] = true;
|
||||||
|
resp["message"] = "Logged out successfully";
|
||||||
|
|
||||||
|
auto response = jsonResp(resp);
|
||||||
|
clearAuthCookie(response);
|
||||||
|
callback(response);
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
LOG_ERROR << "Exception in logout: " << e.what();
|
||||||
|
callback(jsonError("Internal server error"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void UserController::pgpChallenge(const HttpRequestPtr &req,
|
void UserController::pgpChallenge(const HttpRequestPtr &req,
|
||||||
std::function<void(const HttpResponsePtr &)> &&callback) {
|
std::function<void(const HttpResponsePtr &)> &&callback) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -264,7 +313,7 @@ void UserController::pgpVerify(const HttpRequestPtr &req,
|
||||||
if (success) {
|
if (success) {
|
||||||
Json::Value resp;
|
Json::Value resp;
|
||||||
resp["success"] = true;
|
resp["success"] = true;
|
||||||
resp["token"] = token;
|
// Don't send token in body for cookie-based auth
|
||||||
resp["user"]["id"] = static_cast<Json::Int64>(user.id);
|
resp["user"]["id"] = static_cast<Json::Int64>(user.id);
|
||||||
resp["user"]["username"] = user.username;
|
resp["user"]["username"] = user.username;
|
||||||
resp["user"]["isAdmin"] = user.isAdmin;
|
resp["user"]["isAdmin"] = user.isAdmin;
|
||||||
|
|
@ -273,8 +322,11 @@ void UserController::pgpVerify(const HttpRequestPtr &req,
|
||||||
resp["user"]["bio"] = user.bio;
|
resp["user"]["bio"] = user.bio;
|
||||||
resp["user"]["avatarUrl"] = user.avatarUrl;
|
resp["user"]["avatarUrl"] = user.avatarUrl;
|
||||||
resp["user"]["pgpOnlyEnabledAt"] = user.pgpOnlyEnabledAt;
|
resp["user"]["pgpOnlyEnabledAt"] = user.pgpOnlyEnabledAt;
|
||||||
resp["user"]["colorCode"] = user.colorCode; // Add colorCode to PGP login response
|
resp["user"]["colorCode"] = user.colorCode;
|
||||||
callback(jsonResp(resp));
|
|
||||||
|
auto response = jsonResp(resp);
|
||||||
|
setAuthCookie(response, token);
|
||||||
|
callback(response);
|
||||||
} else {
|
} else {
|
||||||
callback(jsonError("Invalid signature", k401Unauthorized));
|
callback(jsonError("Invalid signature", k401Unauthorized));
|
||||||
}
|
}
|
||||||
|
|
@ -782,7 +834,7 @@ void UserController::updateColor(const HttpRequestPtr &req,
|
||||||
AuthService::getInstance().updateUserColor(user.id, newColor,
|
AuthService::getInstance().updateUserColor(user.id, newColor,
|
||||||
[callback, user](bool success, const std::string& error, const std::string& finalColor) {
|
[callback, user](bool success, const std::string& error, const std::string& finalColor) {
|
||||||
if (success) {
|
if (success) {
|
||||||
// Fetch updated user info and generate new token with updated color
|
// Fetch updated user info
|
||||||
AuthService::getInstance().fetchUserInfo(user.id,
|
AuthService::getInstance().fetchUserInfo(user.id,
|
||||||
[callback, finalColor](bool fetchSuccess, const UserInfo& updatedUser) {
|
[callback, finalColor](bool fetchSuccess, const UserInfo& updatedUser) {
|
||||||
if (fetchSuccess) {
|
if (fetchSuccess) {
|
||||||
|
|
@ -792,13 +844,16 @@ void UserController::updateColor(const HttpRequestPtr &req,
|
||||||
Json::Value resp;
|
Json::Value resp;
|
||||||
resp["success"] = true;
|
resp["success"] = true;
|
||||||
resp["color"] = finalColor;
|
resp["color"] = finalColor;
|
||||||
resp["token"] = newToken; // Return new token with updated color
|
|
||||||
resp["user"]["id"] = static_cast<Json::Int64>(updatedUser.id);
|
resp["user"]["id"] = static_cast<Json::Int64>(updatedUser.id);
|
||||||
resp["user"]["username"] = updatedUser.username;
|
resp["user"]["username"] = updatedUser.username;
|
||||||
resp["user"]["isAdmin"] = updatedUser.isAdmin;
|
resp["user"]["isAdmin"] = updatedUser.isAdmin;
|
||||||
resp["user"]["isStreamer"] = updatedUser.isStreamer;
|
resp["user"]["isStreamer"] = updatedUser.isStreamer;
|
||||||
resp["user"]["colorCode"] = updatedUser.colorCode;
|
resp["user"]["colorCode"] = updatedUser.colorCode;
|
||||||
callback(jsonResp(resp));
|
|
||||||
|
auto response = jsonResp(resp);
|
||||||
|
// Update auth cookie with new token
|
||||||
|
setAuthCookie(response, newToken);
|
||||||
|
callback(response);
|
||||||
} else {
|
} else {
|
||||||
// Color was updated but couldn't fetch full user info
|
// Color was updated but couldn't fetch full user info
|
||||||
Json::Value resp;
|
Json::Value resp;
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ public:
|
||||||
METHOD_LIST_BEGIN
|
METHOD_LIST_BEGIN
|
||||||
ADD_METHOD_TO(UserController::register_, "/api/auth/register", Post);
|
ADD_METHOD_TO(UserController::register_, "/api/auth/register", Post);
|
||||||
ADD_METHOD_TO(UserController::login, "/api/auth/login", Post);
|
ADD_METHOD_TO(UserController::login, "/api/auth/login", Post);
|
||||||
|
ADD_METHOD_TO(UserController::logout, "/api/auth/logout", Post);
|
||||||
ADD_METHOD_TO(UserController::pgpChallenge, "/api/auth/pgp-challenge", Post);
|
ADD_METHOD_TO(UserController::pgpChallenge, "/api/auth/pgp-challenge", Post);
|
||||||
ADD_METHOD_TO(UserController::pgpVerify, "/api/auth/pgp-verify", Post);
|
ADD_METHOD_TO(UserController::pgpVerify, "/api/auth/pgp-verify", Post);
|
||||||
ADD_METHOD_TO(UserController::getCurrentUser, "/api/user/me", Get);
|
ADD_METHOD_TO(UserController::getCurrentUser, "/api/user/me", Get);
|
||||||
|
|
@ -30,6 +31,9 @@ public:
|
||||||
void login(const HttpRequestPtr &req,
|
void login(const HttpRequestPtr &req,
|
||||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||||
|
|
||||||
|
void logout(const HttpRequestPtr &req,
|
||||||
|
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||||
|
|
||||||
void pgpChallenge(const HttpRequestPtr &req,
|
void pgpChallenge(const HttpRequestPtr &req,
|
||||||
std::function<void(const HttpResponsePtr &)> &&callback);
|
std::function<void(const HttpResponsePtr &)> &&callback);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,9 @@
|
||||||
#include <random>
|
#include <random>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
#include <fstream>
|
||||||
|
#include <sstream>
|
||||||
|
#include <cstdlib>
|
||||||
|
|
||||||
using namespace drogon;
|
using namespace drogon;
|
||||||
using namespace drogon::orm;
|
using namespace drogon::orm;
|
||||||
|
|
@ -29,6 +32,159 @@ bool AuthService::validatePassword(const std::string& password, std::string& err
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper function to execute GPG commands
|
||||||
|
std::string executeGpgCommand(const std::string& command) {
|
||||||
|
std::array<char, 128> buffer;
|
||||||
|
std::string result;
|
||||||
|
|
||||||
|
FILE* pipe = popen(command.c_str(), "r");
|
||||||
|
if (!pipe) {
|
||||||
|
LOG_ERROR << "Failed to execute GPG command: " << command;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
while (fgets(buffer.data(), buffer.size(), pipe) != nullptr) {
|
||||||
|
result += buffer.data();
|
||||||
|
}
|
||||||
|
|
||||||
|
int exitCode = pclose(pipe);
|
||||||
|
|
||||||
|
// Exit code is returned as status << 8, so we need to extract the actual exit code
|
||||||
|
int actualExitCode = WEXITSTATUS(exitCode);
|
||||||
|
|
||||||
|
if (actualExitCode != 0) {
|
||||||
|
LOG_ERROR << "GPG command failed with exit code: " << actualExitCode
|
||||||
|
<< " for command: " << command
|
||||||
|
<< " output: " << result;
|
||||||
|
// Don't return empty string immediately - sometimes GPG returns non-zero but still works
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server-side PGP signature verification
|
||||||
|
bool verifyPgpSignature(const std::string& message, const std::string& signature, const std::string& publicKey) {
|
||||||
|
try {
|
||||||
|
// Create temporary directory for GPG operations
|
||||||
|
std::string tmpDir = "/tmp/pgp_verify_" + drogon::utils::genRandomString(8);
|
||||||
|
std::string mkdirCmd = "mkdir -p " + tmpDir;
|
||||||
|
if (system(mkdirCmd.c_str()) != 0) {
|
||||||
|
LOG_ERROR << "Failed to create temporary directory: " << tmpDir;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create GPG home directory
|
||||||
|
std::string keyringDir = tmpDir + "/gnupg";
|
||||||
|
std::string mkdirGpgCmd = "mkdir -p " + keyringDir + " && chmod 700 " + keyringDir;
|
||||||
|
if (system(mkdirGpgCmd.c_str()) != 0) {
|
||||||
|
LOG_ERROR << "Failed to create GPG home directory: " << keyringDir;
|
||||||
|
std::string cleanupCmd = "rm -rf " + tmpDir;
|
||||||
|
system(cleanupCmd.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write files
|
||||||
|
std::string messageFile = tmpDir + "/message.txt";
|
||||||
|
std::string sigFile = tmpDir + "/signature.asc";
|
||||||
|
std::string pubkeyFile = tmpDir + "/pubkey.asc";
|
||||||
|
|
||||||
|
// Write message file
|
||||||
|
std::ofstream msgOut(messageFile);
|
||||||
|
if (!msgOut) {
|
||||||
|
LOG_ERROR << "Failed to create message file: " << messageFile;
|
||||||
|
std::string cleanupCmd = "rm -rf " + tmpDir;
|
||||||
|
system(cleanupCmd.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
msgOut << message;
|
||||||
|
msgOut.close();
|
||||||
|
|
||||||
|
// Write signature file
|
||||||
|
std::ofstream sigOut(sigFile);
|
||||||
|
if (!sigOut) {
|
||||||
|
LOG_ERROR << "Failed to create signature file: " << sigFile;
|
||||||
|
std::string cleanupCmd = "rm -rf " + tmpDir;
|
||||||
|
system(cleanupCmd.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
sigOut << signature;
|
||||||
|
sigOut.close();
|
||||||
|
|
||||||
|
// Write public key file
|
||||||
|
std::ofstream keyOut(pubkeyFile);
|
||||||
|
if (!keyOut) {
|
||||||
|
LOG_ERROR << "Failed to create public key file: " << pubkeyFile;
|
||||||
|
std::string cleanupCmd = "rm -rf " + tmpDir;
|
||||||
|
system(cleanupCmd.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
keyOut << publicKey;
|
||||||
|
keyOut.close();
|
||||||
|
|
||||||
|
// Initialize GPG (create trustdb if needed)
|
||||||
|
std::string initCmd = "GNUPGHOME=" + keyringDir + " gpg --batch --yes --list-keys 2>&1";
|
||||||
|
executeGpgCommand(initCmd); // This will create the trustdb if it doesn't exist
|
||||||
|
|
||||||
|
// Import the public key to the temporary keyring
|
||||||
|
// Use --trust-model always to avoid trust issues
|
||||||
|
std::string importCmd = "GNUPGHOME=" + keyringDir +
|
||||||
|
" gpg --batch --yes --trust-model always --import " + pubkeyFile + " 2>&1";
|
||||||
|
std::string importResult = executeGpgCommand(importCmd);
|
||||||
|
|
||||||
|
LOG_DEBUG << "GPG import result: " << importResult;
|
||||||
|
|
||||||
|
// Check if import was successful (be more lenient with the check)
|
||||||
|
bool importSuccess = (importResult.find("imported") != std::string::npos) ||
|
||||||
|
(importResult.find("unchanged") != std::string::npos) ||
|
||||||
|
(importResult.find("processed: 1") != std::string::npos) ||
|
||||||
|
(importResult.find("public key") != std::string::npos);
|
||||||
|
|
||||||
|
if (!importSuccess) {
|
||||||
|
LOG_ERROR << "Failed to import public key. Import output: " << importResult;
|
||||||
|
// Try to get more information about what went wrong
|
||||||
|
std::string debugCmd = "GNUPGHOME=" + keyringDir + " gpg --list-keys 2>&1";
|
||||||
|
std::string debugResult = executeGpgCommand(debugCmd);
|
||||||
|
LOG_ERROR << "GPG keyring state: " << debugResult;
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
std::string cleanupCmd = "rm -rf " + tmpDir;
|
||||||
|
system(cleanupCmd.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the signature
|
||||||
|
// Use --trust-model always to avoid trust issues
|
||||||
|
std::string verifyCmd = "GNUPGHOME=" + keyringDir +
|
||||||
|
" gpg --batch --yes --trust-model always --verify " +
|
||||||
|
sigFile + " " + messageFile + " 2>&1";
|
||||||
|
std::string verifyResult = executeGpgCommand(verifyCmd);
|
||||||
|
|
||||||
|
LOG_DEBUG << "GPG verify result: " << verifyResult;
|
||||||
|
|
||||||
|
// Check if verification succeeded (check both English and potential localized messages)
|
||||||
|
bool verified = (verifyResult.find("Good signature") != std::string::npos) ||
|
||||||
|
(verifyResult.find("gpg: Good signature") != std::string::npos) ||
|
||||||
|
(verifyResult.find("Signature made") != std::string::npos &&
|
||||||
|
verifyResult.find("BAD signature") == std::string::npos);
|
||||||
|
|
||||||
|
if (!verified) {
|
||||||
|
LOG_WARN << "Signature verification failed. Verify output: " << verifyResult;
|
||||||
|
} else {
|
||||||
|
LOG_INFO << "Signature verification successful for challenge";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup temporary files
|
||||||
|
std::string cleanupCmd = "rm -rf " + tmpDir;
|
||||||
|
system(cleanupCmd.c_str());
|
||||||
|
|
||||||
|
return verified;
|
||||||
|
|
||||||
|
} catch (const std::exception& e) {
|
||||||
|
LOG_ERROR << "Exception during signature verification: " << e.what();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void AuthService::generateUniqueColor(std::function<void(const std::string& color)> callback) {
|
void AuthService::generateUniqueColor(std::function<void(const std::string& color)> callback) {
|
||||||
try {
|
try {
|
||||||
auto dbClient = app().getDbClient();
|
auto dbClient = app().getDbClient();
|
||||||
|
|
@ -421,6 +577,7 @@ void AuthService::verifyPgpLogin(const std::string& username, const std::string&
|
||||||
[username, signature, challenge, callback, this](const std::string& storedChallenge) {
|
[username, signature, challenge, callback, this](const std::string& storedChallenge) {
|
||||||
try {
|
try {
|
||||||
if (storedChallenge.empty() || storedChallenge != challenge) {
|
if (storedChallenge.empty() || storedChallenge != challenge) {
|
||||||
|
LOG_WARN << "Challenge mismatch for user: " << username;
|
||||||
callback(false, "", UserInfo{});
|
callback(false, "", UserInfo{});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -428,9 +585,7 @@ void AuthService::verifyPgpLogin(const std::string& username, const std::string&
|
||||||
// Delete challenge after use
|
// Delete challenge after use
|
||||||
RedisHelper::deleteKeyAsync("pgp_challenge:" + username, [](bool) {});
|
RedisHelper::deleteKeyAsync("pgp_challenge:" + username, [](bool) {});
|
||||||
|
|
||||||
// In a real implementation, you would verify the signature here
|
// Get user's public key and verify signature
|
||||||
// For now, we'll trust the client-side verification
|
|
||||||
|
|
||||||
auto dbClient = app().getDbClient();
|
auto dbClient = app().getDbClient();
|
||||||
if (!dbClient) {
|
if (!dbClient) {
|
||||||
LOG_ERROR << "Database client is null";
|
LOG_ERROR << "Database client is null";
|
||||||
|
|
@ -438,16 +593,32 @@ void AuthService::verifyPgpLogin(const std::string& username, const std::string&
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
*dbClient << "SELECT id, username, is_admin, is_streamer, is_pgp_only, bio, avatar_url, pgp_only_enabled_at, user_color "
|
*dbClient << "SELECT pk.public_key, u.id, u.username, u.is_admin, u.is_streamer, "
|
||||||
"FROM users WHERE username = $1 LIMIT 1"
|
"u.is_pgp_only, u.bio, u.avatar_url, u.pgp_only_enabled_at, u.user_color "
|
||||||
|
"FROM pgp_keys pk JOIN users u ON pk.user_id = u.id "
|
||||||
|
"WHERE u.username = $1 ORDER BY pk.created_at DESC LIMIT 1"
|
||||||
<< username
|
<< username
|
||||||
>> [callback, this](const Result& r) {
|
>> [callback, signature, challenge, this](const Result& r) {
|
||||||
try {
|
try {
|
||||||
if (r.empty()) {
|
if (r.empty()) {
|
||||||
|
LOG_WARN << "No PGP key found for user";
|
||||||
callback(false, "", UserInfo{});
|
callback(false, "", UserInfo{});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string publicKey = r[0]["public_key"].as<std::string>();
|
||||||
|
|
||||||
|
// CRITICAL: Server-side signature verification
|
||||||
|
bool signatureValid = verifyPgpSignature(challenge, signature, publicKey);
|
||||||
|
|
||||||
|
if (!signatureValid) {
|
||||||
|
LOG_WARN << "Invalid PGP signature for user";
|
||||||
|
callback(false, "Invalid signature", UserInfo{});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_INFO << "PGP signature verified successfully for user";
|
||||||
|
|
||||||
UserInfo user;
|
UserInfo user;
|
||||||
user.id = r[0]["id"].as<int64_t>();
|
user.id = r[0]["id"].as<int64_t>();
|
||||||
user.username = r[0]["username"].as<std::string>();
|
user.username = r[0]["username"].as<std::string>();
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,89 @@
|
||||||
// Client-side PGP utilities - wraps openpgp for browser-only usage
|
// Client-side PGP utilities with encrypted storage
|
||||||
|
|
||||||
export async function generateKeyPair(username, passphrase = '') {
|
const DB_NAME = 'pgp_storage';
|
||||||
|
const DB_VERSION = 1;
|
||||||
|
const STORE_NAME = 'encrypted_keys';
|
||||||
|
|
||||||
|
// Initialize IndexedDB
|
||||||
|
async function initDB() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
|
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
|
||||||
|
request.onupgradeneeded = (event) => {
|
||||||
|
const db = event.target.result;
|
||||||
|
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||||
|
db.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive key from passphrase using PBKDF2
|
||||||
|
async function deriveKey(passphrase, salt) {
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
const keyMaterial = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
enc.encode(passphrase),
|
||||||
|
'PBKDF2',
|
||||||
|
false,
|
||||||
|
['deriveBits', 'deriveKey']
|
||||||
|
);
|
||||||
|
|
||||||
|
return crypto.subtle.deriveKey(
|
||||||
|
{
|
||||||
|
name: 'PBKDF2',
|
||||||
|
salt: salt,
|
||||||
|
iterations: 100000,
|
||||||
|
hash: 'SHA-256'
|
||||||
|
},
|
||||||
|
keyMaterial,
|
||||||
|
{ name: 'AES-GCM', length: 256 },
|
||||||
|
true,
|
||||||
|
['encrypt', 'decrypt']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate passphrase strength
|
||||||
|
export function validatePassphrase(passphrase) {
|
||||||
|
if (!passphrase || passphrase.length < 12) {
|
||||||
|
return 'Passphrase must be at least 12 characters';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for complexity
|
||||||
|
const hasUpper = /[A-Z]/.test(passphrase);
|
||||||
|
const hasLower = /[a-z]/.test(passphrase);
|
||||||
|
const hasNumber = /[0-9]/.test(passphrase);
|
||||||
|
const hasSpecial = /[!@#$%^&*(),.?":{}|<>]/.test(passphrase);
|
||||||
|
|
||||||
|
const complexity = [hasUpper, hasLower, hasNumber, hasSpecial].filter(Boolean).length;
|
||||||
|
if (complexity < 3) {
|
||||||
|
return 'Passphrase must contain at least 3 of: uppercase, lowercase, numbers, special characters';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null; // Valid
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateKeyPair(username, passphrase) {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
throw new Error('PGP operations can only be performed in the browser');
|
throw new Error('PGP operations can only be performed in the browser');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate passphrase
|
||||||
|
const passphraseError = validatePassphrase(passphrase);
|
||||||
|
if (passphraseError) {
|
||||||
|
throw new Error(passphraseError);
|
||||||
|
}
|
||||||
|
|
||||||
const { generateKey, readKey } = await import('openpgp');
|
const { generateKey, readKey } = await import('openpgp');
|
||||||
|
|
||||||
const { privateKey, publicKey } = await generateKey({
|
const { privateKey, publicKey } = await generateKey({
|
||||||
type: 'rsa',
|
type: 'rsa',
|
||||||
rsaBits: 2048,
|
rsaBits: 2048,
|
||||||
userIDs: [{ name: username }],
|
userIDs: [{ name: username }],
|
||||||
passphrase
|
passphrase // Always encrypt with passphrase
|
||||||
});
|
});
|
||||||
|
|
||||||
const key = await readKey({ armoredKey: publicKey });
|
const key = await readKey({ armoredKey: publicKey });
|
||||||
|
|
@ -37,11 +109,152 @@ export async function getFingerprint(publicKey) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function signMessage(message, privateKeyArmored, passphrase = '') {
|
// Save encrypted private key to IndexedDB
|
||||||
|
export async function saveEncryptedPrivateKey(passphrase, armoredKey) {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
throw new Error('Storage operations can only be performed in the browser');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate passphrase
|
||||||
|
const passphraseError = validatePassphrase(passphrase);
|
||||||
|
if (passphraseError) {
|
||||||
|
throw new Error(passphraseError);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Generate random salt and IV
|
||||||
|
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
|
||||||
|
// Derive encryption key
|
||||||
|
const key = await deriveKey(passphrase, salt);
|
||||||
|
|
||||||
|
// Encrypt the private key
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
const encrypted = await crypto.subtle.encrypt(
|
||||||
|
{
|
||||||
|
name: 'AES-GCM',
|
||||||
|
iv: iv
|
||||||
|
},
|
||||||
|
key,
|
||||||
|
enc.encode(armoredKey)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store in IndexedDB
|
||||||
|
const db = await initDB();
|
||||||
|
const transaction = db.transaction([STORE_NAME], 'readwrite');
|
||||||
|
const store = transaction.objectStore(STORE_NAME);
|
||||||
|
|
||||||
|
await store.put({
|
||||||
|
id: 'primary_key',
|
||||||
|
salt: Array.from(salt),
|
||||||
|
iv: Array.from(iv),
|
||||||
|
encrypted: Array.from(new Uint8Array(encrypted)),
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save encrypted key:', error);
|
||||||
|
throw new Error('Failed to save encrypted key');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlock and retrieve private key from IndexedDB
|
||||||
|
export async function unlockPrivateKey(passphrase) {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
throw new Error('Storage operations can only be performed in the browser');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!passphrase) {
|
||||||
|
throw new Error('Passphrase required');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = await initDB();
|
||||||
|
const transaction = db.transaction([STORE_NAME], 'readonly');
|
||||||
|
const store = transaction.objectStore(STORE_NAME);
|
||||||
|
|
||||||
|
const data = await new Promise((resolve, reject) => {
|
||||||
|
const request = store.get('primary_key');
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
throw new Error('No encrypted key found');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconstruct typed arrays
|
||||||
|
const salt = new Uint8Array(data.salt);
|
||||||
|
const iv = new Uint8Array(data.iv);
|
||||||
|
const encrypted = new Uint8Array(data.encrypted);
|
||||||
|
|
||||||
|
// Derive decryption key
|
||||||
|
const key = await deriveKey(passphrase, salt);
|
||||||
|
|
||||||
|
// Decrypt
|
||||||
|
const decrypted = await crypto.subtle.decrypt(
|
||||||
|
{
|
||||||
|
name: 'AES-GCM',
|
||||||
|
iv: iv
|
||||||
|
},
|
||||||
|
key,
|
||||||
|
encrypted
|
||||||
|
);
|
||||||
|
|
||||||
|
const dec = new TextDecoder();
|
||||||
|
return dec.decode(decrypted);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to unlock private key:', error);
|
||||||
|
throw new Error('Failed to unlock private key. Check your passphrase.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if an encrypted key exists
|
||||||
|
export async function hasEncryptedKey() {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = await initDB();
|
||||||
|
const transaction = db.transaction([STORE_NAME], 'readonly');
|
||||||
|
const store = transaction.objectStore(STORE_NAME);
|
||||||
|
|
||||||
|
const data = await new Promise((resolve) => {
|
||||||
|
const request = store.get('primary_key');
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => resolve(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
return !!data;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove encrypted key from IndexedDB
|
||||||
|
export async function removeEncryptedPrivateKey() {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = await initDB();
|
||||||
|
const transaction = db.transaction([STORE_NAME], 'readwrite');
|
||||||
|
const store = transaction.objectStore(STORE_NAME);
|
||||||
|
await store.delete('primary_key');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to remove encrypted key:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signMessage(message, privateKeyArmored, passphrase) {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
throw new Error('PGP operations can only be performed in the browser');
|
throw new Error('PGP operations can only be performed in the browser');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!passphrase) {
|
||||||
|
throw new Error('Passphrase required to unlock private key');
|
||||||
|
}
|
||||||
|
|
||||||
const { decryptKey, readPrivateKey, createMessage, sign } = await import('openpgp');
|
const { decryptKey, readPrivateKey, createMessage, sign } = await import('openpgp');
|
||||||
|
|
||||||
const privateKey = await decryptKey({
|
const privateKey = await decryptKey({
|
||||||
|
|
@ -83,17 +296,15 @@ export async function verifySignature(message, signature, publicKeyArmored) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function storePrivateKey(privateKey) {
|
// DEPRECATED - DO NOT USE
|
||||||
if (typeof window === 'undefined') return;
|
export function storePrivateKey() {
|
||||||
localStorage.setItem('pgp_private_key', privateKey);
|
throw new Error('Plaintext key storage is disabled for security. Use saveEncryptedPrivateKey() instead.');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStoredPrivateKey() {
|
export function getStoredPrivateKey() {
|
||||||
if (typeof window === 'undefined') return null;
|
throw new Error('Plaintext key storage is disabled for security. Use unlockPrivateKey() instead.');
|
||||||
return localStorage.getItem('pgp_private_key');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeStoredPrivateKey() {
|
export function removeStoredPrivateKey() {
|
||||||
if (typeof window === 'undefined') return;
|
throw new Error('Plaintext key storage is disabled for security. Use removeEncryptedPrivateKey() instead.');
|
||||||
localStorage.removeItem('pgp_private_key');
|
|
||||||
}
|
}
|
||||||
|
|
@ -5,7 +5,6 @@ import { goto } from '$app/navigation';
|
||||||
function createAuthStore() {
|
function createAuthStore() {
|
||||||
const { subscribe, set, update } = writable({
|
const { subscribe, set, update } = writable({
|
||||||
user: null,
|
user: null,
|
||||||
token: null,
|
|
||||||
loading: true
|
loading: true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -15,29 +14,21 @@ function createAuthStore() {
|
||||||
async init() {
|
async init() {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
|
|
||||||
const token = localStorage.getItem('auth_token');
|
// Use cookie-based auth - no localStorage tokens
|
||||||
if (!token) {
|
|
||||||
set({ user: null, token: null, loading: false });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/user/me', {
|
const response = await fetch('/api/user/me', {
|
||||||
headers: {
|
credentials: 'include' // Send cookies
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
set({ user: data.user, token, loading: false });
|
set({ user: data.user, loading: false });
|
||||||
} else {
|
} else {
|
||||||
localStorage.removeItem('auth_token');
|
set({ user: null, loading: false });
|
||||||
set({ user: null, token: null, loading: false });
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Auth init error:', error);
|
console.error('Auth init error:', error);
|
||||||
set({ user: null, token: null, loading: false });
|
set({ user: null, loading: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -45,14 +36,15 @@ function createAuthStore() {
|
||||||
const response = await fetch('/api/auth/login', {
|
const response = await fetch('/api/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include', // Receive httpOnly cookie
|
||||||
body: JSON.stringify(credentials)
|
body: JSON.stringify(credentials)
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (response.ok && data.success) {
|
if (response.ok && data.success) {
|
||||||
localStorage.setItem('auth_token', data.token);
|
// Server sets httpOnly cookie, we just store user data
|
||||||
set({ user: data.user, token: data.token, loading: false });
|
set({ user: data.user, loading: false });
|
||||||
goto('/');
|
goto('/');
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
@ -64,14 +56,15 @@ function createAuthStore() {
|
||||||
const response = await fetch('/api/auth/pgp-verify', {
|
const response = await fetch('/api/auth/pgp-verify', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include', // Receive httpOnly cookie
|
||||||
body: JSON.stringify({ username, signature, challenge })
|
body: JSON.stringify({ username, signature, challenge })
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (response.ok && data.success) {
|
if (response.ok && data.success) {
|
||||||
localStorage.setItem('auth_token', data.token);
|
// Server sets httpOnly cookie, we just store user data
|
||||||
set({ user: data.user, token: data.token, loading: false });
|
set({ user: data.user, loading: false });
|
||||||
goto('/');
|
goto('/');
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
@ -83,6 +76,7 @@ function createAuthStore() {
|
||||||
const response = await fetch('/api/auth/register', {
|
const response = await fetch('/api/auth/register', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
body: JSON.stringify(userData)
|
body: JSON.stringify(userData)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -95,47 +89,32 @@ function createAuthStore() {
|
||||||
return { success: false, error: data.error || 'Registration failed' };
|
return { success: false, error: data.error || 'Registration failed' };
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateColor(color) {
|
async updateColor(color) {
|
||||||
const token = localStorage.getItem('auth_token');
|
const response = await fetch('/api/user/color', {
|
||||||
const response = await fetch('/api/user/color', {
|
method: 'PUT',
|
||||||
method: 'PUT',
|
headers: { 'Content-Type': 'application/json' },
|
||||||
headers: {
|
credentials: 'include', // Use cookies for auth
|
||||||
'Authorization': `Bearer ${token}`,
|
body: JSON.stringify({ color })
|
||||||
'Content-Type': 'application/json'
|
});
|
||||||
},
|
|
||||||
body: JSON.stringify({ color })
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok && data.success) {
|
|
||||||
// IMPORTANT: Store the new token that includes the updated color
|
|
||||||
if (data.token) {
|
|
||||||
localStorage.setItem('auth_token', data.token);
|
|
||||||
|
|
||||||
// Update the store with new token and user data
|
const data = await response.json();
|
||||||
update(state => ({
|
|
||||||
...state,
|
if (response.ok && data.success) {
|
||||||
token: data.token,
|
// Update the store with new user data
|
||||||
user: {
|
update(state => ({
|
||||||
...state.user,
|
...state,
|
||||||
userColor: data.color,
|
user: {
|
||||||
colorCode: data.color // Make sure both fields are updated
|
...state.user,
|
||||||
}
|
userColor: data.color,
|
||||||
}));
|
colorCode: data.color
|
||||||
} else {
|
}
|
||||||
// Fallback if no new token (shouldn't happen with current backend)
|
}));
|
||||||
update(state => ({
|
|
||||||
...state,
|
return { success: true, color: data.color };
|
||||||
user: { ...state.user, userColor: data.color, colorCode: data.color }
|
}
|
||||||
}));
|
|
||||||
}
|
return { success: false, error: data.error || 'Failed to update color' };
|
||||||
|
},
|
||||||
return { success: true, color: data.color };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: false, error: data.error || 'Failed to update color' };
|
|
||||||
},
|
|
||||||
|
|
||||||
updateUser(userData) {
|
updateUser(userData) {
|
||||||
update(state => ({
|
update(state => ({
|
||||||
|
|
@ -144,9 +123,14 @@ async updateColor(color) {
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
logout() {
|
async logout() {
|
||||||
localStorage.removeItem('auth_token');
|
// Call logout endpoint to clear httpOnly cookie
|
||||||
set({ user: null, token: null, loading: false });
|
await fetch('/api/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
|
|
||||||
|
set({ user: null, loading: false });
|
||||||
goto('/login');
|
goto('/login');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@
|
||||||
let username = '';
|
let username = '';
|
||||||
let password = '';
|
let password = '';
|
||||||
let confirmPassword = '';
|
let confirmPassword = '';
|
||||||
|
let keyPassphrase = '';
|
||||||
|
let confirmKeyPassphrase = '';
|
||||||
let error = '';
|
let error = '';
|
||||||
let loading = false;
|
let loading = false;
|
||||||
let pgpLoading = false;
|
let pgpLoading = false;
|
||||||
|
|
@ -22,10 +24,14 @@
|
||||||
let showGeneratedKeys = false;
|
let showGeneratedKeys = false;
|
||||||
let generatedPrivateKey = '';
|
let generatedPrivateKey = '';
|
||||||
let generatedPublicKey = '';
|
let generatedPublicKey = '';
|
||||||
|
let saveKeyLocally = false;
|
||||||
|
|
||||||
// Show PGP command example
|
// Show PGP command example
|
||||||
let showPgpExample = false;
|
let showPgpExample = false;
|
||||||
|
|
||||||
|
// For unlocking stored key
|
||||||
|
let storedKeyPassphrase = '';
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await auth.init();
|
await auth.init();
|
||||||
if ($isAuthenticated) {
|
if ($isAuthenticated) {
|
||||||
|
|
@ -82,11 +88,22 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (keyPassphrase !== confirmKeyPassphrase) {
|
||||||
|
error = 'Key passphrases do not match';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passphraseError = pgp.validatePassphrase(keyPassphrase);
|
||||||
|
if (passphraseError) {
|
||||||
|
error = passphraseError;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
loading = true;
|
loading = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Generate PGP key pair
|
// Generate PGP key pair with passphrase
|
||||||
const keyPair = await pgp.generateKeyPair(username);
|
const keyPair = await pgp.generateKeyPair(username, keyPassphrase);
|
||||||
|
|
||||||
const result = await auth.register({
|
const result = await auth.register({
|
||||||
username,
|
username,
|
||||||
|
|
@ -96,10 +113,7 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
// Store private key locally
|
// Save keys for display
|
||||||
pgp.storePrivateKey(keyPair.privateKey);
|
|
||||||
|
|
||||||
// Show keys
|
|
||||||
generatedPrivateKey = keyPair.privateKey;
|
generatedPrivateKey = keyPair.privateKey;
|
||||||
generatedPublicKey = keyPair.publicKey;
|
generatedPublicKey = keyPair.publicKey;
|
||||||
showGeneratedKeys = true;
|
showGeneratedKeys = true;
|
||||||
|
|
@ -107,13 +121,38 @@
|
||||||
error = result.error;
|
error = result.error;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = 'Failed to generate PGP keys';
|
error = e.message || 'Failed to generate PGP keys';
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function proceedAfterKeys() {
|
||||||
|
loading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// If user wants to save locally, store encrypted
|
||||||
|
if (saveKeyLocally) {
|
||||||
|
await pgp.saveEncryptedPrivateKey(keyPassphrase, generatedPrivateKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-login after registration
|
||||||
|
const result = await auth.login({ username, password });
|
||||||
|
if (result.success) {
|
||||||
|
goto('/');
|
||||||
|
} else {
|
||||||
|
showGeneratedKeys = false;
|
||||||
|
mode = 'login';
|
||||||
|
error = 'Registration successful. Please login.';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error = 'Failed to save keys: ' + e.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
async function initiatePgpLogin() {
|
async function initiatePgpLogin() {
|
||||||
error = '';
|
error = '';
|
||||||
pgpLoading = true;
|
pgpLoading = true;
|
||||||
|
|
@ -133,6 +172,13 @@
|
||||||
|
|
||||||
// Clear the signature field
|
// Clear the signature field
|
||||||
pgpSignature = '';
|
pgpSignature = '';
|
||||||
|
|
||||||
|
// Check if we have a stored key
|
||||||
|
const hasKey = await pgp.hasEncryptedKey();
|
||||||
|
if (hasKey) {
|
||||||
|
// We'll need to unlock it
|
||||||
|
storedKeyPassphrase = '';
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
error = data.error || 'User not found or PGP not enabled';
|
error = data.error || 'User not found or PGP not enabled';
|
||||||
}
|
}
|
||||||
|
|
@ -144,6 +190,26 @@
|
||||||
pgpLoading = false;
|
pgpLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function signWithStoredKey() {
|
||||||
|
error = '';
|
||||||
|
loading = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Unlock the stored private key
|
||||||
|
const privateKey = await pgp.unlockPrivateKey(storedKeyPassphrase);
|
||||||
|
|
||||||
|
// Sign the challenge
|
||||||
|
pgpSignature = await pgp.signMessage(pgpChallenge, privateKey, storedKeyPassphrase);
|
||||||
|
|
||||||
|
// Clear passphrase for security
|
||||||
|
storedKeyPassphrase = '';
|
||||||
|
} catch (e) {
|
||||||
|
error = 'Failed to unlock key or sign: ' + e.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
async function handlePgpLogin() {
|
async function handlePgpLogin() {
|
||||||
error = '';
|
error = '';
|
||||||
loading = true;
|
loading = true;
|
||||||
|
|
@ -172,6 +238,7 @@
|
||||||
pgpChallenge = '';
|
pgpChallenge = '';
|
||||||
pgpPublicKey = '';
|
pgpPublicKey = '';
|
||||||
pgpSignature = '';
|
pgpSignature = '';
|
||||||
|
storedKeyPassphrase = '';
|
||||||
error = '';
|
error = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -189,27 +256,13 @@
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function proceedAfterKeys() {
|
|
||||||
// Auto-login after registration
|
|
||||||
loading = true;
|
|
||||||
const result = await auth.login({ username, password });
|
|
||||||
if (result.success) {
|
|
||||||
goto('/');
|
|
||||||
} else {
|
|
||||||
showGeneratedKeys = false;
|
|
||||||
mode = 'login';
|
|
||||||
error = 'Registration successful. Please login.';
|
|
||||||
}
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="auth-container">
|
<div class="auth-container">
|
||||||
{#if showGeneratedKeys}
|
{#if showGeneratedKeys}
|
||||||
<h1>Your PGP Keys</h1>
|
<h1>Your PGP Keys</h1>
|
||||||
<p style="color: var(--error); margin-bottom: 1rem;">
|
<p style="color: var(--error); margin-bottom: 1rem;">
|
||||||
<strong>Important:</strong> Save your private key securely. You will need it to login with PGP.
|
<strong>Important:</strong> Save your private key securely. You will need it and your passphrase to login with PGP.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|
@ -231,7 +284,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Private Key</label>
|
<label>Private Key (Encrypted with your passphrase)</label>
|
||||||
<textarea
|
<textarea
|
||||||
readonly
|
readonly
|
||||||
rows="10"
|
rows="10"
|
||||||
|
|
@ -243,13 +296,27 @@
|
||||||
Copy
|
Copy
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn-secondary" on:click={() => downloadKey(generatedPrivateKey, `${username}-private-key.asc`)}>
|
<button type="button" class="btn-secondary" on:click={() => downloadKey(generatedPrivateKey, `${username}-private-key.asc`)}>
|
||||||
Download
|
Download (REQUIRED!)
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="background: rgba(255, 193, 7, 0.1); border: 1px solid rgba(255, 193, 7, 0.3); padding: 1rem; border-radius: 4px;">
|
||||||
|
<label style="display: flex; align-items: center; gap: 0.5rem; margin: 0;">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={saveKeyLocally}
|
||||||
|
style="width: 20px; height: 20px;"
|
||||||
|
/>
|
||||||
|
Save encrypted key in this browser for easier PGP login
|
||||||
|
</label>
|
||||||
|
<small style="color: var(--gray); display: block; margin-top: 0.5rem;">
|
||||||
|
The key will be encrypted with your passphrase and stored locally. You can export or remove it later from Settings.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button class="btn-block" on:click={proceedAfterKeys} disabled={loading}>
|
<button class="btn-block" on:click={proceedAfterKeys} disabled={loading}>
|
||||||
{loading ? 'Logging in...' : 'Continue'}
|
{loading ? 'Saving...' : 'Continue'}
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<h1>{mode === 'login' ? 'Login' : 'Register'}</h1>
|
<h1>{mode === 'login' ? 'Login' : 'Register'}</h1>
|
||||||
|
|
@ -303,6 +370,37 @@
|
||||||
<form on:submit|preventDefault={handlePgpLogin}>
|
<form on:submit|preventDefault={handlePgpLogin}>
|
||||||
<h3 style="margin-bottom: 1rem;">PGP Authentication</h3>
|
<h3 style="margin-bottom: 1rem;">PGP Authentication</h3>
|
||||||
|
|
||||||
|
{#await pgp.hasEncryptedKey() then hasKey}
|
||||||
|
{#if hasKey && !pgpSignature}
|
||||||
|
<div class="instruction-box" style="background: rgba(40, 167, 69, 0.1); border-color: rgba(40, 167, 69, 0.3);">
|
||||||
|
<p><strong>Stored key detected!</strong></p>
|
||||||
|
<p>Enter your passphrase to unlock your private key and sign automatically:</p>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin: 1rem 0;">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
bind:value={storedKeyPassphrase}
|
||||||
|
placeholder="Enter your key passphrase"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
on:click={signWithStoredKey}
|
||||||
|
disabled={loading || !storedKeyPassphrase}
|
||||||
|
style="margin-bottom: 0.5rem;"
|
||||||
|
>
|
||||||
|
{loading ? 'Signing...' : 'Unlock & Sign'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin: 1rem 0; text-align: center; color: var(--gray);">
|
||||||
|
OR sign manually
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/await}
|
||||||
|
|
||||||
<div class="instruction-box">
|
<div class="instruction-box">
|
||||||
<p><strong>Step 1:</strong> Copy this challenge text:</p>
|
<p><strong>Step 1:</strong> Copy this challenge text:</p>
|
||||||
<div class="pgp-key" style="margin: 0.5rem 0;">
|
<div class="pgp-key" style="margin: 0.5rem 0;">
|
||||||
|
|
@ -319,7 +417,7 @@
|
||||||
|
|
||||||
<p><strong>Step 2:</strong> Sign it with your private key using GPG or another PGP tool</p>
|
<p><strong>Step 2:</strong> Sign it with your private key using GPG or another PGP tool</p>
|
||||||
<p style="color: var(--gray); font-size: 0.85rem; margin-top: 0.5rem;">
|
<p style="color: var(--gray); font-size: 0.85rem; margin-top: 0.5rem;">
|
||||||
Note: For security, we never handle your private keys. All signing must be done on your device.
|
Note: Your private key passphrase is required to unlock your key for signing.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -339,7 +437,7 @@
|
||||||
# Save the challenge to a file
|
# Save the challenge to a file
|
||||||
echo "{pgpChallenge}" > challenge.txt
|
echo "{pgpChallenge}" > challenge.txt
|
||||||
|
|
||||||
# Sign with your private key
|
# Sign with your private key (will prompt for passphrase)
|
||||||
gpg --armor --detach-sign challenge.txt
|
gpg --armor --detach-sign challenge.txt
|
||||||
|
|
||||||
# This creates challenge.txt.asc with the signature
|
# This creates challenge.txt.asc with the signature
|
||||||
|
|
@ -350,16 +448,9 @@ gpg --armor --detach-sign challenge.txt
|
||||||
<li>Save the challenge text to a file</li>
|
<li>Save the challenge text to a file</li>
|
||||||
<li>Right-click the file → Sign</li>
|
<li>Right-click the file → Sign</li>
|
||||||
<li>Select your key and choose "Create detached signature"</li>
|
<li>Select your key and choose "Create detached signature"</li>
|
||||||
|
<li>Enter your passphrase when prompted</li>
|
||||||
<li>Open the .asc file and copy its contents</li>
|
<li>Open the .asc file and copy its contents</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
<h4>Using GPG Suite (Mac):</h4>
|
|
||||||
<ol style="font-size: 0.9rem; margin: 0.5rem 0;">
|
|
||||||
<li>Save the challenge text to a file</li>
|
|
||||||
<li>Right-click the file → Services → OpenPGP: Sign File</li>
|
|
||||||
<li>Choose "Create Detached Signature"</li>
|
|
||||||
<li>Open the .sig file and copy its contents</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
@ -442,6 +533,40 @@ iQEcBAABCAAGBQJe...
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style="background: rgba(86, 29, 94, 0.1); border: 1px solid rgba(86, 29, 94, 0.3); padding: 1rem; border-radius: 8px; margin: 1.5rem 0;">
|
||||||
|
<h3 style="margin: 0 0 1rem 0; font-size: 1.1rem; color: var(--primary);">🔐 PGP Key Passphrase</h3>
|
||||||
|
<p style="font-size: 0.9rem; color: var(--gray); margin-bottom: 1rem;">
|
||||||
|
This passphrase encrypts your PGP private key. You'll need it every time you use PGP login.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="key-passphrase">Key Passphrase</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="key-passphrase"
|
||||||
|
bind:value={keyPassphrase}
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
placeholder="Enter a strong passphrase (12+ characters)"
|
||||||
|
/>
|
||||||
|
<small style="color: var(--gray);">
|
||||||
|
Must be 12+ characters with 3 of: uppercase, lowercase, numbers, special characters
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="confirm-key-passphrase">Confirm Key Passphrase</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="confirm-key-passphrase"
|
||||||
|
bind:value={confirmKeyPassphrase}
|
||||||
|
required
|
||||||
|
disabled={loading}
|
||||||
|
placeholder="Confirm your key passphrase"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<div class="error">{error}</div>
|
<div class="error">{error}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -451,18 +576,18 @@ iQEcBAABCAAGBQJe...
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p style="margin-top: 1rem; font-size: 0.9rem; color: var(--gray);">
|
<p style="margin-top: 1rem; font-size: 0.9rem; color: var(--gray);">
|
||||||
A PGP key pair will be generated for your account. You'll be able to save both keys after registration.
|
A PGP key pair will be generated and encrypted with your passphrase.
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div style="margin-top: 2rem; text-align: center;">
|
<div style="margin-top: 2rem; text-align: center;">
|
||||||
{#if mode === 'login'}
|
{#if mode === 'login'}
|
||||||
<button class="btn-link" on:click={() => { mode = 'register'; error = ''; }}>
|
<button class="btn-link" on:click={() => { mode = 'register'; error = ''; username = ''; password = ''; }}>
|
||||||
Need an account? Register
|
Need an account? Register
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<button class="btn-link" on:click={() => { mode = 'login'; error = ''; }}>
|
<button class="btn-link" on:click={() => { mode = 'login'; error = ''; username = ''; password = ''; keyPassphrase = ''; confirmKeyPassphrase = ''; }}>
|
||||||
Already have an account? Login
|
Already have an account? Login
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -531,4 +656,14 @@ iQEcBAABCAAGBQJe...
|
||||||
.example-section ol li {
|
.example-section ol li {
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pgp-key {
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
@ -54,6 +54,14 @@
|
||||||
let showGeneratedKeys = false;
|
let showGeneratedKeys = false;
|
||||||
let generatedPrivateKey = '';
|
let generatedPrivateKey = '';
|
||||||
let generatedPublicKey = '';
|
let generatedPublicKey = '';
|
||||||
|
let keyPassphrase = '';
|
||||||
|
let confirmKeyPassphrase = '';
|
||||||
|
let saveKeyLocally = false;
|
||||||
|
let hasEncryptedKey = false;
|
||||||
|
|
||||||
|
// Unlock key dialog
|
||||||
|
let showUnlockDialog = false;
|
||||||
|
let unlockPassphrase = '';
|
||||||
|
|
||||||
// User data for safe access
|
// User data for safe access
|
||||||
let currentUser = null;
|
let currentUser = null;
|
||||||
|
|
@ -71,10 +79,13 @@
|
||||||
// Store user for safe access
|
// Store user for safe access
|
||||||
currentUser = $auth.user;
|
currentUser = $auth.user;
|
||||||
|
|
||||||
|
// Check if we have an encrypted key stored
|
||||||
|
hasEncryptedKey = await pgp.hasEncryptedKey();
|
||||||
|
|
||||||
// Get fresh user data to ensure we have latest values
|
// Get fresh user data to ensure we have latest values
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/user/me', {
|
const response = await fetch('/api/user/me', {
|
||||||
headers: { 'Authorization': `Bearer ${$auth.token}` }
|
credentials: 'include'
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
|
|
@ -111,11 +122,11 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadPgpKeys() {
|
async function loadPgpKeys() {
|
||||||
if (!browser || !$auth.token) return;
|
if (!browser) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/user/pgp-keys', {
|
const response = await fetch('/api/user/pgp-keys', {
|
||||||
headers: { 'Authorization': `Bearer ${$auth.token}` }
|
credentials: 'include'
|
||||||
});
|
});
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
|
|
@ -148,10 +159,8 @@
|
||||||
// Update bio
|
// Update bio
|
||||||
const response = await fetch('/api/user/profile', {
|
const response = await fetch('/api/user/profile', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Authorization': `Bearer ${$auth.token}`,
|
credentials: 'include',
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ bio })
|
body: JSON.stringify({ bio })
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -170,9 +179,7 @@
|
||||||
|
|
||||||
const avatarResponse = await fetch('/api/user/avatar', {
|
const avatarResponse = await fetch('/api/user/avatar', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
credentials: 'include',
|
||||||
'Authorization': `Bearer ${$auth.token}`
|
|
||||||
},
|
|
||||||
body: formData
|
body: formData
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -236,10 +243,8 @@
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/user/password', {
|
const response = await fetch('/api/user/password', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Authorization': `Bearer ${$auth.token}`,
|
credentials: 'include',
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ oldPassword, newPassword })
|
body: JSON.stringify({ oldPassword, newPassword })
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -266,37 +271,37 @@
|
||||||
loading = false;
|
loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateColor() {
|
async function updateColor() {
|
||||||
if (!newColor || newColor === userColor) {
|
if (!newColor || newColor === userColor) {
|
||||||
colorError = 'Please select a different color';
|
colorError = 'Please select a different color';
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
|
|
||||||
colorLoading = true;
|
|
||||||
colorError = '';
|
|
||||||
colorMessage = '';
|
|
||||||
|
|
||||||
const result = await auth.updateColor(newColor);
|
|
||||||
|
|
||||||
if (result.success) {
|
|
||||||
userColor = result.color;
|
|
||||||
colorMessage = 'Color updated successfully!';
|
|
||||||
showColorPicker = false;
|
|
||||||
|
|
||||||
// Refresh the current user data to ensure consistency
|
|
||||||
if (currentUser) {
|
|
||||||
currentUser = { ...currentUser, userColor: result.color, colorCode: result.color };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear message after 3 seconds
|
colorLoading = true;
|
||||||
setTimeout(() => { colorMessage = ''; }, 3000);
|
colorError = '';
|
||||||
} else {
|
colorMessage = '';
|
||||||
colorError = result.error || 'Failed to update color';
|
|
||||||
|
const result = await auth.updateColor(newColor);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
userColor = result.color;
|
||||||
|
colorMessage = 'Color updated successfully!';
|
||||||
|
showColorPicker = false;
|
||||||
|
|
||||||
|
// Refresh the current user data to ensure consistency
|
||||||
|
if (currentUser) {
|
||||||
|
currentUser = { ...currentUser, userColor: result.color, colorCode: result.color };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear message after 3 seconds
|
||||||
|
setTimeout(() => { colorMessage = ''; }, 3000);
|
||||||
|
} else {
|
||||||
|
colorError = result.error || 'Failed to update color';
|
||||||
|
}
|
||||||
|
|
||||||
|
colorLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
colorLoading = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectSuggestedColor(color) {
|
function selectSuggestedColor(color) {
|
||||||
newColor = color;
|
newColor = color;
|
||||||
}
|
}
|
||||||
|
|
@ -355,10 +360,8 @@ async function updateColor() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/user/pgp-only', {
|
const response = await fetch('/api/user/pgp-only', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Authorization': `Bearer ${$auth.token}`,
|
credentials: 'include',
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ enable: true })
|
body: JSON.stringify({ enable: true })
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -407,19 +410,46 @@ async function updateColor() {
|
||||||
async function generateNewKeyPair() {
|
async function generateNewKeyPair() {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
|
|
||||||
loading = true;
|
error = '';
|
||||||
|
keyPassphrase = '';
|
||||||
|
confirmKeyPassphrase = '';
|
||||||
|
saveKeyLocally = false;
|
||||||
|
|
||||||
|
// Prompt for passphrase first
|
||||||
|
showGeneratedKeys = true;
|
||||||
|
generatedPrivateKey = '';
|
||||||
|
generatedPublicKey = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateKeysWithPassphrase() {
|
||||||
error = '';
|
error = '';
|
||||||
|
|
||||||
|
if (!keyPassphrase) {
|
||||||
|
error = 'Passphrase is required';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyPassphrase !== confirmKeyPassphrase) {
|
||||||
|
error = 'Passphrases do not match';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passphraseError = pgp.validatePassphrase(keyPassphrase);
|
||||||
|
if (passphraseError) {
|
||||||
|
error = passphraseError;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const keyPair = await pgp.generateKeyPair(currentUser?.username || 'user');
|
const keyPair = await pgp.generateKeyPair(currentUser?.username || 'user', keyPassphrase);
|
||||||
|
|
||||||
// Show keys instead of auto-downloading
|
|
||||||
generatedPrivateKey = keyPair.privateKey;
|
generatedPrivateKey = keyPair.privateKey;
|
||||||
generatedPublicKey = keyPair.publicKey;
|
generatedPublicKey = keyPair.publicKey;
|
||||||
showGeneratedKeys = true;
|
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = 'Failed to generate key pair';
|
error = 'Failed to generate key pair: ' + e.message;
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -431,14 +461,13 @@ async function updateColor() {
|
||||||
error = '';
|
error = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Save public key to server
|
||||||
const fingerprint = await pgp.getFingerprint(generatedPublicKey);
|
const fingerprint = await pgp.getFingerprint(generatedPublicKey);
|
||||||
|
|
||||||
const response = await fetch('/api/user/pgp-key', {
|
const response = await fetch('/api/user/pgp-key', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Authorization': `Bearer ${$auth.token}`,
|
credentials: 'include',
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
publicKey: generatedPublicKey,
|
publicKey: generatedPublicKey,
|
||||||
fingerprint: fingerprint
|
fingerprint: fingerprint
|
||||||
|
|
@ -448,8 +477,11 @@ async function updateColor() {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (response.ok && data.success) {
|
if (response.ok && data.success) {
|
||||||
// Store private key locally
|
// If user wants to save locally, encrypt and store
|
||||||
pgp.storePrivateKey(generatedPrivateKey);
|
if (saveKeyLocally) {
|
||||||
|
await pgp.saveEncryptedPrivateKey(keyPassphrase, generatedPrivateKey);
|
||||||
|
hasEncryptedKey = true;
|
||||||
|
}
|
||||||
|
|
||||||
message = 'New key pair saved successfully';
|
message = 'New key pair saved successfully';
|
||||||
error = '';
|
error = '';
|
||||||
|
|
@ -457,6 +489,8 @@ async function updateColor() {
|
||||||
showGeneratedKeys = false;
|
showGeneratedKeys = false;
|
||||||
generatedPrivateKey = '';
|
generatedPrivateKey = '';
|
||||||
generatedPublicKey = '';
|
generatedPublicKey = '';
|
||||||
|
keyPassphrase = '';
|
||||||
|
confirmKeyPassphrase = '';
|
||||||
|
|
||||||
// Clear message after 3 seconds
|
// Clear message after 3 seconds
|
||||||
setTimeout(() => { message = ''; }, 3000);
|
setTimeout(() => { message = ''; }, 3000);
|
||||||
|
|
@ -487,10 +521,8 @@ async function updateColor() {
|
||||||
|
|
||||||
const response = await fetch('/api/user/pgp-key', {
|
const response = await fetch('/api/user/pgp-key', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
'Authorization': `Bearer ${$auth.token}`,
|
credentials: 'include',
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
publicKey: newPublicKey,
|
publicKey: newPublicKey,
|
||||||
fingerprint
|
fingerprint
|
||||||
|
|
@ -583,6 +615,35 @@ async function updateColor() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function showUnlockKeyDialog() {
|
||||||
|
showUnlockDialog = true;
|
||||||
|
unlockPassphrase = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unlockAndExportKey() {
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const privateKey = await pgp.unlockPrivateKey(unlockPassphrase);
|
||||||
|
downloadKey(privateKey, `${displayUsername}-private-key.asc`);
|
||||||
|
showUnlockDialog = false;
|
||||||
|
unlockPassphrase = '';
|
||||||
|
message = 'Private key exported successfully';
|
||||||
|
setTimeout(() => { message = ''; }, 3000);
|
||||||
|
} catch (e) {
|
||||||
|
error = 'Failed to unlock key: ' + e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeLocalKey() {
|
||||||
|
if (confirm('Remove the encrypted private key from this browser? You will need to re-import it later to use PGP login.')) {
|
||||||
|
await pgp.removeEncryptedPrivateKey();
|
||||||
|
hasEncryptedKey = false;
|
||||||
|
message = 'Local key removed';
|
||||||
|
setTimeout(() => { message = ''; }, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Track profile changes
|
// Track profile changes
|
||||||
$: if (browser) {
|
$: if (browser) {
|
||||||
hasProfileChanges = bio !== (currentUser?.bio || '') || avatarFile !== null;
|
hasProfileChanges = bio !== (currentUser?.bio || '') || avatarFile !== null;
|
||||||
|
|
@ -910,6 +971,55 @@ async function updateColor() {
|
||||||
border-color: var(--white);
|
border-color: var(--white);
|
||||||
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.2);
|
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.passphrase-info {
|
||||||
|
background: rgba(86, 29, 94, 0.1);
|
||||||
|
border: 1px solid rgba(86, 29, 94, 0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.passphrase-info h4 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.passphrase-info ul {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--gray);
|
||||||
|
}
|
||||||
|
|
||||||
|
.key-storage-option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: rgba(255, 193, 7, 0.1);
|
||||||
|
border: 1px solid rgba(255, 193, 7, 0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.local-key-info {
|
||||||
|
background: rgba(40, 167, 69, 0.1);
|
||||||
|
border: 1px solid rgba(40, 167, 69, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.local-key-info p {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.local-key-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|
@ -1257,6 +1367,26 @@ async function updateColor() {
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if hasEncryptedKey}
|
||||||
|
<div class="local-key-info">
|
||||||
|
<p>
|
||||||
|
<span style="color: var(--success);">🔑</span>
|
||||||
|
<strong>Encrypted private key stored in this browser</strong>
|
||||||
|
</p>
|
||||||
|
<p style="font-size: 0.9rem; color: var(--gray);">
|
||||||
|
Your private key is encrypted and stored locally. You'll need your passphrase to use it.
|
||||||
|
</p>
|
||||||
|
<div class="local-key-actions">
|
||||||
|
<button on:click={showUnlockKeyDialog}>
|
||||||
|
Export Private Key
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-danger" on:click={removeLocalKey}>
|
||||||
|
Remove Local Key
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<h3>Your PGP Keys</h3>
|
<h3>Your PGP Keys</h3>
|
||||||
|
|
||||||
{#if pgpKeys.length > 0}
|
{#if pgpKeys.length > 0}
|
||||||
|
|
@ -1284,58 +1414,116 @@ async function updateColor() {
|
||||||
|
|
||||||
{#if showGeneratedKeys}
|
{#if showGeneratedKeys}
|
||||||
<div style="margin: 2rem 0; padding: 1.5rem; background: rgba(86, 29, 94, 0.1); border: 1px solid rgba(86, 29, 94, 0.3); border-radius: 8px;">
|
<div style="margin: 2rem 0; padding: 1.5rem; background: rgba(86, 29, 94, 0.1); border: 1px solid rgba(86, 29, 94, 0.3); border-radius: 8px;">
|
||||||
<h3>Generated Key Pair</h3>
|
<h3>Generate New Key Pair</h3>
|
||||||
<p style="color: var(--error); margin-bottom: 1rem;">
|
|
||||||
<strong>Important:</strong> Save your private key securely before proceeding.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="form-group">
|
{#if !generatedPrivateKey}
|
||||||
<label>Public Key</label>
|
<div class="passphrase-info">
|
||||||
<textarea
|
<h4>🔐 Passphrase Requirements</h4>
|
||||||
readonly
|
<ul>
|
||||||
rows="8"
|
<li>At least 12 characters long</li>
|
||||||
value={generatedPublicKey}
|
<li>Include 3 of: uppercase, lowercase, numbers, special characters</li>
|
||||||
style="font-family: monospace; font-size: 0.8rem;"
|
<li>This passphrase encrypts your private key</li>
|
||||||
/>
|
<li>You'll need it every time you use your key</li>
|
||||||
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
|
</ul>
|
||||||
<button type="button" on:click={() => copyToClipboard(generatedPublicKey)}>
|
</div>
|
||||||
Copy
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="key-passphrase">Passphrase</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="key-passphrase"
|
||||||
|
bind:value={keyPassphrase}
|
||||||
|
placeholder="Enter a strong passphrase"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="confirm-key-passphrase">Confirm Passphrase</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="confirm-key-passphrase"
|
||||||
|
bind:value={confirmKeyPassphrase}
|
||||||
|
placeholder="Confirm your passphrase"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 1rem;">
|
||||||
|
<button on:click={generateKeysWithPassphrase} disabled={loading || !keyPassphrase}>
|
||||||
|
{loading ? 'Generating...' : 'Generate Keys'}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn-secondary" on:click={() => downloadKey(generatedPublicKey, `${displayUsername}-public-key.asc`)}>
|
<button
|
||||||
Download
|
class="btn btn-secondary"
|
||||||
|
on:click={() => { showGeneratedKeys = false; keyPassphrase = ''; confirmKeyPassphrase = ''; }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{:else}
|
||||||
|
<p style="color: var(--error); margin-bottom: 1rem;">
|
||||||
<div class="form-group">
|
<strong>Important:</strong> Save your private key securely. You will need it and your passphrase to login with PGP.
|
||||||
<label>Private Key</label>
|
</p>
|
||||||
<textarea
|
|
||||||
readonly
|
<div class="form-group">
|
||||||
rows="8"
|
<label>Public Key</label>
|
||||||
value={generatedPrivateKey}
|
<textarea
|
||||||
style="font-family: monospace; font-size: 0.8rem;"
|
readonly
|
||||||
/>
|
rows="8"
|
||||||
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
|
value={generatedPublicKey}
|
||||||
<button type="button" on:click={() => copyToClipboard(generatedPrivateKey)}>
|
style="font-family: monospace; font-size: 0.8rem;"
|
||||||
Copy
|
/>
|
||||||
|
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
|
||||||
|
<button type="button" on:click={() => copyToClipboard(generatedPublicKey)}>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-secondary" on:click={() => downloadKey(generatedPublicKey, `${displayUsername}-public-key.asc`)}>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Private Key (Encrypted with your passphrase)</label>
|
||||||
|
<textarea
|
||||||
|
readonly
|
||||||
|
rows="8"
|
||||||
|
value={generatedPrivateKey}
|
||||||
|
style="font-family: monospace; font-size: 0.8rem;"
|
||||||
|
/>
|
||||||
|
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
|
||||||
|
<button type="button" on:click={() => copyToClipboard(generatedPrivateKey)}>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn-secondary" on:click={() => downloadKey(generatedPrivateKey, `${displayUsername}-private-key.asc`)}>
|
||||||
|
Download (Required!)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="key-storage-option">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="save-key-locally"
|
||||||
|
bind:checked={saveKeyLocally}
|
||||||
|
/>
|
||||||
|
<label for="save-key-locally">
|
||||||
|
Also save encrypted key in this browser for easier PGP login (you can export/remove it later)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 1rem; margin-top: 1rem;">
|
||||||
|
<button on:click={saveGeneratedKeys} disabled={loading}>
|
||||||
|
Save Keys
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn-secondary" on:click={() => downloadKey(generatedPrivateKey, `${displayUsername}-private-key.asc`)}>
|
<button
|
||||||
Download
|
class="btn btn-secondary"
|
||||||
|
on:click={() => { showGeneratedKeys = false; generatedPrivateKey = ''; generatedPublicKey = ''; keyPassphrase = ''; confirmKeyPassphrase = ''; }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
|
|
||||||
<div style="display: flex; gap: 1rem; margin-top: 1rem;">
|
|
||||||
<button on:click={saveGeneratedKeys} disabled={loading}>
|
|
||||||
Save Keys
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn btn-secondary"
|
|
||||||
on:click={() => { showGeneratedKeys = false; generatedPrivateKey = ''; generatedPublicKey = ''; }}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div style="display: flex; gap: 1rem; flex-wrap: wrap;">
|
<div style="display: flex; gap: 1rem; flex-wrap: wrap;">
|
||||||
|
|
@ -1435,4 +1623,42 @@ async function updateColor() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showUnlockDialog}
|
||||||
|
<div class="warning-modal">
|
||||||
|
<div class="warning-content">
|
||||||
|
<h2>Unlock Private Key</h2>
|
||||||
|
<p style="margin-bottom: 1rem;">Enter your passphrase to decrypt and export your private key.</p>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="error" style="margin-bottom: 1rem;">{error}</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="unlock-passphrase">Passphrase</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="unlock-passphrase"
|
||||||
|
bind:value={unlockPassphrase}
|
||||||
|
placeholder="Enter your key passphrase"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="warning-actions">
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary"
|
||||||
|
on:click={() => { showUnlockDialog = false; unlockPassphrase = ''; }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
on:click={unlockAndExportKey}
|
||||||
|
disabled={!unlockPassphrase}
|
||||||
|
>
|
||||||
|
Unlock & Export
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue