Verify SSHSIG using web crypto API


데모

요약

ssh-keygen을 이용한 sign - verification을 이용하면 ssh 키만 갖고 있는 사람들에게도 손쉽게 로그인 기능을 제공할 수 있다. 이 글에서는 ssh-keygen을 이용해서 생성한 signature를 기존에 구현해둔 web crypto에서 검증하기 위한 방법에 대해 조사해 보았다.

배경

암호 넣기 싫어서 웹 로그인을 구현해야 하는데 암호 대신 비밀키를 여기저기 보관해야 한다면 그게 더 큰 보안 헛점이 될 거다. 그렇기 때문에 가능한 한 이미 사용자가 가지고 있는 비밀키를 이용할 수 있으면 새로 비밀키를 만드는 일을 줄일 수 있다.

널리 구현된 비밀키 보관소들:

  • ~/.ssh/id_{rsa, ecdsa, ed25519}: SSH 로그인을 위해 사용하는 개인키들. RSA, ECDSA 등 종류는 다양하다.
  • Metamask 혹은 Web3 wallet 구현체들: 이더리움의 개인키는 secp256k1 이라는 curve의 개인키이기 때문에 ecdsa 라이브러리를 이용해서 인증을 구현할 수 있다.
  • 크롬 브라우저의 ssh agent extension: 구글에서 구현한 ssh 클라이언트 확장에서 이용하는 ssh 키 보관용 확장.

브라우저 확장을 이용하면서도 이더리움같이 불안요소 많은 구현체를 이용하고 싶지 않았기 때문에 크롬 확장을 이용할 수 있으면 정말 좋았을 것 같은데, 아쉽게도 미리 정해진 확장들 외에는 해당 확장을 이용해서 ssh 개인키를 가져올 수 없게 구현되어 있는듯 하다. 이걸 포크해서 따로 만들어…? 누가 유지보수…? 패스…

따라서 남은것은 브라우저 확장 말고, 기존 툴을 이용해서 공개키 서명을 제공하는 방법을 검토하게 되었고, 이중 가장 널리 이용되고 있는 OpenSSH를 이용한 방법에 대해 조사해보게 됐다.

디자인

다음 Flow를 이용해서 웹 로그인을 구현한다고 가정하고 있다.

Challenge 생성

그냥 서버에서 적당한 랜덤 문자열 + expire timestamp 정도를 섞어서 보내주면 된다.

Siganture 생성

클라이언트는 자신의 개인키로 해당 입력 데이터를 서명함으로써 자기 자신을 증명하게 된다. 서버가 아래와 같은 명령어를 미리 생성해서 유저는 바로 복사/붙여넣기만 하면 서명을 생성할 수 있게 할 수 있다.

echo -n "Challenge from server" | ssh-keygen -Y sign -f {id_ecdsa} -n test@blmarket.net -O hashalg=sha256

Signature 반환

클라이언트가 브라우저에 -----BEGIN SSH SIGNATURE----- 로 시작하는 문자열을 직접 복사/붙여넣기 함으로써 인증을 수행한다.

Signature 검증

ssh-keygen -Y verify, 혹은 그와 같은 역할을 하는 툴로 검증을 해도… 되긴 하겠지만, 기존의 web crypto 구현체를 그대로 두고 비슷한 역할의 동일한 프로세스를 또 만드는 건 좀 피하고 싶다. 번거롭지만 Signature를 web crypto에서 사용할 수 있는 형식으로 변환해서, 가능한 한 로직 중복을 피해보자.

Signature 변환 부분 구현

SSH Signature 파싱

즉, 이제 문제는 OpenSSH에서 생성한 Signature를 Web crypto API에서 사용가능한 방법으로 변환하는 부분이 된다. 일단 Signature를 읽어들여야 하는데 프로토콜은 여기, 혹은 여기에 설명되어 있다.

이걸 다시 구현할 필요는 없을테고 당연히 라이브러리가 있을텐데… 안타깝게도 javascript 라이브러리는 찾지 못했지만 Rust 구현체를 찾을 수 있었다. RustCrypto라면 wasm 생성도 당연히 잘 될테지? 하면서 일단 Rust로 구현해보기로 했다.

Signature에서 원하는 데이터 얻기

라이브러리가 깔끔하게 signature 데이터를 주긴 하는데, 바이너리를 보니까 이게 Web crypto에서 원하는 데이터랑 딱 똑같지는 않았다. Web crypto ECDSA P-256의 경우 r값 32바이트 s값 32바이트로 딱 64바이트 signature인데 (참고자료: MDN) OpenSSH의 경우 4바이트로 길이가 먼저 들어오고 그다음에 길이만큼의 바이너리 데이터, 그다음에 또 4바이트짜리 길이 데이터, 그다음에 다시 길이만큼의 바이너리 데이터로 signature가 구성되어 있었다. 코드를 읽어보니 이게 각각 r과 s여서, 이걸 길이 데이터 빼고, 32바이트에 맞춘 다음에 서로 이어붙이기만 하면 web crypto에서 사용하는 signature 데이터 형식으로 변환될 것 같았다.

Signature를 만들 때 사용한 데이터 만들기

OpenSSH 프로토콜 문서를 다시 읽어보면, 그냥 챌린지를 그대로 쓰는게 아니라, 챌린지를 한번 Signed data로 감싼 다음에 signature를 만드는 것으로 구현되어 있었다. 따라서 서버 쪽에서도, web crypto 라이브러리에 넣기 위한 signed data를 재생성해줄 필요가 있었다.

오, 같은 라이브러리에서 해당 기능을 제공해 준다!

결론

일단은 여기까지… 이제 해당 Rust 라이브러리를 잘 포장해서 wasm으로 둔갑시키고 기존 web crypto를 이용한 코드에서 사용하면 될 것 같다.