마을 복구 보안: 비밀 암호 도입

- 새 마을 생성 시 닉네임과 함께 비밀 암호(2~16자) 필수 지정
- 복구 API: GET → POST 전환, 닉네임+암호 일치 시에만 세이브·토큰 반환
- 서버에는 salt+SHA-256 해시만 저장 (timingSafeEqual 비교)
- 암호 도입 전 마을은 최초 복구 시 사용한 암호를 그대로 등록(claim)
- 오프라인 시작 시 암호를 로컬 보관 → 자동 재등록에 포함

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
이 커밋은 다음에 포함됨:
kjs
2026-08-14 11:44:14 +09:00
co-authored by Claude Fable 5
부모 1b438d392a
커밋 eb950dd7af
5개의 변경된 파일과 146개의 추가작업 그리고 35개의 파일을 삭제
+10 -13
파일 보기
@@ -1,22 +1,19 @@
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { ensureTable, validNickname } from "@/lib/villages";
import { recoverWithSecret, validNickname, validSecret } from "@/lib/villages";
export const dynamic = "force-dynamic";
/** 닉네임으로 마을 복구 (토큰 포함 반환 — 추후 비밀코드 보호 예정) */
export async function GET(req: Request) {
const nickname = new URL(req.url).searchParams.get("nickname");
if (!validNickname(nickname))
/** 닉네임+비밀 암호로 마을 복구 */
export async function POST(req: Request) {
const { nickname, secret } = await req.json().catch(() => ({}));
if (!validNickname(nickname) || !validSecret(secret))
return NextResponse.json({ data: null }, { status: 400 });
try {
await ensureTable();
const r = await db.query(
"SELECT data, token FROM villages WHERE nickname = $1 AND data IS NOT NULL",
[nickname],
);
if (!r.rows[0]) return NextResponse.json({ data: null });
return NextResponse.json({ data: r.rows[0].data, token: r.rows[0].token });
const r = await recoverWithSecret(nickname, secret);
if (r === "wrong_secret")
return NextResponse.json({ data: null, reason: "secret" }, { status: 403 });
if (!r) return NextResponse.json({ data: null });
return NextResponse.json(r);
} catch {
return NextResponse.json({ error: "db" }, { status: 503 });
}
+5 -3
파일 보기
@@ -1,14 +1,16 @@
import { NextResponse } from "next/server";
import { register, validNickname } from "@/lib/villages";
import { register, validNickname, validSecret } from "@/lib/villages";
export const dynamic = "force-dynamic";
export async function POST(req: Request) {
const { nickname, token } = await req.json().catch(() => ({}));
const { nickname, token, secret } = await req.json().catch(() => ({}));
if (!validNickname(nickname) || typeof token !== "string" || token.length < 8)
return NextResponse.json({ ok: false }, { status: 400 });
if (secret !== undefined && !validSecret(secret))
return NextResponse.json({ ok: false }, { status: 400 });
try {
return NextResponse.json({ ok: await register(nickname, token) });
return NextResponse.json({ ok: await register(nickname, token, secret) });
} catch {
return NextResponse.json({ error: "db" }, { status: 503 });
}
+37 -7
파일 보기
@@ -7,6 +7,7 @@ import {
newSyncToken,
registerNickname,
recoverVillage,
rememberSecret,
pushSave,
} from "@/lib/game/sync";
import { engine } from "@/lib/game/engine";
@@ -18,13 +19,15 @@ type Status =
| "available"
| "taken"
| "invalid"
| "offline";
| "offline"
| "wrong_secret";
const NICKNAME_RE = /^[가-힣a-zA-Z0-9_-]{2,12}$/;
export default function NicknameModal({ onDone }: { onDone: () => void }) {
const [mode, setMode] = useState<Mode>("new");
const [name, setName] = useState("");
const [secret, setSecret] = useState("");
const [status, setStatus] = useState<Status>("idle");
const [submitting, setSubmitting] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -56,34 +59,46 @@ export default function NicknameModal({ onDone }: { onDone: () => void }) {
};
}, [trimmed, mode]);
const secretOk = secret.trim().length >= 2 && secret.trim().length <= 16;
// 모드별 결정 가능 조건: 새 마을=사용 가능(서버 불가 시에도 허용), 불러오기=이미 존재
// 두 모드 모두 비밀 암호 입력 필수
const canConfirm =
!submitting &&
secretOk &&
(mode === "new"
? status === "available" || status === "offline"
: status === "taken");
: status === "taken" || status === "wrong_secret");
const confirm = async () => {
if (!canConfirm) return;
setSubmitting(true);
const sec = secret.trim();
if (mode === "new") {
const token = newSyncToken();
const ok = await registerNickname(trimmed, token);
const ok = await registerNickname(trimmed, token, sec);
if (ok === false) {
setStatus("taken"); // 그 사이 누가 선점
setSubmitting(false);
return;
}
// 서버 불가여도 로컬에 기록 — 다음 동기화 때 재등록 시도
// 서버 불가여도 로컬에 기록 — 다음 동기화 때 재등록 시도 (암호 포함)
rememberSecret(sec);
engine.setNickname(trimmed, token);
void pushSave();
} else {
const found = await recoverVillage(trimmed);
const found = await recoverVillage(trimmed, sec);
if (found === "wrong_secret") {
setStatus("wrong_secret");
setSubmitting(false);
return;
}
if (!found) {
setStatus("available"); // 불러오기 모드에선 "그 이름의 마을은 없는걸?"
setSubmitting(false);
return;
}
rememberSecret(sec);
engine.replaceState(found.data);
engine.setNickname(trimmed, found.token);
}
@@ -99,6 +114,7 @@ export default function NicknameModal({ onDone }: { onDone: () => void }) {
available: { text: "좋아, 이 이름은 비어 있어! ✨", cls: "text-[#5c8a2a]" },
taken: { text: "앗, 이미 다른 마을이 쓰고 있는 이름이야 🙀", cls: "text-[#b0472a]" },
offline: { text: "확인은 못 했지만 일단 시작할 수 있어! (나중에 자동 확인)", cls: "text-[#8a6316]" },
wrong_secret: { text: "", cls: "" },
},
recover: {
idle: { text: "예전에 쓰던 마을 이름을 알려줘!", cls: "text-[#8a7551]" },
@@ -107,6 +123,7 @@ export default function NicknameModal({ onDone }: { onDone: () => void }) {
available: { text: "그 이름의 마을은 없는걸? 🙀", cls: "text-[#b0472a]" },
taken: { text: "마을을 찾았어! 데려올게 ✨", cls: "text-[#5c8a2a]" },
offline: { text: "서버에 닿지 않아… 잠시 후 다시 시도해줘", cls: "text-[#b0472a]" },
wrong_secret: { text: "앗, 비밀 암호가 달라! 다시 떠올려봐 🙀", cls: "text-[#b0472a]" },
},
};
@@ -125,8 +142,8 @@ export default function NicknameModal({ onDone }: { onDone: () => void }) {
</h2>
<p className="mt-1 text-sm text-[#6f5c39]">
{mode === "new"
? "촌장 냥이가 마을 문패에 걸어둘 이름을 물어봅니다"
: "예전 마을 이름을 입력하면 그대로 이어서 할 수 있어요"}
? "이름과 함께, 마을을 지킬 비밀 암호도 정해주세요"
: "마을 이름과 비밀 암호를 입력하면 이어서 할 수 있어요"}
</p>
<input
@@ -140,6 +157,19 @@ export default function NicknameModal({ onDone }: { onDone: () => void }) {
placeholder={mode === "new" ? "닉네임 입력" : "기존 마을 이름"}
className="font-jua mt-4 w-full rounded-xl border-2 border-[rgba(126,95,48,.35)] bg-white/80 px-4 py-3 text-center text-lg text-[#3f2c14] outline-none focus:border-[#7fb539]"
/>
<input
value={secret}
onChange={(e) => setSecret(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.nativeEvent.isComposing) void confirm();
}}
type="password"
maxLength={16}
placeholder={
mode === "new" ? "마을을 지킬 비밀 암호 (2~16자)" : "비밀 암호"
}
className="font-jua mt-2 w-full rounded-xl border-2 border-[rgba(126,95,48,.35)] bg-white/80 px-4 py-2.5 text-center text-base text-[#3f2c14] outline-none focus:border-[#7fb539]"
/>
<p className={`mt-2 h-5 text-xs ${lines[mode][status].cls}`}>
{lines[mode][status].text}
</p>
+31 -7
파일 보기
@@ -16,16 +16,36 @@ export async function checkNickname(
}
}
const SECRET_KEY = "cat-village-secret";
/** 비밀 암호 로컬 보관 (오프라인 시작 후 자동 재등록용) */
export function rememberSecret(secret: string) {
try {
localStorage.setItem(SECRET_KEY, secret);
} catch {
/* ignore */
}
}
function storedSecret(): string | undefined {
try {
return localStorage.getItem(SECRET_KEY) ?? undefined;
} catch {
return undefined;
}
}
/** 닉네임 선점 등록 */
export async function registerNickname(
nickname: string,
token: string,
secret?: string,
): Promise<boolean | null> {
try {
const res = await fetch("/api/village/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ nickname, token }),
body: JSON.stringify({ nickname, token, secret }),
});
if (!res.ok) return null;
return ((await res.json()) as { ok: boolean }).ok;
@@ -51,7 +71,7 @@ export async function pushSave(): Promise<void> {
});
if (res.status === 403) {
// 오프라인 상태에서 시작해 서버에 등록되지 않은 닉네임 — 재등록 후 재시도
const ok = await registerNickname(s.nickname, s.syncToken);
const ok = await registerNickname(s.nickname, s.syncToken, storedSecret());
if (ok)
await fetch("/api/village/save", {
method: "POST",
@@ -86,14 +106,18 @@ export async function pullSave(): Promise<boolean> {
return false;
}
/** 닉네임으로 기존 마을 복구 */
/** 닉네임+비밀 암호로 기존 마을 복구. "wrong_secret" = 암호 불일치 */
export async function recoverVillage(
nickname: string,
): Promise<{ data: GameState; token: string } | null> {
secret: string,
): Promise<{ data: GameState; token: string } | "wrong_secret" | null> {
try {
const res = await fetch(
`/api/village/recover?nickname=${encodeURIComponent(nickname)}`,
);
const res = await fetch("/api/village/recover", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ nickname, secret }),
});
if (res.status === 403) return "wrong_secret";
if (!res.ok) return null;
const body = (await res.json()) as {
data: GameState | null;
+63 -5
파일 보기
@@ -1,7 +1,32 @@
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { db } from "@/lib/db";
let tableReady = false;
/** 비밀 암호 해시 (salt$hash) */
export function hashSecret(secret: string): string {
const salt = randomBytes(12).toString("hex");
const h = createHash("sha256").update(salt + secret).digest("hex");
return `${salt}$${h}`;
}
export function verifySecret(secret: string, stored: string): boolean {
const [salt, h] = stored.split("$");
if (!salt || !h) return false;
const calc = createHash("sha256").update(salt + secret).digest("hex");
try {
return timingSafeEqual(Buffer.from(calc), Buffer.from(h));
} catch {
return false;
}
}
const SECRET_RE = /^.{2,16}$/;
export function validSecret(secret: unknown): secret is string {
return typeof secret === "string" && SECRET_RE.test(secret.trim());
}
/** villages 테이블 보장 (최초 1회) */
export async function ensureTable() {
if (tableReady) return;
@@ -13,6 +38,7 @@ export async function ensureTable() {
updated_at TIMESTAMPTZ DEFAULT now()
)
`);
await db.query("ALTER TABLE villages ADD COLUMN IF NOT EXISTS secret TEXT");
tableReady = true;
}
@@ -33,18 +59,50 @@ export async function isAvailable(nickname: string): Promise<boolean> {
}
/** 닉네임 선점 등록. 성공 여부 반환 */
export async function register(nickname: string, token: string): Promise<boolean> {
export async function register(
nickname: string,
token: string,
secret?: string,
): Promise<boolean> {
await ensureTable();
const secretHash = secret ? hashSecret(secret.trim()) : null;
const r = await db.query(
`INSERT INTO villages (nickname, token, data)
VALUES ($1, $2, NULL)
ON CONFLICT (nickname) DO UPDATE SET token = EXCLUDED.token, updated_at = now()
`INSERT INTO villages (nickname, token, data, secret)
VALUES ($1, $2, NULL, $3)
ON CONFLICT (nickname) DO UPDATE
SET token = EXCLUDED.token,
secret = COALESCE(EXCLUDED.secret, villages.secret),
updated_at = now()
WHERE villages.data IS NULL`,
[nickname, token],
[nickname, token, secretHash],
);
return (r.rowCount ?? 0) > 0;
}
/** 닉네임+암호로 마을 복구. 암호 없는 옛 마을은 최초 복구 시 암호 등록(claim) */
export async function recoverWithSecret(
nickname: string,
secret: string,
): Promise<{ data: unknown; token: string } | "wrong_secret" | null> {
await ensureTable();
const r = await db.query(
"SELECT data, token, secret FROM villages WHERE nickname = $1 AND data IS NOT NULL",
[nickname],
);
const row = r.rows[0];
if (!row) return null;
if (row.secret == null) {
// 암호 도입 전 마을 — 이번 복구에 쓴 암호를 그대로 등록
await db.query("UPDATE villages SET secret = $2 WHERE nickname = $1", [
nickname,
hashSecret(secret.trim()),
]);
return { data: row.data, token: row.token };
}
if (!verifySecret(secret.trim(), row.secret)) return "wrong_secret";
return { data: row.data, token: row.token };
}
/** 세이브 저장 (토큰 일치 시에만) */
export async function saveVillage(
nickname: string,