P1-32: server-side encrypted repo creds + push-on-hello
CI / Test (linux/amd64) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / Build (windows/amd64) (push) Has been cancelled
CI / Build (linux/amd64) (push) Has been cancelled
CI / Build (linux/arm64) (push) Has been cancelled

Operator-minted enrollment tokens now carry the repo URL/username/
password as one AEAD blob bound (via additional-data) to the token
hash. ConsumeEnrollmentToken re-encrypts under host_id and writes a
host_credentials row in the same tx as token-burn, so the binding
moves with the credential.

PUT /api/hosts/{id}/repo-credentials lets an operator edit creds
post-enrollment; merges with the existing blob, audits, and pushes
config.update if the agent is connected.

WS handler grows an OnHello hook that the HTTP layer wires to send
the host's decrypted creds as a config.update immediately after the
hello succeeds — synchronously, so a racing command.run lands after
the agent has its repo password.

Schema: 0002_host_credentials.sql adds enc_repo_creds to
enrollment_tokens and a host_credentials table (PK = host_id, FK
ON DELETE CASCADE).

Tests: round-trip token → consume → host_credentials with AAD swap
detection; no-creds path stays compatible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 12:38:35 +01:00
parent e58917106d
commit 0ba56ed30d
11 changed files with 538 additions and 19 deletions
+73 -7
View File
@@ -2,6 +2,8 @@ package store
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
@@ -9,14 +11,25 @@ import (
// CreateEnrollmentToken persists a fresh one-time token. The caller
// has already hashed the raw token; the raw form is returned to the
// operator (printed in the install snippet) and never persisted.
func (s *Store) CreateEnrollmentToken(ctx context.Context, tokenHash string, ttl time.Duration) error {
//
// encRepoCreds is the AEAD-encrypted blob of {repo_url, repo_username,
// repo_password} that ConsumeEnrollmentToken will promote to a
// host_credentials row. Empty string = operator chose to set creds
// later via PUT /api/hosts/{id}/repo-credentials; the agent will
// refuse backup jobs until that lands.
func (s *Store) CreateEnrollmentToken(ctx context.Context, tokenHash string, ttl time.Duration, encRepoCreds string) error {
now := time.Now().UTC()
var enc any = nil
if encRepoCreds != "" {
enc = encRepoCreds
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO enrollment_tokens (token_hash, created_at, expires_at)
VALUES (?, ?, ?)`,
`INSERT INTO enrollment_tokens (token_hash, created_at, expires_at, enc_repo_creds)
VALUES (?, ?, ?, ?)`,
tokenHash,
now.Format(time.RFC3339Nano),
now.Add(ttl).Format(time.RFC3339Nano))
now.Add(ttl).Format(time.RFC3339Nano),
enc)
if err != nil {
return fmt.Errorf("store: create enrollment token: %w", err)
}
@@ -24,11 +37,22 @@ func (s *Store) CreateEnrollmentToken(ctx context.Context, tokenHash string, ttl
}
// ConsumeEnrollmentToken atomically validates a token (must exist,
// not be consumed, not be expired) and marks it consumed by hostID.
// not be consumed, not be expired), marks it consumed by hostID, and
// — if the token carries encrypted repo creds — promotes them to a
// host_credentials row in the same tx. The encrypted blob is
// re-encrypted by the caller with host_id as additional data; we
// don't crack it open here.
//
// Returns ErrNotFound on any failure.
func (s *Store) ConsumeEnrollmentToken(ctx context.Context, tokenHash, hostID string) error {
func (s *Store) ConsumeEnrollmentToken(ctx context.Context, tokenHash, hostID, encRepoCredsForHost string) error {
now := time.Now().UTC().Format(time.RFC3339Nano)
res, err := s.db.ExecContext(ctx,
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("store: consume enrollment token: begin: %w", err)
}
defer func() { _ = tx.Rollback() }()
res, err := tx.ExecContext(ctx,
`UPDATE enrollment_tokens
SET consumed_at = ?, consumed_host = ?
WHERE token_hash = ? AND consumed_at IS NULL AND expires_at > ?`,
@@ -40,9 +64,51 @@ func (s *Store) ConsumeEnrollmentToken(ctx context.Context, tokenHash, hostID st
if n == 0 {
return ErrNotFound
}
if encRepoCredsForHost != "" {
if _, err := tx.ExecContext(ctx,
`INSERT INTO host_credentials (host_id, enc_repo_creds, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(host_id) DO UPDATE SET
enc_repo_creds = excluded.enc_repo_creds,
updated_at = excluded.updated_at`,
hostID, encRepoCredsForHost, now); err != nil {
return fmt.Errorf("store: promote host credentials: %w", err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("store: consume enrollment token: commit: %w", err)
}
return nil
}
// GetEnrollmentTokenCreds returns the encrypted repo-creds blob the
// operator stashed when creating the token, or ("", ErrNotFound) if
// the token is gone / consumed / expired / had no creds attached.
//
// The caller decrypts using token_hash as the AEAD additional data,
// then re-encrypts using host_id as additional data before passing
// to ConsumeEnrollmentToken.
func (s *Store) GetEnrollmentTokenCreds(ctx context.Context, tokenHash string) (string, error) {
now := time.Now().UTC().Format(time.RFC3339Nano)
row := s.db.QueryRowContext(ctx,
`SELECT enc_repo_creds FROM enrollment_tokens
WHERE token_hash = ? AND consumed_at IS NULL AND expires_at > ?`,
tokenHash, now)
var enc sql.NullString
if err := row.Scan(&enc); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
return "", fmt.Errorf("store: get enrollment token creds: %w", err)
}
if !enc.Valid {
return "", nil
}
return enc.String, nil
}
// PurgeExpiredEnrollmentTokens deletes long-expired token rows. Tokens
// retained for ~24h after expiry so audit traces still resolve them.
func (s *Store) PurgeExpiredEnrollmentTokens(ctx context.Context) (int64, error) {
+46
View File
@@ -0,0 +1,46 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
// GetHostCredentials returns the AEAD-encrypted repo creds blob for
// the host, or ("", ErrNotFound) if no credential has ever been set.
// The caller decrypts using host_id as AEAD additional data.
func (s *Store) GetHostCredentials(ctx context.Context, hostID string) (string, error) {
row := s.db.QueryRowContext(ctx,
`SELECT enc_repo_creds FROM host_credentials WHERE host_id = ?`,
hostID)
var enc string
if err := row.Scan(&enc); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
return "", fmt.Errorf("store: get host credentials: %w", err)
}
return enc, nil
}
// SetHostCredentials replaces the host's encrypted repo creds blob.
// The caller has already encrypted using host_id as additional data.
func (s *Store) SetHostCredentials(ctx context.Context, hostID, encRepoCreds string) error {
if encRepoCreds == "" {
return fmt.Errorf("store: empty enc_repo_creds")
}
now := time.Now().UTC().Format(time.RFC3339Nano)
_, err := s.db.ExecContext(ctx,
`INSERT INTO host_credentials (host_id, enc_repo_creds, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(host_id) DO UPDATE SET
enc_repo_creds = excluded.enc_repo_creds,
updated_at = excluded.updated_at`,
hostID, encRepoCreds, now)
if err != nil {
return fmt.Errorf("store: set host credentials: %w", err)
}
return nil
}
@@ -0,0 +1,26 @@
-- 0002_host_credentials.sql
--
-- Repo credentials carried on the enrollment token, then promoted to
-- a per-host row on consume. Pulled forward from Phase 2 so the
-- "Add host" flow is genuinely one-shot — operator supplies repo
-- creds at token-mint time, agent receives them via config.update on
-- first WS connect.
--
-- See spec.md §7.3 for the threat model and tasks.md P1-32 for the
-- end-to-end flow.
-- Token row optionally carries an AEAD-encrypted JSON blob of
-- {repo_url, repo_username, repo_password}. AEAD additional-data
-- binds it to the token_hash so swap attacks between rows fail.
ALTER TABLE enrollment_tokens
ADD COLUMN enc_repo_creds TEXT;
-- Per-host repo credential, replaces the blob from the token row on
-- ConsumeEnrollmentToken. AEAD additional-data binds it to host_id.
-- One row per host; absence means "no creds set yet, agent will
-- refuse backup jobs until the operator sets them via the UI."
CREATE TABLE host_credentials (
host_id TEXT PRIMARY KEY REFERENCES hosts(id) ON DELETE CASCADE,
enc_repo_creds TEXT NOT NULL,
updated_at TEXT NOT NULL
);
+7 -2
View File
@@ -72,8 +72,13 @@ func TestMigrateIsIdempotent(t *testing.T) {
if err := row.Scan(&n); err != nil {
t.Fatalf("scan: %v", err)
}
if n != 1 {
t.Errorf("re-running migrations should not insert duplicate rows; got %d", n)
migs, err := loadMigrations()
if err != nil {
t.Fatalf("load migrations: %v", err)
}
if n != len(migs) {
t.Errorf("re-running migrations should not insert duplicate rows; want %d, got %d",
len(migs), n)
}
}
+3 -3
View File
@@ -137,7 +137,7 @@ func TestEnrollmentTokenSingleUse(t *testing.T) {
ctx := context.Background()
hash := "tok-hash"
if err := s.CreateEnrollmentToken(ctx, hash, time.Hour); err != nil {
if err := s.CreateEnrollmentToken(ctx, hash, time.Hour, ""); err != nil {
t.Fatalf("create: %v", err)
}
@@ -148,11 +148,11 @@ func TestEnrollmentTokenSingleUse(t *testing.T) {
t.Fatalf("insert host: %v", err)
}
if err := s.ConsumeEnrollmentToken(ctx, hash, "h1"); err != nil {
if err := s.ConsumeEnrollmentToken(ctx, hash, "h1", ""); err != nil {
t.Fatalf("consume: %v", err)
}
// Second consume must fail — the whole point of one-time tokens.
if err := s.ConsumeEnrollmentToken(ctx, hash, "h1"); !errors.Is(err, ErrNotFound) {
if err := s.ConsumeEnrollmentToken(ctx, hash, "h1", ""); !errors.Is(err, ErrNotFound) {
t.Errorf("re-consume: want ErrNotFound, got %v", err)
}
}