Files
felhom-agent/internal/signedjobs/mint_test.go
T
admin 8033a522cd feat: D1 Part 2 — agent self-update Go plumbing (op class, opsign, executor, commit, report)
- reconcile: ClassAgentUpdate op class; always Destructive (no provenance
  blesses replacing the root-adjacent binary). classify test + companion
  (TestClassify_AgentUpdateAlwaysDestructive).
- opsign: `-op agent_update` with -agent-version + -sha256 (isHex64-validated);
  params {version,sha256}. isHex64 test (Group D).
- config: SelfUpdateConfig{URLTemplate,Username,Token,StateDir,DwellSeconds}
  + WithDefaults + Token redaction.
- internal/selfupdate: Executor (download → verify vs the SIGNED sha → sudo -n
  wrapper `apply`; sha is the only integrity root — mismatch refuses + removes,
  agent untouched); Manager (startup dwell → `commit`; version-mismatch → no
  commit + loud WARN + marker left for report visibility; shutdown-before-dwell
  leaves pending). WrapperRunner seam → tests never shell out.
- hub report: additive selfupdate_pending(+version) via SetSelfUpdateReporter
  seam; both omitempty (Wireguard precedent) so the cross-repo golden contract
  stays byte-stable — no hub change.
- capability manifest: 3 non-critical FELHOM_SELFUPDATE probes.
- main.go: updateExec appended to the executor chain; commit-manager wired to
  the report seam + MaybeCommit goroutine after core init.

Tests: Group A (executor happy/sha-mismatch+companion/bad-params/wrapper-fail),
B (agent_update rides the real gate: pinned-key executes, non-pinned +
retarget rejected), C (commit/version-mismatch/no-pending/shutdown), D (opsign).
C2 companion red-proof verified (neutered Go verify → bad binary reaches apply
→ test fails), reverted. Full go test ./... green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
2026-07-05 15:32:15 +02:00

121 lines
3.5 KiB
Go

package signedjobs
import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"encoding/pem"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-agent/internal/authz"
"gitea.dooplex.hu/admin/felhom-agent/internal/hub"
"golang.org/x/crypto/ssh"
)
// In-test SSHSIG minter (mirrors internal/reconcile/mint_test.go's framing) so the runner tests
// exercise the REAL authz.Verifier + reconcile.Gate over genuinely-signed blobs — a positive case
// verifying proves the framing, and the adversarial cases (non-pinned/replay/expired/retarget/
// forged) exercise the real rejection path end-to-end. Production authz stays verify-only.
const sshsigMagic = "SSHSIG"
type sshsigBlob struct {
Version uint32
PublicKey string
Namespace string
Reserved string
HashAlgo string
Signature string
}
func signedDataForTest(ns string, msg []byte) []byte {
h := sha256Sum(msg)
body := ssh.Marshal(struct {
Namespace string
Reserved string
HashAlgo string
Hash []byte
}{ns, "", "sha256", h})
return append([]byte(sshsigMagic), body...)
}
func mintArmor(pubMarshaled []byte, namespace string, message []byte, sign func([]byte) ssh.Signature) []byte {
sb := &sshsigBlob{Version: 1, PublicKey: string(pubMarshaled), Namespace: namespace, Reserved: "", HashAlgo: "sha256"}
sig := sign(signedDataForTest(namespace, message))
sb.Signature = string(ssh.Marshal(&sig))
raw := append([]byte(sshsigMagic), ssh.Marshal(sb)...)
return pem.EncodeToMemory(&pem.Block{Type: "SSH SIGNATURE", Bytes: raw})
}
type testSigner struct {
pub ssh.PublicKey
line string
sign func([]byte) ssh.Signature
}
func newTestSigner(t *testing.T) testSigner {
t.Helper()
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
sshPub, err := ssh.NewPublicKey(pub)
if err != nil {
t.Fatal(err)
}
return testSigner{
pub: sshPub,
line: string(ssh.MarshalAuthorizedKey(sshPub)),
sign: func(d []byte) ssh.Signature {
return ssh.Signature{Format: ssh.KeyAlgoED25519, Blob: ed25519.Sign(priv, d)}
},
}
}
func (s testSigner) allowed(t *testing.T, keyID string, role authz.KeyRole) authz.AllowedSigner {
t.Helper()
as, err := authz.NewAllowedSigner(keyID, role, s.line)
if err != nil {
t.Fatalf("NewAllowedSigner: %v", err)
}
return as
}
// mintJob builds a hub.JobWire carrying a signed storage_wipe envelope from the given signer.
func mintJob(t *testing.T, s testSigner, jobID, host, guest, keyID, paramsJSON string, issued, expires time.Time) hub.JobWire {
return mintJobOp(t, s, "storage_wipe", jobID, host, guest, keyID, paramsJSON, issued, expires)
}
// mintJobOp is mintJob with an explicit op class (for non-wipe ops, e.g. agent_update ride-along).
func mintJobOp(t *testing.T, s testSigner, op, jobID, host, guest, keyID, paramsJSON string, issued, expires time.Time) hub.JobWire {
t.Helper()
blob, err := authz.CanonicalBlob(op, host, guest, keyID, randNonce(), paramsJSON, issued, expires)
if err != nil {
t.Fatalf("CanonicalBlob: %v", err)
}
sig := mintArmor(s.pub.Marshal(), authz.Namespace, blob, s.sign)
env := Envelope{OpBlobB64: base64.StdEncoding.EncodeToString(blob), SigArmored: string(sig)}
envJSON, _ := json.Marshal(env)
return hub.JobWire{JobID: jobID, BlobB64: base64.StdEncoding.EncodeToString(envJSON)}
}
func randNonce() string {
var b [16]byte
rand.Read(b[:])
const hexd = "0123456789abcdef"
out := make([]byte, 32)
for i, x := range b {
out[i*2] = hexd[x>>4]
out[i*2+1] = hexd[x&0x0f]
}
return string(out)
}
func sha256Sum(b []byte) []byte {
h := sha256.Sum256(b)
return h[:]
}