아키텍처#

이 페이지는 SafePassage 의 내부 설계와 주요 구현 선택의 이유를 설명합니다.

WebAssembly Argon2 엔진#

KeePass KDBX4 데이터베이스는 의도적으로 연산 비용이 큰 Argon2를 사용해 마스터 키를 유도합니다. SafePassage는 kdbxweb 의 암호화 엔진에 hash-wasm 기반 Argon2 구현을 등록하여, 키 유도 연산이 순수 JavaScript가 아닌 WebAssembly로 실행되도록 합니다. 덕분에 반복 횟수나 메모리 파라미터가 크더라도 Obsidian의 UI 스레드가 멈추거나 휴대폰·태블릿 같은 저사양 기기에서 메모리가 고갈되는 것을 막을 수 있습니다.

볼트 기준 상대 경로 파일 접근#

데이터베이스와 키 파일은 Node의 fs 모듈을 직접 사용하지 않고, 오직 Obsidian의 Vault API(vault.readBinary / vault.modifyBinary)를 통해 볼트 기준의 정규화된 상대 경로로만 읽고 씁니다. Vault API가 실제 저장소를 추상화해 주기 때문에, Secrets/vault.kdbx 같은 동일한 프로필 경로 설정이 플러그인이 파일 시스템에 직접 접근할 수 없는 데스크톱과 모바일(iOS/Android) 양쪽에서 똑같이 올바르게 해석됩니다.

메모리 내 세션 키링#

잠금 해제된 데이터베이스는 프로필 ID를 키로 하여 KdbxService 내부의 Map 에 오직 JavaScript 힙 메모리에만 보관되며 디스크에는 절대 기록되지 않습니다. 잠금 해제 모달에 입력한 마스터 암호도 같은 방식으로 KeyringService 에 캐싱되어, 세션이 살아있는 동안에는 인라인 칩이 있는 노트를 다시 열었을 때처럼 사용자에게 다시 묻지 않고 백그라운드에서 조용히 프로필을 재해제할 수 있습니다.

세션 만료와 잠금#

SessionService 는 프로필의 세션 만료 시간 설정에 따라 잠금 해제된 프로필마다 setTimeout 을 실행합니다: 즉시(한 번 조회 후 바로 다시 잠김), 5분, 15분, 또는 Obsidian을 종료하거나 수동으로 잠글 때까지 무기한입니다. 타이머가 만료되면 해당 항목은 활성 데이터베이스 맵과 메모리 내 키링에서 모두 제거되어, 그 시점부터는 비밀 정보에 접근할 수 없게 됩니다.

보호된 필드 값#

민감한 KeePass 필드(암호, 그리고 데이터베이스에서 “보호됨”으로 표시된 모든 필드)는 일반 문자열이 아니라 kdbxwebProtectedValue 컨테이너로 감싸져 보관됩니다. SafePassage는 마스킹된 칩의 복사 동작을 렌더링하거나, 표 셀을 채우거나, 새 항목을 기록하는 등 값이 실제로 필요한 순간에만 ProtectedValue.getText() 를 호출하며, 해당 호출 이후에는 디코딩된 평문 문자열을 보관하지 않습니다.

클립보드 정화#

비밀 정보가 클립보드에 복사되면 ClipboardService 는 타이머를 시작하고 자신이 기록한 값을 정확히 기억해 둡니다. 타이머가 만료되면 먼저 클립보드를 다시 읽어, 그 내용이 SafePassage가 복사한 값과 여전히 일치할 때만 빈 문자열로 덮어씁니다 — 그래서 그 사이 사용자가 다른 것을 복사해 두었다면 그 값을 절대 지우지 않습니다.

인라인 칩과 표 렌더링#

노트는 다음 두 가지 방식으로 비밀 정보를 참조하며, 둘 다 동일한 KdbxService 를 통해 해석됩니다:

  • 인라인 토큰({{sp:<프로필>/<참조>#<필드>}})은 라이브 프리뷰에서는 CodeMirror 데코레이션으로, 읽기 모드에서는 마크다운 후처리기로 각각 매칭되며, 두 경우 모두 동일한 마스킹 칩 컴포넌트를 렌더링합니다. <profile> 부분은 먼저 프로필 ID로 해석을 시도하고, 일치하는 것이 없으면 이름으로 대체 해석합니다 — 새로 삽입되는 토큰은 항상 ID를 사용하므로 이후 프로필 이름이 바뀌어도 계속 동작하며, 이 변경 이전에 삽입된 토큰은 이름 기반 대체 해석을 통해 그대로 동작합니다.

  • safe-passage 코드 블록은 YAML 본문을 파싱해 전체 자격 증명 표를 렌더링하는 전용 블록 프로세서가 처리합니다.

두 경로 모두 DOM에는 마스킹된 자리표시자만 표시할 뿐, 실제 비밀 값은 렌더링된 HTML에 포함되지 않고 사용자가 칩을 클릭해 값을 복사하는 시점 등 필요할 때마다 KdbxService 에서 가져옵니다.

UUID 항목 참조#

토큰의 <참조> 부분은 Group/Path 형태의 문자열이거나 uuid:<base64> 형태의 문자열이며, 문자열의 생김새를 추측하는 게 아니라 명시적인 uuid: 접두사만으로 구분합니다. KdbxService 는 이 접두사를 기준으로 분기합니다: 경로 참조는 기존과 동일하게 그룹 트리를 따라가며 찾고, UUID 참조는 kdbxweb에 UUID로 직접 조회하는 API가 없기 때문에 데이터베이스의 모든 항목(KdbxGroup.allEntries())을 순회하며 entry.uuid.id 를 비교합니다. KdbxService가 UI로 넘겨주는 모든 항목은 uuidgroupPath``(``entry.parentGroup 을 데이터베이스 루트까지 따라 올라가며 계산한 상위 그룹 경로)도 함께 담고 있는데, 이 덕분에 칩과 표가 항목의 제목 대신 전체 경로를 보여줄 수 있고, 자동완성 UI도 별도 조회 없이 uuid: 참조를 만들 수 있습니다.

항목 자동완성#

세 개의 독립된 UI가 동일한 항목 검색(KdbxService.findEntries / getAllEntries)을 노출하는데, 각각 서로 다른 맥락에서 트리거되기 때문입니다:

  • 비밀 정보 삽입 모달의 항목 입력 필드는 일반 텍스트 입력창용 자동완성인 Obsidian의 AbstractInputSuggest 를 사용합니다.

  • 노트에 직접 {{sp: 를 타이핑할 때는 대신 Obsidian의 EditorSuggest 를 사용하는데, 이는 폼 필드가 아니라 문서 안의 특정 위치에 고정되는 자동완성을 위한 API이기 때문입니다. 순수 함수 몇 개(detectTriggerContext, detectProfileFieldTrigger, findEntriesListTrigger)가 커서 주변의 텍스트만 파싱해서, 매 키 입력마다 현재 커서가 토큰의 프로필/참조/필드 부분 중 어디에 있는지 — 또는 safe-passage 코드 블록의 profile: 필드나 entries: 리스트 안에 있는지 — 를 에디터를 전혀 건드리지 않고 판단합니다. 그 덕분에 이 함수들은 단위 테스트가 가능하고, EditorSuggest.onTrigger 가 요구하는 대로 매 키 입력마다 호출해도 비용이 저렴합니다.

  • 같은 EditorSuggest 는 우선순위가 높은 CodeMirror 키맵으로 entries: 리스트 항목 줄 끝에서의 Enter도 가로챕니다. Obsidian 자체의 리스트 자동 들여쓰기가 코드펜스 안의 - 로 시작하는 줄도 실제 마크다운 리스트로 취급해서 줄마다 들여쓰기를 2칸씩 더 추가해 버리기 때문인데, 코드펜스 내용은 애초에 들여쓰기를 인식할 필요가 없는 대상입니다.

같은 노트 안에서 하나의 프로필을 여러 칩이나 표가 참조할 수 있고, 각각이 독립적인 ‘클릭해서 잠금 해제’ 핸들러를 렌더링하기 때문에, SafePassagePlugin.unlockProfile 은 같은 프로필 ID에 대한 동시 호출을 진행 중인 하나의 Promise 로 묶어서 중복 실행을 막습니다 — 그렇지 않으면 첫 번째 요소의 암호 입력창이 아직 열려 있는 상태에서 두 번째로 잠긴 요소를 클릭했을 때 중복으로 또 다른 입력창이 열리게 됩니다.