package steme import ( "crypto/ed25519" "crypto/rand" "encoding/hex" "fmt" "os" "time" ) // Signer handles Ed25519 signing for assertions. // // All assertions submitted to StemeDB must be signed by at least one agent. // The Signer abstracts the cryptographic details and provides a simple API. type Signer struct { privateKey ed25519.PrivateKey publicKey ed25519.PublicKey } // NewSigner creates a Signer from a 32-byte Ed25519 private key seed. // // The seed should be exactly 32 bytes. The Ed25519 private key is derived // from this seed, and the public key is computed from the private key. // // Returns ErrInvalidKeySize if the seed is not 32 bytes. func NewSigner(seed []byte) (*Signer, error) { if len(seed) != ed25519.SeedSize { return nil, fmt.Errorf("%w: got %d bytes", ErrInvalidKeySize, len(seed)) } privateKey := ed25519.NewKeyFromSeed(seed) publicKey := privateKey.Public().(ed25519.PublicKey) return &Signer{ privateKey: privateKey, publicKey: publicKey, }, nil } // NewSignerFromHex creates a Signer from a hex-encoded 32-byte seed. // // The hex string should be exactly 64 characters (32 bytes). // // Returns ErrInvalidHex if hex decoding fails. // Returns ErrInvalidKeySize if the decoded seed is not 32 bytes. func NewSignerFromHex(hexSeed string) (*Signer, error) { seed, err := hex.DecodeString(hexSeed) if err != nil { return nil, fmt.Errorf("%w: %v", ErrInvalidHex, err) } return NewSigner(seed) } // GenerateSigner generates a new random Ed25519 keypair. // // This is useful for testing or creating new agent identities. // The private key seed can be retrieved with Seed() for persistence. func GenerateSigner() (*Signer, error) { publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) if err != nil { return nil, fmt.Errorf("steme: failed to generate keypair: %w", err) } return &Signer{ privateKey: privateKey, publicKey: publicKey, }, nil } // SignerFromEnv creates a Signer from an environment variable. // // The environment variable should contain a hex-encoded 32-byte seed. // // Returns an error if the variable is not set, contains invalid hex, // or is not exactly 32 bytes when decoded. func SignerFromEnv(envVar string) (*Signer, error) { hexSeed := os.Getenv(envVar) if hexSeed == "" { return nil, fmt.Errorf("steme: environment variable %s not set", envVar) } return NewSignerFromHex(hexSeed) } // PublicKey returns the hex-encoded Ed25519 public key (32 bytes). // // This is the agent_id used in StemeDB. func (s *Signer) PublicKey() string { return hex.EncodeToString(s.publicKey) } // PublicKeyBytes returns the raw Ed25519 public key bytes. func (s *Signer) PublicKeyBytes() []byte { return s.publicKey } // Seed returns the hex-encoded Ed25519 private key seed (32 bytes). // // This should be stored securely for key recovery. func (s *Signer) Seed() string { // Ed25519 private key is 64 bytes: [32-byte seed][32-byte public key] // Extract the seed portion return hex.EncodeToString(s.privateKey.Seed()) } // Sign creates an Ed25519 signature over the message bytes. // // The signature is 64 bytes and is hex-encoded in the returned string. func (s *Signer) Sign(message []byte) string { signature := ed25519.Sign(s.privateKey, message) return hex.EncodeToString(signature) } // CreateSignature creates a SignatureEntry for an assertion (v1 legacy format). // // The message should be the canonical serialization "{subject}:{predicate}". // This only covers subject and predicate - other fields can be tampered. // For full tamper protection, use CreateSignatureV2. // // The timestamp is set to the current Unix epoch. func (s *Signer) CreateSignature(message []byte) SignatureEntry { timestamp := uint64(time.Now().Unix()) return SignatureEntry{ AgentID: s.PublicKey(), Signature: s.Sign(message), Timestamp: timestamp, Version: 1, } } // CreateSignatureV2 creates a SignatureEntry using v2 enterprise format. // // The contentHash should be the BLAKE3 hash of the serialized assertion // (without signatures). This protects ALL fields from tampering. // // The timestamp is set to the current Unix epoch. func (s *Signer) CreateSignatureV2(contentHash []byte) SignatureEntry { timestamp := uint64(time.Now().Unix()) return SignatureEntry{ AgentID: s.PublicKey(), Signature: s.Sign(contentHash), Timestamp: timestamp, Version: 2, } } // Verify verifies an Ed25519 signature. // // This is useful for validating signatures received from other agents. // // Returns ErrInvalidPublicKeySize if the public key is not 32 bytes. // Returns ErrInvalidSignatureSize if the signature is not 64 bytes. func Verify(publicKeyHex, signatureHex string, message []byte) error { publicKey, err := hex.DecodeString(publicKeyHex) if err != nil { return fmt.Errorf("%w: invalid public key hex: %v", ErrInvalidHex, err) } if len(publicKey) != ed25519.PublicKeySize { return fmt.Errorf("%w: public key is %d bytes", ErrInvalidPublicKeySize, len(publicKey)) } signature, err := hex.DecodeString(signatureHex) if err != nil { return fmt.Errorf("%w: invalid signature hex: %v", ErrInvalidHex, err) } if len(signature) != ed25519.SignatureSize { return fmt.Errorf("%w: signature is %d bytes", ErrInvalidSignatureSize, len(signature)) } if !ed25519.Verify(publicKey, message, signature) { return fmt.Errorf("steme: signature verification failed") } return nil }