+
+ {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 직속(