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

- 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

@@ -2180,3 +2180,11 @@ MIT License
- 검증:
- `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_internal_llm_scope\\ -p:IntermediateOutputPath=obj\\verify_internal_llm_scope\\` 경고 0 / 오류 0
- `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "LlmOperationModeTests" -p:OutputPath=bin\\verify_internal_llm_scope_tests\\ -p:IntermediateOutputPath=obj\\verify_internal_llm_scope_tests\\` 통과 5
업데이트: 2026-04-15 19:12 (KST)
- Code 탭 실행 로그를 분석해 대화 저장소 복호화 회귀를 수정했습니다. 2026-04-15 18:44 실행(`convId=08179304`) 자체는 완료됐고, 실제 경고는 직전 메타 로드/만료 정리 단계에서 `.axchat` 암호문이 우연히 `[`(`0x5B`) 또는 `0xEF`로 시작할 때 평문 JSON으로 잘못 판별되며 발생하고 있었습니다.
- `src/AxCopilot/Services/CryptoService.cs`는 평문 JSON 판별을 `실제 UTF-8 디코드 성공 + BOM/공백 제거 후 JSON 시작 문자 확인`으로 강화했습니다. 이제 암호화 바이너리가 우연히 JSON처럼 보이는 첫 바이트를 가져도 복호화 경로로 처리하고, 구버전 UTF-8 BOM 평문 대화 파일은 정상적으로 읽어들입니다.
- `src/AxCopilot.Tests/Services/ChatStorageServiceTests.cs`에 회귀 테스트를 추가했습니다. 암호문 선행 바이트가 `[` 또는 `0xEF`인 대화 파일과 UTF-8 BOM이 있는 평문 대화 파일이 모두 `Load()``LoadAllMeta()`에서 정상 복원되는지 검증합니다.
- 검증:
- `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_chat_storage_fix\\ -p:IntermediateOutputPath=obj\\verify_chat_storage_fix\\` 경고 0 / 오류 0
- `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\\` 통과 4

View File

@@ -1484,3 +1484,23 @@ UI ?遺우쁽????域뱀뮆???귐뗫솯?醫딆춦 ???袁る퓮 ?臾믩씜 ??疫
- `src/AxCopilot/Services/Agent/AgentProgressSummarySanitizer.cs``SkillRuntime`, `allowed_tools`, 메인 루프 요청, 읽기 도구 조기 실행 준비, 스트리밍 도구 감지 등 저신호 내부 문구를 추가로 필터링해 본문/라이브 카드에 내부성 로그가 다시 노출되지 않도록 보강했습니다.
- 검증: `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_live_restore\\ -p:IntermediateOutputPath=obj\\verify_live_restore\\` 경고 0 / 오류 0
- 검증: `dotnet test src/AxCopilot.Tests/AxCopilot.Tests.csproj -c Release -v minimal --filter "AgentLoopCodeQualityTests|AgentStatusNarrativeCatalogTests|AgentProgressSummarySanitizerTests" -p:OutputPath=bin\\verify_live_restore_tests\\ -p:IntermediateOutputPath=obj\\verify_live_restore_tests\\` 통과 131
업데이트: 2026-04-15 19:12 (KST)
### Code 탭 로그 분석: 대화 저장소 복호화 오탐 수정
- 2026-04-15 18:44 Code 실행(`convId=08179304`)은 메인 루프 11회 후 정상 종료됐고, 로그상 실제 경고는 별도의 `.axchat` 대화 파일 복호화 단계에서 반복되고 있었습니다.
- 문제 원인:
- `C:\Users\admin\AppData\Roaming\AxCopilot\conversations\0d65bb632d7b4fcea24b3d2cb0f900f0.axchat` 선행 바이트가 `0x5B`(`[`), `a0aa1206a20643959c710091e232d8be.axchat` 선행 바이트가 `0xEF`였습니다.
- 기존 `CryptoService.DecryptFromFile(...)`는 첫 바이트가 `{`, `[`, `0xEF` 중 하나면 평문 JSON으로 간주했기 때문에, 암호화 바이너리가 우연히 이 바이트들로 시작할 때 복호화하지 않고 `JsonSerializer.Deserialize<ChatConversation>(...)`로 바로 넘겨 `대화 메타 로드 실패`, `만료 대화 정리 실패` 경고가 반복됐습니다.
- 수정 내용:
- `src/AxCopilot/Services/CryptoService.cs`
- `StrictUtf8` 디코더를 추가해 평문 JSON 판별 전에 실제 UTF-8 디코드 성공 여부를 먼저 확인합니다.
- `TryNormalizePlainJson(...)`, `TryDecodePlainJson(...)` 헬퍼를 추가해 `UTF-8 BOM/공백 제거 후 첫 문자가 { 또는 [`인 경우만 평문 JSON으로 인정하도록 변경했습니다.
- 복호화 후에도 같은 정규화 경로를 사용해 구버전 UTF-8 BOM 평문 대화 파일이 그대로 복원되도록 보강했습니다.
- `src/AxCopilot.Tests/Services/ChatStorageServiceTests.cs`
- 암호문 선행 바이트가 `[` 또는 `0xEF`인 회귀 케이스를 생성해 `Load()``LoadAllMeta()`가 정상 복원되는지 검증합니다.
- UTF-8 BOM이 있는 레거시 평문 `.axchat`도 정상 복원되는지 검증합니다.
### 검증
- `dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\\verify_chat_storage_fix\\ -p:IntermediateOutputPath=obj\\verify_chat_storage_fix\\` 경고 0 / 오류 0
- `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\\` 통과 4

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