Files
doxa/doxa-secret/ciphers.go
T
2026-08-07 09:48:59 +02:00

318 lines
7.9 KiB
Go

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)
}