코드탭 로그에서 드러난 대화 저장소 복호화 오탐을 수정한다

- 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:
2026-04-15 19:05:21 +09:00
parent 5ab04bc53e
commit 913b42b2f3
4 changed files with 194 additions and 29 deletions

View File

@@ -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);
}
}
}

View File

@@ -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; // 평문도 아니면 원래 예외 재전파
}
}