diff --git a/src/e2ee.mjs b/src/e2ee.mjs new file mode 100644 index 00000000..3f80c46b --- /dev/null +++ b/src/e2ee.mjs @@ -0,0 +1,157 @@ +// E2EE 단계 0 — 기기 키쌍(X25519)·계정 DEK 로컬 보관·DEK 랩/언랩·기기 공개키 등록. +// 설계 정본: 루트 E2EE-DESIGN.md(§4~§7). 원칙: 서버에는 공개키와 암호문(랩)만 — 평문 열쇠는 +// 어떤 형태로도 서버에 두지 않는다(account_keys 평문 구조와의 근본 차이). +// +// 파일: WS_ROOT/.device-e2ee.json (0600) — 회사 폴더 밖이라 동기화 엔진이 걷지 않는다 +// (.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 { readFileSync } from 'node:fs'; +import { link, mkdir, rename, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { withLock } from './mutex.mjs'; +import { WS_ROOT } from './workspace.mjs'; + +const FILE = '.device-e2ee.json'; +const fileOf = (root) => join(root, FILE); + +// X25519 raw(32B) ↔ DER 고정 프리픽스 — Node KeyObject는 DER/JWK만 받으므로 상수로 감싼다. +// 값은 RFC 8410의 AlgorithmIdentifier 고정 인코딩(키와 무관한 구조 바이트라 시크릿 아님). +const SPKI_PREFIX = Buffer.from('302a300506032b656e032100', 'hex'); // + raw pub 32B +const PKCS8_PREFIX = Buffer.from('302e020100300506032b656e04220420', 'hex'); // + raw priv 32B +export const rawToPublicKey = (raw32) => createPublicKey({ key: Buffer.concat([SPKI_PREFIX, raw32]), format: 'der', type: 'spki' }); +const rawToPrivateKey = (raw32) => createPrivateKey({ key: Buffer.concat([PKCS8_PREFIX, raw32]), format: 'der', type: 'pkcs8' }); +const publicKeyToRaw = (keyObj) => keyObj.export({ format: 'der', type: 'spki' }).subarray(SPKI_PREFIX.length); +const privateKeyToRaw = (keyObj) => keyObj.export({ format: 'der', type: 'pkcs8' }).subarray(PKCS8_PREFIX.length); + +let cache = null; // { root, state } — devicesession.mjs와 같은 root-키 캐시 패턴 +let dekBuf = null; // 동기 캐시 — secretbox가 동기 함수라 여기서 읽는다(accountKey 패턴) + +async function writeTmp(state, root) { + await mkdir(root, { recursive: true }); + // 생성 시점부터 0600 — 개인키·DEK가 기본 모드로 노출되는 창을 없앤다(synccreds와 동일) + const tmp = join(root, `.tmp-e2ee-${process.pid}-${Date.now().toString(36)}`); + await writeFile(tmp, JSON.stringify(state, null, 2), { mode: 0o600 }); + return tmp; +} + +function readState(root) { + let raw = null; + try { raw = readFileSync(fileOf(root), 'utf8'); } catch (e) { if (e.code !== 'ENOENT') throw e; } + if (raw == null) return null; + const d = JSON.parse(raw); // 손상 = throw — 조용한 키 재생성은 기존 랩(wrapped_deks)을 전부 무효화한다 + if (!d.pub || !d.priv) throw new Error('기기 E2EE 키 파일 손상 — pub/priv 누락'); + return d; +} + +/** 기기 E2EE 상태 로드 — 없으면 X25519 키쌍을 만들어 저장(최초 1회). 손상 시 재생성하지 않고 throw — + 조용한 키 교체는 기존 랩(wrapped_deks)을 전부 못 열게 만든다(정직 실패가 안전 방향). + 경합 방어(분리 검수 HIGH — 2026-08-14 기기 세션 사고와 동일 계열, 상주:3001 + 앱 사이드카가 같은 + WS_ROOT에서 동시 기동): 인프로세스는 withLock, 크로스 프로세스는 **create-exclusive 승자 결정** — + tmp에 쓴 뒤 link()로 목적지에 결합한다. link는 목적지가 이미 있으면 원자적으로 EEXIST라 승자가 + 정확히 하나이고, 패자는 자기 키를 버리고 디스크의 승자 키를 재독·채택한다(고아 키 금지). + mkdir 락 방식과 달리 크래시 잔재(stale lock) 회수 문제가 아예 없다. */ +export async function loadDeviceE2ee({ root = WS_ROOT } = {}) { + if (cache && cache.root === root) return cache.state; + return withLock(`e2ee:${root}`, async () => { + if (cache && cache.root === root) return cache.state; // 락 대기 중 다른 호출이 이미 로드했을 수 있다 + let state = readState(root); + if (!state) { + const { publicKey, privateKey } = generateKeyPairSync('x25519'); + const fresh = { v: 1, pub: publicKeyToRaw(publicKey).toString('base64'), priv: privateKeyToRaw(privateKey).toString('base64') }; + const tmp = await writeTmp(fresh, root); + try { + await link(tmp, fileOf(root)); // 원자적 create-exclusive — 승자 하나만 성공 + state = fresh; + } catch (e) { + if (e.code !== 'EEXIST') throw e; + state = readState(root); // 패자 — 승자(다른 프로세스)의 키를 채택, 내 생성분은 폐기 + if (!state) throw e; // 극단 레이스(승자 파일이 사라짐) — 조용한 재생성 대신 정직 실패 + } finally { + await rm(tmp, { force: true }).catch(() => {}); + } + } + cache = { root, state }; + dekBuf = state.dek ? Buffer.from(state.dek, 'base64') : null; + return state; + }); +} + +/** 동기 DEK 접근 — secretbox 전용(accountKey와 동일 계약). 미보유면 null. */ +export const dek = () => dekBuf; + +/** DEK 수신·보관(P1: 켜기·기기 승인·복구 코드 경로가 호출). 디스크와 캐시를 함께 갱신. + 락 + 디스크 재독 병합(2026-08-14 원칙) — 캐시 기반 덮어쓰기는 다른 프로세스가 막 쓴 dek를 지운다. + DEK는 계정당 하나라 동시 수신은 같은 값이지만, 재독이 그 가정 없이도 안전하게 만든다. */ +export async function setDek(buf, { root = WS_ROOT } = {}) { + await loadDeviceE2ee({ root }); // 키쌍 보장(최초 생성 경합 방어 포함) + return withLock(`e2ee:${root}`, async () => { + const disk = readState(root); // 락 후 재독 — 캐시가 아니라 디스크가 병합 기준 + const next = { ...disk, dek: Buffer.from(buf).toString('base64') }; + const tmp = await writeTmp(next, root); + await rename(tmp, fileOf(root)); // 갱신은 원자 교체(존재하는 파일의 업데이트라 create-exclusive 아님) + cache = { root, state: next }; + dekBuf = Buffer.from(buf); + }); +} + +export function clearDekCache() { cache = null; dekBuf = null; } + +/* ── DEK 랩 — 임시-정적 X25519 ECDH + AES-256-GCM. 서버는 이 blob을 내용 못 보는 우편함으로만 나른다. ── + 형식: MAGICW(15) ∥ ephPub(32) ∥ IV(12) ∥ TAG(16) ∥ CT. HKDF info에 양쪽 공개키를 각인해 + 바꿔치기(서버가 다른 수신자 랩을 붙여주는 류)를 키 수준에서 무효화한다. */ +const MAGICW = Buffer.from('argokeywrap.v1:'); +const IV_LEN = 12; +const TAG_LEN = 16; +const wrapKey = (shared, ephPubRaw, recipientPubRaw) => + Buffer.from(hkdfSync('sha256', shared, Buffer.concat([ephPubRaw, recipientPubRaw]), 'argo-e2ee-keywrap-v1', 32)); + +/** recipientPubB64(기기 공개키 raw base64) 앞으로 DEK를 랩. */ +export function wrapDekFor(recipientPubB64, dekBytes) { + const recipientRaw = Buffer.from(recipientPubB64, 'base64'); + if (recipientRaw.length !== 32) throw new Error('수신 공개키 형식 오류(raw 32B 아님)'); + const eph = generateKeyPairSync('x25519'); + const ephPubRaw = publicKeyToRaw(eph.publicKey); + const shared = diffieHellman({ privateKey: eph.privateKey, publicKey: rawToPublicKey(recipientRaw) }); + const iv = randomBytes(IV_LEN); + const c = createCipheriv('aes-256-gcm', wrapKey(shared, ephPubRaw, recipientRaw), iv); + const ct = Buffer.concat([c.update(dekBytes), c.final()]); + return Buffer.concat([MAGICW, ephPubRaw, iv, c.getAuthTag(), ct]); +} + +/** 내 기기 개인키로 DEK 랩 개봉. 위변조·수신자 불일치는 GCM 태그가 거부한다(throw). */ +export async function openDekWrap(blob, { root = WS_ROOT } = {}) { + if (!blob.subarray(0, MAGICW.length).equals(MAGICW)) throw new Error('DEK 랩 형식 아님'); + const state = await loadDeviceE2ee({ root }); + const myPriv = rawToPrivateKey(Buffer.from(state.priv, 'base64')); + const myPubRaw = Buffer.from(state.pub, 'base64'); + let off = MAGICW.length; + const ephPubRaw = blob.subarray(off, off += 32); + const iv = blob.subarray(off, off += IV_LEN); + const tag = blob.subarray(off, off += TAG_LEN); + const ct = blob.subarray(off); + const shared = diffieHellman({ privateKey: myPriv, publicKey: rawToPublicKey(ephPubRaw) }); + const d = createDecipheriv('aes-256-gcm', wrapKey(shared, ephPubRaw, myPubRaw), iv); + d.setAuthTag(tag); + return Buffer.concat([d.update(ct), d.final()]); +} + +/* ── 기기 공개키 등록 — device_keys upsert(own-only RLS). 실패는 기능을 막지 않는다(단계 0은 + 등록부 구축이 전부) — 프로세스·오너당 1회만 시도하고, 테이블 부재(셀프호스트 등)는 1회 경고 후 침묵. ── */ +const registered = new Set(); // `${ownerId}` — 프로세스 수명 가드 +export async function ensureDeviceKeyRegistered(sb, ownerId, deviceId, { root = WS_ROOT } = {}) { + if (!sb || !ownerId || !deviceId || registered.has(ownerId)) return; + registered.add(ownerId); // 실패해도 재시도 안 함 — 등록은 다음 프로세스 기동이 자연 재시도 + try { + const state = await loadDeviceE2ee({ root }); + const { error } = await sb.from('device_keys').upsert( + { user_id: ownerId, device_id: deviceId, pubkey: state.pub }, + { onConflict: 'user_id,device_id' }, + ); + if (error) throw new Error(error.message); + } catch (e) { + console.warn('[argo] e2ee 기기 공개키 등록 보류(기능 무영향):', String(e.message).slice(0, 80)); + } +} +export function _resetRegisteredForTest() { registered.clear(); } diff --git a/src/permission-gate.mjs b/src/permission-gate.mjs index 964ee371..2378411b 100644 --- a/src/permission-gate.mjs +++ b/src/permission-gate.mjs @@ -246,6 +246,7 @@ const WS_DOT_FILES = new Set([ '.gateway-', '.gateway.lock', // WS_ROOT 직속(/../.x) — 전 회사 공용 기기 상태·계정 자격 '.device-session.json', '.tmp-devsess-', // 세션 본체 + 원자적 쓰기 임시본(토큰 평문) + '.device-e2ee.json', '.tmp-e2ee-', // E2EE 기기 개인키·DEK + 원자적 쓰기 임시본 — cat 한 줄이 "본인만 여는 열쇠" 유출(세션 파일과 동급 최악) '.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 2912336b..c624a4cf 100644 --- a/src/secretbox.mjs +++ b/src/secretbox.mjs @@ -4,9 +4,12 @@ import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from 'node:crypto'; import { loadSyncCreds } from './synccreds.mjs'; import { accountKey } from './accountkey.mjs'; +import { dek } from './e2ee.mjs'; +const MAGIC3 = Buffer.from('argosecret.v3:'); // E2EE — 키가 사용자 기기에만(DEK). 서버는 절대 못 연다. const MAGIC2 = Buffer.from('argosecret.v2:'); const MAGIC1 = Buffer.from('argosecret.v1:'); +const GENERATION = Buffer.from('argosecret.'); // 세대 공통 접두 — 미지 세대의 평문 통과를 막는 전방 호환 게이트 const IV_LEN = 12; const TAG_LEN = 16; @@ -25,6 +28,19 @@ function key2() { return k2; } +// v3 키 — 계정 DEK(사용자 기기에만 존재)에서 파생. DEK 미보유 = 이 기기가 아직 승인 전이라는 뜻이고, +// 그때의 개봉 시도는 명확한 오류로 보류된다(per-file catch가 잡아 다음 사이클 재시도 — 불가시 홀드). +let k3 = null, k3src = null; +function key3() { + const dk = dek(); + if (!dk) throw new Error('E2EE 봉투(v3) — 이 기기에 아직 열쇠가 없습니다(기기 승인·복구 대기)'); + if (!k3 || k3src !== dk) { + k3 = Buffer.from(hkdfSync('sha256', dk, 'argo-e2ee-v3', 'secretbox', 32)); + k3src = dk; + } + return k3; +} + // v1 레거시 키 — 서비스 키 HKDF (열기 전용) const serviceKey = () => process.env.SUPABASE_SERVICE_ROLE_KEY || loadSyncCreds()?.key || null; let k1 = null, k1src = null; @@ -65,10 +81,12 @@ export const encVaultOn = () => process.env.ARGO_ENC_VAULT === '1'; export const isEncRel = (rel) => isSecretRel(rel) || encVaultOn(); /** 봉투/레거시 평문 겸용 개봉 — 봉투 도입 전에 클라우드에 올라간 평문(mcp.json 등)을 수용한다. - 평문이면 그대로 반환하고, 다음 로컬 변경 push에서 봉투로 승격된다. */ + 평문이면 그대로 반환하고, 다음 로컬 변경 push에서 봉투로 승격된다. + ⚠ 전방 호환 게이트(E2EE 단계 0): 'argosecret.' 접두는 **세대 불문** 개봉기로 보낸다 — 정확 매칭만 + 걸면 미래 세대(v3, v4…)가 평문으로 통과해 로컬 파일이 암호문으로 오염·재봉인·전파된다 + (credSync 마커 사고와 동일 계열을 세대 축에서 원천 차단). 모르는 세대는 개봉기가 throw로 보류한다. */ export function openSecretCompat(buf) { - const enveloped = buf.subarray(0, MAGIC2.length).equals(MAGIC2) || buf.subarray(0, MAGIC1.length).equals(MAGIC1); - return enveloped ? openSecret(buf) : buf; + return buf.subarray(0, GENERATION.length).equals(GENERATION) ? openSecret(buf) : buf; } /** 평문 → v2 봉투(MAGIC ∥ iv ∥ tag ∥ ct). */ @@ -79,13 +97,28 @@ export function sealSecret(buf) { return Buffer.concat([MAGIC2, iv, c.getAuthTag(), ct]); } -/** 봉투 → 평문 (v2/v1 디스패치). 위변조·형식 불일치는 throw — 조용히 깨진 평문을 쓰지 않는다. */ +/** v3(E2EE) 봉인 — 키는 사용자 기기의 DEK에서만 파생. 단계 1(옵트인)부터 쓰기 경로가 호출한다. */ +export function sealSecretV3(buf) { + const iv = randomBytes(IV_LEN); + const c = createCipheriv('aes-256-gcm', key3(), iv); + const ct = Buffer.concat([c.update(buf), c.final()]); + return Buffer.concat([MAGIC3, iv, c.getAuthTag(), ct]); +} + +/** 봉투 → 평문 (v3/v2/v1 디스패치). 위변조·형식 불일치는 throw — 조용히 깨진 평문을 쓰지 않는다. + 미지 세대('argosecret.' 접두인데 아는 MAGIC이 아님)도 throw — 구버전이 신세대 암호문을 다루게 될 때 + 평문 오인이 아니라 보류가 되도록(per-file catch가 잡아 다음 사이클 재시도, 앱 업데이트가 해소). */ export function openSecret(buf) { - const k = buf.subarray(0, MAGIC2.length).equals(MAGIC2) ? key2() + const k = buf.subarray(0, MAGIC3.length).equals(MAGIC3) ? key3() + : buf.subarray(0, MAGIC2.length).equals(MAGIC2) ? key2() : buf.subarray(0, MAGIC1.length).equals(MAGIC1) ? key1() : null; - if (!k) throw new Error('시크릿 봉투 형식 아님'); - const off = MAGIC2.length; // v1/v2 MAGIC 길이 동일 + if (!k) { + throw new Error(buf.subarray(0, GENERATION.length).equals(GENERATION) + ? '미지 봉투 세대 — 이 파일을 읽으려면 앱 업데이트가 필요합니다(보류)' + : '시크릿 봉투 형식 아님'); + } + const off = MAGIC2.length; // v1/v2/v3 MAGIC 길이 동일(14) const iv = buf.subarray(off, off + IV_LEN); const tag = buf.subarray(off + IV_LEN, off + IV_LEN + TAG_LEN); const ct = buf.subarray(off + IV_LEN + TAG_LEN); diff --git a/src/sync.mjs b/src/sync.mjs index e6684e09..41cfbb66 100644 --- a/src/sync.mjs +++ b/src/sync.mjs @@ -26,6 +26,7 @@ import { cryptoOn, isSecretRel, isEncRel, encVaultOn, sealSecret, openSecret, op import { loadSyncCreds, credsEpoch } from './synccreds.mjs'; import { loadDeviceSession, getFreshDeviceSession } from './devicesession.mjs'; import { ensureAccountKey } from './accountkey.mjs'; +import { ensureDeviceKeyRegistered } from './e2ee.mjs'; import { syncEntitled } from './entitlement.mjs'; import { resolveRunner } from './runners.mjs'; // 리더 양보 판단 — 이 기기에서 턴을 돌릴 러너가 있는가 import { invalidatePath } from './memindex.mjs'; // 원격 mtime을 심는 수신 쓰기의 캐시 무효화 @@ -888,6 +889,8 @@ async function cycle() { // 계정 키 확보 — 크레덴셜 봉투(v2)의 열쇠. 실패해도 사이클은 계속(크레덴셜만 이번 사이클 제외). 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(() => {}); // 로컬 회사 수집 (ownerId 있는 것만 — 소유자가 있어야 클라우드에 자리가 있다) // 세션(JWT) 모드는 현재 계정 소유가 아닌 회사를 제외한다 — 다른 계정 소유의 로컬 사본(계정 전환· // 기기 공유 흔적)을 매 사이클 남의 폴더로 밀다 스토리지 격리(RLS)에 막혀 "row-level security" diff --git a/supabase/migrations/20260829130000_e2ee_key_infra.sql b/supabase/migrations/20260829130000_e2ee_key_infra.sql new file mode 100644 index 00000000..8648597e --- /dev/null +++ b/supabase/migrations/20260829130000_e2ee_key_infra.sql @@ -0,0 +1,56 @@ +-- E2EE 키 인프라(단계 0) — 서버에는 공개키와 암호문(랩)만 놓인다. 평문 열쇠 컬럼은 어디에도 없다. +-- account_keys(평문 key_b64)와의 근본 차이가 이 파일의 존재 이유다. 설계 정본: 루트 E2EE-DESIGN.md §5. +-- 단계 0에서는 device_keys 등록만 실사용되고 나머지 3종은 P1(켜기·기기 승인·복구 코드)이 쓴다. +-- RLS는 전부 본인 행만(all-ops own-only) — 서비스 롤이 우회해 읽어도 전부 공개키·암호문뿐이다. + +create table if not exists public.device_keys ( + user_id uuid not null references auth.users (id) on delete cascade, + device_id text not null, + pubkey text not null, -- X25519 raw 32B base64 (공개키 — 비밀 아님) + created_at timestamptz not null default now(), + revoked_at timestamptz, -- 기기 제거 시각(P1) — 행 삭제 대신 폐기 표기(감사 가능) + primary key (user_id, device_id) +); +alter table public.device_keys enable row level security; +drop policy if exists device_keys_own on public.device_keys; +create policy device_keys_own on public.device_keys + for all to authenticated using (user_id = (select auth.uid())) with check (user_id = (select auth.uid())); + +create table if not exists public.wrapped_deks ( + user_id uuid not null references auth.users (id) on delete cascade, + device_id text not null, + wrap text not null, -- argokeywrap.v1 blob base64 (해당 기기 공개키로 랩된 DEK) + wrapped_by text, -- 랩을 만든 기기(감사) + created_at timestamptz not null default now(), + primary key (user_id, device_id) +); +alter table public.wrapped_deks enable row level security; +drop policy if exists wrapped_deks_own on public.wrapped_deks; +create policy wrapped_deks_own on public.wrapped_deks + for all to authenticated using (user_id = (select auth.uid())) with check (user_id = (select auth.uid())); + +create table if not exists public.recovery_wraps ( + user_id uuid primary key references auth.users (id) on delete cascade, + wrap text not null, -- 복구 코드 유도 KEK로 랩된 DEK + kdf jsonb not null, -- {alg:'scrypt', N, r, p, salt} — 파라미터(비밀 아님) + created_at timestamptz not null default now() +); +alter table public.recovery_wraps enable row level security; +drop policy if exists recovery_wraps_own on public.recovery_wraps; +create policy recovery_wraps_own on public.recovery_wraps + for all to authenticated using (user_id = (select auth.uid())) with check (user_id = (select auth.uid())); + +create table if not exists public.key_mailbox ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users (id) on delete cascade, + to_device text not null, + from_device text not null, + wrap text not null, -- 승인 시 전달되는 DEK 랩(내용은 서버가 못 봄) + purpose text not null default 'device-approve', + created_at timestamptz not null default now(), + expires_at timestamptz not null default (now() + interval '15 minutes') +); +alter table public.key_mailbox enable row level security; +drop policy if exists key_mailbox_own on public.key_mailbox; +create policy key_mailbox_own on public.key_mailbox + for all to authenticated using (user_id = (select auth.uid())) with check (user_id = (select auth.uid())); diff --git a/test/e2ee-v3.test.mjs b/test/e2ee-v3.test.mjs new file mode 100644 index 00000000..b896dd88 --- /dev/null +++ b/test/e2ee-v3.test.mjs @@ -0,0 +1,201 @@ +// E2EE 단계 0 — v3 봉투(사용자 전용 DEK)·기기 키쌍·미지 봉투 안전 보류. +// 핀(PIN): v1/v2·평문 관용 개봉의 현행 동작을 먼저 고정한다(인접 회귀 방지 — 수정보다 먼저). +// 핵심 위험(P0-1의 존재 이유): 'argosecret.'으로 시작하는 미지 세대(v3)를 구식 관용 개봉이 +// **평문으로 통과**시키면 로컬 파일이 암호문으로 오염되고 재봉인·전파된다(마커 사고와 동일 계열). +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, rm, readFile, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const ROOT = await mkdtemp(join(tmpdir(), 'argo-e2ee-')); +process.env.ARGO_ROOT = ROOT; + +const { + sealSecret, sealSecretV3, openSecret, openSecretCompat, CRED_WITHDRAWN, +} = await import('../src/secretbox.mjs'); +const { ensureAccountKey, clearAccountKey } = await import('../src/accountkey.mjs'); +const { + loadDeviceE2ee, dek, setDek, clearDekCache, wrapDekFor, openDekWrap, + ensureDeviceKeyRegistered, _resetRegisteredForTest, +} = await import('../src/e2ee.mjs'); +const fakeKeySb = (b64) => ({ from: () => ({ select: () => ({ eq: () => ({ maybeSingle: async () => ({ data: { key_b64: b64 }, error: null }) }) }) }) }); + +/* ── PIN: 현행 관용 개봉 계약 ── */ +test('PIN: 평문은 그대로 통과, v2는 개봉, 마커·비봉투는 현행 그대로(무회귀)', async () => { + clearAccountKey(); + await ensureAccountKey(fakeKeySb(Buffer.alloc(32, 3).toString('base64')), 'o-e2ee-pin'); + try { + const plain = Buffer.from('그냥 평문 노트'); + assert.deepEqual(openSecretCompat(plain), plain, '평문 passthrough 유지'); + const sealed = sealSecret(plain); + assert.ok(sealed.toString('utf8', 0, 14) === 'argosecret.v2:', '기본 봉인은 여전히 v2(단계 0 = 동작 불변)'); + assert.deepEqual(openSecretCompat(sealed), plain, 'v2 관용 개봉 유지'); + assert.throws(() => openSecret(CRED_WITHDRAWN), '마커는 여전히 개봉 거부'); + } finally { clearAccountKey(); } +}); + +/* ── 기기 키 파일 — 생성·안정성·권한 ── */ +test('기기 키쌍: 최초 생성 후 재로드에 안정, 파일 0600, DEK는 비어 시작(단계 0 = 동작 불변)', async () => { + clearDekCache(); + const a = await loadDeviceE2ee(); + const b = await loadDeviceE2ee(); + assert.equal(a.pub, b.pub, '재로드에 키가 바뀌지 않는다(조용한 키 교체 금지)'); + assert.equal(Buffer.from(a.pub, 'base64').length, 32, 'X25519 raw 32B'); + assert.equal(dek(), null, '단계 0 — DEK 미보유가 기본'); + if (process.platform !== 'win32') { + // Windows는 POSIX 모드가 없어(ACL 기반) mode 비트가 0666으로 보인다 — 0600 보호는 POSIX 축 한정 + // 속성이고, Windows에서 mode:0o600 지정은 무해한 no-op이다(실측: windows-latest CI 438). + const st = await stat(join(ROOT, '.device-e2ee.json')); + assert.equal(st.mode & 0o777, 0o600, '개인키 파일은 0600(POSIX)'); + } +}); + +/* ── v3 왕복 + DEK 없는 보류 ── */ +test('v3 봉투: DEK 보유 시 왕복, 미보유 시 명확한 보류 오류(평문 오인 절대 금지)', async () => { + clearDekCache(); + await loadDeviceE2ee(); + await setDek(Buffer.alloc(32, 9)); + const plain = Buffer.from('회사 기억 — 사용자만 연다'); + const sealed = sealSecretV3(plain); + assert.equal(sealed.toString('utf8', 0, 14), 'argosecret.v3:', 'v3 MAGIC'); + assert.deepEqual(openSecretCompat(sealed), plain, '관용 개봉이 v3를 연다'); + // DEK 제거 상태 재현 — 별도 임시 루트(키 파일에 dek가 없음) + const root2 = await mkdtemp(join(tmpdir(), 'argo-e2ee2-')); + clearDekCache(); + await loadDeviceE2ee({ root: root2 }); + assert.equal(dek(), null); + assert.throws(() => openSecretCompat(sealed), /열쇠가 없습니다|기기 승인/, 'DEK 없으면 보류 오류 — 평문 통과 금지'); + await rm(root2, { recursive: true, force: true }); +}); + +/* ── 전방 호환 게이트 — 미지 세대의 평문 통과 금지(이 스토리의 핵심 안전 속성) ── */ +test('미지 세대(v9)도 관용 개봉을 평문으로 통과하지 못한다 — 보류 오류', () => { + const fake = Buffer.concat([Buffer.from('argosecret.v9:'), Buffer.alloc(64, 1)]); + assert.throws(() => openSecretCompat(fake), /미지 봉투 세대|앱 업데이트/, '미래 세대 암호문이 로컬 파일을 오염시키지 않는다'); +}); + +/* ── DEK 랩 왕복 — 두 기기 시뮬(기존 기기 → 새 기기) + 위변조 거부 ── */ +test('DEK 랩: 새 기기 공개키로 랩 → 새 기기만 개봉, 위변조는 GCM이 거부', async () => { + // 새 기기 = 별도 루트 + const newRoot = await mkdtemp(join(tmpdir(), 'argo-e2ee-new-')); + clearDekCache(); + const newDev = await loadDeviceE2ee({ root: newRoot }); + const DEK = Buffer.alloc(32, 7); + const wrap = wrapDekFor(newDev.pub, DEK); + assert.ok(wrap.toString('utf8', 0, 15) === 'argokeywrap.v1:', '랩 형식'); + const opened = await openDekWrap(wrap, { root: newRoot }); + assert.deepEqual(opened, DEK, '새 기기가 DEK를 복원한다'); + // 위변조 — 마지막 바이트 뒤집기 + const tampered = Buffer.from(wrap); tampered[tampered.length - 1] ^= 0xff; + await assert.rejects(async () => openDekWrap(tampered, { root: newRoot }), '위변조 랩은 개봉 거부'); + // 다른 기기(엉뚱한 개인키)는 못 연다 + const otherRoot = await mkdtemp(join(tmpdir(), 'argo-e2ee-other-')); + clearDekCache(); + await loadDeviceE2ee({ root: otherRoot }); + await assert.rejects(async () => openDekWrap(wrap, { root: otherRoot }), '수신자 아닌 기기는 개봉 불가'); + await rm(newRoot, { recursive: true, force: true }); + await rm(otherRoot, { recursive: true, force: true }); +}); + +/* ── 기기 공개키 등록 배선 — upsert 내용·1회 가드·실패 무해 ── */ +test('등록: device_keys upsert(공개키만), 프로세스당 1회, 실패해도 기능 무영향', async () => { + clearDekCache(); _resetRegisteredForTest(); + await loadDeviceE2ee(); + const rows = []; + const sb = { from: (t) => ({ upsert: async (row, opts) => { rows.push({ t, row, opts }); return { error: null }; } }) }; + await ensureDeviceKeyRegistered(sb, 'o-reg', 'dev-1'); + await ensureDeviceKeyRegistered(sb, 'o-reg', 'dev-1'); + assert.equal(rows.length, 1, '오너당 1회만'); + assert.equal(rows[0].t, 'device_keys'); + assert.deepEqual(Object.keys(rows[0].row).sort(), ['device_id', 'pubkey', 'user_id'], '공개키 외 아무것도 안 올린다(개인키·DEK 부재)'); + assert.equal(Buffer.from(rows[0].row.pubkey, 'base64').length, 32); + // 실패 무해 — 테이블 부재 등 + _resetRegisteredForTest(); + const bad = { from: () => ({ upsert: async () => ({ error: { message: 'relation does not exist' } }) }) }; + await ensureDeviceKeyRegistered(bad, 'o-reg2', 'dev-1'); // throw하지 않아야 한다 +}); + +/* ── 크로스 프로세스 최초 생성 경합(분리 검수 HIGH 재현의 회귀 가드) — 진짜 자식 프로세스 2개 ── + 2026-08-14 기기 세션 사고와 동일 계열: 상주(:3001)와 앱 사이드카가 같은 WS_ROOT에서 동시 기동한다. + 락 없던 구현은 10회 중 9회 서로 다른 키를 만들고 한쪽이 디스크와 다른 "고아 키"로 남았다(검수 실측). + 불변식: 어느 프로세스가 이기든, **모든 프로세스의 반환 키 = 디스크의 키** 하나로 수렴한다. */ +test('경합: 두 독립 프로세스가 동시에 최초 생성해도 같은 키로 수렴한다(고아 키 금지)', async () => { + const { execFile } = await import('node:child_process'); + const { promisify } = await import('node:util'); + const run = promisify(execFile); + const script = join(ROOT, 'race-child.mjs'); + const { writeFile: wf } = await import('node:fs/promises'); + await wf(script, ` + process.env.ARGO_ROOT = process.argv[2]; + const { loadDeviceE2ee } = await import(${JSON.stringify(new URL('../src/e2ee.mjs', import.meta.url).href)}); + const s = await loadDeviceE2ee(); + console.log(s.pub); + `); + for (let round = 0; round < 5; round++) { + const raceRoot = await mkdtemp(join(tmpdir(), 'argo-e2ee-race-')); + const [a, b] = await Promise.all([ + run(process.execPath, [script, raceRoot]), + run(process.execPath, [script, raceRoot]), + ]); + const pubA = a.stdout.trim(), pubB = b.stdout.trim(); + const disk = JSON.parse(await readFile(join(raceRoot, '.device-e2ee.json'), 'utf8')).pub; + assert.equal(pubA, disk, `round ${round}: 프로세스 A의 키가 디스크와 일치(고아 키 없음)`); + assert.equal(pubB, disk, `round ${round}: 프로세스 B의 키가 디스크와 일치(고아 키 없음)`); + await rm(raceRoot, { recursive: true, force: true }); + } +}); + +/* ── sync 통합: v3 원격 파일 + DEK 미보유 기기 = 보류(불가시 홀드) — 오염·오삭제 없음 ── */ +test('sync 홀드: DEK 없는 기기가 v3 원격 파일을 만나면 failed 보류 — 로컬 미기록·원격 미삭제', async () => { + const { syncCompany, _setSyncClientForTest } = await import('../src/sync.mjs'); + const { mkdir, writeFile } = await import('node:fs/promises'); + const { existsSync } = await import('node:fs'); + // 송신측 DEK로 v3 봉인 → 수신측(이 프로세스)은 DEK 캐시를 비워 "승인 전 기기" 재현 + clearDekCache(); + await loadDeviceE2ee(); + await setDek(Buffer.alloc(32, 5)); + const cipher = sealSecretV3(Buffer.from('# 다른 기기의 E2EE 노트\n')); + clearDekCache(); + const root3 = await mkdtemp(join(tmpdir(), 'argo-e2ee-hold-')); + process.env.ARGO_ROOT_IGNORED = root3; // (주석용) — syncCompany는 모듈 로드 시 고정된 WS_ROOT(ROOT)를 쓴다 + await loadDeviceE2ee(); // ROOT의 키 파일에는 dek가 저장돼 있으므로, 캐시만 비우면 안 된다 — + // 위 setDek가 ROOT 파일에 dek를 남겼다. 승인 전 기기를 정확히 재현하려면 dek 없는 키 파일이어야 + // 하므로 파일을 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, '전제: 이 기기는 DEK 미보유(승인 전)'); + + const wsId = 'e2ee-hold'; + const wsRoot = join(ROOT, wsId); + await mkdir(join(wsRoot, 'vault'), { recursive: true }); + await writeFile(join(wsRoot, '.sync-state.json'), JSON.stringify({ files: {}, ts: 1000 })); + const key = (rel) => `o/${wsId}/${rel}`; + const store = new Map([ + [key('__manifest__.json'), Buffer.from(JSON.stringify({ files: { 'vault/note.md': { m: 2000, s: cipher.length, h: 'x'.repeat(16) } } }))], + [key('vault/note.md'), cipher], + ]); + const bucket = { + _removed: [], + 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); bucket._removed.push(k); } return { error: null }; }, + async list() { return { data: [] }; }, + }; + _setSyncClientForTest({ _store: store, storage: { from: () => bucket }, createBucket: async () => ({}) }); + const r = await syncCompany(wsId, 'o'); + assert.ok(r.failed >= 1, 'v3 파일은 보류(failed) — 다음 사이클 재시도(DEK 도착·업데이트가 해소)'); + assert.ok(!existsSync(join(wsRoot, 'vault', 'note.md')), '암호문·평문 어느 쪽도 로컬에 기록되지 않는다(오염 차단)'); + assert.ok(store.has(key('vault/note.md')), '원격 원본은 그대로(오삭제 없음)'); + assert.equal(bucket._removed.length, 0, 'remove 미발생'); + await rm(root3, { recursive: true, force: true }); +}); + +test.after(async () => { clearAccountKey(); clearDekCache(); await rm(ROOT, { recursive: true, force: true }); });