셸은 내 서버에, 키는 내 기기에.
SpectaShell은 SSH 클라이언트와, 서버에서 셸을 붙잡아 두는 작은 프로세스 하나로 이루어져 있습니다. 서버에는 내 SSH 로그인으로만 들어가고, 포트를 열지 않고, root를 쓰지 않습니다. 개인 키는 기기의 Secure Enclave 밖으로 나오지 않습니다. Calida Lab 서버가 내 터미널을 보는 일은 없습니다. 우리가 운영하는 서버는 알림 relay 하나뿐이고, 알림은 서버에서 HPKE로 봉인되어 내 기기만 열 수 있습니다. 아래는 이 말들이 구체적으로 무슨 뜻인지, 직접 확인하는 방법과 함께 정리한 것입니다.
~/.local/share/spectashell에 keeper 설치authorized_keys에 내 기기의 공개 키 추가 (주석이 SpectaShell로 시작).bashrc, .zshrc 같은 셸 설정 파일 수정SSH로 연 셸은 그 연결의 자식 프로세스라서, 연결이 끊기면 SIGHUP을 받고 그 안의 작업도 같이 끝납니다. tmux나 screen이 이 문제를 서버 쪽 프로세스로 푸는 것처럼, SpectaShell은 셸마다 spectashell-keeper 프로세스를 하나씩 띄워 그 안에서 셸을 실행합니다. keeper가 PTY와 최근 출력을 쥐고 있으므로 연결이 끊기거나, 기기가 잠들거나, 앱을 종료해도 셸은 계속 돌고, 다시 붙으면 화면이 그대로 돌아옵니다.
sshd → spectashell-keeper attach0700 디렉터리 안spectashell-keeper attach를 실행해 그 소켓에 붙습니다. 방화벽 규칙을 바꿀 일이 없습니다. 밖으로 나가는 연결도 없습니다. 예외는 하나, 알림을 켠 경우 봉인한 알림을 relay로 보내는 HTTPS 요청입니다(05 · 알림 relay)./tmp/spectashell-<uid>/keepers, Mac은 ~/Library/Application Support/SpectaShell/keepers를 씁니다. keeper는 이 디렉터리가 심볼릭 링크가 아닌 실제 디렉터리인지, 내 소유인지, 권한이 0700인지 확인하고, 아니면 시작하지 않습니다. 다른 사용자가 같은 경로를 먼저 만들어 두는 공격도 여기서 막힙니다. 소켓 자체는 0600입니다.$XDG_RUNTIME_DIR가 아니라 /tmp인가. $XDG_RUNTIME_DIR는 마지막 세션이 로그아웃하면 지워져서, 연결이 끊긴 뒤에도 셸을 살려 두려는 목적과 맞지 않습니다. tmux가 /tmp/tmux-<uid>를 쓰는 것과 같은 이유입니다. 대신 systemd-tmpfiles 같은 나이 기반 정리에 걸리지 않도록 keeper가 한 시간마다 소켓, 메타데이터 파일, 디렉터리의 시간을 갱신하고, 그래도 지워지면 다시 만듭니다. Mac은 /tmp를 주기적으로 비우므로 Application Support 아래에 둡니다.~/.local/share/spectashell/ 아래 빌드별 디렉터리에 넣습니다. Plain Shells를 고른 서버에는 아무 프로그램도 올리지 않고 일반 SSH로 씁니다..bashrc, .zshrc, fish 설정은 수정하지 않습니다.TMUX, 코딩 에이전트가 자식 프로세스에 붙이는 표시 같은 변수는 넘기지 않습니다. 그 위에 TERM=xterm-256color, COLORTERM=truecolor, TERM_PROGRAM=sdTerm을 설정합니다.기기 키는 로그인할 때마다 Face ID를 요구합니다. 그런데 앱의 Running 화면은 서버마다 어떤 셸이 돌고 있는지 수시로 확인해야 하므로, 기기마다 묻지 않고 서명하는 두 번째 키인 목록 키를 하나 더 둡니다. 이 키로 할 수 있는 일은 keeper 목록 조회 하나뿐이고, 그 제한은 앱이 아니라 서버의 sshd가 강제합니다.
~/.ssh/authorized_keys 줄. restrict가 PTY 할당, 포트·에이전트·X11 포워딩을 모두 끄고, command=가 클라이언트가 무엇을 요청하든 이 명령만 실행하게 합니다. 셸도, 파일 전송도 열리지 않습니다.restrict,command="$HOME/.local/share/spectashell/current/spectashell-keeper list" ecdsa-sha2-nistp256 AAAA… SpectaShell listing on iPhoneauthorized_keys에서 줄을 지울 때는 주석의 기기 이름이 아니라 키 자체를 비교하고, 팀 공유로 넣은 줄은 그 팀 표시가 있는 줄만 건드립니다. 직접 넣은 다른 줄은 그대로 둡니다.동기화와 팀 공유는 기기에 이미 로그인된 Apple 계정의 iCloud로 합니다. Calida Lab에는 내 계정도, 호스트 목록도, 키도 없습니다. 유일하게 운영하는 알림 relay가 아는 것은 기기의 알림 토큰과 호스트들의 공개 키뿐입니다.
sha256sum ~/.local/share/spectashell/*/spectashell-keeper로 대조하면 됩니다.기기마다 자기 키를 직접 만듭니다. iPhone, iPad, Apple 실리콘이나 T2 칩이 있는 Mac에서는 Secure Enclave 안에서 만들어져 개인 키를 꺼낼 방법이 없습니다. SpectaShell도, 기기를 가진 사람도 마찬가지입니다. 그런 칩이 없는 Mac에서는 동기화되지 않도록 표시한 키체인 항목에 둡니다. 서버가 키를 받아들일 때마다 Face ID, Touch ID 또는 기기 암호를 확인한 뒤 서명합니다. 외부 키를 가져오는(import) 기능은 없습니다.
서버에 에이전트 포워딩을 켜면, 그 서버가 다른 곳에 로그인하거나 git 커밋에 서명할 때 내 기기 키를 쓸 수 있습니다. 서명 요청마다 어느 서버가 무엇에 쓰려는지 보여 주고 따로 생체 인증을 받으며, 다른 요청에 한 승인을 재사용하지 않습니다. 한 번 취소하면 그 연결의 이후 요청은 거부합니다. 포워딩된 에이전트로 키를 추가하거나 지우는 것도 막습니다.
~/.ssh는 읽기만Mac에서는 ~/.ssh/config, ~/.ssh의 키, SSH 에이전트, known_hosts를 ssh처럼 읽습니다. 수정하거나 복사하거나 업로드하지 않습니다. 단, Mac SSH 에이전트의 키는 ssh가 하듯 묻지 않고 포워딩되므로, 믿는 서버에서만 포워딩을 켜세요.
비밀번호, 패스프레이즈, OTP는 물어서 로그인에 넘기고 바로 잊습니다. 대화상자에도 "Not stored anywhere"라고 적혀 있습니다. 입력칸은 시스템 자동 완성을 지원해서 암호 앱이나 1Password가 비밀번호와 일회용 코드를 채울 수 있습니다. 보관은 그 비밀번호 관리자가 하고, SpectaShell은 하지 않습니다. 비밀번호로 한 번 로그인하면 기기 키를 서버에 추가하자고 제안해서, 다음부터는 키로 들어가게 합니다.
OpenSSH와 같은 방식입니다. 처음 접속할 때 키 종류와 지문을 보여 주고 신뢰할지 묻습니다. 신뢰한 키는 다른 기기에도 동기화됩니다. 키가 바뀌면 모든 기기에서 접속을 거부하고, 일부러 Forget Old Key…를 해야만 새 키를 받습니다. Mac에서는 기존 ~/.ssh/known_hosts도 그대로 신뢰합니다.
다른 기기에서 Keys를 열고 잃어버린 기기 옆의 Remove from Every Host…를 누르면, 호스트 목록의 모든 서버에 접속해 그 기기의 키를 지웁니다. 그때 꺼져 있던 서버는 다음에 내 기기가 로그인할 때 지웁니다. 그 키는 다시 올라가지 않고, 그 기기로 가던 알림도 멈춥니다.
서버의 에이전트가 승인을 기다리는데 어느 기기도 그 셸을 보고 있지 않으면, keeper가 iPhone에 알림을 보낼 수 있습니다(설정 ▸ Notifications ▸ Notify This Device). 문제는 Apple이 앱의 푸시 키를 가진 쪽에서만 알림을 받는다는 점입니다. 그 키를 사용자 서버마다 나눠 줄 수는 없으므로, 중간에 Calida Lab의 relay(push.calidalab.ai)가 있습니다. relay는 내용을 볼 수 없게 설계했습니다.
~/.local/share/spectashell-push 아래 파일 하나가 생기고, 나만 읽을 수 있습니다. 서명 키(서버에서 생성)와 기기의 봉인용 공개 키가 들어 있습니다. keeper는 알릴 일이 있을 때만 HTTPS로 보내고, 여전히 아무것도 listen하지 않습니다.0600. 명령 기록, 마지막 동기화 상태 같은 SpectaShell 파일은 나만 읽을 수 있습니다.push.calidalab.ai에 접속합니다.127.0.0.1이라 같은 네트워크의 다른 기기는 쓸 수 없습니다.SpectaShell이 서버에 두는 것은 아래가 전부입니다. 서버의 다른 곳은 건드리지 않습니다.
~/.local/share/spectashell/ # keeper 바이너리 (빌드별 디렉터리)와 셸 통합 스크립트
~/.local/share/spectashell-push/ # 알림을 켠 경우에만: 받는 기기마다 파일 하나
/tmp/spectashell-$(id -u)/ # Linux: keeper 소켓과 메타데이터
~/Library/Application Support/SpectaShell/keepers/ # Mac 서버: 같은 것
~/.ssh/authorized_keys # 주석이 "SpectaShell"(초기 빌드는 "sdTerm")로 시작하는 줄authorized_keys의 줄은 세 종류입니다. 비밀번호 로그인 뒤 Add Key로 넣은 기기 키(SpectaShell on <기기>), 승인된 내 기기들의 목록 키(restrict,command=…로 시작하고 SpectaShell listing on <기기>로 끝남), 그리고 Who May Log In…으로 들여보낸 팀원의 키입니다. 기기 키를 새로 만들면 그 기기의 이전 키 줄은 앱이 지웁니다.아니요. 셸은 내 기기와 내 서버 사이 SSH 안에서만 오가고, 동기화 데이터는 내 iCloud 비공개 DB에, 개인 키와 비밀번호는 기기 안에만 있습니다. Calida Lab이 받는 것은 알림을 켰을 때 relay를 지나가는 봉인된 알림뿐이고, 이를 열 키는 내 기기에만 있습니다.
둘 다 아닙니다. keeper는 내 계정으로 홈 디렉터리에서 실행되고, 0700 디렉터리의 Unix 소켓만 씁니다. 들어오는 길은 내 SSH 로그인 하나뿐이고, 시스템 전체에 설치하는 것이 없습니다.
다른 일반 사용자는 못 들어옵니다. 내 계정과 root는 가능하며, tmux나 screen과 같습니다. 관리자를 믿을 수 없는 서버에서는 셸에 비밀을 두지 마세요.
SSH 에이전트 포워딩은 프로토콜 특성상 그 서버의 root도 포워딩된 에이전트를 쓸 수 있습니다. SpectaShell은 기기 키를 쓸 때마다 생체 인증으로 따로 묻기 때문에 몰래 쓰일 수는 없지만, Mac SSH 에이전트의 키는 ssh처럼 묻지 않고 넘어갑니다. 믿는 서버에서만 켜세요.
동기화된 호스트 목록과 스니펫은 넘어갑니다. 하지만 키와 비밀번호는 동기화되지 않으므로 서버에 로그인할 수는 없고, 새 기기의 목록 키도 내 기존 기기가 승인하기 전에는 서버에 올라가지 않습니다. 스니펫에 비밀번호나 토큰을 넣지 마세요.
그룹 공유는 Apple 계정 사이의 CloudKit 공유이고, 서버 이름과 주소만 오갑니다. 키나 비밀번호는 오가지 않으며, 공유만으로는 아무도 로그인할 수 없습니다. 누가 로그인할 수 있는지는 서버의 authorized_keys가 정합니다. 팀원을 내 계정으로 들여보내면 그 계정의 셸을 모두 보고 조작할 수 있으니, 사람마다 계정을 따로 주세요.
Mac에서 여는 로컬 셸은 내 파일에 닿아야 하는데, 샌드박스 앱의 셸은 그럴 수 없기 때문입니다. 그래서 iTerm2나 Ghostty처럼 Developer ID로 서명하고 Apple 공증을 받아 직접 배포합니다. iPad와 iPhone 앱은 App Store 앱이라 샌드박스 안에서 돌고, 로컬 셸 없이 서버의 셸만 엽니다.
다른 기기에서 Keys를 열고 잃어버린 기기 옆의 Remove from Every Host…를 누르세요. 모든 서버에서 그 기기의 키를 지우고, 그 기기로 가던 알림도 멈춥니다. 나의 찾기에서 기기를 지우고 Apple 계정에서도 제거하세요.
그 서버의 셸을 모두 닫은 뒤, 08 · 서버에 남는 것의 디렉터리를 지우고 ~/.ssh/authorized_keys에서 주석이 SpectaShell(초기 빌드는 sdTerm)로 시작하는 줄을 지우면 됩니다.