Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 142 additions & 0 deletions apps/api/scripts/reencrypt_pii.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
"""ENCRYPTION_KEY 불일치로 복호화 실패하는 환자 PII를 현재 키로 재암호화한다.

배경: 하나의 공유 DB에 서로 다른 ENCRYPTION_KEY로 쓰인 행이 섞이면(예: dev api와
DGX api가 다른 키로 같은 DB에 기록), 각 인스턴스는 자기 키로 쓴 행만 복호화할 수
있고 나머지는 AES-GCM `InvalidTag`(복호화 실패)가 난다. 의료진 대시보드에서 일부
환자 이름이 "복호화 실패"로 뜨는 원인.

이 스크립트는 **실행 환경의 ENCRYPTION_KEY(= 정규 키)**를 타깃으로:
1. 각 암호화 컬럼을 정규 키로 복호화 시도 → 성공하면 이미 정상, 건너뜀.
2. 실패하면 `LEGACY_ENCRYPTION_KEYS`(env, base64-urlsafe 32B, 쉼표구분)의 각 키로
복호화 시도 → 성공한 키로 평문을 얻어 **정규 키로 재암호화**해 UPDATE 대상에 담음.
3. 어떤 키로도 못 읽으면 unrecoverable로 보고(원본 키 소실 — 삭제/재생성만 가능).

대상: patient_profiles(name/phone/emergency_contact), messages(content).
키는 인자·git에 남기지 않는다 — 반드시 env로 주입한다.

# DGX api 컨테이너 안(정규 키 = 컨테이너 env)에서:
LEGACY_ENCRYPTION_KEYS="<이 데이터를 쓴 다른 키(base64)>" \
python -m scripts.reencrypt_pii # dry-run(보고만)
LEGACY_ENCRYPTION_KEYS="..." python -m scripts.reencrypt_pii --apply # 실제 UPDATE
"""

from __future__ import annotations

import asyncio
import base64
import os
import sys
import uuid

from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from sqlalchemy import select

from src.core.encryption import NONCE_BYTES, encrypt_bytes
from src.db import SessionLocal
from src.models.session import Message
from src.models.patient_profile import PatientProfile


def _profile_aad(user_id: uuid.UUID, column: str) -> bytes:
return f"patient_profiles.{column}:{user_id}".encode()


def _message_aad(session_id: uuid.UUID, message_id: uuid.UUID) -> bytes:
return f"messages.content:{session_id}:{message_id}".encode()


def _load_legacy_keys() -> list[bytes]:
raw = os.getenv("LEGACY_ENCRYPTION_KEYS", "").strip()
keys: list[bytes] = []
for tok in (t.strip() for t in raw.split(",") if t.strip()):
k = base64.urlsafe_b64decode(tok + "===")
if len(k) != 32:
raise SystemExit(f"legacy key decoded to {len(k)} bytes (need 32): {tok[:8]}…")
keys.append(k)
return keys


def _try_decrypt_legacy(blob: bytes, aad: bytes, legacy: list[bytes]) -> bytes | None:
"""레거시 키들로 복호화 시도. 성공하면 평문 bytes, 전부 실패하면 None."""
if len(blob) <= NONCE_BYTES:
return None
nonce, ct = blob[:NONCE_BYTES], blob[NONCE_BYTES:]
for k in legacy:
try:
return AESGCM(k).decrypt(nonce, ct, aad)
except Exception:
continue
return None


def _canonical_ok(blob: bytes, aad: bytes) -> bool:
"""정규 키(현재 settings)로 복호화되면 True."""
from src.core.encryption import decrypt_bytes

try:
decrypt_bytes(blob, aad=aad)
return True
except Exception:
return False


async def main(apply: bool) -> None:
legacy = _load_legacy_keys()
if not legacy:
print("LEGACY_ENCRYPTION_KEYS 가 비어 있습니다 — 재암호화할 원본 키를 env로 주입하세요.")
return
print(f"legacy 키 {len(legacy)}개 로드. mode={'APPLY' if apply else 'DRY-RUN'}")

converted = already = unrecoverable = 0
async with SessionLocal() as db:
# ── patient_profiles ──
profs = (await db.execute(select(PatientProfile))).scalars().all()
for p in profs:
for col in ("name", "phone", "emergency_contact"):
blob = getattr(p, f"{col}_encrypted")
if blob is None:
continue
aad = _profile_aad(p.user_id, col)
if _canonical_ok(blob, aad):
already += 1
continue
pt = _try_decrypt_legacy(blob, aad, legacy)
if pt is None:
unrecoverable += 1
print(f" UNRECOVERABLE profile {p.user_id} .{col}")
continue
if apply:
setattr(p, f"{col}_encrypted", encrypt_bytes(pt, aad=aad))
converted += 1

# ── messages.content ──
msgs = (await db.execute(select(Message))).scalars().all()
for m in msgs:
blob = m.content_encrypted
if blob is None:
continue
aad = _message_aad(m.session_id, m.id)
if _canonical_ok(blob, aad):
already += 1
continue
pt = _try_decrypt_legacy(blob, aad, legacy)
if pt is None:
unrecoverable += 1
continue
if apply:
m.content_encrypted = encrypt_bytes(pt, aad=aad)
converted += 1

if apply:
await db.commit()

print(
f"\n결과: 이미정상 {already} · 재암호화{'(적용)' if apply else '(예정)'} {converted} "
f"· 복구불가 {unrecoverable}"
)
if not apply and converted:
print("→ --apply 를 붙여 실제 UPDATE 하세요.")


if __name__ == "__main__":
asyncio.run(main("--apply" in sys.argv[1:]))
36 changes: 35 additions & 1 deletion apps/api/src/services/handoff.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,34 @@ async def _build_request(
_RISK_TO_CTRS = {"critical": 1, "high": 2, "medium": 3, "low": 4, "none": 5}


def _display_vp(patient_id: uuid.UUID) -> str:
"""리포트 표시용 환자 코드. 원본 DB UUID는 개발자 정보라 노출 금지 —
editorial 렌더의 `patient.id`·F4 차트 제목·FHIR `id`가 모두 vp_id를 그대로
쓰므로, 여기서 비가역 축약 코드(`VP-XXXXXX`)로 바꿔 UUID 누출을 차단한다.
템플릿의 VP-XXX 관례와도 맞아 build_report_json의 reportNo 파싱이 그대로 동작."""
return f"VP-{patient_id.hex[:6].upper()}"


async def _patient_display_name(
db: AsyncSession, patient_id: uuid.UUID
) -> str | None:
"""환자 실명(AES-256 복호화). F5 A0 헤더 `환자: {persona_name}`에 실려
editorial 렌더에서 `mask()`로 마스킹 표기된다(예: 김서연→김○연). 프로필/키가
없으면 None → F5는 '이름 없음'으로 우아하게 degrade(UUID 노출 없음)."""
row = await db.execute(
select(PatientProfile.name_encrypted).where(
PatientProfile.user_id == patient_id
)
)
enc = row.scalar_one_or_none()
if enc is None:
return None
try:
return decrypt_str(enc, aad=_profile_aad(patient_id, "name"))
except Exception:
return None


async def _build_longitudinal_sessions(
db: AsyncSession, patient_id: uuid.UUID
) -> list[LongitudinalSessionEntry]:
Expand All @@ -222,6 +250,10 @@ async def _build_longitudinal_sessions(
)
sessions = list(srows.scalars())

# 환자 표시 정체성(실명 마스킹용 + 비-UUID 코드) — F5 A0 헤더/차트/FHIR가 소비.
display_name = await _patient_display_name(db, patient_id)
display_vp = _display_vp(patient_id)

entries: list[LongitudinalSessionEntry] = []
idx = 0
for sess in sessions:
Expand Down Expand Up @@ -281,6 +313,8 @@ async def _build_longitudinal_sessions(
risk_assessment=risk_assessment,
f3=f3,
session_id=str(sess.id),
persona_name=display_name,
persona_id=display_vp,
)
)
idx += 1
Expand Down Expand Up @@ -313,7 +347,7 @@ async def generate_report_task(
if len(entries) >= 2:
# 사용자 고도화 F4+F5 풀 리포트 (결정론적, PDF/FHIR/차트 포함).
report_req = HandoffReportRequest(
vp_id=str(patient_id),
vp_id=_display_vp(patient_id),
sessions=entries,
domain_inference=DomainInferenceInput(),
include_charts=True,
Expand Down
27 changes: 21 additions & 6 deletions apps/mobile/app/(patient)/intake/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ export default function ChatScreen() {
const setRisk = useSession((s) => s.setRisk);
const setProgress = useSession((s) => s.setProgress);
const clearRisk = useSession((s) => s.clearRisk);
const pendingInjection = useSession((s) => s.pendingInjection);
const clearPendingInjection = useSession((s) => s.clearPendingInjection);

// 키보드 높이를 직접 측정해 입력창을 밀어올린다(KeyboardAvoidingView가 이
// 구성에서 불안정해 결정적 방식으로 대체). 컨테이너 하단 패딩 = 키보드 높이.
Expand Down Expand Up @@ -324,25 +326,38 @@ export default function ChatScreen() {
if (status !== "open") setAwaitingAi(false);
}, [status]);

const onSend = () => {
const content = draft.trim();
if (!content || !clientRef.current) return;
if (status !== "open") return;
// 낙관적 버블 + WS 전송 공통 경로. 성공 시 true. draft 지우기는 호출측 책임.
const sendContent = (content: string): boolean => {
if (!content || !clientRef.current || status !== "open") return false;
// Insert AFTER we confirm the socket is ready, to avoid orphan optimistic bubbles.
const idempotencyKey = Crypto.randomUUID();
const sent = clientRef.current.sendMessage({ content, idempotencyKey });
if (!sent) return;
if (!sent) return false;
addUserMessage({
id: idempotencyKey,
role: "user",
content,
sentAt: Date.now(),
});
setDraft("");
setMediumBanner(null);
setAwaitingAi(true); // AI 응답 대기 — "입력 중…" 표시
return true;
};

const onSend = () => {
if (sendContent(draft.trim())) setDraft("");
};

// FR-048 — OCR 확인([확인 완료])이 큐잉한 요약을 소켓 open 시 사용자 메시지로
// 흘려보낸다. F1이 인지·응답하고 conversation_history(→리포트)에 남는다.
// 소켓이 아직 닫혀 있으면 큐를 유지했다가 open되는 즉시 전송된다.
useEffect(() => {
if (!pendingInjection || status !== "open") return;
if (sendContent(pendingInjection)) clearPendingInjection();
// sendContent는 매 렌더 새로 만들어지므로 deps에서 제외(전송은 멱등키로 1회).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pendingInjection, status]);

const statusLabel = useMemo<string>(() => {
switch (status) {
case "connecting":
Expand Down
38 changes: 34 additions & 4 deletions apps/mobile/app/(patient)/intake/ocr-confirm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@
* v3 FR-048 — OCR 결과 확인 화면.
*
* 대화에서 첨부한 처방전/진단서를 업로드→인식하고, 추출 결과를 카드로 보여준다.
* 저신뢰 항목("확인 필요")은 빨간 배지로 표시한다. [확인 완료]를 눌러야 확정되며,
* 그 시점에만 리포트에 반영된다(현재는 확인 후 대화로 복귀 — 리포트 반영은 제출 시).
* 저신뢰 항목("확인 필요")은 빨간 배지로 표시한다. [확인 완료]를 누르면 확정 요약을
* 대화로 흘려보내(FR-048) F1이 인지·응답하고 conversation_history(→리포트)에 남는다.
*/

import { router, useLocalSearchParams } from "expo-router";
Expand All @@ -27,13 +27,36 @@ const DOC_LABEL: Record<string, string> = {
unknown: "문서",
};

/**
* 확정된 OCR 요약을 대화용 한국어 한 문장으로 조립한다(FR-048). 사용자 말풍선으로
* 전송돼 F1이 복용약/진단을 인지하고 후속 질문에 활용한다. 추출값이 전혀 없으면
* null → 주입을 건너뛴다(빈 첨부 안내 방지).
*/
function buildInjection(r: OCRResult): string | null {
const s = r.extractedSummary;
const docLabel = DOC_LABEL[r.documentType] ?? "문서";
const parts: string[] = [];
if (s.diagnoses.length > 0) parts.push(`진단은 ${s.diagnoses.join(", ")}`);
if (s.medications.length > 0) {
const meds = s.medications
.map((m) => [m.name, m.dose, m.frequency].filter(Boolean).join(" "))
.filter(Boolean)
.join(", ");
if (meds) parts.push(`처방약은 ${meds}`);
}
if (s.department) parts.push(`진료과는 ${s.department}`);
if (parts.length === 0) return null;
return `${docLabel}을 첨부했어요. ${parts.join(", ")}예요.`;
}

type Phase = "loading" | "ready" | "error";

export default function OcrConfirmScreen() {
const insets = useSafeAreaInsets();
const { uri, name, mime } = useLocalSearchParams<{ uri: string; name: string; mime: string }>();
const accessToken = useAuth((s) => s.accessToken);
const sessionId = useSession((s) => s.sessionId);
const setPendingInjection = useSession((s) => s.setPendingInjection);

const [phase, setPhase] = useState<Phase>("loading");
const [result, setResult] = useState<OCRResult | null>(null);
Expand Down Expand Up @@ -155,12 +178,19 @@ export default function OcrConfirmScreen() {
) : null}

<Text style={styles.fine}>
확인한 내용만 사전 문진 리포트에 참고 자료로 반영돼요.
확인한 내용은 대화에 반영되고 사전 문진 리포트에 참고 자료로 담겨요.
</Text>
</ScrollView>

<View style={[styles.footer, { paddingBottom: Math.max(insets.bottom, 16) }]}>
<Button label="확인 완료" onPress={() => router.back()} />
<Button
label="확인 완료"
onPress={() => {
const summary = buildInjection(result);
if (summary) setPendingInjection(summary);
router.back();
}}
/>
</View>
</>
) : null}
Expand Down
17 changes: 15 additions & 2 deletions apps/mobile/state/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ export type SessionState = {
lastRisk: RiskEvent | null;
/** Intake completeness 0..1 (FR-004), reported by the AI via ai:complete. */
progress: number;
/**
* FR-048 — OCR 확인 화면([확인 완료])이 대화로 흘려보낼 요약 텍스트.
* ocr-confirm 화면은 WS 클라이언트가 없어 직접 전송할 수 없으므로, 여기에
* 큐잉하고 chat 화면이 소켓 open 시점에 사용자 메시지로 전송한다(전송 후 clear).
*/
pendingInjection: string | null;

start: (sessionId: string) => void;
addUserMessage: (msg: LocalMessage) => void;
Expand All @@ -43,6 +49,8 @@ export type SessionState = {
setRisk: (risk: RiskEvent) => void;
clearRisk: () => void;
setProgress: (ratio: number) => void;
setPendingInjection: (text: string) => void;
clearPendingInjection: () => void;
reset: () => void;
};

Expand All @@ -51,8 +59,10 @@ export const useSession = create<SessionState>((set) => ({
messages: [],
lastRisk: null,
progress: 0,
pendingInjection: null,

start: (sessionId) => set({ sessionId, messages: [], lastRisk: null, progress: 0 }),
start: (sessionId) =>
set({ sessionId, messages: [], lastRisk: null, progress: 0, pendingInjection: null }),
addUserMessage: (msg) => set((s) => ({ messages: [...s.messages, msg] })),
addAiMessage: (msg) =>
set((s) =>
Expand All @@ -71,5 +81,8 @@ export const useSession = create<SessionState>((set) => ({
clearRisk: () => set({ lastRisk: null }),
// Progress is monotonic — never let a late/replayed frame walk it backwards.
setProgress: (ratio) => set((s) => ({ progress: Math.max(s.progress, ratio) })),
reset: () => set({ sessionId: null, messages: [], lastRisk: null, progress: 0 }),
setPendingInjection: (text) => set({ pendingInjection: text }),
clearPendingInjection: () => set({ pendingInjection: null }),
reset: () =>
set({ sessionId: null, messages: [], lastRisk: null, progress: 0, pendingInjection: null }),
}));
8 changes: 6 additions & 2 deletions apps/web/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,12 @@ export default function RootLayout({
children: React.ReactNode;
}) {
return (
<html lang="ko">
<body>{children}</body>
// suppressHydrationWarning: 브라우저 확장(번역기·Grammarly·비밀번호 관리자 등)이
// 하이드레이션 전에 <html>/<body>에 속성(data-*, cz-shortcut-listen 등)을 주입해
// 발생하는 최상위 노드 미스매치를 억제한다(Next.js 공식 권장). 속성 1레벨만 억제하며
// 하위 트리 내용 미스매치는 그대로 보고된다.
<html lang="ko" suppressHydrationWarning>
<body suppressHydrationWarning>{children}</body>
</html>
);
}
Loading
Loading