Sign decision envelopes and close FLEX-WP-0024.
Detached Ed25519 over the canonical envelope with signature omitted. Unsigned is stated, not implied. Testdata fixtures prove verify and tamper failure without minting a production key. FLEX-WP-0025 is finished with the validate check from the previous commit. Assistant: grok Assistant-Session: 01a09dc1-b21e-77e1-919e-fcad2f82b267
This commit is contained in:
parent
c074237aac
commit
127f83da4d
15 changed files with 697 additions and 4 deletions
|
|
@ -382,6 +382,9 @@ func (e *Engine) envelope(ctx context.Context, request, submitted api.CheckReque
|
|||
Now: e.now(),
|
||||
})
|
||||
envelope.ID = decisionID(e.policy.Metadata, request, envelope)
|
||||
if envelope.Signature == nil {
|
||||
envelope.Signature = api.UnsignedEnvelopeSignature()
|
||||
}
|
||||
return envelope
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -72,6 +72,9 @@ func TestCheckUsesExplicitCaringContext(t *testing.T) {
|
|||
if got.Provenance.Caller == nil || got.Provenance.Caller.Mode != "disabled" || got.Provenance.Caller.Principal != "" {
|
||||
t.Errorf("got.Provenance.Caller = %+v; want disabled with no principal", got.Provenance.Caller)
|
||||
}
|
||||
if got.Signature == nil || got.Signature.Mode != api.EnvelopeSignatureUnsigned {
|
||||
t.Errorf("got.Signature = %+v; want unsigned", got.Signature)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCallerProvenanceDoesNotChangeRequestDigest(t *testing.T) {
|
||||
|
|
|
|||
76
internal/sign/sign.go
Normal file
76
internal/sign/sign.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// Package sign attaches a detached Ed25519 signature to a decision envelope
|
||||
// (FLEX-WP-0024). The signed material is json.Marshal of the envelope with
|
||||
// the signature field omitted.
|
||||
package sign
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/netkingdom/flex-auth/pkg/api"
|
||||
)
|
||||
|
||||
const AlgorithmEd25519 = "ed25519"
|
||||
|
||||
// CanonicalBytes is the exact payload that is signed and verified: the
|
||||
// envelope as emitted, with signature absent.
|
||||
func CanonicalBytes(envelope api.DecisionEnvelope) ([]byte, error) {
|
||||
envelope.Signature = nil
|
||||
data, err := json.Marshal(envelope)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal canonical envelope: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func Sign(envelope *api.DecisionEnvelope, private ed25519.PrivateKey, kid string) error {
|
||||
if envelope == nil {
|
||||
return fmt.Errorf("envelope is required")
|
||||
}
|
||||
if len(private) != ed25519.PrivateKeySize {
|
||||
return fmt.Errorf("ed25519 private key has length %d", len(private))
|
||||
}
|
||||
if kid == "" {
|
||||
return fmt.Errorf("kid is required")
|
||||
}
|
||||
payload, err := CanonicalBytes(*envelope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sig := ed25519.Sign(private, payload)
|
||||
envelope.Signature = &api.EnvelopeSignature{
|
||||
Mode: api.EnvelopeSignatureSigned,
|
||||
Alg: AlgorithmEd25519,
|
||||
Kid: kid,
|
||||
Value: base64.RawURLEncoding.EncodeToString(sig),
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Verify(envelope api.DecisionEnvelope, keys map[string]ed25519.PublicKey) error {
|
||||
sig := envelope.Signature
|
||||
if sig == nil || sig.Mode != api.EnvelopeSignatureSigned {
|
||||
return fmt.Errorf("envelope is not signed")
|
||||
}
|
||||
if sig.Alg != AlgorithmEd25519 {
|
||||
return fmt.Errorf("unsupported signature alg %q", sig.Alg)
|
||||
}
|
||||
pub, ok := keys[sig.Kid]
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown signature kid %q", sig.Kid)
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(sig.Value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode signature: %w", err)
|
||||
}
|
||||
payload, err := CanonicalBytes(envelope)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ed25519.Verify(pub, payload, raw) {
|
||||
return fmt.Errorf("signature does not match envelope")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
108
internal/sign/sign_test.go
Normal file
108
internal/sign/sign_test.go
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
package sign_test
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/netkingdom/flex-auth/internal/sign"
|
||||
"github.com/netkingdom/flex-auth/pkg/api"
|
||||
)
|
||||
|
||||
// testdataSeed is a well-known non-production key. It exists so fixtures can
|
||||
// prove verification without an OpenBao lane (FLEX-WP-0024-T02).
|
||||
var testdataSeed = bytes32(0x42)
|
||||
|
||||
func bytes32(b byte) []byte {
|
||||
out := make([]byte, ed25519.SeedSize)
|
||||
for i := range out {
|
||||
out[i] = b
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func fixtureKey() (ed25519.PublicKey, ed25519.PrivateKey) {
|
||||
priv := ed25519.NewKeyFromSeed(testdataSeed)
|
||||
return priv.Public().(ed25519.PublicKey), priv
|
||||
}
|
||||
|
||||
func TestSignAndVerifyRoundTrip(t *testing.T) {
|
||||
pub, priv := fixtureKey()
|
||||
envelope := sampleEnvelope("allow")
|
||||
digest := envelope.Binding.RequestDigest
|
||||
if err := sign.Sign(&envelope, priv, "testdata-ed25519"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if envelope.Binding.RequestDigest != digest {
|
||||
t.Fatalf("request_digest moved from %s to %s", digest, envelope.Binding.RequestDigest)
|
||||
}
|
||||
if envelope.Signature == nil || envelope.Signature.Mode != api.EnvelopeSignatureSigned {
|
||||
t.Fatalf("signature = %+v", envelope.Signature)
|
||||
}
|
||||
if err := sign.Verify(envelope, map[string]ed25519.PublicKey{"testdata-ed25519": pub}); err != nil {
|
||||
t.Fatalf("Verify: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlteredEnvelopeFailsVerification(t *testing.T) {
|
||||
pub, priv := fixtureKey()
|
||||
envelope := sampleEnvelope("allow")
|
||||
if err := sign.Sign(&envelope, priv, "testdata-ed25519"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
envelope.Effect = api.DecisionEffectDeny
|
||||
if err := sign.Verify(envelope, map[string]ed25519.PublicKey{"testdata-ed25519": pub}); err == nil {
|
||||
t.Fatal("tampered envelope verified")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsignedEnvelopeDoesNotVerify(t *testing.T) {
|
||||
pub, _ := fixtureKey()
|
||||
envelope := sampleEnvelope("allow")
|
||||
envelope.Signature = api.UnsignedEnvelopeSignature()
|
||||
if err := sign.Verify(envelope, map[string]ed25519.PublicKey{"testdata-ed25519": pub}); err == nil {
|
||||
t.Fatal("unsigned envelope verified")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplayFixturesProveSuccessAndFailure(t *testing.T) {
|
||||
pub, _ := fixtureKey()
|
||||
keys := map[string]ed25519.PublicKey{"testdata-ed25519": pub}
|
||||
|
||||
signed := loadEnvelope(t, filepath.Join("..", "..", "examples", "secrets-engine", "replay", "decision_rotate_signed.json"))
|
||||
if err := sign.Verify(signed, keys); err != nil {
|
||||
t.Fatalf("genuine fixture: %v", err)
|
||||
}
|
||||
tampered := loadEnvelope(t, filepath.Join("..", "..", "examples", "secrets-engine", "replay", "decision_rotate_signed_tampered.json"))
|
||||
if err := sign.Verify(tampered, keys); err == nil {
|
||||
t.Fatal("tampered fixture verified")
|
||||
}
|
||||
}
|
||||
|
||||
func sampleEnvelope(effect string) api.DecisionEnvelope {
|
||||
return api.DecisionEnvelope{
|
||||
ID: "decision:test",
|
||||
ContractVersion: api.DecisionRecordContractV1,
|
||||
Effect: api.DecisionEffect(effect),
|
||||
Resource: api.ResourceRef{ID: "lane:test", System: "secrets-engine"},
|
||||
Subject: api.SubjectRef{ID: "secrets-engine", Type: "service"},
|
||||
Binding: &api.DecisionBinding{RequestDigest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Action: "rotate"},
|
||||
Provenance: api.DecisionProvenance{Evaluator: "flex-auth/local", Mode: "standalone"},
|
||||
Signature: api.UnsignedEnvelopeSignature(),
|
||||
}
|
||||
}
|
||||
|
||||
func loadEnvelope(t *testing.T, path string) api.DecisionEnvelope {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var envelope api.DecisionEnvelope
|
||||
if err := json.Unmarshal(data, &envelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return envelope
|
||||
}
|
||||
45
internal/sign/write_fixtures_test.go
Normal file
45
internal/sign/write_fixtures_test.go
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
package sign_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/netkingdom/flex-auth/internal/sign"
|
||||
"github.com/netkingdom/flex-auth/pkg/api"
|
||||
)
|
||||
|
||||
func TestWriteReplaySignatureFixtures(t *testing.T) {
|
||||
if os.Getenv("UPDATE_FIXTURES") != "1" {
|
||||
t.Skip("set UPDATE_FIXTURES=1 to regenerate signed replay fixtures")
|
||||
}
|
||||
dir := filepath.Join("..", "..", "examples", "secrets-engine", "replay")
|
||||
data, err := os.ReadFile(filepath.Join(dir, "decision_rotate.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var envelope api.DecisionEnvelope
|
||||
if err := json.Unmarshal(data, &envelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, priv := fixtureKey()
|
||||
if err := sign.Sign(&envelope, priv, "testdata-ed25519"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeJSON(t, filepath.Join(dir, "decision_rotate_signed.json"), envelope)
|
||||
envelope.Effect = api.DecisionEffectDeny
|
||||
envelope.Reason = "tampered_after_signing"
|
||||
writeJSON(t, filepath.Join(dir, "decision_rotate_signed_tampered.json"), envelope)
|
||||
}
|
||||
|
||||
func writeJSON(t *testing.T, path string, value any) {
|
||||
t.Helper()
|
||||
data, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue