SSH 터미널에서 AI 도우미에게 Nginx 502 오류를 분석해 달라고 하면 로그를 복사하고 창을 옮겨 다니는 일을 줄일 수 있습니다. 하지만 터미널에는 오류 메시지만 보이지 않습니다. 환경 변수, 내부 호스트 이름, 설정 파일 내용, 실수로 출력된 액세스 토큰도 섞여 있을 수 있습니다.

AI가 파일을 읽고 명령까지 실행할 수 있다면 답변이 맞는지만 확인해서는 부족합니다. 터미널 내용은 누구에게 전달되고, 어떤 작업은 다시 승인을 받지 않고 실행될까요?

Hatoba는 이 질문을 소스로 검토하기 좋은 예입니다. Show HN 게시물에 소개된 오픈소스 SSH 데스크톱 클라이언트로, AI 도우미와 로컬 Vault, 사용자 Cloudflare 계정의 Worker + D1을 이용한 E2EE 동기화를 결합합니다. 그러나 동기화된 콘텐츠가 종단 간 암호화된다는 사실만으로 AI를 사용하는 모든 과정이 보호된다고 볼 수는 없습니다.

먼저 네 가지 결론입니다.

  • E2EE는 Vault 콘텐츠를 보호하지만 클라우드에서 모든 정보를 숨기지는 않습니다. Worker는 인증 정보와 항목 ID, 버전, 시각 등 동기화 메타데이터를 처리합니다.
  • 읽기 전용 작업도 모델로 데이터를 전송할 수 있습니다. 터미널 화면, 로그, 첨부 파일, read_file 출력이 모델 문맥에 포함되는 경로는 Cloudflare 동기화 암호화와 별개입니다.
  • Manual 모드가 모든 읽기를 매번 승인받는 것은 아닙니다. 읽기·쓰기·실행의 승인 경로가 다르며 Auto, Bypass, MCP에 별도 조건이 있습니다.
  • 인증을 통과한 암호문이 최신 데이터라는 뜻은 아닙니다. AES-GCM 인증 태그만으로는 오래된 정상 암호문의 재전송을 알아낼 수 없습니다.
위험 상황경계가 바뀌는 지점고정 버전 소스에서 확인되는 내용도입 전에 확인할 것
클라우드 Vault 노출로컬 Vault → Worker/D1항목 본문은 클라이언트에서 암호화, 일부 메타데이터는 서버에 표시키 분리, 복구, 메타데이터, 백업
운영 로그·자격 증명이 AI에 전송SSH 화면/파일 → 모델 제공자화면, 첨부, 도구 출력은 동기화와 별도로 전송될 수 있음전송 설정, 마스킹, 모델 제공자
AI의 위험한 명령 실행AI 도구 → SSH 호스트읽기와 수정/실행은 승인 조건이 다르고 Bypass는 일반 승인을 생략최소 권한 SSH 사용자, Manual 확인
이전 동기화 상태 재전송Worker/D1 → 로컬 Vault암호문 인증만으로 최신 버전임을 증명할 수 없음신뢰할 수 있는 버전 상태, 복구

분석 대상은 **Hatoba v0.3.0의 고정 커밋 5e9b35302**입니다. Rust 암호 모듈, Worker 데이터베이스 스키마, 프런트엔드 도구 승인 코드를 살펴보고, 암호학적 성질은 별도로 작성한 Python 모형으로 검증했습니다. Hatoba 프로그램을 직접 설치하거나 SSH 실환경에서 보안 시험을 한 결과는 아닙니다.

E2EE로 보호되는 Vault 본문과 D1에 남는 정보

우선 키 구조를 살펴보겠습니다. Rust crypto.rs에서는 Argon2id로 마스터 암호에서 master_key를 도출하고, HKDF-SHA256으로 목적별 키를 분리합니다. 다음 두 문자열이 핵심입니다.

enc_key: hkdf_sha256(master_key, "hatoba/enc/v1"),
auth_key: hkdf_sha256(master_key, "hatoba/auth/v1"),

enc_key는 무작위로 생성한 vault_key를 로컬에서 감싸는 데 사용합니다. 개별 항목은 vault_key와 AES-256-GCM으로 암호화하고, AAD를 hatoba/item/v1/{item_id}에 묶습니다. 정상적인 암호문을 다른 항목 ID에 붙이는 경우에도 인증이 실패하도록 하는 구조입니다.

반면 auth_key는 로그인용입니다. Worker의 인증 라우트는 클라이언트가 제출한 도출된 인증 키의 SHA-256을 저장된 auth_hash와 비교하고 성공하면 세션 토큰을 발급합니다. 로그인 자격 증명은 Vault를 해독하는 키와 분리되어 있습니다. 그렇다고 인증 키나 복구 코드, 세션 토큰을 덜 중요하게 취급해도 된다는 뜻은 아닙니다.

이제 D1의 items 테이블 정의를 보겠습니다. 주석과 인덱스를 제외한 핵심 필드입니다.

CREATE TABLE items (
  id         TEXT PRIMARY KEY,
  envelope   TEXT,
  revision   INTEGER NOT NULL,
  seq        INTEGER NOT NULL,
  deleted    INTEGER NOT NULL DEFAULT 0,
  updated_at INTEGER NOT NULL
);

envelope가 암호화된 항목 본문을 담고 있습니다. 하지만 id, revision, seq, deleted, updated_at는 동기화와 충돌 처리를 위해 서버가 볼 수 있습니다. 항목 수, 암호문 크기, 접속·수정 시각 등도 암호화된 본문과 다른 범주의 정보입니다. 공식 보안 명세 4.5절에도 서버에 보이는 범위가 정리되어 있습니다.

E2EE는 콘텐츠의 기밀성을 위한 설계이지 사용 흔적까지 모두 감춘다는 약속이 아닙니다. 올바르게 암호화된 SSH 개인 키가 D1 데이터만으로 곧바로 읽히지는 않도록 설계되어 있지만, 동기화 활동과 세션 관리의 위험까지 사라지는 것은 아닙니다.

데이터가 최신인지도 별도 문제입니다. Hatoba의 위협 모델은 악의적인 Worker가 항목을 삭제하거나, 제공을 거부하거나, 과거 데이터를 되돌려 줄 수 있다고 인정합니다. 상태 표에서는 rollback detection이 미완성 P2 작업으로 남아 있습니다. 암호화와 롤백 방어는 같은 기능이 아닙니다.

읽기 전용 도구가 가져온 로그도 모델로 나갈 수 있다

운영 서버의 Nginx 로그를 읽다가 내부 도메인이나 자격 증명의 일부가 포함된 줄을 만났다고 가정해 보겠습니다. SSH 연결과 Vault 동기화가 암호화되어 있어도 그 로그가 모델 요청에 들어가면 데이터를 받는 주체가 달라집니다.

Hatoba README의 보안 설명에 따르면 메시지 전송에 터미널 화면 내용이 함께 붙을 수 있으며, 기본 포함 설정은 바꿀 수 있습니다. 사용자 메시지, 첨부 파일, 도구 결과도 선택한 모델 제공자에게 전달될 수 있습니다. 이것은 Vault 전체가 자동으로 업로드된다는 주장이 아닙니다. 각 요청에 실제로 포함되는 문맥이 무엇인지가 중요합니다. Cloudflare 동기화를 끄는 것만으로 모델 통신까지 사라지지는 않습니다.

프런트엔드 tools.ts의 needsApproval()를 보면 일반적인 읽기 도구는 승인 대상으로 나열되어 있지 않습니다.

export function needsApproval(kind: ToolKind, mode: AiPermissionMode): boolean {
  if (mode === "bypass") return false;
  return kind === "run_command" || kind === "send_input" ||
    kind === "edit_file" || kind === "write_file" ||
    kind === "fetch_url" || kind === "mcp";
}

Manual에서는 run_command, send_input, edit_file, write_file, fetch_url 같은 작업이 일반적으로 승인을 요구합니다. 반면 read_terminal, read_file, grep, glob는 항상 호출마다 승인을 묻지는 않습니다. 이미 저장된 허용 규칙 때문에 다시 묻지 않을 수도 있습니다. MCP는 별도 정책을 사용하며 같은 파일의 mustAsk()에는 always_ask와 always_allow 조건이 있습니다.

읽기 전용이라는 말은 읽은 데이터가 로컬에만 남는다는 뜻이 아닙니다. 데이터를 읽을 권한과 그 데이터를 모델 제공자에게 보낼 권한은 분리해서 판단해야 합니다. 민감한 운영 서버라면 Manual 확인창에만 의존하기보다 터미널 화면의 문맥 포함 설정, 도구 출력 범위, 모델 제공자의 보관 정책부터 확인하는 것이 낫습니다.

Auto는 judge를 이용해 일부 호출에서 승인을 건너뛸지 평가합니다. 프로젝트 상태 문서의 단위 테스트·모의 서비스·제한된 검증은 악의적인 파일이나 터미널 출력의 프롬프트 인젝션을 모두 차단한다는 증거가 아닙니다. Bypass는 일반적인 확인을 생략하지만 MCP의 always_ask는 예외가 될 수 있습니다. 어느 모드든 OS와 SSH 사용자 권한을 독립적인 보호 장치로 둬야 합니다.

변조되지 않은 암호문도 오래되었을 수 있다: 독립 Python 테스트 6개

암호문의 내용이 바뀌면 AES-GCM 인증이 실패해야 합니다. 하지만 예전에 생성된 정상 암호문은 더 새로운 버전이 생겼다는 이유만으로 해독에 실패하지 않습니다. 이 차이를 작은 독립 모형으로 분리해 보았습니다.

고정 버전의 Argon2id, HKDF, AES-GCM, item-ID AAD를 참고해 별도 Python 모델을 작성했습니다. Hatoba Rust나 Worker 코드를 가져와 실행하지 않았습니다. 기록된 실행 환경은 Python 3.13.5, argon2-cffi 25.1.0, cryptography 46.0.4이며, 6개 unittest 모두 통과, 실패 0개, 종료 코드 0이었습니다.

독립 모형 확인 사항기록된 결과
enc_key와 auth_key 분리서로 다른 32바이트 키 생성
암호화/복호화와 평문 검색테스트 데이터 복원, 암호화 envelope에는 테스트 평문 없음
AES-GCM 암호문의 비트 변경InvalidTag로 복호화 실패
다른 item_id에 같은 envelope 사용AAD 불일치로 InvalidTag
한 항목에서 이전/새로운 정상 envelope 생성두 세대 모두 정상 복호화
동기화 행 메타데이터 모의revision, updated_at 노출, 항목 평문은 비노출

다섯 번째 확인은 실제 실행한 스크립트의 다음 단언으로 이루어졌습니다.

old = seal_item(self.vault_key, self.item_id, {"generation": 1})
new = seal_item(self.vault_key, self.item_id, {"generation": 2})

self.assertEqual(open_item(self.vault_key, self.item_id, new), {"generation": 2})
self.assertEqual(open_item(self.vault_key, self.item_id, old), {"generation": 1})

여기에는 AES-GCM의 문제가 없습니다. 둘 다 변조되지 않은 암호문이므로 인증에 성공합니다. 인증 태그에는 클라이언트가 마지막으로 수락한 버전이 기록되지 않습니다. 서버가 과거의 정상 데이터를 재전송했는지 확인하려면 공격자가 같이 되돌릴 수 없는 버전 기준점이나 다른 롤백 방어가 필요합니다.

유효한 암호문 ≠ 최신 데이터. Hatoba가 rollback detection을 별도 작업으로 둔 이유를 이해하는 데 도움이 되지만, 이 모형이 Hatoba 실제 동기화 구현의 취약점을 재현한 것은 아닙니다.

보관된 unittest 출력의 마지막 부분은 다음과 같습니다.

----------------------------------------------------------------------
Ran 6 tests in 0.100s

OK

여기 표시된 소요 시간은 독립 예제의 한 번의 실행 시간이지 Hatoba 암호화 성능이나 AI 응답 속도를 측정한 값이 아닙니다. 전체 소스는 글 끝에 남겼습니다.

AI에 운영 SSH 접근을 주기 전 확인할 네 단계

Hatoba에는 SSH, SFTP, Vault, AI 파일 편집, 클라우드 동기화 기능이 구현되어 있지만 ‘구현 존재’, ‘모의 환경에서 테스트’, ‘실제 서비스 검증’은 서로 다릅니다. v0.3.0 docs/status.md는 다음 제한을 명확히 적고 있습니다.

기능공식 문서의 당시 검증 범위
Worker 동기화구현과 관련 테스트는 있음. 이 글에서 직접 확인한 Cloudflare 배포 결과는 없음
D1 DirectSQLite로 REST API를 모의 검증, 실제 D1은 미검증
AI 제공자, ChatGPT 로그인프로토콜·모의 테스트 존재, 실제 계정/제공자 조합은 추가 검증 필요
AI 파일 작업과 승인 UI스크립트·OpenSSH 또는 모의 백엔드 테스트, 실제 모델-데스크톱 조합은 검증 부족
macOSApple Silicon 실험 빌드, ad-hoc 서명, 공증되지 않음, 기기별 검증 미완료

팀에 도입한다면 처음부터 운영 서버의 관리자 계정을 넘기는 대신 단계별로 권한을 넓힐 것입니다.

  1. 호스트와 계정 격리. 민감 데이터가 없는 테스트 서버를 사용하고 root가 아닌 최소 권한 SSH 사용자로 시작합니다. 불필요한 동기화를 끄고 제한된 읽기 진단만 수행합니다.
  2. 외부로 나가는 내용 검토. 터미널 화면이 기본 포함되는지, 로그·첨부·도구 출력이 어느 모델로 전송되는지, 제공자의 보관 정책을 확인합니다. 고객 정보나 운영 자격 증명은 분류·마스킹을 먼저 적용하고, 조건을 충족하지 못하면 원문을 외부 모델에 보내지 않습니다.
  3. Manual 승인 시험. 테스트 서버에서 무해한 파일 쓰기를 제안해 승인 카드가 나타나는지, ‘항상 허용’이 남아 있지 않은지, 복구 가능한지 봅니다. Auto, Bypass, MCP, 외부 Skill의 권한도 따로 검토합니다.
  4. 복구와 백업 확인. 마스터 암호와 복구 코드를 안전하게 분리 보관하고, 기기 분실 시 세션 해제와 사용 가능한 내보내기/복원, 오프라인 백업을 점검합니다. 둘 다 분실하면 Hatoba 설계상 기존 Vault를 복호화할 수 없습니다.

쓰기 명령에 승인 단계가 있어도 원격 파일이나 터미널 출력에는 모델을 유도하는 공격성 지시가 숨어 있을 수 있습니다. 도구 승인과 프롬프트 인젝션 방어를 같은 보호 장치로 취급하면 안 됩니다.

AI SSH 도구의 장점은 로그 복사와 창 전환을 줄이고 진단에서 다음 행동으로 넘어가는 마찰을 줄일 수 있다는 것입니다. 하지만 운영 도입을 결정할 기준은 E2EE 표시가 아니라 누가 데이터를 받는지, 누가 명령을 실행할 수 있는지, 문제가 생기면 어느 상태까지 되돌릴 수 있는지입니다. 위의 위험 표는 다른 AI 터미널 도구를 비교할 때도 활용할 수 있습니다.

AI에 SSH를 직접 맡기기보다 승인된 배포 경로로 동작을 제한하고 싶다면 ChatGPT Web + GitHub Self-hosted Runner에서 다른 방식을 다룹니다.

독립 Python 암호 모델 테스트 6개의 전체 코드

합성 데이터만 사용하는 스크립트로, Hatoba나 외부 서비스에 접속하지 않습니다. 분리된 Python 환경에서 argon2-cffi와 cryptography를 설치하고 hatoba_crypto_boundary_fixture.py로 저장한 뒤 python3 -m unittest -v hatoba_crypto_boundary_fixture.py를 실행할 수 있습니다.

"""Isolated, source-adapted crypto boundary demonstration; NOT Hatoba execution.

Models only the publicly documented v0.3.0 key labels, algorithms and item AAD.
No Hatoba source is imported or run, no network, accounts, SSH or real data.
"""
import base64
import json
import os
import unittest
from argon2.low_level import Type, hash_secret_raw
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF


def derive_keys(password: bytes, salt: bytes):
    master_key = hash_secret_raw(
        password, salt, time_cost=3, memory_cost=64 * 1024,
        parallelism=4, hash_len=32, type=Type.ID,
    )
    def derive(info):
        return HKDF(algorithm=hashes.SHA256(), length=32, salt=None, info=info).derive(master_key)
    return derive(b"hatoba/enc/v1"), derive(b"hatoba/auth/v1")


def seal_item(vault_key: bytes, item_id: str, content: dict) -> dict:
    nonce = os.urandom(12)
    aad = ("hatoba/item/v1/" + item_id).encode()
    ciphertext = AESGCM(vault_key).encrypt(
        nonce, json.dumps(content, sort_keys=True).encode(), aad,
    )
    return {"v": 1, "n": base64.b64encode(nonce).decode(),
            "c": base64.b64encode(ciphertext).decode()}


def open_item(vault_key: bytes, item_id: str, envelope: dict) -> dict:
    nonce = base64.b64decode(envelope["n"])
    ciphertext = base64.b64decode(envelope["c"])
    aad = ("hatoba/item/v1/" + item_id).encode()
    return json.loads(AESGCM(vault_key).decrypt(nonce, ciphertext, aad))


class BoundaryFixture(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        cls.enc_key, cls.auth_key = derive_keys(b"test-only-long-password", b"16-byte-testsalt")
        cls.vault_key = os.urandom(32)
        cls.item_id = "test-item-1"
        cls.content = {"type": "host", "hostname": "fixture.example.invalid", "secret": "synthetic-only"}

    def test_distinct_auth_and_encryption_keys(self):
        self.assertEqual(len(self.enc_key), 32)
        self.assertEqual(len(self.auth_key), 32)
        self.assertNotEqual(self.enc_key, self.auth_key)

    def test_roundtrip_and_no_plaintext_in_envelope(self):
        envelope = seal_item(self.vault_key, self.item_id, self.content)
        self.assertNotIn("fixture.example.invalid", json.dumps(envelope))
        self.assertNotIn("synthetic-only", json.dumps(envelope))
        self.assertEqual(open_item(self.vault_key, self.item_id, envelope), self.content)

    def test_tampered_ciphertext_rejected(self):
        envelope = seal_item(self.vault_key, self.item_id, self.content)
        tampered = dict(envelope)
        c = bytearray(base64.b64decode(envelope["c"]))
        c[-1] ^= 0x01
        tampered["c"] = base64.b64encode(c).decode()
        with self.assertRaises(InvalidTag):
            open_item(self.vault_key, self.item_id, tampered)

    def test_other_item_id_rejected_by_aad(self):
        envelope = seal_item(self.vault_key, self.item_id, self.content)
        with self.assertRaises(InvalidTag):
            open_item(self.vault_key, "another-item", envelope)

    def test_stale_but_valid_envelope_still_decrypts(self):
        # An authenticated envelope is not a freshness/revision check.
        old = seal_item(self.vault_key, self.item_id, {"generation": 1})
        new = seal_item(self.vault_key, self.item_id, {"generation": 2})
        self.assertEqual(open_item(self.vault_key, self.item_id, new), {"generation": 2})
        self.assertEqual(open_item(self.vault_key, self.item_id, old), {"generation": 1})

    def test_server_row_exposes_sync_metadata_not_item_plaintext(self):
        envelope = seal_item(self.vault_key, self.item_id, self.content)
        row = {"id": self.item_id, "envelope": json.dumps(envelope),
               "revision": 2, "seq": 6, "deleted": False, "updated_at": 42}
        serialized = json.dumps(row)
        self.assertIn('"revision": 2', serialized)
        self.assertIn('"updated_at": 42', serialized)
        self.assertNotIn("fixture.example.invalid", serialized)
        self.assertEqual(json.loads(row["envelope"]), envelope)


if __name__ == "__main__":
    unittest.main()