package files import ( "context" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io/fs" "os" "path/filepath" "strings" "sync" "github.com/bluesky-social/indigo/atproto/atcrypto" "github.com/bluesky-social/indigo/atproto/syntax" atpds "github.com/johnandersen777/socialweb-computer-kcp/internal/atproto/pds" ) var ( _ atpds.RepoKeyStore = (*FileRepoKeyStore)(nil) _ atpds.RepoKeyStore = (*JSONFileRepoKeyStore)(nil) ) func Path(raw string) string { raw = strings.TrimSpace(raw) for _, prefix := range []string{"file://", "file:"} { if after, ok := strings.CutPrefix(raw, prefix); ok { return after } } return raw } func RepoKeyStore(repoKeyPath string) (atpds.RepoKeyStore, error) { raw := strings.TrimSpace(repoKeyPath) if raw == "" { return nil, nil } path := Path(raw) if info, err := os.Stat(path); err == nil { if info.IsDir() { return NewFileRepoKeyStore(path) } return NewJSONFileRepoKeyStore(path) } if strings.HasSuffix(raw, string(os.PathSeparator)) || filepath.Ext(path) == "" { return NewFileRepoKeyStore(path) } return NewJSONFileRepoKeyStore(path) } func SessionSecret(stateDir string, path string) ([]byte, error) { if strings.TrimSpace(path) != "" { data, err := pathData(path) if err != nil { return nil, fmt.Errorf("pds: resolving the session secret: %w", err) } return sessionSecretFromData(data) } file := filepath.Join(stateDir, "session-secret") data, err := os.ReadFile(file) if err == nil { secret, decodeErr := hex.DecodeString(strings.TrimSpace(string(data))) if decodeErr != nil || len(secret) == 0 { return nil, fmt.Errorf("pds: the persisted session secret is invalid") } return secret, nil } if !errors.Is(err, fs.ErrNotExist) { return nil, fmt.Errorf("pds: reading the session secret: %w", err) } secret := make([]byte, 32) if _, err := rand.Read(secret); err != nil { return nil, fmt.Errorf("pds: generating the session secret: %w", err) } if err := writePrivateFile(file, []byte(hex.EncodeToString(secret)+"\n")); err != nil { return nil, fmt.Errorf("pds: persisting the session secret: %w", err) } return secret, nil } const OAuthSignerFile = "oauth-signing.key" func OAuthSigner(stateDir string) (atcrypto.PrivateKey, error) { return resolvePrivateKey(stateDir, OAuthSignerFile, func() (atcrypto.PrivateKeyExportable, error) { return atcrypto.GeneratePrivateKeyP256() }) } func RotationKey(stateDir string, path string) (atcrypto.PrivateKey, error) { if strings.TrimSpace(path) != "" { data, err := pathData(path) if err != nil { return nil, fmt.Errorf("pds: resolving the rotation key: %w", err) } return privateKeyFromData(data) } return resolvePrivateKey(stateDir, "plc-rotation.key", func() (atcrypto.PrivateKeyExportable, error) { return atcrypto.GeneratePrivateKeyK256() }) } func pathData(raw string) (map[string]any, error) { data, err := os.ReadFile(Path(raw)) if err != nil { return nil, err } out := map[string]any{} if json.Unmarshal(data, &out) == nil && out != nil { return out, nil } return map[string]any{"value": string(data)}, nil } type FileRepoKeyStore struct { dir string } func NewFileRepoKeyStore(dir string) (*FileRepoKeyStore, error) { dir = strings.TrimSpace(dir) if dir == "" { return nil, errors.New("pds: the file repo key store needs a directory") } if err := os.MkdirAll(dir, 0o700); err != nil { return nil, fmt.Errorf("pds: creating the repo key directory: %w", err) } return &FileRepoKeyStore{dir: dir}, nil } func (s *FileRepoKeyStore) path(did syntax.DID) string { sum := sha256.Sum256([]byte(did.String())) return filepath.Join(s.dir, hex.EncodeToString(sum[:])+".key") } func (s *FileRepoKeyStore) PutRepoKey(_ context.Context, did syntax.DID, key atcrypto.PrivateKeyExportable) error { if key == nil { return errors.New("pds: the repo signing key is nil") } payload := did.String() + "\n" + key.Multibase() + "\n" if err := writePrivateFile(s.path(did), []byte(payload)); err != nil { return fmt.Errorf("pds: writing the repo signing key: %w", err) } return nil } func (s *FileRepoKeyStore) GetRepoKey(_ context.Context, did syntax.DID) (atcrypto.PrivateKeyExportable, error) { data, err := os.ReadFile(s.path(did)) if err != nil { if errors.Is(err, fs.ErrNotExist) { return nil, fmt.Errorf("pds: no repo signing key for %s", did) } return nil, fmt.Errorf("pds: reading the repo signing key: %w", err) } lines := strings.SplitN(strings.TrimSpace(string(data)), "\n", 2) if len(lines) != 2 || strings.TrimSpace(lines[0]) != did.String() { return nil, fmt.Errorf("pds: the repo signing key file for %s is invalid", did) } key, err := atcrypto.ParsePrivateMultibase(strings.TrimSpace(lines[1])) if err != nil { return nil, fmt.Errorf("pds: parsing the repo signing key: %w", err) } return key, nil } func (s *FileRepoKeyStore) DeleteRepoKey(_ context.Context, did syntax.DID) error { if err := os.Remove(s.path(did)); err != nil && !errors.Is(err, fs.ErrNotExist) { return fmt.Errorf("pds: deleting the repo signing key: %w", err) } return nil } type JSONFileRepoKeyStore struct { path string mu sync.Mutex } func NewJSONFileRepoKeyStore(path string) (*JSONFileRepoKeyStore, error) { path = strings.TrimSpace(path) if path == "" { return nil, errors.New("pds: the json repo key store needs a file path") } if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return nil, fmt.Errorf("pds: creating the repo key directory: %w", err) } return &JSONFileRepoKeyStore{path: path}, nil } func (s *JSONFileRepoKeyStore) load() (map[string]string, error) { keys := map[string]string{} data, err := os.ReadFile(s.path) if err != nil { if errors.Is(err, fs.ErrNotExist) { return keys, nil } return nil, fmt.Errorf("pds: reading the repo key file: %w", err) } if strings.TrimSpace(string(data)) == "" { return keys, nil } if err := json.Unmarshal(data, &keys); err != nil { return nil, fmt.Errorf("pds: the repo key file is invalid: %w", err) } return keys, nil } func (s *JSONFileRepoKeyStore) save(keys map[string]string) error { data, err := json.Marshal(keys) if err != nil { return fmt.Errorf("pds: encoding the repo key file: %w", err) } if err := writePrivateFile(s.path, append(data, '\n')); err != nil { return fmt.Errorf("pds: writing the repo key file: %w", err) } return nil } func (s *JSONFileRepoKeyStore) PutRepoKey(_ context.Context, did syntax.DID, key atcrypto.PrivateKeyExportable) error { if key == nil { return errors.New("pds: the repo signing key is nil") } s.mu.Lock() defer s.mu.Unlock() keys, err := s.load() if err != nil { return err } keys[did.String()] = key.Multibase() return s.save(keys) } func (s *JSONFileRepoKeyStore) GetRepoKey(_ context.Context, did syntax.DID) (atcrypto.PrivateKeyExportable, error) { s.mu.Lock() defer s.mu.Unlock() keys, err := s.load() if err != nil { return nil, err } raw, ok := keys[did.String()] if !ok || strings.TrimSpace(raw) == "" { return nil, fmt.Errorf("pds: no repo signing key for %s", did) } key, err := atcrypto.ParsePrivateMultibase(strings.TrimSpace(raw)) if err != nil { return nil, fmt.Errorf("pds: parsing the repo signing key: %w", err) } return key, nil } func (s *JSONFileRepoKeyStore) DeleteRepoKey(_ context.Context, did syntax.DID) error { s.mu.Lock() defer s.mu.Unlock() keys, err := s.load() if err != nil { return err } if _, ok := keys[did.String()]; !ok { return nil } delete(keys, did.String()) return s.save(keys) } func sessionSecretFromData(data map[string]any) ([]byte, error) { for _, key := range []string{"sessionSecret", "session_secret", "value", "secret"} { raw, ok := data[key] if !ok { continue } switch value := raw.(type) { case []byte: if len(value) > 0 { return append([]byte(nil), value...), nil } case string: value = strings.TrimSpace(value) if value == "" { continue } if decoded, err := hex.DecodeString(value); err == nil && len(decoded) > 0 { return decoded, nil } return []byte(value), nil } } return nil, errors.New("pds: the session secret path holds no session secret") } func privateKeyFromData(data map[string]any) (atcrypto.PrivateKey, error) { for _, key := range []string{"rotationKey", "rotation_key", "signingKey", "value", "key"} { raw, ok := data[key] if !ok { continue } var text string switch value := raw.(type) { case []byte: text = string(value) case string: text = value default: continue } text = strings.TrimSpace(text) if text == "" { continue } key, err := atcrypto.ParsePrivateMultibase(text) if err != nil { return nil, fmt.Errorf("pds: parsing the private key: %w", err) } return key, nil } return nil, errors.New("pds: the path holds no private key") } func resolvePrivateKey(stateDir, filename string, generate func() (atcrypto.PrivateKeyExportable, error)) (atcrypto.PrivateKey, error) { path := filepath.Join(stateDir, filename) data, err := os.ReadFile(path) if err == nil { key, parseErr := atcrypto.ParsePrivateMultibase(strings.TrimSpace(string(data))) if parseErr != nil { return nil, fmt.Errorf("pds: parsing the persisted private key %s: %w", filename, parseErr) } return key, nil } if !errors.Is(err, fs.ErrNotExist) { return nil, fmt.Errorf("pds: reading the private key %s: %w", filename, err) } key, err := generate() if err != nil { return nil, fmt.Errorf("pds: generating the private key %s: %w", filename, err) } if err := writePrivateFile(path, []byte(key.Multibase()+"\n")); err != nil { return nil, fmt.Errorf("pds: persisting the private key %s: %w", filename, err) } return key, nil } func writePrivateFile(path string, data []byte) error { dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0o700); err != nil { return err } tmp, err := os.CreateTemp(dir, ".tmp-*") if err != nil { return err } name := tmp.Name() defer os.Remove(name) if err := tmp.Chmod(0o600); err != nil { tmp.Close() return err } if _, err := tmp.Write(data); err != nil { tmp.Close() return err } if err := tmp.Sync(); err != nil { tmp.Close() return err } if err := tmp.Close(); err != nil { return err } return os.Rename(name, path) }