stemedb/sdk/go/steme/signer.go
jordan 137a588ed0 feat: Concept hierarchy (Phase 5D) - ConceptPath, source schemes, AliasStore
Implements hierarchical subject identifiers with scheme-based source tier inference:

- ConceptPath type with parse/wire_format, leaf/parent, prefix matching
- SourceScheme registry mapping schemes to default SourceClass tiers:
  - rfc://, fda://, ietf:// → Regulatory (Tier 0)
  - peer://, pubmed:// → PeerReviewed (Tier 1)
  - code://, wiki:// → Expert (Tier 3)
  - blog://, anon:// → Anecdotal (Tier 5)
- AliasStore for cross-scheme entity resolution (bidirectional indexing)
- API endpoints for concept operations
- Battery tests 8, 9 & 10 for concepts, aliases, and advanced signatures
- Go SDK updates for concept types and signing

Completes Phase 5, advancing to Phase 6 (Distributed Writes).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 17:44:54 -07:00

182 lines
5.3 KiB
Go

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
}