Outsider's Dev Story

Stay Hungry. Stay Foolish. Don't Be Satisfied.
RetroTech 팟캐스트 44BITS 팟캐스트

1Password의 Service Accounts로 시크릿 관리하기

비밀번호 관리 서비스인 1Password는 2022년에 1Password Developer Tools를 런칭하고 SSH와 Git의 비밀키 관리 기능도 추가하면서 사용자의 비밀번호 관리뿐만 아니라 시스템이나 개발자의 보안을 유지할 수 있는 기능도 추가하고 있다.

작년인가 1Password의 Connect Server를 보고 이를 이용해서 서비스에서 시크릿 보관소로 쓸 수 있을지 궁금했던 기억이 있다. 테스트해 봐야지 하고는 아직 못하고 있었다.

최근 GitHub Universe에서 1Password 부스를 구경하다가 1Password Developer Tools에 Service Accounts라는 기능이 나왔다는 것을 알게 되었고 내가 올해 고민하던 문제의 해결책이 될 수 있겠다는 생각에 살펴봤다.

내가 생각하는 문제는 보통 로컬에서 개발 환경을 구성하기 위해서 다양한 시크릿 정보를 환경 변수에 저장해 놓고 사용하게 되고 direnv나 그 외 환경변수 관리 도구를 쓴다고 하더라도 일반적으로 이 정보는 로컬에 보통 평문으로 저장되어 있다. 개발 환경이긴 하지만 상황에 따라선 프로덕션용 시크릿이 보관되어 있을 수 있다. 물론 컴퓨터의 자체 보안이 있고 노트북을 잃어버려도 보통 암호가 걸려있어서 암호가 풀린 상태로 다른 사람에게 노트북을 주거나 공격자에게 침투당하는 상황까지 고려하는 것은 아니지만 파일을 복사하거나 백업하면서 실수로라도 이러한 시크릿이 유출될 가능성이 높다는 것이었다.

1Password Service Accounts

1Password Service Accounts는 올 6월에 퍼블릭 베타로 공개된 기능이고 지금은 베타가 끝나서 공개된 상태이다.

사용자 삽입 이미지

1Password의 Developer Tools에 들어가면 CI, Kubernetes, CLI, SSH/Git, IDE와 통합할 수 있는 메뉴가 나온다. 참고로 IDE는 현재는 VS Code만 통합할 수 있다.

사용자 삽입 이미지

GitHub Actions를 클릭해서 Service Accounts와 Connect Server 중에서 선택할 수 있게 나온다. 둘 다 시크릿은 자동화해서 연동할 수 있게 하는 기능인데 Service Accounts는 CLI를 사용해서 연동할 수 있고 Connect Server는 별도의 서버를 실행해서 연동할 수 있다. Connect Server는 나중에 또 공부해 보려고 하는 데둘의 기능 차이를 살펴보면 다음과 같다.

사용자 삽입 이미지

CI나 로컬에서 연동하려면 별도의 서버 관리가 필요 없는 Service Accounts가 더 적합해 보였다.

Service Accounts 생성을 클릭하면 이름을 입력할 수 있다.

사용자 삽입 이미지

생성할 서비스 어카운트가 접근할 금고(Vault)를 선택할 수 있다. 권한은 읽기만 허용하거나 읽기/쓰기까지 가능하거나 할 수 있다. 서비스 어카운트에서는 주로 읽기를 할거라고 생각했기에 읽기 권한만 부여했다.

사용자 삽입 이미지

생성이 완료되면 해당 서비스 어카운트의 인증 토큰이 나오고 이 토큰은 1Password CLI에서 사용할 수 있다.

사용자 삽입 이미지

이 토큰을 나중에 사용해야 하므로 1Password에 저장해 둘 수 있다.

사용자 삽입 이미지

1Password CLI

macOS 기준으로 Homebrew를 이용해서 설치하거나 직접 다운로드 받아서 사용할 수 있다.

CLI는 op 라는 명령어로 사용할 수 있고 현재 최신 버전은 2.24.0이다.

$ op -v
2.24.0

인증을 하기 위해 OP_SERVICE_ACCOUNT_TOKEN 환경변수에 아까 발급받은 인증 토큰을 지정한다.

$ export OP_SERVICE_ACCOUNT_TOKEN=ops_eyJ...

그리고 1Password 앱의 설정의 개발자 섹션에서 "1Password CLI와 통합"을 활성화해야 한다.

사용자 삽입 이미지

이를 활성화하면 1Password의 각 시크릿에서 "보기"와 "크게 보기" 외에도 "비밀 참조 복사"라는 항목이 생기게 된다.

사용자 삽입 이미지

위는 예시로 API 키를 저장한 시크릿인데 이 "비밀 참조 복사"를 하면 op://Dev/demo/api_key와 같은 주소가 생긴다. 여기서 op://[금고 이름]/[시크릿 이름]/[필드 이름] 형태가 된다. 여기서 각 항목에 지원하지 않는 문자가 있는 경우에는 op://Dev/iunplqsduyobjbri45irjajgcu/password처럼 이름 대신 UID가 생성된다.

앞에서 서비스 어카운트를 만들고 발급받은 인증 토큰을 OP_SERVICE_ACCOUNT_TOKEN 환경변수에 저장하면 바로 CLI를 사용할 수 있다.

op vault list 명령어로 금고 목록을 볼 수 있는데 해당 금고는 Dev 금고에만 접근 권한을 주었기 때문에 한 개만 나온다.

$ op vault list
ID                            NAME
u7mujejocgwhnu3sxbxjehtdpm    Dev

CLI에서 각 아이템의 목록을 볼 수 있다.

$ op item list
ID                            TITLE                                             VAULT            EDITED
4phd2vr5vnqt3ssrjpupdfbkfy    demo                                              Dev              10 minutes ago
bkgfwcb25abmp4wwslo4ss2qwy    Service Account Auth Token: GitHub Actions        Dev              2 weeks ago

1Password 앱을 켜지 않고도 특정 아이템의 자세한 내용을 확인해 볼 수 있다.(여기서 보이는 api_key는 임의로 만든 문자열이다.)

$ op item get demo --vault Dev
ID:          4phd2vr5vnqt3ssrjpupdfbkfy
Title:       demo
Vault:       Dev (u7mujejocgwhnu3sxbxjehtdpm)
Created:     1 day ago
Updated:     10 minutes ago by Outsider
Favorite:    false
Version:     3
Category:    SERVER
Fields:
  api_key:    RvN4HNRk2oE.iL*42veP.UE.eDre6rPf_K*@m8!j
  관리자 콘솔:

  호스팅 제공업체:

CLI에서 비밀 참조를 알고 싶다면 JSON 형식으로 출력해 보면 확인할 수 있다.

$ op item get demo --vault Dev --format json
{
  "id": "4phd2vr5vnqt3ssrjpupdfbkfy",
  "title": "demo",
  "version": 3,
  "vault": {
    "id": "u7mujejocgwhnu3sxbxjehtdpm",
    "name": "Dev"
  },
  "category": "SERVER",
  "last_edited_by": "K2ACBLCXENBKJLN2V6Y3HPRSBY",
  "created_at": "2023-12-19T08:10:17Z",
  "updated_at": "2023-12-21T01:48:25Z",
  "sections": [
    {
      "id": "hosting_provider_details",
      "label": "호스팅 제공업체"
    }
  ],
  "fields": [
    {
      "id": "password",
      "type": "CONCEALED",
      "label": "api_key",
      "value": "RvN4HNRk2oE.iL*42veP.UE.eDre6rPf_K*@m8!j",
      "reference": "op://Dev/demo/api_key"
    },
    {
      "id": "name",
      "section": {
        "id": "hosting_provider_details",
        "label": "호스팅 제공업체"
      },
      "type": "STRING",
      "label": "이름",
      "reference": "op://Dev/demo/hosting_provider_details/name"
    }
  ]
}


op read

op read는 비밀 참조의 값을 읽어오는 명령어로 다음과 같이 비밀번호를 조회해 볼 수 있다.

$ op read op://Dev/demo/api_key
RvN4HNRk2oE.iL*42veP.UE.eDre6rPf_K*@m8!j

이를 이용해서 특정 시크릿 값을 파일에 저장할 수도 있다.

$ op read op://Dev/demo/api_key --out-file key.txt
/Users/outsider/temp/op-test/key.txt

$ cat key.txt
RvN4HNRk2oE.iL*42veP.UE.eDre6rPf_K*@m8!j

값을 확인하고자 할 때는 op read를 이용할 수 있지만 서비스 어카운트를 쓴다는 것 자체가 비밀번호를 직접 다루지 않기 위함이라고 생각하기 때문에 이 명령어는 CLI에 약간 익숙해 지면 쓸 일은 없어질거로 생각한다.

op run

아마 서비스 어카운트에서 가장 많이 사용할 명령어라고 생각한다. 1Password 금고에 시크릿을 저장했다면 개발할 때 이를 가져다 써야 하는데 op run이 이 문제를 해결하는 명령어다.

$ export API_KEY=op://Dev/demo/api_key

위와 같이 내가 원하는 환경변수(API_KEY)를 앞의 비밀 참조 값으로 설정한다.

애플리케이션에서 이 환경변수를 사용하는 상황을 테스트하기 위해 아래와같이 간단한 Node.js 코드를 작성했다. 3000 포트에 떠 있는 다른 서버에 API_KEY 환경 변수를 쿼리스트링으로 전달하고 API_KEY을 로그로 출력하고 응답받은 결과를 출력하도록 했다.

// app.js
fetch(`http://localhost:3000?secret=${process.env.API_KEY}`)
  .then(async (res) => {
    console.log(`API_KEY: ${process.env.API_KEY}`);
    const result = await res.text();
    console.log(result);
  })

당연하게도 API_KEY의 값으로 op://Dev/demo/api_key가 출력되고 다른 서버의 로그에도 op://Dev/demo/api_key가 출력된다.

$ node app.js
API_KEY: op://Dev/demo/api_key
Response from another server

이를 op run -- 명령어와 연결해서 서버를 실행하면 환경변수 중에 존재하는 비밀 참조를 모두 찾아서 1Password 값으로 치환해서 처리해 준다.(참고로 --인 더블 대시는 셸에서 옵션과 인자를 구분하는 문법이다.)

$ op run -- node app.js
API_KEY: <concealed by 1Password>
Response from another server
````

위에서 보듯이 해당 환경변수를 로그에 출력했을 때도 값이 출력되는 게 아니라 `<concealed by 1Password>`로 가려진다. 당연히 다른 서버에 전달된 쿼리스트링에는 실제 시크릿 값이 제대로 전달된다. 

이렇게 하면 **로컬에서 환경변수를 관리하면서도 실제 시크릿 값을 가지고 있지 않을 수 있다. 실행명령어에 `op run`이 붙어야 하는 불편함이 있지만 보안상으로도 좋고 환경변수 파일을 그대로 GitHub에 공유한다고 하더라도 실제 시크릿 값은 포함되어 있지 않기 때문에 `.env.example` 같은 걸 만들 필요 없이 그냥 환경 파일을 공유해서 사용하는 것도 가능하고 다 참조 값이기 때문에 해당 값을 바꿀 때도 1Password에서 바꾸면 바로 로테이션시킬 수 있다.**

하지만 1Password API에 찔러서 가져오는 것이기 때문에 항상 인터넷에 연결되어 있어야 하고 당연히 API 값을 가져오느라 약간의 시간이 더 걸린다.

```bash
$ op run -- printenv API_KEY
<concealed by 1Password>

$ op run --no-masking -- printenv API_KEY
RvN4HNRk2oE.iL*42veP.UE.eDre6rPf_K*@m8!j

환경변수 값을 확인하고 싶을 때는 위와 같이 확인해 볼 수 있고 --env-file 옵션을 사용하면 사용하고자 하는 환경파일도 지정할 수 있다.

op inject

셸 스크립트 등에서 비밀 참조를 변환해서 실행하고 싶다면 다음과 같이 op inject를 파이프로 연결하면 전달받은 문자열의 비밀 참조를 실제 값으로 치환한다.

$ echo "API key is op://Dev/demo/api_key" | op inject
API key is RvN4HNRk2oE.iL*42veP.UE.eDre6rPf_K*@m8!j

여기서 비밀 참조인 op://Dev/demo/api_key를 처리할 때 환경변수도 섞어서 사용할 수 있다. 예를 들어 다음과 같이 같은 API 키가 DEV, Alpha, Prod 3개가 있을 때 같은 패턴으로 만들면 op://$ENV/demo/api_key로 환경변수에 지정하고 ENV 환경변수로 참조할 시크릿을 바꿔가면서 쓸 수 있다.

  • op://Dev/demo/api_key
  • op://Alpha/demo/api_key
  • op://Prod/demo/api_key

GitHub Actions에서 1Password Service Accounts

로컬에서 사용하는 방법을 알아봤으니 당연히 CI에서도 사용할 수 있다. CI에서는 GitHub 기준으로 저장소에 액션 시크릿을 설정할 수 있고 공통으로 사용하는 시크릿은 Org에 시크릿을 설정해서 공통으로 사용할 수 있다. 기본적으로 GitHub에서는 시크릿을 설정한 뒤에는 내용을 확인하기 어렵기 때문에 개인이라면 좀 낫지만, 회사 차원에서는 예전에 어떤 값을 설정했는지 확인하기 어려워서 로테이션시키기가 어려운데 이때도 1Password Service Accounts를 쓸 수 있다.

1Password에 접근하기 위해 저장소에 OP_SERVICE_ACCOUNT_TOKEN을 저장한다. 여기서는 데모라서 저장소에 Actions 시크릿으로 저장했지만, 회사라면 Org 시크릿으로 저장하거나 관리 정책에 따라 팀별로 따로 지정하는 등의 방법이 가능하다.

사용자 삽입 이미지

GitHub Actions에 다음과 같은 액션을 만들어 보자. 여기서는 1password/load-secrets-action 액션을 사용해서 시크릿을 불러와서 원하는 환경변수에 저장할 수 있다. export-envtrue로 설정해야 다음 스텝에서도 해당 환경변수를 사용할 수 있다. 참고로 onworkflow_dispatch로 지정한 것은 GitHub UI에서 수동으로 실행해서 테스트하기 위함이고 Print unmasked secret 스텝은 보통은 하면 안 되는 트릭이지만 GitHub Actions에서 시크릿 로깅을 허용하지 않기 때문에 이를 회피해서 값을 확인하기 위한 우회법이다.

name: 1Password test

on: workflow_dispatch

jobs:
  1pw-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Load secret
        uses: 1password/load-secrets-action@v1
        with:
          # Export loaded secrets as environment variables
          export-env: true
        env:
          OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
          API_KEY: "op://Dev/mysql/password"

      - name: Print masked secret
        run: echo "Secret is $API_KEY"

      - name: Print unmasked secret
        run: echo "$API_KEY" | sed 's/./& /g'

이 액션을 실행해 보면 다음과 같이 1Password에서 시크릿 값을 가져와서 사용할 수 있는 것을 볼 수 있다.

사용자 삽입 이미지

이렇게 사용하면 로컬이나 CI에서 1Password의 서비스 어카운트 토큰만 저장하고 다른 시크릿은 모두 1Password에서 관리할 수 있는 구조로 만들 수 있다. 보는 관점에 따라서 1Password에 다 담아 놓는 게 좋은가 아닌가는 의견이 갈릴 수 있지만 나는 유출된 지점을 줄이면서 로테이션시킬 수 있다는 점에서 긍정적으로 보고 있다.

물론 1Password의 보안은 상당히 신뢰하는 편이지만 1Password API서버에게 장애가 생기면서 로컬이나 CI에도 영향을 받기 때문에 이 부분은 같이 고려해야 할 것으로 생각한다.

2023/12/28 00:38 2023/12/28 00:38