summaryrefslogtreecommitdiffstats
path: root/pkg/identity/identity.go
diff options
context:
space:
mode:
Diffstat (limited to 'pkg/identity/identity.go')
-rw-r--r--pkg/identity/identity.go254
1 files changed, 254 insertions, 0 deletions
diff --git a/pkg/identity/identity.go b/pkg/identity/identity.go
new file mode 100644
index 0000000..be48a44
--- /dev/null
+++ b/pkg/identity/identity.go
@@ -0,0 +1,254 @@
+// Package identity provides persistent cryptographic identity management for NoshiTalk.
+package identity
+
+import (
+ "crypto/ecdh"
+ cryptorand "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "noshitalk/pkg/crypto"
+)
+
+const (
+ // DefaultKeyDir is the default directory for storing keys
+ DefaultKeyDir = ".noshitalk"
+
+ // IdentityVersion is the current identity file format version
+ IdentityVersion = "1.0"
+)
+
+// Identity represents a user's cryptographic identity.
+type Identity struct {
+ PrivateKey *ecdh.PrivateKey
+ PublicKey *ecdh.PublicKey
+ Username string
+ Fingerprint string
+}
+
+// IdentityFile represents the on-disk format for storing identity.
+type IdentityFile struct {
+ Version string `json:"version"`
+ Username string `json:"username"`
+ Fingerprint string `json:"fingerprint"`
+ SignKeyPair KeyPair `json:"signKeyPair,omitempty"`
+ EncryptKeyPair KeyPair `json:"encryptKeyPair,omitempty"`
+ PrivateKey string `json:"privateKey,omitempty"` // For simple format
+}
+
+// KeyPair represents a public/private key pair in hex encoding.
+type KeyPair struct {
+ PrivateKey string `json:"privateKey"`
+ PublicKey string `json:"publicKey"`
+}
+
+// GetDefaultKeyPath returns the default path for storing identity keys.
+func GetDefaultKeyPath(filename string) (string, error) {
+ homeDir, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("failed to get home directory: %w", err)
+ }
+ return filepath.Join(homeDir, DefaultKeyDir, filename), nil
+}
+
+// New generates a new random identity.
+func New(usernamePrefix string) (*Identity, error) {
+ privateKey, err := crypto.GenerateX25519KeyPair()
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate key pair: %w", err)
+ }
+
+ publicKey := privateKey.PublicKey()
+ fingerprint := crypto.DeriveFingerprint(publicKey.Bytes())
+ username := fmt.Sprintf("%s_%s", usernamePrefix, fingerprint[:8])
+
+ return &Identity{
+ PrivateKey: privateKey,
+ PublicKey: publicKey,
+ Username: username,
+ Fingerprint: fingerprint,
+ }, nil
+}
+
+// LoadOrCreate loads an existing identity from file or creates a new one.
+func LoadOrCreate(keyPath, usernamePrefix string) (*Identity, error) {
+ // Try to load existing
+ if _, err := os.Stat(keyPath); err == nil {
+ return Load(keyPath)
+ }
+
+ // Generate new
+ identity, err := New(usernamePrefix)
+ if err != nil {
+ return nil, err
+ }
+
+ // Save it
+ if err := identity.Save(keyPath); err != nil {
+ return nil, fmt.Errorf("failed to save new identity: %w", err)
+ }
+
+ return identity, nil
+}
+
+// Load loads an identity from a file.
+// Supports both simple format (just private key bytes) and JSON format.
+func Load(keyPath string) (*Identity, error) {
+ data, err := os.ReadFile(keyPath)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read identity file: %w", err)
+ }
+
+ curve := ecdh.X25519()
+
+ // Try JSON format first
+ var idFile IdentityFile
+ if err := json.Unmarshal(data, &idFile); err == nil {
+ // JSON format - check for different key storage formats
+ var privateKeyBytes []byte
+
+ if idFile.EncryptKeyPair.PrivateKey != "" {
+ // GUI client format
+ privateKeyBytes, err = hex.DecodeString(idFile.EncryptKeyPair.PrivateKey)
+ if err != nil {
+ return nil, fmt.Errorf("failed to decode encrypt private key: %w", err)
+ }
+ } else if idFile.SignKeyPair.PrivateKey != "" {
+ // Alternative format
+ privateKeyBytes, err = hex.DecodeString(idFile.SignKeyPair.PrivateKey)
+ if err != nil {
+ return nil, fmt.Errorf("failed to decode sign private key: %w", err)
+ }
+ } else if idFile.PrivateKey != "" {
+ // Simple JSON format
+ privateKeyBytes, err = hex.DecodeString(idFile.PrivateKey)
+ if err != nil {
+ return nil, fmt.Errorf("failed to decode private key: %w", err)
+ }
+ } else {
+ return nil, fmt.Errorf("no private key found in identity file")
+ }
+
+ privateKey, err := curve.NewPrivateKey(privateKeyBytes)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse private key: %w", err)
+ }
+
+ publicKey := privateKey.PublicKey()
+ fingerprint := crypto.DeriveFingerprint(publicKey.Bytes())
+
+ username := idFile.Username
+ if username == "" {
+ username = crypto.DeriveIdentity(publicKey.Bytes())
+ }
+
+ return &Identity{
+ PrivateKey: privateKey,
+ PublicKey: publicKey,
+ Username: username,
+ Fingerprint: fingerprint,
+ }, nil
+ }
+
+ // Try raw binary format (CLI client format)
+ if len(data) == 32 {
+ privateKey, err := curve.NewPrivateKey(data)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse raw private key: %w", err)
+ }
+
+ publicKey := privateKey.PublicKey()
+ fingerprint := crypto.DeriveFingerprint(publicKey.Bytes())
+ username := crypto.DeriveIdentity(publicKey.Bytes())
+
+ return &Identity{
+ PrivateKey: privateKey,
+ PublicKey: publicKey,
+ Username: username,
+ Fingerprint: fingerprint,
+ }, nil
+ }
+
+ return nil, fmt.Errorf("unrecognized identity file format")
+}
+
+// Save saves the identity to a file in JSON format.
+func (id *Identity) Save(keyPath string) error {
+ // Create directory if needed
+ dir := filepath.Dir(keyPath)
+ if err := os.MkdirAll(dir, 0700); err != nil {
+ return fmt.Errorf("failed to create directory: %w", err)
+ }
+
+ idFile := IdentityFile{
+ Version: IdentityVersion,
+ Username: id.Username,
+ Fingerprint: id.Fingerprint,
+ EncryptKeyPair: KeyPair{
+ PrivateKey: hex.EncodeToString(id.PrivateKey.Bytes()),
+ PublicKey: hex.EncodeToString(id.PublicKey.Bytes()),
+ },
+ }
+
+ data, err := json.MarshalIndent(idFile, "", " ")
+ if err != nil {
+ return fmt.Errorf("failed to marshal identity: %w", err)
+ }
+
+ if err := os.WriteFile(keyPath, data, 0600); err != nil {
+ return fmt.Errorf("failed to write identity file: %w", err)
+ }
+
+ return nil
+}
+
+// SaveRaw saves only the private key bytes (for CLI client compatibility).
+func (id *Identity) SaveRaw(keyPath string) error {
+ dir := filepath.Dir(keyPath)
+ if err := os.MkdirAll(dir, 0700); err != nil {
+ return fmt.Errorf("failed to create directory: %w", err)
+ }
+
+ if err := os.WriteFile(keyPath, id.PrivateKey.Bytes(), 0600); err != nil {
+ return fmt.Errorf("failed to write key file: %w", err)
+ }
+
+ return nil
+}
+
+// DeriveSharedIdentity derives a deterministic identity from a seed.
+// This is useful for testing or when you want reproducible identities.
+func DeriveSharedIdentity(seed string, usernamePrefix string) (*Identity, error) {
+ // Hash the seed to get 32 bytes
+ hash := sha256.Sum256([]byte(seed))
+
+ curve := ecdh.X25519()
+ privateKey, err := curve.NewPrivateKey(hash[:])
+ if err != nil {
+ return nil, fmt.Errorf("failed to derive private key: %w", err)
+ }
+
+ publicKey := privateKey.PublicKey()
+ fingerprint := crypto.DeriveFingerprint(publicKey.Bytes())
+ username := fmt.Sprintf("%s_%s", usernamePrefix, fingerprint[:8])
+
+ return &Identity{
+ PrivateKey: privateKey,
+ PublicKey: publicKey,
+ Username: username,
+ Fingerprint: fingerprint,
+ }, nil
+}
+
+// GenerateChallenge generates a random challenge for authentication.
+func GenerateChallenge() ([]byte, error) {
+ challenge := make([]byte, 32)
+ if _, err := cryptorand.Read(challenge); err != nil {
+ return nil, fmt.Errorf("failed to generate challenge: %w", err)
+ }
+ return challenge, nil
+}