2026-07-20
NearSeal의 age-encryption.org 형식이 진짜 상호운용 가능한 age임을 증명하기
NearSeal에 이번 주 두 번째 출력 형식이 추가되었습니다. 기존 형식에 더해, 이제 실제
age-encryption.org 형식으로도 암호화를 선택할 수 있습니다.
이 형식은 Go 언어 암호화 리드인 Filippo Valsorda(FiloSottile)가 만든 것입니다. 이 형식을 제공하는
목적은 상호운용성입니다 — age로 암호화한 파일은 이 웹사이트뿐 아니라 공식 age CLI,
rage, 그 밖의 age 호환 도구 어디에서도 열려야 합니다. 이건 의심해볼 만한 주장입니다.
"age"라고 이름만 붙이고 실제로는 자기 자신하고만 대화하는 것을 만들기는 쉽습니다. 이 글은 우리가
그렇지 않다는 것을 증명하려 한 방법에 대한 이야기이며, 우리 자신의 코드 바깥으로 나가서 확인하는
실제 테스트 스크립트(tests/live-check-age.mjs)를 근거로 합니다.
의도적으로 만든 두 가지 형식, 서로 다른 트레이드오프
NearSeal의 원래 형식 — 매직 바이트를 따서 NSEAL1이라 부르겠습니다 — 은 PBKDF2-SHA256으로
패스프레이즈에서 키를 유도하는 AES-256-GCM이며, 반복 횟수는 220회(1,048,576회)로 OWASP의
2023년 최소 권장치인 600,000회를 넉넉히 웃돕니다. 견고하고 표준적인 암호화지만, 컨테이너 형태 자체는
NearSeal만의 것입니다 — NSEAL1 파일은 NearSeal과 호환되는 도구에서만 열립니다. 새로 추가된 age
형식은 상호운용성과 관련된 모든 구체적인 지점에서 다릅니다. FiloSottile 본인이 만든
age-encryption npm 패키지(순수 JS, noble의 암호화 기본 요소와 WebCrypto 기반 — 서버
호출 없음, 이 앱의 다른 개인정보 보호 약속과 동일)를 그대로 사용하고, 키 유도에는 PBKDF2 대신
scrypt를 쓰며, 암호화에는 AES-256-GCM이 아니라 age 자체의 STREAM 청크 AEAD 구성 안에서
ChaCha20-Poly1305를 사용합니다. 이 구분은 트집이 아닙니다 — 저희 제품 문구는 "AES-256"이라는 표현을
NearSeal 형식에만 붙이고 age 형식에는 암시조차 하지 않도록 신경 써서 작성했습니다. 그렇지 않으면
사실이 아닌 주장이 되기 때문입니다. NearSeal은 기본적으로 바이너리(비무장, unarmored) age
컨테이너를 출력합니다 — 커맨드라인에서 age -e -p가 만드는 것과 같은 형태입니다 —
다만 복호화 시에는 ASCII 무장(armored) 입력도 받아들이는데, 이는 비용이 거의 들지 않으면서
상호운용 범위만 넓혀주기 때문입니다. 복호화 시 앱은 파일의 첫 바이트를 읽어(NSEAL1의 매직 바이트인지,
age 고유의 age-encryption.org/v1 헤더인지, 혹은 무장된 PEM 형식 헤더인지) 형식을
자동으로 감지합니다 — 어떤 종류의 파일인지 사용자가 직접 알려줄 필요가 없습니다.
"우리 라이브러리가 자기 자신과 일치한다"는 증명이 아니다
가장 쓰기 쉬운 상호운용성 테스트이자 가장 설득력이 떨어지는 테스트는 이렇습니다: 우리 앱으로
암호화하고, 우리 앱으로 복호화한 뒤, 바이트가 일치하는지 확인하는 것. 이건 암호화 함수와 복호화
함수가 서로의 역함수라는 것만 증명할 뿐입니다 — 양방향에 대칭적으로 존재하는 NearSeal 고유의 버그가
있다 해도 이런 테스트는 무사통과할 것이고, 이 앱의 코드만 읽을 줄 아는 미묘하게 비표준적인 컨테이너
역시 마찬가지일 것입니다. 증명 과정 전체가 crypto-core.js를 한 번도 벗어나지 않는다면
"진짜 스펙을 준수하는 age"라는 말은 여전히 입증된 사실이 아니라 그냥 주장일 뿐입니다. 그래서
live-check-age.mjs는 더 엄격한 기준으로 설계되었습니다: 같은 바이트를 두고 점점 더
독립적인 구현들을 차례로 대입해 보고, 그 사슬을 얼마나 멀리까지 따라가도 파일이 계속 열리는지
확인하는 것입니다.
1, 2번 체크: 앱이 여전히 정상 동작하는가 (새 형식 포함)
스크립트는 사실 상호운용성 증명이 아니라 회귀 방지에 가까운 두 가지 체크로 시작합니다. 첫 번째로
기존 NSEAL1 형식을 앱의 실제 UI로 구동합니다 — 실제 헤드리스 Chromium 페이지(Playwright), 실제
파일 입력, 실제 Web Worker가 수행하는 암호화, 실제 다운로드까지 — 암호화 후 복호화하고, 결과가 원본
파일과 바이트 단위로 완전히 동일한지 확인합니다. 이는 새로 추가된 형식 선택 라디오 버튼이 기존에
이미 출시된 형식을 망가뜨리지 않았는지 잡아내기 위한 것입니다. 두 번째로 기본값인 NearSeal 대신
age 라디오 버튼을 선택한 채로 같은 왕복 테스트를 다시 실행하고, 이번에도 복호화된 바이트가 원본과
정확히 일치해야 합니다. 같은 체크 안에 포함된 것: 이미 암호화된 파일을 앱에 넘기면 UI의
.mode-badge 요소가 파일의 확장자가 아니라 순전히 바이트 내용만으로 "암호화" 모드에서
"복호화" 모드로 전환됩니다. 이는 상호운용성 이야기에서 특히 중요한데, 다른 확장자를 가진 다른 age
도구로 만든 파일이라도 올바르게 인식된다는 뜻이기 때문입니다.
3번 체크: 완전히 별개인 코드 경로가 같은 바이트를 읽는다
실제로 무언가를 증명하기 시작하는 첫 체크입니다. 여기서 사용된 age 암호화 바이트는 전부 앱의
실제 표면을 통해서만 만들어졌습니다 — 실제 브라우저, 실제 Web Worker, 그 안에서 실행되는 실제 번들된
age-encryption 빌드. 이 바이트를 순수한 Node 스크립트에 넘기는데, 이 스크립트는 독립
age-encryption npm 패키지의 Decrypter 클래스만 직접 임포트합니다 — 이 앱
자체의 crypto-core.js는 전혀 임포트하지 않고, 과정 어디에도 브라우저가 없습니다.
NearSeal 자체의 decryptAgeBytes()에 미묘한 버그가 있어서 짝을 이루는 자체 암호화
함수의 출력만 우연히 호환되게 만들어진 것이었다면, 이 단계가 바로 그것을 잡아낼 수 있는 지점입니다.
위의 두 왕복 테스트는(자기 자신과는 일관되므로) 여전히 통과하겠지만, 이 독립적인 복호화는 실패할
것입니다. 실제로는 실패하지 않습니다 — 독립 라이브러리가 브라우저의 출력을 정확히 복호화합니다.
스크립트는 더 직접적인 것도 확인합니다: 브라우저가 만든 원시 바이트가 정확히 ASCII 문자열
age-encryption.org/v1로 시작하는지 — 스펙이 정의한 실제 표준 age 매직 헤더이지,
NearSeal 특유의 변형이 아니라는 것입니다.
4, 5번 체크: 완전히 다른 언어, 그리고 반대 방향
가능한 가장 강력한 증명은 "다른 JS 라이브러리가 우리 JS 라이브러리와 일치한다"는 것에서 한 걸음
더 나아갑니다. 브라우저가 만든 것과 정확히 같은 파일을 진짜 공식 age CLI —
github.com/FiloSottile/age, 이 테스트 전체에서
사용하는 npm 패키지와는 완전히 별개의 코드베이스이자 언어인 Go로 작성된 레퍼런스 구현 — 에 넘기는
것입니다. Go 구현이 우리 브라우저의 JS가 만든 파일을 복호화할 수 있다면, 그것은 더 이상 "우리
의존성이 내부적으로 일관적이다"가 아니라 출력물이 실제로 스펙을 준수하는 age라는 것을 보여주는
언어 간, 구현 간 교차 증명입니다. 이걸 구동하는 건 까다롭습니다 — 공식 CLI는 파이프로 연결된
stdin에서 패스프레이즈를 읽는 것을 거부하기 때문에(CLI 쪽의 의도적인 보안 설계입니다), 테스트
스크립트는 작은 Python pty 헬퍼를 이용해 실제 의사 터미널(pseudo-tty) 뒤에서 CLI를
실행하고, CLI 자신의 출력 스트림에서 "assphrase"라는 단어를 지켜보다가 대화형 패스프레이즈 프롬프트에
응답합니다. 그런 다음 스크립트는 5번 체크를 위해 같은 트릭을 반대로 실행합니다: 공식 CLI의
-p 플래그(입력과 확인, 두 번 프롬프트하는 것은 우리 코드가 아니라 Go입니다)를 이용해
NearSeal을 한 번도 거치지 않은 파일을 암호화하고, 그 파일을 NearSeal 자체의 웹 UI에 넣습니다.
다른 어떤 age 파일과 마찬가지로 자동 감지되어 정상적으로 복호화됩니다. 이로써 양방향 모두에서
고리가 닫힙니다 — "여기서 만든 파일이 다른 곳에서 열린다"뿐 아니라 "다른 곳에서 만든 파일이 여기서
열린다"까지. 이 다섯 체크의 모든 단계는 Playwright의 네트워크 요청 캡처 아래에서도 실행되며,
스크립트는 로컬 테스트 서버의 오리진을 벗어나는 요청이 전 과정에서 0건임을 확인합니다 — 이 앱이
기존 형식에 대해 해온 것과 같은 네트워크 무전송 보장이 새 형식에도 그대로 적용된다는 뜻입니다.
설치된 CLI에 의존하는 부분을 솔직하게 밝히기
4, 5번 체크는 이 테스트 전체에서 가장 강력한 증거이면서, 동시에 모든 환경에서 실행되지는 않는
두 체크이기도 합니다. 스크립트 자체의 주석에 이렇게 분명히 적혀 있습니다: 실제 age
CLI가 "이 시스템에는 마침 설치되어 있다"고 말이죠 — 만약 설치되어 있지 않다면, 이를테면 최소화된
CI 이미지나 Go 툴링을 한 번도 설정하지 않은 머신이라면, 4, 5번 체크는
SKIP: real age CLI not usable in this environment를 출력하고 실행은 코드의 정확성과
무관한 환경상의 공백 때문에 실패하는 대신 깔끔하게 종료됩니다. 이는 얼버무리기보다 분명히 밝혀둘
가치가 있는 실제 한계입니다 — 이 증명의 완전무결한, 언어 간 교차 버전은 체크가 실행되는 환경에
CLI가 존재하는지에 좌우됩니다. 이와 전혀 무관한 것은 3번 체크입니다 — 독립 age-encryption
라이브러리 체크는 항상 실행되고, 외부 바이너리 의존성이 전혀 없으며, 그 자체만으로도 이 앱의 코드가
전혀 손대지 않은 구현과의 진짜 상호운용성을 이미 보여줍니다. 4, 5번 체크는 보너스 라운드이지,
토대가 아닙니다.
그런데 왜 "덜 우리 것"인 두 번째 형식까지 굳이 넣었나
crypto-core.js의 헤더 주석은 제품 관점의 이유를 한 줄로 요약합니다: age를 제공하는
것은 "당신의 파일이 당신 곁에 남아 있게 하는 것"이며, 이는 기본 NSEAL1 형식이 암묵적으로 가진
트레이드오프인 "당신의 파일이 이 웹사이트에 묶여 있게 하는 것"과 대비됩니다. 둘 다 틀린 게 아닙니다
— NSEAL1은 강력한 KDF 파라미터를 가진 견고한 AES-256-GCM이고, 앞으로도 계속 NearSeal로만 복호화할
계획인 사람에게는 더할 나위 없이 좋은 기본값입니다(그리고 UI에서도 계속 기본으로 미리 선택되어
있습니다). 하지만 파일을 암호화하는 모든 사람이 그런 의존성을 원하는 것은 아닙니다 — 몇 년 동안
손대지 않을 백업, 이 사이트를 방문한 적 없을 수도 있는 누군가에게 건네줄 파일, 특정 웹 앱이
영원히 살아 있으리라 가정해서는 안 되는 개인 아카이빙 습관 같은 경우들 말입니다. 이런 경우에는
age 호환 도구라면 어디서든 열 수 있는 파일 — 여기서는 단순히 주장만 한 게 아니라 실제 레퍼런스
구현에 대해 검증까지 한 — 이 의미 있게 다른 보장이며, 이 테스트 스크립트가 존재하는 이유도 그것이
마케팅 문구에 그치지 않는다는 것을 확인하기 위해서입니다.
솔직한 요약
"age 형식을 추가했다"와 "진짜 상호운용 가능한 age 형식을 추가했다"는 서로 다른 주장이며, 그중 할 만한 가치가 있는 주장은 후자뿐입니다. 이 코드베이스에서 그 차이는 점점 더 속이기 어려워지는 다섯 가지 구체적인 체크로 나타납니다: 앱이 기존 형식을 망가뜨리지 않는다, 새 형식이 앱 자체를 통해 왕복한다, 같은 npm 의존성의 완전히 별개인 구현이 앱 코드 없이도 앱의 출력을 읽을 수 있다, 실제 레퍼런스 Go 구현도 (가능한 경우) 읽을 수 있다, 그 구현이 만든 파일도 여기서 올바르게 열린다. 이 다섯 개 중 넷은 이 환경에서 깔끔하게 통과했고, CLI에 의존하는 다섯째·여섯째 수준의 체크는 아무 흔적 없이 조용히 건너뛰어진 것이 아니라 조건부라는 사실이 정직하게 명시되어 있습니다. 이것이 증명의 실제 상태입니다 — "우리를 믿어라"가 아니라 "우리 코드 바깥으로 정확히 얼마나 멀리까지 확인했는지, 그리고 그 확인이 지금 어디에서 멈추는지"입니다.