Ctrl K
ComunidadGuías…

server/src/routes/items.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
// 볼트 항목: 서버는 암호문(data)과 종류·볼트·시각만 다룬다. 내용 검사(주소 형식 등)는 앱·웹이 한다.
// 암호문에는 "어느 항목·어느 볼트·어떤 종류"가 묶여 있어(AAD) 서버가 다른 곳에 옮겨 붙이면 풀리지 않는다.
import type { FastifyInstance } from 'fastify';
import { requireUser } from '../auth.ts';
import { all, get, now, run, tx } from '../db.ts';
import { isTeamManager, requireVault, teamRole, vaultAccess, type User, type VaultRow } from '../access.ts';
import { audit, badRequest, body, conflict, HttpError, notFound } from '../http.ts';
import { Throttle } from '../password.ts';

// request·httpenv: HTTP 요청 도구(앱 전용)의 저장한 요청·환경 변수. 서버는 다른 항목처럼 암호문만 보관하고,
// HTTP 요청을 대신 보내는 기능은 서버에 없다 (요청은 사용자 PC 의 앱에서만 나간다).
const KINDS = new Set(['group', 'host', 'key', 'identity', 'snippet', 'forward', 'knownhost', 'hostcred', 'request', 'httpenv']);
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const SEALED = /^v1\.[A-Za-z0-9+/]+=*$/;
const MAX_DATA = 64 * 1024;
// 볼트 하나에 둘 수 있는 항목 수 (누구나 가입하는 서버의 남용 막기)
const MAX_ITEMS = 5000;
// 저장 용량: 볼트 하나 16MB, 팀 전체 64MB (항목 목록을 한 번에 읽어도 서버 메모리가 버티게 — 보안 점검 M-4)
const MAX_VAULT_MB = 16;
const MAX_TEAM_MB = 64;
// 쓰기·기록 속도: 사람마다 10분에 저장 3000번, 앱이 알리는 접속 기록 1200개 (넘은 기록은 조용히 버린다)
const writeThrottle = new Throttle(3000, 10 * 60 * 1000);
const auditThrottle = new Throttle(1200, 10 * 60 * 1000);

function checkWriteRate(user: User) {
  if (writeThrottle.blocked(user.id)) throw new HttpError(429, 'too_many', '저장 요청이 너무 많습니다. 잠시 뒤에 다시 시도해 주세요.');
  writeThrottle.fail(user.id);
}

// 이 볼트(와 팀)에 bytes 만큼 더 저장해도 되는지 (고칠 때는 old 만큼 빠진다)
function checkRoom(vault: VaultRow, bytes: number, old = 0) {
  const used = get<{ n: number }>('SELECT COALESCE(SUM(LENGTH(data)), 0) AS n FROM items WHERE vault_id = ?', vault.id)!.n;
  if (used - old + bytes > MAX_VAULT_MB * 1024 * 1024) throw badRequest(`볼트 하나에 저장할 수 있는 용량(${MAX_VAULT_MB}MB)을 넘었습니다`);
  if (!vault.team_id) return;
  const team = get<{ n: number }>('SELECT COALESCE(SUM(LENGTH(i.data)), 0) AS n FROM items i JOIN vaults v ON v.id = i.vault_id WHERE v.team_id = ?', vault.team_id)!.n;
  if (team - old + bytes > MAX_TEAM_MB * 1024 * 1024) throw badRequest(`팀 하나에 저장할 수 있는 용량(${MAX_TEAM_MB}MB)을 넘었습니다`);
}

type ItemRow = { id: string; vault_id: string; kind: string; data: string; created_by: string | null; updated_by: string | null; created_at: number; updated_at: number };

const itemOut = (r: ItemRow) => ({ id: r.id, kind: r.kind, data: r.data, createdBy: r.created_by, updatedBy: r.updated_by, createdAt: r.created_at, updatedAt: r.updated_at });

function sealed(value: unknown, name: string, max = MAX_DATA): string {
  if (typeof value !== 'string' || value.length > max || !SEALED.test(value)) throw badRequest(`${name} 는 암호화된 값이어야 합니다`);
  return value;
}

// 기록에 남길 대상 이름: 볼트 키로 암호화한 것만 받는다
function auditLabel(b: Record<string, unknown>): string | null {
  return b.label === undefined || b.label === null ? null : sealed(b.label, 'label', 2048);
}

const ACTION: Record<string, string> = {
  group: 'group',
  host: 'host',
  key: 'key',
  identity: 'identity',
  snippet: 'snippet',
  forward: 'forward',
  knownhost: 'knownhost',
  hostcred: 'hostcred',
  request: 'request',
  httpenv: 'httpenv',
};

function loadItem(user: User, id: string, need: 'edit' | 'view'): { row: ItemRow; vault: VaultRow } {
  const row = get<ItemRow>('SELECT * FROM items WHERE id = ?', id);
  if (!row) throw notFound();
  const vault = requireVault(user, row.vault_id, need);
  return { row, vault };
}

// 앱이 스스로 알리는 접속 기록 (서버는 접속에 끼지 않으므로 앱의 보고로만 안다)
// http_request: 앱이 HTTP 요청을 보낼 때마다. 대상(메서드·주소)은 볼트 키로 암호화된 이름으로만 받는다
const CLIENT_ACTIONS = new Set(['ssh_connect', 'ssh_disconnect', 'ssh_error', 'sftp_connect', 'sftp_disconnect', 'forward_start', 'forward_stop', 'hostkey_mismatch', 'http_request']);
const HTTP_FAIL = new Set(['timeout', 'dns', 'refused', 'reset', 'tls', 'cancelled', 'error']);
const OFFLINE_AUDIT_MAX_AGE = 30 * 24 * 60 * 60 * 1000;

export function itemRoutes(app: FastifyInstance) {
  app.get('/api/vaults/:vaultId/items', async (req) => {
    const user = requireUser(req);
    const { vaultId } = req.params as { vaultId: string };
    requireVault(user, vaultId, 'view');
    return all<ItemRow>('SELECT * FROM items WHERE vault_id = ? ORDER BY created_at', vaultId).map(itemOut);
  });

  app.post('/api/vaults/:vaultId/items', async (req) => {
    const user = requireUser(req);
    const { vaultId } = req.params as { vaultId: string };
    const b = body(req.body);
    const kind = String(b.kind ?? '');
    if (!KINDS.has(kind)) throw badRequest('항목 종류가 올바르지 않습니다');
    // 서버 지문(알려진 호스트)도 편집 권한이 있어야 쓴다. 보기 권한인 사람이 공용 볼트에 지문을 넣으면
    // 다른 사람의 접속 신뢰를 미리 차지할 수 있다(보안 검토 F-01). 보기 권한인 사람의 확인은 자기 개인 볼트에 남긴다.
    const vault = requireVault(user, vaultId, 'edit');
    if (kind === 'hostcred' && vault.kind !== 'personal') throw badRequest('내 접속 정보 연결은 개인 볼트에만 둡니다');
    if (get<{ n: number }>('SELECT COUNT(*) AS n FROM items WHERE vault_id = ?', vaultId)!.n >= MAX_ITEMS) throw badRequest(`볼트 하나에 항목은 ${MAX_ITEMS}개까지 둘 수 있습니다`);
    const id = String(b.id ?? '');
    if (!UUID.test(id)) throw badRequest('id가 올바르지 않습니다');
    const data = sealed(b.data, 'data');
    if (get('SELECT 1 AS x FROM items WHERE id = ?', id)) throw conflict('같은 id의 항목이 이미 있습니다');
    checkWriteRate(user);
    checkRoom(vault, data.length);
    run(
      'INSERT INTO items (id, vault_id, kind, data, created_by, updated_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
      id,
      vaultId,
      kind,
      data,
      user.id,
      user.id,
      now(),
      now(),
    );
    audit({ userId: user.id, teamId: vault.team_id, vaultId, action: kind === 'knownhost' ? 'hostkey_trust' : `${ACTION[kind]}_create`, targetEnc: auditLabel(b), detail: { id }, ip: req.ip });
    return itemOut(get<ItemRow>('SELECT * FROM items WHERE id = ?', id)!);
  });

  app.patch('/api/items/:id', async (req) => {
    const user = requireUser(req);
    const { row, vault } = loadItem(user, (req.params as { id: string }).id, 'edit');
    const b = body(req.body);
    const data = sealed(b.data, 'data');
    // 다른 사람이 먼저 고쳤으면 덮어쓰지 않는다
    if (b.baseUpdatedAt !== undefined && Number(b.baseUpdatedAt) !== row.updated_at) throw conflict('다른 사람이 먼저 수정했습니다. 새로 불러온 뒤 다시 시도해 주세요.');
    checkWriteRate(user);
    checkRoom(vault, data.length, row.data.length);
    run('UPDATE items SET data = ?, updated_by = ?, updated_at = ? WHERE id = ?', data, user.id, Math.max(now(), row.updated_at + 1), row.id);
    audit({ userId: user.id, teamId: vault.team_id, vaultId: vault.id, action: `${ACTION[row.kind] ?? row.kind}_update`, targetEnc: auditLabel(b), detail: { id: row.id }, ip: req.ip });
    return itemOut(get<ItemRow>('SELECT * FROM items WHERE id = ?', row.id)!);
  });

  app.delete('/api/items/:id', async (req) => {
    const user = requireUser(req);
    const { row, vault } = loadItem(user, (req.params as { id: string }).id, 'edit');
    const b = req.body && typeof req.body === 'object' ? (req.body as Record<string, unknown>) : {};
    // 오프라인에서 지운 것을 나중에 올릴 때: 그사이 다른 곳에서 고쳤으면 지우지 않는다 (고친 쪽을 살린다)
    if (b.baseUpdatedAt !== undefined && Number(b.baseUpdatedAt) !== row.updated_at) throw conflict('다른 곳에서 먼저 수정한 항목이라 지우지 않았습니다.');
    run('DELETE FROM items WHERE id = ?', row.id);
    audit({ userId: user.id, teamId: vault.team_id, vaultId: vault.id, action: row.kind === 'knownhost' ? 'knownhost_delete' : `${ACTION[row.kind] ?? row.kind}_delete`, targetEnc: auditLabel(b), detail: { id: row.id }, ip: req.ip });
    return { ok: true };
  });

  // 개인 볼트의 서버 사본 비우기: 앱에서 개인 동기화를 끄면서 "서버에서 지우기"를 고른 때. 이 PC 의 사본은 앱이 들고 있다.
  // 개인 볼트만 (팀 볼트는 팀이 함께 쓰는 것이라 한 번에 비우지 않는다)
  app.delete('/api/vaults/:vaultId/items', async (req) => {
    const user = requireUser(req);
    const { vaultId } = req.params as { vaultId: string };
    const vault = requireVault(user, vaultId, 'edit');
    if (vault.kind !== 'personal') throw badRequest('개인 볼트만 한 번에 비울 수 있습니다');
    const b = req.body && typeof req.body === 'object' ? (req.body as Record<string, unknown>) : {};
    if (b.confirm !== 'CLEAR') throw badRequest('확인 값이 없습니다');
    const deleted = run('DELETE FROM items WHERE vault_id = ?', vaultId).changes;
    audit({ userId: user.id, teamId: null, vaultId, action: 'vault_clear', detail: { items: deleted }, ip: req.ip });
    return { deleted };
  });

  app.post('/api/vaults/:vaultId/audit', async (req) => {
    const user = requireUser(req);
    const { vaultId } = req.params as { vaultId: string };
    const vault = requireVault(user, vaultId, 'view');
    const b = body(req.body);
    const action = String(b.action ?? '');
    if (!CLIENT_ACTIONS.has(action)) throw badRequest('기록 종류가 올바르지 않습니다');
    // 기록을 끝없이 쌓지 못하게: 넘으면 받은 것으로 하고 버린다 (앱은 기록 실패를 사용자에게 보이지 않는다)
    if (auditThrottle.blocked(user.id)) return { ok: true, dropped: true };
    auditThrottle.fail(user.id);
    const d = (b.detail && typeof b.detail === 'object' ? b.detail : {}) as Record<string, unknown>;
    const detail: Record<string, unknown> = {};
    if (typeof d.hostId === 'string' && UUID.test(d.hostId)) detail.hostId = d.hostId;
    if (typeof d.seconds === 'number' && Number.isFinite(d.seconds)) detail.seconds = Math.max(0, Math.round(d.seconds));
    if (typeof d.reason === 'string') detail.reason = d.reason.slice(0, 160);
    if (typeof d.via === 'string') detail.via = d.via.slice(0, 20);
    // HTTP 요청: 응답 코드·걸린 시간·실패 종류만 (주소·헤더·본문은 받지 않는다)
    if (typeof d.status === 'number' && Number.isInteger(d.status) && d.status >= 0 && d.status < 1000) detail.status = d.status;
    if (typeof d.ms === 'number' && Number.isFinite(d.ms)) detail.ms = Math.max(0, Math.round(d.ms));
    if (typeof d.fail === 'string' && HTTP_FAIL.has(d.fail)) detail.fail = d.fail;
    // 오프라인일 때 한 일: 앱이 모아 뒀다가 다시 연결되면 올린다. 실제로 한 때(앱 시계)를 따로 남긴다 — 최근 30일 안만
    if (typeof d.offlineAt === 'number' && Number.isFinite(d.offlineAt)) {
      const at = Math.round(d.offlineAt);
      if (at <= now() + 60_000 && at >= now() - OFFLINE_AUDIT_MAX_AGE) detail.offlineAt = at;
    }
    audit({ userId: user.id, teamId: vault.team_id, vaultId, action, targetEnc: auditLabel(b), detail, ip: req.ip });
    return { ok: true };
  });

  // ---------- 기록 ----------
  // 편집 권한이면 볼트 전체 기록(팀 볼트면 팀 관리 기록 포함), 보기 권한이면 내 기록만
  app.get('/api/vaults/:vaultId/logs', async (req) => {
    const user = requireUser(req);
    const { vaultId } = req.params as { vaultId: string };
    const access = vaultAccess(user, vaultId);
    if (!access) throw notFound('볼트를 찾을 수 없습니다');
    const before = Number((req.query as { before?: string }).before) || Number.MAX_SAFE_INTEGER;
    // 팀 관리 기록(초대·팀원·역할 — 팀원 IP·초대한 이메일이 담긴다)은 팀 관리자에게만. 볼트 편집자는 그 볼트의 기록만
    const manager = access.vault.kind === 'team' && isTeamManager(teamRole(user.id, access.vault.team_id!));
    const where =
      access.perm === 'edit'
        ? access.vault.kind === 'team'
          ? manager
            ? '(a.vault_id = ? OR (a.vault_id IS NULL AND a.team_id = ?))'
            : '(a.vault_id = ? AND ? IS NOT NULL)'
          : '(a.vault_id = ? OR (a.vault_id IS NULL AND a.team_id IS NULL AND a.user_id = ?))'
        : '(a.vault_id = ? AND a.user_id = ?)';
    const second = access.perm === 'edit' && access.vault.kind === 'team' ? access.vault.team_id : user.id;
    return all<{ id: number; ts: number; action: string; target: string; targetEnc: string | null; detail: string; ip: string; userName: string | null; userEmail: string | null }>(
      `SELECT a.id, a.ts, a.action, a.target, a.target_enc AS targetEnc, a.detail, a.ip, u.name AS userName, u.email AS userEmail
         FROM audit_log a LEFT JOIN users u ON u.id = a.user_id
        WHERE ${where} AND a.id < ?
        ORDER BY a.id DESC LIMIT 200`,
      vaultId,
      second,
      before,
    ).map((r) => ({ ...r, detail: JSON.parse(r.detail) }));
  });

  // ---------- 볼트 키 ----------
  // 아무도 키를 갖지 않은 빈 볼트(새로 만든 볼트)는 편집 권한이 있는 사람이 처음 키를 만든다
  app.post('/api/vaults/:vaultId/key', async (req) => {
    const user = requireUser(req);
    const { vaultId } = req.params as { vaultId: string };
    const vault = requireVault(user, vaultId, 'edit');
    if (!user.public_key) throw badRequest('먼저 암호화 비밀번호를 설정해 주세요');
    const wrapped = sealed(body(req.body).wrapped, 'wrapped', 1024);
    tx(() => {
      if (get('SELECT 1 AS x FROM vault_keys WHERE vault_id = ? LIMIT 1', vaultId)) throw conflict('이 볼트에는 이미 키가 있습니다. 관리자에게 공유를 요청해 주세요.');
      if (get('SELECT 1 AS x FROM items WHERE vault_id = ? LIMIT 1', vaultId)) throw conflict('볼트 키를 가진 사람이 없어 기존 항목을 풀 수 없습니다.');
      run('INSERT INTO vault_keys (vault_id, user_id, wrapped, wrapped_by, created_at) VALUES (?, ?, ?, ?, ?)', vaultId, user.id, wrapped, user.id, now());
    });
    audit({ userId: user.id, teamId: vault.team_id, vaultId, action: 'vault_key_init', target: vault.name, ip: req.ip });
    return { ok: true };
  });

  // 내 볼트 키를 내 계정 키로 다시 봉해 둔다: 봉한 공개키가 내 것이면 나만 만들 수 있는 암호문이라, 다른 기기에서 열 때
  // 서버가 만들어 넣은 키가 아님을 확인할 수 있다 (보안 점검 H-2). 이미 이 볼트 키를 받은 사람이 자기 것만 바꾼다
  app.put('/api/vaults/:vaultId/key/mine', async (req) => {
    const user = requireUser(req);
    const { vaultId } = req.params as { vaultId: string };
    const vault = requireVault(user, vaultId, 'view');
    const wrapped = sealed(body(req.body).wrapped, 'wrapped', 1024);
    const res = run('UPDATE vault_keys SET wrapped = ?, wrapped_by = ? WHERE vault_id = ? AND user_id = ?', wrapped, user.id, vaultId, user.id);
    if (!res.changes) throw notFound('볼트 키를 찾을 수 없습니다');
    audit({ userId: user.id, teamId: vault.team_id, vaultId, action: 'vault_key_rewrap', ip: req.ip });
    return { ok: true };
  });

  // 내가 관리하는 팀 볼트 중, 볼 권한은 있는데 아직 볼트 키를 못 받은 사람
  app.get('/api/vault-keys/pending', async (req) => {
    const user = requireUser(req);
    return all<{ vaultId: string; vaultName: string; teamName: string; userId: string; email: string; name: string; publicKey: string; keysCreatedAt: number | null }>(
      `SELECT v.id AS vaultId, v.name AS vaultName, t.name AS teamName, u.id AS userId, u.email, u.name, u.public_key AS publicKey, u.keys_created_at AS keysCreatedAt
         FROM vaults v
         JOIN teams t ON t.id = v.team_id
         JOIN team_members me ON me.team_id = v.team_id AND me.user_id = ? AND me.role IN ('owner', 'admin')
         JOIN vault_keys mine ON mine.vault_id = v.id AND mine.user_id = me.user_id
         JOIN team_members tm ON tm.team_id = v.team_id
         JOIN users u ON u.id = tm.user_id
        WHERE v.kind = 'team' AND u.public_key IS NOT NULL AND u.disabled = 0
          AND (tm.role IN ('owner', 'admin') OR EXISTS (SELECT 1 FROM vault_members vm WHERE vm.vault_id = v.id AND vm.user_id = u.id))
          AND NOT EXISTS (SELECT 1 FROM vault_keys vk WHERE vk.vault_id = v.id AND vk.user_id = u.id)
        ORDER BY t.name, v.name, u.email`,
      user.id,
    );
  });

  app.post('/api/vault-keys', async (req) => {
    const user = requireUser(req);
    const grants = body(req.body).grants;
    if (!Array.isArray(grants) || !grants.length || grants.length > 200) throw badRequest('공유할 항목이 없습니다');
    let added = 0;
    for (const g of grants as Record<string, unknown>[]) {
      const vaultId = String(g?.vaultId ?? '');
      const userId = String(g?.userId ?? '');
      const wrapped = sealed(g?.wrapped, 'wrapped', 1024);
      const vault = get<VaultRow>('SELECT * FROM vaults WHERE id = ?', vaultId);
      if (!vault || vault.kind !== 'team') throw notFound('볼트를 찾을 수 없습니다');
      const myRole = get<{ role: string }>('SELECT role FROM team_members WHERE team_id = ? AND user_id = ?', vault.team_id, user.id)?.role;
      if (myRole !== 'owner' && myRole !== 'admin') throw notFound('볼트를 찾을 수 없습니다');
      if (!get('SELECT 1 AS x FROM vault_keys WHERE vault_id = ? AND user_id = ?', vaultId, user.id)) throw badRequest('내가 이 볼트 키를 갖고 있지 않습니다');
      const target = get<User>('SELECT * FROM users WHERE id = ?', userId);
      if (!target?.public_key || !vaultAccess(target, vaultId)) throw badRequest('그 사람은 이 볼트를 볼 수 없거나 아직 암호화 설정을 하지 않았습니다');
      const res = run(
        'INSERT INTO vault_keys (vault_id, user_id, wrapped, wrapped_by, created_at) VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING',
        vaultId,
        userId,
        wrapped,
        user.id,
        now(),
      );
      if (res.changes) {
        added++;
        audit({ userId: user.id, teamId: vault.team_id, vaultId, action: 'vault_key_share', target: target.email, ip: req.ip });
      }
    }
    return { added };
  });
}