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}