diff --git a/app/api/me/e2ee/route.js b/app/api/me/e2ee/route.js new file mode 100644 index 00000000..2c067767 --- /dev/null +++ b/app/api/me/e2ee/route.js @@ -0,0 +1,181 @@ +// E2EE(종단간 암호화) 관리 — 계정 스코프. 설계 정본: 루트 E2EE-DESIGN.md. +// 원칙: 서버(이 라우트 포함)는 DEK 평문을 절대 만지지 않는다 — 여기서 다루는 것은 공개키·랩(암호문)· +// 지문뿐이고, DEK 생성·개봉은 전부 로컬 프로세스(src/e2ee.mjs)에서 일어난다. +// 인증: 기기 세션의 사용자 스코프 클라이언트(RLS own) — me/billing과 같은 패턴. 서비스키 불요. +import { randomBytes } from 'node:crypto'; +import { createClient } from '@supabase/supabase-js'; +import { currentUser } from '../../../auth.mjs'; +import { getFreshDeviceSession } from '../../../../src/devicesession.mjs'; +import { getDeviceId } from '../../../../src/workspace.mjs'; +import { + loadDeviceE2ee, dek, setDek, wrapDekFor, openDekWrap, pubFingerprint, + generateRecoveryCode, deriveRecoveryKek, RECOVERY_KDF, wrapDekWithKek, openDekWithKek, + tryClaimDek, _resetClaimForTest, +} from '../../../../src/e2ee.mjs'; +import { markResealAll, nudgeSync } from '../../../../src/sync.mjs'; +import { proRowActive, TRIAL_DAYS } from '../../../../src/entitlement.mjs'; + +export const maxDuration = 60; + +/** 기기 세션 기반 사용자 스코프 클라이언트 — 없으면 null(E2EE는 로그인-연동 기기 전용). */ +async function userSb() { + if (process.env.ARGO_TENANT_OWNER?.trim()) return null; // 워커는 E2EE 관리 주체가 아니다(위임은 별도 트랙) + const sess = await getFreshDeviceSession().catch(() => null); + if (!sess?.access_token) return null; + return createClient(sess.url, sess.anonKey, { + auth: { persistSession: false }, + global: { headers: { Authorization: `Bearer ${sess.access_token}` }, fetch: (u, o) => fetch(u, { ...o, signal: AbortSignal.timeout(15_000) }) }, + }); +} + +async function snapshot(sb, deviceId) { + const [{ data: keys, error: e1 }, { data: wraps, error: e2 }] = await Promise.all([ + sb.from('device_keys').select('device_id,pubkey,created_at'), + sb.from('wrapped_deks').select('device_id,created_at'), + ]); + if (e1 || e2) throw new Error((e1 ?? e2).message); + const wrapped = new Set((wraps ?? []).map((w) => w.device_id)); + const my = await loadDeviceE2ee(); + const devices = (keys ?? []).map((k) => ({ + deviceId: k.device_id, + fingerprint: pubFingerprint(k.pubkey), + hasWrap: wrapped.has(k.device_id), + createdAt: k.created_at, + isThis: k.device_id === deviceId, + // 등록된 pubkey와 이 기기 키 파일의 불일치 — 키 파일 유실 후 재생성 신호(제거 후 재등록 필요, §10.5) + keyMismatch: k.device_id === deviceId && k.pubkey !== my.pub, + })); + return { devices, enabled: wrapped.size > 0, hasDek: !!dek(), fingerprint: pubFingerprint(my.pub) }; +} + +export async function GET() { + try { + const user = await currentUser(); + if (!user?.id || user.id === 'local' || user.id === 'guest') return Response.json({ available: false, reason: 'unauthenticated' }); + const sb = await userSb(); + if (!sb) return Response.json({ available: false, reason: 'no-device-session' }); + const deviceId = await getDeviceId(); + const s = await snapshot(sb, deviceId); + return Response.json({ available: true, deviceId, ...s }); + } catch (e) { + return Response.json({ available: false, reason: String(e.message || e).slice(0, 120) }, { status: 500 }); + } +} + +export async function POST(req) { + try { + const user = await currentUser(); + if (!user?.id || user.id === 'local' || user.id === 'guest') return Response.json({ error: '로그인이 필요합니다' }, { status: 401 }); + const sb = await userSb(); + if (!sb) return Response.json({ error: '기기 연동 세션이 필요합니다 — Argo 앱에서 로그인해 주세요' }, { status: 401 }); + const deviceId = await getDeviceId(); + const body = await req.json().catch(() => ({})); + const action = String(body.action ?? ''); + + if (action === 'enable') { + // 켜기 = 이 기기가 DEK를 생성해 계정의 첫 열쇠 보유자가 된다. 이미 활성이면 claim/승인 경로로 안내. + if (dek()) return Response.json({ error: '이미 이 기기에서 켜져 있습니다' }, { status: 400 }); + // "이미 켜짐" 판정에서 **자기 기기 행은 제외**(분리 검수 MEDIUM): 이전 enable이 자기 랩 기록 후 + // 복구 랩에서 실패하면 자기 잔재를 "남이 켰다"로 오판해 영구 교착이 된다. 자기 랩만 남았으면 + // 그 랩을 열어(개인키 보유) 같은 DEK로 이어간다 — 재시도가 곧 자가 치유. + const { data: mine } = await sb.from('wrapped_deks').select('wrap').eq('device_id', deviceId).maybeSingle(); + const { data: others } = await sb.from('wrapped_deks').select('device_id').neq('device_id', deviceId).limit(1); + if ((others ?? []).length > 0) { + return Response.json({ error: '이미 다른 기기에서 켜져 있습니다 — 그 기기에서 이 기기를 승인해 주세요' }, { status: 409 }); + } + // 플랜 게이트 — 동기화가 실제로 도는 상태(Pro·체험)에서만: 재봉인 push가 free RLS에 막혀 + // "켰는데 아무것도 암호화 안 됨"이 되는 것을 정직하게 사전 차단(#325 HIGH-2와 같은 원칙). + const { data: ent } = await sb.from('entitlements').select('plan,ends_at').maybeSingle(); + const created = (await sb.auth.getUser()).data?.user?.created_at; + const trial = created ? (Date.now() - new Date(created).getTime()) < TRIAL_DAYS * 86_400_000 : false; + if (!(proRowActive(ent ?? {}) || trial)) { + return Response.json({ error: '종단간 암호화는 동기화가 도는 상태(Pro·체험)에서 켤 수 있습니다' }, { status: 403 }); + } + const myKeys = await loadDeviceE2ee(); + const dekBytes = mine?.wrap ? await openDekWrap(Buffer.from(mine.wrap, 'base64')) : randomBytes(32); + // 복구 코드 — 생성 시 1회만 반환하고 어디에도 저장하지 않는다(서버엔 랩+KDF 파라미터만). + const code = generateRecoveryCode(); + const salt = randomBytes(16).toString('base64'); + const kek = deriveRecoveryKek(code, salt); + const recoveryWrap = wrapDekWithKek(kek, dekBytes).toString('base64'); + const myWrap = wrapDekFor(myKeys.pub, dekBytes).toString('base64'); + // 서버 기록(전부 암호문·파라미터) → 성공 후에만 로컬 DEK 확정 — 반쪽 활성(로컬만 켜짐) 방지 + const { error: e1 } = await sb.from('wrapped_deks').upsert({ user_id: user.id, device_id: deviceId, wrap: myWrap, wrapped_by: deviceId }, { onConflict: 'user_id,device_id' }); + if (e1) throw new Error(e1.message); + const { error: e2 } = await sb.from('recovery_wraps').upsert({ user_id: user.id, wrap: recoveryWrap, kdf: { ...RECOVERY_KDF, salt } }, { onConflict: 'user_id' }); + if (e2) throw new Error(e2.message); + await setDek(dekBytes); + const resealed = await markResealAll(); + nudgeSync(); + const s = await snapshot(sb, deviceId); + return Response.json({ ok: true, recoveryCode: code, resealed, ...s }); + } + + if (action === 'approve') { + // 대기 기기 승인 — 이 기기(DEK 보유)가 대상 기기 공개키로 DEK를 랩해 서버에 둔다. + // 사용자는 두 화면의 지문(fingerprint)을 대조한 뒤 눌러야 한다(SAS — 서버의 공개키 바꿔치기 방어). + const dk = dek(); + if (!dk) return Response.json({ error: '이 기기에 열쇠가 없습니다 — 열쇠 보유 기기에서 승인해 주세요' }, { status: 400 }); + const target = String(body.deviceId ?? ''); + if (!target || target === deviceId) return Response.json({ error: '승인할 기기를 지정해 주세요' }, { status: 400 }); + const { data: row, error } = await sb.from('device_keys').select('pubkey').eq('device_id', target).maybeSingle(); + if (error) throw new Error(error.message); + if (!row?.pubkey) return Response.json({ error: '대상 기기의 공개키가 없습니다(앱 업데이트·로그인 확인)' }, { status: 404 }); + const wrap = wrapDekFor(row.pubkey, dk).toString('base64'); + const { error: e1 } = await sb.from('wrapped_deks').upsert({ user_id: user.id, device_id: target, wrap, wrapped_by: deviceId }, { onConflict: 'user_id,device_id' }); + if (e1) throw new Error(e1.message); + return Response.json({ ok: true, fingerprint: pubFingerprint(row.pubkey), ...(await snapshot(sb, deviceId)) }); + } + + if (action === 'claim') { + // 새 기기 — 서버의 내 랩을 회수해 잠김 해제(승인·복구가 넣어준 랩) + _resetClaimForTest(); // 60초 스로틀 해제 — 사용자가 버튼으로 즉시 시도 + const ok = await tryClaimDek(sb, deviceId, { force: true }); + if (ok) nudgeSync(); + return Response.json({ ok, ...(await snapshot(sb, deviceId)) }); + } + + if (action === 'recover') { + // 복구 코드로 DEK 복원 — 전 기기 분실 폴백. 코드는 160bit 랜덤이라 브루트포스는 계산상 무의미하고, + // 이 스로틀은 인증된 사용자의 scrypt(N=2^17) 반복 호출이 서버 CPU를 갉는 것만 막는다(검수 LOW). + if (Date.now() - (globalThis.__argoE2eeRecoverTs ?? 0) < 5_000) { + return Response.json({ error: '잠시 후 다시 시도해 주세요' }, { status: 429 }); + } + globalThis.__argoE2eeRecoverTs = Date.now(); + if (dek()) return Response.json({ error: '이미 이 기기에 열쇠가 있습니다' }, { status: 400 }); + const { data: rec, error } = await sb.from('recovery_wraps').select('wrap,kdf').maybeSingle(); + if (error) throw new Error(error.message); + if (!rec?.wrap) return Response.json({ error: '복구 코드가 설정돼 있지 않습니다' }, { status: 404 }); + let dekBytes; + try { + const kek = deriveRecoveryKek(String(body.code ?? ''), rec.kdf?.salt, rec.kdf ?? undefined); + dekBytes = openDekWithKek(kek, Buffer.from(rec.wrap, 'base64')); + } catch (e2) { + return Response.json({ error: String(e2.message || '복구 코드가 맞지 않습니다') }, { status: 400 }); + } + await setDek(dekBytes); + const myKeys = await loadDeviceE2ee(); + // 이 기기 랩도 등록해 다음부터는 승인·복구 없이 claim으로 열리게 + await sb.from('wrapped_deks').upsert({ user_id: user.id, device_id: deviceId, wrap: wrapDekFor(myKeys.pub, dekBytes).toString('base64'), wrapped_by: deviceId }, { onConflict: 'user_id,device_id' }); + nudgeSync(); + return Response.json({ ok: true, ...(await snapshot(sb, deviceId)) }); + } + + if (action === 'revoke') { + // 기기 제거 — 해당 기기의 랩·공개키 행 삭제(재등록 가능해짐). DEK 회전은 후속 트랙(E2EE-DESIGN §10.5) — + // 제거된 기기가 이미 로컬에 DEK 사본을 가졌을 수 있음을 UI가 정직하게 고지한다. + const target = String(body.deviceId ?? ''); + if (!target) return Response.json({ error: '제거할 기기를 지정해 주세요' }, { status: 400 }); + if (target === deviceId) return Response.json({ error: '이 기기 자신은 제거할 수 없습니다' }, { status: 400 }); + const { error: e1 } = await sb.from('wrapped_deks').delete().eq('device_id', target); + if (e1) throw new Error(e1.message); + const { error: e2 } = await sb.from('device_keys').delete().eq('device_id', target); + if (e2) throw new Error(e2.message); + return Response.json({ ok: true, rotated: false, ...(await snapshot(sb, deviceId)) }); + } + + return Response.json({ error: '알 수 없는 action' }, { status: 400 }); + } catch (e) { + return Response.json({ error: String(e.message || e).slice(0, 160) }, { status: 400 }); + } +} diff --git a/app/c/[ws]/settings/page.jsx b/app/c/[ws]/settings/page.jsx index ea28d19d..7a51f3a2 100644 --- a/app/c/[ws]/settings/page.jsx +++ b/app/c/[ws]/settings/page.jsx @@ -186,6 +186,7 @@ function Settings({ params }) { agents={data?.agents ?? []} /> +
@@ -1343,6 +1344,132 @@ function SyncCard({ ws }) { ); } +/** E2EE(종단간 암호화) 카드 — 회사 데이터까지 "사용자 본인만 여는" 열쇠 구조(설계 정본 E2EE-DESIGN.md). + 상태별 3분기: 꺼짐(켜기+구버전 경고) · 잠김(이 기기 열쇠 대기 — 지문 표시·열쇠 받기·복구 코드) · + 켜짐(기기 목록·지문 대조 승인·제거). 서버는 공개키·암호문만 나른다 — 복구 코드는 1회 표시 후 소멸. */ +function E2eeCard() { + const { t } = useLang(); + const [st, setSt] = useState(null); // GET /api/me/e2ee + const [busy, setBusy] = useState(''); + const [err, setErr] = useState(''); + const [recovery, setRecovery] = useState(''); // enable 직후 1회 표시 + const [code, setCode] = useState(''); // 복구 입력 + const [confirmOld, setConfirmOld] = useState(false); + const [confirm, setConfirm] = useState(null); // { kind: 'approve'|'revoke', deviceId, fp } — 지문 확인 모달 + const pull = useCallback(() => { api('/api/me/e2ee').then(setSt).catch(() => setSt({ available: false })); }, []); + useEffect(() => { pull(); const iv = setInterval(pull, 15000); return () => clearInterval(iv); }, [pull]); + async function act(action, extra = {}, label = action) { + if (busy) return; + setBusy(label); setErr(''); + try { + const d = await api('/api/me/e2ee', { action, ...extra }); + if (d.recoveryCode) setRecovery(d.recoveryCode); + setSt((s) => ({ ...s, ...d, available: true })); + } catch (e) { setErr(String(e.message || e)); } + finally { setBusy(''); } + } + if (!st?.available) return null; // 로그인-연동 기기 전용 — 없는 환경엔 카드 자체를 안 보인다 + const pending = (st.devices ?? []).filter((d) => !d.hasWrap && !d.isThis); + return ( +
+
+ {t('settings.e2ee.title')} + + {st.enabled + ? (st.hasDek + ? {t('settings.e2ee.on')} + : {t('settings.e2ee.locked')}) + : {t('settings.e2ee.off')}} +
+ + {recovery && ( + // 복구 코드 1회 표시 — 서버 어디에도 평문이 없다. 닫으면 다시 볼 수 없음을 명시. +
+ {t('settings.e2ee.recoveryLabel')} + {recovery} + {t('settings.e2ee.recoveryOnce')} + +
+ )} + + {!st.enabled && ( +
+ {t('settings.e2ee.introOff')} + + +
+ )} + + {st.enabled && !st.hasDek && ( +
+ + {t('settings.e2ee.lockedHelp')} {st.fingerprint} + +
+ + setCode(e.target.value)} + placeholder={t('settings.e2ee.recoveryPlaceholder')} style={{ ...fieldStyle, maxWidth: 340 }} /> + +
+
+ )} + + {st.enabled && st.hasDek && ( +
+ {pending.length > 0 && ( + {t('settings.e2ee.approveHint')} + )} + {(st.devices ?? []).map((d) => ( +
+ {d.fingerprint} + {d.deviceId.slice(0, 8)} + {d.isThis && {t('settings.e2ee.thisDevice')}} + {d.keyMismatch && {t('settings.e2ee.keyMismatch')}} + {!d.hasWrap && !d.isThis && ( + + )} + {!d.hasWrap && !d.isThis && {t('settings.e2ee.waiting')}} + + {!d.isThis && ( + + )} +
+ ))} + {t('settings.e2ee.onHelp')} +
+ )} + + {err && {err}} + + {confirm && ( + // 승인 = 지문 대조 확인(SAS의 사람 단계 — 검수 권고), 제거 = 실제 효과의 정직 고지(HIGH-2) + { const c = confirm; setConfirm(null); await act(c.kind, { deviceId: c.deviceId }, `${c.kind}:${c.deviceId}`); }} + onClose={() => setConfirm(null)} + /> + )} +
+ ); +} + /** 자격 증명 동기화 토글 + 정직 고지 — 무엇이 올라가는지(러너 로그인·봇 토큰·MCP env)와 암호화 열쇠가 어디에 보관되는지를 사실대로 밝힌다(고지 없는 수집 금지 — 2026-08-29 판정). 끄면 서버가 다음 사이클에 클라우드 사본을 마커로 회수한다(src/sync.mjs noSecrets — 로컬 로그인은 유지, 새 기기만 재연결). */ diff --git a/app/i18n.jsx b/app/i18n.jsx index 6d7ab507..f95695c9 100644 --- a/app/i18n.jsx +++ b/app/i18n.jsx @@ -454,6 +454,34 @@ const DICT = { 'settings.sync.credOffHelp': ['이 회사의 자격 증명은 각 기기에만 저장됩니다. 클라우드에 있던 사본은 동기화가 실제로 도는 상태(Pro·체험)에서 다음 사이클에 회수되며, 무료 플랜은 클라우드 쓰기가 막혀 있어 회수가 보류됩니다. 새 기기(및 클라우드 워커)에서는 러너·봇을 다시 연결해야 합니다.', 'Credentials for this company stay on each device only. Cloud copies are withdrawn on the next cycle while sync actually runs (Pro/trial); on the free plan cloud writes are blocked, so withdrawal stays pending. New devices (and cloud workers) must reconnect runners and bots.'], 'settings.sync.credWithdrawn': ['자격 회수 {n}건', 'credentials withdrawn: {n}'], 'settings.sync.credErr': ['설정 저장에 실패했습니다', 'Failed to save the setting'], + // E2EE(종단간 암호화) — 회사 데이터까지 본인만 여는 열쇠 구조 + 'settings.e2ee.title': ['종단간 암호화', 'End-to-end encryption'], + 'settings.e2ee.on': ['켜짐', 'On'], + 'settings.e2ee.off': ['꺼짐', 'Off'], + 'settings.e2ee.locked': ['이 기기 열쇠 대기', 'Awaiting key on this device'], + 'settings.e2ee.introOff': ['켜면 회사 데이터(기억·대화·크루)가 이 기기에서 만든 열쇠로 암호화되어 동기화됩니다 — 서버에는 열쇠가 없어 운영자를 포함해 본인 외에는 누구도 열 수 없습니다. 새 기기는 기존 기기의 승인(또는 복구 코드)으로 열쇠를 받습니다. 켤 때 복구 코드가 한 번 표시되니 안전한 곳에 보관하세요 — 모든 기기를 잃으면 복구 코드 없이는 클라우드의 기억을 되찾을 수 없습니다.', 'When on, company data (memory, chats, crew) syncs encrypted with a key created on this device — the server never holds the key, so no one but you (operator included) can open it. New devices receive the key via approval from an existing device (or the recovery code). A recovery code is shown once when enabling — keep it safe: if you lose every device, cloud memories cannot be recovered without it.'], + 'settings.e2ee.oldDeviceConfirm': ['이 계정으로 로그인한 모든 컴퓨터가 최신 버전으로 업데이트되어 있습니다(구버전 기기는 켠 뒤 동기화가 안전하게 멈추며, 업데이트하면 승인 대기 상태로 이어집니다).', 'Every computer signed into this account is updated to the latest version (older devices safely pause syncing after enabling, and continue to approval once updated).'], + 'settings.e2ee.turnOn': ['종단간 암호화 켜기', 'Turn on end-to-end encryption'], + 'settings.e2ee.recoveryLabel': ['복구 코드 — 지금 한 번만 표시됩니다', 'Recovery code — shown only once, now'], + 'settings.e2ee.recoveryOnce': ['이 코드는 다시 표시되지 않습니다. 비밀번호 관리자 등 안전한 곳에 보관하세요.', 'This code will not be shown again. Store it somewhere safe (e.g. a password manager).'], + 'settings.e2ee.recoverySaved': ['보관했습니다', 'I saved it'], + 'settings.e2ee.lockedHelp': ['이 기기는 아직 열쇠가 없어 회사 동기화가 안전하게 멈춰 있습니다. 열쇠를 가진 다른 기기의 설정 화면에서 아래 지문을 대조한 뒤 승인해 주세요 — 이 기기 지문:', 'This device has no key yet, so company sync is safely paused. On a device that holds the key, compare the fingerprint below and approve — this device\'s fingerprint:'], + 'settings.e2ee.claim': ['열쇠 받기', 'Receive key'], + 'settings.e2ee.recoveryPlaceholder': ['복구 코드 (XXXXXXXX-XXXXXXXX-…)', 'Recovery code (XXXXXXXX-XXXXXXXX-…)'], + 'settings.e2ee.recover': ['복구 코드로 열기', 'Unlock with recovery code'], + 'settings.e2ee.approveHint': ['승인 전, 아래 지문이 새 기기 화면의 지문과 같은지 눈으로 대조하세요 — 다르면 승인하지 마세요.', 'Before approving, visually compare the fingerprint below with the one on the new device — do not approve if they differ.'], + 'settings.e2ee.thisDevice': ['이 기기', 'This device'], + 'settings.e2ee.keyMismatch': ['키 불일치 — 제거 후 재등록 필요', 'Key mismatch — remove and re-register'], + 'settings.e2ee.approve': ['승인', 'Approve'], + 'settings.e2ee.waiting': ['승인 대기', 'awaiting approval'], + 'settings.e2ee.revoke': ['제거', 'Remove'], + // 제거의 실제 효과를 사실대로(분리 검수 HIGH-2): 행 삭제는 "새 열쇠 배포 차단"일 뿐, 이미 열쇠를 받은 + // 기기는 로컬 열쇠+세션이 살아있는 한 계속 연다. 진짜 차단 = 열쇠 회전(후속) / 지금 실효 조치 = 세션 로그아웃. + 'settings.e2ee.onHelp': ['기기 제거는 "앞으로 열쇠를 다시 내주지 않는 것"입니다 — 이미 열쇠를 받은 기기는 그 기기에 남은 열쇠와 로그인 세션이 살아 있는 한 계속 열 수 있습니다(열쇠 회전은 후속 업데이트 예정). 분실·도난 기기를 지금 차단하려면 그 기기의 로그인 세션을 끊는 것(비밀번호 변경·기기 로그아웃)이 실효 조치입니다.', 'Removing a device means "no key will be issued to it again" — a device that already received the key can keep opening data as long as its local key and login session survive (key rotation is planned). To block a lost/stolen device now, the effective action is revoking its login session (change password / sign out devices).'], + 'settings.e2ee.revokeConfirmTitle': ['기기 제거', 'Remove device'], + 'settings.e2ee.revokeConfirmBody': ['이 기기({fp})에 더 이상 열쇠를 내주지 않습니다. 주의: 이미 열쇠를 받은 기기라면 제거 후에도 그 기기에 남은 열쇠로 계속 열 수 있습니다 — 분실·도난이라면 로그인 세션 차단(비밀번호 변경)을 함께 하세요.', 'No key will be issued to this device ({fp}) again. Note: if it already received the key, it can still open data with its local copy — for a lost/stolen device, also revoke its login session (change password).'], + 'settings.e2ee.approveConfirmTitle': ['기기 승인', 'Approve device'], + 'settings.e2ee.approveConfirmBody': ['새 기기 화면에 표시된 지문이 {fp} 와 정확히 같은지 확인하셨나요? 다르면 절대 승인하지 마세요 — 승인하면 그 기기가 이 계정의 모든 데이터를 열 수 있게 됩니다.', 'Did you verify the fingerprint shown on the new device exactly matches {fp}? Never approve if they differ — approval lets that device open all data in this account.'], 'settings.sync.leader': ['이 기기가 실행 담당 — 폴러·루틴이 여기서 돕니다', 'This device runs the crew — pollers and routines execute here'], 'settings.sync.follower': ['다른 기기가 실행 담당 — 이 기기는 최신 상태만 유지합니다', 'Another device runs the crew — this one stays in sync'], 'settings.sync.last': ['마지막 동기화', 'Last sync'], diff --git a/src/e2ee.mjs b/src/e2ee.mjs index 3f80c46b..c5c07824 100644 --- a/src/e2ee.mjs +++ b/src/e2ee.mjs @@ -6,7 +6,7 @@ // (.device-session.json과 동일 원칙: 기기 로컬 상태). { pub, priv, dek? } 전부 base64. // dek는 이 계정의 데이터 키 — 켜는 기기가 생성하거나(P1), 기기 승인으로 수신하면 여기 저장된다. // 단계 0에서는 keypair 생성·등록까지만 실사용되고 dek는 비어 있다(동작 불변). -import { createCipheriv, createDecipheriv, createPrivateKey, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes } from 'node:crypto'; +import { createCipheriv, createDecipheriv, createHash, createPrivateKey, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes, scryptSync } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { link, mkdir, rename, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; @@ -145,9 +145,12 @@ export async function ensureDeviceKeyRegistered(sb, ownerId, deviceId, { root = registered.add(ownerId); // 실패해도 재시도 안 함 — 등록은 다음 프로세스 기동이 자연 재시도 try { const state = await loadDeviceE2ee({ root }); + // ignoreDuplicates — **pubkey는 최초 등록 후 불변**(E2EE-DESIGN.md §10.5): device_id는 자기신고 + // 값이라 갱신을 허용하면 탈취 세션 하나로 기존 기기의 pubkey를 바꿔치기(하이재킹)할 수 있다. + // 키 파일을 잃은 기기의 재등록은 revoke(행 삭제) 후 신규 등록 경로로만. const { error } = await sb.from('device_keys').upsert( { user_id: ownerId, device_id: deviceId, pubkey: state.pub }, - { onConflict: 'user_id,device_id' }, + { onConflict: 'user_id,device_id', ignoreDuplicates: true }, ); if (error) throw new Error(error.message); } catch (e) { @@ -155,3 +158,78 @@ export async function ensureDeviceKeyRegistered(sb, ownerId, deviceId, { root = } } export function _resetRegisteredForTest() { registered.clear(); } + +/* ── P1: 대조 지문·복구 코드·자기 랩 회수 ── */ + +/** 공개키 지문 — 기기 승인 화면의 대조 코드(SAS). 새 기기와 승인 기기 양쪽이 같은 값을 계산해 + 표시하므로, 서버가 공개키를 바꿔치면 두 화면의 숫자가 어긋나 사용자 대조가 실패한다. + (HKDF 각인이 막는 "랩 재해석"과는 별개의 방어선 — E2EE-DESIGN.md §10.5) */ +export const pubFingerprint = (pubB64) => + createHash('sha256').update(Buffer.from(pubB64, 'base64')).digest('hex').slice(0, 6).toUpperCase(); + +/** 복구 코드 — 160bit 랜덤을 Crockford base32(8자×4묶음)로. 생성 시 1회만 표시하고 저장하지 않는다. */ +const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; +export function generateRecoveryCode() { + const bytes = randomBytes(20); // 160bit + let bits = 0, acc = 0, out = ''; + for (const b of bytes) { + acc = (acc << 8) | b; bits += 8; + while (bits >= 5) { out += CROCKFORD[(acc >>> (bits - 5)) & 31]; bits -= 5; } + } + return out.slice(0, 32).match(/.{1,8}/g).join('-'); // XXXXXXXX-XXXXXXXX-XXXXXXXX-XXXXXXXX +} + +/** 복구 코드 정규화 — 사용자 입력 관용(소문자·구분자·Crockford 동형문자 O→0, I/L→1). */ +export const normalizeRecoveryCode = (s) => + String(s).toUpperCase().replace(/[-\s]/g, '').replace(/O/g, '0').replace(/[IL]/g, '1'); + +/** 복구 KEK 유도 — scrypt(Node 내장, 의존성 무추가). 코드가 160bit 랜덤이라 사전 공격이 무의미하고 + KDF는 방어 심층이다(E2EE-DESIGN.md §4). 파라미터는 recovery_wraps.kdf에 기록돼 미래 상향과 공존. */ +export const RECOVERY_KDF = { alg: 'scrypt', N: 131072, r: 8, p: 1 }; +export function deriveRecoveryKek(code, saltB64, kdf = RECOVERY_KDF) { + if (kdf.alg !== 'scrypt') throw new Error(`알 수 없는 복구 KDF: ${kdf.alg} — 앱 업데이트가 필요합니다`); + return scryptSync(normalizeRecoveryCode(code), Buffer.from(saltB64, 'base64'), 32, { N: kdf.N, r: kdf.r, p: kdf.p, maxmem: 256 * 1024 * 1024 }); +} + +/* 복구 랩 — 대칭 KEK로 DEK를 직접 봉인(기기 랩의 ECDH와 달리 수신 공개키가 없다). */ +const MAGICR = Buffer.from('argorecwrap.v1:'); +export function wrapDekWithKek(kek, dekBytes) { + const iv = randomBytes(IV_LEN); + const c = createCipheriv('aes-256-gcm', kek, iv); + const ct = Buffer.concat([c.update(dekBytes), c.final()]); + return Buffer.concat([MAGICR, iv, c.getAuthTag(), ct]); +} +export function openDekWithKek(kek, blob) { + if (!blob.subarray(0, MAGICR.length).equals(MAGICR)) throw new Error('복구 랩 형식 아님'); + let off = MAGICR.length; + const iv = blob.subarray(off, off += IV_LEN); + const tag = blob.subarray(off, off += TAG_LEN); + const ct = blob.subarray(off); + const d = createDecipheriv('aes-256-gcm', kek, iv); + d.setAuthTag(tag); + try { return Buffer.concat([d.update(ct), d.final()]); } + catch { throw new Error('복구 코드가 맞지 않습니다'); } // GCM 태그 불일치 = 코드 오입력(정직 문구) +} + +/** 자기 랩 회수(claim) — wrapped_deks에서 내 기기 행을 찾아 DEK를 개봉·보관한다. + 승인(다른 기기가 내 공개키로 랩을 넣어줌) 후 이 기기가 잠김을 푸는 경로. cycle이 DEK 미보유일 때 + 60초 간격으로 시도한다(가벼운 own-RLS select 1행). 반환: true = DEK 확보. */ +let lastClaim = 0; +export async function tryClaimDek(sb, deviceId, { root = WS_ROOT, force = false } = {}) { + if (dekBuf) return true; + if (!sb || !deviceId) return false; + if (!force && Date.now() - lastClaim < 60_000) return false; + lastClaim = Date.now(); + try { + const { data, error } = await sb.from('wrapped_deks').select('wrap').eq('device_id', deviceId).maybeSingle(); + if (error || !data?.wrap) return false; + const dekBytes = await openDekWrap(Buffer.from(data.wrap, 'base64'), { root }); + await setDek(dekBytes, { root }); + console.log('[argo] e2ee: 이 기기의 열쇠(DEK) 수신 — 잠김 해제'); + return true; + } catch (e) { + console.warn('[argo] e2ee: 열쇠 회수 보류:', String(e.message).slice(0, 80)); + return false; + } +} +export function _resetClaimForTest() { lastClaim = 0; } diff --git a/src/permission-gate.mjs b/src/permission-gate.mjs index 2378411b..d0f755a0 100644 --- a/src/permission-gate.mjs +++ b/src/permission-gate.mjs @@ -247,6 +247,7 @@ const WS_DOT_FILES = new Set([ // WS_ROOT 직속(/../.x) — 전 회사 공용 기기 상태·계정 자격 '.device-session.json', '.tmp-devsess-', // 세션 본체 + 원자적 쓰기 임시본(토큰 평문) '.device-e2ee.json', '.tmp-e2ee-', // E2EE 기기 개인키·DEK + 원자적 쓰기 임시본 — cat 한 줄이 "본인만 여는 열쇠" 유출(세션 파일과 동급 최악) + '.e2ee-reseal.json', // E2EE 재봉인 마커(wsId 목록뿐 — 시크릿 아님) — 루트 직속 제어 파일 일관 방어(크루 조작 시 재봉인 누락·유발) '.sync-credentials.json', // `.account-secrets` 접두 하나로 3형태를 덮는다 — 로컬(`-local.json`)·사용자 스코프 // (`-{uid}.json`, runners/creds.mjs 템플릿 리터럴)·레거시(`.account-secrets.json`). diff --git a/src/secretbox.mjs b/src/secretbox.mjs index c624a4cf..88804333 100644 --- a/src/secretbox.mjs +++ b/src/secretbox.mjs @@ -89,6 +89,10 @@ export function openSecretCompat(buf) { return buf.subarray(0, GENERATION.length).equals(GENERATION) ? openSecret(buf) : buf; } +/** 이 버퍼가 봉투 세대('argosecret.' 접두)인가 — 열 수 있는지와 무관한 형식 판정. + sync의 매니페스트 세대 다운그레이드 게이트가 쓴다(열지 못한 세대를 만난 기기가 평문을 되쓰지 않게). */ +export const isEnvelopeGeneration = (buf) => buf.subarray(0, GENERATION.length).equals(GENERATION); + /** 평문 → v2 봉투(MAGIC ∥ iv ∥ tag ∥ ct). */ export function sealSecret(buf) { const iv = randomBytes(IV_LEN); diff --git a/src/sync.mjs b/src/sync.mjs index 41cfbb66..45f8680e 100644 --- a/src/sync.mjs +++ b/src/sync.mjs @@ -22,7 +22,8 @@ import { createClient } from '@supabase/supabase-js'; import { WS_ROOT, paths, archiveCompany, writeTombstone, TOMBSTONE_DIR, getDeviceId } from './workspace.mjs'; import { writeJsonAtomic, writeFileAtomic, readJsonLenient } from './jsonstore.mjs'; import { withLock } from './mutex.mjs'; -import { cryptoOn, isSecretRel, isEncRel, encVaultOn, sealSecret, openSecret, openSecretCompat, CRED_WITHDRAWN, isCredWithdrawn } from './secretbox.mjs'; +import { cryptoOn, isSecretRel, isEncRel, encVaultOn, sealSecret, sealSecretV3, openSecret, openSecretCompat, isEnvelopeGeneration, CRED_WITHDRAWN, isCredWithdrawn } from './secretbox.mjs'; +import { dek, tryClaimDek } from './e2ee.mjs'; import { loadSyncCreds, credsEpoch } from './synccreds.mjs'; import { loadDeviceSession, getFreshDeviceSession } from './devicesession.mjs'; import { ensureAccountKey } from './accountkey.mjs'; @@ -482,7 +483,9 @@ export async function syncCompany(wsId, owner, isRestore = false, opts = {}) { return (rel === 'connections.json' || rel === '.secrets.json') ? openSecret(b) : openSecretCompat(b); }; /** 업로드 직전 봉투 — 모든 업로드 경로가 이걸 거쳐야 평문이 새지 않는다(병합 분기 포함). */ - const sealFor = (rel, buf) => (isEncRel(rel) ? sealSecret(buf) : buf); + // E2EE 활성(이 기기가 DEK 보유) = 동기 대상 **전량**을 v3로 봉인 — 별도 스위치가 없다: + // DEK 보유가 곧 스위치(디스크 사실에서 파생 원칙). 미보유 기기는 v2/평문 기존 동작 그대로(단계 0 불변). + const sealFor = (rel, buf) => (dek() ? sealSecretV3(buf) : isEncRel(rel) ? sealSecret(buf) : buf); const pushBuf = async (rel) => sealFor(rel, await readFile(relFull(rel))); // 로컬 쓰기 — 스레드 파일이면 진행 중 턴과 직렬화(레이스 방지). 원자쓰기(tmp→fsync→rename)로 // 크래시 시 파일이 잘려 '손상→삭제 오전파'로 번지는 것을 차단(.tmp-는 EXCLUDE라 원격에 안 샌다). @@ -628,7 +631,13 @@ export async function syncCompany(wsId, owner, isRestore = false, opts = {}) { continue; } // ── 양쪽 존재 ── - if (!localChg && !remoteChg) continue; // 변경 없음 + if (!localChg && !remoteChg) { + // E2EE 재봉인 — 켠 직후 1회, 무변경 파일도 원격 blob을 v3로 되덮는다(메타·base 불변이라 + // 다른 기기의 diff 판정에 영향 없음 — blob 세대만 교체. 이게 없으면 옛 평문/v2 사본이 + // 클라우드에 영구 잔존해 "본인만 연다"가 신규 파일에만 성립한다). + if (opts.reseal) { await upload(remoteKey(rel), await pushBuf(rel)); pushed++; } + continue; + } if (remoteChg && !localChg) { // 원격만 변경 → 받기 await writeLocal(rel, await pullBuf(rel), r.m); local[rel] = r; pulled++; continue; } @@ -699,11 +708,28 @@ export async function syncCompany(wsId, owner, isRestore = false, opts = {}) { // "로컬에 없는데 base에 있음 = 내가 지움"으로 오판해 다음 사이클에 원격 삭제를 전파해 버린다. // base에 없으니 다음 사이클에 '원격 신규 → 받기'로 정상 pull된다. const uploadFiles = { ...remote.files }; - try { - const { data } = await client().storage.from(BUCKET).download(manifestKey); - if (data) { - const fresh = JSON.parse(openSecretCompat(Buffer.from(await data.arrayBuffer())).toString()); // 재읽기도 관용 개봉 - for (const [rel, meta] of Object.entries(fresh.files ?? {})) { + { + // 재읽기는 두 단계로 갈라 관용의 범위를 정확히 한다(분리 검수 HIGH-1): + // ① 네트워크 실패 = 기존 관용(병합 없이 진행). ② **받았는데 열 수 없는 봉투 세대** = 관용 금지 — + // 사이클 시작 후 다른 기기가 세대를 올린 것(E2EE 켬 등)이고, 이대로 아래에서 내(구세대) 매니페스트를 + // 쓰면 게이트가 평문으로 되돌아간다(실증: 열쇠 없는 기기의 정상 사이클 하나가 v3 매니페스트를 + // 평문으로 다운그레이드). 이번 사이클 전체를 보류해 세대를 지킨다 — 다음 사이클 초기 읽기가 + // 같은 세대를 만나 정식 잠김(보류) 경로로 수렴한다. + let freshBuf = null; + try { + const { data } = await client().storage.from(BUCKET).download(manifestKey); + if (data) freshBuf = Buffer.from(await data.arrayBuffer()); + } catch { /* 재읽기 네트워크 실패 — 병합 없이 진행(남는 경합은 blob 검사가 방어, 다음 사이클 self-heal) */ } + if (freshBuf) { + let fresh = null; + try { fresh = JSON.parse(openSecretCompat(freshBuf).toString()); } + catch (e) { + if (isEnvelopeGeneration(freshBuf)) { + throw new Error(`매니페스트 세대 상승 감지(다른 기기가 암호화를 켬) — 이번 사이클 보류: ${String(e.message).slice(0, 60)}`); + } + /* 평문 손상 등 — 기존 관용: 병합 없이 진행 */ + } + for (const [rel, meta] of Object.entries(fresh?.files ?? {})) { if (!(rel in uploadFiles) && !deletedRels.has(rel) && safeRel(rel)) { // 다른 기기가 "삭제 진행 중"(blob은 지웠고 매니페스트 drop 전)인 항목을 되살리면 그 삭제가 // 미전파되고 죽은 항목이 남는다(검수 MEDIUM) — blob이 실존할 때만 병합, 확인 불가면 생략 @@ -712,7 +738,7 @@ export async function syncCompany(wsId, owner, isRestore = false, opts = {}) { } } } - } catch { /* 재읽기 실패 — 병합 없이 진행(남는 경합은 blob 검사가 방어, 다음 사이클 self-heal) */ } + } // 매니페스트도 봉투 대상(E-b) — 경로(노트 제목)만으로 맥락이 새므로. 읽기 두 지점이 관용 개봉이라 // 스위치 off 기기도 안전하게 읽는다. off면 평문 그대로(동작 불변). const manifestBuf = Buffer.from(JSON.stringify({ ...remote, files: uploadFiles })); @@ -720,7 +746,11 @@ export async function syncCompany(wsId, owner, isRestore = false, opts = {}) { // 키 미확보 사이클에 sealSecret이 throw해 동기화가 멈추던 비대칭 제거(데이터 위험은 없었으나 가용성 문제). let manifestDenied = false; try { - await upload(manifestKey, encVaultOn() && cryptoOn() ? sealSecret(manifestBuf) : manifestBuf); + // E2EE 활성이면 매니페스트도 v3 — 이것이 곧 **열쇠 없는 기기의 안전 게이트**다: DEK 미보유 기기는 + // 매니페스트 개봉이 보류 오류로 떨어져 회사 동기화가 통째로 멈춘다(파일 단위 오염·오삭제 원천 불가, + // 기존 "매니페스트 읽기 실패 — 삭제 보류" 경로 재사용). 구버전(전방 게이트 없는 ≤0.1.51)도 매니페스트 + // JSON 파싱 실패로 같은 보류에 떨어진다 — 켜기 전 전 기기 업데이트 안내는 라우트·UI가 담당. + await upload(manifestKey, dek() ? sealSecretV3(manifestBuf) : encVaultOn() && cryptoOn() ? sealSecret(manifestBuf) : manifestBuf); } catch (e) { // free 복원의 완결 처리(재검수 HIGH-E) — pull은 전부 끝났는데 매니페스트 업로드만 RLS에 거부되면, // 여기서 throw할 경우 state가 영영 안 써져 복원이 영구 restoring이 된다(지운 노트가 8초마다 부활, @@ -874,6 +904,32 @@ export const _tombstonesForTest = { syncTombstones, discoverRemote }; (export: 회귀 테스트용 — cycle 전체는 실 env가 필요해 단위로 못 태운다) */ export const isDiscoverDue = (now, last, intervalMs) => !last || now - last >= intervalMs; +/* ─── E2EE 재봉인 마커 — WS_ROOT 레벨(회사 밖 = walk·동기화 비대상) { [wsId]: true } ─── + enable 라우트가 markResealAll()로 표시하면, cycle이 회사별 1회 reseal 동기화(무변경 파일 포함 + 전량 v3 되덮기)를 돌리고 성공 시 지운다. 실패 시 마커가 남아 다음 사이클 재시도. */ +const RESEAL_FILE = () => join(WS_ROOT, '.e2ee-reseal.json'); +export async function markResealAll() { + const targets = {}; + let entries = []; + try { entries = await readdir(WS_ROOT, { withFileTypes: true }); } catch { /* 루트 없음 */ } + for (const e of entries) { + if (!e.isDirectory() || e.name.startsWith('.')) continue; + try { + const meta = JSON.parse(await readFile(join(WS_ROOT, e.name, 'company.json'), 'utf8')); + if (meta.ownerId) targets[meta.id] = true; + } catch { /* 회사 아님 */ } + } + await writeJsonAtomic(RESEAL_FILE(), targets); + return Object.keys(targets); +} +const loadReseal = () => readJsonLenient(RESEAL_FILE(), {}); +async function clearReseal(wsId) { + const cur = await loadReseal(); + if (!cur[wsId]) return; + delete cur[wsId]; + await writeJsonAtomic(RESEAL_FILE(), cur); +} + /* ─── 상주 루프 ─── */ const status = (globalThis.__argoSyncStatus ??= { lastTs: null, lastError: '', paywalled: false, plan: null, companies: {} }); export function syncStatus() { @@ -890,7 +946,12 @@ async function cycle() { const keyOwner = process.env.ARGO_SYNC_OWNER || loadSyncCreds()?.owner || loadDeviceSession()?.user?.id || null; await ensureAccountKey(client(), keyOwner); // E2EE 단계 0 — 기기 공개키 등록부 구축(내부 1회 가드·실패 무해). 켜기 전까지 다른 동작 없음. - ensureDeviceKeyRegistered(client(), keyOwner, await getDeviceId()).catch(() => {}); + const myDeviceId = await getDeviceId(); + ensureDeviceKeyRegistered(client(), keyOwner, myDeviceId).catch(() => {}); + // E2EE P1 — DEK 미보유면 자기 랩 회수 시도(승인·복구가 서버에 넣어준 랩, 60초 간격 own-RLS 1행). + // 성공 순간부터 sealFor가 v3로 전환되고, 이 기기의 "잠김"이 풀린다. + if (!dek()) await tryClaimDek(client(), myDeviceId).catch(() => {}); + const resealSet = dek() ? await loadReseal() : {}; // 로컬 회사 수집 (ownerId 있는 것만 — 소유자가 있어야 클라우드에 자리가 있다) // 세션(JWT) 모드는 현재 계정 소유가 아닌 회사를 제외한다 — 다른 계정 소유의 로컬 사본(계정 전환· // 기기 공유 흔적)을 매 사이클 남의 폴더로 밀다 스토리지 격리(RLS)에 막혀 "row-level security" @@ -1028,7 +1089,12 @@ async function cycle() { continue; } try { - const r = await syncCompany(wsId, owner, restoring, { freePlan, noSecrets: noSecretsWs.has(wsId) }); + const reseal = !!resealSet[wsId]; + const r = await syncCompany(wsId, owner, restoring, { freePlan, noSecrets: noSecretsWs.has(wsId), reseal }); + // 재봉인 완결은 **파일 실패 0**일 때만 — throw만 안 하면 지우던 이전 배선은 개별 push 실패 + // (r.failed>0) 파일을 영구 구세대로 남겼다(분리 검수 MEDIUM). 실패가 있으면 마커를 남겨 + // 다음 사이클이 회사째 재시도한다(무변경 재푸시 비용 < 영구 평문 잔존). + if (reseal && (r.failed ?? 0) === 0) await clearReseal(wsId).catch(() => {}); status.companies[wsId] = { ts: Date.now(), ...r }; } catch (e) { status.lastError = `${wsId}: ${String(e.message).slice(0, 120)}`; diff --git a/test/e2ee-p1.test.mjs b/test/e2ee-p1.test.mjs new file mode 100644 index 00000000..7cd36dfd --- /dev/null +++ b/test/e2ee-p1.test.mjs @@ -0,0 +1,224 @@ +// E2EE P1 — 복구 코드·자기 랩 회수(claim)·v3 전량 봉인·열쇠 없는 기기 보류·재봉인(reseal). +// 행동 테스트: syncCompany·e2ee 프리미티브를 실제로 돌린다(소스 문자열 단언 금지 규율). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { createHash } from 'node:crypto'; + +const ROOT = await mkdtemp(join(tmpdir(), 'argo-e2ee-p1-')); +process.env.ARGO_ROOT = ROOT; +process.env.ARGO_SYNC = '1'; + +const { syncCompany, _setSyncClientForTest } = await import('../src/sync.mjs'); +const { + loadDeviceE2ee, dek, setDek, clearDekCache, wrapDekFor, pubFingerprint, + generateRecoveryCode, normalizeRecoveryCode, deriveRecoveryKek, wrapDekWithKek, openDekWithKek, + tryClaimDek, _resetClaimForTest, +} = await import('../src/e2ee.mjs'); + +const OWNER = 'o'; +const hashBuf = (buf) => createHash('sha1').update(buf).digest('hex').slice(0, 16); +const meta = (buf, m = 1000) => ({ m, s: buf.length, h: hashBuf(buf) }); + +function fakeStorage(initial = {}) { + const store = new Map(Object.entries(initial)); + const bucket = { + async download(k) { + if (!store.has(k)) return { data: null, error: { message: 'Object not found', status: 404 } }; + const buf = store.get(k); + return { data: { arrayBuffer: async () => new Uint8Array(buf).buffer }, error: null }; + }, + async upload(k, blob) { store.set(k, Buffer.from(await blob.arrayBuffer())); return { error: null }; }, + async remove(keys) { for (const k of keys) store.delete(k); return { error: null }; }, + async list() { return { data: [] }; }, + }; + return { _store: store, storage: { from: () => bucket }, createBucket: async () => ({}) }; +} + +/* ── 복구 코드 — 생성·정규화·왕복·오입력 거부 ── */ +test('복구 코드: 왕복 성공, 관용 입력(소문자·구분자·O/I 동형) 수용, 오입력은 정직 거부', () => { + const code = generateRecoveryCode(); + assert.match(code, /^[0-9A-Z]{8}(-[0-9A-Z]{8}){3}$/, 'Crockford 8자×4'); + const salt = Buffer.alloc(16, 2).toString('base64'); + const DEK = Buffer.alloc(32, 4); + const wrap = wrapDekWithKek(deriveRecoveryKek(code, salt), DEK); + // 관용 입력 — 소문자·공백, O→0 동형(코드에 0이 있으면 O로 쳐도 같음) + const sloppy = code.toLowerCase().replace(/-/g, ' ').replace(/0/g, 'O'); + assert.deepEqual(openDekWithKek(deriveRecoveryKek(sloppy, salt), wrap), DEK, '관용 정규화로 왕복'); + assert.equal(normalizeRecoveryCode('i1-Lo'), '1110', '동형 문자 정규화'); + const wrong = deriveRecoveryKek(code.slice(0, -1) + (code.endsWith('A') ? 'B' : 'A'), salt); + assert.throws(() => openDekWithKek(wrong, wrap), /복구 코드가 맞지 않습니다/, '오입력은 GCM 거부 → 정직 문구'); +}); + +/* ── 지문 — SAS 대조 코드 ── */ +test('지문: 6자리 hex, 키가 다르면 지문이 다르다(서버 바꿔치기 대조 가능)', async () => { + clearDekCache(); + const a = await loadDeviceE2ee(); + const fp = pubFingerprint(a.pub); + assert.match(fp, /^[0-9A-F]{6}$/); + assert.notEqual(fp, pubFingerprint(Buffer.alloc(32, 1).toString('base64'))); +}); + +/* ── claim — 서버의 내 랩 회수로 잠김 해제 ── */ +test('claim: wrapped_deks의 내 랩을 회수해 DEK 확보, 랩 없으면 false·스로틀 동작', async () => { + const claimRoot = await mkdtemp(join(tmpdir(), 'argo-p1-claim-')); + clearDekCache(); _resetClaimForTest(); + const me = await loadDeviceE2ee({ root: claimRoot }); + const DEK = Buffer.alloc(32, 6); + const wrapB64 = wrapDekFor(me.pub, DEK).toString('base64'); + const sb = { from: () => ({ select: () => ({ eq: () => ({ maybeSingle: async () => ({ data: { wrap: wrapB64 }, error: null }) }) }) }) }; + assert.equal(await tryClaimDek(sb, 'dev-x', { root: claimRoot, force: true }), true, '랩 회수 성공'); + assert.deepEqual(dek(), DEK, 'DEK 확보'); + // 없는 경우 + clearDekCache(); _resetClaimForTest(); + const root2 = await mkdtemp(join(tmpdir(), 'argo-p1-claim2-')); + await loadDeviceE2ee({ root: root2 }); + const empty = { from: () => ({ select: () => ({ eq: () => ({ maybeSingle: async () => ({ data: null, error: null }) }) }) }) }; + assert.equal(await tryClaimDek(empty, 'dev-x', { root: root2, force: true }), false, '랩 없으면 false'); + assert.equal(await tryClaimDek(empty, 'dev-x', { root: root2 }), false, '스로틀 — 즉시 재시도는 건너뜀'); + await rm(claimRoot, { recursive: true, force: true }); + await rm(root2, { recursive: true, force: true }); +}); + +/* ── sync 통합: DEK 보유 = 전량 v3 봉인(파일+매니페스트) ── */ +test('v3 전환: DEK 보유 기기의 push는 파일·매니페스트 전부 v3 봉투로 나간다', async () => { + clearDekCache(); + await loadDeviceE2ee(); + await setDek(Buffer.alloc(32, 8)); + const wsId = 'p1-seal'; + const wsRoot = join(ROOT, wsId); + await mkdir(join(wsRoot, 'vault'), { recursive: true }); + await writeFile(join(wsRoot, 'vault', 'memo.md'), '# 사용자만 여는 기억\n'); + await writeFile(join(wsRoot, '.sync-state.json'), JSON.stringify({ files: {}, ts: 1000 })); + const fake = fakeStorage({ [`${OWNER}/${wsId}/__manifest__.json`]: Buffer.from('{"files":{}}') }); + _setSyncClientForTest(fake); + const r = await syncCompany(wsId, OWNER); + assert.equal(r.failed, 0); + const blob = fake._store.get(`${OWNER}/${wsId}/vault/memo.md`); + assert.equal(blob.toString('utf8', 0, 14), 'argosecret.v3:', '파일이 v3 봉투'); + const man = fake._store.get(`${OWNER}/${wsId}/__manifest__.json`); + assert.equal(man.toString('utf8', 0, 14), 'argosecret.v3:', '매니페스트도 v3 — 열쇠 없는 기기의 게이트'); +}); + +/* ── sync 통합: 열쇠 없는 기기는 그 회사 동기화가 통째로 안전 보류 ── */ +test('잠김: DEK 없는 기기는 v3 매니페스트 개봉이 보류로 떨어져 회사 동기화 전체가 멈춘다(오염·오삭제 없음)', async () => { + // 위 테스트가 올린 v3 상태를 그대로 사용하되, DEK 없는 기기를 재현 + const wsId = 'p1-locked'; + const sealRoot = await mkdtemp(join(tmpdir(), 'argo-p1-lock-')); + // 송신측: DEK 보유 상태로 원격에 v3 회사 구성 + clearDekCache(); + await loadDeviceE2ee(); + await setDek(Buffer.alloc(32, 8)); + const wsRootA = join(ROOT, wsId); + await mkdir(join(wsRootA, 'vault'), { recursive: true }); + await writeFile(join(wsRootA, 'vault', 'a.md'), '# v3\n'); + await writeFile(join(wsRootA, '.sync-state.json'), JSON.stringify({ files: {}, ts: 1000 })); + const fake = fakeStorage({ [`${OWNER}/${wsId}/__manifest__.json`]: Buffer.from('{"files":{}}') }); + _setSyncClientForTest(fake); + await syncCompany(wsId, OWNER); + // 수신측(잠김): DEK 없는 키 파일로 전환 — ROOT의 dek를 지워 승인 전 기기 재현 + const cur = JSON.parse(await readFile(join(ROOT, '.device-e2ee.json'), 'utf8')); + delete cur.dek; + await writeFile(join(ROOT, '.device-e2ee.json'), JSON.stringify(cur), { mode: 0o600 }); + clearDekCache(); + await loadDeviceE2ee(); + assert.equal(dek(), null, '전제: 잠김 기기'); + // 로컬을 비운 새 회사 폴더처럼 만들어 pull 시도 상황 재현 + await rm(join(wsRootA, 'vault'), { recursive: true, force: true }); + await mkdir(join(wsRootA, 'vault'), { recursive: true }); + await assert.rejects( + () => syncCompany(wsId, OWNER), + /매니페스트|열쇠|앱 업데이트/, + '매니페스트 게이트 — 회사 단위 보류(파일 단위 오염·오삭제 원천 차단)', + ); + assert.ok(!existsSync(join(wsRootA, 'vault', 'a.md')), '로컬에 아무것도 기록되지 않는다'); + assert.ok(fake._store.has(`${OWNER}/${wsId}/vault/a.md`), '원격도 그대로'); + await rm(sealRoot, { recursive: true, force: true }); +}); + +/* ── reseal: 무변경 파일도 1회 강제 push로 blob이 v3로 교체(메타·base 불변) ── */ +test('reseal: 켠 직후 옛 평문 blob이 v3로 되덮이고 메타·base는 불변(타 기기 diff 무영향)', async () => { + clearDekCache(); + // DEK 재보유(위 테스트가 파일에서 dek를 지웠으므로 다시 세팅) + await loadDeviceE2ee(); + await setDek(Buffer.alloc(32, 8)); + const wsId = 'p1-reseal'; + const wsRoot = join(ROOT, wsId); + const note = Buffer.from('# 예전 평문 노트\n'); + await mkdir(join(wsRoot, 'vault'), { recursive: true }); + await writeFile(join(wsRoot, 'vault', 'old.md'), note); + await writeFile(join(wsRoot, '.sync-state.json'), JSON.stringify({ files: { 'vault/old.md': meta(note) }, ts: 1000 })); + const fake = fakeStorage({ + [`${OWNER}/${wsId}/__manifest__.json`]: Buffer.from(JSON.stringify({ files: { 'vault/old.md': meta(note) } })), + [`${OWNER}/${wsId}/vault/old.md`]: note, // 옛 평문이 클라우드에 남아 있는 상태 + }); + _setSyncClientForTest(fake); + const r = await syncCompany(wsId, OWNER, false, { reseal: true }); + assert.equal(r.failed, 0); + const blob = fake._store.get(`${OWNER}/${wsId}/vault/old.md`); + assert.equal(blob.toString('utf8', 0, 14), 'argosecret.v3:', '옛 평문이 v3로 되덮임'); + const st = JSON.parse(await readFile(join(wsRoot, '.sync-state.json'), 'utf8')); + assert.equal(st.files['vault/old.md'].h, meta(note).h, 'base 해시(평문 기준) 불변 — 타 기기 diff 무영향'); + // 대비군: reseal 없이는 무변경 파일이 push되지 않는다(비용·의미 없음) + const before = fake._store.get(`${OWNER}/${wsId}/vault/old.md`); + await syncCompany(wsId, OWNER); + assert.deepEqual(fake._store.get(`${OWNER}/${wsId}/vault/old.md`), before, 'reseal 아닌 사이클은 재업로드 없음'); +}); + +/* ── 세대 다운그레이드 게이트(분리 검수 HIGH-1 재현의 회귀 가드) ── + 시나리오: 열쇠 없는 기기 B의 사이클이 초기 읽기에서는 아직 평문 매니페스트를 받고(다른 기기 A의 + 재봉인이 그 사이 완료), 재읽기에서 v3를 만난다. 종전 코드는 재읽기 실패를 삼키고 B의 평문 + 매니페스트를 되써서 게이트를 다운그레이드했다(검수 실증). 불변식: 열지 못한 봉투 세대를 만난 + 사이클은 평문을 쓰지 않고 보류하며, 원격 매니페스트는 v3로 남는다. */ +test('세대 게이트: 재읽기에서 열 수 없는 v3를 만나면 평문 되쓰기 대신 사이클 보류(원격 v3 유지)', async () => { + // 준비 — A(DEK 보유)가 v3 회사를 구성 + clearDekCache(); + await loadDeviceE2ee(); + await setDek(Buffer.alloc(32, 8)); + const wsId = 'p1-downgrade'; + const wsRoot = join(ROOT, wsId); + const note = Buffer.from('# note\n'); + await mkdir(join(wsRoot, 'vault'), { recursive: true }); + await writeFile(join(wsRoot, 'vault', 'n.md'), note); + await writeFile(join(wsRoot, '.sync-state.json'), JSON.stringify({ files: {}, ts: 1000 })); + const fake = fakeStorage({ [`${OWNER}/${wsId}/__manifest__.json`]: Buffer.from('{"files":{}}') }); + _setSyncClientForTest(fake); + await syncCompany(wsId, OWNER); // A의 사이클 — 원격 매니페스트가 v3가 됨 + const v3Manifest = fake._store.get(`${OWNER}/${wsId}/__manifest__.json`); + assert.equal(v3Manifest.toString('utf8', 0, 14), 'argosecret.v3:', '전제: 원격 v3'); + + // B(열쇠 없음) 재현 — 키 파일에서 dek 제거 + const cur = JSON.parse(await readFile(join(ROOT, '.device-e2ee.json'), 'utf8')); + delete cur.dek; + await writeFile(join(ROOT, '.device-e2ee.json'), JSON.stringify(cur), { mode: 0o600 }); + clearDekCache(); + await loadDeviceE2ee(); + // B의 초기 읽기 1회만 "아직 평문"을 반환하는 스토리지 패치(검수 재현 방식) + const manifestKey = `${OWNER}/${wsId}/__manifest__.json`; + const bucket = fake.storage.from(); + const orig = bucket.download.bind(bucket); + let manifestReads = 0; + const patched = { + ...bucket, + async download(k) { + if (k === manifestKey) { + manifestReads++; + if (manifestReads === 1) return { data: { arrayBuffer: async () => new Uint8Array(Buffer.from('{"files":{}}')).buffer }, error: null }; + } + return orig(k); + }, + }; + _setSyncClientForTest({ ...fake, storage: { from: () => patched } }); + await assert.rejects( + () => syncCompany(wsId, OWNER), + /세대 상승|보류/, + 'B의 사이클은 평문을 쓰는 대신 보류된다', + ); + const after = fake._store.get(manifestKey); + assert.equal(after.toString('utf8', 0, 14), 'argosecret.v3:', '원격 매니페스트가 v3로 유지된다(다운그레이드 없음)'); +}); + +test.after(async () => { clearDekCache(); await rm(ROOT, { recursive: true, force: true }); });