package main import ( "crypto/rand" "encoding/json" "errors" "fmt" "math/big" "os" "path/filepath" ) type CipherConfig struct { Version string `json:"version"` Mappings map[string]string `json:"mappings"` } const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" // getCipherDir returns the config directory ~/.doxa-secret func getCipherDir() (string, error) { home, err := os.UserHomeDir() if err != nil { return "", err } configDir := filepath.Join(home, ".doxa-secret") if _, err := os.Stat(configDir); os.IsNotExist(err) { err = os.MkdirAll(configDir, 0755) if err != nil { return "", err } } return configDir, nil } // getExportDir returns the export directory ~/doxa-secret/export func getExportDir() (string, error) { home, err := os.UserHomeDir() if err != nil { return "", err } dir := filepath.Join(home, "doxa-secret", "export") if _, err := os.Stat(dir); os.IsNotExist(err) { err = os.MkdirAll(dir, 0755) if err != nil { return "", err } } return dir, nil } // getImportDir returns the import directory ~/doxa-secret/import func getImportDir() (string, error) { home, err := os.UserHomeDir() if err != nil { return "", err } dir := filepath.Join(home, "doxa-secret", "import") if _, err := os.Stat(dir); os.IsNotExist(err) { err = os.MkdirAll(dir, 0755) if err != nil { return "", err } } return dir, nil } // generateRandomCode generates a unique 5-character alphanumeric string func generateRandomCode(existing map[string]bool) (string, error) { for { code := make([]byte, 5) for i := 0; i < 5; i++ { n, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset)))) if err != nil { return "", err } code[i] = charset[n.Int64()] } codeStr := string(code) if !existing[codeStr] { existing[codeStr] = true return codeStr, nil } } } // GenerateNewCipher creates a new cipher configuration with 5-character mappings func GenerateNewCipher(name string) error { dir, err := getCipherDir() if err != nil { return err } destPath := filepath.Join(dir, name+".json") if _, err := os.Stat(destPath); err == nil { return fmt.Errorf("šifra s názvem '%s' již existuje", name) } // Basic characters to encrypt charsToMap := []string{ // Space, comma, dot " ", ",", ".", // Digits "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", // Lowercase letters "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", // Uppercase letters "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", // Punctuation and special characters "!", "?", "-", "+", "=", "*", "/", "\\", "_", "@", "#", "$", "%", "^", "&", "(", ")", "[", "]", "{", "}", ":", ";", "\"", "'", "<", ">", "|", "~", "`", // Control chars (newlines, tabs) "\n", "\r", "\t", } mappings := make(map[string]string) usedCodes := make(map[string]bool) for _, ch := range charsToMap { code, err := generateRandomCode(usedCodes) if err != nil { return err } mappings[ch] = code } config := CipherConfig{ Version: "1.0", Mappings: mappings, } data, err := json.MarshalIndent(config, "", " ") if err != nil { return err } return os.WriteFile(destPath, data, 0644) } // LoadCipher loads a cipher by its file name (without path) from the configuration directory func LoadCipher(name string) (*CipherConfig, error) { dir, err := getCipherDir() if err != nil { return nil, err } path := filepath.Join(dir, name+".json") return LoadCipherFromPath(path) } // LoadCipherFromPath loads a cipher from an absolute path func LoadCipherFromPath(path string) (*CipherConfig, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } var config CipherConfig if err := json.Unmarshal(data, &config); err != nil { return nil, err } // Validate cipher: ensure each code has length 5 and is unique if config.Mappings == nil { return nil, errors.New("abeceda neobsahuje žádné mapování") } used := make(map[string]bool) for char, code := range config.Mappings { if len(code) != 5 { return nil, fmt.Errorf("neplatná délka šifry pro znak '%s': očekáváno 5 znaků, nalezeno %d", char, len(code)) } if used[code] { return nil, fmt.Errorf("duplicitní šifra '%s' v mapování", code) } used[code] = true } return &config, nil } // ListCiphers returns a list of all cipher names (without .json extension) in the configuration directory func ListCiphers() ([]string, error) { dir, err := getCipherDir() if err != nil { return nil, err } files, err := os.ReadDir(dir) if err != nil { return nil, err } var names []string for _, f := range files { if !f.IsDir() && filepath.Ext(f.Name()) == ".json" { name := f.Name() names = append(names, name[:len(name)-len(".json")]) } } return names, nil } // RemoveDiacritics converts Czech/accented runes to their basic Latin counterparts func RemoveDiacritics(text string) string { replacements := map[rune]rune{ 'á': 'a', 'č': 'c', 'ď': 'd', 'é': 'e', 'ě': 'e', 'í': 'i', 'ň': 'n', 'ó': 'o', 'ř': 'r', 'š': 's', 'ť': 't', 'ú': 'u', 'ů': 'u', 'ý': 'y', 'ž': 'z', 'Á': 'A', 'Č': 'C', 'Ď': 'D', 'É': 'E', 'Ě': 'E', 'Í': 'I', 'Ň': 'N', 'Ó': 'O', 'Ř': 'R', 'Š': 'S', 'Ť': 'T', 'Ú': 'U', 'Ů': 'U', 'Ý': 'Y', 'Ž': 'Z', } runes := []rune(text) for i, r := range runes { if repl, ok := replacements[r]; ok { runes[i] = repl } } return string(runes) } // EncryptMessage encrypts plain text into secret code func EncryptMessage(plainText string, config *CipherConfig) string { // Normalize text to Latin alphabet without diacritics plainText = RemoveDiacritics(plainText) var encrypted string // Iterate over the string as runes to correctly handle UTF-8 for _, r := range plainText { charStr := string(r) if code, ok := config.Mappings[charStr]; ok { encrypted += code } else { // If not mapped, keep the character as is encrypted += charStr } } return encrypted } // DecryptMessage decrypts secret code back into plain text func DecryptMessage(encryptedText string, config *CipherConfig) string { // Create reverse mapping reverse := make(map[string]string) for k, v := range config.Mappings { reverse[v] = k } var decrypted string runes := []rune(encryptedText) n := len(runes) for i := 0; i < n; { // Check if we can extract a 5-character block if i+5 <= n { block := string(runes[i : i+5]) if char, ok := reverse[block]; ok { decrypted += char i += 5 continue } } // Fallback: if it's not a valid 5-char code block, or not found, consume 1 rune decrypted += string(runes[i]) i++ } return decrypted } // ImportCipher copies a cipher JSON file from any location to ~/.doxa-secret/ func ImportCipher(srcPath string) (string, error) { config, err := LoadCipherFromPath(srcPath) if err != nil { return "", fmt.Errorf("chyba při načtení/validaci šifry: %v", err) } dir, err := getCipherDir() if err != nil { return "", err } baseName := filepath.Base(srcPath) nameWithoutExt := baseName[:len(baseName)-len(filepath.Ext(baseName))] destPath := filepath.Join(dir, baseName) data, err := json.MarshalIndent(config, "", " ") if err != nil { return "", err } err = os.WriteFile(destPath, data, 0644) if err != nil { return "", err } return nameWithoutExt, nil } // ExportCipher copies the active cipher JSON to a target path func ExportCipher(name string, destPath string) error { dir, err := getCipherDir() if err != nil { return err } srcPath := filepath.Join(dir, name+".json") data, err := os.ReadFile(srcPath) if err != nil { return err } // Ensure destination directory exists destDir := filepath.Dir(destPath) if _, err := os.Stat(destDir); os.IsNotExist(err) { err = os.MkdirAll(destDir, 0755) if err != nil { return err } } return os.WriteFile(destPath, data, 0644) }