From 372c154ebc5a19853fc66ba2d176d4bdbe640677 Mon Sep 17 00:00:00 2001 From: beyondworks Date: Sat, 29 Aug 2026 12:50:57 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(e2ee):=20=EB=8B=A8=EA=B3=84=200=20?= =?UTF-8?q?=E2=80=94=20v3=20=EB=B4=89=ED=88=AC=C2=B7=EA=B8=B0=EA=B8=B0=20?= =?UTF-8?q?=ED=82=A4=EC=8C=8D=C2=B7=EB=AF=B8=EC=A7=80=20=EC=84=B8=EB=8C=80?= =?UTF-8?q?=20=EC=A0=84=EB=B0=A9=20=ED=98=B8=ED=99=98=20=EA=B2=8C=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=20(=EB=8F=99=EC=9E=91=20=EB=B6=88=EB=B3=80,=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=EB=A7=8C=20=EB=8F=99=EC=8A=B9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 유건 승인(2026-08-29): E2EE 권고안(기기 키쌍+승인 기본·복구 코드 강권·v3 직행, 정본 루트 E2EE-DESIGN.md — 커밋 금지 문서) 착수. 이 커밋은 롤아웃 단계 0 = "읽을 수 있는 코드 선배포". 스위치 없음·기본 봉인은 여전히 v2 — 동작 불변. - src/e2ee.mjs 신설: X25519 기기 키쌍(WS_ROOT/.device-e2ee.json, 0600·원자쓰기·손상 시 조용한 재생성 금지), DEK 동기 캐시(accountKey 패턴), DEK 랩/언랩(임시-정적 ECDH + AES-256-GCM, HKDF info에 양쪽 공개키 각인 = 바꿔치기 무효), device_keys 등록(1회 가드·실패 무해). - secretbox v3: MAGIC 'argosecret.v3:', 키 = HKDF(DEK). DEK 미보유 개봉 = 명확한 보류 오류. **전방 호환 게이트(핵심)**: openSecretCompat이 'argosecret.' 접두를 세대 불문 개봉기로 보낸다 — 정확 매칭만 걸면 미래 세대 암호문이 평문으로 통과해 로컬 오염·재봉인·전파된다(credSync 마커 사고의 세대 축 재발 차단). 미지 세대는 "앱 업데이트 필요" 보류. - 서버 스키마 4종(device_keys·wrapped_deks·recovery_wraps·key_mailbox) — 전부 공개키·암호문만, RLS own-only. account_keys(평문)와의 근본 차이. 단계 0은 device_keys 등록만 실사용. - cycle에 기기 공개키 등록 배선(실패 무해). permission-gate WS_DOT_FILES에 .device-e2ee.json· .tmp-e2ee- 등재(드리프트 스캐너가 미등재를 red로 잡음 — 기기 세션 파일과 동급 금고). - 테스트 7종: 핀(v1/v2/평문/마커 무회귀)·키 안정성/0600·v3 왕복·DEK 없는 보류·미지 세대 차단· 랩 왕복(두 기기+위변조+수신자 불일치 거부)·등록 배선·sync 홀드(v3+무DEK = failed 보류, 로컬 미기록·원격 미삭제). 변이 red: compat 게이트를 구식 정확 매칭으로 되돌리면 3건 red. 전체 1,022/1,022 · next build 통과. 마이그레이션은 머지 후 프로덕션 적용 예정(테이블은 단계 0에서 등록 외 미사용 — 불활성). Co-Authored-By: Claude Fable 5 --- src/e2ee.mjs | 129 ++++++++++++++ src/permission-gate.mjs | 1 + src/secretbox.mjs | 47 ++++- src/sync.mjs | 3 + .../20260829130000_e2ee_key_infra.sql | 56 ++++++ test/e2ee-v3.test.mjs | 167 ++++++++++++++++++ 6 files changed, 396 insertions(+), 7 deletions(-) create mode 100644 src/e2ee.mjs create mode 100644 supabase/migrations/20260829130000_e2ee_key_infra.sql create mode 100644 test/e2ee-v3.test.mjs diff --git a/src/e2ee.mjs b/src/e2ee.mjs new file mode 100644 index 00000000..d2ff059f --- /dev/null +++ b/src/e2ee.mjs @@ -0,0 +1,129 @@ +// 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 { mkdir, rename, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +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 persist(state, root) { + await mkdir(root, { recursive: true }); + // 생성 시점부터 0600 + rename 교체 — 개인키·DEK가 기본 모드로 노출되는 창을 없앤다(synccreds와 동일) + const tmp = join(root, `.tmp-e2ee-${process.pid}-${Date.now().toString(36)}`); + await writeFile(tmp, JSON.stringify(state, null, 2), { mode: 0o600 }); + await rename(tmp, fileOf(root)); +} + +/** 기기 E2EE 상태 로드 — 없으면 X25519 키쌍을 만들어 저장(최초 1회). 손상 시 재생성하지 않고 throw — + 조용한 키 교체는 기존 랩(wrapped_deks)을 전부 못 열게 만든다(정직 실패가 안전 방향). */ +export async function loadDeviceE2ee({ root = WS_ROOT } = {}) { + if (cache && cache.root === root) return cache.state; + let state = null; + let raw = null; + try { raw = readFileSync(fileOf(root), 'utf8'); } catch (e) { if (e.code !== 'ENOENT') throw e; } + if (raw != null) { + const d = JSON.parse(raw); // 손상 = throw(위 주석 — 키 유실을 조용히 덮지 않는다) + if (!d.pub || !d.priv) throw new Error('기기 E2EE 키 파일 손상 — pub/priv 누락'); + state = d; + } else { + const { publicKey, privateKey } = generateKeyPairSync('x25519'); + state = { v: 1, pub: publicKeyToRaw(publicKey).toString('base64'), priv: privateKeyToRaw(privateKey).toString('base64') }; + await persist(state, root); + } + cache = { root, state }; + dekBuf = state.dek ? Buffer.from(state.dek, 'base64') : null; + return state; +} + +/** 동기 DEK 접근 — secretbox 전용(accountKey와 동일 계약). 미보유면 null. */ +export const dek = () => dekBuf; + +/** DEK 수신·보관(P1: 켜기·기기 승인·복구 코드 경로가 호출). 디스크와 캐시를 함께 갱신. */ +export async function setDek(buf, { root = WS_ROOT } = {}) { + const state = await loadDeviceE2ee({ root }); + const next = { ...state, dek: Buffer.from(buf).toString('base64') }; + await persist(next, root); + 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..45a2e73c --- /dev/null +++ b/test/e2ee-v3.test.mjs @@ -0,0 +1,167 @@ +// 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 미보유가 기본'); + const st = await stat(join(ROOT, '.device-e2ee.json')); + assert.equal(st.mode & 0o777, 0o600, '개인키 파일은 0600'); +}); + +/* ── 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하지 않아야 한다 +}); + +/* ── 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 }); }); From 629ba2091ea520e65699ac9707bd3073c39f1b73 Mon Sep 17 00:00:00 2001 From: beyondworks Date: Sat, 29 Aug 2026 13:14:53 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(e2ee):=20=EB=B6=84=EB=A6=AC=20=EA=B2=80?= =?UTF-8?q?=EC=88=98=20HIGH=20=EB=B0=98=EC=98=81=20=E2=80=94=20=EC=B5=9C?= =?UTF-8?q?=EC=B4=88=20=ED=82=A4=20=EC=83=9D=EC=84=B1=EC=9D=98=20=ED=81=AC?= =?UTF-8?q?=EB=A1=9C=EC=8A=A4=20=ED=94=84=EB=A1=9C=EC=84=B8=EC=8A=A4=20?= =?UTF-8?q?=EA=B2=BD=ED=95=A9=20=EB=B4=89=EC=87=84(create-exclusive=20?= =?UTF-8?q?=EC=8A=B9=EC=9E=90=20=EA=B2=B0=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 검수가 실증한 결함: loadDeviceE2ee 최초 생성에 락이 없어 상주(:3001)+앱 사이드카 동시 기동 시 10회 중 9회 서로 다른 키쌍을 생성, rename 경합의 패자가 프로세스 수명 동안 디스크와 다른 "고아 키"로 동작(2026-08-14 기기 세션 사고와 동일 계열 — 그때 확립한 패턴을 신규 파일에 미적용). 단계 1이었다면 고아 키로 등록된 기기가 DEK 랩을 영구 미수신하는 조용한 잠금. - 인프로세스 withLock + 크로스 프로세스는 **link() create-exclusive 승자 결정**: tmp에 쓰고 link로 결합 — 목적지 존재 시 원자적 EEXIST라 승자가 정확히 하나, 패자는 디스크의 승자 키를 재독·채택. mkdir 락과 달리 stale 락 회수 문제가 없다. - setDek도 락 + 디스크 재독 병합(캐시 기반 덮어쓰기가 다른 프로세스의 dek를 지우는 lost-update 차단). - 회귀 테스트: 진짜 자식 프로세스 2개 동시 기동 × 5라운드 — 모든 프로세스의 반환 키 = 디스크 키 수렴 단언(2026-08-14 교훈 "재현은 진짜 자식 프로세스로"). 변이 red 실증: 락·link 없는 원구현 복귀 시 경합 테스트 red. - 검수 MEDIUM·LOW는 설계 정본(E2EE-DESIGN.md, 미커밋)에 반영: 위협 모델 §2-⑤ CLI 전권 러너 경로 명시 + §10-4 결정 문항 신설 + §10.5 P1 반영 사항(pubkey 불변 고정·mailbox 만료 앱 강제· 각인 vs SAS 구분·키 파일 쓰기 단일 경유). 전체 스위트 fail 0. Co-Authored-By: Claude Fable 5 --- src/e2ee.mjs | 80 +++++++++++++++++++++++++++++-------------- test/e2ee-v3.test.mjs | 30 ++++++++++++++++ 2 files changed, 84 insertions(+), 26 deletions(-) diff --git a/src/e2ee.mjs b/src/e2ee.mjs index d2ff059f..3f80c46b 100644 --- a/src/e2ee.mjs +++ b/src/e2ee.mjs @@ -8,8 +8,9 @@ // 단계 0에서는 keypair 생성·등록까지만 실사용되고 dek는 비어 있다(동작 불변). import { createCipheriv, createDecipheriv, createPrivateKey, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes } from 'node:crypto'; import { readFileSync } from 'node:fs'; -import { mkdir, rename, writeFile } from 'node:fs/promises'; +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'; @@ -27,45 +28,72 @@ const privateKeyToRaw = (keyObj) => keyObj.export({ format: 'der', type: 'pkcs8' let cache = null; // { root, state } — devicesession.mjs와 같은 root-키 캐시 패턴 let dekBuf = null; // 동기 캐시 — secretbox가 동기 함수라 여기서 읽는다(accountKey 패턴) -async function persist(state, root) { +async function writeTmp(state, root) { await mkdir(root, { recursive: true }); - // 생성 시점부터 0600 + rename 교체 — 개인키·DEK가 기본 모드로 노출되는 창을 없앤다(synccreds와 동일) + // 생성 시점부터 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 }); - await rename(tmp, fileOf(root)); + 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)을 전부 못 열게 만든다(정직 실패가 안전 방향). */ + 조용한 키 교체는 기존 랩(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; - let state = null; - let raw = null; - try { raw = readFileSync(fileOf(root), 'utf8'); } catch (e) { if (e.code !== 'ENOENT') throw e; } - if (raw != null) { - const d = JSON.parse(raw); // 손상 = throw(위 주석 — 키 유실을 조용히 덮지 않는다) - if (!d.pub || !d.priv) throw new Error('기기 E2EE 키 파일 손상 — pub/priv 누락'); - state = d; - } else { - const { publicKey, privateKey } = generateKeyPairSync('x25519'); - state = { v: 1, pub: publicKeyToRaw(publicKey).toString('base64'), priv: privateKeyToRaw(privateKey).toString('base64') }; - await persist(state, root); - } - cache = { root, state }; - dekBuf = state.dek ? Buffer.from(state.dek, 'base64') : null; - return 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: 켜기·기기 승인·복구 코드 경로가 호출). 디스크와 캐시를 함께 갱신. */ +/** DEK 수신·보관(P1: 켜기·기기 승인·복구 코드 경로가 호출). 디스크와 캐시를 함께 갱신. + 락 + 디스크 재독 병합(2026-08-14 원칙) — 캐시 기반 덮어쓰기는 다른 프로세스가 막 쓴 dek를 지운다. + DEK는 계정당 하나라 동시 수신은 같은 값이지만, 재독이 그 가정 없이도 안전하게 만든다. */ export async function setDek(buf, { root = WS_ROOT } = {}) { - const state = await loadDeviceE2ee({ root }); - const next = { ...state, dek: Buffer.from(buf).toString('base64') }; - await persist(next, root); - cache = { root, state: next }; - dekBuf = Buffer.from(buf); + 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; } diff --git a/test/e2ee-v3.test.mjs b/test/e2ee-v3.test.mjs index 45a2e73c..63403ae1 100644 --- a/test/e2ee-v3.test.mjs +++ b/test/e2ee-v3.test.mjs @@ -112,6 +112,36 @@ test('등록: device_keys upsert(공개키만), 프로세스당 1회, 실패해 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'); From 18e1a9299d4c76858b7b167a854806679b6295e6 Mon Sep 17 00:00:00 2001 From: beyondworks Date: Sat, 29 Aug 2026 13:27:29 +0900 Subject: [PATCH 3/3] =?UTF-8?q?test(e2ee):=200600=20=EB=8B=A8=EC=96=B8?= =?UTF-8?q?=EC=9D=84=20POSIX=20=ED=95=9C=EC=A0=95=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=E2=80=94=20Windows=20CI=20=EC=8B=A4=EC=B8=A1=20=EB=B0=98?= =?UTF-8?q?=EC=98=81(mode=3D0666/ACL)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit windows-latest 실측(검수 조건부): link() create-exclusive·자식 프로세스 경합 테스트는 통과, 유일 실패는 0600 mode 단언(Windows는 POSIX 모드 부재, 438 고정). 플랫폼 가드 + 정직 주석. Co-Authored-By: Claude Fable 5 --- test/e2ee-v3.test.mjs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/e2ee-v3.test.mjs b/test/e2ee-v3.test.mjs index 63403ae1..b896dd88 100644 --- a/test/e2ee-v3.test.mjs +++ b/test/e2ee-v3.test.mjs @@ -43,8 +43,12 @@ test('기기 키쌍: 최초 생성 후 재로드에 안정, 파일 0600, DEK는 assert.equal(a.pub, b.pub, '재로드에 키가 바뀌지 않는다(조용한 키 교체 금지)'); assert.equal(Buffer.from(a.pub, 'base64').length, 32, 'X25519 raw 32B'); assert.equal(dek(), null, '단계 0 — DEK 미보유가 기본'); - const st = await stat(join(ROOT, '.device-e2ee.json')); - assert.equal(st.mode & 0o777, 0o600, '개인키 파일은 0600'); + 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 없는 보류 ── */