외부 자격 증명 제공자 인터페이스
Sandfly는 일반적으로 자체 데이터베이스에 저장된 credentials를 사용하여 SSH로 대상 호스트에 로그인합니다. 이러한 credentials는 public key로 암호화되며, 해당 private key는 nodes에만 구성됩니다. 따라서 서버가 완전히 손상되더라도(API를 통한 원격 손상 또는 서버 호스트 자체의 로컬 손상 등) 공격자가 데이터베이스의 credentials를 복호화할 수 없습니다.
하지만 조직에 따라 SSH credentials를 Sandfly 데이터베이스에 저장하는 방식이(설령 안전하게 저장하더라도) 적합하지 않을 수 있습니다. credentials의 중앙 관리와 사용 감사 요구 사항 때문에 권한 있는 요청자에게 secrets를 제공할 수 있는 credential provider(일명 "key vault") 제품을 도입하기도 합니다. Sandfly server는 credentials를 데이터베이스에 저장하는 대신 런타임에 credential provider에서 가져오는 것을 지원합니다. 이를 통해 고객은 호스트별 고유 credentials 관리가 쉬워지고, Sandfly가 대상 호스트에 접근하기 위해 단기 credentials를 사용할 수 있게 됩니다.
외부 credential provider에서 credentials를 가져오기 위해, Sandfly server는 Sandfly가 정의한 인터페이스를 사용하는 Credential Provider Adapter web service로 web service call을 수행합니다. 고객은 독립적으로 또는 Sandfly의 지원을 받아 Credential Provider Adapter protocol을 구현하여 자신들의 credential provider에 맞는 adapter를 만들 수 있습니다.
Sandfly Credential 구성
Sandfly 내에서는 credential provider adapter 연결을 일반 credential과 동일하게 생성하되, credential type으로 "External Credential Provider"를 사용합니다. 이 유형의 새 credential을 만들 때 제공해야 하는 필드는 다음과 같습니다:
- URL of external credential adapter service: 이 credential이 할당된 hosts에 대한 credential 요청 시 Sandfly가 호출할 URL입니다. 요청의 상세 형식은 다음 섹션에 설명되어 있습니다.
- Unique per host: Sandfly가 credential provider adapter에 호스트마다 새 요청을 해야 하는지 여부를 나타내는 boolean 값입니다. 예를 들어, 다수의 대상 호스트에 동일한 비밀번호 또는 SSH key를 가진 공통의 "sandfly" SSH 사용자가 있는 경우 "Unique per host"를 false로 설정하면, Sandfly는 한 번만 요청하여 받은 credential을(credential cache 시간이 만료될 때까지 — 아래 참조) 이 credential이 할당된 모든 호스트에서 재사용할 수 있습니다. 이는 여러 호스트에서 공유되는 static credential을 사용할 때 일반적입니다. 반대로 credential provider가 각 개별 호스트마다 단기 또는 고유 credentials를 생성한다면 true로 설정해야 하며, 이 경우 Sandfly는 연결하는 각 호스트마다 credential 요청을 해야 합니다.
- External credential service extra data: 이 필드의 데이터는 변경 없이 credential provider adapter로의 요청에 포함됩니다. 사용 방식은 credential provider adapter의 설계/구현에 따라 달라지지만, 일반적으로 동일한 credential adapter service URL에서 서로 다른 host 그룹을 구분하거나, credential provider가 username을 제공하지 않는 경우(비밀번호/SSH key만 제공) username을 adapter 설정이 아니라 여기에서 구성 가능하도록 두는 데 사용할 수 있습니다. 이 필드는 Sandfly 데이터베이스에서 암호화되지 않으므로 민감한 데이터에는 사용하면 안 됩니다.
- External credential service root CA certificate: 외부 credential adapter service에 HTTPS URL을 사용하는 경우, Sandfly는 Ubuntu Linux에 포함된 표준 trusted CA database를 사용하여 인증서를 검증합니다. credential adapter web service가 self-signed 인증서 또는 공개적으로 신뢰되지 않는 내부 CA에서 발급한 인증서를 사용하는 경우, 여기에서 trusted CA certificate를 입력할 수 있습니다.
Sandfly가 Host Add 또는 Scan 작업 중 credential provider adapter에서 credential을 가져오는 데 문제가 발생하면, 해당 오류는 scanning error log에 기록됩니다.
external credential provider 타입은 API를 통한 ad-hoc scans에서도 credential type으로 지원됩니다.
Credential Provider Adapter 요청 사양
External Credential Provider 타입의 credential이 할당된 호스트에 Sandfly가 연결해야 하는 경우, Sandfly는 credential에 구성된 URL로 HTTP 요청을 전송합니다. 요청 본문에는 다음 필드를 가진 JSON 객체가 포함됩니다:
credential_name: (string, required) Sandfly에서의 credential 이름.extra_data: (string, optional) credential의 External credential service extra data 필드에 입력된 값(빈 문자열일 수 있음).nonce: (string, required) Sandfly server의 각 요청마다 고유한 무작위 문자열.request_time: (string, required) Sandfly server가 요청을 생성한 시간.YYYY-MM-DDTHH:MM:SSZ형식.target_host: (string, optional) Sandfly의 credential이 "Unique per host"로 설정된 경우 포함됩니다. "add host" 과정에서 입력된 원래 대상 주소(처음에 IP 또는 IP 대역을 입력했다면 해당 호스트의 IP, hostname을 입력했다면 해당 hostname). "Unique per host"가 false이면 이 필드는 없습니다.targetport: (int, optional)target_host와 동일하게, "Unique per host"가 true일 때만 존재합니다. 대상 호스트의 SSH 포트 번호입니다.
요청 본문 외에도, server는 요청에 서명을 포함하여 credential provider adapter에 정당한 요청임을 증명합니다.
요청에는 전체 요청 본문에 대한 ed25519 서명을 Base64로 인코딩한 값을 담은 X-Sandfly-Signature HTTP 헤더가 포함됩니다. 서명 검증에 사용할 server public key는 Sandfly UI의 "Settings" → "About Sandfly" 섹션에서 base64-encoded 문자열로 확인할 수 있습니다.
예를 들어, Sandfly UI에 표시된 server public key가 2UVlbv5silqT5bgN6XHqKXUQjLejqXjUlOtyuD7wYNM=인 경우, 요청의 서명을 검증하는 Go 샘플 코드는 다음과 같습니다:
import (
"crypto/ed25519"
"encoding/base64"
"io"
"net/http"
)
// typically would be a configuration value, not hard-coded
var serverPubKeyB64 = "2UVlbv5silqT5bgN6XHqKXUQjLejqXjUlOtyuD7wYNM="
func requestHandler(w http.ResponseWriter, r *http.Request) {
serverPubKey, err := base64.StdEncoding.DecodeString(serverPubKeyB64)
if err != nil {
// ERROR: invalid server public key
w.WriteHeader(http.StatusInternalServerError)
return
}
signatureB64 := r.Header.Get("X-Sandfly-Signature")
if sigb64 == "" {
// ERROR: missing signature
w.WriteHeader(http.StatusInternalServerError)
return
}
signature, err := base64.StdEncoding.DecodeString(signatureB64)
if err != nil {
// ERROR: invalid signature base64 format
w.WriteHeader(http.StatusInternalServerError)
return
}
bodyRaw, err := io.ReadAll(r.Body)
if err != nil {
// ERROR: unable to read request body
w.WriteHeader(http.StatusInternalServerError)
return
}
if ok := ed25519.Verify(serverPubKey, bodyRaw, signature); !ok {
// ERROR: signature on request does not match server's key
w.WriteHeader(http.StatusInternalServerError)
return
}
// At this point, the request signature confirmed the request was correctly
// signed by the Sandfly Server's private key. You may proceed to parse the
// request JSON, and perform additional validation as desired -- e.g.
// mitigate the possibility of replay attacks (the HTTPS protocol should
// already cover this possibility, but Sandfly provides a timestamp and
// nonce if you wish to implement additional protection) by checking that
// the timestamp is within a small window of the current time and that the
// nonce hasn't already been used within that timeframe.
[...]
}Credential Provider Adapter 응답 사양
Sandfly server로부터 유효한 credential 요청을 수신하면, adapter는 대상 호스트에 대한 credential을 포함하는 응답을 제공해야 합니다.
성공 응답은 HTTP status code가 200이어야 하며, 응답 본문은 다음 필드를 포함한 JSON 객체여야 합니다:
credentials_type: (string, required) credential 유형. 허용되는 값은 username(아이디+비밀번호) 또는 ssh_key(아이디+SSH key)입니다.encrypted_credential: (string, required) base64-encoded 암호화된 credential.ttl: (int, required) Sandfly server가 이 credential을 캐시할 수 있는 시간(초)입니다. 0은 캐시 허용 안 함을 의미합니다. 양수 값은 해당 시간 동안 대상 호스트(또는 unique per host가 아니면 다른 호스트)에 대한 추가 연결 시 credential을 재사용할 수 있음을 의미합니다. Sandfly server는 암호화된 credential만 메모리에 캐시하며, 데이터베이스에 기록하지 않고, nodes도 복호화된 형태를 캐시하지 않습니다. 기존 TTL 내에 있더라도 Sandfly server는 언제든지 credential adapter에 다시 요청할 수 있습니다.
encrypted_credential 필드는 암호화 이전에 다음 필드를 가진 JSON 객체이며(의미는 Sandfly server credentials API와 동일):
username: (string, required) 대상 호스트에 연결할 사용자 이름.password: (string,credentials_type이 username일 때 required) credentials_type이 username인 경우 대상 호스트에 연결할 때 사용하는 비밀번호입니다. credentials_type이 ssh_key이고 대상 호스트가 sudo 비밀번호를 요구하는 경우에도 이 값을 사용합니다.credentials_type: (string, required) username 또는 ssh_key. 바깥 응답 객체의 credentials_type과 동일해야 합니다.ssh_key_b64: (string, credentials_type이 ssh_key일 때 required) 대상 호스트에 연결하기 위한 base64-encoded SSH private key.ssh_key_certificate_b64: (string, optional) SSH private key의 유효성을 증명하기 위해 대상 호스트에 제시할 base64-encoded SSH certificate.ssh_key_password: (string, optional) ssh_key_b64에 암호화된 private key가 포함된 경우 이를 복호화하는 비밀번호.
credential JSON 객체는 Node의 public key로 직접 암호화됩니다. 즉, 요청은 Sandfly server가 외부 credential adapter에 수행하지만, Sandfly의 credential 처리 아키텍처는 그대로 유지되어, server–node 분리 배포에서 서버가 완전히 손상되더라도 공격자가 서버를 이용해 암호화된 credentials를 탈취하지 못하도록 보장합니다.
credential provider에는 node의 public key를 구성해야 합니다. 초기 구성 시 이 값은 Sandfly UI의 Settings 페이지 내 About Sandfly 섹션에서 확인할 수 있습니다.
credential을 암호화하려면 libsodium encryption library와 상호 운용 가능한 anonymous sealed box 구현을 사용하세요. credential JSON 객체를 node의 ed25519 public key로 암호화한 뒤 base64-encode하여 응답 객체의 encrypted_credential 필드에 넣습니다.
예를 들어, Go:
import (
"crypto/ed25519"
"encoding/base64"
"golang.org/x/crypto/nacl/box"
)
func encryptCredential(nodePubKeyB64 string, credentialJSON []byte) {
// node public key input would be in credential adapter configuration
nodePubKey, err := base64.StdEncoding.DecodeString(
"Z3onNCnFDtGvFN2QS/EZXOiN9/Zy0uh4IiEtmo9asD8=")
if err != nil {
// ERROR: invalid base64 encoding of node public key
return
}
if len(nodePubKey) != ed25519.PublicKeySize {
// ERROR: node public key is not a valid ed25519 public key
return
}
nodePubKeyPtr := (*[ed25519.PublicKeySize]byte)(nodePubKey)
ciphertext, err := box.SealAnonymous(nil, credentialJSON,
nodePubKeyPtr, nil)
if err != nil {
// ERROR: couldn't encrypt data
return
}
// At this point, ciphertext has the encrypted credential JSON.
// Base64-encode it.
finalValue := base64.StdEncoding.EncodeToString(ciphertext)
// Use finalValue as the value in the response JSON
// encrypted_credential field.
}따라서 SSH credential에 대한 전체 HTTP 응답 본문 예시는 다음과 같습니다:
{
"credentials_type": "ssh_key",
"encrypted_credential": "TFMwdExTMUNSVWRKVGlCUFVFVk9VMU5J...",
"ttl": 300
}암호화 및 base64-encoding 이전의 encrypted_credential 값 예시는 다음 JSON과 같습니다:
{
"username": "sandfly",
"credentials_type": "ssh_key",
"ssh_key_b64": "LS0tLS1CRUdJTiBPUEVOU1NIIFBSS..."
}Updated 7 days ago