코드탭 로그에서 드러난 대화 저장소 복호화 오탐을 수정한다
- CryptoService 평문 JSON 판별을 UTF-8 디코드 성공과 BOM/공백 제거 후 JSON 시작 문자 확인으로 강화 - 암호문 선행 바이트가 '[' 또는 0xEF인 경우와 UTF-8 BOM 평문 대화 파일 복원 회귀 테스트 추가 - README와 DEVELOPMENT 문서에 2026-04-15 19:12 KST 기준 로그 원인 및 검증 결과 기록 검증: - dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_chat_storage_fix\\ -p:IntermediateOutputPath=obj\\verify_chat_storage_fix\\ - dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter ChatStorageServiceTests -p:OutputPath=bin\\verify_chat_storage_fix_tests\\ -p:IntermediateOutputPath=obj\\verify_chat_storage_fix_tests\
This commit is contained in:
@@ -9,15 +9,42 @@ namespace AxCopilot.Tests.Services;
|
||||
|
||||
public class ChatStorageServiceTests
|
||||
{
|
||||
private static string GetConversationPath(string conversationId) => Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"AxCopilot",
|
||||
"conversations",
|
||||
conversationId + ".axchat");
|
||||
|
||||
private static void DeleteConversationFile(string conversationId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var storage = new ChatStorageService();
|
||||
storage.Delete(conversationId);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] CreateEncryptedPayloadWithLeadingByte(string content, byte leadingByte)
|
||||
{
|
||||
var plainBytes = System.Text.Encoding.UTF8.GetBytes(content);
|
||||
for (int attempt = 0; attempt < 10_000; attempt++)
|
||||
{
|
||||
var encrypted = CryptoService.EncryptBytes(plainBytes);
|
||||
if (encrypted.Length > 0 && encrypted[0] == leadingByte)
|
||||
return encrypted;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"암호화 결과 선행 바이트 0x{leadingByte:X2}를 찾지 못했습니다.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Load_ShouldRestoreMissingToolResultPreviewFromLegacyStoredConversation()
|
||||
{
|
||||
var conversationId = "legacy-preview-" + Guid.NewGuid().ToString("N");
|
||||
var storagePath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"AxCopilot",
|
||||
"conversations",
|
||||
conversationId + ".axchat");
|
||||
var storagePath = GetConversationPath(conversationId);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(storagePath)!);
|
||||
|
||||
var conversation = new ChatConversation
|
||||
@@ -52,14 +79,98 @@ public class ChatStorageServiceTests
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
DeleteConversationFile(conversationId);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData((byte)'[')]
|
||||
[InlineData((byte)0xEF)]
|
||||
public void LoadAndLoadAllMeta_ShouldHandleEncryptedConversation_WhenCiphertextLooksLikePlainJson(byte leadingByte)
|
||||
{
|
||||
var conversationId = $"cipher-leading-{leadingByte:X2}-" + Guid.NewGuid().ToString("N");
|
||||
var storagePath = GetConversationPath(conversationId);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(storagePath)!);
|
||||
|
||||
var conversation = new ChatConversation
|
||||
{
|
||||
Id = conversationId,
|
||||
Title = "Cipher prefix regression",
|
||||
Messages = new()
|
||||
{
|
||||
var storage = new ChatStorageService();
|
||||
storage.Delete(conversationId);
|
||||
new ChatMessage
|
||||
{
|
||||
MsgId = "user-1",
|
||||
Role = "user",
|
||||
Content = "recover me"
|
||||
}
|
||||
}
|
||||
catch
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(conversation);
|
||||
var encrypted = CreateEncryptedPayloadWithLeadingByte(json, leadingByte);
|
||||
File.WriteAllBytes(storagePath, encrypted);
|
||||
|
||||
try
|
||||
{
|
||||
var storage = new ChatStorageService();
|
||||
|
||||
var loaded = storage.Load(conversationId);
|
||||
var metas = storage.LoadAllMeta();
|
||||
|
||||
loaded.Should().NotBeNull();
|
||||
loaded!.Id.Should().Be(conversationId);
|
||||
loaded.Title.Should().Be("Cipher prefix regression");
|
||||
metas.Should().Contain(c => c.Id == conversationId && c.Title == "Cipher prefix regression");
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteConversationFile(conversationId);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadAndLoadAllMeta_ShouldHandleLegacyPlainJsonWithUtf8Bom()
|
||||
{
|
||||
var conversationId = "legacy-bom-" + Guid.NewGuid().ToString("N");
|
||||
var storagePath = GetConversationPath(conversationId);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(storagePath)!);
|
||||
|
||||
var conversation = new ChatConversation
|
||||
{
|
||||
Id = conversationId,
|
||||
Title = "Legacy BOM conversation",
|
||||
Messages = new()
|
||||
{
|
||||
new ChatMessage
|
||||
{
|
||||
MsgId = "user-1",
|
||||
Role = "user",
|
||||
Content = "plain json with bom"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(conversation);
|
||||
var bom = new System.Text.UTF8Encoding(encoderShouldEmitUTF8Identifier: true).GetPreamble();
|
||||
var payload = bom.Concat(System.Text.Encoding.UTF8.GetBytes(json)).ToArray();
|
||||
File.WriteAllBytes(storagePath, payload);
|
||||
|
||||
try
|
||||
{
|
||||
var storage = new ChatStorageService();
|
||||
|
||||
var loaded = storage.Load(conversationId);
|
||||
var metas = storage.LoadAllMeta();
|
||||
|
||||
loaded.Should().NotBeNull();
|
||||
loaded!.Id.Should().Be(conversationId);
|
||||
loaded.Title.Should().Be("Legacy BOM conversation");
|
||||
metas.Should().Contain(c => c.Id == conversationId && c.Title == "Legacy BOM conversation");
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteConversationFile(conversationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ namespace AxCopilot.Services;
|
||||
/// </summary>
|
||||
public static class CryptoService
|
||||
{
|
||||
private static readonly UTF8Encoding StrictUtf8 = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// 1. 앱 공용 키 — 설정값 암호화 (Portable, 모든 PC에서 동일)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
@@ -201,6 +203,41 @@ public static class CryptoService
|
||||
File.WriteAllBytes(filePath, enc);
|
||||
}
|
||||
|
||||
private static bool TryNormalizePlainJson(string? text, out string json)
|
||||
{
|
||||
json = "";
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return false;
|
||||
|
||||
var normalized = text.TrimStart('\uFEFF', ' ', '\t', '\r', '\n');
|
||||
if (string.IsNullOrEmpty(normalized))
|
||||
return false;
|
||||
|
||||
var first = normalized[0];
|
||||
if (first != '{' && first != '[')
|
||||
return false;
|
||||
|
||||
json = normalized;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryDecodePlainJson(byte[] raw, out string json)
|
||||
{
|
||||
json = "";
|
||||
if (raw.Length == 0)
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var text = StrictUtf8.GetString(raw);
|
||||
return TryNormalizePlainJson(text, out json);
|
||||
}
|
||||
catch (DecoderFallbackException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>PC별 키로 AES-256-GCM 암호화 파일을 복호화. 평문 JSON 파일은 그대로 반환.</summary>
|
||||
public static string DecryptFromFile(string filePath)
|
||||
{
|
||||
@@ -208,34 +245,23 @@ public static class CryptoService
|
||||
var raw = File.ReadAllBytes(filePath);
|
||||
if (raw.Length == 0) return "";
|
||||
|
||||
// 평문 JSON 파일 감지: UTF-8 텍스트가 '{' 또는 '[' 로 시작하면 암호화되지 않은 것으로 간주
|
||||
if (raw.Length > 0 && (raw[0] == (byte)'{' || raw[0] == (byte)'[' || raw[0] == 0xEF /* BOM */))
|
||||
{
|
||||
try
|
||||
{
|
||||
return Encoding.UTF8.GetString(raw);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// BOM이었지만 유효한 UTF-8이 아닌 경우 → 암호화된 데이터로 처리
|
||||
}
|
||||
}
|
||||
// 평문 JSON은 실제 UTF-8 + JSON 시작 문자까지 확인한 경우에만 인정합니다.
|
||||
// 암호화 바이너리가 우연히 '[' 또는 0xEF로 시작해도 평문으로 오인하지 않도록 합니다.
|
||||
if (TryDecodePlainJson(raw, out var plainJson))
|
||||
return plainJson;
|
||||
|
||||
try
|
||||
{
|
||||
var plain = DecryptBytes(raw);
|
||||
return Encoding.UTF8.GetString(plain);
|
||||
var text = Encoding.UTF8.GetString(plain);
|
||||
return TryNormalizePlainJson(text, out var normalized) ? normalized : text;
|
||||
}
|
||||
catch (System.Security.Cryptography.CryptographicException)
|
||||
{
|
||||
// 복호화 실패 시 평문 텍스트로 한 번 더 시도 (마이그레이션/손상 대응)
|
||||
try
|
||||
{
|
||||
var text = Encoding.UTF8.GetString(raw);
|
||||
if (text.Contains('"') && (text.TrimStart().StartsWith('{') || text.TrimStart().StartsWith('[')))
|
||||
return text;
|
||||
}
|
||||
catch { /* 무시 */ }
|
||||
if (TryDecodePlainJson(raw, out plainJson))
|
||||
return plainJson;
|
||||
|
||||
throw; // 평문도 아니면 원래 예외 재전파
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user