Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a140c4-354a-7823-8f0d-8f717397f786
95 lines
2.7 KiB
Go
95 lines
2.7 KiB
Go
// 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 (
|
|
"bytes"
|
|
"crypto/ed25519"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
|
|
"github.com/netkingdom/access-engine/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
|
|
}
|
|
|
|
// DecodeEnvelope preserves JSON numeric kinds/lexemes emitted by task inputs.
|
|
// Legacy producer envelopes remain byte-compatible. It does not invent another
|
|
// signature canonicalization: CanonicalBytes still calls typed json.Marshal.
|
|
func DecodeEnvelope(raw []byte) (api.DecisionEnvelope, error) {
|
|
var envelope api.DecisionEnvelope
|
|
decoder := json.NewDecoder(bytes.NewReader(raw))
|
|
decoder.UseNumber()
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(&envelope); err != nil {
|
|
return envelope, err
|
|
}
|
|
if _, err := decoder.Token(); err != io.EOF {
|
|
return api.DecisionEnvelope{}, fmt.Errorf("trailing envelope data")
|
|
}
|
|
return envelope, nil
|
|
}
|