server/src/routes/account.ts
Rama main · réplica de lectura del repositorio original.
// 내 계정: 공개키와 "잠긴 계정 키 묶음"만 보관한다.
// 묶음 안의 개인키·계정 키는 암호화 비밀번호(또는 복구 키)로만 풀린다 — 서버는 풀 수 없다.
import type { FastifyInstance } from 'fastify';
import { requireUser, sessionAgeMs, sessionHash } from '../auth.ts';
import { endLive } from '../live.ts';
import { all, get, now, run, tx } from '../db.ts';
import { addTeamMember, canCreateTeams, listVaults, needsInviteCode, pendingInvites, type TeamRole, type User } from '../access.ts';
import { audit, badRequest, body, conflict, forbidden, HttpError, notFound, str } from '../http.ts';
import { sameString, sha256 } from '../crypto.ts';
import { config } from '../config.ts';
import { checkNewPassword, hashPassword, hashSlot, normalizeInviteCode, passwordField, Throttle, tooMany, verifyPassword } from '../password.ts';
// 초대 수락 때 초대 코드를 틀린 횟수 (사람마다 15분에 10번)
const acceptThrottle = new Throttle(10, 15 * 60 * 1000);
// 로그인 비밀번호 바꾸기에서 지금 비밀번호를 틀린 횟수 (사람마다 15분에 10번)
const changeThrottle = new Throttle(10, 15 * 60 * 1000);
const B64_32 = /^[A-Za-z0-9+/]{43}=$/;
const SEALED = /^v1\.[A-Za-z0-9+/]+=*$/;
const HEX64 = /^[0-9a-f]{64}$/;
// 초기화는 방금 로그인한(Google 로 다시 확인한) 세션에서만 받는다 (보안 검토 F-06)
const RESET_LOGIN_WINDOW_MS = 10 * 60 * 1000;
type Bundle = {
v: 1;
kdf: { alg: 'argon2id'; salt: string; m: number; t: number; p: number };
encPrivateKey: string;
wrapPw: string;
wrapRecovery: string;
};
function parseBundle(value: unknown): Bundle {
const b = value as Bundle;
const ok =
b &&
typeof b === 'object' &&
b.v === 1 &&
b.kdf?.alg === 'argon2id' &&
typeof b.kdf.salt === 'string' &&
b.kdf.salt.length <= 64 &&
[b.kdf.m, b.kdf.t, b.kdf.p].every((n) => Number.isInteger(n)) &&
// 화면(e2ee.ts KDF_LIMITS)과 같은 범위: 너무 약하게도, 기기를 멈출 만큼 무겁게도 못 한다
b.kdf.m >= 19 * 1024 &&
b.kdf.m <= 256 * 1024 &&
b.kdf.t >= 1 &&
b.kdf.t <= 10 &&
b.kdf.p >= 1 &&
b.kdf.p <= 4 &&
[b.encPrivateKey, b.wrapPw, b.wrapRecovery].every((s) => typeof s === 'string' && s.length <= 1024 && SEALED.test(s));
if (!ok) throw badRequest('키 묶음 형식이 올바르지 않습니다');
return { v: 1, kdf: { alg: 'argon2id', salt: b.kdf.salt, m: b.kdf.m, t: b.kdf.t, p: b.kdf.p }, encPrivateKey: b.encPrivateKey, wrapPw: b.wrapPw, wrapRecovery: b.wrapRecovery };
}
function publicKey(value: unknown) {
if (typeof value !== 'string' || !B64_32.test(value)) throw badRequest('공개키 형식이 올바르지 않습니다');
return value;
}
function proof(value: unknown) {
if (typeof value !== 'string' || !HEX64.test(value)) throw badRequest('확인 값이 올바르지 않습니다');
return value;
}
// 계정 키 증명은 해시로만 보관한다 (db.ts 가 예전 원문도 바꿔 둔다) — DB 를 읽은 사람이 그 값을 내밀지 못하게
const proofHash = (p: string) => `h1:${sha256(p)}`;
const proofMatches = (p: string, stored: string) => (stored.startsWith('h1:') ? sameString(proofHash(p), stored) : sameString(p, stored));
export function meOut(user: User) {
const teams = all<{ id: string; name: string; role: TeamRole }>(
'SELECT t.id, t.name, tm.role FROM team_members tm JOIN teams t ON t.id = tm.team_id WHERE tm.user_id = ? ORDER BY t.name',
user.id,
);
return {
user: { id: user.id, email: user.email, name: user.name, avatarUrl: user.avatar_url, isAdmin: Boolean(user.is_admin), canCreateTeams: canCreateTeams(user) },
// 로그인 방법: 이 서버가 비밀번호 로그인을 받는지, 이 사람에게 로그인 비밀번호·Google 연결이 있는지
login: { password: config.passwordLogin, hasPassword: Boolean(user.password_hash), google: Boolean(user.google_sub) },
crypto: user.public_key && user.key_bundle ? { publicKey: user.public_key, bundle: JSON.parse(user.key_bundle) as Bundle, createdAt: user.keys_created_at } : null,
teams,
vaults: listVaults(user),
// 수락을 기다리는 팀 초대 (받은 사람이 수락해야 팀에 들어간다)
invites: pendingInvites(user),
};
}
export function accountRoutes(app: FastifyInstance) {
app.get('/api/me', async (req) => meOut(requireUser(req)));
// 처음 설정: 키 쌍과 잠긴 묶음을 올린다 (이미 있으면 거절 — 바꿀 때는 PUT, 다시 만들 때는 reset)
app.post('/api/me/keys', async (req) => {
const user = requireUser(req);
const b = body(req.body);
const pub = publicKey(b.publicKey);
const bundle = parseBundle(b.bundle);
const p = proof(b.proof);
const res = run(
'UPDATE users SET public_key = ?, key_bundle = ?, key_proof = ?, keys_created_at = ? WHERE id = ? AND public_key IS NULL',
pub,
JSON.stringify(bundle),
proofHash(p),
now(),
user.id,
);
if (!res.changes) throw conflict('이미 암호화 설정이 되어 있습니다. 새로고침해 주세요.');
audit({ userId: user.id, action: 'keys_setup', ip: req.ip });
return { ok: true };
});
// 암호화 비밀번호 바꾸기·복구 키 새로 받기: 계정 키를 가진 사람만(proof) 묶음을 바꿀 수 있다
app.put('/api/me/keys', async (req) => {
const user = requireUser(req);
const b = body(req.body);
const bundle = parseBundle(b.bundle);
if (!user.key_proof || !user.key_bundle || !proofMatches(proof(b.proof), user.key_proof)) throw forbidden('계정 키 확인에 실패했습니다');
const current = JSON.parse(user.key_bundle) as Bundle;
if (bundle.encPrivateKey !== current.encPrivateKey) throw badRequest('개인키는 바꿀 수 없습니다');
run('UPDATE users SET key_bundle = ? WHERE id = ?', JSON.stringify(bundle), user.id);
audit({ userId: user.id, action: b.reason === 'recovery' ? 'keys_recovered' : b.reason === 'new_recovery' ? 'keys_new_recovery' : 'keys_password', ip: req.ip });
return { ok: true };
});
// 로그인 비밀번호 정하기·바꾸기 (비밀번호 로그인 서버). 이미 있으면 지금 비밀번호를, 없으면 방금 로그인한 세션을 요구한다.
// 바꾸면 다른 기기·브라우저의 로그인은 끊는다. (볼트를 여는 암호화 비밀번호와는 별개 — 서버는 그것을 모른다)
app.put('/api/me/password', async (req) => {
const user = requireUser(req);
if (!config.passwordLogin) throw new HttpError(404, 'not_found');
const b = body(req.body);
const next = passwordField(b, 'password');
checkNewPassword(next);
if (user.password_hash) {
if (changeThrottle.blocked(user.id)) throw tooMany();
// 느린 해시 앞에서 먼저 센다 (한꺼번에 보내 제한을 넘지 못하게)
changeThrottle.fail(user.id);
const release = hashSlot(`change:${user.id}`);
let ok: boolean;
try {
ok = await verifyPassword(passwordField(b, 'current'), user.password_hash);
} finally {
release();
}
if (!ok) throw new HttpError(400, 'bad_password', '지금 비밀번호가 맞지 않습니다');
changeThrottle.reset(user.id);
} else {
const age = sessionAgeMs(req);
if (age === null || age > RESET_LOGIN_WINDOW_MS) throw new HttpError(403, 'reauth_required', '안전을 위해 다시 로그인한 뒤 10분 안에 정해 주세요.');
}
const release = hashSlot(`change:${user.id}`);
let hash: string;
try {
hash = await hashPassword(next);
} finally {
release();
}
const current = sessionHash(req);
tx(() => {
run('UPDATE users SET password_hash = ?, password_changed_at = ? WHERE id = ?', hash, now(), user.id);
run('DELETE FROM sessions WHERE user_id = ? AND id_hash != ?', user.id, current);
});
endLive((l) => l.userId === user.id && l.sessionHash !== current, 'password changed');
audit({ userId: user.id, action: user.password_hash ? 'password_change' : 'password_set', ip: req.ip });
return { ok: true };
});
// 비밀번호도 복구 키도 잃었을 때: 새 키로 다시 시작. 개인 볼트 내용은 지우고, 팀 볼트는 관리자가 다시 공유해 준다.
// 지금 세션으로 초기화할 수 있는지 (화면이 비밀번호를 받기 전에 먼저 묻는다)
app.get('/api/me/keys/reset', async (req) => {
requireUser(req);
const age = sessionAgeMs(req);
return { allowed: age !== null && age <= RESET_LOGIN_WINDOW_MS, windowMinutes: RESET_LOGIN_WINDOW_MS / 60_000 };
});
app.post('/api/me/keys/reset', async (req) => {
const user = requireUser(req);
const b = body(req.body);
if (b.confirm !== 'RESET') throw badRequest('확인이 필요합니다');
// 세션만 훔친 사람이 개인 볼트를 지우지 못하게, 로그인한 지 10분 안에만 받는다
const age = sessionAgeMs(req);
if (age === null || age > RESET_LOGIN_WINDOW_MS) throw new HttpError(403, 'reauth_required', '안전을 위해 다시 로그인한 뒤 10분 안에 초기화해 주세요.');
const current = sessionHash(req);
const pub = publicKey(b.publicKey);
const bundle = parseBundle(b.bundle);
const p = proof(b.proof);
tx(() => {
run('DELETE FROM vault_keys WHERE user_id = ?', user.id);
run("DELETE FROM items WHERE vault_id IN (SELECT id FROM vaults WHERE kind = 'personal' AND owner_id = ?)", user.id);
run('UPDATE users SET public_key = ?, key_bundle = ?, key_proof = ?, keys_created_at = ? WHERE id = ?', pub, JSON.stringify(bundle), proofHash(p), now(), user.id);
// 다른 기기·브라우저의 로그인은 모두 끊는다 (옛 키로 열려 있던 곳)
run('DELETE FROM sessions WHERE user_id = ? AND id_hash != ?', user.id, current);
});
endLive((l) => l.userId === user.id && l.sessionHash !== current, 'keys reset');
audit({ userId: user.id, action: 'keys_reset', ip: req.ip });
return { ok: true };
});
// ---------- 내게 온 팀 초대: 수락해야 팀에 들어간다 (보안 점검 M-3) ----------
app.get('/api/me/invites', async (req) => pendingInvites(requireUser(req)));
app.post('/api/me/invites/:inviteId/accept', async (req) => {
const user = requireUser(req);
const { inviteId } = req.params as { inviteId: string };
const invite = get<{ id: string; team_id: string; role: TeamRole; code_hash: string | null; code_expires_at: number | null }>(
'SELECT id, team_id, role, code_hash, code_expires_at FROM invites WHERE id = ? AND email = ?',
inviteId,
user.email.toLowerCase(),
);
if (!invite) throw notFound('초대를 찾을 수 없습니다');
// 아이디·비밀번호 계정은 초대한 사람에게 받은 초대 코드도 낸다 (이메일을 확인한 적이 없는 계정이라)
if (needsInviteCode(user)) {
if (acceptThrottle.blocked(user.id)) throw tooMany();
const code = normalizeInviteCode(str(body(req.body), 'code', { optional: true, max: 64 }) ?? '');
if (!invite.code_hash || (invite.code_expires_at ?? 0) <= now() || !sameString(invite.code_hash, sha256(code))) {
acceptThrottle.fail(user.id);
throw new HttpError(400, 'invite_bad', '초대 코드가 맞지 않거나 기한이 지났습니다');
}
acceptThrottle.reset(user.id);
}
tx(() => {
addTeamMember(invite.team_id, user.id, invite.role);
run('DELETE FROM invites WHERE id = ?', invite.id);
});
audit({ userId: user.id, teamId: invite.team_id, action: 'invite_accept', target: user.email, ip: req.ip });
return { ok: true, teamId: invite.team_id };
});
app.delete('/api/me/invites/:inviteId', async (req) => {
const user = requireUser(req);
const { inviteId } = req.params as { inviteId: string };
const invite = get<{ id: string; team_id: string }>('SELECT id, team_id FROM invites WHERE id = ? AND email = ?', inviteId, user.email.toLowerCase());
if (!invite) throw notFound('초대를 찾을 수 없습니다');
run('DELETE FROM invites WHERE id = ?', invite.id);
audit({ userId: user.id, teamId: invite.team_id, action: 'invite_decline', target: user.email, ip: req.ip });
return { ok: true };
});
// 팀원 공개키(지문 대조용)
app.get('/api/users/:userId/public-key', async (req) => {
const user = requireUser(req);
const { userId } = req.params as { userId: string };
const shared = get(
'SELECT 1 AS x FROM team_members a JOIN team_members b ON a.team_id = b.team_id WHERE a.user_id = ? AND b.user_id = ? LIMIT 1',
user.id,
userId,
);
if (!shared && userId !== user.id) throw forbidden();
const row = get<{ public_key: string | null; keys_created_at: number | null }>('SELECT public_key, keys_created_at FROM users WHERE id = ?', userId);
return { publicKey: row?.public_key ?? null, createdAt: row?.keys_created_at ?? null };
});
}