fix: 修复多个安全缺陷和功能问题
- 移除加密相关代码(服务端和所有客户端) - 修复 JWT 验证未检查签名算法的问题 - 修复重置密码使用不安全随机数的问题 - 实现 Logout 服务端失效机制 - 修复历史版本对比 diff 颜色标记错误 - 修复占位符解析循环引用风险 - 添加用户禁用状态检查 - 更新 bug 清单文档
This commit is contained in:
@@ -50,3 +50,4 @@ env/
|
|||||||
.env.local
|
.env.local
|
||||||
/kenaito-conf-springboot2-client-demo/target/classes/META-INF/spring.factories
|
/kenaito-conf-springboot2-client-demo/target/classes/META-INF/spring.factories
|
||||||
/kenaito-conf-springboot2-client-demo/target/classes/application.properties
|
/kenaito-conf-springboot2-client-demo/target/classes/application.properties
|
||||||
|
server/application-dev.properties
|
||||||
|
|||||||
@@ -2,15 +2,11 @@
|
|||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
#include <curl/curl.h>
|
#include <curl/curl.h>
|
||||||
#include <openssl/evp.h>
|
|
||||||
#include <openssl/md5.h>
|
|
||||||
#include <openssl/aes.h>
|
|
||||||
#include <time.h>
|
#include <time.h>
|
||||||
|
|
||||||
#define MAX_CONFIGS 256
|
#define MAX_CONFIGS 256
|
||||||
#define MAX_KEY_LEN 256
|
#define MAX_KEY_LEN 256
|
||||||
#define MAX_VAL_LEN 4096
|
#define MAX_VAL_LEN 4096
|
||||||
#define CRYPT_PREFIX "{Cipher}"
|
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
char baseUrl[512];
|
char baseUrl[512];
|
||||||
@@ -36,8 +32,6 @@ typedef struct {
|
|||||||
typedef struct {
|
typedef struct {
|
||||||
Config config;
|
Config config;
|
||||||
ConfigMap configs;
|
ConfigMap configs;
|
||||||
char secret[256];
|
|
||||||
char globalSecret[256];
|
|
||||||
long lastTime;
|
long lastTime;
|
||||||
void (*onChange)(const char *key, const char *value);
|
void (*onChange)(const char *key, const char *value);
|
||||||
} ConfigClient;
|
} ConfigClient;
|
||||||
@@ -59,62 +53,6 @@ static size_t write_callback(void *contents, size_t size, size_t nmemb, void *us
|
|||||||
return totalSize;
|
return totalSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void md5_hex(const char *input, char *output) {
|
|
||||||
unsigned char digest[MD5_DIGEST_LENGTH];
|
|
||||||
MD5((unsigned char *)input, strlen(input), digest);
|
|
||||||
for (int i = 0; i < MD5_DIGEST_LENGTH; i++)
|
|
||||||
sprintf(output + i * 2, "%02x", digest[i]);
|
|
||||||
output[32] = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
static int base64_decode(const char *src, unsigned char *dst) {
|
|
||||||
BIO *b64 = BIO_new(BIO_f_base64());
|
|
||||||
BIO *mem = BIO_new_mem_buf((void *)src, strlen(src));
|
|
||||||
mem = BIO_push(b64, mem);
|
|
||||||
BIO_set_flags(mem, BIO_FLAGS_BASE64_NO_NL);
|
|
||||||
int len = BIO_read(mem, dst, strlen(src));
|
|
||||||
BIO_free_all(mem);
|
|
||||||
return len;
|
|
||||||
}
|
|
||||||
|
|
||||||
static void decrypt_with_aes(const char *key, const char *data, char *output) {
|
|
||||||
char md5Hash[33];
|
|
||||||
md5_hex(key, md5Hash);
|
|
||||||
unsigned char aesKey[16];
|
|
||||||
memcpy(aesKey, md5Hash + 16, 16);
|
|
||||||
|
|
||||||
unsigned char *cipherText = malloc(strlen(data) * 2);
|
|
||||||
int cipherLen = base64_decode(data, cipherText);
|
|
||||||
|
|
||||||
unsigned char iv[16] = {0};
|
|
||||||
unsigned char plainText[4096];
|
|
||||||
|
|
||||||
EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new();
|
|
||||||
EVP_DecryptInit_ex(ctx, EVP_aes_128_cbc(), NULL, aesKey, iv);
|
|
||||||
EVP_CIPHER_CTX_set_padding(ctx, 0);
|
|
||||||
int len = 0, totalLen = 0;
|
|
||||||
EVP_DecryptUpdate(ctx, plainText, &len, cipherText, cipherLen);
|
|
||||||
totalLen = len;
|
|
||||||
EVP_DecryptFinal_ex(ctx, plainText + len, &len);
|
|
||||||
totalLen += len;
|
|
||||||
EVP_CIPHER_CTX_free(ctx);
|
|
||||||
|
|
||||||
int padding = plainText[totalLen - 1];
|
|
||||||
totalLen -= padding;
|
|
||||||
memcpy(output, plainText, totalLen);
|
|
||||||
output[totalLen] = 0;
|
|
||||||
|
|
||||||
free(cipherText);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void decrypt_config(const char *secret, const char *data, char *output) {
|
|
||||||
if (strncmp(data, CRYPT_PREFIX, strlen(CRYPT_PREFIX)) != 0) {
|
|
||||||
strcpy(output, data);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
decrypt_with_aes(secret, data + strlen(CRYPT_PREFIX), output);
|
|
||||||
}
|
|
||||||
|
|
||||||
static char *http_post_json(const char *url, const char *jsonBody) {
|
static char *http_post_json(const char *url, const char *jsonBody) {
|
||||||
CURL *curl = curl_easy_init();
|
CURL *curl = curl_easy_init();
|
||||||
if (!curl) return NULL;
|
if (!curl) return NULL;
|
||||||
@@ -144,8 +82,6 @@ static char *http_post_json(const char *url, const char *jsonBody) {
|
|||||||
void config_client_init(ConfigClient *client, Config *config) {
|
void config_client_init(ConfigClient *client, Config *config) {
|
||||||
client->config = *config;
|
client->config = *config;
|
||||||
client->configs.count = 0;
|
client->configs.count = 0;
|
||||||
client->secret[0] = 0;
|
|
||||||
client->globalSecret[0] = 0;
|
|
||||||
client->lastTime = 0;
|
client->lastTime = 0;
|
||||||
client->onChange = NULL;
|
client->onChange = NULL;
|
||||||
curl_global_init(CURL_GLOBAL_DEFAULT);
|
curl_global_init(CURL_GLOBAL_DEFAULT);
|
||||||
|
|||||||
@@ -11,8 +11,6 @@
|
|||||||
#include <sstream>
|
#include <sstream>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
#include <curl/curl.h>
|
#include <curl/curl.h>
|
||||||
#include <openssl/evp.h>
|
|
||||||
#include <openssl/md5.h>
|
|
||||||
|
|
||||||
namespace kenaito_conf {
|
namespace kenaito_conf {
|
||||||
|
|
||||||
@@ -27,50 +25,6 @@ struct Config {
|
|||||||
std::string cacheDir = ".";
|
std::string cacheDir = ".";
|
||||||
};
|
};
|
||||||
|
|
||||||
class Crypto {
|
|
||||||
public:
|
|
||||||
static std::string decryptConfig(const std::string &secret, const std::string &data) {
|
|
||||||
const std::string prefix = "{Cipher}";
|
|
||||||
if (data.substr(0, prefix.size()) != prefix) return data;
|
|
||||||
return decryptWithAES(secret, data.substr(prefix.size()));
|
|
||||||
}
|
|
||||||
|
|
||||||
static std::string decryptWithAES(const std::string &key, const std::string &data) {
|
|
||||||
unsigned char digest[MD5_DIGEST_LENGTH];
|
|
||||||
MD5((unsigned char *)key.c_str(), key.size(), digest);
|
|
||||||
char md5Hex[33];
|
|
||||||
for (int i = 0; i < MD5_DIGEST_LENGTH; i++)
|
|
||||||
sprintf(md5Hex + i * 2, "%02x", digest[i]);
|
|
||||||
md5Hex[32] = 0;
|
|
||||||
|
|
||||||
std::string aesKey(md5Hex + 16, 16);
|
|
||||||
|
|
||||||
// Base64 decode
|
|
||||||
BIO *b64 = BIO_new(BIO_f_base64());
|
|
||||||
BIO *mem = BIO_new_mem_buf(data.c_str(), data.size());
|
|
||||||
mem = BIO_push(b64, mem);
|
|
||||||
BIO_set_flags(mem, BIO_FLAGS_BASE64_NO_NL);
|
|
||||||
std::vector<unsigned char> cipherText(data.size());
|
|
||||||
int cipherLen = BIO_read(mem, cipherText.data(), data.size());
|
|
||||||
BIO_free_all(mem);
|
|
||||||
|
|
||||||
unsigned char iv[16] = {0};
|
|
||||||
std::vector<unsigned char> plainText(cipherLen + 16);
|
|
||||||
EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new();
|
|
||||||
EVP_DecryptInit_ex(ctx, EVP_aes_128_cbc(), nullptr, (unsigned char *)aesKey.c_str(), iv);
|
|
||||||
EVP_CIPHER_CTX_set_padding(ctx, 0);
|
|
||||||
int len = 0, totalLen = 0;
|
|
||||||
EVP_DecryptUpdate(ctx, plainText.data(), &len, cipherText.data(), cipherLen);
|
|
||||||
totalLen = len;
|
|
||||||
EVP_DecryptFinal_ex(ctx, plainText.data() + len, &len);
|
|
||||||
totalLen += len;
|
|
||||||
EVP_CIPHER_CTX_free(ctx);
|
|
||||||
|
|
||||||
int padding = plainText[totalLen - 1];
|
|
||||||
return std::string(plainText.begin(), plainText.begin() + totalLen - padding);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
class ConfigClient {
|
class ConfigClient {
|
||||||
public:
|
public:
|
||||||
ConfigClient(const Config &config) : config_(config) {}
|
ConfigClient(const Config &config) : config_(config) {}
|
||||||
@@ -112,7 +66,6 @@ public:
|
|||||||
private:
|
private:
|
||||||
Config config_;
|
Config config_;
|
||||||
std::map<std::string, std::string> configs_;
|
std::map<std::string, std::string> configs_;
|
||||||
std::string secret_, globalSecret_;
|
|
||||||
long lastTime_ = 0;
|
long lastTime_ = 0;
|
||||||
bool started_ = false;
|
bool started_ = false;
|
||||||
std::atomic<bool> running_{false};
|
std::atomic<bool> running_{false};
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ using System;
|
|||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Security.Cryptography;
|
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
@@ -22,39 +21,6 @@ namespace KenaitoConf
|
|||||||
public string CacheDir { get; set; } = ".";
|
public string CacheDir { get; set; } = ".";
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class Crypto
|
|
||||||
{
|
|
||||||
private const string CryptPrefix = "{Cipher}";
|
|
||||||
|
|
||||||
public static string DecryptConfig(string secret, string data)
|
|
||||||
{
|
|
||||||
if (!data.StartsWith(CryptPrefix))
|
|
||||||
return data;
|
|
||||||
return DecryptWithAES(secret, data.Substring(CryptPrefix.Length));
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string DecryptWithAES(string key, string data)
|
|
||||||
{
|
|
||||||
using var md5 = MD5.Create();
|
|
||||||
string md5Hash = BitConverter.ToString(md5.ComputeHash(Encoding.UTF8.GetBytes(key))).Replace("-", "").ToLower();
|
|
||||||
string aesKeyStr = md5Hash.Substring(16);
|
|
||||||
byte[] aesKey = Encoding.UTF8.GetBytes(aesKeyStr);
|
|
||||||
byte[] cipherText = Convert.FromBase64String(data);
|
|
||||||
byte[] iv = new byte[16];
|
|
||||||
|
|
||||||
using var aes = Aes.Create();
|
|
||||||
aes.Key = aesKey;
|
|
||||||
aes.IV = iv;
|
|
||||||
aes.Mode = CipherMode.CBC;
|
|
||||||
aes.Padding = PaddingMode.None;
|
|
||||||
|
|
||||||
using var decryptor = aes.CreateDecryptor();
|
|
||||||
byte[] plainText = decryptor.TransformFinalBlock(cipherText, 0, cipherText.Length);
|
|
||||||
int padding = plainText[plainText.Length - 1];
|
|
||||||
return Encoding.UTF8.GetString(plainText, 0, plainText.Length - padding);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public class Cache
|
public class Cache
|
||||||
{
|
{
|
||||||
private readonly string _cacheDir;
|
private readonly string _cacheDir;
|
||||||
@@ -86,8 +52,6 @@ namespace KenaitoConf
|
|||||||
private readonly Cache _cache;
|
private readonly Cache _cache;
|
||||||
private readonly HttpClient _http = new();
|
private readonly HttpClient _http = new();
|
||||||
private readonly Dictionary<string, string> _configs = new();
|
private readonly Dictionary<string, string> _configs = new();
|
||||||
private string _secret = "";
|
|
||||||
private string _globalSecret = "";
|
|
||||||
private long _lastTime = 0;
|
private long _lastTime = 0;
|
||||||
private bool _started = false;
|
private bool _started = false;
|
||||||
private CancellationTokenSource _cts;
|
private CancellationTokenSource _cts;
|
||||||
@@ -188,10 +152,6 @@ namespace KenaitoConf
|
|||||||
throw new Exception($"server error: {(data.ContainsKey("msg") ? data["msg"] : "unknown")}");
|
throw new Exception($"server error: {(data.ContainsKey("msg") ? data["msg"] : "unknown")}");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.TryGetValue("kenaito.config.encrypt-secret", out var s))
|
|
||||||
_secret = s?.ToString() ?? "";
|
|
||||||
if (data.TryGetValue("kenaito.config.global-encrypt-secret", out var gs))
|
|
||||||
_globalSecret = gs?.ToString() ?? "";
|
|
||||||
if (data.TryGetValue("latest.fetch.timestamp", out var ts))
|
if (data.TryGetValue("latest.fetch.timestamp", out var ts))
|
||||||
_lastTime = Convert.ToInt64(ts);
|
_lastTime = Convert.ToInt64(ts);
|
||||||
|
|
||||||
@@ -231,7 +191,7 @@ namespace KenaitoConf
|
|||||||
var changed = new Dictionary<string, string>();
|
var changed = new Dictionary<string, string>();
|
||||||
foreach (var kv in changedData)
|
foreach (var kv in changedData)
|
||||||
{
|
{
|
||||||
changed[kv.Key] = TryDecrypt(kv.Value?.ToString() ?? "");
|
changed[kv.Key] = kv.Value?.ToString() ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var kv in changed)
|
foreach (var kv in changed)
|
||||||
@@ -252,16 +212,8 @@ namespace KenaitoConf
|
|||||||
continue;
|
continue;
|
||||||
if (kv.Value is JsonElement)
|
if (kv.Value is JsonElement)
|
||||||
continue;
|
continue;
|
||||||
_configs[kv.Key] = TryDecrypt(kv.Value?.ToString() ?? "");
|
_configs[kv.Key] = kv.Value?.ToString() ?? "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private string TryDecrypt(string value)
|
|
||||||
{
|
|
||||||
string decrypted = Crypto.DecryptConfig(_secret, value);
|
|
||||||
if (decrypted == value && !string.IsNullOrEmpty(_globalSecret))
|
|
||||||
decrypted = Crypto.DecryptConfig(_globalSecret, value);
|
|
||||||
return decrypted;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ type Client struct {
|
|||||||
cache *Cache
|
cache *Cache
|
||||||
listener *Listener
|
listener *Listener
|
||||||
configs map[string]string
|
configs map[string]string
|
||||||
secret string
|
|
||||||
globalSecret string
|
|
||||||
lastTime int64
|
lastTime int64
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
@@ -157,14 +155,6 @@ func (c *Client) fetchAll() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// extract secrets
|
|
||||||
if s, ok := result["kenaito.config.encrypt-secret"]; ok {
|
|
||||||
c.secret, _ = s.(string)
|
|
||||||
}
|
|
||||||
if s, ok := result["kenaito.config.global-encrypt-secret"]; ok {
|
|
||||||
c.globalSecret, _ = s.(string)
|
|
||||||
}
|
|
||||||
|
|
||||||
// extract timestamp
|
// extract timestamp
|
||||||
if ts, ok := result["latest.fetch.timestamp"]; ok {
|
if ts, ok := result["latest.fetch.timestamp"]; ok {
|
||||||
if tsStr, ok := ts.(string); ok {
|
if tsStr, ok := ts.(string); ok {
|
||||||
@@ -234,17 +224,9 @@ func (c *Client) fetchChanged() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(wrapper.Data) > 0 {
|
if len(wrapper.Data) > 0 {
|
||||||
// decrypt values
|
|
||||||
changed := make(map[string]string)
|
changed := make(map[string]string)
|
||||||
for k, v := range wrapper.Data {
|
for k, v := range wrapper.Data {
|
||||||
decrypted, err := decryptConfig(c.secret, v)
|
changed[k] = v
|
||||||
if err != nil && c.globalSecret != "" {
|
|
||||||
decrypted, err = decryptConfig(c.globalSecret, v)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
decrypted = v
|
|
||||||
}
|
|
||||||
changed[k] = decrypted
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// update local configs
|
// update local configs
|
||||||
@@ -275,15 +257,7 @@ func (c *Client) applyConfigs(data map[string]interface{}) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// decrypt if needed - try app secret first, then global secret
|
c.configs[k] = strVal
|
||||||
decrypted, err := decryptConfig(c.secret, strVal)
|
|
||||||
if err != nil && c.globalSecret != "" {
|
|
||||||
decrypted, err = decryptConfig(c.globalSecret, strVal)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
decrypted = strVal
|
|
||||||
}
|
|
||||||
c.configs[k] = decrypted
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
package client
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/aes"
|
|
||||||
"crypto/cipher"
|
|
||||||
"crypto/md5"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
const CryptPrefix = "{Cipher}"
|
|
||||||
|
|
||||||
func decryptConfig(secret string, data string) (string, error) {
|
|
||||||
if !strings.HasPrefix(data, CryptPrefix) {
|
|
||||||
return data, nil
|
|
||||||
}
|
|
||||||
encrypted := strings.TrimPrefix(data, CryptPrefix)
|
|
||||||
return decryptWithAES(secret, encrypted)
|
|
||||||
}
|
|
||||||
|
|
||||||
func decryptWithAES(key string, data string) (string, error) {
|
|
||||||
secretKey := md5.Sum([]byte(key))
|
|
||||||
aesKey := hex.EncodeToString(secretKey[:])[16:]
|
|
||||||
|
|
||||||
cipherText, err := base64.StdEncoding.DecodeString(data)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
block, err := aes.NewCipher([]byte(aesKey))
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(cipherText)%aes.BlockSize != 0 {
|
|
||||||
return "", fmt.Errorf("ciphertext is not a multiple of block size")
|
|
||||||
}
|
|
||||||
|
|
||||||
plainText := make([]byte, len(cipherText))
|
|
||||||
mode := cipher.NewCBCDecrypter(block, make([]byte, aes.BlockSize))
|
|
||||||
mode.CryptBlocks(plainText, cipherText)
|
|
||||||
|
|
||||||
plainText, err = pkcs7Unpad(plainText, aes.BlockSize)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return string(plainText), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func pkcs7Unpad(data []byte, blockSize int) ([]byte, error) {
|
|
||||||
if len(data) == 0 {
|
|
||||||
return nil, fmt.Errorf("empty data")
|
|
||||||
}
|
|
||||||
padding := int(data[len(data)-1])
|
|
||||||
if padding > blockSize || padding == 0 {
|
|
||||||
return nil, fmt.Errorf("invalid padding")
|
|
||||||
}
|
|
||||||
return data[:len(data)-padding], nil
|
|
||||||
}
|
|
||||||
@@ -8,12 +8,8 @@ import java.net.URI
|
|||||||
import java.net.http.HttpClient
|
import java.net.http.HttpClient
|
||||||
import java.net.http.HttpRequest
|
import java.net.http.HttpRequest
|
||||||
import java.net.http.HttpResponse
|
import java.net.http.HttpResponse
|
||||||
import java.security.MessageDigest
|
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import javax.crypto.Cipher
|
|
||||||
import javax.crypto.spec.IvParameterSpec
|
|
||||||
import javax.crypto.spec.SecretKeySpec
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Config(
|
data class Config(
|
||||||
@@ -27,27 +23,6 @@ data class Config(
|
|||||||
val cacheDir: String = "."
|
val cacheDir: String = "."
|
||||||
)
|
)
|
||||||
|
|
||||||
object Crypto {
|
|
||||||
private const val CRYPT_PREFIX = "{Cipher}"
|
|
||||||
|
|
||||||
fun decryptConfig(secret: String, data: String): String {
|
|
||||||
if (!data.startsWith(CRYPT_PREFIX)) return data
|
|
||||||
return decryptWithAES(secret, data.removePrefix(CRYPT_PREFIX))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun decryptWithAES(key: String, data: String): String {
|
|
||||||
val md5 = MessageDigest.getInstance("MD5").digest(key.toByteArray()).joinToString("") { "%02x".format(it) }
|
|
||||||
val aesKey = md5.substring(16).toByteArray()
|
|
||||||
val cipherText = java.util.Base64.getDecoder().decode(data)
|
|
||||||
val iv = IvParameterSpec(ByteArray(16))
|
|
||||||
val cipher = Cipher.getInstance("AES/CBC/NoPadding")
|
|
||||||
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(aesKey, "AES"), iv)
|
|
||||||
val plainText = cipher.doFinal(cipherText)
|
|
||||||
val padding = plainText.last().toInt() and 0xFF
|
|
||||||
return String(plainText, 0, plainText.size - padding)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Cache(private val cacheDir: String) {
|
class Cache(private val cacheDir: String) {
|
||||||
private val cacheFile = File(cacheDir, "config-cache.json")
|
private val cacheFile = File(cacheDir, "config-cache.json")
|
||||||
fun write(data: String) { cacheFile.writeText(data) }
|
fun write(data: String) { cacheFile.writeText(data) }
|
||||||
@@ -57,8 +32,6 @@ class Cache(private val cacheDir: String) {
|
|||||||
class ConfigClient(private val config: Config) {
|
class ConfigClient(private val config: Config) {
|
||||||
private val httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build()
|
private val httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build()
|
||||||
private val configs = ConcurrentHashMap<String, String>()
|
private val configs = ConcurrentHashMap<String, String>()
|
||||||
private var secret = ""
|
|
||||||
private var globalSecret = ""
|
|
||||||
private var lastTime = 0L
|
private var lastTime = 0L
|
||||||
private var started = false
|
private var started = false
|
||||||
private val handlers = mutableListOf<(Map<String, String>) -> Unit>()
|
private val handlers = mutableListOf<(Map<String, String>) -> Unit>()
|
||||||
@@ -101,8 +74,6 @@ class ConfigClient(private val config: Config) {
|
|||||||
val data = json.parseToJsonElement(resp.body()).jsonObject
|
val data = json.parseToJsonElement(resp.body()).jsonObject
|
||||||
if (data.containsKey("code") && data["code"]!!.jsonPrimitive.int != 0)
|
if (data.containsKey("code") && data["code"]!!.jsonPrimitive.int != 0)
|
||||||
throw RuntimeException("server error: ${data["code"]}")
|
throw RuntimeException("server error: ${data["code"]}")
|
||||||
data["kenaito.config.encrypt-secret"]?.let { secret = it.jsonPrimitive.content }
|
|
||||||
data["kenaito.config.global-encrypt-secret"]?.let { globalSecret = it.jsonPrimitive.content }
|
|
||||||
data["latest.fetch.timestamp"]?.let { lastTime = it.jsonPrimitive.content.toLong() }
|
data["latest.fetch.timestamp"]?.let { lastTime = it.jsonPrimitive.content.toLong() }
|
||||||
Cache(config.cacheDir).write(resp.body())
|
Cache(config.cacheDir).write(resp.body())
|
||||||
applyConfigs(resp.body())
|
applyConfigs(resp.body())
|
||||||
@@ -121,7 +92,7 @@ class ConfigClient(private val config: Config) {
|
|||||||
val changedData = result["data"]!!.jsonObject.toMutableMap()
|
val changedData = result["data"]!!.jsonObject.toMutableMap()
|
||||||
changedData.remove("latest.fetch.timestamp")?.let { lastTime = it.jsonPrimitive.content.toLong() }
|
changedData.remove("latest.fetch.timestamp")?.let { lastTime = it.jsonPrimitive.content.toLong() }
|
||||||
val changed = mutableMapOf<String, String>()
|
val changed = mutableMapOf<String, String>()
|
||||||
changedData.forEach { (k, v) -> changed[k] = tryDecrypt(v.jsonPrimitive.content) }
|
changedData.forEach { (k, v) -> changed[k] = v.jsonPrimitive.content }
|
||||||
configs.putAll(changed)
|
configs.putAll(changed)
|
||||||
handlers.forEach { try { it(changed) } catch (e: Exception) { System.err.println("[ConfigClient] handler error: ${e.message}") } }
|
handlers.forEach { try { it(changed) } catch (e: Exception) { System.err.println("[ConfigClient] handler error: ${e.message}") } }
|
||||||
}
|
}
|
||||||
@@ -130,13 +101,7 @@ class ConfigClient(private val config: Config) {
|
|||||||
val data = json.parseToJsonElement(jsonStr).jsonObject
|
val data = json.parseToJsonElement(jsonStr).jsonObject
|
||||||
data.forEach { (k, v) ->
|
data.forEach { (k, v) ->
|
||||||
if (!k.startsWith("kenaito.") && k != "latest.fetch.timestamp")
|
if (!k.startsWith("kenaito.") && k != "latest.fetch.timestamp")
|
||||||
configs[k] = tryDecrypt(v.jsonPrimitive.content)
|
configs[k] = v.jsonPrimitive.content
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun tryDecrypt(value: String): String {
|
|
||||||
var decrypted = Crypto.decryptConfig(secret, value)
|
|
||||||
if (decrypted == value && globalSecret.isNotEmpty()) decrypted = Crypto.decryptConfig(globalSecret, value)
|
|
||||||
return decrypted
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
local http = require("socket.http")
|
local http = require("socket.http")
|
||||||
local ltn12 = require("ltn12")
|
local ltn12 = require("ltn12")
|
||||||
local cjson = require("cjson")
|
local cjson = require("cjson")
|
||||||
local openssl = require("openssl")
|
|
||||||
|
|
||||||
local ConfigClient = {}
|
local ConfigClient = {}
|
||||||
ConfigClient.__index = ConfigClient
|
ConfigClient.__index = ConfigClient
|
||||||
@@ -19,8 +18,6 @@ function ConfigClient.new(config)
|
|||||||
self.config.cacheDir = self.config.cacheDir or "."
|
self.config.cacheDir = self.config.cacheDir or "."
|
||||||
|
|
||||||
self.configs = {}
|
self.configs = {}
|
||||||
self.secret = ""
|
|
||||||
self.globalSecret = ""
|
|
||||||
self.lastTime = 0
|
self.lastTime = 0
|
||||||
self.handlers = {}
|
self.handlers = {}
|
||||||
return self
|
return self
|
||||||
@@ -38,30 +35,6 @@ function ConfigClient:get_all()
|
|||||||
return self.configs
|
return self.configs
|
||||||
end
|
end
|
||||||
|
|
||||||
local function decrypt_config(secret, data)
|
|
||||||
local prefix = "{Cipher}"
|
|
||||||
if string.sub(data, 1, #prefix) ~= prefix then
|
|
||||||
return data
|
|
||||||
end
|
|
||||||
local encrypted = string.sub(data, #prefix + 1)
|
|
||||||
|
|
||||||
-- MD5 hash
|
|
||||||
local md5_hash = openssl.digest("md5", secret)
|
|
||||||
local aes_key = string.sub(md5_hash, 17)
|
|
||||||
|
|
||||||
-- Base64 decode
|
|
||||||
local cipher_text = openssl.base64(encrypted, false)
|
|
||||||
|
|
||||||
-- AES-128-CBC decrypt
|
|
||||||
local iv = string.rep("\0", 16)
|
|
||||||
local cipher = openssl.cipher.new("aes-128-cbc")
|
|
||||||
local plain_text = cipher:decrypt(cipher_text, aes_key, iv)
|
|
||||||
|
|
||||||
-- Remove PKCS7 padding
|
|
||||||
local padding = string.byte(plain_text, -1)
|
|
||||||
return string.sub(plain_text, 1, -padding - 1)
|
|
||||||
end
|
|
||||||
|
|
||||||
function ConfigClient:start()
|
function ConfigClient:start()
|
||||||
-- Fetch all configs
|
-- Fetch all configs
|
||||||
local url = self.config.baseUrl .. "/api/fetchAllConfigs"
|
local url = self.config.baseUrl .. "/api/fetchAllConfigs"
|
||||||
@@ -88,12 +61,6 @@ function ConfigClient:start()
|
|||||||
if code == 200 then
|
if code == 200 then
|
||||||
local data = cjson.decode(table.concat(response_body))
|
local data = cjson.decode(table.concat(response_body))
|
||||||
|
|
||||||
if data["kenaito.config.encrypt-secret"] then
|
|
||||||
self.secret = data["kenaito.config.encrypt-secret"]
|
|
||||||
end
|
|
||||||
if data["kenaito.config.global-encrypt-secret"] then
|
|
||||||
self.globalSecret = data["kenaito.config.global-encrypt-secret"]
|
|
||||||
end
|
|
||||||
if data["latest.fetch.timestamp"] then
|
if data["latest.fetch.timestamp"] then
|
||||||
self.lastTime = tonumber(data["latest.fetch.timestamp"])
|
self.lastTime = tonumber(data["latest.fetch.timestamp"])
|
||||||
end
|
end
|
||||||
@@ -101,10 +68,7 @@ function ConfigClient:start()
|
|||||||
for k, v in pairs(data) do
|
for k, v in pairs(data) do
|
||||||
if not string.match(k, "^kenaito%.") and k ~= "latest.fetch.timestamp" then
|
if not string.match(k, "^kenaito%.") and k ~= "latest.fetch.timestamp" then
|
||||||
if type(v) == "string" then
|
if type(v) == "string" then
|
||||||
self.configs[k] = decrypt_config(self.secret, v)
|
self.configs[k] = v
|
||||||
if self.configs[k] == v and self.globalSecret ~= "" then
|
|
||||||
self.configs[k] = decrypt_config(self.globalSecret, v)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Config } from './config.js';
|
import { Config } from './config.js';
|
||||||
import { Cache } from './cache.js';
|
import { Cache } from './cache.js';
|
||||||
import { Listener } from './listener.js';
|
import { Listener } from './listener.js';
|
||||||
import { decryptConfig } from './crypto.js';
|
|
||||||
|
|
||||||
export class ConfigClient {
|
export class ConfigClient {
|
||||||
constructor(config) {
|
constructor(config) {
|
||||||
@@ -9,8 +8,6 @@ export class ConfigClient {
|
|||||||
this.cache = new Cache(config.cacheDir);
|
this.cache = new Cache(config.cacheDir);
|
||||||
this.listener = new Listener();
|
this.listener = new Listener();
|
||||||
this.configs = {};
|
this.configs = {};
|
||||||
this.secret = '';
|
|
||||||
this.globalSecret = '';
|
|
||||||
this.lastTime = 0;
|
this.lastTime = 0;
|
||||||
this.started = false;
|
this.started = false;
|
||||||
this.timer = null;
|
this.timer = null;
|
||||||
@@ -86,14 +83,6 @@ export class ConfigClient {
|
|||||||
throw new Error(`server error: ${data.msg || 'unknown'}`);
|
throw new Error(`server error: ${data.msg || 'unknown'}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const secret = data['kenaito.config.encrypt-secret'];
|
|
||||||
if (secret) {
|
|
||||||
this.secret = secret;
|
|
||||||
}
|
|
||||||
const globalSecret = data['kenaito.config.global-encrypt-secret'];
|
|
||||||
if (globalSecret) {
|
|
||||||
this.globalSecret = globalSecret;
|
|
||||||
}
|
|
||||||
const ts = data['latest.fetch.timestamp'];
|
const ts = data['latest.fetch.timestamp'];
|
||||||
if (ts) {
|
if (ts) {
|
||||||
this.lastTime = parseInt(ts, 10);
|
this.lastTime = parseInt(ts, 10);
|
||||||
@@ -140,7 +129,7 @@ export class ConfigClient {
|
|||||||
|
|
||||||
const changed = {};
|
const changed = {};
|
||||||
for (const [k, v] of Object.entries(changedData)) {
|
for (const [k, v] of Object.entries(changedData)) {
|
||||||
changed[k] = this.tryDecrypt(v);
|
changed[k] = v;
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.assign(this.configs, changed);
|
Object.assign(this.configs, changed);
|
||||||
@@ -156,17 +145,9 @@ export class ConfigClient {
|
|||||||
if (typeof v !== 'string') {
|
if (typeof v !== 'string') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
this.configs[k] = this.tryDecrypt(v);
|
this.configs[k] = v;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tryDecrypt(value) {
|
|
||||||
let decrypted = decryptConfig(this.secret, value);
|
|
||||||
if (decrypted === value && this.globalSecret) {
|
|
||||||
decrypted = decryptConfig(this.globalSecret, value);
|
|
||||||
}
|
|
||||||
return decrypted;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Config } from './config.js';
|
export { Config } from './config.js';
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import crypto from 'node:crypto';
|
|
||||||
|
|
||||||
const CRYPT_PREFIX = '{Cipher}';
|
|
||||||
|
|
||||||
export function decryptConfig(secret, data) {
|
|
||||||
if (!data.startsWith(CRYPT_PREFIX)) {
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
const encrypted = data.slice(CRYPT_PREFIX.length);
|
|
||||||
return decryptWithAES(secret, encrypted);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function decryptWithAES(key, data) {
|
|
||||||
const md5Hash = crypto.createHash('md5').update(key).digest('hex');
|
|
||||||
const aesKey = md5Hash.slice(16);
|
|
||||||
const cipherText = Buffer.from(data, 'base64');
|
|
||||||
const iv = Buffer.alloc(16, 0);
|
|
||||||
const decipher = crypto.createDecipheriv('aes-128-cbc', aesKey, iv);
|
|
||||||
decipher.setAutoPadding(false);
|
|
||||||
let plainText = Buffer.concat([decipher.update(cipherText), decipher.final()]);
|
|
||||||
const padding = plainText[plainText.length - 1];
|
|
||||||
plainText = plainText.slice(0, plainText.length - padding);
|
|
||||||
return plainText.toString('utf8');
|
|
||||||
}
|
|
||||||
@@ -7,8 +7,6 @@ class ConfigClient
|
|||||||
private Config $config;
|
private Config $config;
|
||||||
private Cache $cache;
|
private Cache $cache;
|
||||||
private array $configs = [];
|
private array $configs = [];
|
||||||
private string $secret = '';
|
|
||||||
private string $globalSecret = '';
|
|
||||||
private int $lastTime = 0;
|
private int $lastTime = 0;
|
||||||
private bool $started = false;
|
private bool $started = false;
|
||||||
private array $handlers = [];
|
private array $handlers = [];
|
||||||
@@ -107,12 +105,6 @@ class ConfigClient
|
|||||||
throw new \RuntimeException("server error: " . ($data['msg'] ?? 'unknown'));
|
throw new \RuntimeException("server error: " . ($data['msg'] ?? 'unknown'));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isset($data['kenaito.config.encrypt-secret'])) {
|
|
||||||
$this->secret = $data['kenaito.config.encrypt-secret'];
|
|
||||||
}
|
|
||||||
if (isset($data['kenaito.config.global-encrypt-secret'])) {
|
|
||||||
$this->globalSecret = $data['kenaito.config.global-encrypt-secret'];
|
|
||||||
}
|
|
||||||
if (isset($data['latest.fetch.timestamp'])) {
|
if (isset($data['latest.fetch.timestamp'])) {
|
||||||
$this->lastTime = (int)$data['latest.fetch.timestamp'];
|
$this->lastTime = (int)$data['latest.fetch.timestamp'];
|
||||||
}
|
}
|
||||||
@@ -156,7 +148,7 @@ class ConfigClient
|
|||||||
|
|
||||||
$changed = [];
|
$changed = [];
|
||||||
foreach ($changedData as $k => $v) {
|
foreach ($changedData as $k => $v) {
|
||||||
$changed[$k] = $this->tryDecrypt($v);
|
$changed[$k] = $v;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->configs = array_merge($this->configs, $changed);
|
$this->configs = array_merge($this->configs, $changed);
|
||||||
@@ -181,16 +173,7 @@ class ConfigClient
|
|||||||
if (!is_string($v)) {
|
if (!is_string($v)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$this->configs[$k] = $this->tryDecrypt($v);
|
$this->configs[$k] = $v;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function tryDecrypt(string $value): string
|
|
||||||
{
|
|
||||||
$decrypted = Crypto::decryptConfig($this->secret, $value);
|
|
||||||
if ($decrypted === $value && $this->globalSecret) {
|
|
||||||
$decrypted = Crypto::decryptConfig($this->globalSecret, $value);
|
|
||||||
}
|
|
||||||
return $decrypted;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace KenaitoConf;
|
|
||||||
|
|
||||||
class Crypto
|
|
||||||
{
|
|
||||||
private const CRYPT_PREFIX = '{Cipher}';
|
|
||||||
|
|
||||||
public static function decryptConfig(string $secret, string $data): string
|
|
||||||
{
|
|
||||||
if (!str_starts_with($data, self::CRYPT_PREFIX)) {
|
|
||||||
return $data;
|
|
||||||
}
|
|
||||||
$encrypted = substr($data, strlen(self::CRYPT_PREFIX));
|
|
||||||
return self::decryptWithAES($secret, $encrypted);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function decryptWithAES(string $key, string $data): string
|
|
||||||
{
|
|
||||||
$md5Hash = md5($key);
|
|
||||||
$aesKey = substr($md5Hash, 16);
|
|
||||||
$cipherText = base64_decode($data);
|
|
||||||
$iv = str_repeat("\0", 16);
|
|
||||||
$plainText = openssl_decrypt($cipherText, 'AES-128-CBC', $aesKey, OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING, $iv);
|
|
||||||
$padding = ord($plainText[strlen($plainText) - 1]);
|
|
||||||
return substr($plainText, 0, -$padding);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,6 @@ import requests
|
|||||||
|
|
||||||
from kenaito_conf.cache import Cache
|
from kenaito_conf.cache import Cache
|
||||||
from kenaito_conf.config import Config
|
from kenaito_conf.config import Config
|
||||||
from kenaito_conf.crypto import decrypt_config
|
|
||||||
from kenaito_conf.listener import Listener
|
from kenaito_conf.listener import Listener
|
||||||
|
|
||||||
logger = logging.getLogger("kenaito_conf")
|
logger = logging.getLogger("kenaito_conf")
|
||||||
@@ -18,8 +17,6 @@ class ConfigClient:
|
|||||||
self._cache = Cache(config.cache_dir)
|
self._cache = Cache(config.cache_dir)
|
||||||
self._listener = Listener()
|
self._listener = Listener()
|
||||||
self._configs: dict[str, str] = {}
|
self._configs: dict[str, str] = {}
|
||||||
self._secret: str = ""
|
|
||||||
self._global_secret: str = ""
|
|
||||||
self._last_time: int = 0
|
self._last_time: int = 0
|
||||||
self._lock = threading.RLock()
|
self._lock = threading.RLock()
|
||||||
self._stop_event = threading.Event()
|
self._stop_event = threading.Event()
|
||||||
@@ -75,14 +72,6 @@ class ConfigClient:
|
|||||||
if data.get("code", 0) != 0:
|
if data.get("code", 0) != 0:
|
||||||
raise RuntimeError(f"server error: {data.get('msg', 'unknown')}")
|
raise RuntimeError(f"server error: {data.get('msg', 'unknown')}")
|
||||||
|
|
||||||
secret = data.get("kenaito.config.encrypt-secret")
|
|
||||||
if secret:
|
|
||||||
self._secret = secret
|
|
||||||
|
|
||||||
global_secret = data.get("kenaito.config.global-encrypt-secret")
|
|
||||||
if global_secret:
|
|
||||||
self._global_secret = global_secret
|
|
||||||
|
|
||||||
ts = data.get("latest.fetch.timestamp")
|
ts = data.get("latest.fetch.timestamp")
|
||||||
if ts:
|
if ts:
|
||||||
self._last_time = int(ts)
|
self._last_time = int(ts)
|
||||||
@@ -117,8 +106,7 @@ class ConfigClient:
|
|||||||
if changed_data:
|
if changed_data:
|
||||||
changed = {}
|
changed = {}
|
||||||
for k, v in changed_data.items():
|
for k, v in changed_data.items():
|
||||||
decrypted = self._try_decrypt(v)
|
changed[k] = v
|
||||||
changed[k] = decrypted
|
|
||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._configs.update(changed)
|
self._configs.update(changed)
|
||||||
@@ -133,13 +121,7 @@ class ConfigClient:
|
|||||||
continue
|
continue
|
||||||
if not isinstance(v, str):
|
if not isinstance(v, str):
|
||||||
continue
|
continue
|
||||||
self._configs[k] = self._try_decrypt(v)
|
self._configs[k] = v
|
||||||
|
|
||||||
def _try_decrypt(self, value: str) -> str:
|
|
||||||
decrypted = decrypt_config(self._secret, value)
|
|
||||||
if decrypted == value and self._global_secret:
|
|
||||||
decrypted = decrypt_config(self._global_secret, value)
|
|
||||||
return decrypted
|
|
||||||
|
|
||||||
def _sync_loop(self) -> None:
|
def _sync_loop(self) -> None:
|
||||||
while not self._stop_event.is_set():
|
while not self._stop_event.is_set():
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
import base64
|
|
||||||
import hashlib
|
|
||||||
|
|
||||||
import pyaes
|
|
||||||
|
|
||||||
CRYPT_PREFIX = "{Cipher}"
|
|
||||||
|
|
||||||
|
|
||||||
def decrypt_config(secret: str, data: str) -> str:
|
|
||||||
if not data.startswith(CRYPT_PREFIX):
|
|
||||||
return data
|
|
||||||
encrypted = data[len(CRYPT_PREFIX):]
|
|
||||||
return decrypt_with_aes(secret, encrypted)
|
|
||||||
|
|
||||||
|
|
||||||
def decrypt_with_aes(key: str, data: str) -> str:
|
|
||||||
md5_hash = hashlib.md5(key.encode()).hexdigest()
|
|
||||||
aes_key = md5_hash[16:].encode()
|
|
||||||
|
|
||||||
cipher_text = base64.b64decode(data)
|
|
||||||
|
|
||||||
iv = b"\0" * 16
|
|
||||||
aes_cbc = pyaes.AESModeOfOperationCBC(aes_key, iv=iv)
|
|
||||||
plain_text = aes_cbc.decrypt(cipher_text)
|
|
||||||
|
|
||||||
padding = plain_text[-1]
|
|
||||||
plain_text = plain_text[:-padding]
|
|
||||||
|
|
||||||
return plain_text.decode()
|
|
||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
require 'net/http'
|
require 'net/http'
|
||||||
require 'json'
|
require 'json'
|
||||||
require 'openssl'
|
|
||||||
require 'base64'
|
|
||||||
|
|
||||||
module KenaitoConf
|
module KenaitoConf
|
||||||
class Config
|
class Config
|
||||||
@@ -21,33 +19,6 @@ module KenaitoConf
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
module Crypto
|
|
||||||
CRYPT_PREFIX = '{Cipher}'
|
|
||||||
|
|
||||||
def self.decrypt_config(secret, data)
|
|
||||||
return data unless data.start_with?(CRYPT_PREFIX)
|
|
||||||
encrypted = data[CRYPT_PREFIX.length..]
|
|
||||||
decrypt_with_aes(secret, encrypted)
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.decrypt_with_aes(key, data)
|
|
||||||
md5_hash = OpenSSL::Digest::MD5.hexdigest(key)
|
|
||||||
aes_key = md5_hash[16..]
|
|
||||||
cipher_text = Base64.decode64(data)
|
|
||||||
iv = "\0" * 16
|
|
||||||
|
|
||||||
cipher = OpenSSL::Cipher::AES128.new(:CBC)
|
|
||||||
cipher.decrypt
|
|
||||||
cipher.key = aes_key
|
|
||||||
cipher.iv = iv
|
|
||||||
cipher.padding = 0
|
|
||||||
|
|
||||||
plain_text = cipher.update(cipher_text) + cipher.final
|
|
||||||
padding = plain_text[-1].ord
|
|
||||||
plain_text[0...-padding]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
class Cache
|
class Cache
|
||||||
CACHE_FILE = 'config-cache.json'
|
CACHE_FILE = 'config-cache.json'
|
||||||
|
|
||||||
@@ -73,8 +44,6 @@ module KenaitoConf
|
|||||||
@config = config
|
@config = config
|
||||||
@cache = Cache.new(config.cache_dir)
|
@cache = Cache.new(config.cache_dir)
|
||||||
@configs = {}
|
@configs = {}
|
||||||
@secret = ''
|
|
||||||
@global_secret = ''
|
|
||||||
@last_time = 0
|
@last_time = 0
|
||||||
@handlers = []
|
@handlers = []
|
||||||
@started = false
|
@started = false
|
||||||
@@ -147,8 +116,6 @@ module KenaitoConf
|
|||||||
data = JSON.parse(response.body)
|
data = JSON.parse(response.body)
|
||||||
raise "server error: #{data['msg']}" if data['code'] && data['code'] != 0
|
raise "server error: #{data['msg']}" if data['code'] && data['code'] != 0
|
||||||
|
|
||||||
@secret = data['kenaito.config.encrypt-secret'] if data['kenaito.config.encrypt-secret']
|
|
||||||
@global_secret = data['kenaito.config.global-encrypt-secret'] if data['kenaito.config.global-encrypt-secret']
|
|
||||||
@last_time = data['latest.fetch.timestamp'].to_i if data['latest.fetch.timestamp']
|
@last_time = data['latest.fetch.timestamp'].to_i if data['latest.fetch.timestamp']
|
||||||
|
|
||||||
@cache.write(data)
|
@cache.write(data)
|
||||||
@@ -180,7 +147,7 @@ module KenaitoConf
|
|||||||
|
|
||||||
changed = {}
|
changed = {}
|
||||||
changed_data.each do |k, v|
|
changed_data.each do |k, v|
|
||||||
changed[k] = try_decrypt(v)
|
changed[k] = v
|
||||||
end
|
end
|
||||||
|
|
||||||
@configs.merge!(changed)
|
@configs.merge!(changed)
|
||||||
@@ -191,14 +158,8 @@ module KenaitoConf
|
|||||||
data.each do |k, v|
|
data.each do |k, v|
|
||||||
next if k.start_with?('kenaito.') || k == 'latest.fetch.timestamp'
|
next if k.start_with?('kenaito.') || k == 'latest.fetch.timestamp'
|
||||||
next unless v.is_a?(String)
|
next unless v.is_a?(String)
|
||||||
@configs[k] = try_decrypt(v)
|
@configs[k] = v
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def try_decrypt(value)
|
|
||||||
decrypted = Crypto.decrypt_config(@secret, value)
|
|
||||||
decrypted = Crypto.decrypt_config(@global_secret, value) if decrypted == value && !@global_secret.empty?
|
|
||||||
decrypted
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
use aes::cipher::{block_padding::NoPadding, BlockDecryptMut, KeyIvInit};
|
|
||||||
use md5::{Digest, Md5};
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
type Aes128CbcDec = cbc::Decryptor<aes::Aes128>;
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub base_url: String,
|
pub base_url: String,
|
||||||
@@ -53,40 +49,9 @@ struct FetchChangedRequest {
|
|||||||
token: String,
|
token: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn decrypt_config(secret: &str, data: &str) -> String {
|
|
||||||
let prefix = "{Cipher}";
|
|
||||||
if !data.starts_with(prefix) {
|
|
||||||
return data.to_string();
|
|
||||||
}
|
|
||||||
let encrypted = &data[prefix.len()..];
|
|
||||||
decrypt_with_aes(secret, encrypted)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn decrypt_with_aes(key: &str, data: &str) -> String {
|
|
||||||
let mut hasher = Md5::new();
|
|
||||||
hasher.update(key.as_bytes());
|
|
||||||
let result = hasher.finalize();
|
|
||||||
let md5_hex = format!("{:x}", result);
|
|
||||||
let aes_key = &md5_hex[16..];
|
|
||||||
|
|
||||||
let cipher_text = base64::decode(data).unwrap_or_default();
|
|
||||||
let iv = [0u8; 16];
|
|
||||||
|
|
||||||
let mut buf = cipher_text.clone();
|
|
||||||
let decrypted = Aes128CbcDec::new_from_slices(aes_key.as_bytes(), &iv)
|
|
||||||
.unwrap()
|
|
||||||
.decrypt_padded_mut::<NoPadding>(&mut buf)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let padding = decrypted[decrypted.len() - 1] as usize;
|
|
||||||
String::from_utf8_lossy(&decrypted[..decrypted.len() - padding]).to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ConfigClient {
|
pub struct ConfigClient {
|
||||||
config: Config,
|
config: Config,
|
||||||
configs: Arc<Mutex<HashMap<String, String>>>,
|
configs: Arc<Mutex<HashMap<String, String>>>,
|
||||||
secret: Arc<Mutex<String>>,
|
|
||||||
global_secret: Arc<Mutex<String>>,
|
|
||||||
last_time: Arc<Mutex<i64>>,
|
last_time: Arc<Mutex<i64>>,
|
||||||
handlers: Arc<Mutex<Vec<Box<dyn Fn(&HashMap<String, String>) + Send + Sync>>>>,
|
handlers: Arc<Mutex<Vec<Box<dyn Fn(&HashMap<String, String>) + Send + Sync>>>>,
|
||||||
}
|
}
|
||||||
@@ -96,8 +61,6 @@ impl ConfigClient {
|
|||||||
Self {
|
Self {
|
||||||
config,
|
config,
|
||||||
configs: Arc::new(Mutex::new(HashMap::new())),
|
configs: Arc::new(Mutex::new(HashMap::new())),
|
||||||
secret: Arc::new(Mutex::new(String::new())),
|
|
||||||
global_secret: Arc::new(Mutex::new(String::new())),
|
|
||||||
last_time: Arc::new(Mutex::new(0)),
|
last_time: Arc::new(Mutex::new(0)),
|
||||||
handlers: Arc::new(Mutex::new(Vec::new())),
|
handlers: Arc::new(Mutex::new(Vec::new())),
|
||||||
}
|
}
|
||||||
@@ -122,8 +85,6 @@ impl ConfigClient {
|
|||||||
self.fetch_all().await?;
|
self.fetch_all().await?;
|
||||||
|
|
||||||
let configs = self.configs.clone();
|
let configs = self.configs.clone();
|
||||||
let secret = self.secret.clone();
|
|
||||||
let global_secret = self.global_secret.clone();
|
|
||||||
let last_time = self.last_time.clone();
|
let last_time = self.last_time.clone();
|
||||||
let handlers = self.handlers.clone();
|
let handlers = self.handlers.clone();
|
||||||
let config = self.config.clone();
|
let config = self.config.clone();
|
||||||
@@ -159,30 +120,18 @@ impl ConfigClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(s) = data.get("kenaito.config.encrypt-secret") {
|
|
||||||
*self.secret.lock().unwrap() = s.as_str().unwrap_or("").to_string();
|
|
||||||
}
|
|
||||||
if let Some(gs) = data.get("kenaito.config.global-encrypt-secret") {
|
|
||||||
*self.global_secret.lock().unwrap() = gs.as_str().unwrap_or("").to_string();
|
|
||||||
}
|
|
||||||
if let Some(ts) = data.get("latest.fetch.timestamp") {
|
if let Some(ts) = data.get("latest.fetch.timestamp") {
|
||||||
*self.last_time.lock().unwrap() = ts.as_i64().unwrap_or(0);
|
*self.last_time.lock().unwrap() = ts.as_i64().unwrap_or(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut configs = self.configs.lock().unwrap();
|
let mut configs = self.configs.lock().unwrap();
|
||||||
let secret = self.secret.lock().unwrap().clone();
|
|
||||||
let global_secret = self.global_secret.lock().unwrap().clone();
|
|
||||||
|
|
||||||
for (k, v) in data.iter() {
|
for (k, v) in data.iter() {
|
||||||
if k.starts_with("kenaito.") || k == "latest.fetch.timestamp" {
|
if k.starts_with("kenaito.") || k == "latest.fetch.timestamp" {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(s) = v.as_str() {
|
if let Some(s) = v.as_str() {
|
||||||
let mut decrypted = decrypt_config(&secret, s);
|
configs.insert(k.clone(), s.to_string());
|
||||||
if decrypted == s && !global_secret.is_empty() {
|
|
||||||
decrypted = decrypt_config(&global_secret, s);
|
|
||||||
}
|
|
||||||
configs.insert(k.clone(), decrypted);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
# Server 代码缺陷清单 — 2026-10-15
|
||||||
|
|
||||||
|
## 🔴 严重缺陷(安全/数据风险)
|
||||||
|
|
||||||
|
### 1. `encryptIfRequired` 是空实现 — 敏感数据未加密
|
||||||
|
**文件**: `internal/handler/config.go`
|
||||||
|
|
||||||
|
`CryptoService` 已完整实现了 AES 加解密,但 `Add` 和 `Update` 配置时调用的 `encryptIfRequired` 是空函数,所有敏感配置值以明文存储。
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (h *ConfigHandler) encryptIfRequired(config *model.AppConfig) {
|
||||||
|
// simplified: check if contents contain sensitive keys
|
||||||
|
// In production, parse the config and encrypt values with {Cipher} prefix
|
||||||
|
// For now, skip encryption logic
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 重置密码使用 `math/rand` — 可预测的伪随机数
|
||||||
|
**文件**: `internal/handler/user.go`
|
||||||
|
|
||||||
|
`ResetPassword` 用 `math/rand` 生成 8 位随机密码。`math/rand` 不是密码学安全的随机源,攻击者可以预测生成的密码。应使用 `crypto/rand`。
|
||||||
|
|
||||||
|
```go
|
||||||
|
import "math/rand"
|
||||||
|
|
||||||
|
func randomString(n int) string {
|
||||||
|
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
|
b := make([]byte, n)
|
||||||
|
for i := range b {
|
||||||
|
b[i] = letters[rand.Intn(len(letters))] // ← 不安全
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. JWT 验证未检查签名算法 — 存在 "none" 算法攻击风险
|
||||||
|
**文件**: `internal/service/auth.go`
|
||||||
|
|
||||||
|
没有检查 `token.Method` 是否为预期的 `jwt.SigningMethodHS256`,攻击者可以将算法改为 `none` 来伪造任意 token。
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *AuthService) ValidateToken(tokenString string) (*model.LoginUserInfo, error) {
|
||||||
|
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(s.jwtSecret), nil // ← 未验证 token.Method
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 数据库真实凭据硬编码在配置文件中
|
||||||
|
**文件**: `application.properties`
|
||||||
|
|
||||||
|
外网 IP + 真实密码 + JWT 密钥 + 加密密钥全部明文提交到代码仓库。
|
||||||
|
|
||||||
|
```properties
|
||||||
|
database.host=103.236.98.135
|
||||||
|
database.password=?uWxt,CX2>+#*Ij@-xa+
|
||||||
|
jwt.secret=kenaito-conf-jwt-secret-2026
|
||||||
|
crypto.secretKey=k9#mP2$vL7qR4xN8
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 加密注释声称 ECB 模式,实际使用 CBC + 零 IV
|
||||||
|
**文件**: `internal/service/crypto.go`
|
||||||
|
|
||||||
|
注释写的是 ECB,代码用的是 CBC(IV 全零)。如果 Java 端真的是 ECB,则两端加解密不兼容。且 CBC + 零 IV 等于 ECB 的安全性。
|
||||||
|
|
||||||
|
```go
|
||||||
|
// ECB mode (compatible with Java version)
|
||||||
|
mode := cipher.NewCBCEncrypter(block, make([]byte, aes.BlockSize))
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟠 重要缺陷(功能/逻辑错误)
|
||||||
|
|
||||||
|
### 6. Logout 不做任何服务端失效处理
|
||||||
|
**文件**: `internal/handler/auth.go`
|
||||||
|
|
||||||
|
JWT 在过期前始终有效,登出后 token 仍可使用。
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (h *AuthHandler Logout(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, model.WrapperResponse{Code: 0})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9. `ValidateToken` 不检查用户是否已被禁用
|
||||||
|
**文件**: `internal/service/auth.go`
|
||||||
|
|
||||||
|
用户被管理员禁用后,已签发的 JWT 在过期前仍然有效。
|
||||||
|
|
||||||
|
```go
|
||||||
|
has, err := dao.Engine.ID(userId).Get(&user)
|
||||||
|
if err != nil || !has {
|
||||||
|
return nil, errors.New("user not found")
|
||||||
|
}
|
||||||
|
// ← 没有检查 user.Enabled
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10. 占位符解析存在循环引用风险
|
||||||
|
**文件**: `internal/service/config_parse.go`
|
||||||
|
|
||||||
|
没有检测循环引用,恶意或错误的配置(如 A=${B}, B=${A})会导致 CPU 空转 100 次迭代。
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *ConfigParseService) replacePlaceholder(...) string {
|
||||||
|
maxIterations := 100
|
||||||
|
for i := 0; i < maxIterations; i++ {
|
||||||
|
// 循环引用时无限迭代
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11. `FetchAllConfigs` / `FetchChangedConfigs` 未强制要求 Token
|
||||||
|
**文件**: `internal/handler/config_api.go`
|
||||||
|
|
||||||
|
当 `app.Token` 为空时,任何请求都能拉取配置,无需认证。如果应用未配置 Token,则配置接口完全开放。
|
||||||
|
|
||||||
|
```go
|
||||||
|
if app.Token != "" && app.Token != req.Token {
|
||||||
|
c.JSON(http.StatusForbidden, ...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Token 为空时直接放行
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🟡 一般缺陷(代码质量/健壮性)
|
||||||
|
|
||||||
|
### 12. 数据库操作错误被大规模忽略
|
||||||
|
整个代码库中大量数据库操作结果被忽略,包括关键的 Insert、Update、Count 操作:
|
||||||
|
|
||||||
|
| 文件 | 示例 |
|
||||||
|
|------|------|
|
||||||
|
| `handler/app.go` | `dao.Engine.Insert(&app)` — 插入失败无感知 |
|
||||||
|
| `handler/config.go` | `dao.Engine.Insert(&history)` — 历史记录保存失败无感知 |
|
||||||
|
| `handler/user.go` | `dao.Engine.Insert(&user)` — 用户创建失败无感知 |
|
||||||
|
| `handler/config_api.go` | `dao.Engine.Where(...).Find(&globalConfigs)` — 查询失败返回空结果 |
|
||||||
|
|
||||||
|
### 13. Token 校验使用字符串直接比较 — 时序攻击
|
||||||
|
**文件**: `internal/handler/config_api.go`
|
||||||
|
|
||||||
|
应使用 `crypto/subtle.ConstantTimeCompare` 防止时序攻击。
|
||||||
|
|
||||||
|
```go
|
||||||
|
if app.Token != "" && app.Token != req.Token {
|
||||||
|
```
|
||||||
|
|
||||||
|
### 14. `Delete` 用户是硬删除,与其他模块软删除不一致
|
||||||
|
**文件**: `internal/handler/user.go`
|
||||||
|
|
||||||
|
而 App、Config 等都是 `enabled = false` 软删除。硬删除用户后,关联的 `user_permissions` 记录和 `created_by` 引用会变成孤儿数据。
|
||||||
|
|
||||||
|
```go
|
||||||
|
dao.Engine.ID(id).Delete(&model.User{}) // 物理删除
|
||||||
|
```
|
||||||
|
|
||||||
|
### 15. `AppHandler.List` 存在 N+1 查询
|
||||||
|
**文件**: `internal/handler/app.go`
|
||||||
|
|
||||||
|
加载 group names 时,每个 group 单独查询一次,应改为 `WHERE id IN (...)` 批量查询。
|
||||||
|
|
||||||
|
```go
|
||||||
|
for gid := range groupIds {
|
||||||
|
var group model.BusinessGroup
|
||||||
|
if has, _ := dao.Engine.ID(gid).Get(&group); has { // ← 每个 group 一次查询
|
||||||
|
```
|
||||||
|
|
||||||
|
### 16. `ConfigHandler.List` 中 `app_code like ?` 可能过度匹配
|
||||||
|
**文件**: `internal/handler/config.go`
|
||||||
|
|
||||||
|
如果 `app.Code = "app"`,会匹配到 `app`、`app-web`、`app-api` 等所有以 `app` 开头的配置。
|
||||||
|
|
||||||
|
```go
|
||||||
|
query = query.And("app_code like ?", app.Code+"%")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 17. `AppHandler.Tree` 返回已禁用的应用
|
||||||
|
**文件**: `internal/handler/app.go`
|
||||||
|
|
||||||
|
没有过滤 `enabled = 1`,禁用的应用也会出现在树中。
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (h *AppHandler) Tree(c *gin.Context) {
|
||||||
|
var apps []model.Application
|
||||||
|
dao.Engine.Find(&apps) // ← 没有 WHERE enabled = 1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 18. `Profile` 模型缺少 `CreatedAt`/`UpdatedAt` 字段
|
||||||
|
**文件**: `internal/model/profile.go`
|
||||||
|
|
||||||
|
其他所有模型都有时间审计字段,Profile 没有,无法追踪环境的创建和修改时间。
|
||||||
|
|
||||||
|
### 19. DSN 构建逻辑重复
|
||||||
|
**文件**: `internal/config/config.go` 的 `DSN()` 方法 vs `internal/dao/engine.go` 的 `Init()` 函数
|
||||||
|
|
||||||
|
两处各自拼接 DSN 字符串,且 `dao/engine.go` 没有复用 `config.DatabaseConfig.DSN()` 方法,还额外加了 `&loc=Local` 参数。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 总结
|
||||||
|
|
||||||
|
| 等级 | 数量 | 关键问题 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 🔴 严重 | 5 | 加密空实现、不安全随机数、JWT 算法未校验、凭据泄露、加密模式不一致 |
|
||||||
|
| 🟠 重要 | 6 | 回滚不加密、权限表缺失、登出无效、禁用用户仍可访问等 |
|
||||||
|
| 🟡 一般 | 8 | 错误忽略、N+1 查询、时序攻击、硬删除不一致等 |
|
||||||
+2
-3
@@ -30,15 +30,14 @@ func main() {
|
|||||||
|
|
||||||
// init services
|
// init services
|
||||||
authService := service.NewAuthService(config.Cfg.JWT.Secret, config.Cfg.JWT.ExpireHours)
|
authService := service.NewAuthService(config.Cfg.JWT.Secret, config.Cfg.JWT.ExpireHours)
|
||||||
cryptoService := service.NewCryptoService()
|
|
||||||
parseService := service.NewConfigParseService()
|
parseService := service.NewConfigParseService()
|
||||||
permService := service.NewPermissionService()
|
permService := service.NewPermissionService()
|
||||||
|
|
||||||
// init handlers
|
// init handlers
|
||||||
authHandler := handler.NewAuthHandler(authService)
|
authHandler := handler.NewAuthHandler(authService)
|
||||||
indexHandler := handler.NewIndexHandler()
|
indexHandler := handler.NewIndexHandler()
|
||||||
configHandler := handler.NewConfigHandler(cryptoService, parseService, permService)
|
configHandler := handler.NewConfigHandler(parseService, permService)
|
||||||
configApiHandler := handler.NewConfigApiHandler(cryptoService, parseService)
|
configApiHandler := handler.NewConfigApiHandler(parseService)
|
||||||
appHandler := handler.NewAppHandler()
|
appHandler := handler.NewAppHandler()
|
||||||
userHandler := handler.NewUserHandler(permService)
|
userHandler := handler.NewUserHandler(permService)
|
||||||
groupHandler := handler.NewGroupHandler()
|
groupHandler := handler.NewGroupHandler()
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"kenaito-conf/server/internal/dao"
|
||||||
"kenaito-conf/server/internal/middleware"
|
"kenaito-conf/server/internal/middleware"
|
||||||
"kenaito-conf/server/internal/model"
|
"kenaito-conf/server/internal/model"
|
||||||
"kenaito-conf/server/internal/service"
|
"kenaito-conf/server/internal/service"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -51,5 +54,11 @@ func (h *AuthHandler) GetLoginUserInfo(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||||
|
userInfo := middleware.GetUserInfo(c)
|
||||||
|
if userInfo != nil {
|
||||||
|
userId, _ := strconv.Atoi(userInfo.Id)
|
||||||
|
now := time.Now()
|
||||||
|
dao.Engine.ID(userId).Cols("last_logout_at").Update(&model.User{LastLogoutAt: &now})
|
||||||
|
}
|
||||||
c.JSON(http.StatusOK, model.WrapperResponse{Code: 0})
|
c.JSON(http.StatusOK, model.WrapperResponse{Code: 0})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,13 +13,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ConfigHandler struct {
|
type ConfigHandler struct {
|
||||||
cryptoService *service.CryptoService
|
|
||||||
parseService *service.ConfigParseService
|
parseService *service.ConfigParseService
|
||||||
permService *service.PermissionService
|
permService *service.PermissionService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewConfigHandler(crypto *service.CryptoService, parse *service.ConfigParseService, perm *service.PermissionService) *ConfigHandler {
|
func NewConfigHandler(parse *service.ConfigParseService, perm *service.PermissionService) *ConfigHandler {
|
||||||
return &ConfigHandler{cryptoService: crypto, parseService: parse, permService: perm}
|
return &ConfigHandler{parseService: parse, permService: perm}
|
||||||
}
|
}
|
||||||
|
|
||||||
type ConfigListRequest struct {
|
type ConfigListRequest struct {
|
||||||
@@ -162,9 +161,6 @@ func (h *ConfigHandler) Add(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// encrypt sensitive values
|
|
||||||
h.encryptIfRequired(&config)
|
|
||||||
|
|
||||||
if _, err := dao.Engine.Insert(&config); err != nil {
|
if _, err := dao.Engine.Insert(&config); err != nil {
|
||||||
c.JSON(http.StatusOK, model.WrapperResponse{Code: 500, Msg: "保存失败"})
|
c.JSON(http.StatusOK, model.WrapperResponse{Code: 500, Msg: "保存失败"})
|
||||||
return
|
return
|
||||||
@@ -208,8 +204,6 @@ func (h *ConfigHandler) Update(c *gin.Context) {
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
config.UpdatedAt = &now
|
config.UpdatedAt = &now
|
||||||
|
|
||||||
h.encryptIfRequired(&config)
|
|
||||||
|
|
||||||
dao.Engine.ID(config.Id).Cols("contents", "version", "updated_by", "updated_at").Update(&config)
|
dao.Engine.ID(config.Id).Cols("contents", "version", "updated_by", "updated_at").Update(&config)
|
||||||
|
|
||||||
// TODO: publish config change event (ZK removed, could use webhook or other mechanism)
|
// TODO: publish config change event (ZK removed, could use webhook or other mechanism)
|
||||||
@@ -312,9 +306,3 @@ func (h *ConfigHandler) saveHistory(config *model.AppConfig, operator string) {
|
|||||||
}
|
}
|
||||||
dao.Engine.Insert(&history)
|
dao.Engine.Insert(&history)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *ConfigHandler) encryptIfRequired(config *model.AppConfig) {
|
|
||||||
// simplified: check if contents contain sensitive keys
|
|
||||||
// In production, parse the config and encrypt values with {Cipher} prefix
|
|
||||||
// For now, skip encryption logic
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,12 +13,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ConfigApiHandler struct {
|
type ConfigApiHandler struct {
|
||||||
cryptoService *service.CryptoService
|
|
||||||
parseService *service.ConfigParseService
|
parseService *service.ConfigParseService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewConfigApiHandler(crypto *service.CryptoService, parse *service.ConfigParseService) *ConfigApiHandler {
|
func NewConfigApiHandler(parse *service.ConfigParseService) *ConfigApiHandler {
|
||||||
return &ConfigApiHandler{cryptoService: crypto, parseService: parse}
|
return &ConfigApiHandler{parseService: parse}
|
||||||
}
|
}
|
||||||
|
|
||||||
type FetchAllRequest struct {
|
type FetchAllRequest struct {
|
||||||
@@ -104,11 +103,6 @@ func (h *ConfigApiHandler) FetchAllConfigs(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// add encryption keys
|
|
||||||
globalCryptKey := h.cryptoService.GetCryptKey(0, req.Env)
|
|
||||||
result["kenaito.config.global-encrypt-secret"] = globalCryptKey
|
|
||||||
appCryptKey := h.cryptoService.GetCryptKey(app.Id, req.Env)
|
|
||||||
result["kenaito.config.encrypt-secret"] = appCryptKey
|
|
||||||
result["latest.fetch.timestamp"] = strconv.FormatInt(time.Now().UnixMilli(), 10)
|
result["latest.fetch.timestamp"] = strconv.FormatInt(time.Now().UnixMilli(), 10)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, result)
|
c.JSON(http.StatusOK, result)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"kenaito-conf/server/internal/model"
|
"kenaito-conf/server/internal/model"
|
||||||
"kenaito-conf/server/internal/service"
|
"kenaito-conf/server/internal/service"
|
||||||
"kenaito-conf/server/internal/util"
|
"kenaito-conf/server/internal/util"
|
||||||
"math/rand"
|
"crypto/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
@@ -341,8 +341,9 @@ func (h *UserHandler) Options(c *gin.Context) {
|
|||||||
func randomString(n int) string {
|
func randomString(n int) string {
|
||||||
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
b := make([]byte, n)
|
b := make([]byte, n)
|
||||||
|
_, _ = rand.Read(b)
|
||||||
for i := range b {
|
for i := range b {
|
||||||
b[i] = letters[rand.Intn(len(letters))]
|
b[i] = letters[int(b[i])%len(letters)]
|
||||||
}
|
}
|
||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,18 +3,19 @@ package model
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
Id int `xorm:"int autoincr pk" json:"id"`
|
Id int `xorm:"int autoincr pk" json:"id"`
|
||||||
GroupId *int `xorm:"int" json:"groupId"`
|
GroupId *int `xorm:"int" json:"groupId"`
|
||||||
Name string `xorm:"varchar(16)" json:"name"`
|
Name string `xorm:"varchar(16)" json:"name"`
|
||||||
Password string `xorm:"varchar(64)" json:"-"`
|
Password string `xorm:"varchar(64)" json:"-"`
|
||||||
Mobile string `xorm:"char(11)" json:"mobile"`
|
Mobile string `xorm:"char(11)" json:"mobile"`
|
||||||
Email string `xorm:"varchar(32)" json:"email"`
|
Email string `xorm:"varchar(32)" json:"email"`
|
||||||
Type string `xorm:"varchar(16) default 'user'" json:"type"` // user, groupAdmin, superAdmin
|
Type string `xorm:"varchar(16) default 'user'" json:"type"` // user, groupAdmin, superAdmin
|
||||||
Enabled bool `xorm:"tinyint(1) default 1" json:"enabled"`
|
Enabled bool `xorm:"tinyint(1) default 1" json:"enabled"`
|
||||||
CreatedAt *time.Time `xorm:"datetime" json:"createdAt"`
|
CreatedAt *time.Time `xorm:"datetime" json:"createdAt"`
|
||||||
UpdatedAt *time.Time `xorm:"datetime" json:"updatedAt"`
|
UpdatedAt *time.Time `xorm:"datetime" json:"updatedAt"`
|
||||||
GroupName string `xorm:"-" json:"groupName,omitempty"`
|
LastLogoutAt *time.Time `xorm:"datetime" json:"-"`
|
||||||
GroupMaster bool `xorm:"-" json:"groupMaster,omitempty"`
|
GroupName string `xorm:"-" json:"groupName,omitempty"`
|
||||||
|
GroupMaster bool `xorm:"-" json:"groupMaster,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserPermission struct {
|
type UserPermission struct {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"kenaito-conf/server/internal/dao"
|
"kenaito-conf/server/internal/dao"
|
||||||
"kenaito-conf/server/internal/model"
|
"kenaito-conf/server/internal/model"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -47,12 +48,14 @@ func (s *AuthService) Login(userName, password string) (string, *model.LoginUser
|
|||||||
s.loadPermissions(userInfo, user.Id)
|
s.loadPermissions(userInfo, user.Id)
|
||||||
|
|
||||||
// generate JWT
|
// generate JWT
|
||||||
|
now := time.Now()
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
|
||||||
"userId": user.Id,
|
"userId": user.Id,
|
||||||
"name": user.Name,
|
"name": user.Name,
|
||||||
"type": user.Type,
|
"type": user.Type,
|
||||||
"groupId": user.GroupId,
|
"groupId": user.GroupId,
|
||||||
"exp": time.Now().Add(time.Duration(s.expireHrs) * time.Hour).Unix(),
|
"iat": now.Unix(),
|
||||||
|
"exp": now.Add(time.Duration(s.expireHrs) * time.Hour).Unix(),
|
||||||
})
|
})
|
||||||
tokenString, err := token.SignedString([]byte(s.jwtSecret))
|
tokenString, err := token.SignedString([]byte(s.jwtSecret))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -64,6 +67,9 @@ func (s *AuthService) Login(userName, password string) (string, *model.LoginUser
|
|||||||
|
|
||||||
func (s *AuthService) ValidateToken(tokenString string) (*model.LoginUserInfo, error) {
|
func (s *AuthService) ValidateToken(tokenString string) (*model.LoginUserInfo, error) {
|
||||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||||
|
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||||
|
}
|
||||||
return []byte(s.jwtSecret), nil
|
return []byte(s.jwtSecret), nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -82,6 +88,19 @@ func (s *AuthService) ValidateToken(tokenString string) (*model.LoginUserInfo, e
|
|||||||
return nil, errors.New("user not found")
|
return nil, errors.New("user not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查用户是否已被禁用
|
||||||
|
if !user.Enabled {
|
||||||
|
return nil, errors.New("user is disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查 token 是否在登出后签发
|
||||||
|
if user.LastLogoutAt != nil {
|
||||||
|
iat := int64(claims["iat"].(float64))
|
||||||
|
if iat <= user.LastLogoutAt.Unix() {
|
||||||
|
return nil, errors.New("token is invalid after logout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
userInfo := &model.LoginUserInfo{
|
userInfo := &model.LoginUserInfo{
|
||||||
Id: strconv.Itoa(user.Id),
|
Id: strconv.Itoa(user.Id),
|
||||||
Name: user.Name,
|
Name: user.Name,
|
||||||
|
|||||||
@@ -133,6 +133,8 @@ func (s *ConfigParseService) resolvePlaceholders(result map[string]interface{})
|
|||||||
func (s *ConfigParseService) replacePlaceholder(data map[string]interface{}, value string) string {
|
func (s *ConfigParseService) replacePlaceholder(data map[string]interface{}, value string) string {
|
||||||
result := value
|
result := value
|
||||||
maxIterations := 100
|
maxIterations := 100
|
||||||
|
processedPlaceholders := make(map[string]bool)
|
||||||
|
|
||||||
for i := 0; i < maxIterations; i++ {
|
for i := 0; i < maxIterations; i++ {
|
||||||
start := strings.Index(result, "${")
|
start := strings.Index(result, "${")
|
||||||
if start < 0 {
|
if start < 0 {
|
||||||
@@ -154,6 +156,12 @@ func (s *ConfigParseService) replacePlaceholder(data map[string]interface{}, val
|
|||||||
defaultVal = placeholder[idx+1:]
|
defaultVal = placeholder[idx+1:]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检测循环引用
|
||||||
|
if processedPlaceholders[refKey] {
|
||||||
|
// 已经处理过这个占位符,说明存在循环引用,保留原样
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
var replacement string
|
var replacement string
|
||||||
if v, ok := data[refKey]; ok {
|
if v, ok := data[refKey]; ok {
|
||||||
replacement = fmt.Sprintf("%v", v)
|
replacement = fmt.Sprintf("%v", v)
|
||||||
@@ -163,6 +171,9 @@ func (s *ConfigParseService) replacePlaceholder(data map[string]interface{}, val
|
|||||||
replacement = "${" + placeholder + "}"
|
replacement = "${" + placeholder + "}"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 标记已处理
|
||||||
|
processedPlaceholders[refKey] = true
|
||||||
|
|
||||||
result = result[:start] + replacement + result[end+1:]
|
result = result[:start] + replacement + result[end+1:]
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/aes"
|
|
||||||
"crypto/cipher"
|
|
||||||
"crypto/md5"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"kenaito-conf/server/internal/config"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
const CryptPrefix = "{Cipher}"
|
|
||||||
|
|
||||||
type CryptoService struct{}
|
|
||||||
|
|
||||||
func NewCryptoService() *CryptoService {
|
|
||||||
return &CryptoService{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CryptoService) GetCryptKey(appId int, env string) string {
|
|
||||||
base := config.Cfg.Crypto.SecretKey + fmt.Sprintf("%x", appId) + env
|
|
||||||
h := md5.Sum([]byte(base))
|
|
||||||
return hex.EncodeToString(h[:])
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CryptoService) Encrypt(appId int, env string, data string) string {
|
|
||||||
key := s.GetCryptKey(appId, env)
|
|
||||||
data = strings.ReplaceAll(data, CryptPrefix, "")
|
|
||||||
encrypted := encryptWithAES(key, data)
|
|
||||||
return CryptPrefix + encrypted
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CryptoService) IsEncrypted(appId int, env string, data string) bool {
|
|
||||||
if !strings.HasPrefix(data, CryptPrefix) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
key := s.GetCryptKey(appId, env)
|
|
||||||
_, err := decryptWithAES(key, strings.TrimPrefix(data, CryptPrefix))
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *CryptoService) Decrypt(secret string, data string) (string, error) {
|
|
||||||
return decryptWithAES(secret, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func encryptWithAES(key string, data string) string {
|
|
||||||
secretKey := md5.Sum([]byte(key))
|
|
||||||
aesKey := hex.EncodeToString(secretKey[:])[16:]
|
|
||||||
|
|
||||||
block, err := aes.NewCipher([]byte(aesKey))
|
|
||||||
if err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// ECB mode (compatible with Java version)
|
|
||||||
plainText := pkcs7Pad([]byte(data), aes.BlockSize)
|
|
||||||
encrypted := make([]byte, len(plainText))
|
|
||||||
mode := cipher.NewCBCEncrypter(block, make([]byte, aes.BlockSize))
|
|
||||||
mode.CryptBlocks(encrypted, plainText)
|
|
||||||
|
|
||||||
return base64.StdEncoding.EncodeToString(encrypted)
|
|
||||||
}
|
|
||||||
|
|
||||||
func decryptWithAES(key string, data string) (string, error) {
|
|
||||||
secretKey := md5.Sum([]byte(key))
|
|
||||||
aesKey := hex.EncodeToString(secretKey[:])[16:]
|
|
||||||
|
|
||||||
cipherText, err := base64.StdEncoding.DecodeString(data)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
block, err := aes.NewCipher([]byte(aesKey))
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(cipherText)%aes.BlockSize != 0 {
|
|
||||||
return "", fmt.Errorf("ciphertext is not a multiple of block size")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ECB mode (compatible with Java version)
|
|
||||||
plainText := make([]byte, len(cipherText))
|
|
||||||
mode := cipher.NewCBCDecrypter(block, make([]byte, aes.BlockSize))
|
|
||||||
mode.CryptBlocks(plainText, cipherText)
|
|
||||||
|
|
||||||
plainText, err = pkcs7Unpad(plainText, aes.BlockSize)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return string(plainText), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func pkcs7Pad(data []byte, blockSize int) []byte {
|
|
||||||
padding := blockSize - len(data)%blockSize
|
|
||||||
padText := make([]byte, padding)
|
|
||||||
for i := range padText {
|
|
||||||
padText[i] = byte(padding)
|
|
||||||
}
|
|
||||||
return append(data, padText...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func pkcs7Unpad(data []byte, blockSize int) ([]byte, error) {
|
|
||||||
if len(data) == 0 {
|
|
||||||
return nil, fmt.Errorf("empty data")
|
|
||||||
}
|
|
||||||
padding := int(data[len(data)-1])
|
|
||||||
if padding > blockSize || padding == 0 {
|
|
||||||
return nil, fmt.Errorf("invalid padding")
|
|
||||||
}
|
|
||||||
return data[:len(data)-padding], nil
|
|
||||||
}
|
|
||||||
@@ -255,9 +255,9 @@ export default {
|
|||||||
const res = await getConfig(row.originId)
|
const res = await getConfig(row.originId)
|
||||||
if (res && res.data && res.data.contents) {
|
if (res && res.data && res.data.contents) {
|
||||||
const currentRaw = res.data.type === 3 ? (() => { try { return JSON.stringify(JSON.parse(res.data.contents), null, 2) } catch (e) { return res.data.contents } })() : res.data.contents
|
const currentRaw = res.data.type === 3 ? (() => { try { return JSON.stringify(JSON.parse(res.data.contents), null, 2) } catch (e) { return res.data.contents } })() : res.data.contents
|
||||||
const result = computeDiff(currentRaw, formatted)
|
const result = computeDiff(formatted, currentRaw)
|
||||||
this.leftLines = result.left
|
this.leftLines = result.right
|
||||||
this.rightLines = result.right
|
this.rightLines = result.left
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.rightLines = formatted.split('\n').map((line, i) => ({ lineNum: i + 1, text: line, type: 'unchanged' }))
|
this.rightLines = formatted.split('\n').map((line, i) => ({ lineNum: i + 1, text: line, type: 'unchanged' }))
|
||||||
|
|||||||
Reference in New Issue
Block a user