Files
restic-manager/internal/server/http/host_credentials_test.go
T
steve 0ba56ed30d
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
P1-32: server-side encrypted repo creds + push-on-hello
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>
2026-05-01 12:38:35 +01:00

103 lines
3.2 KiB
Go

package http
import (
"context"
"encoding/json"
"testing"
)
// TestEnrollmentTransfersRepoCreds verifies the round-trip:
// - operator mints a token with repo_url/username/password
// - encrypted blob lands on the token row, bound to token_hash
// - on consume, the blob is re-encrypted bound to host_id and
// written to host_credentials in the same tx.
func TestEnrollmentTransfersRepoCreds(t *testing.T) {
t.Parallel()
srv, _, st := newTestServerWithHub(t)
ctx := context.Background()
want := repoCredsBlob{
RepoURL: "rest:https://repo.example/host42",
RepoUsername: "host42",
RepoPassword: "hunter2",
}
// Encrypt + create token like the operator endpoint would.
const tokHash = "tok-hash-fixture"
enc, err := srv.encryptRepoCreds(want, []byte("token:"+tokHash))
if err != nil {
t.Fatalf("encrypt: %v", err)
}
if err := st.CreateEnrollmentToken(ctx, tokHash, 1<<20, enc); err != nil {
t.Fatalf("create token: %v", err)
}
// Rebind under host_id, then consume (this is what the agent
// enroll handler does inline).
const hostID = "h-fixture"
encForHost, err := srv.rebindTokenCreds(ctx, tokHash, hostID)
if err != nil {
t.Fatalf("rebind: %v", err)
}
if encForHost == "" {
t.Fatal("rebind returned empty blob; expected re-encrypted ciphertext")
}
if encForHost == enc {
t.Errorf("rebind should change ciphertext (additional-data differs); got identical")
}
// Need a host row for the FK.
if _, err := st.DB().Exec(
`INSERT INTO hosts (id, name, os, arch, enrolled_at) VALUES (?,?,?,?,?)`,
hostID, "host42", "linux", "amd64", "2026-01-01T00:00:00Z"); err != nil {
t.Fatalf("insert host: %v", err)
}
if err := st.ConsumeEnrollmentToken(ctx, tokHash, hostID, encForHost); err != nil {
t.Fatalf("consume: %v", err)
}
// host_credentials row should now hold the host-bound ciphertext.
got, err := st.GetHostCredentials(ctx, hostID)
if err != nil {
t.Fatalf("get host creds: %v", err)
}
plain, err := srv.deps.AEAD.Decrypt(got, []byte("host:"+hostID))
if err != nil {
t.Fatalf("decrypt: %v", err)
}
var blob repoCredsBlob
if err := json.Unmarshal(plain, &blob); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if blob != want {
t.Errorf("blob mismatch:\n got %+v\nwant %+v", blob, want)
}
// Cross-check: decrypting with a wrong AD must fail (swap
// detection — proves the AAD binding is doing real work).
if _, err := srv.deps.AEAD.Decrypt(got, []byte("host:other-host")); err == nil {
t.Error("decrypt with wrong AD must fail; AAD binding is broken")
}
}
// TestEnrollmentTokenWithoutCreds is the regression that ensures the
// existing ttl/single-use semantics still work when no creds are
// attached (used by the enrollment_test.go fixture path).
func TestEnrollmentTokenWithoutCreds(t *testing.T) {
t.Parallel()
_, _, st := newTestServerWithHub(t)
ctx := context.Background()
const tokHash = "no-creds-token"
if err := st.CreateEnrollmentToken(ctx, tokHash, 1<<20, ""); err != nil {
t.Fatalf("create: %v", err)
}
enc, err := st.GetEnrollmentTokenCreds(ctx, tokHash)
if err != nil {
t.Fatalf("get token creds: %v", err)
}
if enc != "" {
t.Errorf("token without creds should return empty blob; got %q", enc)
}
}