diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 303d5a0..35510a8 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -276,6 +276,14 @@ Bearer 토큰 입력 칸은 없앴다. API 키(`api-key-create`)는 REST 자동 - ⚠ **롤백 주의**: 이 변경 뒤에 새로 정하거나 다시 저장된 해시는 정규화한 값으로 만들어진다. 이전 릴리즈(v2026.10.18 이하)로 되돌리면 정규화로 모양이 바뀌는 비밀번호(전각 문자, 조합형 한글, 보이지 않는 문자 등)를 쓰는 사용자는 다시 배포할 때까지 비밀번호 로그인이 막힌다. ASCII 비밀번호는 영향이 없다. - 로그인·MFA·비밀번호 변경 요청 본문은 64 KiB까지만 받는다(`MAX_LOGIN_BODY_BYTES`). 비밀번호 길이 제한 대신 해시 부담 공격을 막는 장치다. +### 앱 비밀번호 + +앱 비밀번호는 서버가 만든다(위 비밀번호 규칙은 적용하지 않는다). 소문자 28자(4자씩 7묶음, 약 131비트)이고 발급할 때 한 번만 보인다. 사용자당 여러 개를 만들 수 있고, 하나씩 철회할 수 있고, 마지막 사용 시각이 남는다. + +- 저장은 `sha256$`다. 엔트로피가 충분해 빠른 해시로 두고, 로그인 때 해시값으로 바로 찾는다. 서버 키를 쓰지 않는 이유는 `store/auth.ts` 주석에 있다. 자체 발급 토큰도 같다. +- 2026-10-18 이전에 발급한 16자 앱 비밀번호는 그대로 쓸 수 있다(재발급을 권하지만 강제하지 않는다). +- ⚠ **롤백 주의**: 이 릴리즈 뒤에 발급한 앱 비밀번호·토큰(`sha256$`)은 이전 릴리즈에서 로그인되지 않는다. 되돌리면 그 메일 앱들이 다시 배포하거나 재발급할 때까지 멈춘다. + ### 기본 비밀번호로 메일에 접속하는 계정 찾기 (앱 비밀번호 전환 준비) 메일 프로토콜(IMAP·POP3·SMTP 제출·JMAP·ManageSieve)은 앱 비밀번호로 옮겨 간다(회원 공통 수치 2026-10-18). 기본 비밀번호로 접속하던 길은 diff --git a/docs/PROTOCOLS.md b/docs/PROTOCOLS.md index 525a4aa..b6542a4 100644 --- a/docs/PROTOCOLS.md +++ b/docs/PROTOCOLS.md @@ -345,6 +345,7 @@ SPF+DKIM pass · From 도메인 정렬(SPF 또는 DKIM) · DMARC p=none 이상 | 가입 | 관리자 생성만(`account-create`, 콘솔 관리자는 `admin-user-create`). 공개 가입·초대·JIT 없음 | `admin-cmd/accounts.ts`, `admin-users.ts` | | 식별자 | `accounts.id`(ULID) 불변, `email` 전역 유일·소문자. 주소 변경 명령 없음 | SCHEMA §4 | | 저장 | scrypt(N=16384,r=8,p=1) + SCRAM-SHA-256 세그먼트 병기. 해시 전 SASLprep 정규화(정규화 전 기존 해시는 원문으로 확인 뒤 재저장) | `store/auth.ts`, `core/saslprep.ts` | +| 앱 비밀번호 | 서버 생성 소문자 28자(≈131비트), 한 번만 노출, 여러 개·개별 철회·마지막 사용 시각. `sha256$` 저장, 로그인 때 해시로 바로 조회(scrypt 없음). 이전 16자는 계속 받음 | `store/auth.ts` | | 비밀번호 규칙 | 사람이 정하는 비밀번호만: 8자 이상, 4종 가운데 3종 이상, 최대 길이 없음. 로그인·변경 본문 64 KiB 상한. 기존 비밀번호는 막지 않음 | `core/password-policy.ts` | | 기본 비밀번호 교체 | `account-password-set` — kind=0 전부 교체, **앱 비밀번호·OAuth 토큰은 유지** | `setPrimaryPassword` | | 부가 자격증명 철회 | `credential-revoke` 즉시. 기본 비밀번호는 철회 불가(정지를 쓴다) | `revokeCredential` | diff --git a/docs/SCHEMA.md b/docs/SCHEMA.md index 7f403a3..ec05dfe 100644 --- a/docs/SCHEMA.md +++ b/docs/SCHEMA.md @@ -222,6 +222,8 @@ CREATE TABLE credentials ( label VARCHAR(190), secret TEXT NOT NULL, -- 자기서술 포맷: "scrypt$N$r$p$salt$hash[ scram256$iter$salt$storedKey$serverKey]" (store/auth.ts — 원안의 argon2id는 node 내장이 아니라 scrypt로 갔다) -- SCRAM 키는 다음 PLAIN 로그인 때 지연 생성 (포맷 버전이 앞에) + -- 서버가 만든 무작위 비밀(2026-10-18 이후 발급한 앱 비밀번호·자체 토큰)은 "sha256$" — + -- 128비트 넘는 엔트로피라 느린 해시가 필요 없고, 로그인 때 해시값으로 바로 찾는다(store/auth.ts) scopes VARCHAR(190), last_used_at BIGINT, created_at BIGINT NOT NULL diff --git a/packages/api/test/api.test.ts b/packages/api/test/api.test.ts index ee644f1..50b8541 100644 --- a/packages/api/test/api.test.ts +++ b/packages/api/test/api.test.ts @@ -699,7 +699,7 @@ describe("AdminApiServer", () => { expect(createRes.status).toBe(200); const created = (await createRes.json()) as { id: string; label: string; password: string }; expect(created.label).toBe("iPhone Mail"); - expect(created.password).toMatch(/^[a-z]{4}-[a-z]{4}-[a-z]{4}-[a-z]{4}$/); + expect(created.password).toMatch(/^[a-z]{4}(-[a-z]{4}){6}$/); // 발급된 평문으로 실제 로그인이 되어야 한다(store 인증 경로까지 태운다) expect((await authenticate(f.db, "ap@acme.test", created.password, "imap"))?.accountId).toBe(accountId); diff --git a/packages/store/src/auth.ts b/packages/store/src/auth.ts index b0f89b5..872bc97 100644 --- a/packages/store/src/auth.ts +++ b/packages/store/src/auth.ts @@ -1,7 +1,8 @@ /** * 자격증명 (SCHEMA.md §4 credentials). * secret은 자기서술 포맷 — Phase 0은 scrypt (node:crypto 내장, 의존성 제로 원칙). - * 포맷: `scrypt$N$r$p$saltB64$hashB64[ scram256$i$salt$storedKey$serverKey]`. + * 포맷: `scrypt$N$r$p$saltB64$hashB64[ scram256$i$salt$storedKey$serverKey]`(사람이 고른 비밀번호). + * 서버가 만든 무작위 비밀(새 앱 비밀번호·자체 토큰)은 `sha256$` — 아래 `RANDOM_SECRET_PREFIX`. * 두 세그먼트를 **함께** 둔다 — SCRAM으로 갈아타면 PBKDF2가 scrypt를 대체해 오프라인 * 대입 내성이 내려간다. 옛 행은 첫 세그먼트만 있고, 첫 로그인 때 SCRAM 키가 지연 생성된다. * @@ -9,7 +10,8 @@ * modseq 클레임 없이 단순 삽입 (§3-3 전역 불변식은 "계정 메일 데이터" 쓰기에 적용). */ import { randomBytes, scrypt as scryptCb, timingSafeEqual, type ScryptOptions } from "node:crypto"; -import { ulid } from "@ionosphere/core"; +import { sha256hex, ulid } from "@ionosphere/core"; +import { queryInChunks } from "./chunk.ts"; import { ACCOUNT_STATUS, CREDENTIAL_KIND, credentialKindName, type CredentialKind, type CredentialKindName, type DbDriver } from "@ionosphere/db"; import { deriveScramKeys, saslprep } from "@ionosphere/core"; @@ -160,21 +162,49 @@ function dummySecret(): Promise { } /** - * 앱 비밀번호용 랜덤 문자열 — 16 소문자(4-4-4-4 그룹, Google 앱비번 스타일). - * 편향 없는 rejection sampling(randomBytes). 약 75비트 엔트로피. + * 앱 비밀번호 길이(글자 수). 소문자 28자 = log2(26^28) ≈ 131.6비트 — 회원 공통 수치(2026-10-18 결정 1-2)의 + * "128비트 이상 CSPRNG"를 넘긴다. 예전 16자(약 75비트)로 발급된 것은 계속 받는다(같은 날 이 창 결정 B). + */ +const APP_PASSWORD_CHARS = 28; +/** 사람이 옮겨 적기 쉽게 4자씩 끊는다(입력할 때 하이픈·공백은 무시한다). */ +const APP_PASSWORD_GROUP = 4; + +/** + * 앱 비밀번호용 랜덤 문자열 — 소문자 28자(4자씩 7묶음). 편향 없는 rejection sampling(randomBytes). + * 소문자만 쓰는 이유: 휴대폰 메일 앱에 손으로 옮겨 적을 때 대소문자·비슷한 글자 혼동이 없다. 길이로 엔트로피를 채운다. */ export function generateAppPassword(): string { const alphabet = "abcdefghijklmnopqrstuvwxyz"; const limit = 256 - (256 % alphabet.length); // 234 — 이 이상 바이트는 버려 편향 제거 const chars: string[] = []; - while (chars.length < 16) { - for (const b of randomBytes(32)) { + while (chars.length < APP_PASSWORD_CHARS) { + for (const b of randomBytes(64)) { if (b >= limit) continue; chars.push(alphabet[b % alphabet.length]!); - if (chars.length === 16) break; + if (chars.length === APP_PASSWORD_CHARS) break; } } - return `${chars.slice(0, 4).join("")}-${chars.slice(4, 8).join("")}-${chars.slice(8, 12).join("")}-${chars.slice(12, 16).join("")}`; + const groups: string[] = []; + for (let i = 0; i < chars.length; i += APP_PASSWORD_GROUP) groups.push(chars.slice(i, i + APP_PASSWORD_GROUP).join("")); + return groups.join("-"); +} + +/** + * 서버가 만든 무작위 비밀(앱 비밀번호·자체 발급 토큰)의 저장 형식 — `sha256$`. + * + * 왜 scrypt가 아닌가(2026-10-18 결정 1-2: "엔트로피가 충분하면 빠른 해시로 저장해도 된다"): 128비트 넘는 무작위 값은 + * DB가 새어도 역산(대입)이 불가능하다 — 느린 해시는 사람이 고른 낮은 엔트로피 비밀을 위한 장치다. 대신 로그인할 때 + * **해시값으로 바로 찾을 수 있어**, 자격증명마다 scrypt를 돌리던 메일 프로토콜 로그인 비용이 사라진다. + * + * 왜 서버 키(HMAC)를 쓰지 않는가: 결정은 "HMAC + 서버 키 등"을 예로 들었다. 키를 더하면 DB만 샌 경우를 한 번 더 막지만, + * 위와 같이 이 엔트로피에서는 더할 것이 거의 없다. 반대로 마스터 키에 묶이면 키가 없거나 바뀌는 순간 모든 앱 비밀번호가 + * 한꺼번에 무효가 된다(메일 앱 전부가 멈춘다). 그 위험이 더 커서 키 없는 SHA-256으로 둔다. + * ⚠ 사람이 고른 비밀번호에는 절대 쓰지 않는다 — 그쪽은 `hashSecret`(scrypt)이다. + */ +const RANDOM_SECRET_PREFIX = "sha256$"; + +function hashRandomSecret(value: string): string { + return RANDOM_SECRET_PREFIX + sha256hex(value); } /** @@ -189,13 +219,7 @@ export async function createAppPassword( scopes?: string, ): Promise<{ id: string; password: string }> { const password = generateAppPassword(); - const id = await createCredential(db, { - accountId, - password: normalizeAppPassword(password), - kind: CREDENTIAL_KIND.appPassword, - label, - ...(scopes ? { scopes } : {}), - }); + const id = await insertRandomCredential(db, { accountId, value: normalizeAppPassword(password), kind: CREDENTIAL_KIND.appPassword, label, ...(scopes ? { scopes } : {}) }); return { id, password }; } @@ -222,7 +246,7 @@ export async function createOAuthToken( scopes?: string, ): Promise<{ id: string; token: string }> { const token = generateOAuthToken(); - const id = await createCredential(db, { accountId, password: token, kind: 2, label, ...(scopes ? { scopes } : {}) }); + const id = await insertRandomCredential(db, { accountId, value: token, kind: CREDENTIAL_KIND.oauthToken, label, ...(scopes ? { scopes } : {}) }); return { id, token }; } @@ -296,7 +320,23 @@ export async function setPrimaryPassword(db: DbDriver, accountId: string, passwo return (r[1]?.changes ?? 0) > 0; } -/** 자격증명 생성. kind 생략 시 기본 비밀번호(CREDENTIAL_KIND.password). */ +/** 서버가 만든 무작위 비밀을 `sha256$` 형식으로 저장한다(위 `RANDOM_SECRET_PREFIX` 주석). */ +async function insertRandomCredential( + db: DbDriver, + input: { accountId: string; value: string; kind: CredentialKind; label: string; scopes?: string }, +): Promise { + const id = ulid(); + await db.batch([ + { + sql: `INSERT INTO credentials (id, account_id, kind, label, secret, scopes, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + params: [id, input.accountId, input.kind, input.label, hashRandomSecret(input.value), input.scopes ?? null, Date.now()], + }, + ]); + return id; +} + +/** 자격증명 생성(사람이 고른 비밀번호 — scrypt). kind 생략 시 기본 비밀번호(CREDENTIAL_KIND.password). */ export async function createCredential( db: DbDriver, input: { accountId: string; password: string; kind?: CredentialKind; label?: string; scopes?: string }, @@ -374,11 +414,38 @@ export async function authenticate( return null; } + const normalizedApp = normalizeAppPassword(password); + /** + * ★서버가 만든 무작위 비밀(새 앱 비밀번호·토큰)은 해시값으로 **바로 찾는다** — scrypt를 돌지 않는다. + * 앱 비밀번호는 정규화(하이픈·공백 제거, 소문자)한 값, 토큰은 원문이 저장 키다. 둘 다 후보로 넣는다. + */ + const randomCandidates = [...new Set([hashRandomSecret(normalizedApp), hashRandomSecret(password)])]; + const fast = await queryInChunks( + db, + randomCandidates, + (ph) => `SELECT id, kind, scopes FROM credentials WHERE account_id = ? AND kind IN (${CREDENTIAL_KIND.appPassword}, ${CREDENTIAL_KIND.oauthToken}) AND secret IN (${ph})`, + [accountId], + ); + for (const c of fast) { + const scopes = c.scopes == null ? null : String(c.scopes); + if (!credentialAllowsSurface(scopes, surface)) continue; + const credentialId = String(c.id); + try { + await db.batch([{ sql: "UPDATE credentials SET last_used_at = ? WHERE id = ?", params: [Date.now(), credentialId] }]); + } catch { + /* 무시 */ + } + return { accountId, credentialId, credKind: credentialKindName(Number(c.kind)), scopes }; + } + const { rows: creds } = await db.query({ - sql: "SELECT id, kind, secret, scopes FROM credentials WHERE account_id = ?", + // 무작위 비밀(sha256$)은 위에서 끝났다 — 아래는 scrypt로 저장된 것(기본 비밀번호·예전 앱 비밀번호·예전 토큰)만 돈다. + sql: `SELECT id, kind, secret, scopes FROM credentials WHERE account_id = ? AND secret NOT LIKE '${RANDOM_SECRET_PREFIX}%'`, params: [accountId], }); - const normalizedApp = normalizeAppPassword(password); + // 예전 형식 자격증명이 하나도 없으면 scrypt를 한 번도 돌지 않아 "없는 계정"(더미 scrypt)보다 빨라진다 — 존재가 시간으로 + // 새지 않게 같은 비용을 태운다(허용 목록에 든 kind만 빠른 경로가 받으므로 앞으로 생길 kind는 저절로 로그인 수단이 되지 않는다). + if (creds.length === 0) await verifySecret(password, await dummySecret()); for (const c of creds) { // 원문 우선 검증(기본 비번·원문 저장 앱비번 하위호환). 앱 비번(kind=1)은 실패 시 // 정규화 입력(하이픈/공백 무시)으로 한 번 더 — createAppPassword가 정규화 저장하므로. diff --git a/packages/store/test/app-password-random.test.ts b/packages/store/test/app-password-random.test.ts new file mode 100644 index 0000000..74633a0 --- /dev/null +++ b/packages/store/test/app-password-random.test.ts @@ -0,0 +1,70 @@ +/** + * 앱 비밀번호·자체 발급 토큰의 무작위 비밀 저장(2026-10-18 결정 1-2, 이 창 결정 B). + * + * - 새 앱 비밀번호는 128비트 이상(소문자 28자), 저장은 sha256$, 로그인은 해시값으로 바로 찾는다. + * - 예전 16자 앱 비밀번호(scrypt 저장)는 계속 받는다. + */ +import { describe, expect, test } from "@ionosphere/testkit"; +import { allMigrations, CREDENTIAL_KIND, migrate, openSqlite } from "@ionosphere/db"; +import { authenticate, createAppPassword, createCredential, createOAuthToken, generateAppPassword, Store } from "@ionosphere/store"; + +async function setup() { + const db = await openSqlite(); + await migrate(db, allMigrations); + const store = new Store(db); + const { tenantId } = await store.createTenant("t"); + const { accountId } = await store.createAccount({ tenantId, email: "u@x.test" }); + await createCredential(db, { accountId, password: "Primary-pw1" }); + return { db, accountId }; +} + +describe("앱 비밀번호 — 128비트 이상", () => { + test("소문자 28자, 4자씩 7묶음 — log2(26^28) > 128", () => { + const pw = generateAppPassword(); + expect(pw).toMatch(/^[a-z]{4}(-[a-z]{4}){6}$/); + expect(28 * Math.log2(26)).toBeGreaterThan(128); + expect(generateAppPassword()).not.toBe(pw); + }); + + test("저장은 sha256$이고, 하이픈·공백·대문자를 섞어 입력해도 로그인된다", async () => { + const { db, accountId } = await setup(); + const { id, password } = await createAppPassword(db, accountId, "phone"); + const { rows } = await db.query({ sql: "SELECT secret FROM credentials WHERE id = ?", params: [id] }); + expect(String(rows[0]!.secret)).toMatch(/^sha256\$[0-9a-f]{64}$/); + expect(await authenticate(db, "u@x.test", password, "imap")).toMatchObject({ accountId, credKind: "appPassword", credentialId: id }); + const sloppy = password.replace(/-/g, " ").toUpperCase(); + expect(await authenticate(db, "u@x.test", sloppy, "imap")).toMatchObject({ credentialId: id }); + expect(await authenticate(db, "u@x.test", password.slice(0, -1) + "a", "imap")).toBeNull(); + }); + + test("표면 제한은 빠른 경로에서도 지킨다", async () => { + const { db, accountId } = await setup(); + const { password } = await createAppPassword(db, accountId, "imap-only", "imap"); + expect(await authenticate(db, "u@x.test", password, "imap")).not.toBeNull(); + expect(await authenticate(db, "u@x.test", password, "submission")).toBeNull(); + // 표면이 막힌 새 앱 비밀번호가 예전(scrypt) 루프로 넘어가도 맞는 것이 없다. + expect(await authenticate(db, "u@x.test", password.replace(/-/g, ""), "pop3")).toBeNull(); + }); + + test("예전 16자 앱 비밀번호(scrypt 저장)는 계속 받는다", async () => { + const { db, accountId } = await setup(); + const legacy = "abcdefghijklmnop"; + await createCredential(db, { accountId, password: legacy, kind: CREDENTIAL_KIND.appPassword, label: "old" }); + expect(await authenticate(db, "u@x.test", "abcd-efgh-ijkl-mnop", "imap")).toMatchObject({ credKind: "appPassword" }); + }); + + test("자체 발급 토큰도 sha256$로 저장하고 바로 찾는다", async () => { + const { db, accountId } = await setup(); + const { id, token } = await createOAuthToken(db, accountId, "cli"); + const { rows } = await db.query({ sql: "SELECT secret FROM credentials WHERE id = ?", params: [id] }); + expect(String(rows[0]!.secret)).toMatch(/^sha256\$/); + expect(await authenticate(db, "u@x.test", token, "imap")).toMatchObject({ credentialId: id, credKind: "oauthToken" }); + // 토큰은 원문이 저장 키다 — 앱 비밀번호처럼 정규화(하이픈 제거·소문자)한 값으로는 맞지 않는다. + if (token.replace(/-/g, "").toLowerCase() !== token) expect(await authenticate(db, "u@x.test", token.replace(/-/g, "").toLowerCase(), "imap")).toBeNull(); + }); + + test("기본 비밀번호는 여전히 scrypt로 확인한다(빠른 경로가 기본 비밀번호를 찾지 않는다)", async () => { + const { db, accountId } = await setup(); + expect(await authenticate(db, "u@x.test", "Primary-pw1", "imap")).toMatchObject({ accountId, credKind: "password" }); + }); +}); diff --git a/packages/store/test/app-password.test.ts b/packages/store/test/app-password.test.ts index b3fa2e9..270fea9 100644 --- a/packages/store/test/app-password.test.ts +++ b/packages/store/test/app-password.test.ts @@ -14,9 +14,9 @@ async function setup(): Promise<{ db: DbDriver; accountId: string }> { } describe("generateAppPassword", () => { - test("4-4-4-4 소문자 형식, 매번 다름", () => { + test("소문자 28자(4자씩 7묶음, 약 131비트) 형식, 매번 다름", () => { const a = generateAppPassword(); - expect(a).toMatch(/^[a-z]{4}-[a-z]{4}-[a-z]{4}-[a-z]{4}$/); + expect(a).toMatch(/^[a-z]{4}(-[a-z]{4}){6}$/); expect(generateAppPassword()).not.toBe(a); }); });