slice 10B: operator-signed destructive completion (offline key + signing CLI) (v0.16.0)
A destructive op runs ONLY on a pinned-key-verified, nonce-fresh, in-window, host-bound, durable-id-bound operator signature. New cmd/felhom-opsign signs canonical OpBlobs offline via ssh-keygen -Y sign (hardware-ready); the signing key is never in the hub or agent. New internal/signedjobs runner verifies each queued blob through the gate and only on all-pass runs the WipeExecutor, which re-resolves the DURABLE device id + re-inspects (8C) before mkfs — closing the 8C data-bearing-wipe pending_signature gap. New storage durable-device resolution; authz.CanonicalBlob promoted to production. Real-crypto tests assert valid executes and forged/replay/expired/retarget/non-pinned are rejected (executor never called). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,115 @@
|
||||
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 {
|
||||
t.Helper()
|
||||
blob, err := authz.CanonicalBlob("storage_wipe", 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[:]
|
||||
}
|
||||
Reference in New Issue
Block a user