001/*
002 * Copyright 2025 devteam@scivics-lab.com
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing,
011 * software distributed under the License is distributed on an
012 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND,
013 * either express or implied.  See the License for the
014 * specific language governing permissions and limitations
015 * under the License.
016 */
017
018package com.scivicslab.actoriac;
019
020import java.io.BufferedReader;
021import java.io.IOException;
022import java.io.InputStream;
023import java.io.InputStreamReader;
024import java.io.StringReader;
025import java.util.HashMap;
026import java.util.Map;
027
028/**
029 * Parser for encrypted secret configuration files.
030 *
031 * <p>This class reads an encrypted INI-format file containing secrets
032 * (SSH keys, passphrases, sudo passwords), decrypts it, and provides
033 * access to the secrets with host/group/global priority.</p>
034 *
035 * <h2>File Format (before encryption)</h2>
036 * <pre>
037 * [secrets:all]
038 * ssh_key=-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNz...
039 * ssh_passphrase=MyPassphrase123
040 * sudo_password=MySudoPassword
041 *
042 * [secrets:webservers]
043 * sudo_password=WebServerSudoPassword
044 *
045 * [secrets:host:web1.example.com]
046 * ssh_key=...different key...
047 * </pre>
048 *
049 * <h2>Usage Example</h2>
050 * <pre>{@code
051 * InputStream encryptedInput = new FileInputStream("secrets.enc");
052 * String key = System.getenv("ACTOR_IAC_SECRET_KEY");
053 * EncryptedSecretConfig config = EncryptedSecretConfig.parse(encryptedInput, key);
054 *
055 * Map<String, String> secrets = config.getSecretsForHost("web1.example.com", "webservers");
056 * String sshKey = secrets.get("ssh_key");
057 * String passphrase = secrets.get("ssh_passphrase");
058 * }</pre>
059 *
060 * @author devteam@scivics-lab.com
061 */
062public class EncryptedSecretConfig {
063
064    private final Map<String, String> globalSecrets = new HashMap<>();
065    private final Map<String, Map<String, String>> groupSecrets = new HashMap<>();
066    private final Map<String, Map<String, String>> hostSecrets = new HashMap<>();
067
068    /**
069     * Parses an encrypted secret configuration file.
070     *
071     * @param encryptedInput InputStream of the encrypted file
072     * @param encryptionKey Base64-encoded encryption key
073     * @return parsed EncryptedSecretConfig
074     * @throws IOException if reading or decryption fails
075     */
076    public static EncryptedSecretConfig parse(InputStream encryptedInput, String encryptionKey) throws IOException {
077        try {
078            // Read encrypted content
079            StringBuilder encrypted = new StringBuilder();
080            try (BufferedReader reader = new BufferedReader(new InputStreamReader(encryptedInput))) {
081                String line;
082                while ((line = reader.readLine()) != null) {
083                    encrypted.append(line);
084                }
085            }
086
087            // Decrypt
088            String decrypted = SecretEncryptor.decrypt(encrypted.toString(), encryptionKey);
089
090            // Parse decrypted content
091            return parseDecrypted(decrypted);
092
093        } catch (SecretEncryptor.EncryptionException e) {
094            throw new IOException("Failed to decrypt secret configuration", e);
095        }
096    }
097
098    /**
099     * Parses decrypted INI-format content.
100     *
101     * @param content decrypted INI content
102     * @return parsed EncryptedSecretConfig
103     * @throws IOException if parsing fails
104     */
105    private static EncryptedSecretConfig parseDecrypted(String content) throws IOException {
106        EncryptedSecretConfig config = new EncryptedSecretConfig();
107
108        try (BufferedReader reader = new BufferedReader(new StringReader(content))) {
109            String line;
110            String currentSection = null;
111
112            while ((line = reader.readLine()) != null) {
113                line = line.trim();
114
115                // Skip empty lines and comments
116                if (line.isEmpty() || line.startsWith("#") || line.startsWith(";")) {
117                    continue;
118                }
119
120                // Section header
121                if (line.startsWith("[") && line.endsWith("]")) {
122                    currentSection = line.substring(1, line.length() - 1).trim();
123                    continue;
124                }
125
126                // Key-value pair
127                int equalsIndex = line.indexOf('=');
128                if (equalsIndex > 0 && currentSection != null) {
129                    String key = line.substring(0, equalsIndex).trim();
130                    String value = line.substring(equalsIndex + 1).trim();
131
132                    // Unescape newlines in multi-line values (e.g., SSH keys)
133                    value = value.replace("\\n", "\n");
134
135                    if (currentSection.equals("secrets:all")) {
136                        config.globalSecrets.put(key, value);
137                    } else if (currentSection.startsWith("secrets:host:")) {
138                        String hostname = currentSection.substring("secrets:host:".length());
139                        config.hostSecrets.computeIfAbsent(hostname, k -> new HashMap<>()).put(key, value);
140                    } else if (currentSection.startsWith("secrets:")) {
141                        String groupName = currentSection.substring("secrets:".length());
142                        config.groupSecrets.computeIfAbsent(groupName, k -> new HashMap<>()).put(key, value);
143                    }
144                }
145            }
146        }
147
148        return config;
149    }
150
151    /**
152     * Gets secrets for a specific host, applying priority rules.
153     * Priority: host-specific > group-specific > global
154     *
155     * @param hostname Hostname
156     * @param groupNames Group names this host belongs to
157     * @return Map of secrets for this host
158     */
159    public Map<String, String> getSecretsForHost(String hostname, String... groupNames) {
160        Map<String, String> result = new HashMap<>(globalSecrets);
161
162        // Apply group secrets (later groups override earlier ones)
163        for (String groupName : groupNames) {
164            Map<String, String> groupSecretMap = groupSecrets.get(groupName);
165            if (groupSecretMap != null) {
166                result.putAll(groupSecretMap);
167            }
168        }
169
170        // Apply host-specific secrets (highest priority)
171        Map<String, String> hostSecretMap = hostSecrets.get(hostname);
172        if (hostSecretMap != null) {
173            result.putAll(hostSecretMap);
174        }
175
176        return result;
177    }
178
179    /**
180     * Gets global secrets.
181     *
182     * @return Map of global secrets
183     */
184    public Map<String, String> getGlobalSecrets() {
185        return new HashMap<>(globalSecrets);
186    }
187}