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
+83 -4
View File
@@ -1,7 +1,9 @@
package http
import (
"context"
"encoding/json"
"fmt"
stdhttp "net/http"
"strings"
"time"
@@ -41,9 +43,18 @@ type enrollResponse struct {
// enrollOperatorRequest creates a one-time enrollment token for an
// operator who is about to install an agent. Authenticated UI route.
//
// Repo creds are required at token-mint time so the agent can run a
// backup the moment it comes online. The trio is JSON-encoded,
// AEAD-encrypted with token_hash as additional data, and stashed on
// the token row. ConsumeEnrollmentToken re-encrypts under host_id
// and writes the host_credentials row in the same tx as token-burn.
type enrollOperatorRequest struct {
HostName string `json:"hostname"`
Tags []string `json:"tags,omitempty"`
HostName string `json:"hostname"`
Tags []string `json:"tags,omitempty"`
RepoURL string `json:"repo_url"`
RepoUsername string `json:"repo_username"`
RepoPassword string `json:"repo_password"`
}
type enrollOperatorResponse struct {
@@ -51,6 +62,15 @@ type enrollOperatorResponse struct {
ExpiresAt time.Time `json:"expires_at"`
}
// repoCredsBlob is the JSON shape of the encrypted repo-creds blob.
// Lives only inside AEAD ciphertext — never on the wire as plaintext
// outside the WS config.update push.
type repoCredsBlob struct {
RepoURL string `json:"repo_url"`
RepoUsername string `json:"repo_username"`
RepoPassword string `json:"repo_password"`
}
// handleAgentEnroll consumes a one-time token, persists a Host row,
// and returns persistent agent credentials. Open endpoint (no
// session) — the token is the credential.
@@ -72,7 +92,18 @@ func (s *Server) handleAgentEnroll(w stdhttp.ResponseWriter, r *stdhttp.Request)
// We do these in two statements; if create-host fails, the token
// is already burned. That's acceptable — operator just regens.
tokHash := auth.HashToken(req.Token)
if err := s.deps.Store.ConsumeEnrollmentToken(r.Context(), tokHash, hostID); err != nil {
// If the token carries repo creds, re-encrypt them under the new
// host_id so the host_credentials row is bound to the host (not
// the token, which is about to disappear).
encForHost, err := s.rebindTokenCreds(r.Context(), tokHash, hostID)
if err != nil {
writeJSONError(w, stdhttp.StatusUnauthorized, "invalid_token",
"token unknown, expired, or already used")
return
}
if err := s.deps.Store.ConsumeEnrollmentToken(r.Context(), tokHash, hostID, encForHost); err != nil {
writeJSONError(w, stdhttp.StatusUnauthorized, "invalid_token",
"token unknown, expired, or already used")
return
@@ -137,14 +168,29 @@ func (s *Server) handleCreateEnrollmentToken(w stdhttp.ResponseWriter, r *stdhtt
writeJSONError(w, stdhttp.StatusBadRequest, "invalid_json", err.Error())
return
}
if req.RepoURL == "" || req.RepoPassword == "" {
writeJSONError(w, stdhttp.StatusBadRequest, "missing_field",
"repo_url and repo_password are required so the agent can run backups on first connect")
return
}
token, err := auth.NewToken()
if err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
return
}
tokHash := auth.HashToken(token)
enc, err := s.encryptRepoCreds(repoCredsBlob{
RepoURL: req.RepoURL, RepoUsername: req.RepoUsername, RepoPassword: req.RepoPassword,
}, []byte("token:"+tokHash))
if err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
return
}
const ttl = time.Hour
if err := s.deps.Store.CreateEnrollmentToken(r.Context(), auth.HashToken(token), ttl); err != nil {
if err := s.deps.Store.CreateEnrollmentToken(r.Context(), tokHash, ttl, enc); err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
return
}
@@ -155,6 +201,39 @@ func (s *Server) handleCreateEnrollmentToken(w stdhttp.ResponseWriter, r *stdhtt
})
}
// rebindTokenCreds decrypts the creds attached to the token (if any),
// re-encrypts under the new host_id, and returns the new ciphertext.
// Empty return = the token had no creds attached, which we treat as
// a hard error today (the operator must supply creds at mint time).
func (s *Server) rebindTokenCreds(ctx context.Context, tokHash, hostID string) (string, error) {
enc, err := s.deps.Store.GetEnrollmentTokenCreds(ctx, tokHash)
if err != nil {
return "", err
}
if enc == "" {
return "", nil
}
plain, err := s.deps.AEAD.Decrypt(enc, []byte("token:"+tokHash))
if err != nil {
return "", fmt.Errorf("decrypt token creds: %w", err)
}
out, err := s.deps.AEAD.Encrypt(plain, []byte("host:"+hostID))
if err != nil {
return "", fmt.Errorf("re-encrypt for host: %w", err)
}
return out, nil
}
// encryptRepoCreds JSON-encodes blob and seals it with the given
// additional-data context.
func (s *Server) encryptRepoCreds(blob repoCredsBlob, ad []byte) (string, error) {
body, err := json.Marshal(blob)
if err != nil {
return "", fmt.Errorf("marshal repo creds: %w", err)
}
return s.deps.AEAD.Encrypt(body, ad)
}
// authedUser returns true iff the request carries a valid session
// cookie. Minimal stub for now; full RBAC middleware lands with
// P4-03.