updatehub
사람과 AI 에이전트 모두를 위한 가이드

update-hub 연동 가이드

이 페이지는 https://update.kimhwan.kr/agents.md 를 그대로 보여 줍니다. 에이전트에게는 그 주소를 주세요 — 이 서버의 실제 URL 과 서명 공개키가 채워져 있습니다.

원문(markdown) llms.txt

update-hub 연동 가이드

AI 에이전트에게: 이 문서는 당신이 사람 도움 없이(승인 한 번만 받고) 앱에 자동 업데이트를 붙이도록 쓰였습니다. 끝까지 읽은 뒤 작업 순서대로 진행하세요. 서버: https://update.kimhwan.kr · 이 문서: https://update.kimhwan.kr/agents.md

한눈에 보기

update-hub 는 여러 제품이 같이 쓰는 업데이트 서버입니다.

  • 프로젝트 = 제품(앱) 하나. 키 예: my-app
  • 컴포넌트 = 프로젝트 안에서 따로 버전이 붙고 따로 내려받는 단위. 예: launcher(실행 파일), content(데이터 묶음)
  • 채널 = stable(기본), beta … 컴포넌트마다 채널별로 "최신"이 있습니다
  • 릴리스 = 컴포넌트의 한 버전 + 그 버전을 설치하는 데 필요한 파일 전체. 상태: DRAFT(초안) → PUBLISHED(게시, 서명됨) → REVOKED(철회)
  • 최신 = 그 채널에서 가장 최근에 게시된 릴리스입니다 (버전 문자열 비교가 아님). 되돌리기 = 최신을 철회하거나 이전 릴리스를 다시 게시

앱은 매니페스트 한 번으로 모든 컴포넌트의 최신 릴리스를 알고, 서명을 검증한 뒤, 바뀐 파일만 받습니다. 게시된 릴리스는 서버의 ECDSA P-256 키로 서명되므로, 서버·DNS·TLS 가 뚫려도 가짜 업데이트를 밀어 넣을 수 없습니다(앱이 검증한다면).

주소 용도
https://update.kimhwan.kr 콘솔, 관리 API, 매니페스트(앱이 읽는 곳)
https://files.update.kimhwan.kr 릴리스 업로드(CI)와 파일 다운로드 — 프록시를 거치지 않아 크기 제한·속도 문제가 없음

지켜야 할 규칙

  1. 비밀을 출력하지 마세요. 명령 출력은 대화 기록에 남습니다. 토큰·키는 echo/print/로그로 내보내지 말고 파이프로 바로 저장하세요(아래 예시가 모두 그렇게 되어 있습니다). 사람에게 비밀을 채팅으로 달라고 하지도 마세요.
  2. 비밀을 저장소에 커밋하지 마세요. 업로드 토큰은 CI 시크릿 저장소에만, 개인 토큰은 ~/.config/update-hub/ 에만 둡니다. .env 같은 파일을 만들었다면 .gitignore 를 확인하세요.
  3. stable 에 게시하는 것은 사람이 시켰을 때만. 확인용 업로드는 publish=false(초안) 또는 channel=beta 로 하세요.
  4. 게시된 버전은 바뀌지 않습니다. 같은 버전을 다시 올리면 409 — 새 버전 번호를 쓰세요. 초안·철회본은 같은 버전으로 덮어쓸 수 있습니다.
  5. 이미 있는 것을 깨뜨리지 마세요. 프로젝트에 업로드 토큰이 이미 있으면(uploadToken: true) 재발급하면 기존 CI 가 멈춥니다. 클라이언트 키 교체도 배포된 앱에 영향을 줍니다. 둘 다 사람에게 먼저 물어보세요.
  6. 토큰으로 못 하는 일: 프로젝트 삭제, 키 보호 끄기, 다른 사람 프로젝트 접근. 필요하면 사람에게 콘솔에서 하도록 안내하세요.
  7. 끝나면 토큰을 폐기하세요 (마무리) — 사람이 계속 쓰라고 하지 않았다면.

작업 순서

  1. 인증 — 기기 로그인으로 개인 액세스 토큰을 받는다 (사람이 브라우저에서 한 번 승인)
  2. 설계 — 프로젝트 키와 컴포넌트를 정한다
  3. 만들기 — 프로젝트·컴포넌트를 만든다 (이미 있으면 그대로 쓴다)
  4. 업로드 토큰 — 발급해서 CI 시크릿에 바로 넣는다
  5. CI — 빌드 끝에 업로드 단계를 넣는다
  6. 앱 — 매니페스트 확인 → 서명 검증 → 다운로드 → SHA-256 확인 → 적용
  7. 확인 — 초안/beta 로 올려 보고 앱의 업데이트 경로를 시험한다
  8. 마무리 — 토큰 폐기, 사람에게 보고

인증: 기기 로그인

에이전트는 기기 로그인(OAuth device flow 와 같은 방식)으로 토큰을 받습니다. 비밀번호나 토큰을 채팅으로 주고받지 않습니다.

  1. 에이전트가 코드를 요청한다 → 8자리 코드(예: BCDF-GHJK)와 승인 주소를 받는다
  2. 사람에게 주소와 코드를 알려 준다 → 사람이 콘솔에서 요청한 클라이언트·IP·권한을 확인하고 승인한다
  3. 에이전트가 기다렸다가 토큰을 받아 ~/.config/update-hub/auth-header 에 저장한다 (화면에는 토큰 id 앞부분만 나옴)

토큰 형식은 uhp_<16자 id>_<비밀>, 기본 만료 30일, 권한(scope)은 projects(프로젝트·컴포넌트·업로드 토큰·클라이언트 키)와 releases(릴리스 업로드·게시·철회·삭제)입니다. 토큰은 승인한 사람의 권한 안에서만 동작합니다.

이미 로그인돼 있는지 확인

H="$HOME/.config/update-hub/auth-header"
[ -s "$H" ] && curl -fsS -H @"$H" https://update.kimhwan.kr/api/v1/me

200 이면 그대로 쓰세요. 401 이거나 파일이 없으면 아래로 로그인합니다.

도우미 스크립트로 로그인 (권장)

짧은 스크립트이니 원하면 먼저 읽어 보세요. 두 단계로 나뉘어 있어서 첫 단계는 바로 끝나고, 그 사이에 사람에게 코드를 전할 수 있습니다.

curl -fsSL https://update.kimhwan.kr/cli/login.sh -o "${TMPDIR:-/tmp}/uh-login.sh"
bash "${TMPDIR:-/tmp}/uh-login.sh" start "Claude Code - my-app"   # 주소와 코드를 출력하고 끝남
# → 사람에게: "브라우저에서 <주소> 를 열고 코드 <코드> 를 승인해 주세요"
bash "${TMPDIR:-/tmp}/uh-login.sh" wait                                 # 승인될 때까지 기다림 (최대 10분)

wait 는 최대 10분 동안 5초 간격으로 확인하므로 명령 타임아웃을 10분 이상으로 주거나 백그라운드로 실행하세요. status 는 현재 토큰 확인, logout 은 폐기입니다.

PowerShell(Windows):

irm https://update.kimhwan.kr/cli/login.ps1 -OutFile "$env:TEMP\uh-login.ps1"
powershell -ExecutionPolicy Bypass -File "$env:TEMP\uh-login.ps1" start -ClientName "Claude Code - my-app"
powershell -ExecutionPolicy Bypass -File "$env:TEMP\uh-login.ps1" wait

curl 만으로 로그인

도우미를 쓸 수 없을 때. device_code 도 비밀이니 출력하지 말고 변수에만 둡니다.

SITE=https://update.kimhwan.kr; H="$HOME/.config/update-hub/auth-header"; mkdir -p "$(dirname "$H")"
R=$(curl -fsS -X POST "$SITE/api/v1/auth/device/code" --data-urlencode "client_name=Claude Code - my-app" --data-urlencode "scope=projects releases")
DC=$(printf %s "$R" | sed -E 's/.*"device_code":"([^"]+)".*/\1/')
printf %s "$R" | sed -E 's/.*"verification_uri_complete":"([^"]+)".*/승인 주소: \1/'
# 사람이 승인할 때까지 5초 간격으로:
R=$(curl -sS -X POST "$SITE/api/v1/auth/device/token" --data-urlencode "device_code=$DC")
case "$R" in
  *access_token*) (umask 077; printf 'Authorization: Bearer %s\n' "$(printf %s "$R" | sed -E 's/.*"access_token":"([^"]+)".*/\1/')" > "$H"); echo saved ;;
  *) printf %s "$R" | sed -E 's/.*"error":"([^"]+)".*/\1/' ;;   # authorization_pending → 다시, slow_down → 간격 늘리기
esac
응답 error 뜻
authorization_pending 아직 승인 전 — interval(5초) 뒤 다시
slow_down 너무 자주 물음 — 간격을 5초 늘리기
access_denied 사람이 거부함 — 멈추고 이유를 물어보기
expired_token 코드 만료(10분) — 처음부터
invalid_grant 모르는 코드이거나 이미 토큰을 받은 코드

토큰 쓰기

curl -fsS -H @"$HOME/.config/update-hub/auth-header" https://update.kimhwan.kr/api/v1/me
$auth = (Get-Content "$HOME\.config\update-hub\auth-header" -Raw).Trim() -replace '^Authorization:\s*', ''
Invoke-RestMethod https://update.kimhwan.kr/api/v1/me -Headers @{ Authorization = $auth }

사람이 콘솔(액세스 토큰 → 새 토큰)에서 직접 만든 토큰을 같은 파일에 넣어 줄 수도 있습니다. 그때도 채팅으로 받지 말고, 콘솔이 보여 주는 저장 명령을 사람이 자기 터미널에서 실행하게 하세요.

프로젝트 설계

  • 프로젝트 키: 제품 이름을 소문자로 (a-z 0-9 . _ -, 최대 64자). API 경로에 쓰이고 바꿀 수 없습니다.
  • 컴포넌트: "따로 바뀌고 따로 받는 것" 단위로 나눕니다.
    • 실행 파일 하나짜리 앱 → app 하나
    • 런처 + 큰 데이터 → launcher, content (데이터만 바뀔 때 실행 파일을 다시 받지 않게)
    • 플랫폼별 빌드 → app-win-x64, app-linux-x64 처럼 컴포넌트를 나누거나, 한 릴리스 안에서 경로로 나눕니다
  • 릴리스 = 전체 파일 묶음(차분이 아님). 파일마다 SHA-256 이 있으니 앱은 바뀐 파일만 받으면 됩니다.
  • 파일 경로(paths)는 앱이 설치 폴더에 쓸 상대 경로입니다. 예: MyApp.exe, data/pack.bin
  • 버전: A-Z a-z 0-9 . _ + -, 최대 100자, 컴포넌트 안에서 유일. 서버는 버전을 비교하지 않지만, 앱이 다운그레이드를 막으려면 비교 가능한 형식(SemVer, 2026.11.05.1 같은 날짜형)을 쓰세요.
  • metadata: 앱이 읽을 자유 JSON 객체(서명에 포함). 예: {"minLauncher":"2.0.0","releaseNotesUrl":"…"}

프로젝트와 컴포넌트 만들기

H="$HOME/.config/update-hub/auth-header"; API=https://update.kimhwan.kr/api/v1

# 내가 관리하는 프로젝트 목록 (이미 있으면 새로 만들지 말고 그걸 씁니다)
curl -fsS -H @"$H" "$API/manage/projects"

printf '%s' '{"key":"my-app","name":"My App","description":"Windows 데스크톱 앱"}' \
  | curl -fsS -H @"$H" -H 'Content-Type: application/json' --data-binary @- "$API/manage/projects"

printf '%s' '{"key":"app","name":"My App (Windows x64)"}' \
  | curl -fsS -H @"$H" -H 'Content-Type: application/json' --data-binary @- "$API/manage/projects/my-app/components"

curl -fsS -H @"$H" "$API/manage/projects/my-app"     # 상태 확인: components, uploadToken, clientKey, urls

409 {"error":"project exists: …"} 는 이미 있다는 뜻입니다. 남의 프로젝트라면 이후 요청이 404 가 되니, 다른 키를 고르세요.

Windows 주의: Windows 의 curl.exe 는 명령줄 인자를 ANSI 코드 페이지(한국어 Windows 는 CP949)로 받아서, -d '{"name":"한글"}' 처럼 인자에 넣은 한글이 깨지고 400 request body is not valid UTF-8 JSON 이 납니다. 위 예시처럼 JSON 은 printf … | curl --data-binary @- 로 stdin 에 넘기세요. 업로드의 한글 릴리스 노트는 파일에 써 두고 -F "notes=<notes.txt" 로 보내면 됩니다.

업로드 토큰을 CI 시크릿으로

업로드 토큰(uh_…)은 그 프로젝트의 릴리스 쓰기 전용이라 CI 에 넣기에 알맞습니다. 응답에 한 번만 나오므로 출력하지 말고 바로 시크릿 저장소로 보내세요. 먼저 GET …/manage/projects/my-app 의 uploadToken 이 false 인지 확인하세요(true 면 규칙 5).

GitHub Actions (gh CLI 로그인 필요):

curl -fsS -X POST -H @"$H" "$API/manage/projects/my-app/upload-token" \
  | sed -E 's/.*"token":"([^"]+)".*/\1/' \
  | gh secret set UPDATE_HUB_UPLOAD_TOKEN --repo OWNER/REPO

Jenkins 등 CLI 로 시크릿을 넣을 수 없는 곳: 파일로 받아 두고 사람에게 등록을 부탁하세요.

(umask 077; curl -fsS -X POST -H @"$H" "$API/manage/projects/my-app/upload-token" \
  | sed -E 's/.*"token":"([^"]+)".*/\1/' > "$HOME/.config/update-hub/my-app-upload-token")
# → 사람에게: "~/.config/update-hub/my-app-upload-token 내용을 Jenkins 'Secret text' 크리덴셜 update-hub-my-app 으로 등록해 주세요"

CI 에서 릴리스 올리기

POST https://files.update.kimhwan.kr/api/v1/projects/{project}/components/{component}/releases
Authorization: Bearer <업로드 토큰 또는 releases 권한의 개인 토큰>
multipart/form-data:
  version   (필수)
  channel   기본 stable
  notes     릴리스 노트
  metadata  JSON 객체 문자열
  publish   true 면 바로 서명·게시 (기본 false = 초안)
  files     파일 (반복)
  paths     각 파일의 경로 (files 와 같은 순서로 반복, 생략 시 파일 이름)

응답 201 은 릴리스 JSON 입니다. 같은 버전이 게시돼 있으면 409, 초안·철회 상태면 덮어씁니다(CI 재실행 안전). 업로드는 https://files.update.kimhwan.kr 로 보내세요(100 MB 넘는 파일도 됨). 큰 파일에는 -H "Expect:" 를 붙이면 빨라집니다.

GitHub Actions:

      - name: Publish to update-hub
        if: startsWith(github.ref, 'refs/tags/v')
        env:
          UPDATE_HUB_UPLOAD_TOKEN: ${{ secrets.UPDATE_HUB_UPLOAD_TOKEN }}
        run: |
          curl -fsS -H "Expect:" -H "Authorization: Bearer $UPDATE_HUB_UPLOAD_TOKEN" \
            -F "version=${GITHUB_REF_NAME#v}" -F channel=stable -F publish=true \
            -F "notes=Release $GITHUB_REF_NAME" \
            -F files=@dist/MyApp.exe -F paths=MyApp.exe \
            https://files.update.kimhwan.kr/api/v1/projects/my-app/components/app/releases

Jenkins:

stage('Publish') {
  steps {
    withCredentials([string(credentialsId: 'update-hub-my-app', variable: 'UH_TOKEN')]) {
      sh '''
        curl -fsS -H "Expect:" -H "Authorization: Bearer $UH_TOKEN" \
          -F version=$BUILD_VERSION -F channel=stable -F publish=true \
          -F files=@dist/MyApp.exe -F paths=MyApp.exe \
          https://files.update.kimhwan.kr/api/v1/projects/my-app/components/app/releases
      '''
    }
  }
}

나중에 게시·철회·삭제:

POST   https://files.update.kimhwan.kr/api/v1/projects/{p}/components/{c}/releases/{version}/publish
POST   https://files.update.kimhwan.kr/api/v1/projects/{p}/components/{c}/releases/{version}/revoke
DELETE https://files.update.kimhwan.kr/api/v1/projects/{p}/components/{c}/releases/{version}

앱에 업데이트 기능 넣기

흐름

  1. GET https://update.kimhwan.kr/api/v1/projects/{project}/manifest?channel=stable — 키 보호 프로젝트면 X-Update-Key 헤더와 정해진 User-Agent 를 함께 보냅니다. 매니페스트와 파일 요청 모두에 X-Install-Id(설치마다 처음 한 번 만든 임의의 값, 8–64자 A-Za-z0-9._:-)와 X-Client-Version(지금 실행 중인 버전)을 붙이세요. 필수는 아니지만, 붙이면 콘솔의 사용 통계가 사용자 수·버전 분포를 설치 단위로 정확히 셉니다(안 붙이면 IP + User-Agent 로 셈). 개인정보가 아닌 임의 값이어야 합니다.
  2. 쓰는 컴포넌트의 latest 가 null 이면 게시된 것이 없음 → 끝.
  3. 서명 검증: signature(base64, DER) 를 signedPayload 의 UTF-8 바이트에 대해 앱에 내장한 공개키로 검증합니다(SHA256withECDSA, P-256). 실패하면 아무것도 설치하지 말고 중단합니다.
  4. signedPayload 를 JSON 으로 파싱해서 그 안의 값만 믿습니다. project·component·channel 이 기대한 값인지 확인하세요 — 모든 프로젝트가 같은 서명키를 쓰므로, 이 확인이 없으면 다른 프로젝트의 정상 서명 릴리스를 바꿔치기 당할 수 있습니다.
  5. version 이 설치된 것과 같으면 최신. 더 낮으면(버전이 비교 가능할 때) 거부합니다(오래된 취약 버전 재생 공격 방지).
  6. payload 의 files 각각: 로컬 파일의 SHA-256 이 같으면 건너뛰고, 다르면 매니페스트의 같은 path 항목 url 에서 받습니다. 링크는 15분 뒤 만료되니(403) 바로 받고, 만료되면 매니페스트를 다시 읽습니다. Range 이어받기를 지원합니다.
  7. 받은 파일의 크기와 SHA-256 을 payload 값과 대조합니다. 다르면 버립니다.
  8. 임시 폴더에 다 받은 뒤 한 번에 교체합니다(폴더 교체·rename). 실행 중인 exe 는 새 이름으로 받고 재시작 때 바꿉니다.

매니페스트 예시

{
  "project": "my-app",
  "channel": "stable",
  "generatedAt": "2026-11-05T03:12:45Z",
  "signingKeyFingerprint": "8e7a6fe0acdd5116",
  "components": [
    {
      "key": "app",
      "name": "My App (Windows x64)",
      "latest": {
        "project": "my-app", "component": "app", "version": "1.4.0", "channel": "stable", "status": "PUBLISHED",
        "notes": "…", "metadata": {"minLauncher": "2.0.0"},
        "createdAt": "2026-11-05T03:10:02Z", "publishedAt": "2026-11-05T03:10:03Z", "totalSize": 48213504,
        "files": [
          {"path": "MyApp.exe", "size": 48213504, "sha256": "9f2c…", "url": "https://files.update.kimhwan.kr/api/v1/projects/my-app/components/app/releases/1.4.0/files/MyApp.exe?exp=1762313265&sig=…"}
        ],
        "signedPayload": "{\"project\":\"my-app\",\"component\":\"app\",\"version\":\"1.4.0\",\"channel\":\"stable\",\"publishedAt\":\"2026-11-05T03:10:03Z\",\"metadata\":{\"minLauncher\":\"2.0.0\"},\"files\":[{\"path\":\"MyApp.exe\",\"size\":48213504,\"sha256\":\"9f2c…\"}]}",
        "signature": "MEUCIQ…"
      }
    }
  ]
}

서명 공개키

앱 소스에 상수로 넣으세요(비밀 아님). 실행 중에 서버에서 받아 쓰면 검증 의미가 없습니다.

MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEB5NseUrIEq9ihVWnkl+Kc6dsq3q5YB1eGDwFLQUIYXfJ6vFOQ1iyb0b7Q892UMjMLXJjtRche0rTRX9WdLHTTA==

fingerprint 8e7a6fe0acdd5116 · SHA256withECDSA (P-256) · SubjectPublicKeyInfo DER, base64 · GET https://update.kimhwan.kr/api/v1/signing-key

C# (.NET 6 이상)

패키지 없이 동작합니다. 업데이트 확인 → 검증 → 바뀐 파일만 받기까지.

using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

public sealed class UpdateClient
{
    const string Server = "https://update.kimhwan.kr";
    // 릴리스 서명 공개키 (update-hub, fingerprint 8e7a6fe0acdd5116)
    const string PublicKeySpki = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEB5NseUrIEq9ihVWnkl+Kc6dsq3q5YB1eGDwFLQUIYXfJ6vFOQ1iyb0b7Q892UMjMLXJjtRche0rTRX9WdLHTTA==";

    readonly string _project, _channel;
    readonly HttpClient _http;

    /// <param name="clientKey">키 보호 프로젝트의 클라이언트 키(uk_…) — 빌드 때 주입, 없으면 null</param>
    /// <param name="installedVersion">지금 실행 중인 버전 (사용 통계의 버전 분포용)</param>
    public UpdateClient(string project, string channel = "stable", string? clientKey = null, string userAgent = "MyApp/1.0",
                        string? installedVersion = null)
    {
        _project = project;
        _channel = channel;
        _http = new HttpClient { Timeout = TimeSpan.FromMinutes(30) };
        _http.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
        if (clientKey != null) _http.DefaultRequestHeaders.Add("X-Update-Key", clientKey);
        _http.DefaultRequestHeaders.Add("X-Install-Id", InstallId());
        if (installedVersion != null) _http.DefaultRequestHeaders.Add("X-Client-Version", installedVersion);
    }

    /// <summary>설치마다 한 번 만들어 두는 임의의 id (사용자 정보가 아님) — 사용 통계가 설치 단위로 셀 수 있게.</summary>
    static string InstallId()
    {
        string file = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "MyApp", "install-id");
        try { if (File.Exists(file)) { string id = File.ReadAllText(file).Trim(); if (id.Length is >= 8 and <= 64) return id; } } catch (IOException) { }
        string fresh = Guid.NewGuid().ToString("N");
        try { Directory.CreateDirectory(Path.GetDirectoryName(file)!); File.WriteAllText(file, fresh); } catch (IOException) { }
        return fresh;
    }

    public sealed record SignedFile(string Path, long Size, string Sha256);
    public sealed record Update(string Component, string Version, JsonElement Metadata,
                                IReadOnlyList<SignedFile> Files, IReadOnlyDictionary<string, string> Urls);

    /// <summary>검증된 최신 릴리스. 채널에 게시된 것이 없으면 null.</summary>
    public async Task<Update?> CheckAsync(string component, CancellationToken ct = default)
    {
        string json = await _http.GetStringAsync($"{Server}/api/v1/projects/{_project}/manifest?channel={_channel}", ct);
        using var doc = JsonDocument.Parse(json);
        JsonElement? latest = null;
        foreach (var c in doc.RootElement.GetProperty("components").EnumerateArray())
            if (c.GetProperty("key").GetString() == component && c.GetProperty("latest").ValueKind == JsonValueKind.Object)
                latest = c.GetProperty("latest");
        if (latest is not { } l) return null;

        string payload = l.GetProperty("signedPayload").GetString()!;
        if (!Verify(payload, l.GetProperty("signature").GetString()!))
            throw new CryptographicException("update signature is invalid");

        using var signed = JsonDocument.Parse(payload);   // 서명된 내용만 믿는다
        var p = signed.RootElement;
        if (p.GetProperty("project").GetString() != _project || p.GetProperty("component").GetString() != component
            || p.GetProperty("channel").GetString() != _channel)
            throw new CryptographicException("signed release is for another project/component/channel");

        var files = p.GetProperty("files").EnumerateArray()
            .Select(f => new SignedFile(f.GetProperty("path").GetString()!, f.GetProperty("size").GetInt64(), f.GetProperty("sha256").GetString()!))
            .ToList();
        var urls = l.GetProperty("files").EnumerateArray()
            .ToDictionary(f => f.GetProperty("path").GetString()!, f => f.GetProperty("url").GetString()!);
        return new Update(component, p.GetProperty("version").GetString()!, p.GetProperty("metadata").Clone(), files, urls);
    }

    /// <summary>설치 폴더와 다른 파일만 받아 staging 폴더에 완성본을 만든다(크기·SHA-256 확인).</summary>
    public async Task StageAsync(Update u, string installDir, string stagingDir, CancellationToken ct = default)
    {
        foreach (var f in u.Files)
        {
            string target = SafePath(stagingDir, f.Path), current = SafePath(installDir, f.Path);
            Directory.CreateDirectory(Path.GetDirectoryName(target)!);
            if (File.Exists(current) && await Sha256Async(current, ct) == f.Sha256) { File.Copy(current, target, true); continue; }

            using (var res = await _http.GetAsync(u.Urls[f.Path], HttpCompletionOption.ResponseHeadersRead, ct))
            {
                res.EnsureSuccessStatusCode();   // 403 = 링크 만료(15분) → CheckAsync 부터 다시
                await using var output = File.Create(target);
                await res.Content.CopyToAsync(output, ct);
            }
            if (new FileInfo(target).Length != f.Size || await Sha256Async(target, ct) != f.Sha256)
            {
                File.Delete(target);
                throw new InvalidDataException($"checksum mismatch: {f.Path}");
            }
        }
    }

    static bool Verify(string payload, string signatureB64)
    {
        using var ecdsa = ECDsa.Create();
        ecdsa.ImportSubjectPublicKeyInfo(Convert.FromBase64String(PublicKeySpki), out _);
        return ecdsa.VerifyData(Encoding.UTF8.GetBytes(payload), Convert.FromBase64String(signatureB64),
                                HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence);
    }

    static async Task<string> Sha256Async(string file, CancellationToken ct)
    {
        using var sha = SHA256.Create();
        await using var s = File.OpenRead(file);
        return Convert.ToHexString(await sha.ComputeHashAsync(s, ct)).ToLowerInvariant();
    }

    static string SafePath(string root, string relative)
    {
        string baseDir = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar;
        string full = Path.GetFullPath(Path.Combine(baseDir, relative.Replace('/', Path.DirectorySeparatorChar)));
        if (!full.StartsWith(baseDir, StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("path escapes the folder: " + relative);
        return full;
    }
}

다른 언어의 서명 검증

모두 signedPayload 의 UTF-8 바이트, base64 DER 서명, base64 SPKI 공개키를 씁니다. 검증 뒤 payload 의 project/component/channel 확인은 똑같이 하세요.

Node.js / Electron:

import { createPublicKey, verify } from 'node:crypto';
const key = createPublicKey({ key: Buffer.from(PUBLIC_KEY_SPKI, 'base64'), format: 'der', type: 'spki' });
const ok = verify('sha256', Buffer.from(release.signedPayload, 'utf8'), key, Buffer.from(release.signature, 'base64'));

Python (cryptography):

import base64
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import ec

key = serialization.load_der_public_key(base64.b64decode(PUBLIC_KEY_SPKI))
key.verify(base64.b64decode(release["signature"]), release["signedPayload"].encode("utf-8"), ec.ECDSA(hashes.SHA256()))  # 실패 시 InvalidSignature

Go:

der, _ := base64.StdEncoding.DecodeString(publicKeySPKI)
pub, _ := x509.ParsePKIXPublicKey(der)
sig, _ := base64.StdEncoding.DecodeString(release.Signature)
sum := sha256.Sum256([]byte(release.SignedPayload))
ok := ecdsa.VerifyASN1(pub.(*ecdsa.PublicKey), sum[:], sig)

Java / Kotlin (JVM, Android):

var key = KeyFactory.getInstance("EC").generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(PUBLIC_KEY_SPKI)));
var s = Signature.getInstance("SHA256withECDSA");
s.initVerify(key);
s.update(release.signedPayload().getBytes(StandardCharsets.UTF_8));
boolean ok = s.verify(Base64.getDecoder().decode(release.signature()));

다운로드를 우리 앱으로만 제한하기 (선택)

기본은 누구나 받을 수 있는 공개 프로젝트입니다. 사람이 원하면 클라이언트 키를 켭니다. 켜면 읽기 API 가 X-Update-Key 헤더 없이는 401 이 되고, 파일 링크는 매니페스트가 준 15분짜리 서명 링크만 통합니다.

# 발급 (응답에 한 번만 나옴) → CI 시크릿으로. 앱 빌드 때 주입하고 소스에 하드코딩하지 마세요.
curl -fsS -X POST -H @"$H" "$API/manage/projects/my-app/client-key" \
  | sed -E 's/.*"key":"([^"]+)".*/\1/' | gh secret set UPDATE_HUB_CLIENT_KEY --repo OWNER/REPO

# User-Agent 패턴 (봇·브라우저를 거르는 가벼운 문)
printf '%s' '{"uaPattern":"^MyApp/"}' | curl -fsS -X PATCH -H @"$H" -H 'Content-Type: application/json' --data-binary @- "$API/manage/projects/my-app"

공개 exe 에 넣은 키는 결국 꺼낼 수 있으므로 이것은 "가벼운 문"입니다. 진짜 보증은 서명 검증입니다. 이미 키가 있는 프로젝트에서 다시 발급하면 기존 키는 "이전 키"가 되어 폐기 전까지 계속 통합니다(배포 중인 앱 보호). 이전 키 폐기는 새 앱이 충분히 퍼진 뒤 사람이 결정합니다.

처음 설치하는 사람에게 주는 링크

업데이트는 앱이 스스로 받지만, 앱이 아직 없는 사람에게는 받을 곳이 필요합니다. 프로젝트 소유자가 콘솔(프로젝트 → 설정 → 공개 다운로드 페이지)에서 켜면, 고른 컴포넌트의 최신 게시 릴리스에서 파일 하나(설치 파일이나 단일 exe)를 클라이언트 키 없이 받을 수 있습니다.

주소 용도
https://update.kimhwan.kr/d/{project} 다운로드 페이지 (버전·크기·SHA-256·다운로드 버튼) — 사람에게 주는 링크
https://files.update.kimhwan.kr/d/{project}/download 파일 직접 받기 (항상 최신, Range 지원) — README·스크립트용
  • 켜는 것은 사람만 할 수 있습니다(다운로드 범위를 넓히는 일이라 토큰으로는 불가). 필요하면 사람에게 켜 달라고 하고, 어떤 컴포넌트·파일을 고를지 알려 주세요.
  • 상태는 GET /api/v1/manage/projects/{p} 의 publicDownload(enabled, component, channel, pageUrl, downloadUrl, version, file)로 확인합니다.
  • 설치 파일을 업데이트 파일과 따로 두려면 installer 같은 컴포넌트를 만들어 CI 가 거기에도 게시하게 하세요. 새 버전을 게시하면 페이지도 자동으로 바뀝니다.
  • 저장소 README 에는 다운로드 페이지 주소를 적으면 됩니다. 매니페스트·업데이트 파일·다른 컴포넌트는 계속 보호됩니다.

첫 릴리스로 확인

개인 토큰(releases 권한)으로도 업로드 API 를 쓸 수 있습니다. 확인용은 초안이나 beta 로:

curl -fsS -H "Expect:" -H @"$H" -F version=0.0.1-test -F channel=beta -F publish=true \
     -F files=@dist/MyApp.exe -F paths=MyApp.exe \
     https://files.update.kimhwan.kr/api/v1/projects/my-app/components/app/releases > /dev/null && echo uploaded
curl -fsS https://update.kimhwan.kr/api/v1/projects/my-app/manifest?channel=beta     # 공개 프로젝트일 때
curl -fsS -H @"$H" "$API/manage/projects/my-app/components/app/releases" # 초안 포함 전체 목록

앱을 beta 채널로 돌려 업데이트가 받아지고 검증되는지 보세요. 끝나면 시험 릴리스를 지웁니다:

curl -fsS -X DELETE -H @"$H" https://files.update.kimhwan.kr/api/v1/projects/my-app/components/app/releases/0.0.1-test

마무리

curl -fsS -X DELETE -H @"$HOME/.config/update-hub/auth-header" https://update.kimhwan.kr/api/v1/auth/token && rm -f "$HOME/.config/update-hub/auth-header"

사람에게 보고할 것: 프로젝트 키와 컴포넌트, 업로드 토큰을 넣은 곳(시크릿 이름), CI 에 추가한 단계, 앱에 넣은 코드 위치, stable 게시 방법, 남겨 둔 시험 릴리스나 확인이 필요한 일.

API 레퍼런스

모든 오류는 {"error": "…"} JSON 입니다. 시간은 ISO-8601(UTC).

인증

요청 설명
POST /api/v1/auth/device/code form: client_name, scope(기본 projects releases) → device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval
POST /api/v1/auth/device/token form: device_code → 200 {access_token, token_type, scope, expires_at, token_id} 또는 400 {error}
DELETE /api/v1/auth/token 지금 쓰는 개인 토큰을 폐기
GET /api/v1/me 토큰의 사용자·권한·만료, 서버 주소, 서명 공개키

관리 (개인 토큰: 읽기는 아무 권한, 쓰기는 projects)

요청 설명
GET /api/v1/manage/projects 내가 관리하는 프로젝트 (관리자는 전체)
POST /api/v1/manage/projects JSON {key, name, description} → 201
GET /api/v1/manage/projects/{p} 컴포넌트(채널별 최신 버전), uploadToken, clientKey, uaPattern, urls
PATCH /api/v1/manage/projects/{p} JSON {name?, description?, uaPattern?} ("" = 지우기)
POST /api/v1/manage/projects/{p}/components JSON {key, name, description} → 201
POST /api/v1/manage/projects/{p}/upload-token {token} 한 번만. 이전 업로드 토큰은 즉시 무효
POST /api/v1/manage/projects/{p}/client-key {key, id, previousId?} 한 번만. 이전 키는 계속 유효
POST /api/v1/manage/projects/{p}/client-key/revoke-previous 이전 클라이언트 키 폐기
GET /api/v1/manage/projects/{p}/components/{c}/releases 초안·철회 포함 전체 릴리스
GET /api/v1/manage/projects/{p}/usage?days=30 사용 통계: 업데이트 확인·다운로드 시작/완료·전송량·고유 클라이언트(기간·전체), 일별 추이, 버전별, 많이 받은 클라이언트. days = 1 · 7 · 30 · 90 · 0(전체)
GET /api/v1/manage/projects/{p}/clients?q=&sort=&active=&page=&size= 클라이언트별 누적(확인·다운로드·전송량·가진 버전·마지막 IP). sort = bytes · downloads · checks · recent · first
GET /api/v1/manage/projects/{p}/downloads?kind=&result=&component=&version=&client=&q=&from=&to=&page=&size= 상세 로그(업데이트 확인·파일 응답 하나하나, 실제 전송 바이트·완료 여부). kind = file · check, result = ok · completed · partial · rejected, 날짜는 YYYY-MM-DD

릴리스 쓰기 (업로드 토큰, 또는 releases 권한의 개인 토큰)

요청 설명
POST /api/v1/projects/{p}/components/{c}/releases multipart 업로드 (CI 참고) → 201 릴리스
POST /api/v1/projects/{p}/components/{c}/releases/{v}/publish 서명·게시 (이 채널의 최신이 됨)
POST /api/v1/projects/{p}/components/{c}/releases/{v}/revoke 철회 (이전 게시본이 최신이 됨)
DELETE /api/v1/projects/{p}/components/{c}/releases/{v} 삭제 (파일 포함)
POST /api/v1/projects/{p}/components form key, name, description (업로드 토큰 또는 projects 권한)

앱이 읽는 API (인증 없음, 키 보호 프로젝트는 X-Update-Key)

요청 설명
GET /api/v1/projects/{p}/manifest?channel=stable 모든 컴포넌트의 최신 릴리스 (latest: null = 게시 없음)
GET /api/v1/projects/{p}/components/{c}/latest?channel=stable 한 컴포넌트의 최신 (없으면 404)
GET /api/v1/projects/{p}/components/{c}/releases?channel= 게시된 릴리스 목록
GET /api/v1/projects/{p}/components/{c}/releases/{v} 특정 릴리스
GET …/releases/{v}/files/{path}?exp=&sig= 파일. Range 지원, X-Sha256 헤더
GET /api/v1/signing-key 서명 공개키 + fingerprint

문제 해결

상태 흔한 원인
401 토큰이 없거나 만료·폐기됨 → 다시 로그인. 앱 쪽이면 X-Update-Key 누락
403 토큰 권한(scope) 부족, 업로드 토큰을 관리 API 에 씀, User-Agent 패턴 불일치, 파일 링크 만료(매니페스트 다시 읽기)
404 키 오타, 또는 내가 관리하지 않는 프로젝트(남의 프로젝트는 존재 여부도 보이지 않음)
409 이미 있는 프로젝트·컴포넌트, 또는 이미 게시된 버전
413 업로드가 너무 큼 — https://update.kimhwan.kr 대신 https://files.update.kimhwan.kr 로 올리기
415 / 400 관리 API 에 Content-Type: application/json 누락, JSON 오류(Windows 에서 인자로 넘긴 한글 — stdin 으로), 잘못된 키·버전 형식
429 요청이 너무 많음 (IP 당 분당 API 120회, 파일 60회) — 잠시 뒤 다시