
간단하고 안전한 비밀 보관함
Keeper는 Go를 위한 암호화 비밀 저장소입니다. Argon2id 키 유도 및 XChaCha20-Poly1305(기본값) 인증 암호화를 사용하여 유휴 상태의 임의 바이트 페이로드를 암호화하고, 이를 내장 bbolt 데이터베이스에 저장합니다.
다음 세 가지로 독립적으로 사용할 수 있습니다:
x/keephandler) — 단 한 번의 호출로 모든 net/http 멀티플렉서에 keeper 엔드포인트를 마운트하며, 접근 제어 및 감사 로깅을 위한 플러그형 훅, 가드, 응답 인코더를 제공합니다.cmd/keeper) — 지속적인 REPL 세션, 에코 없는 비밀 입력, 쉘 히스토리 노출 제로를 제공하는 터미널 인터페이스입니다.Keeper는 Agbero 로드 밸런서의 기초 비밀 관리 계층으로 설계되었지만 Agbero에 대한 의존성이 없으며 모든 Go 프로젝트에서 작동합니다.
Keeper는 비밀을 버킷으로 분할합니다. 모든 버킷에는 데이터 암호화 키(DEK)가 보호되는 방식을 관리하는 변경 불가능한 BucketSecurityPolicy가 있습니다. 네 가지 수준을 사용할 수 있습니다.
스키마는 관련 버킷을 그룹화하는 URI 접두사입니다(vault://, certs://, space:// 또는 등록하는 모든 이름). 보안 수준은 생성 시 설정된 버킷 정책의 속성이며 그 이후로 변경 불가능합니다.
동일한 스키마 내에서 보안 수준을 자유롭게 혼합할 수 있습니다. 예를 들어, vault://system은 LevelPasswordOnly(시작 시 자동 잠금 해제)일 수 있고, vault://admin은 LevelAdminWrapped(명시적 자격 증명 필요)일 수 있습니다.
버킷 DEK는 마스터 키에서 HKDF-SHA256을 사용하여 버킷별 도메인 분리 정보 문자열(keeper-bucket-dek-v1:scheme:namespace)로 유도됩니다. UnlockDatabase가 올바른 마스터 암호로 호출되면 모든 LevelPasswordOnly 버킷이 자동으로 잠금 해제됩니다. 런타임에 버킷별 자격 증명이 필요하지 않습니다. 이 수준은 프로세스가 시작 시 사람의 개입 없이 필요한 비밀에 적합합니다.
버킷에는 해당 버킷 고유의 무작위로 생성된 32바이트 DEK가 있습니다. DEK는 평문으로 저장되지 않습니다. 각 승인된 관리자에 대해 HKDF(masterKey‖adminCred, dekSalt)에서 키 암호화 키(KEK)가 유도되어 XChaCha20-Poly1305를 통해 DEK를 래핑하는 데 사용됩니다. 관리자가 자격 증명으로 UnlockBucket을 호출하기 전까지 버킷에 접근할 수 없습니다. 마스터 암호만으로는 버킷을 해독할 수 없습니다. 한 관리자를 취소해도 다른 관리자의 래핑된 복사본에는 영향을 미치지 않습니다.
버킷 DEK는 CreateBucket 시 생성되고 호출자가 제공한 HSMProvider에 의해 즉시 래핑됩니다. 공급자는 래핑 및 언래핑 작업을 수행합니다. keeper는 공급자에게 전달한 후 원시 DEK를 처리하지 않습니다. UnlockDatabase는 자동으로 공급자를 호출하여 등록된 모든 HSM 버킷에 대해 봉투(Envelope)를 언래핑하고 시드합니다. 마스터 키 교체는 이러한 버킷을 다시 암호화하지 않습니다. DEK는 공급자 제어 하에 있습니다.
A built-in SoftHSM implementation backed by a memguard-protected wrapping key is available in pkg/hsm for testing and CI environments. Do not use it in production.
Identical to LevelHSM in key management behaviour, but the HSMProvider is implemented by pkg/remote.Provider — a configurable HTTPS adapter that delegates wrap and unwrap to any remote KMS service over TLS. Pre-built configurations for HashiCorp Vault Transit, AWS KMS, and GCP Cloud KMS are provided in pkg/remote. For production use, configure TLSClientCert and TLSClientKey to enable mutual TLS authentication.
salt ← random 32 bytes, generated once, stored as a versioned SaltStore (unencrypted) masterKey ← Argon2id(passphrase, salt, t=3, m=64 MiB, p=4) → 32 bytes
검증 해시는 첫 번째 유도 시 저장됩니다:```
verifyHash ← Argon2id(masterKey, "verification", t=1, m=64 MiB, p=4) → 32 bytes
이후의 DeriveMaster 호출은 이 해시를 다시 계산하고 crypto/subtle.ConstantTimeCompare와 비교합니다. 불일치 시 ErrInvalidPassphrase를 반환합니다.
KDF 솔트는 의도적으로 암호화되지 않은 상태로 저장됩니다. 마스터 키를 도출하기 위해 UnlockDatabase 이전에 읽을 수 있어야 합니다. 마스터에서 파생된 키로 암호화하는 것은 순환적일 것입니다. KDF 솔트는 비밀이 아닙니다. 그 목적은 고유성(uniqueness)이지 기밀성(confidentiality)이 아닙니다.
각 평문 값은 버킷 DEK를 사용하여 XChaCha20-Poly1305로 암호화됩니다.``` nonce ← random 24 bytes ciphertext ← XChaCha20-Poly1305.Seal(nonce, DEK, plaintext)
저장된 레코드는 msgpack으로 인코딩된 `Secret` 구조체로, 암호문, 암호화된 메타데이터, 스키마 버전을 포함합니다. 인증은 암시적입니다. 잘못된 키로 복호화된 암호문은 일반 텍스트가 반환되기 전에 AEAD 인증 실패를 발생시킵니다.
### KEK 유도 — LevelAdminWrapped```
salt ← random 32 bytes, generated at bucket creation, stored in policy
ikm ← masterKey ‖ adminCredential
KEK ← HKDF-SHA256(ikm, salt, info="keeper-kek-v1") → 32 bytes
wrappedDEK ← XChaCha20-Poly1305.Seal(nonce, KEK, DEK)
KEK는 두 번째 Argon2 패스 대신 HKDF를 사용하여 파생됩니다. 마스터 키
는 이미 고비용 KDF에 의해 생성되었습니다. 두 번째 Argon2 호출은 보안 이점 없이 모든
UnlockBucket 호출에 수백 밀리초의 지연을 추가할 것입니다. HKDF-SHA256은 약 1마이크로초 내에 작동합니다.
심층 방어: 데이터베이스만 손상시킨 공격자는 래핑된 DEK와 HKDF 솔트를 얻지만, 마스터 키 없이는 KEK를 파생할 수 없습니다.
마스터 키만 손상시킨 공격자는 관리자 자격 증명을 알지 못하면 어떤
LevelAdminWrapped DEK도 언래핑할 수 없습니다.
비밀 메타데이터(생성 시간, 업데이트 시간, 액세스 횟수, 버전)는 암호문과 별도로 암호화됩니다:``` metaKey ← HKDF-SHA256(bucketDEK, nil, info="keeper-metadata-v1") → 32 bytes encryptedMeta ← XChaCha20-Poly1305.Seal(nonce, metaKey, msgpack(metadata))
For `LevelAdminWrapped`, `LevelHSM`, `LevelRemote` 버킷의 경우, 이는 버킷 자격 증명 없이는 메타데이터에 접근할 수 없음을 의미하며, 데이터베이스 파일에 읽기 접근 권한이 있는 공격자가 접근 패턴이나 타임스탬프를 알아내는 것을 방지합니다.
**타이밍 사이드 채널 참고:** XChaCha20-Poly1305는 인증 오류를 반환하기 전에 전체 암호문을 처리합니다. 대체 복호화 경로(새로운 파생 DEK → 이전 마스터 키를 DEK로 사용)는 어떤 키가 성공하든 동일한 실제 시간이 소요됩니다. 타이밍 사이드 채널이 레코드의 마이그레이션 상태를 유출하지 않습니다.
### 메타데이터 암호화 — 정책, WAL 및 감사
모든 구조적 메타데이터도 저장 시 암호화됩니다. `UnlockDatabase` 시간에 마스터 키로부터 두 개의 키가 파생됩니다:```
policyEncKey ← HKDF-SHA256(masterKey, nil, info="keeper-policy-enc-v1") → 32 bytes
auditEncKey ← HKDF-SHA256(masterKey, nil, info="keeper-audit-enc-v1") → 32 bytes
policyEncKey는 BucketSecurityPolicy 값과 순환 WAL을 암호화합니다.
auditEncKey는 모든 감사 이벤트의 Scheme, Namespace, Details 필드를 암호화합니다.
두 키 모두 Lock()에서 메모리에서 지워집니다. 메타데이터 암호화에 사용되는 암호는 비밀에 사용되는 것과 동일한 구성 가능한 crypt.Cipher 인터페이스입니다. 사용자의 암호 선택(AES-256-GCM for FIPS, 기본값 XChaCha20-Poly1305)이 자동으로 적용됩니다.
모든 암호화된 메타데이터 블롭의 와이어 형식:``` nonce (cipher.NonceSize() bytes) || AEAD-ciphertext
### 정책 버킷 키 해싱
디스크 상의 정책 키는 일반 텍스트 `scheme:namespace` 문자열이 아닌 불투명 해시값으로, 버킷 이름의 오프라인 열거를 방지합니다:```
base ← hex(SHA-256("scheme:namespace"))[:32] // 32 hex chars = 128-bit key space
_policies/<base> → encrypted BucketSecurityPolicy
_policies/<base>__hash__ → SHA-256(encrypted policy bytes)
_policies/<base>__hmac__ → HMAC-SHA256(policyKey, encrypted policy bytes)
메모리 내 schemeRegistry는 계속해서 "scheme:namespace"를 키로 사용합니다 — 단, 디스크 상의 표현만 변경됩니다.
각 정책 레코드는 하나의 bbolt 트랜잭션에서 원자적으로 기록되는 두 개의 무결성 태그를 포함합니다:``` hash ← SHA-256(encryptedPolicyBytes) — unauthenticated, pre-unlock integrity policyKey ← HKDF-SHA256(masterKey, nil, info="keeper-policy-hmac-v1") → 32 bytes hmac ← HMAC-SHA256(policyKey, encryptedPolicyBytes) — authenticated, post-unlock integrity
`UnlockDatabase` 이전에는 SHA-256 해시만 사용할 수 있습니다. 잠금 해제 후, `loadPolicy`가 HMAC 태그를 확인합니다. `UnlockDatabase`는 이 기능이 추가되기 전에 생성된 정책에 HMAC 태그를 역으로 채우기 위해 `upgradePolicyHMACs`를 호출합니다.
### HMAC 서명 감사```
auditKey ← HKDF-SHA256(masterKey, nil, info="keeper-audit-hmac-v1") → 32 bytes
HMAC ← HMAC-SHA256(auditKey, event fields including Seq)
서명 키는 UnlockDatabase에서 활성화되고 Lock에서 해제됩니다. 마스터 키가 교체되면 Rotate는 키 교체 체크포인트 이벤트를 모든 활성 감사 체인에 추가하며, 이전 에포크의 마지막 이벤트로 이전 감사 키로 서명합니다. 기록은 절대 다시 쓰이지 않으며, 체크포인트는 에포크 간의 신뢰 브리지입니다.
passphrase │ └─ Argon2id(salt) ──→ masterKey (32 bytes, memguard Enclave) │ ├─ HKDF("keeper-audit-hmac-v1") ──→ auditKey (HMAC signing) ├─ HKDF("keeper-audit-enc-v1") ──→ auditEncKey (audit field encryption) ├─ HKDF("keeper-policy-hmac-v1") ──→ policyKey (policy HMAC) ├─ HKDF("keeper-policy-enc-v1") ──→ policyEncKey (policy/WAL encryption) │ ├─ [LevelPasswordOnly] │ └─ HKDF("keeper-bucket-dek-v1:scheme:ns") ──→ DEK │ └─ HKDF("keeper-metadata-v1") ──→ metaKey │ ├─ [LevelAdminWrapped] │ ├─ random 32 bytes ──→ DEK │ │ └─ HKDF("keeper-metadata-v1") ──→ metaKey │ │ │ └─ HKDF("keeper-kek-v1", masterKey‖adminCred, dekSalt) │ └─ KEK │ └─ XChaCha20-Poly1305(KEK, DEK) ──→ wrappedDEK │ └─ [LevelHSM / LevelRemote] ├─ random 32 bytes ──→ DEK │ └─ HKDF("keeper-metadata-v1") ──→ metaKey │ └─ HSMProvider.WrapDEK(DEK) ──→ wrappedDEK (stored; provider controls the wrapping key)
All intermediate keys are zeroed immediately after use. The master key is never written to disk in any form.
---
## Storage schema
The underlying database is bbolt. All buckets and their contents:
| bbolt bucket | Key | Value |
|---|---|---|
| `__meta__` | `salt` | msgpack — SaltStore (unencrypted; circular dependency if encrypted) |
| `__meta__` | `verify` | raw bytes — Argon2id verification hash |
| `__meta__` | `rotation_wal` | `nonce‖AEAD(msgpack(RotationWAL))` |
| `__meta__` | `bucket_dek_done` | `"1"` — DEK migration completion marker |
| `__policies__` | `hex(SHA-256(scheme:ns))[:32]` | `nonce‖AEAD(msgpack(BucketSecurityPolicy))` |
| `__policies__` | `<base>__hash__` | hex SHA-256 of encrypted policy bytes |
| `__policies__` | `<base>__hmac__` | hex HMAC-SHA256(policyKey, encrypted policy bytes) |
| `__audit__/scheme/namespace` | event UUID | JSON — audit Event |
| `__audit__/scheme/namespace` | `__chain_index__` | JSON — chainIndex |
| `scheme/namespace` | key string | msgpack — Secret struct |
### Secret struct (msgpack)```go
type Secret struct {
Ciphertext []byte `msgpack:"ct"`
EncryptedMeta []byte `msgpack:"em,omitempty"`
SchemaVersion int `msgpack:"sv"` // always 1
}
Event 구조체는 평문 라우팅 필드(Scheme, Namespace)와 암호화된 페이로드 필드(EncScheme, EncNamespace, EncDetails)를 분리하여 사용합니다. 체크섬은 평문 라우팅 필드와 암호화된 EncDetails 바이트에 대해 계산되므로, 체인 무결성을 키 없이도 세 가지 계층에서 검증할 수 있습니다:
| 계층 | 보유 정보 | 검증 가능한 항목 |
|---|---|---|
| 공개 | 없음 | SHA-256 체크섬 체인 (변조 및 삽입 탐지) |
| 감사 키 보유자 | auditEncKey | 전체 체인 + Scheme/Namespace/Details 복호화 |
| 운영자 | 마스터 암호 | 모든 것 |
예시: 규정 준수 감사관은 auditEncKey만 받습니다. 키 순환 전체에 걸쳐 HMAC 체인을 검증하고 모든 이벤트 세부 정보를 읽을 수 있지만, 비밀 값은 복호화할 수 없습니다. 데이터베이스 파일만 가지고 있는 공개 관찰자는 여전히 어떤 이벤트가 사후에 수정되거나 삽입되었는지 감지할 수 있습니다.
KDF 솔트는 salt 메타데이터 키 아래 msgpack으로 인코딩된 SaltStore로 저장됩니다. 각 솔트 순환은 새 SaltEntry를 추가하고 CurrentVersion을 증가시킵니다. 이전 항목은 감사 추적으로 유지됩니다. SaltStore는 암호화되지 않은 상태로 저장됩니다 — 보안 결정 참조.
Rotate는 레코드에 접근하기 전에 WAL을 작성합니다. WAL은 WrappedOldKey(새 마스터 키로 암호화된 순환 전 마스터 키)를 전달합니다. 충돌 후 이전 암호는 사라집니다. WrappedOldKey는 경계를 넘어 이전 키를 전달하는 유일한 올바른 방법입니다. UnlockDatabase에서 WAL이 존재할 때 새 마스터 키가 WrappedOldKey를 복호화하고 WAL 커서에서 순환이 재개됩니다. WAL 자체는 policyEncKey로 암호화됩니다.
모든 중요한 작업은 버킷의 감사 체인에 변조 증거 이벤트를 추가합니다. 체인 무결성은 두 가지 메커니즘에 의존합니다.
체크섬. prevChecksum, ID, BucketID, Scheme, Namespace, EncDetails, EventType 및 Timestamp에 대한 SHA-256입니다. Scheme/Namespace를 평문(항상 암호화된 형태와 함께 보존됨)으로 사용하면 로드 경로 전반에 걸쳐 체크섬이 안정적으로 유지됩니다. EncDetails는 암호화된 페이로드의 무결성을 제공합니다.
HMAC. Seq를 포함한 모든 필드에 대한 HMAC-SHA256입니다. 데이터베이스에 쓸 수 있지만 감사 키를 모르는 공격자는 유효한 HMAC을 생성할 수 없습니다. VerifyIntegrity는 모든 이벤트에 대해 두 계층을 모두 확인합니다.
키 순환 에포크 경계. Rotate에서 체크포인트 이벤트가 모든 활성 체인에 추가되며, 이 체크포인트는 나가는 감사 키와 들어오는 감사 키의 지문을 모두 전달합니다. 체크포인트는 나가는 키로 서명됩니다. 에포크 키를 보유한 감사자는 wrapped_new_key 필드에서 후속 에포크 키를 복구하고 전체 체인에 걸친 HMAC 연속성을 검증할 수 있습니다.
자동 정리. Config에 AuditPruneInterval이 설정되면 jack.Scheduler가 주기적으로 실행되어 등록된 모든 버킷에서 PruneEvents를 호출합니다. LevelHSM 및 LevelRemote 버킷은 이 설정에 관계없이 정리되지 않습니다.
Jack은 선택적 프로세스 감독 라이브러리입니다. WithJack을 통해 JackConfig가 제공되면 keeper가 백그라운드 구성 요소를 자동으로 활성화합니다:
AutoLockInterval 이후 LevelAdminWrapped 버킷 DEK를 해제합니다. LevelPasswordOnly 버킷은 잠금 해제 상태로 유지되어 백그라운드 작업이 중단 없이 계속됩니다. 루퍼 작업 내의 단일 쓰기 잠금 패턴은 이전 설계에 존재했던 RUnlock→Lock 경쟁 조건을 제거합니다.LevelAdminWrapped DEK에 대한 TTL 기반 만료.jack.Doctor에 등록됩니다.PruneEvents.JackConfig가 제공되지 않으면 keeper는 이러한 백그라운드 작업 없이 실행됩니다. Keeper는 pool.Shutdown을 호출하지 않습니다 — 풀 라이프사이클은 호출자에게 속합니다.
x/keepcmd는 CLI 프레임워크에서 분리된 재사용 가능한 keeper 작업을 제공합니다. 자신의 애플리케이션에 포함시켜 CLI 바이너리를 가져오지 않고도 타입이 지정되고 테스트 가능한 비밀 관리를 얻을 수 있습니다.```go
import "github.com/agberohq/keeper/x/keepcmd"
cmds := &keepcmd.Commands{ Store: func() (*keeper.Keeper, error) { return security.KeeperOpen(cfg) // your own config }, Out: keepcmd.PlainOutput{}, NoClose: false, // true in REPL / session contexts }
cmds.List() // all keys: scheme://namespace/key cmds.List("vault") // all keys in scheme vault cmds.List("vault", "system") // all keys in vault://system cmds.Get("vault://system/jwt_secret") cmds.Set("vault://system/jwt_secret", "newsecret", keepcmd.SetOptions{}) cmds.Rotate(newPassphraseBytes) // caller resolved the passphrase — no prompter dependency cmds.RotateSalt(currentPassBytes) // same
`keepcmd`는 `prompter`를 호출하거나 stdin에서 읽지 않습니다. 암호 해결은 전적으로 호출자의 책임입니다. 이는 헤드리스 서버 환경에서 패키지를 안전하게 유지합니다.
`NoClose: true`는 `Commands`가 각 작업 후에 `store.Close()`를 호출하는 것을 방지합니다. 여러 호출에서 하나의 저장소를 공유하는 REPL/세션 컨텍스트에서 이를 사용하세요.
---
## x/keephandler
`x/keephandler`는 keeper HTTP 엔드포인트를 모든 `net/http` mux에 마운트합니다. 외부 라우터 의존성이 없습니다. Go 1.22+의 메서드+패턴 라우팅과 stdlib `http.ServeMux`를 사용합니다.```go
import "github.com/agberohq/keeper/x/keephandler"
keephandler.Mount(mux, store,
keephandler.WithPrefix("/api/keeper"),
keephandler.WithGuard(func(w http.ResponseWriter, r *http.Request, route string) bool {
if !acl.Allow(r.Header.Get("X-Principal"), route) {
http.Error(w, `{"error":"forbidden"}`, http.StatusForbidden)
return false
}
return true
}),
keephandler.WithHooks(
keephandler.Hook{
Route: keephandler.RouteGet,
CaptureBody: false,
After: func(r *http.Request, status int, _ []byte) {
audit.Log(r.Context(), route, status)
},
},
),
keephandler.WithEncoder(func(w http.ResponseWriter, route string, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]any{
"ok": status < 400,
"route": route,
"data": data,
})
}),
keephandler.WithRoutes(func(m *http.ServeMux) {
m.HandleFunc("POST /api/keeper/totp/{user}", myTOTPHandler)
}),
)
BeforeFunc는 (allow bool, err error)를 반환합니다.
(true, nil) — 요청을 진행시킵니다.(false, nil) — 중단; 후크가 이미 완전한 응답을 작성했습니다.(false, err) — 중단; 프레임워크가 err.Error()를 사용하여 500을 작성합니다.
후크는 w에 아무것도 작성하지 않아야 합니다.Hook.CaptureBody bool은 AfterFunc가 응답 본문을 수신할지 여부를 제어합니다.
false(기본값)는 가벼운 statusWriter 래퍼 하나를 사용합니다;
true는 AfterFunc를 위해 전체 본문을 bytes.Buffer로 버퍼링합니다 — 요청당 한 번의 할당입니다.
후크는 등록 순서대로 실행됩니다. 여러 WithHooks 호출은 누적됩니다.
특정 경로 이름에 등록된 첫 번째 후크만 사용됩니다—동일한 경로에 대한 후속 등록은 무시됩니다.
store, err := keeper.New(keeper.Config{ DBPath: "/var/lib/agbero/keeper.db", AutoLockInterval: 30 * time.Minute, EnableAudit: true, AuditPruneInterval: 24 * time.Hour, AuditPruneKeepLastN: 10_000, AuditPruneOlderThan: 90 * 24 * time.Hour, DBLatencyThreshold: 200 * time.Millisecond, Logger: logger, }, keeper.WithJack(keeper.JackConfig{ Pool: jackPool, Shutdown: jackShutdown, })) defer store.Close()
// Shorthand (wraps DeriveMaster + UnlockDatabase): if err := store.Unlock([]byte(os.Getenv("KEEPER_PASSPHRASE"))); err != nil { log.Fatal(err) // ErrInvalidPassphrase on wrong passphrase }
`UnlockDatabase`는 다음 순서로 수행합니다:
1. 감사 HMAC 서명 키를 파생하고 활성화합니다
2. 정책 HMAC 키를 파생하고 활성화합니다
3. `policyEncKey` 및 `auditEncKey`를 파생하고 활성화합니다
4. `schemeRegistry`를 지우고 다시 로드합니다 (모든 정책 blob 복호화)
5. 중단된 회전 WAL을 재개합니다
6. 정책 HMAC 태그를 업그레이드합니다
7. 모든 `LevelPasswordOnly` 버킷 DEK를 Envelope에 시드합니다
8. 백그라운드 작업을 시작합니다 (마이그레이션 루퍼, 자동 잠금, 헬스 환자)
### LevelPasswordOnly 버킷 — 전체 수명 주기```go
err := store.CreateBucket("vault", "system", keeper.LevelPasswordOnly, "init")
store.Set("vault://system/jwt_secret", []byte("supersecret"))
val, err := store.Get("vault://system/jwt_secret")
// Namespaced convenience wrappers
store.SetNamespaced("admin", "jwt_secret", secretBytes)
val, err = store.GetNamespaced("admin", "jwt_secret")
err := store.CreateBucket("finance", "payroll", keeper.LevelAdminWrapped, "ops-team") err = store.AddAdminToPolicy("finance", "payroll", "alice", []byte("alicepass"))
store.SetNamespacedFull("finance", "payroll", "salary_key", []byte("AES256..."))
store.LockBucket("finance", "payroll") err = store.UnlockBucket("finance", "payroll", "bob", []byte("bobpass")) // ErrAuthFailed — does not distinguish wrong password from unknown admin (CWE-204)
err = store.RevokeAdmin("finance", "payroll", "alice") err = store.RotateAdminWrappedDEK("finance", "payroll", "bob", []byte("bobpass"))
needs, err := store.NeedsAdminRekey("finance", "payroll")
### LevelHSM / LevelRemote 버킷```go
import (
"github.com/agberohq/keeper/pkg/hsm"
"github.com/agberohq/keeper/pkg/remote"
)
// SoftHSM — testing only
provider, _ := hsm.NewSoftHSM()
store.RegisterHSMProvider("secure", "keys", provider)
store.CreateBucket("secure", "keys", keeper.LevelHSM, "ops")
// Vault Transit
cfg := remote.VaultTransit("https://vault.corp:8200", vaultToken, "my-key")
cfg.TLSClientCert = "/etc/keeper/client.crt"
cfg.TLSClientKey = "/etc/keeper/client.key"
provider, _ = remote.New(cfg)
store.RegisterHSMProvider("tenant", "secrets", provider)
store.CreateBucket("tenant", "secrets", keeper.LevelRemote, "ops")
// Export the audit encryption key to allow a third-party auditor to decrypt // event details without access to the master passphrase. auditKey, err := store.ExportAuditKey() defer zero.Bytes(auditKey)
events, err := auditStore.LoadChain("vault", "system", auditKey)
### 키 순환```go
// Rotate passphrase — crash-safe WAL, resumes on next Unlock if interrupted
store.Rotate([]byte("new-passphrase"))
// Rotate KDF salt — re-derives master key, re-encrypts LevelPasswordOnly
store.RotateSalt([]byte("current-passphrase"))
err := store.CompareAndSwapNamespacedFull("vault", "system", "counter", []byte("old"), []byte("new")) // ErrCASConflict if current value does not match old
### 백업```go
f, _ := os.Create("keeper.db.bak")
info, err := store.Backup(f)
// info.Bytes, info.Timestamp, info.DBPath
모든 센티널 오류는 errors.Is 및 errors.As와 함께 작동합니다. 스택 트레이스는 github.com/olekukonko/errors를 통해 생성 시점에 캡처됩니다.
ErrAuthFailed는 모든 UnlockBucket 실패를 통합합니다 (CWE-204 / CVSS 5.3). 알 수 없는 관리자 ID와 잘못된 비밀번호 모두 ErrAuthFailed를 반환합니다. 이는 타이밍 또는 오류 문자열 비교를 통한 관리자 ID 열거를 방지합니다. RevokeAdmin은 이미 잠금 해제된 스토어에서 관리 작업이므로 ErrAdminNotFound를 유지합니다. 관리자 ID 존재 여부에 대한 상수 시간 비교는 의도적으로 생략되었습니다. bbolt 버킷 조회에서 미크로초 미만의 차이를 측정할 수 있는 공격자는 로컬 파일 시스템 접근 권한이 필요하며, 이 시점에서 정책 버킷을 직접 읽을 수 있습니다. 위협 모델은 데이터베이스 파일이 손상될 수 있다고 가정합니다. 원격 열거에 대한 타이밍 방어가 주요 관심사입니다.
Argon2id가 타이밍을 지배합니다. Argon2id는 일반적인 하드웨어에서 200~500ms가 소요됩니다. 파생 후 비교 차이는 4자릿수 이상 작으며 원격으로 측정할 수 없습니다. 인위적인 균등화는 적용되지 않습니다.
DEK는 CAS 트랜잭션 경계 내에서 검색됩니다. CompareAndSwapNamespacedFull은 bbolt 쓰기 트랜잭션 내에서 버킷 DEK를 검색하여, 검색과 사용 사이에 동시 Rotate가 DEK를 변경할 수 있는 기회를 제거합니다.
암호는 HTTP 핸들러에서 Go 문자열로 저장되지 않습니다. 세 가지 암호 필드(passphrase, new_passphrase)는 원시 맵 추출을 통해 JSON에서 직접 []byte로 디코딩되어, 문자열 백업 배열이 장기 힙에 남지 않도록 합니다. []byte 복사본은 사용 후 wipeBytes로 초기화됩니다.
CLI에 --passphrase 플래그가 없습니다. 플래그는 ps 출력과 셸 히스토리에 나타납니다. CLI는 KEEPER_PASSPHRASE 환경 변수 또는 대화형 에코 없는 프롬프트에서만 암호를 입력받습니다.
REPL 비밀 값은 절대 표시되지 않습니다. 인라인 값 없이 REPL에서 set <key>는 term.ReadPassword를 사용합니다 — 이는 터미널 스크롤백, 셸 히스토리 또는 ps에 나타나지 않습니다. 민감하지 않은 데이터의 경우 편의에 따라 인라인 값(set key value)을 제공할 수 있습니다.
SaltStore는 의도적으로 암호화되지 않습니다. KDF 솔트는 마스터 키를 파생하기 위해 UnlockDatabase 전에 읽을 수 있어야 합니다. policyEncKey(다른 모든 메타데이터 암호화에 사용됨)는 마스터 키에서 파생됩니다. 솔트를 policyEncKey로 암호화하는 것은 순환적입니다. KDF 솔트는 기밀성이 아닌 고유성을 제공합니다. 암호화해도 보안상의 이점이 없습니다.
정책 버킷 키는 해시 처리되며, 평문이 아닙니다. 디스크 상의 정책 키는 읽을 수 있는 문자열 대신 hex(SHA-256("scheme:namespace"))[:32] — 128비트 키 공간입니다. bbolt 파일을 읽는 오프라인 공격자는 정책 블롭을 복호화하지 않고는 버킷 이름을 열거할 수 없습니다.
메타데이터 암호화는 비밀과 동일한 암호 인터페이스를 사용합니다. 모든 policyEncKey 및 auditEncKey 작업은 s.config.NewCipher(key)를 통해 이루어집니다. 이는 비밀 값에 대해 구성된 동일한 crypt.Cipher 인터페이스입니다. 사용자의 암호 선택(FIPS 140의 경우 AES-256-GCM, 기본값은 XChaCha20-Poly1305)이 정책, WAL 및 감사 암호화에 자동으로 적용됩니다. 특정 알고리즘을 하드코딩한 코드 경로는 없습니다.
마스터 키 로테이션 중 LevelHSM 및 LevelRemote 버킷은 건너뜁니다. reencryptAllWithKey 및 RotateSalt는 이러한 버킷을 명시적으로 건너뜁니다. DEK는 공급자가 제어합니다. 마스터 솔트 로테이션은 이에 영향을 미치지 않습니다.
WrappedOldKey를 통한 크래시 안전 로테이션. Rotate는 레코드를 건드리기 전에 WAL을 씁니다. WAL은 WrappedOldKey를 포함합니다. 이는 새 마스터 키로 암호화된 로테이션 전 마스터 키입니다. 크래시 후 UnlockDatabase는 검증된 새 키를 사용하여 WrappedOldKey를 복호화하고 커서에서 로테이션을 재개합니다.
| Method | Path | Description |
|---|
POST | {prefix}/unlock | 패스프레이즈로 스토어 잠금 해제 |
POST | {prefix}/lock | 스토어 잠금 |
GET | {prefix}/status | 잠금 상태 — 인증 없이 폴링 가능 |
GET | {prefix}/keys | 모든 비밀 키 나열 |
GET | {prefix}/keys/{key} | 비밀 값 검색 |
POST | {prefix}/keys | 비밀 저장 (JSON 또는 multipart) |
DELETE | {prefix}/keys/{key} | 비밀 삭제 |
POST | {prefix}/rotate | 마스터 패스프레이즈 교체 |
POST | {prefix}/rotate/salt | KDF 솔트 교체 |
GET | {prefix}/backup | 데이터베이스 스냅샷 스트리밍 |
| 오류 | 의미 |
|---|
ErrStoreLocked | 스토어가 잠겨 있는 상태에서 작업을 시도했습니다. |
ErrInvalidPassphrase | 잘못된 마스터 암호입니다. |
ErrAuthFailed | UnlockBucket 실패 — 잘못된 비밀번호와 알 수 없는 관리자 ID를 구분하지 않음 (CWE-204) |
ErrKeyNotFound | 비밀 키가 존재하지 않습니다. |
ErrBucketLocked | 버킷이 잠금 해제되지 않았습니다. |
ErrPolicyImmutable | 기존 버킷에 대한 두 번째 정책입니다. |
ErrPolicyNotFound | 주어진 체계/네임스페이스에 대한 정책이 없습니다. |
ErrAdminNotFound | 관리자 ID가 정책에 없음 — RevokeAdmin 전용입니다. |
ErrHSMProviderNil | 등록된 공급자 없이 HSM/원격 버킷이 생성되었습니다. |
ErrCheckLatency | DB 읽기 지연 시간이 DBLatencyThreshold를 초과했습니다. |
ErrCASConflict | 현재 값이 CompareAndSwap의 예상 값과 일치하지 않습니다. |
ErrSecurityDowngrade | 보안 수준이 높은 버킷에서 낮은 버킷으로의 교차 이동입니다. |
ErrAlreadyUnlocked | 이미 잠금 해제된 스토어에서 UnlockDatabase가 호출되었습니다. |
ErrMasterRequired | nil 또는 삭제된 Master로 UnlockDatabase가 호출되었습니다. |
ErrChainBroken | 감사 체인 무결성 검증에 실패했습니다. |
ErrMetadataDecrypt | 암호화된 메타데이터를 복호화할 수 없습니다. |
ErrPolicySignature | 정책 HMAC 검증에 실패했습니다 — 레코드가 변조되었습니다. |
| 패키지 | 용도 |
|---|
go.etcd.io/bbolt | 임베디드 키-값 저장소 |
golang.org/x/crypto | Argon2id, XChaCha20-Poly1305, HKDF, scrypt |
github.com/awnumar/memguard | 메모리 안전 키 영역 (마스터 키, DEK) |
github.com/vmihailenco/msgpack/v5 | 비밀 및 정책에 대한 바이너리 직렬화 |
github.com/olekukonko/jack | 프로세스 감독 (선택적 Jack 통합) |
github.com/olekukonko/ll | 구조화된 로깅 |
github.com/olekukonko/errors | 스택 트레이스가 포함된 센티널 오류 |
github.com/olekukonko/zero | 안전한 바이트 슬라이스 초기화 |
github.com/olekukonko/prompter | 에코 없는 터미널 프롬프트 (CLI 전용) |
github.com/integrii/flaggy | CLI 플래그 파싱 (cmd/keeper 전용) |
golang.org/x/term | TTY 감지 및 원시 비밀번호 읽기 (CLI 전용) |