보안 모델
이 문서는 Deplite의 보안 메커니즘을 한 곳에 모았어요.
도입 검토용 보안 리뷰나 내부 감사 자료로 활용하실 수 있어요.
핵심 원칙
- 실행 분리: 시크릿과 실행 로그는 Agent 밖으로 나가지 않아요.
- 모든 명령은 서명: ED25519로 서명되고, nonce로 재생 공격을 막아요.
- 토큰은 해시 저장: API 토큰은 SHA256으로 저장되고, 평문은 발급 시점에만 보여요.
- 모든 동작은 기록: 누가·언제·무엇을 했는지 audit log로 남아요.
ED25519 키 쌍
| 키 | 소유자 | 어디에 보관 |
|---|---|---|
| Agent 개인키 | Agent | ./creds/agent.key (chmod 600) |
| Agent 공개키 | Server | 등록 시 전송, DB의 agents.publicKey에 저장 |
| Server 개인키 | Server | KMS 또는 환경변수 |
| Server 공개키 | Agent | 등록 응답으로 전달, ./creds/server.pub |
Agent → Server 요청 서명
모든 요청 헤더에 다음이 들어가요.
X-Agent-Id: <agentId>
X-Timestamp: 1717180800
X-Nonce: a1b2c3d4... (hex 16바이트)
X-Signature: <base64 ed25519>서명 대상 문자열 (canonical):
<timestamp>\n
<nonce>\n
<UPPERCASE-METHOD>\n
<path-with-query>\n
<sha256-hex-of-body>서버 검증:
X-Timestamp현재 시각 ±60초X-NonceLRU 캐시(TTL 10분)에 없는가- 서명이 등록된 공개키로 검증되는가
Server → Agent 명령 서명 (Deploy)
SSE deploy 이벤트의 페이로드:
{
"payload": "<base64-json>",
"signature": "<base64-ed25519>"
}Agent 검증:
- payload JSON 정규화(정렬된 키)
server.pub로 ED25519 검증issued_at현재 시각 ±60초nonceAgent 측 LRU 캐시에 없는가
API Token
prefix:tk_ + 32바이트 hex (총 64자 본문)- DB에는
SHA256(token)만 저장 - 발급 직후 1회만 평문 노출 (이후 복원 불가)
- 회수 시
revokedAt설정 (즉시 무효) - 만료 시
expiresAt설정 가능
스코프
- agent: 특정 Agent에 명령 (Agent ID 목록 필수)
- trigger: 특정 Trigger의 webhook 호출 (Trigger ID 목록 필수)
- storage: 파일 스토리지 접근. binding ID와 권한(read/write/delete)을 세분화
토큰 레이트 리밋
분/시간/일 단위로 호출 횟수를 제한할 수 있어요. 초과 시 HTTP 429.
JWT (사용자 인증)
- Firebase Authentication 사용
- 헤더:
Authorization: Bearer <jwt> - 매 요청마다 검증
Slack 서명
Slack에서 들어오는 모든 콜백은 다음 헤더로 검증해요.
x-slack-signature(HMAC-SHA256)x-slack-request-timestamp
타임스탬프 ±5분, HMAC 일치 시에만 처리해요.
시크릿 격리
워크플로우 YAML의 secrets: 필드는 환경변수 이름만 적어요.
secrets:
- DATABASE_URL
- SLACK_WEBHOOK- 값은 Agent 머신의 환경변수(
/etc/deplite/agent.env등)에서 주입 - stdout에서 해당 값이 보이면 자동으로
***로 마스킹 - 시크릿 이름 목록은 메타데이터로 서버에 보고되지만, 값은 절대 전송 안 됨
Agent 회수 (Revoke)
대시보드에서 Agent를 회수하면 다음이 일어나요.
- DB의 Agent
status: revoked,revokedAt설정 - 다음 SSE 응답으로
revoke이벤트 전송 - Agent는 받자마자 종료
- 이후 같은
agent.key로는 어떤 요청도 거부됨
Audit Log
조직 내 중요한 모든 동작이 기록돼요.
| 액션 | 의미 |
|---|---|
trigger.run_manual | 대시보드 수동 실행 |
trigger.force_run | force=true 실행 |
job.dispatch | 서버가 Agent로 실행 명령 전송 |
job.rejected | 리밋/큐 초과로 거부 |
agent.enroll | Agent 등록 |
agent.revoke | Agent 회수 |
api_token.create | 토큰 발급 |
api_token.revoke | 토큰 회수 |
slack.install | Slack 워크스페이스 연결 |
metadata 필드에 부가 정보(워크플로우, ref, reason 등)가 담겨요.
책임 분담 (Responsibility Matrix)
| 항목 | Deplite Server | Agent (당신) |
|---|---|---|
| 워크플로우 정의 | 메타데이터만 | 실제 YAML |
| 시크릿 값 | 보관 안 함 | 환경변수로 주입 |
| 실행 로그 원본 | 보관 안 함 | ./logs/jobs/ |
| stdout 요약 | summary만 보관 | verbose는 로컬만 |
| 명령 서명 | Server 개인키 | Agent 개인키 |
| 토큰 발급/검증 | DB(해시) | — |
| 감사 로그 | 보관 | — |
실전 예시
요청 서명을 직접 만들어 보기 (Go)
import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"time"
)
func signRequest(priv ed25519.PrivateKey, method, path string, body []byte) map[string]string {
ts := fmt.Sprintf("%d", time.Now().Unix())
nonceBytes := make([]byte, 16)
rand.Read(nonceBytes)
nonce := hex.EncodeToString(nonceBytes)
bodyHash := sha256.Sum256(body)
canonical := fmt.Sprintf("%s\n%s\n%s\n%s\n%x",
ts, nonce, method, path, bodyHash)
sig := ed25519.Sign(priv, []byte(canonical))
return map[string]string{
"X-Agent-Id": "ag-001",
"X-Timestamp": ts,
"X-Nonce": nonce,
"X-Signature": base64.StdEncoding.EncodeToString(sig),
}
}실제 요청 헤더 예:
POST /agent/heartbeat HTTP/1.1
Host: api.deplite.io
X-Agent-Id: ag-001-2f3e-4a5b
X-Timestamp: 1748700000
X-Nonce: a1b2c3d4e5f6789012345678abcdef00
X-Signature: dGhpc0lzQUR1bW15U2lnbmF0dXJlRm9yRG9jc1B1cnBvc2VPbmx5PQ==
Content-Type: application/json
{"agent_version":"0.2.0","running_jobs":[],"workflow_count":5}API 토큰 로테이션
/rotate는 새 평문 토큰을 발급하고 옛 토큰을 즉시 무효화해요. CI 환경변수만 교체하면 끝.
# 1. 회전 (응답으로 새 plaintext 토큰 한 번만 노출)
curl -X POST https://app.deplite.io/api/orgs/<orgId>/api-tokens/<id>/rotate \
-H "Authorization: Bearer $JWT"
# 응답
{ "id": "tok-001", "plainToken": "tk_NEW_64_HEX_..." }# 2. CI 시크릿 업데이트 (GitHub 예시)
gh secret set DEPLITE_TOKEN --body "tk_NEW_64_HEX_..."Agent 키 유출 대응 절차
- 대시보드에서 해당 Agent Revoke → DB에
revokedAt, 다음 SSE로revoke이벤트 - Agent 머신 접근하여
./creds/*모두 삭제 - 새 Enrollment Token 발급
DEPLITE_TOKEN을 새 값으로 재시작 → 새 키쌍 자동 생성·등록- Audit log에서 의심스러운
job.dispatch기록 검토
”이 요청이 정말 우리 Agent에서 왔는가” 검증 예 (서버 측 의사코드)
function verify(req) {
const ts = parseInt(req.headers['x-timestamp'])
if (Math.abs(Date.now()/1000 - ts) > 60) throw new Error('clock skew')
if (nonceCache.has(req.headers['x-nonce'])) throw new Error('replay')
nonceCache.set(req.headers['x-nonce'], true, 600) // TTL 10분
const agent = db.agents.findById(req.headers['x-agent-id'])
if (agent.status === 'revoked') throw new Error('revoked')
const canonical = `${ts}\n${nonce}\n${method}\n${path}\n${sha256(body)}`
if (!ed25519.verify(agent.publicKey, canonical, sig)) throw new Error('bad sig')
}세 단계(시계·재생·서명) 모두 통과해야 요청이 받아들여져요.
다음으로
최종 수정 일자: