From 2b2ebc02018479c2edfe451bfc1f6001c202a868 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Tue, 12 May 2026 17:54:23 +0200 Subject: [PATCH 1/5] vault --- C2Client/C2Client/TerminalPanel.py | 87 +- C2Client/C2Client/grpcClient.py | 32 + C2Client/tests/test_grpc_client.py | 39 + protocol/TeamServerApi.proto | 79 ++ teamServer/CMakeLists.txt | 6 + teamServer/teamServer/TeamServer.cpp | 50 +- teamServer/teamServer/TeamServer.hpp | 7 + teamServer/teamServer/TeamServerConfig.json | 3 + .../TeamServerCredentialVaultService.cpp | 1082 +++++++++++++++++ .../TeamServerCredentialVaultService.hpp | 99 ++ .../teamServer/TeamServerRuntimeConfig.cpp | 11 + .../teamServer/TeamServerRuntimeConfig.hpp | 3 + .../teamServer/TeamServerTermLocalService.cpp | 32 +- .../teamServer/TeamServerTermLocalService.hpp | 10 +- .../TeamServerCredentialVaultServiceTests.cpp | 159 +++ thirdParty/donut | 2 +- 16 files changed, 1659 insertions(+), 42 deletions(-) create mode 100644 teamServer/teamServer/TeamServerCredentialVaultService.cpp create mode 100644 teamServer/teamServer/TeamServerCredentialVaultService.hpp create mode 100644 teamServer/tests/TeamServerCredentialVaultServiceTests.cpp diff --git a/C2Client/C2Client/TerminalPanel.py b/C2Client/C2Client/TerminalPanel.py index 4e0a275..cf637c4 100644 --- a/C2Client/C2Client/TerminalPanel.py +++ b/C2Client/C2Client/TerminalPanel.py @@ -210,6 +210,7 @@ GrpcBatcaveUploadToolInstruction = "batcaveUpload" GrpcSocksInstruction = "socks" GrpcReloadModulesInstruction = "reloadModules"; +GrpcCredentialVaultInstruction = "cred" BeaconFileWindowsPattern = "Beacon-{}.exe" BeaconFileLinuxGenerated = "Beacon-linux" @@ -335,26 +336,30 @@ def isTerminalResponseError(response): CredentialStoreInstruction = "credentialStore" CredentialStoreHelp = """credentialStore -Read and update the TeamServer credential store. +Read and update the encrypted TeamServer credential vault. -Usage: credentialStore [arguments] +Usage: credentialStore [arguments] Kind: terminal Target: teamserver Requires session: no Arguments: - (text, required) - One of get, set, or search. + (text, required) - One of get, set, search, reveal, or delete. [arguments] (text, optional) - Action-specific values. Examples: credentialStore get credentialStore set domain username credential - credentialStore search username""" + credentialStore search username + credentialStore reveal credential_id + credentialStore delete credential_id""" GetSubInstruction = "get" SetSubInstruction = "set" SearchSubInstruction = "search" +RevealSubInstruction = "reveal" +DeleteSubInstruction = "delete" ReloadModulesInstruction = "reloadModules"; ReloadModulesHelp = """reloadModules @@ -383,6 +388,13 @@ def getHelpMsg(): reloadModules - Reload TeamServer module libraries.""" +def redactTerminalCommand(commandLine: str) -> str: + parts = commandLine.split() + if len(parts) >= 5 and parts[0].lower() == CredentialStoreInstruction.lower() and parts[1].lower() == SetSubInstruction.lower(): + return " ".join([*parts[:4], ""]) + return commandLine + + def normalizeWindowsArch(arch): normalized = (arch or "").lower() if normalized in ("amd64", "x86_64"): @@ -718,7 +730,7 @@ def build_terminal_completer_data(grpcClient: Any = None) -> list[tuple[str, lis (HostInstruction, _host_artifact_entries(artifacts, listener_with_optional_filename)), (DropperInstruction, dropper_children), (BatcaveInstruction, [("install", []), ("bundleInstall", []), ("search", [])]), - (CredentialStoreInstruction, [(GetSubInstruction, []), (SetSubInstruction, []), (SearchSubInstruction, [])]), + (CredentialStoreInstruction, [(GetSubInstruction, []), (SetSubInstruction, []), (SearchSubInstruction, []), (RevealSubInstruction, []), (DeleteSubInstruction, [])]), (SocksInstruction, [("start", []), ("stop", []), ("unbind", []), ("bind", _session_entries(sessions))]), (ReloadModulesInstruction, []), ] @@ -883,13 +895,14 @@ def runCommand(self): self.printInTerminal("", "") else: + commandLineForLog = redactTerminalCommand(commandLine) cmdHistoryFile = open(HistoryFileName, 'a') - cmdHistoryFile.write(commandLine) + cmdHistoryFile.write(commandLineForLog) cmdHistoryFile.write('\n') cmdHistoryFile.close() logFile = open(logsDir+"/"+self.logFileName, 'a') - logFile.write('[+] send: \"' + commandLine + '\"') + logFile.write('[+] send: \"' + commandLineForLog + '\"') logFile.write('\n') logFile.close() @@ -1071,17 +1084,9 @@ def runCredentialStore(self, commandLine, instructions): cmd = instructions[1].lower() if cmd == GetSubInstruction.lower(): - try: - currentcredentials = json.loads(credentials.getCredentials(self.grpcClient, TeamServerApi_pb2)) - except (RuntimeError, json.JSONDecodeError) as exc: - self.printInTerminal(commandLine, str(exc)) - return - - toPrint = "" - for cred in currentcredentials: - toPrint+=json.dumps(cred) - toPrint+="\n" - self.printInTerminal(commandLine, toPrint) + termCommand = TeamServerApi_pb2.TerminalCommandRequest(command=GrpcCredentialVaultInstruction + " list") + resultTermCommand = self.grpcClient.executeTerminalCommand(termCommand) + self.printInTerminal(commandLine, terminal_response_text(resultTermCommand)) return @@ -1097,14 +1102,13 @@ def runCredentialStore(self, commandLine, instructions): cred = {} cred["domain"] = domain cred["username"] = username - cred["manual"] = credential - try: - result = credentials.addCredentials(self.grpcClient, TeamServerApi_pb2, json.dumps(cred)) - except (RuntimeError, json.JSONDecodeError) as exc: - self.printInTerminal(commandLine, str(exc)) - return - if result: - self.printInTerminal(commandLine, result) + cred["password"] = credential + termCommand = TeamServerApi_pb2.TerminalCommandRequest( + command=GrpcCredentialVaultInstruction + " add", + data=json.dumps(cred).encode(), + ) + resultTermCommand = self.grpcClient.executeTerminalCommand(termCommand) + self.printInTerminal(commandLine, terminal_response_text(resultTermCommand)) return elif cmd == SearchSubInstruction.lower(): @@ -1113,20 +1117,29 @@ def runCredentialStore(self, commandLine, instructions): return searchPatern = instructions[2] + termCommand = TeamServerApi_pb2.TerminalCommandRequest(command=GrpcCredentialVaultInstruction + " list " + searchPatern) + resultTermCommand = self.grpcClient.executeTerminalCommand(termCommand) + self.printInTerminal(commandLine, terminal_response_text(resultTermCommand)) + return - try: - currentcredentials = json.loads(credentials.getCredentials(self.grpcClient, TeamServerApi_pb2)) - except (RuntimeError, json.JSONDecodeError) as exc: - self.printInTerminal(commandLine, str(exc)) + elif cmd == RevealSubInstruction.lower(): + if len(instructions) < 3: + self.printInTerminal(commandLine, CredentialStoreHelp) return + credentialId = instructions[2] + termCommand = TeamServerApi_pb2.TerminalCommandRequest(command=GrpcCredentialVaultInstruction + " get " + credentialId + " --reveal") + resultTermCommand = self.grpcClient.executeTerminalCommand(termCommand) + self.printInTerminal(commandLine, terminal_response_text(resultTermCommand)) + return - toPrint = "" - for cred in currentcredentials: - for key, value in cred.items(): - if searchPatern in value: - toPrint+=json.dumps(cred) - toPrint+="\n" - self.printInTerminal(commandLine, toPrint) + elif cmd == DeleteSubInstruction.lower(): + if len(instructions) < 3: + self.printInTerminal(commandLine, CredentialStoreHelp) + return + credentialId = instructions[2] + termCommand = TeamServerApi_pb2.TerminalCommandRequest(command=GrpcCredentialVaultInstruction + " delete " + credentialId) + resultTermCommand = self.grpcClient.executeTerminalCommand(termCommand) + self.printInTerminal(commandLine, terminal_response_text(resultTermCommand)) return else: diff --git a/C2Client/C2Client/grpcClient.py b/C2Client/C2Client/grpcClient.py index 2261d4e..811736a 100644 --- a/C2Client/C2Client/grpcClient.py +++ b/C2Client/C2Client/grpcClient.py @@ -266,6 +266,38 @@ def deleteGeneratedArtifact(self, artifact_id: str) -> Any: return self.deleteArtifact(artifact_id) + def listCredentials(self, query: Optional[Any] = None) -> Iterable[Any]: + """Return credential metadata from the TeamServer vault without secrets.""" + + if query is None: + query = TeamServerApi_pb2.CredentialQuery() + return self._stream_rpc("ListCredentials", lambda: self.stub.ListCredentials(query, metadata=self.metadata)) + + def getCredential(self, credential_id: str, reveal_secret: bool = False) -> Any: + """Return one credential; secrets are included only when explicitly requested.""" + + selector = TeamServerApi_pb2.CredentialSelector( + credential_id=credential_id, + reveal_secret=reveal_secret, + ) + return self._unary_rpc("GetCredential", lambda: self.stub.GetCredential(selector, metadata=self.metadata)) + + def addCredential(self, request: Any) -> Any: + """Add a credential to the TeamServer vault.""" + + return self._unary_rpc("AddCredential", lambda: self.stub.AddCredential(request, metadata=self.metadata)) + + def updateCredential(self, request: Any) -> Any: + """Update a credential in the TeamServer vault.""" + + return self._unary_rpc("UpdateCredential", lambda: self.stub.UpdateCredential(request, metadata=self.metadata)) + + def deleteCredential(self, credential_id: str) -> Any: + """Delete a credential from the TeamServer vault.""" + + selector = TeamServerApi_pb2.CredentialSelector(credential_id=credential_id) + return self._unary_rpc("DeleteCredential", lambda: self.stub.DeleteCredential(selector, metadata=self.metadata)) + def listCommands(self, query: Optional[Any] = None) -> Iterable[Any]: """Return command specs exposed by the TeamServer catalog.""" diff --git a/C2Client/tests/test_grpc_client.py b/C2Client/tests/test_grpc_client.py index 29d5ebc..a26dc2a 100644 --- a/C2Client/tests/test_grpc_client.py +++ b/C2Client/tests/test_grpc_client.py @@ -170,6 +170,45 @@ def test_grpc_client_lists_commands(tmp_path, monkeypatch): assert events == [("ListCommands", True, "")] +def test_grpc_client_credential_vault_rpcs(tmp_path, monkeypatch): + cert = tmp_path / "cert.crt" + cert.write_text("cert") + monkeypatch.setenv("C2_CERT_PATH", str(cert)) + monkeypatch.setattr(grpc, "ssl_channel_credentials", lambda _: object()) + monkeypatch.setattr(grpc, "secure_channel", lambda *args, **kwargs: object()) + monkeypatch.setattr(grpc, "channel_ready_future", lambda channel: DummyFuture()) + stub = mock.MagicMock() + summary = object() + detail = object() + ack = object() + stub.ListCredentials.return_value = iter([summary]) + stub.GetCredential.return_value = detail + stub.AddCredential.return_value = ack + stub.UpdateCredential.return_value = ack + stub.DeleteCredential.return_value = ack + monkeypatch.setattr(TeamServerApi_pb2_grpc, "TeamServerApiStub", lambda channel: stub) + + client = GrpcClient("127.0.0.1", 50051, False, token="tok") + + query = TeamServerApi_pb2.CredentialQuery(username="alice") + assert list(client.listCredentials(query)) == [summary] + stub.ListCredentials.assert_called_once_with(query, metadata=client.metadata) + + assert client.getCredential("cred-1", reveal_secret=True) is detail + selector = stub.GetCredential.call_args.args[0] + assert selector.credential_id == "cred-1" + assert selector.reveal_secret is True + + request = TeamServerApi_pb2.CredentialUpsertRequest(username="alice") + assert client.addCredential(request) is ack + stub.AddCredential.assert_called_once_with(request, metadata=client.metadata) + assert client.updateCredential(request) is ack + stub.UpdateCredential.assert_called_once_with(request, metadata=client.metadata) + assert client.deleteCredential("cred-1") is ack + delete_selector = stub.DeleteCredential.call_args.args[0] + assert delete_selector.credential_id == "cred-1" + + def test_grpc_client_uses_env_certificate_and_grpc_options(tmp_path, monkeypatch): cert = tmp_path / "cert.crt" cert.write_text("cert") diff --git a/protocol/TeamServerApi.proto b/protocol/TeamServerApi.proto index 748f7f2..31193b6 100644 --- a/protocol/TeamServerApi.proto +++ b/protocol/TeamServerApi.proto @@ -18,6 +18,11 @@ service TeamServerApi rpc DownloadArtifact(ArtifactSelector) returns (ArtifactContent) {} rpc UploadArtifact(ArtifactUploadRequest) returns (OperationAck) {} rpc DeleteGeneratedArtifact(ArtifactSelector) returns (OperationAck) {} + rpc ListCredentials(CredentialQuery) returns (stream CredentialSummary) {} + rpc GetCredential(CredentialSelector) returns (CredentialDetail) {} + rpc AddCredential(CredentialUpsertRequest) returns (OperationAck) {} + rpc UpdateCredential(CredentialUpsertRequest) returns (OperationAck) {} + rpc DeleteCredential(CredentialSelector) returns (OperationAck) {} rpc ListCommands(CommandQuery) returns (stream CommandSpec) {} rpc ListModules(SessionSelector) returns (stream LoadedModule) {} @@ -164,6 +169,80 @@ message ArtifactUploadRequest } +message CredentialQuery +{ + string type = 1; + string username = 2; + string domain = 3; + string target = 4; + string protocol = 5; + string tag = 6; + string name_contains = 7; + bool include_expired = 8; +} + + +message CredentialSelector +{ + string credential_id = 1; + bool reveal_secret = 2; +} + + +message CredentialSecret +{ + string name = 1; + string value = 2; +} + + +message CredentialSummary +{ + string credential_id = 1; + string display_name = 2; + string type = 3; + string username = 4; + string domain = 5; + string realm = 6; + string target = 7; + string protocol = 8; + repeated string tags = 9; + string description = 10; + string created_at = 11; + string updated_at = 12; + string last_used_at = 13; + string expires_at = 14; + repeated string secret_fields = 15; +} + + +message CredentialDetail +{ + Status status = 1; + string message = 2; + CredentialSummary summary = 3; + repeated CredentialSecret secrets = 4; +} + + +message CredentialUpsertRequest +{ + string credential_id = 1; + string display_name = 2; + string type = 3; + string username = 4; + string domain = 5; + string realm = 6; + string target = 7; + string protocol = 8; + repeated string tags = 9; + string description = 10; + string expires_at = 11; + repeated CredentialSecret secrets = 12; + bool replace_secrets = 13; +} + + message CommandQuery { string kind = 1; diff --git a/teamServer/CMakeLists.txt b/teamServer/CMakeLists.txt index 8778080..14fb694 100644 --- a/teamServer/CMakeLists.txt +++ b/teamServer/CMakeLists.txt @@ -11,6 +11,7 @@ set(TEAMSERVER_CORE_SOURCES teamServer/TeamServerCommandCatalog.cpp teamServer/TeamServerCommandCatalogService.cpp teamServer/TeamServerCommandPreparationService.cpp + teamServer/TeamServerCredentialVaultService.cpp teamServer/TeamServerFileArtifactService.cpp teamServer/TeamServerFileTransferCommandPreparer.cpp teamServer/TeamServerGeneratedArtifactStore.cpp @@ -163,6 +164,11 @@ if(WITH_TESTS) tests/TeamServerCommandCatalogTests.cpp ) + teamserver_add_test(testsTeamServerCredentialVaultService + server_core + tests/TeamServerCredentialVaultServiceTests.cpp + ) + teamserver_add_test(testsTeamServerSocksService server_core tests/TeamServerSocksServiceTests.cpp diff --git a/teamServer/teamServer/TeamServer.cpp b/teamServer/teamServer/TeamServer.cpp index ad6269c..db66d0a 100644 --- a/teamServer/teamServer/TeamServer.cpp +++ b/teamServer/teamServer/TeamServer.cpp @@ -9,6 +9,7 @@ #include "TeamServerCommandCatalog.hpp" #include "TeamServerCommandCatalogService.hpp" #include "TeamServerCommandPreparationService.hpp" +#include "TeamServerCredentialVaultService.hpp" #include "TeamServerFileArtifactService.hpp" #include "TeamServerFileTransferCommandPreparer.hpp" #include "TeamServerGeneratedArtifactStore.hpp" @@ -95,9 +96,12 @@ TeamServer::TeamServer(const nlohmann::json& config) m_artifactService = std::make_unique( m_logger, TeamServerArtifactCatalog(runtimeConfig)); - m_commandCatalogService = std::make_unique( + m_commandCatalogService = std::make_unique( m_logger, TeamServerCommandCatalog(runtimeConfig)); + m_credentialVaultService = std::make_shared( + m_logger, + runtimeConfig); m_helpService = std::make_unique( m_logger, m_listeners, @@ -176,7 +180,8 @@ TeamServer::TeamServer(const nlohmann::json& config) m_credentials, m_moduleCmd, [this]() - { return m_moduleLoader->loadModules(); }); + { return m_moduleLoader->loadModules(); }, + m_credentialVaultService); m_moduleCmd = m_moduleLoader->loadModules(); @@ -285,6 +290,47 @@ grpc::Status TeamServer::DeleteGeneratedArtifact(grpc::ServerContext* context, c return m_artifactService->deleteGeneratedArtifact(*selector, response); } +grpc::Status TeamServer::ListCredentials(grpc::ServerContext* context, const teamserverapi::CredentialQuery* query, grpc::ServerWriter* writer) +{ + auto authStatus = ensureAuthenticated(context); + if (!authStatus.ok()) + return authStatus; + return m_credentialVaultService->listCredentials(*query, [&](const teamserverapi::CredentialSummary& credential) + { return writer->Write(credential); }); +} + +grpc::Status TeamServer::GetCredential(grpc::ServerContext* context, const teamserverapi::CredentialSelector* selector, teamserverapi::CredentialDetail* response) +{ + auto authStatus = ensureAuthenticated(context); + if (!authStatus.ok()) + return authStatus; + return m_credentialVaultService->getCredential(*selector, response); +} + +grpc::Status TeamServer::AddCredential(grpc::ServerContext* context, const teamserverapi::CredentialUpsertRequest* request, teamserverapi::OperationAck* response) +{ + auto authStatus = ensureAuthenticated(context); + if (!authStatus.ok()) + return authStatus; + return m_credentialVaultService->addCredential(*request, response); +} + +grpc::Status TeamServer::UpdateCredential(grpc::ServerContext* context, const teamserverapi::CredentialUpsertRequest* request, teamserverapi::OperationAck* response) +{ + auto authStatus = ensureAuthenticated(context); + if (!authStatus.ok()) + return authStatus; + return m_credentialVaultService->updateCredential(*request, response); +} + +grpc::Status TeamServer::DeleteCredential(grpc::ServerContext* context, const teamserverapi::CredentialSelector* selector, teamserverapi::OperationAck* response) +{ + auto authStatus = ensureAuthenticated(context); + if (!authStatus.ok()) + return authStatus; + return m_credentialVaultService->deleteCredential(*selector, response); +} + grpc::Status TeamServer::ListCommands(grpc::ServerContext* context, const teamserverapi::CommandQuery* query, grpc::ServerWriter* writer) { auto authStatus = ensureAuthenticated(context); diff --git a/teamServer/teamServer/TeamServer.hpp b/teamServer/teamServer/TeamServer.hpp index 2e88bb0..e309150 100644 --- a/teamServer/teamServer/TeamServer.hpp +++ b/teamServer/teamServer/TeamServer.hpp @@ -32,6 +32,7 @@ class TeamServerAuthManager; class TeamServerArtifactService; class TeamServerCommandCatalogService; +class TeamServerCredentialVaultService; class TeamServerFileArtifactService; class TeamServerGeneratedArtifactStore; class TeamServerHelpService; @@ -62,6 +63,11 @@ class TeamServer final : public teamserverapi::TeamServerApi::Service grpc::Status DownloadArtifact(grpc::ServerContext* context, const teamserverapi::ArtifactSelector* selector, teamserverapi::ArtifactContent* response) override; grpc::Status UploadArtifact(grpc::ServerContext* context, const teamserverapi::ArtifactUploadRequest* request, teamserverapi::OperationAck* response) override; grpc::Status DeleteGeneratedArtifact(grpc::ServerContext* context, const teamserverapi::ArtifactSelector* selector, teamserverapi::OperationAck* response) override; + grpc::Status ListCredentials(grpc::ServerContext* context, const teamserverapi::CredentialQuery* query, grpc::ServerWriter* writer) override; + grpc::Status GetCredential(grpc::ServerContext* context, const teamserverapi::CredentialSelector* selector, teamserverapi::CredentialDetail* response) override; + grpc::Status AddCredential(grpc::ServerContext* context, const teamserverapi::CredentialUpsertRequest* request, teamserverapi::OperationAck* response) override; + grpc::Status UpdateCredential(grpc::ServerContext* context, const teamserverapi::CredentialUpsertRequest* request, teamserverapi::OperationAck* response) override; + grpc::Status DeleteCredential(grpc::ServerContext* context, const teamserverapi::CredentialSelector* selector, teamserverapi::OperationAck* response) override; grpc::Status ListCommands(grpc::ServerContext* context, const teamserverapi::CommandQuery* query, grpc::ServerWriter* writer) override; grpc::Status ListModules(grpc::ServerContext* context, const teamserverapi::SessionSelector* session, grpc::ServerWriter* writer) override; @@ -104,6 +110,7 @@ class TeamServer final : public teamserverapi::TeamServerApi::Service std::unique_ptr m_authManager; std::unique_ptr m_artifactService; std::unique_ptr m_commandCatalogService; + std::shared_ptr m_credentialVaultService; std::shared_ptr m_fileArtifactService; std::shared_ptr m_generatedArtifactStore; std::unique_ptr m_helpService; diff --git a/teamServer/teamServer/TeamServerConfig.json b/teamServer/teamServer/TeamServerConfig.json index d89a9d2..1668f1c 100644 --- a/teamServer/teamServer/TeamServerConfig.json +++ b/teamServer/teamServer/TeamServerConfig.json @@ -10,6 +10,9 @@ "UploadedArtifactsDirectoryPath": "../data/UploadedArtifacts/", "GeneratedArtifactsDirectoryPath": "../data/GeneratedArtifacts/", "HostedArtifactsDirectoryPath": "../data/GeneratedArtifacts/hosted/", + "CredentialVaultDirectoryPath": "../data/CredentialVault/", + "CredentialVaultPath": "../data/CredentialVault/vault.json", + "CredentialVaultKeyFile": "../data/CredentialVault/vault.key", "//Host contacted by the beacon": "3 following value are related to the host, probably a proxy, that will be contacted by the beacon, if DomainName is filled it will be selected first, then the ExposedIp and then the IpInterface", "DomainName": "", "ExposedIp": "", diff --git a/teamServer/teamServer/TeamServerCredentialVaultService.cpp b/teamServer/teamServer/TeamServerCredentialVaultService.cpp new file mode 100644 index 0000000..8ece72b --- /dev/null +++ b/teamServer/teamServer/TeamServerCredentialVaultService.cpp @@ -0,0 +1,1082 @@ +#include "TeamServerCredentialVaultService.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "spdlog/logger.h" + +namespace fs = std::filesystem; +using json = nlohmann::json; + +namespace +{ +constexpr std::size_t VaultKeySize = 32; +constexpr std::size_t VaultNonceSize = 12; +constexpr std::size_t VaultTagSize = 16; + +void setTerminalOk(teamserverapi::TerminalCommandResponse* response, const std::string& result) +{ + response->set_status(teamserverapi::OK); + response->set_result(result); + response->clear_message(); +} + +void setTerminalError(teamserverapi::TerminalCommandResponse* response, const std::string& result) +{ + response->set_status(teamserverapi::KO); + response->set_result(result); + response->set_message(result); +} + +void setAck(teamserverapi::OperationAck* response, teamserverapi::Status status, const std::string& message) +{ + response->set_status(status); + response->set_message(message); +} + +std::string toLower(std::string value) +{ + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) + { + return static_cast(std::tolower(c)); + }); + return value; +} + +bool containsCaseInsensitive(const std::string& haystack, const std::string& needle) +{ + if (needle.empty()) + return true; + return toLower(haystack).find(toLower(needle)) != std::string::npos; +} + +bool matchesField(const std::string& requested, const std::string& actual) +{ + return requested.empty() || toLower(requested) == toLower(actual); +} + +std::string bytesToHex(const std::vector& bytes) +{ + std::ostringstream output; + output << std::hex << std::setfill('0'); + for (unsigned char byte : bytes) + output << std::setw(2) << static_cast(byte); + return output.str(); +} + +bool hexToBytes(const std::string& hex, std::vector& bytes) +{ + if (hex.size() % 2 != 0) + return false; + bytes.clear(); + bytes.reserve(hex.size() / 2); + for (std::size_t i = 0; i < hex.size(); i += 2) + { + const std::string part = hex.substr(i, 2); + char* end = nullptr; + const unsigned long value = std::strtoul(part.c_str(), &end, 16); + if (end == nullptr || *end != '\0' || value > 255) + return false; + bytes.push_back(static_cast(value)); + } + return true; +} + +std::string jsonString(const json& input, const char* key, const std::string& fallback = "") +{ + auto it = input.find(key); + if (it == input.end() || !it->is_string()) + return fallback; + return it->get(); +} + +std::vector jsonStringList(const json& input, const char* key) +{ + std::vector values; + auto it = input.find(key); + if (it == input.end() || !it->is_array()) + return values; + for (const auto& value : *it) + { + if (value.is_string()) + values.push_back(value.get()); + } + return values; +} + +bool isKnownSecretField(const std::string& field) +{ + const std::string normalized = toLower(field); + return normalized == "password" + || normalized == "ntlm" + || normalized == "ntlm_hash" + || normalized == "hash" + || normalized == "token" + || normalized == "manual" + || normalized == "private_key" + || normalized == "secret" + || normalized == "aes_key"; +} + +std::string defaultTypeFromSecrets(const std::map& secrets) +{ + if (secrets.find("password") != secrets.end() || secrets.find("manual") != secrets.end()) + return "password"; + if (secrets.find("ntlm") != secrets.end() || secrets.find("ntlm_hash") != secrets.end() || secrets.find("hash") != secrets.end()) + return "ntlm_hash"; + if (secrets.find("token") != secrets.end()) + return "token"; + if (secrets.find("private_key") != secrets.end()) + return "ssh_key"; + return "custom"; +} + +bool aesGcmEncrypt( + const std::vector& key, + const std::vector& nonce, + const std::string& plaintext, + std::vector& ciphertext, + std::vector& tag) +{ + EVP_CIPHER_CTX* ctx = EVP_CIPHER_CTX_new(); + if (!ctx) + return false; + + bool ok = false; + int len = 0; + ciphertext.assign(plaintext.size() + EVP_MAX_BLOCK_LENGTH, 0); + tag.assign(VaultTagSize, 0); + + if (EVP_EncryptInit_ex(ctx, EVP_aes_256_gcm(), nullptr, nullptr, nullptr) == 1 + && EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, static_cast(nonce.size()), nullptr) == 1 + && EVP_EncryptInit_ex(ctx, nullptr, nullptr, key.data(), nonce.data()) == 1 + && EVP_EncryptUpdate(ctx, ciphertext.data(), &len, reinterpret_cast(plaintext.data()), static_cast(plaintext.size())) == 1) + { + int total = len; + if (EVP_EncryptFinal_ex(ctx, ciphertext.data() + total, &len) == 1) + { + total += len; + ciphertext.resize(static_cast(total)); + ok = EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_GET_TAG, static_cast(tag.size()), tag.data()) == 1; + } + } + + EVP_CIPHER_CTX_free(ctx); + return ok; +} + +bool aesGcmDecrypt( + const std::vector& key, + const std::vector& nonce, + const std::vector& ciphertext, + const std::vector& tag, + std::string& plaintext) +{ + EVP_CIPHER_CTX* ctx = EVP_CIPHER_CTX_new(); + if (!ctx) + return false; + + bool ok = false; + int len = 0; + std::vector output(ciphertext.size() + EVP_MAX_BLOCK_LENGTH, 0); + + if (EVP_DecryptInit_ex(ctx, EVP_aes_256_gcm(), nullptr, nullptr, nullptr) == 1 + && EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, static_cast(nonce.size()), nullptr) == 1 + && EVP_DecryptInit_ex(ctx, nullptr, nullptr, key.data(), nonce.data()) == 1 + && EVP_DecryptUpdate(ctx, output.data(), &len, ciphertext.data(), static_cast(ciphertext.size())) == 1) + { + int total = len; + if (EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_TAG, static_cast(tag.size()), const_cast(tag.data())) == 1 + && EVP_DecryptFinal_ex(ctx, output.data() + total, &len) == 1) + { + total += len; + plaintext.assign(reinterpret_cast(output.data()), static_cast(total)); + ok = true; + } + } + + EVP_CIPHER_CTX_free(ctx); + return ok; +} +} // namespace + +TeamServerCredentialVaultService::TeamServerCredentialVaultService( + std::shared_ptr logger, + TeamServerRuntimeConfig runtimeConfig) + : m_logger(std::move(logger)), + m_runtimeConfig(std::move(runtimeConfig)) +{ +} + +std::string TeamServerCredentialVaultService::currentTimestamp() const +{ + const auto now = std::chrono::system_clock::now(); + const std::time_t nowTime = std::chrono::system_clock::to_time_t(now); + std::tm utcTime {}; +#ifdef _WIN32 + gmtime_s(&utcTime, &nowTime); +#else + gmtime_r(&nowTime, &utcTime); +#endif + std::ostringstream output; + output << std::put_time(&utcTime, "%Y-%m-%dT%H:%M:%SZ"); + return output.str(); +} + +std::string TeamServerCredentialVaultService::generateCredentialId() const +{ + std::vector bytes(8, 0); + if (RAND_bytes(bytes.data(), static_cast(bytes.size())) != 1) + return std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()); + return bytesToHex(bytes); +} + +bool TeamServerCredentialVaultService::ensureVaultKeyLocked(std::vector& key, std::string& message) const +{ + if (readVaultKeyLocked(key, message)) + return true; + if (fs::exists(m_runtimeConfig.credentialVaultKeyFile)) + return false; + return writeNewVaultKeyLocked(key, message); +} + +bool TeamServerCredentialVaultService::readVaultKeyLocked(std::vector& key, std::string& message) const +{ + std::ifstream input(m_runtimeConfig.credentialVaultKeyFile); + if (!input.good()) + { + message = "credential vault key file not found"; + return false; + } + + std::string hex; + input >> hex; + if (!hexToBytes(hex, key) || key.size() != VaultKeySize) + { + message = "credential vault key file is invalid"; + return false; + } + return true; +} + +bool TeamServerCredentialVaultService::writeNewVaultKeyLocked(std::vector& key, std::string& message) const +{ + key.assign(VaultKeySize, 0); + if (RAND_bytes(key.data(), static_cast(key.size())) != 1) + { + message = "could not generate credential vault key"; + return false; + } + + std::error_code ec; + fs::create_directories(fs::path(m_runtimeConfig.credentialVaultKeyFile).parent_path(), ec); + if (ec) + { + message = "could not create credential vault key directory"; + return false; + } + + std::ofstream output(m_runtimeConfig.credentialVaultKeyFile, std::ios::out | std::ios::trunc); + if (!output.good()) + { + message = "could not write credential vault key file"; + return false; + } + output << bytesToHex(key) << "\n"; + output.close(); + chmod(m_runtimeConfig.credentialVaultKeyFile.c_str(), S_IRUSR | S_IWUSR); + return true; +} + +bool TeamServerCredentialVaultService::encryptVaultLocked(const json& plainVault, json& encryptedVault, std::string& message) const +{ + std::vector key; + if (!ensureVaultKeyLocked(key, message)) + return false; + + std::vector nonce(VaultNonceSize, 0); + if (RAND_bytes(nonce.data(), static_cast(nonce.size())) != 1) + { + message = "could not generate credential vault nonce"; + return false; + } + + std::vector ciphertext; + std::vector tag; + if (!aesGcmEncrypt(key, nonce, plainVault.dump(), ciphertext, tag)) + { + message = "could not encrypt credential vault"; + return false; + } + + encryptedVault = json::object(); + encryptedVault["version"] = 1; + encryptedVault["cipher"] = "AES-256-GCM"; + encryptedVault["nonce"] = bytesToHex(nonce); + encryptedVault["tag"] = bytesToHex(tag); + encryptedVault["ciphertext"] = bytesToHex(ciphertext); + return true; +} + +bool TeamServerCredentialVaultService::decryptVaultLocked(const json& encryptedVault, json& plainVault, std::string& message) const +{ + if (!encryptedVault.is_object() + || encryptedVault.value("cipher", std::string()) != "AES-256-GCM") + { + message = "credential vault format is invalid"; + return false; + } + + std::vector key; + std::vector nonce; + std::vector tag; + std::vector ciphertext; + if (!ensureVaultKeyLocked(key, message) + || !hexToBytes(encryptedVault.value("nonce", std::string()), nonce) + || !hexToBytes(encryptedVault.value("tag", std::string()), tag) + || !hexToBytes(encryptedVault.value("ciphertext", std::string()), ciphertext)) + { + message = "credential vault envelope is invalid"; + return false; + } + + std::string plaintext; + if (!aesGcmDecrypt(key, nonce, ciphertext, tag, plaintext)) + { + message = "could not decrypt credential vault"; + return false; + } + + plainVault = json::parse(plaintext, nullptr, false); + if (plainVault.is_discarded() || !plainVault.is_object()) + { + message = "decrypted credential vault content is invalid"; + return false; + } + return true; +} + +void TeamServerCredentialVaultService::loadLocked() +{ + if (m_loaded) + return; + m_loaded = true; + + std::ifstream input(m_runtimeConfig.credentialVaultPath); + if (!input.good()) + { + m_credentials.clear(); + m_audit = json::array(); + return; + } + + json encryptedVault = json::parse(input, nullptr, false); + if (encryptedVault.is_discarded()) + { + m_logger->error("Credential vault file is not valid JSON: {0}", m_runtimeConfig.credentialVaultPath); + return; + } + + std::string message; + json plainVault; + if (!decryptVaultLocked(encryptedVault, plainVault, message)) + { + m_logger->error("Unable to load credential vault: {0}", message); + return; + } + + m_credentials.clear(); + const json credentials = plainVault.value("credentials", json::array()); + if (credentials.is_array()) + { + for (const auto& item : credentials) + { + if (item.is_object()) + m_credentials.push_back(recordFromJson(item)); + } + } + m_audit = plainVault.value("audit", json::array()); + if (!m_audit.is_array()) + m_audit = json::array(); +} + +bool TeamServerCredentialVaultService::saveLocked(std::string& message) const +{ + json plainVault = json::object(); + plainVault["version"] = 1; + plainVault["credentials"] = json::array(); + for (const TeamServerCredentialRecord& record : m_credentials) + plainVault["credentials"].push_back(recordToJson(record, true)); + plainVault["audit"] = m_audit; + + json encryptedVault; + if (!encryptVaultLocked(plainVault, encryptedVault, message)) + return false; + + std::error_code ec; + fs::create_directories(fs::path(m_runtimeConfig.credentialVaultPath).parent_path(), ec); + if (ec) + { + message = "could not create credential vault directory"; + return false; + } + + const fs::path destination(m_runtimeConfig.credentialVaultPath); + const fs::path temporary = destination.string() + ".tmp"; + std::ofstream output(temporary, std::ios::out | std::ios::trunc); + if (!output.good()) + { + message = "could not write credential vault"; + return false; + } + output << encryptedVault.dump(2) << "\n"; + output.close(); + if (!output.good()) + { + message = "could not flush credential vault"; + return false; + } + fs::rename(temporary, destination, ec); + if (ec) + { + fs::remove(destination, ec); + ec.clear(); + fs::rename(temporary, destination, ec); + } + if (ec) + { + message = "could not replace credential vault"; + return false; + } + chmod(destination.c_str(), S_IRUSR | S_IWUSR); + return true; +} + +TeamServerCredentialRecord TeamServerCredentialVaultService::recordFromRequest(const teamserverapi::CredentialUpsertRequest& request) const +{ + TeamServerCredentialRecord record; + record.credentialId = request.credential_id(); + record.displayName = request.display_name(); + record.type = request.type(); + record.username = request.username(); + record.domain = request.domain(); + record.realm = request.realm(); + record.target = request.target(); + record.protocol = request.protocol(); + record.description = request.description(); + record.expiresAt = request.expires_at(); + for (const std::string& tag : request.tags()) + { + if (!tag.empty()) + record.tags.push_back(tag); + } + for (const auto& secret : request.secrets()) + { + if (!secret.name().empty()) + record.secrets[secret.name()] = secret.value(); + } + if (record.type.empty()) + record.type = defaultTypeFromSecrets(record.secrets); + if (record.displayName.empty()) + { + record.displayName = record.domain.empty() + ? record.username + : record.domain + "\\" + record.username; + if (record.displayName.empty()) + record.displayName = record.type; + } + return record; +} + +TeamServerCredentialRecord TeamServerCredentialVaultService::recordFromJson(const json& input) const +{ + TeamServerCredentialRecord record; + record.credentialId = jsonString(input, "credential_id", jsonString(input, "id")); + record.displayName = jsonString(input, "display_name"); + record.type = jsonString(input, "type"); + record.username = jsonString(input, "username"); + record.domain = jsonString(input, "domain"); + record.realm = jsonString(input, "realm"); + record.target = jsonString(input, "target"); + record.protocol = jsonString(input, "protocol"); + record.tags = jsonStringList(input, "tags"); + record.description = jsonString(input, "description"); + record.createdAt = jsonString(input, "created_at"); + record.updatedAt = jsonString(input, "updated_at"); + record.lastUsedAt = jsonString(input, "last_used_at"); + record.expiresAt = jsonString(input, "expires_at"); + + auto secretsIt = input.find("secrets"); + if (secretsIt != input.end()) + { + if (secretsIt->is_object()) + { + for (auto it = secretsIt->begin(); it != secretsIt->end(); ++it) + { + if (it.value().is_string()) + record.secrets[it.key()] = it.value().get(); + } + } + else if (secretsIt->is_array()) + { + for (const auto& secret : *secretsIt) + { + const std::string name = jsonString(secret, "name"); + if (!name.empty()) + record.secrets[name] = jsonString(secret, "value"); + } + } + } + + for (auto it = input.begin(); it != input.end(); ++it) + { + if (it.value().is_string() && isKnownSecretField(it.key())) + record.secrets[it.key()] = it.value().get(); + } + + if (record.type.empty()) + record.type = defaultTypeFromSecrets(record.secrets); + if (record.displayName.empty()) + record.displayName = record.domain.empty() ? record.username : record.domain + "\\" + record.username; + if (record.displayName.empty()) + record.displayName = record.type; + return record; +} + +json TeamServerCredentialVaultService::recordToJson(const TeamServerCredentialRecord& record, bool revealSecrets) const +{ + json output = json::object(); + output["credential_id"] = record.credentialId; + output["display_name"] = record.displayName; + output["type"] = record.type; + output["username"] = record.username; + output["domain"] = record.domain; + output["realm"] = record.realm; + output["target"] = record.target; + output["protocol"] = record.protocol; + output["tags"] = record.tags; + output["description"] = record.description; + output["created_at"] = record.createdAt; + output["updated_at"] = record.updatedAt; + output["last_used_at"] = record.lastUsedAt; + output["expires_at"] = record.expiresAt; + output["secret_fields"] = json::array(); + for (const auto& [name, _] : record.secrets) + output["secret_fields"].push_back(name); + if (revealSecrets) + { + output["secrets"] = json::object(); + for (const auto& [name, value] : record.secrets) + output["secrets"][name] = value; + } + return output; +} + +teamserverapi::CredentialSummary TeamServerCredentialVaultService::toSummary(const TeamServerCredentialRecord& record) const +{ + teamserverapi::CredentialSummary summary; + summary.set_credential_id(record.credentialId); + summary.set_display_name(record.displayName); + summary.set_type(record.type); + summary.set_username(record.username); + summary.set_domain(record.domain); + summary.set_realm(record.realm); + summary.set_target(record.target); + summary.set_protocol(record.protocol); + summary.set_description(record.description); + summary.set_created_at(record.createdAt); + summary.set_updated_at(record.updatedAt); + summary.set_last_used_at(record.lastUsedAt); + summary.set_expires_at(record.expiresAt); + for (const std::string& tag : record.tags) + summary.add_tags(tag); + for (const auto& [name, _] : record.secrets) + summary.add_secret_fields(name); + return summary; +} + +void TeamServerCredentialVaultService::fillDetail(const TeamServerCredentialRecord& record, bool revealSecret, teamserverapi::CredentialDetail* response) const +{ + response->set_status(teamserverapi::OK); + response->clear_message(); + *response->mutable_summary() = toSummary(record); + response->clear_secrets(); + if (!revealSecret) + return; + for (const auto& [name, value] : record.secrets) + { + teamserverapi::CredentialSecret* secret = response->add_secrets(); + secret->set_name(name); + secret->set_value(value); + } +} + +bool TeamServerCredentialVaultService::matchesQuery(const TeamServerCredentialRecord& record, const teamserverapi::CredentialQuery& query) const +{ + bool tagMatches = query.tag().empty(); + for (const std::string& tag : record.tags) + { + if (toLower(tag) == toLower(query.tag())) + tagMatches = true; + } + + return matchesField(query.type(), record.type) + && containsCaseInsensitive(record.username, query.username()) + && containsCaseInsensitive(record.domain, query.domain()) + && containsCaseInsensitive(record.target, query.target()) + && matchesField(query.protocol(), record.protocol) + && tagMatches + && (containsCaseInsensitive(record.displayName, query.name_contains()) + || containsCaseInsensitive(record.username, query.name_contains()) + || containsCaseInsensitive(record.domain, query.name_contains()) + || containsCaseInsensitive(record.target, query.name_contains()) + || containsCaseInsensitive(record.credentialId, query.name_contains())); +} + +TeamServerCredentialRecord* TeamServerCredentialVaultService::findRecordLocked(const std::string& credentialId) +{ + auto it = std::find_if(m_credentials.begin(), m_credentials.end(), [&](const TeamServerCredentialRecord& record) + { + return record.credentialId == credentialId || record.credentialId.rfind(credentialId, 0) == 0; + }); + return it == m_credentials.end() ? nullptr : &(*it); +} + +const TeamServerCredentialRecord* TeamServerCredentialVaultService::findRecordLocked(const std::string& credentialId) const +{ + auto it = std::find_if(m_credentials.begin(), m_credentials.end(), [&](const TeamServerCredentialRecord& record) + { + return record.credentialId == credentialId || record.credentialId.rfind(credentialId, 0) == 0; + }); + return it == m_credentials.end() ? nullptr : &(*it); +} + +void TeamServerCredentialVaultService::appendAuditLocked(const std::string& action, const std::string& credentialId) +{ + json event = json::object(); + event["timestamp"] = currentTimestamp(); + event["action"] = action; + event["credential_id"] = credentialId; + m_audit.push_back(event); +} + +grpc::Status TeamServerCredentialVaultService::listCredentials( + const teamserverapi::CredentialQuery& query, + const CredentialEmitter& emit) +{ + std::lock_guard lock(m_mutex); + loadLocked(); + for (const TeamServerCredentialRecord& record : m_credentials) + { + if (matchesQuery(record, query) && !emit(toSummary(record))) + return grpc::Status::OK; + } + return grpc::Status::OK; +} + +grpc::Status TeamServerCredentialVaultService::getCredential( + const teamserverapi::CredentialSelector& selector, + teamserverapi::CredentialDetail* response) +{ + std::lock_guard lock(m_mutex); + loadLocked(); + const TeamServerCredentialRecord* record = findRecordLocked(selector.credential_id()); + if (!record) + { + response->set_status(teamserverapi::KO); + response->set_message("Credential not found."); + return grpc::Status::OK; + } + + fillDetail(*record, selector.reveal_secret(), response); + appendAuditLocked(selector.reveal_secret() ? "credential_revealed" : "credential_read", record->credentialId); + std::string message; + saveLocked(message); + return grpc::Status::OK; +} + +grpc::Status TeamServerCredentialVaultService::addCredential( + const teamserverapi::CredentialUpsertRequest& request, + teamserverapi::OperationAck* response) +{ + std::lock_guard lock(m_mutex); + loadLocked(); + + TeamServerCredentialRecord record = recordFromRequest(request); + record.credentialId = record.credentialId.empty() ? generateCredentialId() : record.credentialId; + if (findRecordLocked(record.credentialId)) + { + setAck(response, teamserverapi::KO, "Credential already exists."); + return grpc::Status::OK; + } + const std::string now = currentTimestamp(); + record.createdAt = now; + record.updatedAt = now; + m_credentials.push_back(std::move(record)); + appendAuditLocked("credential_created", m_credentials.back().credentialId); + + std::string message; + if (!saveLocked(message)) + { + setAck(response, teamserverapi::KO, message); + return grpc::Status::OK; + } + setAck(response, teamserverapi::OK, "Credential stored: cred:" + m_credentials.back().credentialId); + return grpc::Status::OK; +} + +grpc::Status TeamServerCredentialVaultService::updateCredential( + const teamserverapi::CredentialUpsertRequest& request, + teamserverapi::OperationAck* response) +{ + std::lock_guard lock(m_mutex); + loadLocked(); + + if (request.credential_id().empty()) + { + setAck(response, teamserverapi::KO, "Credential id is required."); + return grpc::Status::OK; + } + + TeamServerCredentialRecord* existing = findRecordLocked(request.credential_id()); + if (!existing) + { + setAck(response, teamserverapi::KO, "Credential not found."); + return grpc::Status::OK; + } + + TeamServerCredentialRecord update = recordFromRequest(request); + if (!update.displayName.empty()) existing->displayName = update.displayName; + if (!update.type.empty()) existing->type = update.type; + if (!update.username.empty()) existing->username = update.username; + if (!update.domain.empty()) existing->domain = update.domain; + if (!update.realm.empty()) existing->realm = update.realm; + if (!update.target.empty()) existing->target = update.target; + if (!update.protocol.empty()) existing->protocol = update.protocol; + if (!update.tags.empty()) existing->tags = update.tags; + if (!update.description.empty()) existing->description = update.description; + if (!update.expiresAt.empty()) existing->expiresAt = update.expiresAt; + if (request.replace_secrets()) + existing->secrets.clear(); + for (const auto& [name, value] : update.secrets) + existing->secrets[name] = value; + existing->updatedAt = currentTimestamp(); + appendAuditLocked("credential_updated", existing->credentialId); + + std::string message; + if (!saveLocked(message)) + { + setAck(response, teamserverapi::KO, message); + return grpc::Status::OK; + } + setAck(response, teamserverapi::OK, "Credential updated."); + return grpc::Status::OK; +} + +grpc::Status TeamServerCredentialVaultService::deleteCredential( + const teamserverapi::CredentialSelector& selector, + teamserverapi::OperationAck* response) +{ + std::lock_guard lock(m_mutex); + loadLocked(); + + const std::string credentialId = selector.credential_id(); + auto it = std::find_if(m_credentials.begin(), m_credentials.end(), [&](const TeamServerCredentialRecord& record) + { + return record.credentialId == credentialId || record.credentialId.rfind(credentialId, 0) == 0; + }); + if (it == m_credentials.end()) + { + setAck(response, teamserverapi::KO, "Credential not found."); + return grpc::Status::OK; + } + + const std::string removedId = it->credentialId; + m_credentials.erase(it); + appendAuditLocked("credential_deleted", removedId); + + std::string message; + if (!saveLocked(message)) + { + setAck(response, teamserverapi::KO, message); + return grpc::Status::OK; + } + setAck(response, teamserverapi::OK, "Credential deleted."); + return grpc::Status::OK; +} + +std::string TeamServerCredentialVaultService::terminalPayloadJson( + const std::vector& splitedCmd, + const teamserverapi::TerminalCommandRequest& command, + std::size_t tailIndex) const +{ + if (!command.data().empty()) + return command.data(); + + std::string raw = command.command(); + std::size_t offset = 0; + for (std::size_t i = 0; i < tailIndex && i < splitedCmd.size(); ++i) + { + offset = raw.find(splitedCmd[i], offset); + if (offset == std::string::npos) + return ""; + offset += splitedCmd[i].size(); + } + while (offset < raw.size() && std::isspace(static_cast(raw[offset]))) + ++offset; + return raw.substr(offset); +} + +std::string TeamServerCredentialVaultService::listCredentialsJsonLocked(const teamserverapi::CredentialQuery& query) const +{ + json output = json::array(); + for (const TeamServerCredentialRecord& record : m_credentials) + { + if (matchesQuery(record, query)) + output.push_back(recordToJson(record, false)); + } + return output.dump(2); +} + +grpc::Status TeamServerCredentialVaultService::handleTerminalCommand( + const std::vector& splitedCmd, + const teamserverapi::TerminalCommandRequest& command, + teamserverapi::TerminalCommandResponse* response) +{ + if (splitedCmd.empty()) + { + setTerminalError(response, "Error: missing credential command."); + return grpc::Status::OK; + } + + const std::string root = toLower(splitedCmd[0]); + if (root == "addcred") + { + const std::string payload = command.data(); + json input = json::parse(payload, nullptr, false); + if (input.is_discarded() || !input.is_object()) + { + setTerminalError(response, "Error: invalid credential payload."); + return grpc::Status::OK; + } + + teamserverapi::CredentialUpsertRequest request; + TeamServerCredentialRecord record = recordFromJson(input); + request.set_display_name(record.displayName); + request.set_type(record.type); + request.set_username(record.username); + request.set_domain(record.domain); + request.set_realm(record.realm); + request.set_target(record.target); + request.set_protocol(record.protocol); + request.set_description(record.description); + request.set_expires_at(record.expiresAt); + for (const std::string& tag : record.tags) + request.add_tags(tag); + for (const auto& [name, value] : record.secrets) + { + teamserverapi::CredentialSecret* secret = request.add_secrets(); + secret->set_name(name); + secret->set_value(value); + } + teamserverapi::OperationAck ack; + addCredential(request, &ack); + if (ack.status() == teamserverapi::OK) + setTerminalOk(response, ack.message()); + else + setTerminalError(response, "Error: " + ack.message()); + return grpc::Status::OK; + } + + if (root == "getcred") + { + std::lock_guard lock(m_mutex); + loadLocked(); + teamserverapi::CredentialQuery query; + setTerminalOk(response, listCredentialsJsonLocked(query)); + return grpc::Status::OK; + } + + if (root != "cred") + { + setTerminalError(response, "Error: unknown credential command."); + return grpc::Status::OK; + } + + if (splitedCmd.size() < 2) + { + setTerminalOk(response, + "cred \n" + "Examples:\n" + " cred list\n" + " cred list alice\n" + " cred add {\"username\":\"alice\",\"domain\":\"CORP\",\"password\":\"secret\"}\n" + " cred get --reveal\n" + " cred delete "); + return grpc::Status::OK; + } + + const std::string action = toLower(splitedCmd[1]); + if (action == "list" || action == "search") + { + std::lock_guard lock(m_mutex); + loadLocked(); + teamserverapi::CredentialQuery query; + if (splitedCmd.size() >= 3) + query.set_name_contains(splitedCmd[2]); + setTerminalOk(response, listCredentialsJsonLocked(query)); + return grpc::Status::OK; + } + + if (action == "get" || action == "reveal") + { + if (splitedCmd.size() < 3) + { + setTerminalError(response, "Error: credential id is required."); + return grpc::Status::OK; + } + teamserverapi::CredentialSelector selector; + selector.set_credential_id(splitedCmd[2]); + selector.set_reveal_secret(action == "reveal" || std::find(splitedCmd.begin(), splitedCmd.end(), "--reveal") != splitedCmd.end()); + teamserverapi::CredentialDetail detail; + getCredential(selector, &detail); + if (detail.status() != teamserverapi::OK) + { + setTerminalError(response, "Error: " + detail.message()); + return grpc::Status::OK; + } + + json output = json::object(); + output["credential_id"] = detail.summary().credential_id(); + output["display_name"] = detail.summary().display_name(); + output["type"] = detail.summary().type(); + output["username"] = detail.summary().username(); + output["domain"] = detail.summary().domain(); + output["realm"] = detail.summary().realm(); + output["target"] = detail.summary().target(); + output["protocol"] = detail.summary().protocol(); + output["secret_fields"] = json::array(); + for (const std::string& field : detail.summary().secret_fields()) + output["secret_fields"].push_back(field); + if (selector.reveal_secret()) + { + output["secrets"] = json::object(); + for (const auto& secret : detail.secrets()) + output["secrets"][secret.name()] = secret.value(); + } + setTerminalOk(response, output.dump(2)); + return grpc::Status::OK; + } + + if (action == "add") + { + const std::string payload = terminalPayloadJson(splitedCmd, command, 2); + json input = json::parse(payload, nullptr, false); + if (input.is_discarded() || !input.is_object()) + { + setTerminalError(response, "Error: invalid credential payload."); + return grpc::Status::OK; + } + TeamServerCredentialRecord record = recordFromJson(input); + teamserverapi::CredentialUpsertRequest request; + request.set_display_name(record.displayName); + request.set_type(record.type); + request.set_username(record.username); + request.set_domain(record.domain); + request.set_realm(record.realm); + request.set_target(record.target); + request.set_protocol(record.protocol); + request.set_description(record.description); + request.set_expires_at(record.expiresAt); + for (const std::string& tag : record.tags) + request.add_tags(tag); + for (const auto& [name, value] : record.secrets) + { + teamserverapi::CredentialSecret* secret = request.add_secrets(); + secret->set_name(name); + secret->set_value(value); + } + teamserverapi::OperationAck ack; + addCredential(request, &ack); + if (ack.status() == teamserverapi::OK) + setTerminalOk(response, ack.message()); + else + setTerminalError(response, "Error: " + ack.message()); + return grpc::Status::OK; + } + + if (action == "update") + { + if (splitedCmd.size() < 3) + { + setTerminalError(response, "Error: credential id is required."); + return grpc::Status::OK; + } + const std::string payload = terminalPayloadJson(splitedCmd, command, 3); + json input = json::parse(payload, nullptr, false); + if (input.is_discarded() || !input.is_object()) + { + setTerminalError(response, "Error: invalid credential payload."); + return grpc::Status::OK; + } + TeamServerCredentialRecord record = recordFromJson(input); + teamserverapi::CredentialUpsertRequest request; + request.set_credential_id(splitedCmd[2]); + request.set_display_name(record.displayName); + request.set_type(record.type); + request.set_username(record.username); + request.set_domain(record.domain); + request.set_realm(record.realm); + request.set_target(record.target); + request.set_protocol(record.protocol); + request.set_description(record.description); + request.set_expires_at(record.expiresAt); + for (const std::string& tag : record.tags) + request.add_tags(tag); + for (const auto& [name, value] : record.secrets) + { + teamserverapi::CredentialSecret* secret = request.add_secrets(); + secret->set_name(name); + secret->set_value(value); + } + teamserverapi::OperationAck ack; + updateCredential(request, &ack); + if (ack.status() == teamserverapi::OK) + setTerminalOk(response, ack.message()); + else + setTerminalError(response, "Error: " + ack.message()); + return grpc::Status::OK; + } + + if (action == "delete") + { + if (splitedCmd.size() < 3) + { + setTerminalError(response, "Error: credential id is required."); + return grpc::Status::OK; + } + teamserverapi::CredentialSelector selector; + selector.set_credential_id(splitedCmd[2]); + teamserverapi::OperationAck ack; + deleteCredential(selector, &ack); + if (ack.status() == teamserverapi::OK) + setTerminalOk(response, ack.message()); + else + setTerminalError(response, "Error: " + ack.message()); + return grpc::Status::OK; + } + + setTerminalError(response, "Error: unknown credential action."); + return grpc::Status::OK; +} diff --git a/teamServer/teamServer/TeamServerCredentialVaultService.hpp b/teamServer/teamServer/TeamServerCredentialVaultService.hpp new file mode 100644 index 0000000..98dcaaf --- /dev/null +++ b/teamServer/teamServer/TeamServerCredentialVaultService.hpp @@ -0,0 +1,99 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +#include "TeamServerApi.pb.h" +#include "TeamServerRuntimeConfig.hpp" +#include "nlohmann/json.hpp" + +namespace spdlog +{ +class logger; +} + +struct TeamServerCredentialRecord +{ + std::string credentialId; + std::string displayName; + std::string type; + std::string username; + std::string domain; + std::string realm; + std::string target; + std::string protocol; + std::vector tags; + std::string description; + std::string createdAt; + std::string updatedAt; + std::string lastUsedAt; + std::string expiresAt; + std::map secrets; +}; + +class TeamServerCredentialVaultService +{ +public: + using CredentialEmitter = std::function; + + TeamServerCredentialVaultService( + std::shared_ptr logger, + TeamServerRuntimeConfig runtimeConfig); + + grpc::Status listCredentials( + const teamserverapi::CredentialQuery& query, + const CredentialEmitter& emit); + grpc::Status getCredential( + const teamserverapi::CredentialSelector& selector, + teamserverapi::CredentialDetail* response); + grpc::Status addCredential( + const teamserverapi::CredentialUpsertRequest& request, + teamserverapi::OperationAck* response); + grpc::Status updateCredential( + const teamserverapi::CredentialUpsertRequest& request, + teamserverapi::OperationAck* response); + grpc::Status deleteCredential( + const teamserverapi::CredentialSelector& selector, + teamserverapi::OperationAck* response); + + grpc::Status handleTerminalCommand( + const std::vector& splitedCmd, + const teamserverapi::TerminalCommandRequest& command, + teamserverapi::TerminalCommandResponse* response); + +private: + void loadLocked(); + bool saveLocked(std::string& message) const; + bool ensureVaultKeyLocked(std::vector& key, std::string& message) const; + bool readVaultKeyLocked(std::vector& key, std::string& message) const; + bool writeNewVaultKeyLocked(std::vector& key, std::string& message) const; + bool encryptVaultLocked(const nlohmann::json& plainVault, nlohmann::json& encryptedVault, std::string& message) const; + bool decryptVaultLocked(const nlohmann::json& encryptedVault, nlohmann::json& plainVault, std::string& message) const; + + TeamServerCredentialRecord recordFromRequest(const teamserverapi::CredentialUpsertRequest& request) const; + TeamServerCredentialRecord recordFromJson(const nlohmann::json& input) const; + nlohmann::json recordToJson(const TeamServerCredentialRecord& record, bool revealSecrets) const; + teamserverapi::CredentialSummary toSummary(const TeamServerCredentialRecord& record) const; + void fillDetail(const TeamServerCredentialRecord& record, bool revealSecret, teamserverapi::CredentialDetail* response) const; + bool matchesQuery(const TeamServerCredentialRecord& record, const teamserverapi::CredentialQuery& query) const; + TeamServerCredentialRecord* findRecordLocked(const std::string& credentialId); + const TeamServerCredentialRecord* findRecordLocked(const std::string& credentialId) const; + std::string generateCredentialId() const; + std::string currentTimestamp() const; + std::string terminalPayloadJson(const std::vector& splitedCmd, const teamserverapi::TerminalCommandRequest& command, std::size_t tailIndex) const; + std::string listCredentialsJsonLocked(const teamserverapi::CredentialQuery& query) const; + void appendAuditLocked(const std::string& action, const std::string& credentialId); + + std::shared_ptr m_logger; + TeamServerRuntimeConfig m_runtimeConfig; + mutable std::mutex m_mutex; + bool m_loaded = false; + std::vector m_credentials; + nlohmann::json m_audit = nlohmann::json::array(); +}; diff --git a/teamServer/teamServer/TeamServerRuntimeConfig.cpp b/teamServer/teamServer/TeamServerRuntimeConfig.cpp index 79d8039..1a625c8 100644 --- a/teamServer/teamServer/TeamServerRuntimeConfig.cpp +++ b/teamServer/teamServer/TeamServerRuntimeConfig.cpp @@ -107,6 +107,16 @@ TeamServerRuntimeConfig TeamServerRuntimeConfig::fromJson(const nlohmann::json& jsonString(config, "GeneratedArtifactsDirectoryPath", childPath(runtimeConfig.dataRoot, "GeneratedArtifacts"))); runtimeConfig.hostedArtifactsDirectoryPath = ensureTrailingSeparator( jsonString(config, "HostedArtifactsDirectoryPath", childPath(runtimeConfig.generatedArtifactsDirectoryPath, "hosted"))); + runtimeConfig.credentialVaultDirectoryPath = ensureTrailingSeparator( + jsonString(config, "CredentialVaultDirectoryPath", childPath(runtimeConfig.dataRoot, "CredentialVault"))); + runtimeConfig.credentialVaultPath = jsonString( + config, + "CredentialVaultPath", + (fs::path(runtimeConfig.credentialVaultDirectoryPath) / "vault.json").string()); + runtimeConfig.credentialVaultKeyFile = jsonString( + config, + "CredentialVaultKeyFile", + (fs::path(runtimeConfig.credentialVaultDirectoryPath) / "vault.key").string()); if (auto it = config.find("DefaultWindowsArch"); it != config.end() && it->is_string()) runtimeConfig.defaultWindowsArch = normalizeWindowsArch(it->get()); @@ -225,6 +235,7 @@ void TeamServerRuntimeConfig::validateDirectories(const std::shared_ptrerror("Command specs directory path don't exist: {0}", commandSpecsDirectoryPath.c_str()); diff --git a/teamServer/teamServer/TeamServerRuntimeConfig.hpp b/teamServer/teamServer/TeamServerRuntimeConfig.hpp index 78c582f..7f1e006 100644 --- a/teamServer/teamServer/TeamServerRuntimeConfig.hpp +++ b/teamServer/teamServer/TeamServerRuntimeConfig.hpp @@ -28,6 +28,9 @@ struct TeamServerRuntimeConfig std::string uploadedArtifactsDirectoryPath = "../data/UploadedArtifacts/"; std::string generatedArtifactsDirectoryPath = "../data/GeneratedArtifacts/"; std::string hostedArtifactsDirectoryPath = "../data/GeneratedArtifacts/hosted/"; + std::string credentialVaultDirectoryPath = "../data/CredentialVault/"; + std::string credentialVaultPath = "../data/CredentialVault/vault.json"; + std::string credentialVaultKeyFile = "../data/CredentialVault/vault.key"; std::string defaultWindowsArch = "x64"; std::string defaultLinuxArch = "x64"; std::vector supportedWindowsArchs = {"x86", "x64", "arm64"}; diff --git a/teamServer/teamServer/TeamServerTermLocalService.cpp b/teamServer/teamServer/TeamServerTermLocalService.cpp index 49a9a81..5c44321 100644 --- a/teamServer/teamServer/TeamServerTermLocalService.cpp +++ b/teamServer/teamServer/TeamServerTermLocalService.cpp @@ -7,6 +7,7 @@ #include #include "TeamServerArtifactCatalog.hpp" +#include "TeamServerCredentialVaultService.hpp" #include "TeamServerModuleLoader.hpp" #include "listener/ListenerHttp.hpp" using json = nlohmann::json; @@ -20,6 +21,7 @@ const std::string ReloadModulesInstruction = "reloadModules"; const std::string BatcaveInstruction = "batcaveUpload"; const std::string AddCredentialInstruction = "addCred"; const std::string GetCredentialInstruction = "getCred"; +const std::string CredentialVaultInstruction = "cred"; void setTerminalOk(teamserverapi::TerminalCommandResponse* response, const std::string& result) { @@ -75,13 +77,15 @@ TeamServerTermLocalService::TeamServerTermLocalService( std::vector>& listeners, nlohmann::json& credentials, std::vector>& moduleCmd, - ModuleLoader moduleLoader) + ModuleLoader moduleLoader, + std::shared_ptr credentialVaultService) : m_logger(std::move(logger)), m_config(config), m_runtimeConfig(std::move(runtimeConfig)), m_listeners(listeners), m_credentials(credentials), m_moduleCmd(moduleCmd), + m_credentialVaultService(std::move(credentialVaultService)), m_moduleLoader(std::move(moduleLoader)) { } @@ -93,6 +97,7 @@ bool TeamServerTermLocalService::canHandle(const std::string& instruction) const || instruction == BatcaveInstruction || instruction == AddCredentialInstruction || instruction == GetCredentialInstruction + || instruction == CredentialVaultInstruction || instruction == ReloadModulesInstruction; } @@ -114,6 +119,8 @@ grpc::Status TeamServerTermLocalService::handleCommand( return handlePutIntoUploadDir(splitedCmd, command, response); if (instruction == BatcaveInstruction) return handleBatcaveUpload(splitedCmd, command, response); + if (instruction == CredentialVaultInstruction) + return handleCredentialVault(splitedCmd, command, response); if (instruction == AddCredentialInstruction) return handleAddCredential(command, response); if (instruction == GetCredentialInstruction) @@ -380,6 +387,9 @@ grpc::Status TeamServerTermLocalService::handleAddCredential( const teamserverapi::TerminalCommandRequest& command, teamserverapi::TerminalCommandResponse* response) { + if (m_credentialVaultService) + return m_credentialVaultService->handleTerminalCommand({AddCredentialInstruction}, command, response); + m_logger->debug("AddCredentials command received"); json cred; @@ -401,11 +411,31 @@ grpc::Status TeamServerTermLocalService::handleAddCredential( grpc::Status TeamServerTermLocalService::handleGetCredential(teamserverapi::TerminalCommandResponse* response) { + if (m_credentialVaultService) + { + teamserverapi::TerminalCommandRequest command; + command.set_command(GetCredentialInstruction); + return m_credentialVaultService->handleTerminalCommand({GetCredentialInstruction}, command, response); + } + m_logger->debug("GetCredentials command received"); setTerminalOk(response, m_credentials.dump()); return grpc::Status::OK; } +grpc::Status TeamServerTermLocalService::handleCredentialVault( + const std::vector& splitedCmd, + const teamserverapi::TerminalCommandRequest& command, + teamserverapi::TerminalCommandResponse* response) +{ + if (!m_credentialVaultService) + { + setTerminalError(response, "Error: credential vault service is not available."); + return grpc::Status::OK; + } + return m_credentialVaultService->handleTerminalCommand(splitedCmd, command, response); +} + grpc::Status TeamServerTermLocalService::handleReloadModules(teamserverapi::TerminalCommandResponse* response) { m_logger->info("Reloading TeamServer modules from directory: {0}", m_runtimeConfig.teamServerModulesDirectoryPath.c_str()); diff --git a/teamServer/teamServer/TeamServerTermLocalService.hpp b/teamServer/teamServer/TeamServerTermLocalService.hpp index f5c0e1e..15c18ae 100644 --- a/teamServer/teamServer/TeamServerTermLocalService.hpp +++ b/teamServer/teamServer/TeamServerTermLocalService.hpp @@ -14,6 +14,8 @@ #include "nlohmann/json.hpp" #include "spdlog/logger.h" +class TeamServerCredentialVaultService; + class TeamServerTermLocalService { public: @@ -26,7 +28,8 @@ class TeamServerTermLocalService std::vector>& listeners, nlohmann::json& credentials, std::vector>& moduleCmd, - ModuleLoader moduleLoader = {}); + ModuleLoader moduleLoader = {}, + std::shared_ptr credentialVaultService = {}); bool canHandle(const std::string& instruction) const; grpc::Status handleCommand( @@ -54,6 +57,10 @@ class TeamServerTermLocalService const teamserverapi::TerminalCommandRequest& command, teamserverapi::TerminalCommandResponse* response); grpc::Status handleGetCredential(teamserverapi::TerminalCommandResponse* response); + grpc::Status handleCredentialVault( + const std::vector& splitedCmd, + const teamserverapi::TerminalCommandRequest& command, + teamserverapi::TerminalCommandResponse* response); grpc::Status handleReloadModules(teamserverapi::TerminalCommandResponse* response); std::shared_ptr m_logger; @@ -62,5 +69,6 @@ class TeamServerTermLocalService std::vector>& m_listeners; nlohmann::json& m_credentials; std::vector>& m_moduleCmd; + std::shared_ptr m_credentialVaultService; ModuleLoader m_moduleLoader; }; diff --git a/teamServer/tests/TeamServerCredentialVaultServiceTests.cpp b/teamServer/tests/TeamServerCredentialVaultServiceTests.cpp new file mode 100644 index 0000000..9dac80b --- /dev/null +++ b/teamServer/tests/TeamServerCredentialVaultServiceTests.cpp @@ -0,0 +1,159 @@ +#include +#include +#include +#include +#include +#include + +#include "TeamServerCredentialVaultService.hpp" +#include "spdlog/logger.h" + +namespace fs = std::filesystem; + +namespace +{ +class ScopedPath +{ +public: + explicit ScopedPath(fs::path path) + : m_path(std::move(path)) + { + } + + ~ScopedPath() + { + std::error_code ec; + fs::remove_all(m_path, ec); + } + + const fs::path& path() const + { + return m_path; + } + +private: + fs::path m_path; +}; + +fs::path makeTempDirectory(const std::string& name) +{ + fs::path root = fs::temp_directory_path() / ("c2teamserver-credential-vault-" + name + "-" + std::to_string(::getpid())); + fs::create_directories(root); + return root; +} + +std::shared_ptr makeLogger() +{ + auto logger = std::make_shared("credential-vault-tests"); + logger->set_level(spdlog::level::off); + return logger; +} + +void require(bool condition, const std::string& message) +{ + if (!condition) + throw std::runtime_error(message); +} + +TeamServerRuntimeConfig makeRuntimeConfig(const fs::path& root) +{ + TeamServerRuntimeConfig runtimeConfig; + runtimeConfig.dataRoot = root.string(); + runtimeConfig.credentialVaultDirectoryPath = (root / "CredentialVault").string(); + runtimeConfig.credentialVaultPath = (root / "CredentialVault" / "vault.json").string(); + runtimeConfig.credentialVaultKeyFile = (root / "CredentialVault" / "vault.key").string(); + fs::create_directories(runtimeConfig.credentialVaultDirectoryPath); + return runtimeConfig; +} + +void testAddListRevealAndPersistence() +{ + ScopedPath tempRoot(makeTempDirectory("basic")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + + TeamServerCredentialVaultService service(makeLogger(), runtimeConfig); + teamserverapi::CredentialUpsertRequest addRequest; + addRequest.set_display_name("corp alice"); + addRequest.set_type("password"); + addRequest.set_username("alice"); + addRequest.set_domain("CORP"); + addRequest.set_protocol("smb"); + teamserverapi::CredentialSecret* password = addRequest.add_secrets(); + password->set_name("password"); + password->set_value("secret-value"); + + teamserverapi::OperationAck ack; + require(service.addCredential(addRequest, &ack).ok(), "addCredential RPC status failed"); + require(ack.status() == teamserverapi::OK, "addCredential ack failed: " + ack.message()); + require(ack.message().find("cred:") != std::string::npos, "addCredential did not return credential reference"); + + std::vector summaries; + teamserverapi::CredentialQuery query; + require(service.listCredentials(query, [&](const teamserverapi::CredentialSummary& summary) + { + summaries.push_back(summary); + return true; + }).ok(), "listCredentials RPC status failed"); + require(summaries.size() == 1, "listCredentials did not return stored credential"); + require(summaries[0].username() == "alice", "stored username mismatch"); + require(summaries[0].secret_fields_size() == 1, "secret fields metadata missing"); + + teamserverapi::CredentialSelector selector; + selector.set_credential_id(summaries[0].credential_id().substr(0, 8)); + selector.set_reveal_secret(false); + teamserverapi::CredentialDetail detail; + require(service.getCredential(selector, &detail).ok(), "getCredential hidden RPC status failed"); + require(detail.status() == teamserverapi::OK, "getCredential hidden failed: " + detail.message()); + require(detail.secrets_size() == 0, "hidden credential unexpectedly revealed secrets"); + + selector.set_reveal_secret(true); + require(service.getCredential(selector, &detail).ok(), "getCredential reveal RPC status failed"); + require(detail.status() == teamserverapi::OK, "getCredential reveal failed: " + detail.message()); + require(detail.secrets_size() == 1, "credential reveal did not return secret"); + bool revealedPassword = false; + for (const auto& secret : detail.secrets()) + revealedPassword = revealedPassword || (secret.name() == "password" && secret.value() == "secret-value"); + require(revealedPassword, "revealed password mismatch"); + + std::ifstream vaultFile(runtimeConfig.credentialVaultPath); + std::string vaultContent((std::istreambuf_iterator(vaultFile)), std::istreambuf_iterator()); + require(vaultContent.find("secret-value") == std::string::npos, "vault file contains plaintext secret"); + + TeamServerCredentialVaultService reloadedService(makeLogger(), runtimeConfig); + std::vector reloaded; + require(reloadedService.listCredentials(teamserverapi::CredentialQuery(), [&](const teamserverapi::CredentialSummary& summary) + { + reloaded.push_back(summary); + return true; + }).ok(), "reloaded listCredentials RPC status failed"); + require(reloaded.size() == 1, "credential was not persisted"); +} + +void testTerminalIntegration() +{ + ScopedPath tempRoot(makeTempDirectory("terminal")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + TeamServerCredentialVaultService service(makeLogger(), runtimeConfig); + + teamserverapi::TerminalCommandRequest addCommand; + addCommand.set_command("cred add"); + addCommand.set_data(R"({"username":"bob","domain":"CORP","password":"super-secret"})"); + teamserverapi::TerminalCommandResponse response; + require(service.handleTerminalCommand({"cred", "add"}, addCommand, &response).ok(), "terminal cred add RPC status failed"); + require(response.status() == teamserverapi::OK, "terminal cred add failed: " + response.message()); + + teamserverapi::TerminalCommandRequest listCommand; + listCommand.set_command("cred list bob"); + require(service.handleTerminalCommand({"cred", "list", "bob"}, listCommand, &response).ok(), "terminal cred list RPC status failed"); + require(response.status() == teamserverapi::OK, "terminal cred list failed: " + response.message()); + require(response.result().find("bob") != std::string::npos, "terminal cred list missing username"); + require(response.result().find("super-secret") == std::string::npos, "terminal cred list leaked secret"); +} +} // namespace + +int main() +{ + testAddListRevealAndPersistence(); + testTerminalIntegration(); + return 0; +} diff --git a/thirdParty/donut b/thirdParty/donut index cddbff4..728289d 160000 --- a/thirdParty/donut +++ b/thirdParty/donut @@ -1 +1 @@ -Subproject commit cddbff4ce256c98233dc55b0227efcfa25c77f93 +Subproject commit 728289d3d8b694c642c6f7f7c79b7bce62f788eb From 83132028b9dde48407ba268c134ca963f92bef38 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Wed, 13 May 2026 09:46:45 +0200 Subject: [PATCH 2/5] Vault --- C2Client/C2Client/ConsolePanel.py | 100 ++- C2Client/C2Client/CredentialVaultPanel.py | 638 ++++++++++++++++++ .../TerminalModules/Credentials/.gitignore | 1 - .../Credentials/credentials.py | 107 --- C2Client/C2Client/TerminalPanel.py | 115 +--- C2Client/TODO.md | 2 +- C2Client/tests/test_console_panel.py | 59 +- C2Client/tests/test_credential_vault_panel.py | 287 ++++++++ .../tests/test_terminal_panel_dropper_arch.py | 1 + core | 2 +- protocol/TeamServerApi.proto | 2 + teamServer/teamServer/TeamServer.cpp | 3 +- .../teamServer/TeamServerCommandCatalog.cpp | 37 + .../teamServer/TeamServerCommandCatalog.hpp | 15 + .../TeamServerCommandCatalogService.cpp | 26 + .../TeamServerCommandPreparationService.cpp | 190 +++++- .../TeamServerCommandPreparationService.hpp | 7 +- .../TeamServerCredentialVaultService.cpp | 7 + .../tests/TeamServerCommandCatalogTests.cpp | 55 +- ...amServerCommandPreparationServiceTests.cpp | 158 ++++- .../TeamServerCredentialVaultServiceTests.cpp | 76 +++ 21 files changed, 1637 insertions(+), 251 deletions(-) create mode 100644 C2Client/C2Client/CredentialVaultPanel.py delete mode 100644 C2Client/C2Client/TerminalModules/Credentials/.gitignore delete mode 100644 C2Client/C2Client/TerminalModules/Credentials/credentials.py create mode 100644 C2Client/tests/test_credential_vault_panel.py diff --git a/C2Client/C2Client/ConsolePanel.py b/C2Client/C2Client/ConsolePanel.py index 4d5e331..69e62e6 100644 --- a/C2Client/C2Client/ConsolePanel.py +++ b/C2Client/C2Client/ConsolePanel.py @@ -28,7 +28,7 @@ from .AssistantPanel import Assistant from .ArtifactPanel import Artifacts, ArtifactTabTitle from .CommandPanel import Commands, CommandTabTitle -from .TerminalModules.Credentials import credentials +from .CredentialVaultPanel import CredentialVault, CredentialVaultTabTitle from .console_style import ( CONSOLE_COLORS, apply_console_output_style, @@ -70,7 +70,7 @@ # Constant # TerminalTabTitle = "Terminal" -SYSTEM_TAB_COUNT = 5 +SYSTEM_TAB_COUNT = 6 CmdHistoryFileName = ".cmdHistory" HelpInstruction = "help" @@ -202,7 +202,10 @@ def _arg_name(arg: Any) -> str: def _command_has_artifact_args(command: Any) -> bool: - return any(_arg_has_artifact_filter(arg) for arg in getattr(command, "args", [])) + return any( + _arg_has_artifact_filter(arg) or _arg_has_credential_filter(arg) + for arg in getattr(command, "args", []) + ) def _flag_is_context_only(arg: Any) -> bool: @@ -297,6 +300,24 @@ def _add_artifact_completions( _add_inject_pid_continuations(artifact_entry[1], arg) +def _credential_completion_values(credential: Any) -> list[str]: + credential_id = str(getattr(credential, "credential_id", "") or "").strip() + if not credential_id: + return [] + short_id = credential_id[:8] if len(credential_id) > 8 else credential_id + return [f"cred:{short_id}"] + + +def _add_credential_completions( + children: list[tuple[str, list]], + grpcClient: Any, + arg: Any, +) -> None: + for credential in _load_credentials_for_arg(grpcClient, arg): + for value in _credential_completion_values(credential): + _add_completion_value(children, value) + + def _build_flag_entries( args: list[Any], grpcClient: Any = None, @@ -320,6 +341,7 @@ def _build_flag_entries( for value in getattr(arg, "values", []): _add_completion_value(flag_entry[1], value) _add_artifact_completions(flag_entry[1], grpcClient, arg, session, command_name) + _add_credential_completions(flag_entry[1], grpcClient, arg) if command_name == "inject" and name == "--pid": _add_completion_path(flag_entry[1], [PID_COMPLETION_PLACEHOLDER]) @@ -371,9 +393,12 @@ def _add_arg_completions( if first_positional_done: continue + if _arg_completion_parents(arg): + continue for value in getattr(arg, "values", []): _add_completion_value(children, value) _add_artifact_completions(children, grpcClient, arg, session, command_name) + _add_credential_completions(children, grpcClient, arg) first_positional_done = True for arg in args: @@ -385,6 +410,7 @@ def _add_arg_completions( for value in getattr(arg, "values", []): _add_completion_value(parent_entry[1], value) _add_artifact_completions(parent_entry[1], grpcClient, arg, session, command_name) + _add_credential_completions(parent_entry[1], grpcClient, arg) def _normalized_module_name(value: Any) -> str: @@ -485,6 +511,35 @@ def _arg_has_artifact_filter(arg: Any) -> bool: return bool(_artifact_filters_for_arg(arg)) +def _credential_filters_for_arg(arg: Any) -> list[Any]: + credential_filters = getattr(arg, "credential_filters", None) + if credential_filters is not None: + try: + filters = [credential_filter for credential_filter in credential_filters if credential_filter is not None] + except TypeError: + filters = [] + if filters: + return filters + + if not hasattr(arg, "credential_filter"): + return [] + + credential_filter = getattr(arg, "credential_filter", None) + if credential_filter is None: + return [] + if hasattr(arg, "HasField"): + try: + if not arg.HasField("credential_filter"): + return [] + except ValueError: + pass + return [credential_filter] + + +def _arg_has_credential_filter(arg: Any) -> bool: + return bool(_credential_filters_for_arg(arg)) + + def _arg_completion_parents(arg: Any) -> list[str]: try: parents = getattr(arg, "completion_parents", []) @@ -505,6 +560,17 @@ def _artifact_query_from_filter(artifact_filter: Any, session: Any | None) -> An return query +def _credential_query_from_filter(credential_filter: Any) -> Any: + query = TeamServerApi_pb2.CredentialQuery() + for field_name in ("type", "username", "domain", "target", "protocol", "tag", "name_contains"): + value = str(getattr(credential_filter, field_name, "") or "").strip() + if value: + setattr(query, field_name, value) + if bool(getattr(credential_filter, "include_expired", False)): + query.include_expired = True + return query + + def _load_commands(grpcClient: Any) -> list[Any]: if grpcClient is None or not hasattr(grpcClient, "listCommands"): return [] @@ -576,6 +642,26 @@ def _load_artifacts_for_arg(grpcClient: Any, arg: Any, session: Any | None) -> l return artifacts +def _load_credentials_for_arg(grpcClient: Any, arg: Any) -> list[Any]: + if grpcClient is None or not hasattr(grpcClient, "listCredentials") or not _arg_has_credential_filter(arg): + return [] + + credentials: list[Any] = [] + seen: set[str] = set() + for credential_filter in _credential_filters_for_arg(arg): + try: + query = _credential_query_from_filter(credential_filter) + for credential in grpcClient.listCredentials(query): + credential_id = str(getattr(credential, "credential_id", "") or "").strip() + if not credential_id or credential_id in seen: + continue + seen.add(credential_id) + credentials.append(credential) + except Exception as exc: + logger.debug("Command autocomplete could not load credential context: %s", exc) + return credentials + + def _module_command_names(command_specs: list[Any]) -> list[str]: return _dedupe_values([ getattr(command, "name", "") @@ -866,6 +952,11 @@ def __init__(self, parent, grpcClient): self.tabs.addTab(tab, ArtifactTabTitle) self.tabs.setCurrentIndex(self.tabs.count()-1) + self.credentialVault = CredentialVault(self, self.grpcClient) + tab = self.createConsolePage(self.credentialVault) + self.tabs.addTab(tab, CredentialVaultTabTitle) + self.tabs.setCurrentIndex(self.tabs.count()-1) + self.commands = Commands(self, self.grpcClient) tab = self.createConsolePage(self.commands) self.tabs.addTab(tab, CommandTabTitle) @@ -1396,9 +1487,6 @@ def displayResponse(self, response=None): if not response_ok: decoded_response = response_message(response) or decoded_response or "Command failed." self.consoleScriptSignal.emit("receive", self.beaconHash, listener_hash, context, command_text, decoded_response, command_id) - # check the response for mimikatz and not the cmd line ??? - if "-e mimikatz.exe" in command_text: - credentials.handleMimikatzCredentials(decoded_response, self.grpcClient, TeamServerApi_pb2) status = "done" if response_ok else "error" self.setCommandStatus(command_id, status, command_text, decoded_response if not response_ok else "") self.printCommandStatusInTerminal(command_id, status, command_text) diff --git a/C2Client/C2Client/CredentialVaultPanel.py b/C2Client/C2Client/CredentialVaultPanel.py new file mode 100644 index 0000000..eaa6e97 --- /dev/null +++ b/C2Client/C2Client/CredentialVaultPanel.py @@ -0,0 +1,638 @@ +from __future__ import annotations + +from typing import Any + +from PyQt6.QtCore import Qt +from PyQt6.QtWidgets import ( + QAbstractItemView, + QCheckBox, + QComboBox, + QDialog, + QDialogButtonBox, + QFormLayout, + QFrame, + QGridLayout, + QHBoxLayout, + QHeaderView, + QLabel, + QLineEdit, + QMessageBox, + QPushButton, + QSizePolicy, + QTableWidget, + QTableWidgetItem, + QTextEdit, + QVBoxLayout, + QWidget, +) + +from .console_style import apply_console_output_style +from .grpcClient import TeamServerApi_pb2 +from .grpc_status import is_response_ok, operation_ack_text, response_message +from .panel_style import apply_dark_panel_style +from .ui_status import StatusKind, apply_status, compact_message + + +CredentialVaultTabTitle = "Vault" + +ALL_FILTER = "All" +TYPE_FILTERS = [ALL_FILTER, "password", "ntlm_hash", "token", "ssh_key", "custom"] +ENTRY_TYPES = ["password", "ntlm_hash", "token", "ssh_key", "custom"] + +COL_TITLE = 0 +COL_USERNAME = 1 +COL_TYPE = 2 +COL_SECRET = 3 +COL_NOTES = 4 +COL_MODIFIED = 5 + +SECRET_NAME_BY_TYPE = { + "password": "password", + "ntlm_hash": "ntlm", + "token": "token", + "ssh_key": "private_key", +} + + +def _text(value: Any) -> str: + return str(value or "").strip() + + +def _field(value: Any, name: str, default: Any = "") -> Any: + return getattr(value, name, default) + + +def _list_field(value: Any, name: str) -> list[Any]: + field = _field(value, name, []) + try: + return list(field) + except TypeError: + return [] + + +def _first_text(values: list[Any]) -> str: + for value in values: + text = _text(value) + if text: + return text + return "" + + +def secret_name_for_type(credential_type: str, existing_secret_name: str = "") -> str: + normalized_type = _text(credential_type).lower() + if existing_secret_name and normalized_type == "custom": + return existing_secret_name + return SECRET_NAME_BY_TYPE.get(normalized_type, existing_secret_name or "secret") + + +def first_secret_value(secrets: list[Any]) -> tuple[str, str]: + for secret in secrets: + name = _text(_field(secret, "name")) + value = str(_field(secret, "value", "")) + if name: + return name, value + return "", "" + + +class CredentialEntryDialog(QDialog): + def __init__( + self, + parent: QWidget | None, + *, + title: str, + credential: Any | None = None, + secret_name: str = "", + secret_value: str = "", + require_secret: bool = False, + ) -> None: + super().__init__(parent) + self.setWindowTitle(title) + self.setModal(True) + self.setMinimumSize(680, 480) + self.resize(740, 520) + self.secretName = secret_name + self.requireSecret = require_secret + apply_dark_panel_style(self) + + layout = QVBoxLayout(self) + layout.setContentsMargins(10, 10, 10, 10) + layout.setSpacing(8) + + form = QFormLayout() + form.setHorizontalSpacing(8) + form.setVerticalSpacing(6) + + self.titleInput = QLineEdit(self) + self.titleInput.setPlaceholderText("Title") + self.usernameInput = QLineEdit(self) + self.usernameInput.setPlaceholderText("Username or DOMAIN\\username") + self.typeInput = QComboBox(self) + self.typeInput.addItems(ENTRY_TYPES) + self.typeInput.setEditable(True) + self.secretInput = QLineEdit(self) + self.secretInput.setPlaceholderText("Secret value") + self.secretInput.setEchoMode(QLineEdit.EchoMode.Password) + self.showSecretCheck = QCheckBox("Show", self) + self.showSecretCheck.stateChanged.connect(self.toggleSecretVisibility) + secretRow = QHBoxLayout() + secretRow.setSpacing(6) + secretRow.addWidget(self.secretInput, 1) + secretRow.addWidget(self.showSecretCheck) + + self.notesInput = QTextEdit(self) + self.notesInput.setPlaceholderText("Notes / description") + self.notesInput.setMinimumHeight(150) + apply_console_output_style(self.notesInput) + + form.addRow("Title", self.titleInput) + form.addRow("Username", self.usernameInput) + form.addRow("Type", self.typeInput) + form.addRow("Secret", secretRow) + form.addRow("Notes", self.notesInput) + layout.addLayout(form) + + self.statusLabel = QLabel("", self) + layout.addWidget(self.statusLabel) + + buttons = QDialogButtonBox( + QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel, + self, + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + if credential is not None: + self.titleInput.setText(_text(_field(credential, "display_name"))) + self.usernameInput.setText(_text(_field(credential, "username"))) + self.typeInput.setEditText(_text(_field(credential, "type")) or "password") + self.notesInput.setPlainText(_text(_field(credential, "description"))) + else: + self.typeInput.setEditText("password") + + if secret_value: + self.secretInput.setText(secret_value) + elif not require_secret: + self.secretInput.setPlaceholderText("Leave empty to keep current secret") + + def toggleSecretVisibility(self) -> None: + self.secretInput.setEchoMode( + QLineEdit.EchoMode.Normal + if self.showSecretCheck.isChecked() + else QLineEdit.EchoMode.Password + ) + + def values(self) -> dict[str, str]: + return { + "title": self.titleInput.text().strip(), + "username": self.usernameInput.text().strip(), + "type": self.typeInput.currentText().strip(), + "secret_name": self.secretName, + "secret": self.secretInput.text(), + "description": self.notesInput.toPlainText().strip(), + } + + def accept(self) -> None: + values = self.values() + if not values["title"]: + apply_status(self.statusLabel, "Title is required.", StatusKind.ERROR) + return + if not values["type"]: + apply_status(self.statusLabel, "Type is required.", StatusKind.ERROR) + return + if self.requireSecret and not values["secret"]: + apply_status(self.statusLabel, "Secret value is required.", StatusKind.ERROR) + return + super().accept() + + +class CredentialVault(QWidget): + COLUMN_WIDTHS = [240, 180, 104, 90, 360, 150] + STRETCH_COLUMN = COL_NOTES + + def __init__(self, parent: QWidget | None, grpcClient: Any) -> None: + super().__init__(parent) + self.grpcClient = grpcClient + self.credentials: list[Any] = [] + self.selectedCredentialId = "" + self.detailSecretRevealed = False + apply_dark_panel_style(self) + + self.layout = QVBoxLayout(self) + self.layout.setContentsMargins(0, 0, 0, 0) + self.layout.setSpacing(6) + + toolbar = QHBoxLayout() + toolbar.setSpacing(6) + self.typeFilter = self.createFilter(TYPE_FILTERS, "Filter by credential type.") + self.usernameFilter = self.createLineEdit("Username", "Filter by username.") + self.searchInput = self.createLineEdit("Search title / notes", "Filter by title, username, notes, or id.") + self.refreshButton = self.createToolbarButton("Refresh", "Refresh credential vault.", width=72) + self.refreshButton.clicked.connect(self.refreshCredentials) + self.newButton = self.createToolbarButton("New", "Create a credential entry.", width=64) + self.newButton.clicked.connect(self.addCredential) + self.editButton = self.createToolbarButton("Edit", "Edit selected credential entry.", width=64) + self.editButton.clicked.connect(self.editSelectedCredential) + self.deleteButton = self.createToolbarButton("Delete", "Delete selected credential.", width=72) + self.deleteButton.clicked.connect(self.deleteSelectedCredential) + + toolbar.addWidget(QLabel("Type")) + toolbar.addWidget(self.typeFilter) + toolbar.addWidget(self.usernameFilter) + toolbar.addWidget(self.searchInput, 1) + toolbar.addWidget(self.refreshButton) + toolbar.addWidget(self.newButton) + toolbar.addWidget(self.editButton) + toolbar.addWidget(self.deleteButton) + self.layout.addLayout(toolbar) + + self.statusLabel = QLabel("") + self.statusLabel.setMinimumHeight(18) + self.layout.addWidget(self.statusLabel) + + self.credentialTable = QTableWidget(self) + self.credentialTable.setObjectName("C2CredentialVaultTable") + self.credentialTable.setShowGrid(False) + self.credentialTable.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + self.credentialTable.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.credentialTable.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) + self.credentialTable.setHorizontalScrollMode(QAbstractItemView.ScrollMode.ScrollPerPixel) + self.credentialTable.setRowCount(0) + self.credentialTable.setColumnCount(len(self.COLUMN_WIDTHS)) + self.credentialTable.verticalHeader().setVisible(False) + self.credentialTable.itemSelectionChanged.connect(self.onSelectionChanged) + self.credentialTable.itemDoubleClicked.connect(lambda _item: self.editSelectedCredential()) + self.configureTableColumns() + self.layout.addWidget(self.credentialTable, 1) + + self.detailsFrame = QFrame(self) + self.detailsFrame.setObjectName("C2CredentialVaultDetails") + self.detailsFrame.setFrameShape(QFrame.Shape.StyledPanel) + self.detailsFrame.setStyleSheet( + """ + QFrame#C2CredentialVaultDetails { + background-color: #101820; + border: 1px solid #263443; + border-radius: 4px; + } + """ + ) + detailsLayout = QGridLayout(self.detailsFrame) + detailsLayout.setContentsMargins(10, 8, 10, 8) + detailsLayout.setHorizontalSpacing(10) + detailsLayout.setVerticalSpacing(4) + self.detailTitleLabel = QLabel("-", self.detailsFrame) + self.detailUsernameLabel = QLabel("-", self.detailsFrame) + self.detailTypeLabel = QLabel("-", self.detailsFrame) + self.detailModifiedLabel = QLabel("-", self.detailsFrame) + self.detailNotesLabel = QLabel("-", self.detailsFrame) + self.detailNotesLabel.setWordWrap(True) + self.detailSecretLabel = QLineEdit("••••••••", self.detailsFrame) + self.detailSecretLabel.setReadOnly(True) + self.detailSecretLabel.setToolTip("Selected credential secret. Reveal to inspect, select, or copy it.") + self.detailRevealButton = self.createToolbarButton("Reveal", "Reveal selected credential secret.", width=72) + self.detailRevealButton.clicked.connect(self.revealDetailSecret) + + detailsLayout.addWidget(QLabel("Title"), 0, 0) + detailsLayout.addWidget(self.detailTitleLabel, 0, 1) + detailsLayout.addWidget(QLabel("Username"), 0, 2) + detailsLayout.addWidget(self.detailUsernameLabel, 0, 3) + detailsLayout.addWidget(QLabel("Type"), 1, 0) + detailsLayout.addWidget(self.detailTypeLabel, 1, 1) + detailsLayout.addWidget(QLabel("Modified"), 1, 2) + detailsLayout.addWidget(self.detailModifiedLabel, 1, 3) + detailsLayout.addWidget(QLabel("Secret"), 2, 0) + secretLayout = QHBoxLayout() + secretLayout.setContentsMargins(0, 0, 0, 0) + secretLayout.setSpacing(6) + secretLayout.addWidget(self.detailSecretLabel, 1) + secretLayout.addWidget(self.detailRevealButton) + detailsLayout.addLayout(secretLayout, 2, 1, 1, 3) + detailsLayout.addWidget(QLabel("Notes"), 3, 0) + detailsLayout.addWidget(self.detailNotesLabel, 3, 1, 1, 3) + self.layout.addWidget(self.detailsFrame) + + self.connectFilterSignals() + self.updateActionButtons() + self.refreshCredentials() + + def createFilter(self, values: list[str], tooltip: str) -> QComboBox: + combo = QComboBox(self) + combo.addItems(values) + combo.setToolTip(tooltip) + combo.setMinimumWidth(112) + return combo + + def createLineEdit(self, placeholder: str, tooltip: str) -> QLineEdit: + line_edit = QLineEdit(self) + line_edit.setPlaceholderText(placeholder) + line_edit.setToolTip(tooltip) + return line_edit + + def createToolbarButton(self, text: str, tooltip: str, width: int = 58) -> QPushButton: + button = QPushButton(text, self) + button.setToolTip(tooltip) + button.setFixedHeight(26) + button.setMinimumWidth(width) + button.setMaximumWidth(width) + return button + + def configureTableColumns(self) -> None: + header = self.credentialTable.horizontalHeader() + header.setStretchLastSection(False) + header.setMinimumSectionSize(54) + for index, width in enumerate(self.COLUMN_WIDTHS): + if index == self.STRETCH_COLUMN: + header.setSectionResizeMode(index, QHeaderView.ResizeMode.Stretch) + else: + header.setSectionResizeMode(index, QHeaderView.ResizeMode.Interactive) + self.credentialTable.setColumnWidth(index, width) + + def connectFilterSignals(self) -> None: + self.typeFilter.currentTextChanged.connect(lambda _value: self.refreshCredentials()) + self.usernameFilter.returnPressed.connect(self.refreshCredentials) + self.searchInput.returnPressed.connect(self.refreshCredentials) + + def buildQuery(self) -> Any: + query = TeamServerApi_pb2.CredentialQuery() + credential_type = self.typeFilter.currentText().strip() + if credential_type and credential_type != ALL_FILTER: + query.type = credential_type + if self.usernameFilter.text().strip(): + query.username = self.usernameFilter.text().strip() + if self.searchInput.text().strip(): + query.name_contains = self.searchInput.text().strip() + return query + + def refreshCredentials(self) -> None: + try: + self.credentials = list(self.grpcClient.listCredentials(self.buildQuery())) + except Exception as exc: + self.credentials = [] + self.printCredentials() + apply_status( + self.statusLabel, + f"Vault: {compact_message(exc, limit=120)}", + StatusKind.ERROR, + ) + return + + self.printCredentials() + apply_status( + self.statusLabel, + f"Vault: {len(self.credentials)} item(s)", + StatusKind.SUCCESS, + ) + + def printCredentials(self) -> None: + preferred_credential_id = self.selectedCredentialId + self.selectedCredentialId = "" + self.updateDetails(None) + self.credentialTable.setRowCount(len(self.credentials)) + self.credentialTable.setHorizontalHeaderLabels( + ["Title", "Username", "Type", "Secret", "Notes", "Modified"] + ) + + for row, credential in enumerate(self.credentials): + credential_id = _text(_field(credential, "credential_id")) + secret_fields = _list_field(credential, "secret_fields") + has_secret = bool(_first_text(secret_fields)) + values = [ + _text(_field(credential, "display_name")), + _text(_field(credential, "username")), + _text(_field(credential, "type")), + "••••••••" if has_secret else "", + _text(_field(credential, "description")), + _text(_field(credential, "updated_at")), + ] + tooltip = "\n".join( + value + for value in [ + f"id: {credential_id}" if credential_id else "", + f"secret: {_first_text(secret_fields)}" if has_secret else "", + ] + if value + ) + for column, value in enumerate(values): + item = QTableWidgetItem(value) + item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable) + item.setData(Qt.ItemDataRole.UserRole, row) + if tooltip: + item.setToolTip(tooltip) + self.credentialTable.setItem(row, column, item) + + self.restoreSelection(preferred_credential_id) + self.updateActionButtons() + + def restoreSelection(self, preferred_credential_id: str = "") -> None: + if not self.credentials: + self.credentialTable.clearSelection() + self.selectedCredentialId = "" + self.updateDetails(None) + return + + row_to_select = -1 + if preferred_credential_id: + for row, credential in enumerate(self.credentials): + if _text(_field(credential, "credential_id")) == preferred_credential_id: + row_to_select = row + break + if row_to_select < 0 and len(self.credentials) == 1: + row_to_select = 0 + if row_to_select < 0: + self.credentialTable.clearSelection() + self.selectedCredentialId = "" + self.updateDetails(None) + return + + self.credentialTable.selectRow(row_to_select) + credential = self.credentials[row_to_select] + self.selectedCredentialId = _text(_field(credential, "credential_id")) + self.updateDetails(credential) + + def selectedCredential(self) -> Any | None: + selected_rows = self.credentialTable.selectionModel().selectedRows() if self.credentialTable.selectionModel() else [] + if not selected_rows: + if not self.selectedCredentialId: + return None + for credential in self.credentials: + if _text(_field(credential, "credential_id")) == self.selectedCredentialId: + return credential + return None + row = selected_rows[0].row() + if row < 0 or row >= len(self.credentials): + return None + return self.credentials[row] + + def onSelectionChanged(self) -> None: + credential = self.selectedCredential() + self.selectedCredentialId = _text(_field(credential, "credential_id")) if credential is not None else "" + self.updateDetails(credential) + self.updateActionButtons() + + def updateDetails(self, credential: Any | None = None) -> None: + self.detailSecretRevealed = False + self.detailTitleLabel.setText(_text(_field(credential, "display_name")) or "-") + self.detailUsernameLabel.setText(_text(_field(credential, "username")) or "-") + self.detailTypeLabel.setText(_text(_field(credential, "type")) or "-") + self.detailModifiedLabel.setText(_text(_field(credential, "updated_at")) or "-") + self.detailNotesLabel.setText(_text(_field(credential, "description")) or "-") + self.detailSecretLabel.setText("••••••••" if credential is not None and _first_text(_list_field(credential, "secret_fields")) else "-") + self.detailSecretLabel.setCursorPosition(0) + self.detailRevealButton.setText("Reveal") + self.detailRevealButton.setToolTip("Reveal selected credential secret.") + + def buildRequestFromValues(self, values: dict[str, str], *, credential_id: str = "", replace_secret: bool = False) -> Any: + request = TeamServerApi_pb2.CredentialUpsertRequest() + if credential_id: + request.credential_id = credential_id + request.display_name = values["title"] + request.type = values["type"] + request.username = values["username"] + request.description = values["description"] + if values["secret"]: + secret = request.secrets.add() + secret.name = secret_name_for_type(values["type"], values.get("secret_name", "")) + secret.value = values["secret"] + request.replace_secrets = True + else: + request.replace_secrets = replace_secret + return request + + def runEntryDialog( + self, + *, + title: str, + credential: Any | None = None, + secret_name: str = "", + secret_value: str = "", + require_secret: bool = False, + ) -> dict[str, str] | None: + dialog = CredentialEntryDialog( + self, + title=title, + credential=credential, + secret_name=secret_name, + secret_value=secret_value, + require_secret=require_secret, + ) + if dialog.exec() != QDialog.DialogCode.Accepted: + return None + return dialog.values() + + def addCredential(self) -> None: + values = self.runEntryDialog(title="New Credential", require_secret=True) + if values is None: + return + try: + ack = self.grpcClient.addCredential(self.buildRequestFromValues(values)) + except Exception as exc: + apply_status(self.statusLabel, f"Add credential: {compact_message(exc, limit=120)}", StatusKind.ERROR) + return + self.handleMutationAck(ack, "Credential stored.") + + def editSelectedCredential(self) -> None: + credential = self.selectedCredential() + if credential is None: + apply_status(self.statusLabel, "Edit credential: select a credential first.", StatusKind.WARNING) + return + try: + detail = self.grpcClient.getCredential(self.selectedCredentialId, reveal_secret=True) + except Exception as exc: + apply_status(self.statusLabel, f"Edit credential: {compact_message(exc, limit=120)}", StatusKind.ERROR) + return + if not is_response_ok(detail): + apply_status(self.statusLabel, response_message(detail, "Credential reveal failed."), StatusKind.ERROR) + return + secret_name, secret_value = first_secret_value(list(detail.secrets)) + values = self.runEntryDialog( + title="Edit Credential", + credential=detail.summary, + secret_name=secret_name, + secret_value=secret_value, + ) + if values is None: + return + self.updateCredential(values) + + def updateCredential(self, values: dict[str, str]) -> None: + if not self.selectedCredentialId: + apply_status(self.statusLabel, "Update credential: select a credential first.", StatusKind.WARNING) + return + try: + request = self.buildRequestFromValues(values, credential_id=self.selectedCredentialId) + ack = self.grpcClient.updateCredential(request) + except Exception as exc: + apply_status(self.statusLabel, f"Update credential: {compact_message(exc, limit=120)}", StatusKind.ERROR) + return + self.handleMutationAck(ack, "Credential updated.") + + def revealDetailSecret(self) -> None: + credential = self.selectedCredential() + if credential is None or not self.selectedCredentialId: + apply_status(self.statusLabel, "Reveal credential: select a credential first.", StatusKind.WARNING) + return + if self.detailSecretRevealed: + self.detailSecretRevealed = False + self.detailSecretLabel.setText("••••••••" if _first_text(_list_field(credential, "secret_fields")) else "-") + self.detailSecretLabel.setCursorPosition(0) + self.detailRevealButton.setText("Reveal") + self.detailRevealButton.setToolTip("Reveal selected credential secret.") + apply_status(self.statusLabel, "Credential secret hidden.", StatusKind.SUCCESS) + return + try: + detail = self.grpcClient.getCredential(self.selectedCredentialId, reveal_secret=True) + except Exception as exc: + apply_status(self.statusLabel, f"Reveal credential: {compact_message(exc, limit=120)}", StatusKind.ERROR) + return + if not is_response_ok(detail): + apply_status(self.statusLabel, response_message(detail, "Credential reveal failed."), StatusKind.ERROR) + return + + _secret_name, secret_value = first_secret_value(list(detail.secrets)) + self.detailSecretRevealed = bool(secret_value) + self.detailSecretLabel.setText(secret_value or "-") + self.detailSecretLabel.setCursorPosition(0) + if self.detailSecretRevealed: + self.detailRevealButton.setText("Hide") + self.detailRevealButton.setToolTip("Hide selected credential secret.") + apply_status(self.statusLabel, "Credential secret revealed in details.", StatusKind.WARNING) + else: + self.detailRevealButton.setText("Reveal") + self.detailRevealButton.setToolTip("Reveal selected credential secret.") + apply_status(self.statusLabel, "Credential has no secret value to reveal.", StatusKind.WARNING) + + def deleteSelectedCredential(self) -> None: + credential = self.selectedCredential() + if credential is None or not self.selectedCredentialId: + apply_status(self.statusLabel, "Delete credential: select a credential first.", StatusKind.WARNING) + return + title = _text(_field(credential, "display_name")) or self.selectedCredentialId[:12] + answer = QMessageBox.question( + self, + "Delete credential", + f"Delete credential '{title}'?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if answer != QMessageBox.StandardButton.Yes: + return + try: + ack = self.grpcClient.deleteCredential(self.selectedCredentialId) + except Exception as exc: + apply_status(self.statusLabel, f"Delete credential: {compact_message(exc, limit=120)}", StatusKind.ERROR) + return + self.handleMutationAck(ack, "Credential deleted.") + + def handleMutationAck(self, ack: Any, success_fallback: str) -> None: + if is_response_ok(ack): + apply_status(self.statusLabel, operation_ack_text(ack, success_fallback), StatusKind.SUCCESS) + self.refreshCredentials() + else: + apply_status(self.statusLabel, operation_ack_text(ack), StatusKind.ERROR) + + def updateActionButtons(self) -> None: + has_selection = bool(self.selectedCredentialId) + self.editButton.setEnabled(has_selection) + self.detailRevealButton.setEnabled(has_selection) + self.deleteButton.setEnabled(has_selection) diff --git a/C2Client/C2Client/TerminalModules/Credentials/.gitignore b/C2Client/C2Client/TerminalModules/Credentials/.gitignore deleted file mode 100644 index ed8ebf5..0000000 --- a/C2Client/C2Client/TerminalModules/Credentials/.gitignore +++ /dev/null @@ -1 +0,0 @@ -__pycache__ \ No newline at end of file diff --git a/C2Client/C2Client/TerminalModules/Credentials/credentials.py b/C2Client/C2Client/TerminalModules/Credentials/credentials.py deleted file mode 100644 index 381e033..0000000 --- a/C2Client/C2Client/TerminalModules/Credentials/credentials.py +++ /dev/null @@ -1,107 +0,0 @@ -import json -import re - -from ...grpcClient import GrpcClient -from ...grpc_status import is_response_ok, terminal_response_text - -GetCredentialsInstruction = "getCred" -AddCredentialsInstruction = "addCred" - - -def getCredentials(grpcClient: GrpcClient, TeamServerApi_pb2): - commandTeamServer = GetCredentialsInstruction - termCommand = TeamServerApi_pb2.TerminalCommandRequest(command=commandTeamServer, data=b"") - resultTermCommand = grpcClient.executeTerminalCommand(termCommand) - result = terminal_response_text(resultTermCommand) - if not is_response_ok(resultTermCommand): - raise RuntimeError(result) - return result - - -def addCredentials(grpcClient: GrpcClient,TeamServerApi_pb2, cred: str): - currentcredentials = json.loads(getCredentials(grpcClient, TeamServerApi_pb2)) - credjson = json.loads(cred) - - if credjson in currentcredentials: - return - - commandTeamServer = AddCredentialsInstruction - termCommand = TeamServerApi_pb2.TerminalCommandRequest(command=commandTeamServer, data=cred.encode()) - resultTermCommand = grpcClient.executeTerminalCommand(termCommand) - result = terminal_response_text(resultTermCommand) - if not is_response_ok(resultTermCommand): - raise RuntimeError(result) - return result - - -def handleSekurlsaLogonPasswords(mimikatzOutput: str, grpcClient: GrpcClient,TeamServerApi_pb2): - auth_block_pattern = r"Authentication Id : .*?\n(.*?)(?=\nAuthentication Id :|\Z)" - user_domain_pattern = r"User Name\s*:\s*(.*?)\s*Domain\s*:\s*(.*?)\n" - ntlm_pattern = r"\*\s*NTLM\s*:\s*([a-fA-F0-9]{32})" - password_pattern = r"\*\s*Password\s*:\s*(.+)" - - auth_blocks = re.findall(auth_block_pattern, mimikatzOutput, re.DOTALL) - for block in auth_blocks: - user_domain_match = re.search(user_domain_pattern, block) - if user_domain_match: - username = user_domain_match.group(1).strip() - domain = user_domain_match.group(2).strip() - else: - username = "N/A" - domain = "N/A" - - matchs = re.findall(ntlm_pattern, block) - matchs = list(dict.fromkeys(matchs)) - for ntlm in matchs: - ntlm = ntlm.strip() - if ntlm: - cred = {} - cred["username"] = username - cred["domain"] = domain - cred["ntlm"] = ntlm - addCredentials(grpcClient, TeamServerApi_pb2, json.dumps(cred)) - - matchs = re.findall(password_pattern, block) - matchs = list(dict.fromkeys(matchs)) - for password in matchs: - password = password.strip() - if password and password != "(null)": - cred = {} - cred["username"] = username - cred["domain"] = domain - cred["password"] = password - addCredentials(grpcClient, TeamServerApi_pb2, json.dumps(cred)) - - -def handleLsaDumpSAM(mimikatzOutput: str, grpcClient: GrpcClient,TeamServerApi_pb2): - domain_block_pattern = r"(Domain :.*?)(?=\nDomain :|\Z)" - domain_pattern = r"Domain : (.*)" - rid_block_pattern = r"(RID\s*:.*?)(?=\nRID\s*:|\Z)" - user_hash_pattern = r"User\s*:\s*(\S+)\r?\n\s+Hash NTLM:\s*([a-fA-F0-9]+)" - - domain_blocks = re.findall(domain_block_pattern, mimikatzOutput, re.DOTALL) - for block in domain_blocks: - domain_match = re.search(domain_pattern, block) - if domain_match: - domain = domain_match.group(1).strip() - else: - continue - - rid_blocks = re.findall(rid_block_pattern, block, re.DOTALL) - for rid_block in rid_blocks: - matches = re.findall(user_hash_pattern, rid_block) - for user, hash_ntlm in matches: - cred = {} - cred["username"] = user - cred["domain"] = domain - cred["ntlm"] = hash_ntlm - addCredentials(grpcClient, TeamServerApi_pb2, json.dumps(cred)) - - -def handleMimikatzCredentials(mimikatzOutput: str, grpcClient: GrpcClient,TeamServerApi_pb2): - # check if "sekurlsa::logonpasswords" - handleSekurlsaLogonPasswords(mimikatzOutput, grpcClient,TeamServerApi_pb2) - # check if "lsadump::sam" - handleLsaDumpSAM(mimikatzOutput, grpcClient, TeamServerApi_pb2) - # check if "sekurlsa::ekeys" - # extract Password / aies256_hmac / rc4_md4 diff --git a/C2Client/C2Client/TerminalPanel.py b/C2Client/C2Client/TerminalPanel.py index cf637c4..c238cc2 100644 --- a/C2Client/C2Client/TerminalPanel.py +++ b/C2Client/C2Client/TerminalPanel.py @@ -1,6 +1,5 @@ import sys import os -import json import logging import re import subprocess @@ -32,7 +31,6 @@ from .grpc_status import is_response_ok, terminal_response_text from .panel_style import apply_dark_panel_style from .TerminalModules.Batcave import batcave -from .TerminalModules.Credentials import credentials from git import Repo @@ -210,7 +208,6 @@ GrpcBatcaveUploadToolInstruction = "batcaveUpload" GrpcSocksInstruction = "socks" GrpcReloadModulesInstruction = "reloadModules"; -GrpcCredentialVaultInstruction = "cred" BeaconFileWindowsPattern = "Beacon-{}.exe" BeaconFileLinuxGenerated = "Beacon-linux" @@ -334,33 +331,6 @@ def isTerminalResponseError(response): host text.txt listenerHash host artifactShortHash listenerHash hostedName.exe""" -CredentialStoreInstruction = "credentialStore" -CredentialStoreHelp = """credentialStore -Read and update the encrypted TeamServer credential vault. - -Usage: credentialStore [arguments] - -Kind: terminal -Target: teamserver -Requires session: no - -Arguments: - (text, required) - One of get, set, search, reveal, or delete. - [arguments] (text, optional) - Action-specific values. - -Examples: - credentialStore get - credentialStore set domain username credential - credentialStore search username - credentialStore reveal credential_id - credentialStore delete credential_id""" - -GetSubInstruction = "get" -SetSubInstruction = "set" -SearchSubInstruction = "search" -RevealSubInstruction = "reveal" -DeleteSubInstruction = "delete" - ReloadModulesInstruction = "reloadModules"; ReloadModulesHelp = """reloadModules Reload TeamServer module libraries without restarting the TeamServer. @@ -383,16 +353,12 @@ def getHelpMsg(): host - Host a TeamServer artifact through an HTTP/HTTPS listener. dropper - Generate and host a beacon dropper. batcave - Install or search Batcave tools. - credentialStore - Read and update TeamServer credentials. socks - Manage local SOCKS bridge bindings. reloadModules - Reload TeamServer module libraries.""" def redactTerminalCommand(commandLine: str) -> str: - parts = commandLine.split() - if len(parts) >= 5 and parts[0].lower() == CredentialStoreInstruction.lower() and parts[1].lower() == SetSubInstruction.lower(): - return " ".join([*parts[:4], ""]) - return commandLine + return commandLine def normalizeWindowsArch(arch): @@ -692,7 +658,6 @@ def build_terminal_completer_data(grpcClient: Any = None) -> list[tuple[str, lis HostInstruction, DropperInstruction, BatcaveInstruction, - CredentialStoreInstruction, SocksInstruction, ReloadModulesInstruction, ] @@ -730,7 +695,6 @@ def build_terminal_completer_data(grpcClient: Any = None) -> list[tuple[str, lis (HostInstruction, _host_artifact_entries(artifacts, listener_with_optional_filename)), (DropperInstruction, dropper_children), (BatcaveInstruction, [("install", []), ("bundleInstall", []), ("search", [])]), - (CredentialStoreInstruction, [(GetSubInstruction, []), (SetSubInstruction, []), (SearchSubInstruction, []), (RevealSubInstruction, []), (DeleteSubInstruction, [])]), (SocksInstruction, [("start", []), ("stop", []), ("unbind", []), ("bind", _session_entries(sessions))]), (ReloadModulesInstruction, []), ] @@ -919,8 +883,6 @@ def runCommand(self): self.printInTerminal(commandLine, BatcaveHelp) elif instructions[1].lower() == HostInstruction.lower(): self.printInTerminal(commandLine, HostHelp) - elif instructions[1].lower() == CredentialStoreInstruction.lower(): - self.printInTerminal(commandLine, CredentialStoreHelp) elif instructions[1].lower() == ReloadModulesInstruction.lower(): self.printInTerminal(commandLine, ReloadModulesHelp) elif instructions[1].lower() == DropperInstruction.lower(): @@ -939,8 +901,6 @@ def runCommand(self): self.runBatcave(commandLine, instructions) elif instructions[0].lower()==HostInstruction.lower(): self.runHost(commandLine, instructions) - elif instructions[0].lower()==CredentialStoreInstruction.lower(): - self.runCredentialStore(commandLine, instructions) elif instructions[0].lower()==DropperInstruction.lower(): self.runDropper(commandLine, instructions) elif instructions[0].lower()==SocksInstruction.lower(): @@ -1073,79 +1033,6 @@ def runBatcave(self, commandLine, instructions): self.printInTerminal(commandLine, ErrorCmdUnknow) return - # - # CredentialStore - # - def runCredentialStore(self, commandLine, instructions): - if len(instructions) < 2: - self.printInTerminal(commandLine, CredentialStoreHelp) - return; - - cmd = instructions[1].lower() - - if cmd == GetSubInstruction.lower(): - termCommand = TeamServerApi_pb2.TerminalCommandRequest(command=GrpcCredentialVaultInstruction + " list") - resultTermCommand = self.grpcClient.executeTerminalCommand(termCommand) - self.printInTerminal(commandLine, terminal_response_text(resultTermCommand)) - - return - - elif cmd == SetSubInstruction.lower(): - if len(instructions) < 5: - self.printInTerminal(commandLine, CredentialStoreHelp) - return - - domain = instructions[2] - username = instructions[3] - credential = instructions[4] - - cred = {} - cred["domain"] = domain - cred["username"] = username - cred["password"] = credential - termCommand = TeamServerApi_pb2.TerminalCommandRequest( - command=GrpcCredentialVaultInstruction + " add", - data=json.dumps(cred).encode(), - ) - resultTermCommand = self.grpcClient.executeTerminalCommand(termCommand) - self.printInTerminal(commandLine, terminal_response_text(resultTermCommand)) - return - - elif cmd == SearchSubInstruction.lower(): - if len(instructions) < 3: - self.printInTerminal(commandLine, CredentialStoreHelp) - return - - searchPatern = instructions[2] - termCommand = TeamServerApi_pb2.TerminalCommandRequest(command=GrpcCredentialVaultInstruction + " list " + searchPatern) - resultTermCommand = self.grpcClient.executeTerminalCommand(termCommand) - self.printInTerminal(commandLine, terminal_response_text(resultTermCommand)) - return - - elif cmd == RevealSubInstruction.lower(): - if len(instructions) < 3: - self.printInTerminal(commandLine, CredentialStoreHelp) - return - credentialId = instructions[2] - termCommand = TeamServerApi_pb2.TerminalCommandRequest(command=GrpcCredentialVaultInstruction + " get " + credentialId + " --reveal") - resultTermCommand = self.grpcClient.executeTerminalCommand(termCommand) - self.printInTerminal(commandLine, terminal_response_text(resultTermCommand)) - return - - elif cmd == DeleteSubInstruction.lower(): - if len(instructions) < 3: - self.printInTerminal(commandLine, CredentialStoreHelp) - return - credentialId = instructions[2] - termCommand = TeamServerApi_pb2.TerminalCommandRequest(command=GrpcCredentialVaultInstruction + " delete " + credentialId) - resultTermCommand = self.grpcClient.executeTerminalCommand(termCommand) - self.printInTerminal(commandLine, terminal_response_text(resultTermCommand)) - return - - else: - self.printInTerminal(commandLine, ErrorCmdUnknow) - return - # # Host # diff --git a/C2Client/TODO.md b/C2Client/TODO.md index 14a26b8..95bc550 100644 --- a/C2Client/TODO.md +++ b/C2Client/TODO.md @@ -32,7 +32,7 @@ Objectif: rendre le client plus agreable pour un operateur, puis enrichir propre | 24 | [x] | Supporter les hostnames SOCKS5 cote beacon | L | Tres fort | Fait cote code. `libSocks5` accepte `ATYP=DName`, le TeamServer transporte `host:` vers la beacon, la beacon resout/connecte depuis son contexte, IPv4 reste compatible, les echecs d'init renvoient un reply SOCKS type au lieu d'un EOF. Tests auto `TestsSocksServer`; validation live `scripts/socks5_stress_test.py --socks-hostname` a refaire sur beacon. | | 25 | [ ] | Ajouter `ValidateCommand` / `DryRunCommand` | L | Tres fort | Verifier une commande sans l'envoyer au beacon; retourner erreur, hint, instruction preparee, fichiers requis. | | 26 | [ ] | Ajouter un modele d'erreurs type dans le proto | L | Fort | `code`, `message`, `hint`, `details`; eviter de parser du texte libre cote client. | -| 27 | [ ] | Ajouter un credential store serveur pour les modules | XL | Tres fort | Store central cote TeamServer avec RPC list/search/add/update/delete, audit et masquage des secrets; ajouter un `credential_filter` aux CommandSpecs pour autocompleter les modules qui prennent des credentials (`psExec`, `wmiExec`, `winRm`, `dcomExec`, `spawnAs`, `makeToken`, etc.) sans exposer les mots de passe. | +| 27 | [x] | Ajouter un credential store serveur pour les modules | XL | Tres fort | Store central cote TeamServer avec RPC list/search/add/update/delete, audit et masquage des secrets; ajouter un `credential_filter` aux CommandSpecs pour autocompleter les modules qui prennent des credentials (`psExec`, `wmiExec`, `winRm`, `dcomExec`, `spawnAs`, `makeToken`, etc.) sans exposer les mots de passe. | | 28 | [ ] | Ajouter historique/audit operateur cote serveur | XL | Tres fort | Qui a envoye quoi, quand, sur quelle session, command_id, resultat, statut. Base pour recherche, replay, reporting. | | 29 | [ ] | Ajouter `GetCommandStatus` / `ListCommandHistory` / `CancelCommand` | XL | Tres fort | Suivi propre des commandes queued/running/done/error/cancelled; utile pour console, assistant et workflows longs. | | 30 | [ ] | Ajouter tags/notes/assignation sessions cote serveur | XL | Fort | Tags persistants, notes operationnelles, owner operateur, priorite, commentaires. | diff --git a/C2Client/tests/test_console_panel.py b/C2Client/tests/test_console_panel.py index 4769e3d..6ac3294 100644 --- a/C2Client/tests/test_console_panel.py +++ b/C2Client/tests/test_console_panel.py @@ -56,6 +56,9 @@ def listModules(self, session): self.list_modules_requests.append(session) return iter(self.modules) + def listCredentials(self, query): + return iter([]) + class DummyPanel(QWidget): def __init__(self, parent=None, *_args, **_kwargs): @@ -343,6 +346,7 @@ def test_consoles_tab_uses_dark_flush_pages(qtbot, monkeypatch): monkeypatch.setattr('C2Client.ConsolePanel.Terminal', DummyPanel) monkeypatch.setattr('C2Client.ConsolePanel.Script', DummyPanel) monkeypatch.setattr('C2Client.ConsolePanel.Artifacts', DummyPanel) + monkeypatch.setattr('C2Client.ConsolePanel.CredentialVault', DummyPanel) monkeypatch.setattr('C2Client.ConsolePanel.Commands', DummyPanel) monkeypatch.setattr('C2Client.ConsolePanel.Assistant', DummyPanel) @@ -354,8 +358,9 @@ def test_consoles_tab_uses_dark_flush_pages(qtbot, monkeypatch): assert consoles.tabs.objectName() == "C2ConsoleTabs" assert consoles.tabs.tabText(1) == "Hooks" assert consoles.tabs.tabText(2) == "Artifacts" - assert consoles.tabs.tabText(3) == "Commands" - assert consoles.tabs.tabText(4) == "Data AI" + assert consoles.tabs.tabText(3) == "Vault" + assert consoles.tabs.tabText(4) == "Commands" + assert consoles.tabs.tabText(5) == "Data AI" assert "#0b1117" in consoles.styleSheet() assert "#070b10" in consoles.styleSheet() assert consoles.layout.contentsMargins().left() == 0 @@ -392,6 +397,7 @@ def setResponsePollingActive(self, active): monkeypatch.setattr('C2Client.ConsolePanel.Terminal', DummyPanel) monkeypatch.setattr('C2Client.ConsolePanel.Script', DummyPanel) monkeypatch.setattr('C2Client.ConsolePanel.Artifacts', DummyPanel) + monkeypatch.setattr('C2Client.ConsolePanel.CredentialVault', DummyPanel) monkeypatch.setattr('C2Client.ConsolePanel.Commands', DummyPanel) monkeypatch.setattr('C2Client.ConsolePanel.Assistant', DummyPanel) monkeypatch.setattr('C2Client.ConsolePanel.Console', FakeConsole) @@ -541,6 +547,55 @@ def listArtifacts(self, query): assert grpc.queries[1].runtime == "file" +def test_credential_arg_uses_vault_completions(): + class FakeGrpc: + def __init__(self): + self.queries = [] + + def listCredentials(self, query): + self.queries.append(query) + return iter([ + SimpleNamespace(credential_id="abcdef1234567890", username="alice", domain="CORP"), + ]) + + credential_filter = SimpleNamespace( + type="password", + username="", + domain="CORP", + target="", + protocol="smb", + tag="admin", + name_contains="", + include_expired=False, + ) + ps_exec_spec = SimpleNamespace( + name="psExec", + kind="module", + examples=["psExec -u DOMAIN\\alice secret server svc.exe"], + args=[ + SimpleNamespace( + name="username", + type="credential", + values=[], + completion_parents=["-u"], + credential_filter=credential_filter, + ), + ], + ) + + grpc = FakeGrpc() + server_data = command_specs_to_completer_data([ps_exec_spec], grpcClient=grpc) + ps_exec_children = _completion_children(server_data, "psExec") + username_children = _completion_children(ps_exec_children, "-u") + + assert ("cred:abcdef12", []) in username_children + assert len(grpc.queries) == 1 + assert grpc.queries[0].type == "password" + assert grpc.queries[0].domain == "CORP" + assert grpc.queries[0].protocol == "smb" + assert grpc.queries[0].tag == "admin" + + def test_script_and_powershell_commands_use_script_artifact_completions(): class FakeGrpc: def __init__(self): diff --git a/C2Client/tests/test_credential_vault_panel.py b/C2Client/tests/test_credential_vault_panel.py new file mode 100644 index 0000000..c9ab9fb --- /dev/null +++ b/C2Client/tests/test_credential_vault_panel.py @@ -0,0 +1,287 @@ +from types import SimpleNamespace + +import C2Client.grpcClient as grpc_client_module +import sys +sys.modules['grpcClient'] = grpc_client_module + +import C2Client.CredentialVaultPanel as credential_vault_panel +from C2Client.CredentialVaultPanel import ( + CredentialEntryDialog, + CredentialVault, + first_secret_value, + secret_name_for_type, +) +from C2Client.grpcClient import TeamServerApi_pb2 + + +def _summary(**overrides): + values = { + "credential_id": "abcdef1234567890", + "display_name": "corp alice", + "type": "password", + "username": "CORP\\alice", + "domain": "", + "realm": "", + "target": "", + "protocol": "", + "tags": [], + "description": "local admin", + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-02T00:00:00Z", + "last_used_at": "", + "expires_at": "", + "secret_fields": ["password"], + } + values.update(overrides) + return SimpleNamespace(**values) + + +class FakeGrpc: + def __init__(self): + self.queries = [] + self.added = [] + self.updated = [] + self.deleted = [] + self.revealed = None + self.reveal_calls = [] + self.credentials = [_summary()] + self.detail = SimpleNamespace( + status=TeamServerApi_pb2.OK, + message="", + summary=self.credentials[0], + secrets=[ + SimpleNamespace(name="password", value="Secret123!"), + ], + ) + + def listCredentials(self, query): + self.queries.append(query) + return iter(self.credentials) + + def getCredential(self, credential_id, reveal_secret=False): + self.revealed = (credential_id, reveal_secret) + self.reveal_calls.append((credential_id, reveal_secret)) + return self.detail + + def addCredential(self, request): + self.added.append(request) + return SimpleNamespace(status=TeamServerApi_pb2.OK, message="Credential stored.") + + def updateCredential(self, request): + self.updated.append(request) + return SimpleNamespace(status=TeamServerApi_pb2.OK, message="Credential updated.") + + def deleteCredential(self, credential_id): + self.deleted.append(credential_id) + return SimpleNamespace(status=TeamServerApi_pb2.OK, message="Credential deleted.") + + +def _entry_values(**overrides): + values = { + "title": "corp alice", + "username": "CORP\\alice", + "type": "password", + "secret_name": "password", + "secret": "", + "description": "local admin", + } + values.update(overrides) + return values + + +def test_credential_vault_panel_lists_like_keepass_and_filters(qtbot): + grpc = FakeGrpc() + panel = CredentialVault(None, grpc) + qtbot.addWidget(panel) + + assert panel.credentialTable.rowCount() == 1 + assert panel.credentialTable.item(0, 0).text() == "corp alice" + assert panel.credentialTable.item(0, 1).text() == "CORP\\alice" + assert panel.credentialTable.item(0, 2).text() == "password" + assert panel.credentialTable.item(0, 3).text() == "••••••••" + assert panel.credentialTable.item(0, 4).text() == "local admin" + assert panel.credentialTable.item(0, 5).text() == "2026-01-02T00:00:00Z" + assert panel.selectedCredentialId == "abcdef1234567890" + + panel.credentialTable.selectRow(0) + assert panel.detailTitleLabel.text() == "corp alice" + assert panel.detailUsernameLabel.text() == "CORP\\alice" + assert panel.detailTypeLabel.text() == "password" + assert panel.detailSecretLabel.text() == "••••••••" + assert panel.detailSecretLabel.isReadOnly() is True + assert panel.detailNotesLabel.text() == "local admin" + + panel.typeFilter.setCurrentText("ntlm_hash") + panel.usernameFilter.setText("alice") + panel.searchInput.setText("corp") + + grpc.queries.clear() + panel.refreshCredentials() + query = grpc.queries[-1] + + assert query.type == "ntlm_hash" + assert query.username == "alice" + assert query.name_contains == "corp" + assert query.domain == "" + assert query.target == "" + assert query.protocol == "" + assert query.tag == "" + assert query.include_expired is False + + +def test_credential_vault_panel_deletes_single_entry_without_manual_selection(qtbot, monkeypatch): + grpc = FakeGrpc() + panel = CredentialVault(None, grpc) + qtbot.addWidget(panel) + + monkeypatch.setattr( + credential_vault_panel.QMessageBox, + "question", + lambda *args, **kwargs: credential_vault_panel.QMessageBox.StandardButton.Yes, + ) + + assert panel.selectedCredentialId == "abcdef1234567890" + assert panel.deleteButton.isEnabled() is True + + panel.deleteSelectedCredential() + + assert grpc.deleted == ["abcdef1234567890"] + + +def test_credential_vault_panel_adds_single_secret_entry(qtbot, monkeypatch): + grpc = FakeGrpc() + panel = CredentialVault(None, grpc) + qtbot.addWidget(panel) + + monkeypatch.setattr( + panel, + "runEntryDialog", + lambda **_kwargs: _entry_values( + title="ssh alice", + username="alice", + type="ssh_key", + secret_name="", + secret="KEYDATA", + description="ssh private key\nhost: linux01", + ), + ) + + panel.addCredential() + request = grpc.added[-1] + + assert request.display_name == "ssh alice" + assert request.username == "alice" + assert request.type == "ssh_key" + assert request.description == "ssh private key\nhost: linux01" + assert request.domain == "" + assert request.realm == "" + assert request.target == "" + assert request.protocol == "" + assert list(request.tags) == [] + assert request.expires_at == "" + assert request.replace_secrets is True + assert request.secrets[0].name == "private_key" + assert request.secrets[0].value == "KEYDATA" + + +def test_credential_vault_panel_edits_without_replacing_secret(qtbot, monkeypatch): + grpc = FakeGrpc() + panel = CredentialVault(None, grpc) + qtbot.addWidget(panel) + panel.credentialTable.selectRow(0) + + monkeypatch.setattr( + panel, + "runEntryDialog", + lambda **_kwargs: _entry_values(title="updated title", secret=""), + ) + + panel.editSelectedCredential() + request = grpc.updated[-1] + + assert grpc.revealed == ("abcdef1234567890", True) + assert request.credential_id == "abcdef1234567890" + assert request.display_name == "updated title" + assert len(request.secrets) == 0 + assert request.replace_secrets is False + + +def test_credential_vault_panel_edit_reveals_and_updates_single_secret(qtbot, monkeypatch): + grpc = FakeGrpc() + panel = CredentialVault(None, grpc) + qtbot.addWidget(panel) + panel.credentialTable.selectRow(0) + + calls = [] + + def fake_dialog(**kwargs): + calls.append(kwargs) + return _entry_values(secret_name="password", secret="UpdatedSecret!") + + monkeypatch.setattr(panel, "runEntryDialog", fake_dialog) + + panel.editSelectedCredential() + request = grpc.updated[-1] + + assert grpc.revealed == ("abcdef1234567890", True) + assert calls[0]["secret_value"] == "Secret123!" + assert request.credential_id == "abcdef1234567890" + assert request.replace_secrets is True + assert request.secrets[0].name == "password" + assert request.secrets[0].value == "UpdatedSecret!" + + +def test_credential_vault_panel_detail_reveals_secret_without_editing(qtbot): + grpc = FakeGrpc() + panel = CredentialVault(None, grpc) + qtbot.addWidget(panel) + panel.credentialTable.selectRow(0) + + panel.revealDetailSecret() + + assert grpc.revealed == ("abcdef1234567890", True) + assert grpc.reveal_calls == [("abcdef1234567890", True)] + assert panel.detailSecretLabel.text() == "Secret123!" + assert panel.detailRevealButton.text() == "Hide" + assert grpc.updated == [] + + panel.revealDetailSecret() + + assert grpc.reveal_calls == [("abcdef1234567890", True)] + assert panel.detailSecretLabel.text() == "••••••••" + assert panel.detailRevealButton.text() == "Reveal" + assert grpc.updated == [] + + +def test_credential_vault_panel_deletes_with_confirmation(qtbot, monkeypatch): + grpc = FakeGrpc() + panel = CredentialVault(None, grpc) + qtbot.addWidget(panel) + panel.credentialTable.selectRow(0) + + monkeypatch.setattr( + credential_vault_panel.QMessageBox, + "question", + lambda *args, **kwargs: credential_vault_panel.QMessageBox.StandardButton.Yes, + ) + + panel.deleteSelectedCredential() + + assert grpc.deleted == ["abcdef1234567890"] + + +def test_credential_vault_secret_helpers(): + assert secret_name_for_type("password") == "password" + assert secret_name_for_type("ntlm_hash") == "ntlm" + assert secret_name_for_type("ssh_key") == "private_key" + assert secret_name_for_type("custom", "api_key") == "api_key" + assert first_secret_value([SimpleNamespace(name="token", value="abc")]) == ("token", "abc") + + +def test_credential_entry_dialog_opens_at_comfortable_size(qtbot): + dialog = CredentialEntryDialog(None, title="Edit Credential", require_secret=False) + qtbot.addWidget(dialog) + + assert dialog.minimumWidth() >= 680 + assert dialog.minimumHeight() >= 480 + assert dialog.notesInput.minimumHeight() >= 150 diff --git a/C2Client/tests/test_terminal_panel_dropper_arch.py b/C2Client/tests/test_terminal_panel_dropper_arch.py index e09222f..b573ff2 100644 --- a/C2Client/tests/test_terminal_panel_dropper_arch.py +++ b/C2Client/tests/test_terminal_panel_dropper_arch.py @@ -287,6 +287,7 @@ def test_terminal_completer_uses_artifacts_listeners_sessions_and_dropper_module help_children = _completion_children(completions, terminal_panel.HelpInstruction) assert (terminal_panel.HostInstruction, []) in help_children assert (terminal_panel.SocksInstruction, []) in help_children + assert all(entry[0] != "credentialVault" for entry in help_children) host_children = _completion_children(completions, terminal_panel.HostInstruction) host_labels = [entry[0] for entry in host_children] diff --git a/core b/core index 108a370..fcbbaff 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 108a3708079a4d2a741f16218119fe4407484196 +Subproject commit fcbbaffa7f04f14bc9cb663e1fe3842ca48a7f57 diff --git a/protocol/TeamServerApi.proto b/protocol/TeamServerApi.proto index 31193b6..8cda56e 100644 --- a/protocol/TeamServerApi.proto +++ b/protocol/TeamServerApi.proto @@ -263,6 +263,8 @@ message CommandArgSpec bool variadic = 7; repeated ArtifactQuery artifact_filters = 8; repeated string completion_parents = 9; + CredentialQuery credential_filter = 10; + repeated CredentialQuery credential_filters = 11; } diff --git a/teamServer/teamServer/TeamServer.cpp b/teamServer/teamServer/TeamServer.cpp index db66d0a..e53f1bd 100644 --- a/teamServer/teamServer/TeamServer.cpp +++ b/teamServer/teamServer/TeamServer.cpp @@ -171,7 +171,8 @@ TeamServer::TeamServer(const nlohmann::json& config) runtimeConfig, m_commonCommands, m_moduleCmd, - std::move(commandPreparers)); + std::move(commandPreparers), + m_credentialVaultService); m_termLocalService = std::make_unique( m_logger, m_config, diff --git a/teamServer/teamServer/TeamServerCommandCatalog.cpp b/teamServer/teamServer/TeamServerCommandCatalog.cpp index d02ce55..b7cd6e8 100644 --- a/teamServer/teamServer/TeamServerCommandCatalog.cpp +++ b/teamServer/teamServer/TeamServerCommandCatalog.cpp @@ -103,6 +103,20 @@ TeamServerCommandArtifactFilter parseArtifactFilter(const json& input) return filter; } +TeamServerCommandCredentialFilter parseCredentialFilter(const json& input) +{ + TeamServerCommandCredentialFilter filter; + filter.type = jsonString(input, "type"); + filter.username = jsonString(input, "username"); + filter.domain = jsonString(input, "domain"); + filter.target = jsonString(input, "target"); + filter.protocol = jsonString(input, "protocol"); + filter.tag = jsonString(input, "tag"); + filter.nameContains = jsonString(input, "name_contains"); + filter.includeExpired = jsonBool(input, "include_expired", false); + return filter; +} + void addArtifactFilter(TeamServerCommandArgSpec& arg, TeamServerCommandArtifactFilter filter) { arg.artifactFilters.push_back(std::move(filter)); @@ -110,6 +124,13 @@ void addArtifactFilter(TeamServerCommandArgSpec& arg, TeamServerCommandArtifactF arg.hasArtifactFilter = true; } +void addCredentialFilter(TeamServerCommandArgSpec& arg, TeamServerCommandCredentialFilter filter) +{ + arg.credentialFilters.push_back(std::move(filter)); + arg.credentialFilter = arg.credentialFilters.front(); + arg.hasCredentialFilter = true; +} + TeamServerCommandArgSpec parseArgSpec(const json& input) { TeamServerCommandArgSpec arg; @@ -136,6 +157,22 @@ TeamServerCommandArgSpec parseArgSpec(const json& input) addArtifactFilter(arg, parseArtifactFilter(artifactFilter)); } } + + auto credentialFilterIt = input.find("credential_filter"); + if (credentialFilterIt != input.end() && credentialFilterIt->is_object()) + { + addCredentialFilter(arg, parseCredentialFilter(*credentialFilterIt)); + } + + auto credentialFiltersIt = input.find("credential_filters"); + if (credentialFiltersIt != input.end() && credentialFiltersIt->is_array()) + { + for (const auto& credentialFilter : *credentialFiltersIt) + { + if (credentialFilter.is_object()) + addCredentialFilter(arg, parseCredentialFilter(credentialFilter)); + } + } return arg; } diff --git a/teamServer/teamServer/TeamServerCommandCatalog.hpp b/teamServer/teamServer/TeamServerCommandCatalog.hpp index cab086b..857cd73 100644 --- a/teamServer/teamServer/TeamServerCommandCatalog.hpp +++ b/teamServer/teamServer/TeamServerCommandCatalog.hpp @@ -17,6 +17,18 @@ struct TeamServerCommandArtifactFilter std::string format; }; +struct TeamServerCommandCredentialFilter +{ + std::string type; + std::string username; + std::string domain; + std::string target; + std::string protocol; + std::string tag; + std::string nameContains; + bool includeExpired = false; +}; + struct TeamServerCommandArgSpec { std::string name; @@ -27,6 +39,9 @@ struct TeamServerCommandArgSpec TeamServerCommandArtifactFilter artifactFilter; std::vector artifactFilters; bool hasArtifactFilter = false; + TeamServerCommandCredentialFilter credentialFilter; + std::vector credentialFilters; + bool hasCredentialFilter = false; bool variadic = false; std::vector completionParents; }; diff --git a/teamServer/teamServer/TeamServerCommandCatalogService.cpp b/teamServer/teamServer/TeamServerCommandCatalogService.cpp index 70320f3..05bbf20 100644 --- a/teamServer/teamServer/TeamServerCommandCatalogService.cpp +++ b/teamServer/teamServer/TeamServerCommandCatalogService.cpp @@ -90,6 +90,32 @@ teamserverapi::CommandSpec TeamServerCommandCatalogService::toProto(const TeamSe filter->set_name_contains(artifactFilter.nameContains); filter->set_format(artifactFilter.format); } + + if (arg.hasCredentialFilter) + { + teamserverapi::CredentialQuery* filter = argSpec->mutable_credential_filter(); + filter->set_type(arg.credentialFilter.type); + filter->set_username(arg.credentialFilter.username); + filter->set_domain(arg.credentialFilter.domain); + filter->set_target(arg.credentialFilter.target); + filter->set_protocol(arg.credentialFilter.protocol); + filter->set_tag(arg.credentialFilter.tag); + filter->set_name_contains(arg.credentialFilter.nameContains); + filter->set_include_expired(arg.credentialFilter.includeExpired); + } + + for (const TeamServerCommandCredentialFilter& credentialFilter : arg.credentialFilters) + { + teamserverapi::CredentialQuery* filter = argSpec->add_credential_filters(); + filter->set_type(credentialFilter.type); + filter->set_username(credentialFilter.username); + filter->set_domain(credentialFilter.domain); + filter->set_target(credentialFilter.target); + filter->set_protocol(credentialFilter.protocol); + filter->set_tag(credentialFilter.tag); + filter->set_name_contains(credentialFilter.nameContains); + filter->set_include_expired(credentialFilter.includeExpired); + } } return spec; diff --git a/teamServer/teamServer/TeamServerCommandPreparationService.cpp b/teamServer/teamServer/TeamServerCommandPreparationService.cpp index 044c8bd..3c3bbe8 100644 --- a/teamServer/teamServer/TeamServerCommandPreparationService.cpp +++ b/teamServer/teamServer/TeamServerCommandPreparationService.cpp @@ -5,21 +5,66 @@ #include #include +#include "TeamServerCredentialVaultService.hpp" #include "TeamServerRuntimeConfig.hpp" namespace fs = std::filesystem; +namespace +{ +struct ResolvedCredential +{ + std::string domain; + std::string username; + std::string password; +}; + +std::string lowerString(std::string value) +{ + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) + { + return static_cast(std::tolower(c)); + }); + return value; +} + +bool isCredentialReference(const std::string& value) +{ + return value.rfind("cred:", 0) == 0; +} + +std::string credentialIdFromReference(const std::string& value) +{ + return isCredentialReference(value) ? value.substr(5) : ""; +} + +std::string credentialUsername(const ResolvedCredential& credential, bool includeDomain) +{ + if (!includeDomain || credential.domain.empty()) + return credential.username; + return credential.domain + "\\" + credential.username; +} + +bool isPasswordFlag(const std::string& value) +{ + const std::string lower = lowerString(value); + return lower == "-p" || lower == "--password"; +} +} // namespace + TeamServerCommandPreparationService::TeamServerCommandPreparationService( std::shared_ptr logger, TeamServerRuntimeConfig runtimeConfig, CommonCommands& commonCommands, std::vector>& moduleCmd, - std::vector> preparers) + std::vector> preparers, + std::shared_ptr credentialVaultService) : m_logger(std::move(logger)), m_runtimeConfig(std::move(runtimeConfig)), m_commonCommands(commonCommands), m_moduleCmd(moduleCmd), - m_preparers(std::move(preparers)) + m_preparers(std::move(preparers)), + m_credentialVaultService(std::move(credentialVaultService)) { } @@ -63,6 +108,145 @@ std::string TeamServerCommandPreparationService::toLower(const std::string& str) return result; } +bool TeamServerCommandPreparationService::rewriteCredentialReferences(std::vector& tokens, C2Message& c2Message) const +{ + if (!m_credentialVaultService || tokens.empty()) + return true; + + auto resolve = [&](const std::string& reference, ResolvedCredential& credential) -> bool + { + const std::string credentialId = credentialIdFromReference(reference); + if (credentialId.empty()) + return false; + + teamserverapi::CredentialSelector selector; + selector.set_credential_id(credentialId); + selector.set_reveal_secret(true); + teamserverapi::CredentialDetail detail; + m_credentialVaultService->getCredential(selector, &detail); + if (detail.status() != teamserverapi::OK) + { + c2Message.set_returnvalue("Credential not found: " + reference + "."); + return false; + } + + credential.domain = detail.summary().domain(); + credential.username = detail.summary().username(); + for (const auto& secret : detail.secrets()) + { + const std::string name = lowerString(secret.name()); + if (name == "password" || name == "manual" || name == "secret") + { + credential.password = secret.value(); + break; + } + } + + if (credential.username.empty() || credential.password.empty()) + { + c2Message.set_returnvalue("Credential " + reference + " must contain username and password secret."); + return false; + } + return true; + }; + + auto replaceCredentialToken = [&](std::size_t index) -> bool + { + if (index >= tokens.size() || !isCredentialReference(tokens[index])) + return true; + ResolvedCredential credential; + if (!resolve(tokens[index], credential)) + return false; + tokens.erase(tokens.begin() + static_cast(index)); + tokens.insert( + tokens.begin() + static_cast(index), + {credentialUsername(credential, true), credential.password}); + return true; + }; + + const std::string instruction = lowerString(tokens[0]); + if (instruction == "maketoken") + return replaceCredentialToken(1); + + if (instruction == "spawnas") + { + for (std::size_t i = 1; i < tokens.size(); ++i) + { + const std::string token = lowerString(tokens[i]); + if (token == "--") + return true; + if (token == "-d" || token == "--domain" || token == "-l" || token == "--logon-type") + { + ++i; + continue; + } + if (isCredentialReference(tokens[i])) + return replaceCredentialToken(i); + } + return true; + } + + if (instruction == "psexec" || instruction == "wmiexec" || instruction == "winrm") + { + for (std::size_t i = 1; i + 1 < tokens.size(); ++i) + { + if (lowerString(tokens[i]) != "-u" || !isCredentialReference(tokens[i + 1])) + continue; + + ResolvedCredential credential; + if (!resolve(tokens[i + 1], credential)) + return false; + tokens.erase(tokens.begin() + static_cast(i + 1)); + tokens.insert( + tokens.begin() + static_cast(i + 1), + {credentialUsername(credential, true), credential.password}); + return true; + } + return true; + } + + if (instruction == "dcomexec" || instruction == "cimexec" || instruction == "taskscheduler" || instruction == "sshexec") + { + const bool hasExplicitPasswordFlag = std::any_of(tokens.begin() + 1, tokens.end(), isPasswordFlag); + std::vector rewritten; + rewritten.reserve(tokens.size() + 2); + bool usedCredentialReference = false; + for (std::size_t i = 0; i < tokens.size(); ++i) + { + const std::string token = lowerString(tokens[i]); + if ((token == "-u" || token == "--user") && i + 1 < tokens.size() && isCredentialReference(tokens[i + 1])) + { + if (hasExplicitPasswordFlag) + { + c2Message.set_returnvalue("Do not provide a password flag when using a credential reference."); + return false; + } + ResolvedCredential credential; + if (!resolve(tokens[i + 1], credential)) + return false; + rewritten.push_back(tokens[i]); + rewritten.push_back(credentialUsername(credential, instruction != "sshexec")); + rewritten.push_back(instruction == "sshexec" ? "--password" : "-p"); + rewritten.push_back(credential.password); + usedCredentialReference = true; + ++i; + continue; + } + + if (usedCredentialReference && isPasswordFlag(token)) + { + c2Message.set_returnvalue("Do not provide a password flag when using a credential reference."); + return false; + } + rewritten.push_back(tokens[i]); + } + if (usedCredentialReference) + tokens = std::move(rewritten); + } + + return true; +} + int TeamServerCommandPreparationService::prepareMessage( const std::string& input, C2Message& c2Message, @@ -75,6 +259,8 @@ int TeamServerCommandPreparationService::prepareMessage( splitInputCmd(input, splitedCmd); if (splitedCmd.empty()) return 0; + if (!rewriteCredentialReferences(splitedCmd, c2Message)) + return -1; int res = 0; const std::string instruction = splitedCmd[0]; diff --git a/teamServer/teamServer/TeamServerCommandPreparationService.hpp b/teamServer/teamServer/TeamServerCommandPreparationService.hpp index 4204003..a8c50b8 100644 --- a/teamServer/teamServer/TeamServerCommandPreparationService.hpp +++ b/teamServer/teamServer/TeamServerCommandPreparationService.hpp @@ -10,6 +10,8 @@ #include "modules/ModuleCmd/ModuleCmd.hpp" #include "spdlog/logger.h" +class TeamServerCredentialVaultService; + class TeamServerCommandPreparationService { public: @@ -18,7 +20,8 @@ class TeamServerCommandPreparationService TeamServerRuntimeConfig runtimeConfig, CommonCommands& commonCommands, std::vector>& moduleCmd, - std::vector> preparers = {}); + std::vector> preparers = {}, + std::shared_ptr credentialVaultService = nullptr); int prepareMessage( const std::string& input, @@ -29,10 +32,12 @@ class TeamServerCommandPreparationService private: static std::string toLower(const std::string& str); void splitInputCmd(const std::string& input, std::vector& splitedList) const; + bool rewriteCredentialReferences(std::vector& tokens, C2Message& c2Message) const; std::shared_ptr m_logger; TeamServerRuntimeConfig m_runtimeConfig; CommonCommands& m_commonCommands; std::vector>& m_moduleCmd; std::vector> m_preparers; + std::shared_ptr m_credentialVaultService; }; diff --git a/teamServer/teamServer/TeamServerCredentialVaultService.cpp b/teamServer/teamServer/TeamServerCredentialVaultService.cpp index 8ece72b..b7a49ba 100644 --- a/teamServer/teamServer/TeamServerCredentialVaultService.cpp +++ b/teamServer/teamServer/TeamServerCredentialVaultService.cpp @@ -624,6 +624,13 @@ void TeamServerCredentialVaultService::fillDetail(const TeamServerCredentialReco bool TeamServerCredentialVaultService::matchesQuery(const TeamServerCredentialRecord& record, const teamserverapi::CredentialQuery& query) const { + if (!query.include_expired() + && !record.expiresAt.empty() + && record.expiresAt <= currentTimestamp()) + { + return false; + } + bool tagMatches = query.tag().empty(); for (const std::string& tag : record.tags) { diff --git a/teamServer/tests/TeamServerCommandCatalogTests.cpp b/teamServer/tests/TeamServerCommandCatalogTests.cpp index 3086064..c618c60 100644 --- a/teamServer/tests/TeamServerCommandCatalogTests.cpp +++ b/teamServer/tests/TeamServerCommandCatalogTests.cpp @@ -131,6 +131,18 @@ void seedCommandSpecs(const TeamServerRuntimeConfig& runtimeConfig) "platforms": ["windows"], "archs": ["x86", "x64"], "args": [ + { + "name": "username", + "type": "credential", + "required": false, + "description": "Credential reference used after -u.", + "completion_parents": ["-u"], + "credential_filter": { + "type": "password", + "protocol": "smb", + "tag": "admin" + } + }, { "name": "service_artifact", "type": "artifact", @@ -217,13 +229,20 @@ void testCommandCatalogLoadsManifestSpecs() const TeamServerCommandSpecRecord* psExec = findCommand(commands, "psExec"); assert(psExec != nullptr); - assert(psExec->args.size() == 1); - assert(psExec->args[0].hasArtifactFilter); - assert(psExec->args[0].artifactFilters.size() == 2); - assert(psExec->args[0].artifactFilters[0].category == "tool"); - assert(psExec->args[0].artifactFilters[0].arch == "session.arch"); - assert(psExec->args[0].artifactFilters[1].category == "upload"); - assert(psExec->args[0].artifactFilters[1].scope == "operator"); + assert(psExec->args.size() == 2); + assert(psExec->args[0].type == "credential"); + assert(psExec->args[0].hasCredentialFilter); + assert(psExec->args[0].credentialFilter.type == "password"); + assert(psExec->args[0].credentialFilter.protocol == "smb"); + assert(psExec->args[0].credentialFilter.tag == "admin"); + assert(psExec->args[0].completionParents.size() == 1); + assert(psExec->args[0].completionParents[0] == "-u"); + assert(psExec->args[1].hasArtifactFilter); + assert(psExec->args[1].artifactFilters.size() == 2); + assert(psExec->args[1].artifactFilters[0].category == "tool"); + assert(psExec->args[1].artifactFilters[0].arch == "session.arch"); + assert(psExec->args[1].artifactFilters[1].category == "upload"); + assert(psExec->args[1].artifactFilters[1].scope == "operator"); } void testCommandCatalogFiltersSpecs() @@ -296,13 +315,21 @@ void testCommandCatalogServiceStreamsProto() assert(commands.size() == 1); assert(commands[0].name() == "psExec"); - assert(commands[0].args_size() == 1); - assert(commands[0].args(0).artifact_filter().category() == "tool"); - assert(commands[0].args(0).artifact_filters_size() == 2); - assert(commands[0].args(0).artifact_filters(0).category() == "tool"); - assert(commands[0].args(0).artifact_filters(1).category() == "upload"); - assert(commands[0].args(0).artifact_filters(1).scope() == "operator"); - assert(commands[0].args(0).artifact_filters(1).runtime() == "file"); + assert(commands[0].args_size() == 2); + assert(commands[0].args(0).type() == "credential"); + assert(commands[0].args(0).credential_filter().type() == "password"); + assert(commands[0].args(0).credential_filter().protocol() == "smb"); + assert(commands[0].args(0).credential_filter().tag() == "admin"); + assert(commands[0].args(0).credential_filters_size() == 1); + assert(commands[0].args(0).credential_filters(0).type() == "password"); + assert(commands[0].args(0).completion_parents_size() == 1); + assert(commands[0].args(0).completion_parents(0) == "-u"); + assert(commands[0].args(1).artifact_filter().category() == "tool"); + assert(commands[0].args(1).artifact_filters_size() == 2); + assert(commands[0].args(1).artifact_filters(0).category() == "tool"); + assert(commands[0].args(1).artifact_filters(1).category() == "upload"); + assert(commands[0].args(1).artifact_filters(1).scope() == "operator"); + assert(commands[0].args(1).artifact_filters(1).runtime() == "file"); } } // namespace diff --git a/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp b/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp index 48abf0b..d30c9dd 100644 --- a/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp +++ b/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp @@ -11,6 +11,7 @@ #include "TeamServerArtifactCatalog.hpp" #include "TeamServerChiselCommandPreparer.hpp" #include "TeamServerCommandPreparationService.hpp" +#include "TeamServerCredentialVaultService.hpp" #include "TeamServerFileArtifactService.hpp" #include "TeamServerFileTransferCommandPreparer.hpp" #include "TeamServerGeneratedArtifactStore.hpp" @@ -118,6 +119,39 @@ class FakeShellcodeModule final : public ModuleCmd } }; +class FakeTokenCaptureModule final : public ModuleCmd +{ +public: + explicit FakeTokenCaptureModule(std::string name) + : ModuleCmd(std::move(name)) + { + } + + std::string getInfo() override + { + return "fake token capture"; + } + + int init(std::vector& tokens, C2Message& c2Message) override + { + c2Message.set_instruction(getName()); + std::string packed; + for (std::size_t i = 1; i < tokens.size(); ++i) + { + if (i > 1) + packed.push_back('\0'); + packed += tokens[i]; + } + c2Message.set_cmd(packed); + return 0; + } + + int process(C2Message&, C2Message&) override + { + return 0; + } +}; + fs::path makeTempDirectory(const std::string& name) { fs::path root = fs::temp_directory_path() / ("c2teamserver-prep-" + name + "-" + std::to_string(::getpid())); @@ -201,9 +235,41 @@ TeamServerRuntimeConfig makeRuntimeConfig(const fs::path& root) runtimeConfig.scriptsDirectoryPath = (root / "Scripts").string() + "/"; runtimeConfig.uploadedArtifactsDirectoryPath = (root / "UploadedArtifacts").string() + "/"; runtimeConfig.generatedArtifactsDirectoryPath = (root / "GeneratedArtifacts").string() + "/"; + runtimeConfig.dataRoot = root.string(); + runtimeConfig.credentialVaultDirectoryPath = (root / "CredentialVault").string(); + runtimeConfig.credentialVaultPath = (root / "CredentialVault" / "vault.json").string(); + runtimeConfig.credentialVaultKeyFile = (root / "CredentialVault" / "vault.key").string(); return runtimeConfig; } +std::string addPasswordCredential( + const std::shared_ptr& credentialVaultService, + const std::string& domain, + const std::string& username, + const std::string& passwordValue) +{ + teamserverapi::CredentialUpsertRequest addCredential; + addCredential.set_display_name(domain + " " + username); + addCredential.set_type("password"); + addCredential.set_username(username); + addCredential.set_domain(domain); + teamserverapi::CredentialSecret* password = addCredential.add_secrets(); + password->set_name("password"); + password->set_value(passwordValue); + teamserverapi::OperationAck ack; + require(credentialVaultService->addCredential(addCredential, &ack).ok(), "vault credential add RPC failed"); + require(ack.status() == teamserverapi::OK, "vault credential add failed"); + + std::vector credentials; + require(credentialVaultService->listCredentials(teamserverapi::CredentialQuery(), [&](const teamserverapi::CredentialSummary& summary) + { + credentials.push_back(summary); + return true; + }).ok(), "vault credential list failed"); + require(!credentials.empty(), "vault credential count mismatch"); + return credentials.back().credential_id().substr(0, 8); +} + void testPrepareCommonCommand() { ScopedPath tempRoot(makeTempDirectory("common")); @@ -920,10 +986,30 @@ void testPreparePsExecUsesToolThenUploadedArtifact() auto artifactStore = std::make_shared(runtimeConfig); auto fileArtifactService = std::make_shared(makeLogger(), runtimeConfig, artifactStore); + auto credentialVaultService = std::make_shared(makeLogger(), runtimeConfig); + teamserverapi::CredentialUpsertRequest addCredential; + addCredential.set_display_name("corp alice"); + addCredential.set_type("password"); + addCredential.set_username("alice"); + addCredential.set_domain("DOMAIN"); + teamserverapi::CredentialSecret* password = addCredential.add_secrets(); + password->set_name("password"); + password->set_value("secret"); + teamserverapi::OperationAck ack; + require(credentialVaultService->addCredential(addCredential, &ack).ok(), "psExec vault credential add RPC failed"); + require(ack.status() == teamserverapi::OK, "psExec vault credential add failed"); + std::vector credentials; + require(credentialVaultService->listCredentials(teamserverapi::CredentialQuery(), [&](const teamserverapi::CredentialSummary& summary) + { + credentials.push_back(summary); + return true; + }).ok(), "psExec vault credential list failed"); + require(credentials.size() == 1, "psExec vault credential count mismatch"); + std::vector> preparers; preparers.push_back(std::make_unique(makeLogger(), fileArtifactService, modules)); - TeamServerCommandPreparationService service(makeLogger(), runtimeConfig, commonCommands, modules, std::move(preparers)); + TeamServerCommandPreparationService service(makeLogger(), runtimeConfig, commonCommands, modules, std::move(preparers), credentialVaultService); C2Message credentialMessage; require(service.prepareMessage("psExec -u DOMAIN\\alice secret server01 svc.exe", credentialMessage, true, "amd64") == 0, "psExec tool prepare failed"); @@ -937,6 +1023,17 @@ void testPreparePsExecUsesToolThenUploadedArtifact() require(credentialFields[2] == "secret", "psExec password mismatch"); require(credentialFields[3] == "server01", "psExec target mismatch"); + C2Message credentialRefMessage; + require( + service.prepareMessage("psExec -u cred:" + credentials[0].credential_id().substr(0, 8) + " server01 svc.exe", credentialRefMessage, true, "amd64") == 0, + "psExec credential reference prepare failed"); + const std::vector credentialRefFields = splitNullFields(credentialRefMessage.cmd()); + require(credentialRefFields.size() == 4, "psExec credential reference fields count mismatch"); + require(credentialRefFields[0] == "DOMAIN", "psExec credential reference domain mismatch"); + require(credentialRefFields[1] == "alice", "psExec credential reference username mismatch"); + require(credentialRefFields[2] == "secret", "psExec credential reference password mismatch"); + require(credentialRefFields[3] == "server01", "psExec credential reference target mismatch"); + C2Message uploadMessage; require(service.prepareMessage("psExec -n server01 uploadSvc.exe", uploadMessage, true, "amd64") == 0, "psExec upload fallback prepare failed"); require(uploadMessage.inputfile() == "uploadSvc.exe", "psExec upload artifact mismatch"); @@ -944,6 +1041,64 @@ void testPreparePsExecUsesToolThenUploadedArtifact() require(uploadMessage.cmd() == "server01", "psExec token target mismatch"); } +void testPrepareCredentialReferencesForDirectModules() +{ + ScopedPath tempRoot(makeTempDirectory("credential-references")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + auto credentialVaultService = std::make_shared(makeLogger(), runtimeConfig); + const std::string credentialId = addPasswordCredential(credentialVaultService, "DOMAIN", "alice", "secret"); + + CommonCommands commonCommands; + std::vector> modules; + modules.push_back(std::make_unique("makeToken")); + modules.push_back(std::make_unique("spawnAs")); + modules.push_back(std::make_unique("sshExec")); + + TeamServerCommandPreparationService service( + makeLogger(), + runtimeConfig, + commonCommands, + modules, + std::vector>{}, + credentialVaultService); + + C2Message makeTokenMessage; + require(service.prepareMessage("makeToken cred:" + credentialId, makeTokenMessage, true, "amd64") == 0, "makeToken credential reference prepare failed"); + std::vector makeTokenFields = splitNullFields(makeTokenMessage.cmd()); + require(makeTokenFields.size() == 2, "makeToken credential fields count mismatch"); + require(makeTokenFields[0] == "DOMAIN\\alice", "makeToken credential username mismatch"); + require(makeTokenFields[1] == "secret", "makeToken credential password mismatch"); + + C2Message spawnAsMessage; + require( + service.prepareMessage("spawnAs --no-profile cred:" + credentialId + " -- cmd.exe /c whoami", spawnAsMessage, true, "amd64") == 0, + "spawnAs credential reference prepare failed"); + std::vector spawnAsFields = splitNullFields(spawnAsMessage.cmd()); + require(spawnAsFields.size() == 7, "spawnAs credential fields count mismatch"); + require(spawnAsFields[0] == "--no-profile", "spawnAs option mismatch"); + require(spawnAsFields[1] == "DOMAIN\\alice", "spawnAs credential username mismatch"); + require(spawnAsFields[2] == "secret", "spawnAs credential password mismatch"); + require(spawnAsFields[3] == "--", "spawnAs separator mismatch"); + + C2Message sshExecMessage; + require(service.prepareMessage("sshExec -h host -u cred:" + credentialId + " id", sshExecMessage, true, "amd64") == 0, "sshExec credential reference prepare failed"); + std::vector sshExecFields = splitNullFields(sshExecMessage.cmd()); + require(sshExecFields.size() == 7, "sshExec credential fields count mismatch"); + require(sshExecFields[0] == "-h", "sshExec host flag mismatch"); + require(sshExecFields[2] == "-u", "sshExec username flag mismatch"); + require(sshExecFields[3] == "alice", "sshExec credential username should omit domain"); + require(sshExecFields[4] == "--password", "sshExec password flag mismatch"); + require(sshExecFields[5] == "secret", "sshExec credential password mismatch"); + + C2Message invalidMessage; + require( + service.prepareMessage("sshExec -u cred:" + credentialId + " --password manual host", invalidMessage, true, "amd64") == -1, + "sshExec credential reference should reject explicit password flag"); + require( + invalidMessage.returnvalue().find("Do not provide a password flag") != std::string::npos, + "sshExec credential reference error mismatch"); +} + void testPrepareCoffLoaderUsesToolArtifact() { ScopedPath tempRoot(makeTempDirectory("coffloader-preparer")); @@ -1070,6 +1225,7 @@ int main() testPrepareScreenShotCreatesGeneratedArtifactSlot(); testPrepareKerberosUseTicketUsesUploadedArtifact(); testPreparePsExecUsesToolThenUploadedArtifact(); + testPrepareCredentialReferencesForDirectModules(); testPrepareCoffLoaderUsesToolArtifact(); testPrepareDotnetExecLoadUsesToolArtifact(); testPreparePwShUsesFixedRunnerAndScriptArtifacts(); diff --git a/teamServer/tests/TeamServerCredentialVaultServiceTests.cpp b/teamServer/tests/TeamServerCredentialVaultServiceTests.cpp index 9dac80b..8263d26 100644 --- a/teamServer/tests/TeamServerCredentialVaultServiceTests.cpp +++ b/teamServer/tests/TeamServerCredentialVaultServiceTests.cpp @@ -149,11 +149,87 @@ void testTerminalIntegration() require(response.result().find("bob") != std::string::npos, "terminal cred list missing username"); require(response.result().find("super-secret") == std::string::npos, "terminal cred list leaked secret"); } + +void testUpdateDeleteAndExpiredFiltering() +{ + ScopedPath tempRoot(makeTempDirectory("lifecycle")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + TeamServerCredentialVaultService service(makeLogger(), runtimeConfig); + + teamserverapi::CredentialUpsertRequest expiredRequest; + expiredRequest.set_display_name("expired alice"); + expiredRequest.set_type("password"); + expiredRequest.set_username("alice"); + expiredRequest.set_domain("CORP"); + expiredRequest.set_expires_at("2000-01-01T00:00:00Z"); + teamserverapi::CredentialSecret* expiredPassword = expiredRequest.add_secrets(); + expiredPassword->set_name("password"); + expiredPassword->set_value("old-secret"); + teamserverapi::OperationAck ack; + require(service.addCredential(expiredRequest, &ack).ok(), "expired addCredential RPC status failed"); + require(ack.status() == teamserverapi::OK, "expired addCredential failed: " + ack.message()); + + std::vector activeCredentials; + require(service.listCredentials(teamserverapi::CredentialQuery(), [&](const teamserverapi::CredentialSummary& summary) + { + activeCredentials.push_back(summary); + return true; + }).ok(), "active listCredentials RPC status failed"); + require(activeCredentials.empty(), "expired credential should be hidden by default"); + + teamserverapi::CredentialQuery includeExpiredQuery; + includeExpiredQuery.set_include_expired(true); + std::vector allCredentials; + require(service.listCredentials(includeExpiredQuery, [&](const teamserverapi::CredentialSummary& summary) + { + allCredentials.push_back(summary); + return true; + }).ok(), "include expired listCredentials RPC status failed"); + require(allCredentials.size() == 1, "include expired did not return expired credential"); + + teamserverapi::CredentialUpsertRequest updateRequest; + updateRequest.set_credential_id(allCredentials[0].credential_id().substr(0, 8)); + updateRequest.set_expires_at("2999-01-01T00:00:00Z"); + updateRequest.set_replace_secrets(true); + teamserverapi::CredentialSecret* newPassword = updateRequest.add_secrets(); + newPassword->set_name("password"); + newPassword->set_value("new-secret"); + require(service.updateCredential(updateRequest, &ack).ok(), "updateCredential RPC status failed"); + require(ack.status() == teamserverapi::OK, "updateCredential failed: " + ack.message()); + + activeCredentials.clear(); + require(service.listCredentials(teamserverapi::CredentialQuery(), [&](const teamserverapi::CredentialSummary& summary) + { + activeCredentials.push_back(summary); + return true; + }).ok(), "post-update listCredentials RPC status failed"); + require(activeCredentials.size() == 1, "updated credential should be active"); + + teamserverapi::CredentialSelector selector; + selector.set_credential_id(activeCredentials[0].credential_id().substr(0, 8)); + selector.set_reveal_secret(true); + teamserverapi::CredentialDetail detail; + require(service.getCredential(selector, &detail).ok(), "updated getCredential RPC status failed"); + require(detail.status() == teamserverapi::OK, "updated getCredential failed: " + detail.message()); + require(detail.secrets_size() == 1, "updated credential secret count mismatch"); + require(detail.secrets(0).value() == "new-secret", "updated credential secret mismatch"); + + require(service.deleteCredential(selector, &ack).ok(), "deleteCredential RPC status failed"); + require(ack.status() == teamserverapi::OK, "deleteCredential failed: " + ack.message()); + activeCredentials.clear(); + require(service.listCredentials(includeExpiredQuery, [&](const teamserverapi::CredentialSummary& summary) + { + activeCredentials.push_back(summary); + return true; + }).ok(), "post-delete listCredentials RPC status failed"); + require(activeCredentials.empty(), "deleted credential still listed"); +} } // namespace int main() { testAddListRevealAndPersistence(); testTerminalIntegration(); + testUpdateDeleteAndExpiredFiltering(); return 0; } From 7333dff7b6dc935055589db53ad25462e02f3e9b Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Wed, 13 May 2026 11:06:21 +0200 Subject: [PATCH 3/5] Vault autcomplet --- C2Client/C2Client/ConsolePanel.py | 130 ++++++++- C2Client/C2Client/CredentialVaultPanel.py | 11 +- C2Client/tests/test_console_panel.py | 27 +- C2Client/tests/test_credential_vault_panel.py | 2 + core | 2 +- .../TeamServerCommandPreparationService.cpp | 262 +++++++++++++----- .../tests/TeamServerCommandCatalogTests.cpp | 10 +- ...amServerCommandPreparationServiceTests.cpp | 109 ++++++-- 8 files changed, 438 insertions(+), 115 deletions(-) diff --git a/C2Client/C2Client/ConsolePanel.py b/C2Client/C2Client/ConsolePanel.py index 69e62e6..0cb2267 100644 --- a/C2Client/C2Client/ConsolePanel.py +++ b/C2Client/C2Client/ConsolePanel.py @@ -117,6 +117,8 @@ def console_completion_options( ) -> list[CompletionOption]: normalized_text, placeholder_values = normalize_console_completion_text(command_text) options = completion_options(completion_data, normalized_text, descend_exact=descend_exact) + if not options: + options = _console_contextual_completion_options(completion_data, normalized_text) return [ CompletionOption( label=option.label, @@ -128,6 +130,73 @@ def console_completion_options( ] +def _options_from_entries(entries: list[tuple], prefix_parts: list[str], token: str = "") -> list[CompletionOption]: + options: list[CompletionOption] = [] + seen: set[str] = set() + normalized_token = token.strip().lower() + for entry in entries: + label = _entry_text(entry) + insert_text = _entry_insert_text(entry) + if not label or not insert_text: + continue + if normalized_token: + normalized_label = label.lower() + normalized_insert = insert_text.lower() + if not ( + normalized_label.startswith(normalized_token) + or normalized_insert.startswith(normalized_token) + or ("(" in normalized_label and normalized_token in normalized_label) + ): + continue + full_text = " ".join([*prefix_parts, insert_text]).strip() + if full_text in seen: + continue + seen.add(full_text) + options.append( + CompletionOption( + label=label, + insert_text=insert_text, + full_text=full_text, + has_children=bool(_entry_children(entry)), + ) + ) + return options + + +def _console_contextual_completion_options(completion_data: list[tuple], command_text: str) -> list[CompletionOption]: + text = str(command_text or "") + if not text.strip(): + return [] + + trailing_space = text.endswith(" ") + tokens = [token for token in text.split(" ") if token] + if not tokens or "--" in tokens[1:]: + return [] + + command_entry = _find_entry(completion_data, tokens[0]) + if command_entry is None: + return [] + command_children = _entry_children(command_entry) + + current_token = "" if trailing_space else tokens[-1] + prefix_parts = tokens if trailing_space else tokens[:-1] + previous_token = tokens[-1] if trailing_space else (tokens[-2] if len(tokens) >= 2 else "") + previous_entry = _find_entry(command_children, previous_token) + if previous_entry is not None and _entry_children(previous_entry): + return _options_from_entries(_entry_children(previous_entry), prefix_parts, current_token) + + if current_token and not current_token.startswith("-"): + return [] + + used_flags = {token for token in tokens[1:] if token.startswith("-")} + flag_entries = [ + entry + for entry in command_children + if _entry_insert_text(entry).startswith("-") and _entry_insert_text(entry) not in used_flags + ] + return _options_from_entries(flag_entries, prefix_parts, current_token) + + def _completion_suffix(command_name: Any, example: Any): command_name = str(command_name or "").strip() example = str(example or "").strip() @@ -141,8 +210,22 @@ def _completion_suffix(command_name: Any, example: Any): return example -def _entry_text(entry: tuple[str, list]) -> str: - return entry[0] +def _entry_text(entry: tuple) -> str: + return str(entry[0]).strip() if entry else "" + + +def _entry_children(entry: tuple) -> list[tuple]: + if len(entry) < 2 or entry[1] is None: + return [] + return entry[1] + + +def _entry_insert_text(entry: tuple) -> str: + if len(entry) >= 3: + insert_text = str(entry[2]).strip() + if insert_text: + return insert_text + return _entry_text(entry) def _find_entry(entries: list[tuple[str, list]], text: str): @@ -164,7 +247,21 @@ def _add_completion_path(entries: list[tuple[str, list]], parts: list[str]) -> N if entry is None: entry = (text, []) entries.append(entry) - _add_completion_path(entry[1], parts[1:]) + _add_completion_path(_entry_children(entry), parts[1:]) + + +def _add_completion_entry(entries: list[tuple], label: Any, insert_text: Any = "", children: list[tuple] | None = None) -> None: + text = str(label or "").strip() + if not text: + return + insert = str(insert_text or "").strip() + entry_children = list(children or []) + if _find_entry(entries, text) is not None: + return + if insert and insert != text: + entries.append((text, entry_children, insert)) + else: + entries.append((text, entry_children)) def _add_completion_value(entries: list[tuple[str, list]], value: Any) -> None: @@ -173,12 +270,14 @@ def _add_completion_value(entries: list[tuple[str, list]], value: Any) -> None: _add_completion_path(entries, text.split()) -def _merge_completion_entries(destination: list[tuple[str, list]], source: list[tuple[str, list]]) -> None: - for text, children in source: - _add_completion_path(destination, [text]) +def _merge_completion_entries(destination: list[tuple[str, list]], source: list[tuple]) -> None: + for entry in source: + text = _entry_text(entry) + children = _entry_children(entry) + _add_completion_entry(destination, text, _entry_insert_text(entry)) destination_entry = _find_entry(destination, text) if destination_entry is not None and children: - _merge_completion_entries(destination_entry[1], children) + _merge_completion_entries(_entry_children(destination_entry), children) def _add_example_completions(children: list[tuple[str, list]], command: Any) -> None: @@ -300,12 +399,21 @@ def _add_artifact_completions( _add_inject_pid_continuations(artifact_entry[1], arg) -def _credential_completion_values(credential: Any) -> list[str]: +def _credential_completion_entries(credential: Any) -> list[tuple]: credential_id = str(getattr(credential, "credential_id", "") or "").strip() if not credential_id: return [] short_id = credential_id[:8] if len(credential_id) > 8 else credential_id - return [f"cred:{short_id}"] + display_name = str(getattr(credential, "display_name", "") or "").strip() + username = str(getattr(credential, "username", "") or "").strip() + domain = str(getattr(credential, "domain", "") or "").strip() + identity = f"{domain}\\{username}" if domain and username else username + title = display_name or identity or short_id + if identity and identity.lower() not in title.lower(): + label = f"{title} - {identity} ({short_id})" + else: + label = f"{title} ({short_id})" + return [(label, [], f"cred:{short_id}")] def _add_credential_completions( @@ -314,8 +422,8 @@ def _add_credential_completions( arg: Any, ) -> None: for credential in _load_credentials_for_arg(grpcClient, arg): - for value in _credential_completion_values(credential): - _add_completion_value(children, value) + for entry in _credential_completion_entries(credential): + _add_completion_entry(children, _entry_text(entry), _entry_insert_text(entry), _entry_children(entry)) def _build_flag_entries( diff --git a/C2Client/C2Client/CredentialVaultPanel.py b/C2Client/C2Client/CredentialVaultPanel.py index eaa6e97..c15c80b 100644 --- a/C2Client/C2Client/CredentialVaultPanel.py +++ b/C2Client/C2Client/CredentialVaultPanel.py @@ -287,6 +287,9 @@ def __init__(self, parent: QWidget | None, grpcClient: Any) -> None: self.detailModifiedLabel = QLabel("-", self.detailsFrame) self.detailNotesLabel = QLabel("-", self.detailsFrame) self.detailNotesLabel.setWordWrap(True) + self.detailIdLabel = QLineEdit("-", self.detailsFrame) + self.detailIdLabel.setReadOnly(True) + self.detailIdLabel.setToolTip("Selected credential id. Select and copy it for --vault if needed.") self.detailSecretLabel = QLineEdit("••••••••", self.detailsFrame) self.detailSecretLabel.setReadOnly(True) self.detailSecretLabel.setToolTip("Selected credential secret. Reveal to inspect, select, or copy it.") @@ -308,8 +311,10 @@ def __init__(self, parent: QWidget | None, grpcClient: Any) -> None: secretLayout.addWidget(self.detailSecretLabel, 1) secretLayout.addWidget(self.detailRevealButton) detailsLayout.addLayout(secretLayout, 2, 1, 1, 3) - detailsLayout.addWidget(QLabel("Notes"), 3, 0) - detailsLayout.addWidget(self.detailNotesLabel, 3, 1, 1, 3) + detailsLayout.addWidget(QLabel("ID"), 3, 0) + detailsLayout.addWidget(self.detailIdLabel, 3, 1, 1, 3) + detailsLayout.addWidget(QLabel("Notes"), 4, 0) + detailsLayout.addWidget(self.detailNotesLabel, 4, 1, 1, 3) self.layout.addWidget(self.detailsFrame) self.connectFilterSignals() @@ -477,6 +482,8 @@ def updateDetails(self, credential: Any | None = None) -> None: self.detailTypeLabel.setText(_text(_field(credential, "type")) or "-") self.detailModifiedLabel.setText(_text(_field(credential, "updated_at")) or "-") self.detailNotesLabel.setText(_text(_field(credential, "description")) or "-") + self.detailIdLabel.setText(_text(_field(credential, "credential_id")) or "-") + self.detailIdLabel.setCursorPosition(0) self.detailSecretLabel.setText("••••••••" if credential is not None and _first_text(_list_field(credential, "secret_fields")) else "-") self.detailSecretLabel.setCursorPosition(0) self.detailRevealButton.setText("Reveal") diff --git a/C2Client/tests/test_console_panel.py b/C2Client/tests/test_console_panel.py index 6ac3294..198614c 100644 --- a/C2Client/tests/test_console_panel.py +++ b/C2Client/tests/test_console_panel.py @@ -425,7 +425,7 @@ def setResponsePollingActive(self, active): def _completion_children(entries, text): - return next(children for entry_text, children in entries if entry_text == text) + return next(entry[1] for entry in entries if entry[0] == text) def test_command_specs_seed_console_completer_from_manifest_examples(): @@ -547,7 +547,7 @@ def listArtifacts(self, query): assert grpc.queries[1].runtime == "file" -def test_credential_arg_uses_vault_completions(): +def test_vault_arg_uses_human_readable_vault_completions(): class FakeGrpc: def __init__(self): self.queries = [] @@ -555,7 +555,12 @@ def __init__(self): def listCredentials(self, query): self.queries.append(query) return iter([ - SimpleNamespace(credential_id="abcdef1234567890", username="alice", domain="CORP"), + SimpleNamespace( + credential_id="abcdef1234567890", + display_name="Domain Admin", + username="alice", + domain="CORP", + ), ]) credential_filter = SimpleNamespace( @@ -574,10 +579,9 @@ def listCredentials(self, query): examples=["psExec -u DOMAIN\\alice secret server svc.exe"], args=[ SimpleNamespace( - name="username", + name="--vault", type="credential", values=[], - completion_parents=["-u"], credential_filter=credential_filter, ), ], @@ -586,9 +590,16 @@ def listCredentials(self, query): grpc = FakeGrpc() server_data = command_specs_to_completer_data([ps_exec_spec], grpcClient=grpc) ps_exec_children = _completion_children(server_data, "psExec") - username_children = _completion_children(ps_exec_children, "-u") - - assert ("cred:abcdef12", []) in username_children + vault_children = _completion_children(ps_exec_children, "--vault") + options = console_completion_options(server_data, "psExec --vault ") + contextual_options = console_completion_options(server_data, "psExec server --vault ") + flag_options = console_completion_options(server_data, "psExec server --v") + + assert vault_children == [("Domain Admin - CORP\\alice (abcdef12)", [], "cred:abcdef12")] + assert options[0].label == "Domain Admin - CORP\\alice (abcdef12)" + assert options[0].full_text == "psExec --vault cred:abcdef12" + assert contextual_options[0].full_text == "psExec server --vault cred:abcdef12" + assert flag_options[0].full_text == "psExec server --vault" assert len(grpc.queries) == 1 assert grpc.queries[0].type == "password" assert grpc.queries[0].domain == "CORP" diff --git a/C2Client/tests/test_credential_vault_panel.py b/C2Client/tests/test_credential_vault_panel.py index c9ab9fb..5cdb5aa 100644 --- a/C2Client/tests/test_credential_vault_panel.py +++ b/C2Client/tests/test_credential_vault_panel.py @@ -107,6 +107,8 @@ def test_credential_vault_panel_lists_like_keepass_and_filters(qtbot): assert panel.detailTitleLabel.text() == "corp alice" assert panel.detailUsernameLabel.text() == "CORP\\alice" assert panel.detailTypeLabel.text() == "password" + assert panel.detailIdLabel.text() == "abcdef1234567890" + assert panel.detailIdLabel.isReadOnly() is True assert panel.detailSecretLabel.text() == "••••••••" assert panel.detailSecretLabel.isReadOnly() is True assert panel.detailNotesLabel.text() == "local admin" diff --git a/core b/core index fcbbaff..0fa6920 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit fcbbaffa7f04f14bc9cb663e1fe3842ca48a7f57 +Subproject commit 0fa692090cb27cee6f260544741e196d6073125c diff --git a/teamServer/teamServer/TeamServerCommandPreparationService.cpp b/teamServer/teamServer/TeamServerCommandPreparationService.cpp index 3c3bbe8..7deb5b7 100644 --- a/teamServer/teamServer/TeamServerCommandPreparationService.cpp +++ b/teamServer/teamServer/TeamServerCommandPreparationService.cpp @@ -33,9 +33,20 @@ bool isCredentialReference(const std::string& value) return value.rfind("cred:", 0) == 0; } -std::string credentialIdFromReference(const std::string& value) +bool isVaultFlag(const std::string& value) { - return isCredentialReference(value) ? value.substr(5) : ""; + return lowerString(value) == "--vault"; +} + +std::string credentialIdFromVaultValue(std::string value) +{ + if (isCredentialReference(value)) + value = value.substr(5); + + const std::size_t labelStart = value.find('('); + if (labelStart != std::string::npos) + value = value.substr(0, labelStart); + return value; } std::string credentialUsername(const ResolvedCredential& credential, bool includeDomain) @@ -48,7 +59,95 @@ std::string credentialUsername(const ResolvedCredential& credential, bool includ bool isPasswordFlag(const std::string& value) { const std::string lower = lowerString(value); - return lower == "-p" || lower == "--password"; + return value == "-p" || lower == "--password"; +} + +bool isUsernameFlag(const std::string& value) +{ + const std::string lower = lowerString(value); + return lower == "-u" || lower == "--user"; +} + +bool isPsExecAuthFlag(const std::string& value) +{ + const std::string lower = lowerString(value); + return lower == "-u" || lower == "-k" || lower == "-n"; +} + +bool isVaultEnabledCommand(const std::string& instruction) +{ + const std::string lower = lowerString(instruction); + return lower == "maketoken" + || lower == "spawnas" + || lower == "psexec" + || lower == "wmiexec" + || lower == "winrm" + || lower == "dcomexec" + || lower == "cimexec" + || lower == "taskscheduler" + || lower == "sshexec"; +} + +bool containsPsExecAuthFlag(const std::vector& tokens) +{ + return std::any_of(tokens.begin() + 1, tokens.end(), isPsExecAuthFlag); +} + +bool containsUsernameOrPasswordFlag(const std::vector& tokens) +{ + return std::any_of(tokens.begin() + 1, tokens.end(), [](const std::string& token) + { + return isUsernameFlag(token) || isPasswordFlag(token); + }); +} + +std::vector::iterator commandTailSeparator(std::vector& tokens) +{ + return std::find(tokens.begin() + 1, tokens.end(), "--"); +} + +bool usesLegacyCredentialReference(const std::vector& tokens, const std::string& instruction) +{ + if (instruction == "maketoken") + return tokens.size() > 1 && isCredentialReference(tokens[1]); + + if (instruction == "spawnas") + { + for (std::size_t i = 1; i < tokens.size(); ++i) + { + const std::string token = lowerString(tokens[i]); + if (token == "--") + return false; + if (token == "-d" || token == "--domain" || token == "-l" || token == "--logon-type") + { + ++i; + continue; + } + if (isCredentialReference(tokens[i])) + return true; + } + return false; + } + + if (instruction == "psexec" || instruction == "wmiexec" || instruction == "winrm") + { + for (std::size_t i = 1; i + 1 < tokens.size(); ++i) + { + if (lowerString(tokens[i]) == "-u" && isCredentialReference(tokens[i + 1])) + return true; + } + return false; + } + + if (instruction == "dcomexec" || instruction == "cimexec" || instruction == "taskscheduler" || instruction == "sshexec") + { + for (std::size_t i = 1; i + 1 < tokens.size(); ++i) + { + if (isUsernameFlag(tokens[i]) && isCredentialReference(tokens[i + 1])) + return true; + } + } + return false; } } // namespace @@ -110,12 +209,21 @@ std::string TeamServerCommandPreparationService::toLower(const std::string& str) bool TeamServerCommandPreparationService::rewriteCredentialReferences(std::vector& tokens, C2Message& c2Message) const { - if (!m_credentialVaultService || tokens.empty()) + if (tokens.empty()) + return true; + if (!m_credentialVaultService) + { + if (std::any_of(tokens.begin() + 1, tokens.end(), isVaultFlag)) + { + c2Message.set_returnvalue("Credential vault service is not available."); + return false; + } return true; + } auto resolve = [&](const std::string& reference, ResolvedCredential& credential) -> bool { - const std::string credentialId = credentialIdFromReference(reference); + const std::string credentialId = credentialIdFromVaultValue(reference); if (credentialId.empty()) return false; @@ -150,98 +258,118 @@ bool TeamServerCommandPreparationService::rewriteCredentialReferences(std::vecto return true; }; - auto replaceCredentialToken = [&](std::size_t index) -> bool + const std::string instruction = lowerString(tokens[0]); + if (!isVaultEnabledCommand(instruction)) { - if (index >= tokens.size() || !isCredentialReference(tokens[index])) - return true; - ResolvedCredential credential; - if (!resolve(tokens[index], credential)) + if (std::any_of(tokens.begin() + 1, tokens.end(), isVaultFlag)) + { + c2Message.set_returnvalue("--vault is not supported by command: " + tokens[0] + "."); return false; - tokens.erase(tokens.begin() + static_cast(index)); - tokens.insert( - tokens.begin() + static_cast(index), - {credentialUsername(credential, true), credential.password}); + } return true; - }; + } - const std::string instruction = lowerString(tokens[0]); - if (instruction == "maketoken") - return replaceCredentialToken(1); + bool hasVault = false; + ResolvedCredential vaultCredential; + for (std::size_t i = 1; i < tokens.size(); ++i) + { + if (!isVaultFlag(tokens[i])) + continue; - if (instruction == "spawnas") + if (hasVault) + { + c2Message.set_returnvalue("Only one --vault credential can be used."); + return false; + } + if (i + 1 >= tokens.size()) + { + c2Message.set_returnvalue("Usage: " + tokens[0] + " --vault ..."); + return false; + } + + if (!resolve(tokens[i + 1], vaultCredential)) + return false; + tokens.erase(tokens.begin() + static_cast(i), tokens.begin() + static_cast(i + 2)); + hasVault = true; + --i; + } + + if (!hasVault) { - for (std::size_t i = 1; i < tokens.size(); ++i) + if (usesLegacyCredentialReference(tokens, instruction)) { - const std::string token = lowerString(tokens[i]); - if (token == "--") - return true; - if (token == "-d" || token == "--domain" || token == "-l" || token == "--logon-type") - { - ++i; - continue; - } - if (isCredentialReference(tokens[i])) - return replaceCredentialToken(i); + c2Message.set_returnvalue("Use --vault instead of placing credential references in command arguments."); + return false; } return true; } if (instruction == "psexec" || instruction == "wmiexec" || instruction == "winrm") { - for (std::size_t i = 1; i + 1 < tokens.size(); ++i) + if (containsPsExecAuthFlag(tokens)) { - if (lowerString(tokens[i]) != "-u" || !isCredentialReference(tokens[i + 1])) - continue; + c2Message.set_returnvalue("Do not combine --vault with explicit authentication mode flags."); + return false; + } + tokens.insert(tokens.begin() + 1, {"-u", credentialUsername(vaultCredential, true), vaultCredential.password}); + return true; + } - ResolvedCredential credential; - if (!resolve(tokens[i + 1], credential)) - return false; - tokens.erase(tokens.begin() + static_cast(i + 1)); - tokens.insert( - tokens.begin() + static_cast(i + 1), - {credentialUsername(credential, true), credential.password}); - return true; + if (instruction == "maketoken") + { + if (tokens.size() > 1) + { + c2Message.set_returnvalue("Do not combine --vault with explicit username/password arguments."); + return false; } + tokens.insert(tokens.end(), {credentialUsername(vaultCredential, true), vaultCredential.password}); return true; } - if (instruction == "dcomexec" || instruction == "cimexec" || instruction == "taskscheduler" || instruction == "sshexec") + if (instruction == "spawnas") { - const bool hasExplicitPasswordFlag = std::any_of(tokens.begin() + 1, tokens.end(), isPasswordFlag); - std::vector rewritten; - rewritten.reserve(tokens.size() + 2); - bool usedCredentialReference = false; - for (std::size_t i = 0; i < tokens.size(); ++i) + for (std::size_t i = 1; i < tokens.size(); ++i) { const std::string token = lowerString(tokens[i]); - if ((token == "-u" || token == "--user") && i + 1 < tokens.size() && isCredentialReference(tokens[i + 1])) + if (token == "--") + break; + if (token == "-d" || token == "--domain" || token == "-l" || token == "--logon-type") { - if (hasExplicitPasswordFlag) - { - c2Message.set_returnvalue("Do not provide a password flag when using a credential reference."); - return false; - } - ResolvedCredential credential; - if (!resolve(tokens[i + 1], credential)) - return false; - rewritten.push_back(tokens[i]); - rewritten.push_back(credentialUsername(credential, instruction != "sshexec")); - rewritten.push_back(instruction == "sshexec" ? "--password" : "-p"); - rewritten.push_back(credential.password); - usedCredentialReference = true; ++i; continue; } - - if (usedCredentialReference && isPasswordFlag(token)) + if (token == "-p" || token == "--with-profile" || token == "--no-profile" || token == "-w" || token == "--show-window" || token == "--netonly") + continue; + if (!token.empty()) { - c2Message.set_returnvalue("Do not provide a password flag when using a credential reference."); + c2Message.set_returnvalue("Do not combine --vault with explicit username/password arguments."); return false; } - rewritten.push_back(tokens[i]); } - if (usedCredentialReference) - tokens = std::move(rewritten); + tokens.insert(commandTailSeparator(tokens), {credentialUsername(vaultCredential, true), vaultCredential.password}); + return true; + } + + if (instruction == "sshexec") + { + if (containsUsernameOrPasswordFlag(tokens)) + { + c2Message.set_returnvalue("Do not combine --vault with explicit username or password options."); + return false; + } + tokens.insert(commandTailSeparator(tokens), {"-u", credentialUsername(vaultCredential, false), "--password", vaultCredential.password}); + return true; + } + + if (instruction == "dcomexec" || instruction == "cimexec" || instruction == "taskscheduler") + { + if (containsUsernameOrPasswordFlag(tokens) || (instruction == "dcomexec" && std::find(tokens.begin() + 1, tokens.end(), "-n") != tokens.end())) + { + c2Message.set_returnvalue("Do not combine --vault with explicit username or password options."); + return false; + } + tokens.insert(tokens.end(), {"-u", credentialUsername(vaultCredential, true), "-p", vaultCredential.password}); + return true; } return true; diff --git a/teamServer/tests/TeamServerCommandCatalogTests.cpp b/teamServer/tests/TeamServerCommandCatalogTests.cpp index c618c60..2fb4a9b 100644 --- a/teamServer/tests/TeamServerCommandCatalogTests.cpp +++ b/teamServer/tests/TeamServerCommandCatalogTests.cpp @@ -125,18 +125,17 @@ void seedCommandSpecs(const TeamServerRuntimeConfig& runtimeConfig) "name": "psExec", "kind": "module", "description": "Copy and run a service executable.", - "command_template": "psExec {auth_mode} {username:q?} {password:q?} {target:q} {service_artifact:q}", + "command_template": "psExec [--vault {vault:q}] {target:q} {service_artifact:q}", "target": "beacon", "requires_session": true, "platforms": ["windows"], "archs": ["x86", "x64"], "args": [ { - "name": "username", + "name": "--vault", "type": "credential", "required": false, - "description": "Credential reference used after -u.", - "completion_parents": ["-u"], + "description": "Credential reference used by --vault.", "credential_filter": { "type": "password", "protocol": "smb", @@ -322,8 +321,7 @@ void testCommandCatalogServiceStreamsProto() assert(commands[0].args(0).credential_filter().tag() == "admin"); assert(commands[0].args(0).credential_filters_size() == 1); assert(commands[0].args(0).credential_filters(0).type() == "password"); - assert(commands[0].args(0).completion_parents_size() == 1); - assert(commands[0].args(0).completion_parents(0) == "-u"); + assert(commands[0].args(0).name() == "--vault"); assert(commands[0].args(1).artifact_filter().category() == "tool"); assert(commands[0].args(1).artifact_filters_size() == 2); assert(commands[0].args(1).artifact_filters(0).category() == "tool"); diff --git a/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp b/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp index d30c9dd..3caf96e 100644 --- a/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp +++ b/teamServer/tests/TeamServerCommandPreparationServiceTests.cpp @@ -1025,14 +1025,22 @@ void testPreparePsExecUsesToolThenUploadedArtifact() C2Message credentialRefMessage; require( - service.prepareMessage("psExec -u cred:" + credentials[0].credential_id().substr(0, 8) + " server01 svc.exe", credentialRefMessage, true, "amd64") == 0, - "psExec credential reference prepare failed"); + service.prepareMessage("psExec --vault " + credentials[0].credential_id().substr(0, 8) + " server01 svc.exe", credentialRefMessage, true, "amd64") == 0, + "psExec vault credential prepare failed"); const std::vector credentialRefFields = splitNullFields(credentialRefMessage.cmd()); - require(credentialRefFields.size() == 4, "psExec credential reference fields count mismatch"); - require(credentialRefFields[0] == "DOMAIN", "psExec credential reference domain mismatch"); - require(credentialRefFields[1] == "alice", "psExec credential reference username mismatch"); - require(credentialRefFields[2] == "secret", "psExec credential reference password mismatch"); - require(credentialRefFields[3] == "server01", "psExec credential reference target mismatch"); + require(credentialRefFields.size() == 4, "psExec vault credential fields count mismatch"); + require(credentialRefFields[0] == "DOMAIN", "psExec vault credential domain mismatch"); + require(credentialRefFields[1] == "alice", "psExec vault credential username mismatch"); + require(credentialRefFields[2] == "secret", "psExec vault credential password mismatch"); + require(credentialRefFields[3] == "server01", "psExec vault credential target mismatch"); + + C2Message oldCredentialRefMessage; + require( + service.prepareMessage("psExec -u cred:" + credentials[0].credential_id().substr(0, 8) + " server01 svc.exe", oldCredentialRefMessage, true, "amd64") == -1, + "psExec legacy credential reference should be rejected"); + require( + oldCredentialRefMessage.returnvalue().find("Use --vault") != std::string::npos, + "psExec legacy credential reference error mismatch"); C2Message uploadMessage; require(service.prepareMessage("psExec -n server01 uploadSvc.exe", uploadMessage, true, "amd64") == 0, "psExec upload fallback prepare failed"); @@ -1053,6 +1061,11 @@ void testPrepareCredentialReferencesForDirectModules() modules.push_back(std::make_unique("makeToken")); modules.push_back(std::make_unique("spawnAs")); modules.push_back(std::make_unique("sshExec")); + modules.push_back(std::make_unique("wmiExec")); + modules.push_back(std::make_unique("winRm")); + modules.push_back(std::make_unique("cimExec")); + modules.push_back(std::make_unique("dcomExec")); + modules.push_back(std::make_unique("taskScheduler")); TeamServerCommandPreparationService service( makeLogger(), @@ -1063,7 +1076,7 @@ void testPrepareCredentialReferencesForDirectModules() credentialVaultService); C2Message makeTokenMessage; - require(service.prepareMessage("makeToken cred:" + credentialId, makeTokenMessage, true, "amd64") == 0, "makeToken credential reference prepare failed"); + require(service.prepareMessage("makeToken --vault cred:" + credentialId, makeTokenMessage, true, "amd64") == 0, "makeToken vault credential prepare failed"); std::vector makeTokenFields = splitNullFields(makeTokenMessage.cmd()); require(makeTokenFields.size() == 2, "makeToken credential fields count mismatch"); require(makeTokenFields[0] == "DOMAIN\\alice", "makeToken credential username mismatch"); @@ -1071,8 +1084,8 @@ void testPrepareCredentialReferencesForDirectModules() C2Message spawnAsMessage; require( - service.prepareMessage("spawnAs --no-profile cred:" + credentialId + " -- cmd.exe /c whoami", spawnAsMessage, true, "amd64") == 0, - "spawnAs credential reference prepare failed"); + service.prepareMessage("spawnAs --no-profile --vault cred:" + credentialId + " -- cmd.exe /c whoami", spawnAsMessage, true, "amd64") == 0, + "spawnAs vault credential prepare failed"); std::vector spawnAsFields = splitNullFields(spawnAsMessage.cmd()); require(spawnAsFields.size() == 7, "spawnAs credential fields count mismatch"); require(spawnAsFields[0] == "--no-profile", "spawnAs option mismatch"); @@ -1081,22 +1094,78 @@ void testPrepareCredentialReferencesForDirectModules() require(spawnAsFields[3] == "--", "spawnAs separator mismatch"); C2Message sshExecMessage; - require(service.prepareMessage("sshExec -h host -u cred:" + credentialId + " id", sshExecMessage, true, "amd64") == 0, "sshExec credential reference prepare failed"); + require(service.prepareMessage("sshExec --vault cred:" + credentialId + " -h host -P 2222 -- id", sshExecMessage, true, "amd64") == 0, "sshExec vault credential prepare failed"); std::vector sshExecFields = splitNullFields(sshExecMessage.cmd()); - require(sshExecFields.size() == 7, "sshExec credential fields count mismatch"); + require(sshExecFields.size() == 10, "sshExec credential fields count mismatch"); require(sshExecFields[0] == "-h", "sshExec host flag mismatch"); - require(sshExecFields[2] == "-u", "sshExec username flag mismatch"); - require(sshExecFields[3] == "alice", "sshExec credential username should omit domain"); - require(sshExecFields[4] == "--password", "sshExec password flag mismatch"); - require(sshExecFields[5] == "secret", "sshExec credential password mismatch"); + require(sshExecFields[2] == "-P", "sshExec port flag mismatch"); + require(sshExecFields[4] == "-u", "sshExec username flag mismatch"); + require(sshExecFields[5] == "alice", "sshExec credential username should omit domain"); + require(sshExecFields[6] == "--password", "sshExec password flag mismatch"); + require(sshExecFields[7] == "secret", "sshExec credential password mismatch"); + require(sshExecFields[8] == "--", "sshExec separator mismatch"); + + C2Message wmiExecMessage; + require(service.prepareMessage("wmiExec --vault cred:" + credentialId + " server01 whoami.exe", wmiExecMessage, true, "amd64") == 0, "wmiExec vault credential prepare failed"); + std::vector wmiExecFields = splitNullFields(wmiExecMessage.cmd()); + require(wmiExecFields.size() == 5, "wmiExec credential fields count mismatch"); + require(wmiExecFields[0] == "-u", "wmiExec auth flag mismatch"); + require(wmiExecFields[1] == "DOMAIN\\alice", "wmiExec credential username mismatch"); + require(wmiExecFields[2] == "secret", "wmiExec credential password mismatch"); + require(wmiExecFields[3] == "server01", "wmiExec target mismatch"); + + C2Message winRmMessage; + require(service.prepareMessage("winRm --vault cred:" + credentialId + " http://server01:5985/wsman whoami.exe", winRmMessage, true, "amd64") == 0, "winRm vault credential prepare failed"); + std::vector winRmFields = splitNullFields(winRmMessage.cmd()); + require(winRmFields.size() == 5, "winRm credential fields count mismatch"); + require(winRmFields[0] == "-u", "winRm auth flag mismatch"); + require(winRmFields[1] == "DOMAIN\\alice", "winRm credential username mismatch"); + require(winRmFields[2] == "secret", "winRm credential password mismatch"); + require(winRmFields[3] == "http://server01:5985/wsman", "winRm target mismatch"); + + C2Message cimExecMessage; + require(service.prepareMessage("cimExec --vault cred:" + credentialId + " -h server01 -c cmd.exe", cimExecMessage, true, "amd64") == 0, "cimExec vault credential prepare failed"); + std::vector cimExecFields = splitNullFields(cimExecMessage.cmd()); + require(cimExecFields.size() == 8, "cimExec credential fields count mismatch"); + require(cimExecFields[0] == "-h", "cimExec host flag mismatch"); + require(cimExecFields[4] == "-u", "cimExec username flag mismatch"); + require(cimExecFields[5] == "DOMAIN\\alice", "cimExec credential username mismatch"); + require(cimExecFields[6] == "-p", "cimExec password flag mismatch"); + require(cimExecFields[7] == "secret", "cimExec credential password mismatch"); + + C2Message dcomExecMessage; + require(service.prepareMessage("dcomExec --vault cred:" + credentialId + " -h server01 -c cmd.exe", dcomExecMessage, true, "amd64") == 0, "dcomExec vault credential prepare failed"); + std::vector dcomExecFields = splitNullFields(dcomExecMessage.cmd()); + require(dcomExecFields.size() == 8, "dcomExec credential fields count mismatch"); + require(dcomExecFields[4] == "-u", "dcomExec username flag mismatch"); + require(dcomExecFields[5] == "DOMAIN\\alice", "dcomExec credential username mismatch"); + require(dcomExecFields[6] == "-p", "dcomExec password flag mismatch"); + require(dcomExecFields[7] == "secret", "dcomExec credential password mismatch"); + + C2Message taskSchedulerMessage; + require(service.prepareMessage("taskScheduler --vault cred:" + credentialId + " -c cmd.exe -s server01", taskSchedulerMessage, true, "amd64") == 0, "taskScheduler vault credential prepare failed"); + std::vector taskSchedulerFields = splitNullFields(taskSchedulerMessage.cmd()); + require(taskSchedulerFields.size() == 8, "taskScheduler credential fields count mismatch"); + require(taskSchedulerFields[4] == "-u", "taskScheduler username flag mismatch"); + require(taskSchedulerFields[5] == "DOMAIN\\alice", "taskScheduler credential username mismatch"); + require(taskSchedulerFields[6] == "-p", "taskScheduler password flag mismatch"); + require(taskSchedulerFields[7] == "secret", "taskScheduler credential password mismatch"); C2Message invalidMessage; require( - service.prepareMessage("sshExec -u cred:" + credentialId + " --password manual host", invalidMessage, true, "amd64") == -1, - "sshExec credential reference should reject explicit password flag"); + service.prepareMessage("sshExec --vault cred:" + credentialId + " --password manual host", invalidMessage, true, "amd64") == -1, + "sshExec vault credential should reject explicit password flag"); + require( + invalidMessage.returnvalue().find("Do not combine --vault") != std::string::npos, + "sshExec vault credential error mismatch"); + + C2Message oldCredentialMessage; + require( + service.prepareMessage("makeToken cred:" + credentialId, oldCredentialMessage, true, "amd64") == -1, + "legacy positional credential reference should be rejected"); require( - invalidMessage.returnvalue().find("Do not provide a password flag") != std::string::npos, - "sshExec credential reference error mismatch"); + oldCredentialMessage.returnvalue().find("Use --vault") != std::string::npos, + "legacy positional credential reference error mismatch"); } void testPrepareCoffLoaderUsesToolArtifact() From 6848e83e300a58d3b2e1e5852862c3a32c81c752 Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Wed, 13 May 2026 11:24:38 +0200 Subject: [PATCH 4/5] minor --- core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core b/core index 0fa6920..cf30445 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 0fa692090cb27cee6f260544741e196d6073125c +Subproject commit cf304454067f67b2e26afa6f400a73048982c7b8 From b123e0df00664010385c84bf066bf783dcb0de0e Mon Sep 17 00:00:00 2001 From: maxdcb <40819564+maxDcb@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:09:38 +0200 Subject: [PATCH 5/5] Minor vault --- C2Client/C2Client/CredentialVaultPanel.py | 1 + C2Client/tests/test_credential_vault_panel.py | 1 + protocol/TeamServerApi.proto | 4 + .../TeamServerCredentialVaultService.cpp | 296 ++++++++++++++---- .../TeamServerCredentialVaultService.hpp | 6 +- .../TeamServerCredentialVaultServiceTests.cpp | 241 ++++++++++++++ 6 files changed, 484 insertions(+), 65 deletions(-) diff --git a/C2Client/C2Client/CredentialVaultPanel.py b/C2Client/C2Client/CredentialVaultPanel.py index c15c80b..3011594 100644 --- a/C2Client/C2Client/CredentialVaultPanel.py +++ b/C2Client/C2Client/CredentialVaultPanel.py @@ -493,6 +493,7 @@ def buildRequestFromValues(self, values: dict[str, str], *, credential_id: str = request = TeamServerApi_pb2.CredentialUpsertRequest() if credential_id: request.credential_id = credential_id + request.update_fields.extend(["display_name", "type", "username", "description"]) request.display_name = values["title"] request.type = values["type"] request.username = values["username"] diff --git a/C2Client/tests/test_credential_vault_panel.py b/C2Client/tests/test_credential_vault_panel.py index 5cdb5aa..2c41d1c 100644 --- a/C2Client/tests/test_credential_vault_panel.py +++ b/C2Client/tests/test_credential_vault_panel.py @@ -204,6 +204,7 @@ def test_credential_vault_panel_edits_without_replacing_secret(qtbot, monkeypatc assert grpc.revealed == ("abcdef1234567890", True) assert request.credential_id == "abcdef1234567890" assert request.display_name == "updated title" + assert list(request.update_fields) == ["display_name", "type", "username", "description"] assert len(request.secrets) == 0 assert request.replace_secrets is False diff --git a/protocol/TeamServerApi.proto b/protocol/TeamServerApi.proto index 8cda56e..a6f0c79 100644 --- a/protocol/TeamServerApi.proto +++ b/protocol/TeamServerApi.proto @@ -240,6 +240,10 @@ message CredentialUpsertRequest string expires_at = 11; repeated CredentialSecret secrets = 12; bool replace_secrets = 13; + // Fields to replace during UpdateCredential. An explicitly listed field may + // be set to an empty value, which allows clients to clear optional metadata. + // An empty mask preserves the legacy non-empty-field update behavior. + repeated string update_fields = 14; } diff --git a/teamServer/teamServer/TeamServerCredentialVaultService.cpp b/teamServer/teamServer/TeamServerCredentialVaultService.cpp index b7a49ba..810c796 100644 --- a/teamServer/teamServer/TeamServerCredentialVaultService.cpp +++ b/teamServer/teamServer/TeamServerCredentialVaultService.cpp @@ -66,6 +66,12 @@ bool matchesField(const std::string& requested, const std::string& actual) return requested.empty() || toLower(requested) == toLower(actual); } +bool updateFieldRequested(const teamserverapi::CredentialUpsertRequest& request, const std::string& field) +{ + const auto& fields = request.update_fields(); + return std::find(fields.begin(), fields.end(), field) != fields.end(); +} + std::string bytesToHex(const std::vector& bytes) { std::ostringstream output; @@ -293,9 +299,21 @@ bool TeamServerCredentialVaultService::writeNewVaultKeyLocked(std::vector nonce; std::vector tag; std::vector ciphertext; - if (!ensureVaultKeyLocked(key, message) - || !hexToBytes(encryptedVault.value("nonce", std::string()), nonce) + if (!readVaultKeyLocked(key, message)) + return false; + if (!hexToBytes(encryptedVault.value("nonce", std::string()), nonce) || !hexToBytes(encryptedVault.value("tag", std::string()), tag) - || !hexToBytes(encryptedVault.value("ciphertext", std::string()), ciphertext)) + || !hexToBytes(encryptedVault.value("ciphertext", std::string()), ciphertext) + || nonce.size() != VaultNonceSize + || tag.size() != VaultTagSize) { message = "credential vault envelope is invalid"; return false; @@ -367,33 +389,39 @@ bool TeamServerCredentialVaultService::decryptVaultLocked(const json& encryptedV return true; } -void TeamServerCredentialVaultService::loadLocked() +bool TeamServerCredentialVaultService::loadLocked(std::string& message) { if (m_loaded) - return; - m_loaded = true; + return true; std::ifstream input(m_runtimeConfig.credentialVaultPath); if (!input.good()) { + if (fs::exists(m_runtimeConfig.credentialVaultPath)) + { + message = "could not read credential vault"; + m_logger->error("Unable to load credential vault: {0}", message); + return false; + } m_credentials.clear(); m_audit = json::array(); - return; + m_loaded = true; + return true; } json encryptedVault = json::parse(input, nullptr, false); if (encryptedVault.is_discarded()) { - m_logger->error("Credential vault file is not valid JSON: {0}", m_runtimeConfig.credentialVaultPath); - return; + message = "credential vault file is not valid JSON"; + m_logger->error("{0}: {1}", message, m_runtimeConfig.credentialVaultPath); + return false; } - std::string message; json plainVault; if (!decryptVaultLocked(encryptedVault, plainVault, message)) { m_logger->error("Unable to load credential vault: {0}", message); - return; + return false; } m_credentials.clear(); @@ -409,6 +437,8 @@ void TeamServerCredentialVaultService::loadLocked() m_audit = plainVault.value("audit", json::array()); if (!m_audit.is_array()) m_audit = json::array(); + m_loaded = true; + return true; } bool TeamServerCredentialVaultService::saveLocked(std::string& message) const @@ -440,26 +470,53 @@ bool TeamServerCredentialVaultService::saveLocked(std::string& message) const message = "could not write credential vault"; return false; } + if (chmod(temporary.c_str(), S_IRUSR | S_IWUSR) != 0) + { + output.close(); + fs::remove(temporary, ec); + message = "could not secure credential vault file permissions"; + return false; + } output << encryptedVault.dump(2) << "\n"; output.close(); if (!output.good()) { + fs::remove(temporary, ec); message = "could not flush credential vault"; return false; } fs::rename(temporary, destination, ec); if (ec) { - fs::remove(destination, ec); + const fs::path backup = destination.string() + ".bak"; + std::error_code cleanupError; + fs::remove(backup, cleanupError); + + const bool hadDestination = fs::exists(destination); ec.clear(); + if (hadDestination) + fs::rename(destination, backup, ec); + if (ec) + { + fs::remove(temporary, cleanupError); + message = "could not preserve existing credential vault before replacement"; + return false; + } + fs::rename(temporary, destination, ec); + if (ec) + { + std::error_code restoreError; + if (hadDestination) + fs::rename(backup, destination, restoreError); + message = restoreError + ? "could not replace credential vault and failed to restore the previous file" + : "could not replace credential vault"; + return false; + } + if (hadDestination) + fs::remove(backup, cleanupError); } - if (ec) - { - message = "could not replace credential vault"; - return false; - } - chmod(destination.c_str(), S_IRUSR | S_IWUSR); return true; } @@ -651,22 +708,54 @@ bool TeamServerCredentialVaultService::matchesQuery(const TeamServerCredentialRe || containsCaseInsensitive(record.credentialId, query.name_contains())); } -TeamServerCredentialRecord* TeamServerCredentialVaultService::findRecordLocked(const std::string& credentialId) +TeamServerCredentialRecord* TeamServerCredentialVaultService::findRecordLocked(const std::string& credentialId, bool* ambiguous) { - auto it = std::find_if(m_credentials.begin(), m_credentials.end(), [&](const TeamServerCredentialRecord& record) + if (ambiguous) + *ambiguous = false; + if (credentialId.empty()) + return nullptr; + + TeamServerCredentialRecord* candidate = nullptr; + for (TeamServerCredentialRecord& record : m_credentials) { - return record.credentialId == credentialId || record.credentialId.rfind(credentialId, 0) == 0; - }); - return it == m_credentials.end() ? nullptr : &(*it); + if (record.credentialId == credentialId) + return &record; + if (record.credentialId.rfind(credentialId, 0) != 0) + continue; + if (candidate) + { + if (ambiguous) + *ambiguous = true; + return nullptr; + } + candidate = &record; + } + return candidate; } -const TeamServerCredentialRecord* TeamServerCredentialVaultService::findRecordLocked(const std::string& credentialId) const +const TeamServerCredentialRecord* TeamServerCredentialVaultService::findRecordLocked(const std::string& credentialId, bool* ambiguous) const { - auto it = std::find_if(m_credentials.begin(), m_credentials.end(), [&](const TeamServerCredentialRecord& record) + if (ambiguous) + *ambiguous = false; + if (credentialId.empty()) + return nullptr; + + const TeamServerCredentialRecord* candidate = nullptr; + for (const TeamServerCredentialRecord& record : m_credentials) { - return record.credentialId == credentialId || record.credentialId.rfind(credentialId, 0) == 0; - }); - return it == m_credentials.end() ? nullptr : &(*it); + if (record.credentialId == credentialId) + return &record; + if (record.credentialId.rfind(credentialId, 0) != 0) + continue; + if (candidate) + { + if (ambiguous) + *ambiguous = true; + return nullptr; + } + candidate = &record; + } + return candidate; } void TeamServerCredentialVaultService::appendAuditLocked(const std::string& action, const std::string& credentialId) @@ -683,7 +772,9 @@ grpc::Status TeamServerCredentialVaultService::listCredentials( const CredentialEmitter& emit) { std::lock_guard lock(m_mutex); - loadLocked(); + std::string message; + if (!loadLocked(message)) + return grpc::Status(grpc::StatusCode::FAILED_PRECONDITION, message); for (const TeamServerCredentialRecord& record : m_credentials) { if (matchesQuery(record, query) && !emit(toSummary(record))) @@ -697,19 +788,38 @@ grpc::Status TeamServerCredentialVaultService::getCredential( teamserverapi::CredentialDetail* response) { std::lock_guard lock(m_mutex); - loadLocked(); - const TeamServerCredentialRecord* record = findRecordLocked(selector.credential_id()); + std::string message; + if (!loadLocked(message)) + { + response->set_status(teamserverapi::KO); + response->set_message(message); + return grpc::Status::OK; + } + if (selector.credential_id().empty()) + { + response->set_status(teamserverapi::KO); + response->set_message("Credential id is required."); + return grpc::Status::OK; + } + bool ambiguous = false; + const TeamServerCredentialRecord* record = findRecordLocked(selector.credential_id(), &ambiguous); if (!record) { response->set_status(teamserverapi::KO); - response->set_message("Credential not found."); + response->set_message(ambiguous ? "Credential id is ambiguous." : "Credential not found."); return grpc::Status::OK; } - fillDetail(*record, selector.reveal_secret(), response); + const json previousAudit = m_audit; appendAuditLocked(selector.reveal_secret() ? "credential_revealed" : "credential_read", record->credentialId); - std::string message; - saveLocked(message); + if (!saveLocked(message)) + { + m_audit = previousAudit; + response->set_status(teamserverapi::KO); + response->set_message("Could not persist credential audit: " + message); + return grpc::Status::OK; + } + fillDetail(*record, selector.reveal_secret(), response); return grpc::Status::OK; } @@ -718,7 +828,12 @@ grpc::Status TeamServerCredentialVaultService::addCredential( teamserverapi::OperationAck* response) { std::lock_guard lock(m_mutex); - loadLocked(); + std::string message; + if (!loadLocked(message)) + { + setAck(response, teamserverapi::KO, message); + return grpc::Status::OK; + } TeamServerCredentialRecord record = recordFromRequest(request); record.credentialId = record.credentialId.empty() ? generateCredentialId() : record.credentialId; @@ -730,12 +845,15 @@ grpc::Status TeamServerCredentialVaultService::addCredential( const std::string now = currentTimestamp(); record.createdAt = now; record.updatedAt = now; + const auto previousCredentials = m_credentials; + const json previousAudit = m_audit; m_credentials.push_back(std::move(record)); appendAuditLocked("credential_created", m_credentials.back().credentialId); - std::string message; if (!saveLocked(message)) { + m_credentials = previousCredentials; + m_audit = previousAudit; setAck(response, teamserverapi::KO, message); return grpc::Status::OK; } @@ -748,7 +866,12 @@ grpc::Status TeamServerCredentialVaultService::updateCredential( teamserverapi::OperationAck* response) { std::lock_guard lock(m_mutex); - loadLocked(); + std::string message; + if (!loadLocked(message)) + { + setAck(response, teamserverapi::KO, message); + return grpc::Status::OK; + } if (request.credential_id().empty()) { @@ -756,24 +879,32 @@ grpc::Status TeamServerCredentialVaultService::updateCredential( return grpc::Status::OK; } - TeamServerCredentialRecord* existing = findRecordLocked(request.credential_id()); + bool ambiguous = false; + TeamServerCredentialRecord* existing = findRecordLocked(request.credential_id(), &ambiguous); if (!existing) { - setAck(response, teamserverapi::KO, "Credential not found."); + setAck(response, teamserverapi::KO, ambiguous ? "Credential id is ambiguous." : "Credential not found."); return grpc::Status::OK; } + const auto previousCredentials = m_credentials; + const json previousAudit = m_audit; TeamServerCredentialRecord update = recordFromRequest(request); - if (!update.displayName.empty()) existing->displayName = update.displayName; - if (!update.type.empty()) existing->type = update.type; - if (!update.username.empty()) existing->username = update.username; - if (!update.domain.empty()) existing->domain = update.domain; - if (!update.realm.empty()) existing->realm = update.realm; - if (!update.target.empty()) existing->target = update.target; - if (!update.protocol.empty()) existing->protocol = update.protocol; - if (!update.tags.empty()) existing->tags = update.tags; - if (!update.description.empty()) existing->description = update.description; - if (!update.expiresAt.empty()) existing->expiresAt = update.expiresAt; + const bool hasUpdateMask = request.update_fields_size() > 0; + auto shouldUpdate = [&](const std::string& field, const std::string& value) + { + return hasUpdateMask ? updateFieldRequested(request, field) : !value.empty(); + }; + if (shouldUpdate("display_name", request.display_name())) existing->displayName = request.display_name(); + if (shouldUpdate("type", request.type())) existing->type = request.type(); + if (shouldUpdate("username", request.username())) existing->username = request.username(); + if (shouldUpdate("domain", request.domain())) existing->domain = request.domain(); + if (shouldUpdate("realm", request.realm())) existing->realm = request.realm(); + if (shouldUpdate("target", request.target())) existing->target = request.target(); + if (shouldUpdate("protocol", request.protocol())) existing->protocol = request.protocol(); + if ((hasUpdateMask && updateFieldRequested(request, "tags")) || (!hasUpdateMask && !update.tags.empty())) existing->tags = update.tags; + if (shouldUpdate("description", request.description())) existing->description = request.description(); + if (shouldUpdate("expires_at", request.expires_at())) existing->expiresAt = request.expires_at(); if (request.replace_secrets()) existing->secrets.clear(); for (const auto& [name, value] : update.secrets) @@ -781,9 +912,10 @@ grpc::Status TeamServerCredentialVaultService::updateCredential( existing->updatedAt = currentTimestamp(); appendAuditLocked("credential_updated", existing->credentialId); - std::string message; if (!saveLocked(message)) { + m_credentials = previousCredentials; + m_audit = previousAudit; setAck(response, teamserverapi::KO, message); return grpc::Status::OK; } @@ -796,26 +928,41 @@ grpc::Status TeamServerCredentialVaultService::deleteCredential( teamserverapi::OperationAck* response) { std::lock_guard lock(m_mutex); - loadLocked(); + std::string message; + if (!loadLocked(message)) + { + setAck(response, teamserverapi::KO, message); + return grpc::Status::OK; + } const std::string credentialId = selector.credential_id(); - auto it = std::find_if(m_credentials.begin(), m_credentials.end(), [&](const TeamServerCredentialRecord& record) + if (credentialId.empty()) { - return record.credentialId == credentialId || record.credentialId.rfind(credentialId, 0) == 0; - }); - if (it == m_credentials.end()) + setAck(response, teamserverapi::KO, "Credential id is required."); + return grpc::Status::OK; + } + bool ambiguous = false; + TeamServerCredentialRecord* record = findRecordLocked(credentialId, &ambiguous); + if (!record) { - setAck(response, teamserverapi::KO, "Credential not found."); + setAck(response, teamserverapi::KO, ambiguous ? "Credential id is ambiguous." : "Credential not found."); return grpc::Status::OK; } - const std::string removedId = it->credentialId; + const auto previousCredentials = m_credentials; + const json previousAudit = m_audit; + const std::string removedId = record->credentialId; + auto it = std::find_if(m_credentials.begin(), m_credentials.end(), [&](const TeamServerCredentialRecord& candidate) + { + return candidate.credentialId == removedId; + }); m_credentials.erase(it); appendAuditLocked("credential_deleted", removedId); - std::string message; if (!saveLocked(message)) { + m_credentials = previousCredentials; + m_audit = previousAudit; setAck(response, teamserverapi::KO, message); return grpc::Status::OK; } @@ -909,7 +1056,12 @@ grpc::Status TeamServerCredentialVaultService::handleTerminalCommand( if (root == "getcred") { std::lock_guard lock(m_mutex); - loadLocked(); + std::string message; + if (!loadLocked(message)) + { + setTerminalError(response, "Error: " + message); + return grpc::Status::OK; + } teamserverapi::CredentialQuery query; setTerminalOk(response, listCredentialsJsonLocked(query)); return grpc::Status::OK; @@ -938,7 +1090,12 @@ grpc::Status TeamServerCredentialVaultService::handleTerminalCommand( if (action == "list" || action == "search") { std::lock_guard lock(m_mutex); - loadLocked(); + std::string message; + if (!loadLocked(message)) + { + setTerminalError(response, "Error: " + message); + return grpc::Status::OK; + } teamserverapi::CredentialQuery query; if (splitedCmd.size() >= 3) query.set_name_contains(splitedCmd[2]); @@ -1006,6 +1163,21 @@ grpc::Status TeamServerCredentialVaultService::handleTerminalCommand( request.set_protocol(record.protocol); request.set_description(record.description); request.set_expires_at(record.expiresAt); + auto markUpdatedField = [&](const char* jsonField, const char* requestField) + { + if (input.contains(jsonField)) + request.add_update_fields(requestField); + }; + markUpdatedField("display_name", "display_name"); + markUpdatedField("type", "type"); + markUpdatedField("username", "username"); + markUpdatedField("domain", "domain"); + markUpdatedField("realm", "realm"); + markUpdatedField("target", "target"); + markUpdatedField("protocol", "protocol"); + markUpdatedField("tags", "tags"); + markUpdatedField("description", "description"); + markUpdatedField("expires_at", "expires_at"); for (const std::string& tag : record.tags) request.add_tags(tag); for (const auto& [name, value] : record.secrets) diff --git a/teamServer/teamServer/TeamServerCredentialVaultService.hpp b/teamServer/teamServer/TeamServerCredentialVaultService.hpp index 98dcaaf..3084b7d 100644 --- a/teamServer/teamServer/TeamServerCredentialVaultService.hpp +++ b/teamServer/teamServer/TeamServerCredentialVaultService.hpp @@ -68,7 +68,7 @@ class TeamServerCredentialVaultService teamserverapi::TerminalCommandResponse* response); private: - void loadLocked(); + bool loadLocked(std::string& message); bool saveLocked(std::string& message) const; bool ensureVaultKeyLocked(std::vector& key, std::string& message) const; bool readVaultKeyLocked(std::vector& key, std::string& message) const; @@ -82,8 +82,8 @@ class TeamServerCredentialVaultService teamserverapi::CredentialSummary toSummary(const TeamServerCredentialRecord& record) const; void fillDetail(const TeamServerCredentialRecord& record, bool revealSecret, teamserverapi::CredentialDetail* response) const; bool matchesQuery(const TeamServerCredentialRecord& record, const teamserverapi::CredentialQuery& query) const; - TeamServerCredentialRecord* findRecordLocked(const std::string& credentialId); - const TeamServerCredentialRecord* findRecordLocked(const std::string& credentialId) const; + TeamServerCredentialRecord* findRecordLocked(const std::string& credentialId, bool* ambiguous = nullptr); + const TeamServerCredentialRecord* findRecordLocked(const std::string& credentialId, bool* ambiguous = nullptr) const; std::string generateCredentialId() const; std::string currentTimestamp() const; std::string terminalPayloadJson(const std::vector& splitedCmd, const teamserverapi::TerminalCommandRequest& command, std::size_t tailIndex) const; diff --git a/teamServer/tests/TeamServerCredentialVaultServiceTests.cpp b/teamServer/tests/TeamServerCredentialVaultServiceTests.cpp index 8263d26..d610e06 100644 --- a/teamServer/tests/TeamServerCredentialVaultServiceTests.cpp +++ b/teamServer/tests/TeamServerCredentialVaultServiceTests.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include "TeamServerCredentialVaultService.hpp" @@ -224,6 +225,240 @@ void testUpdateDeleteAndExpiredFiltering() }).ok(), "post-delete listCredentials RPC status failed"); require(activeCredentials.empty(), "deleted credential still listed"); } + +void testRejectsEmptyAndAmbiguousSelectors() +{ + ScopedPath tempRoot(makeTempDirectory("selectors")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + TeamServerCredentialVaultService service(makeLogger(), runtimeConfig); + + auto addCredential = [&](const std::string& credentialId, const std::string& username) + { + teamserverapi::CredentialUpsertRequest request; + request.set_credential_id(credentialId); + request.set_username(username); + teamserverapi::CredentialSecret* password = request.add_secrets(); + password->set_name("password"); + password->set_value("secret"); + teamserverapi::OperationAck ack; + require(service.addCredential(request, &ack).ok(), "selector fixture add RPC failed"); + require(ack.status() == teamserverapi::OK, "selector fixture add failed: " + ack.message()); + }; + + addCredential("abcdef0011111111", "alice"); + addCredential("abcdef0022222222", "bob"); + + teamserverapi::CredentialSelector selector; + selector.set_reveal_secret(true); + teamserverapi::CredentialDetail detail; + require(service.getCredential(selector, &detail).ok(), "empty selector get RPC failed"); + require(detail.status() == teamserverapi::KO, "empty selector unexpectedly returned a credential"); + require(detail.message().find("required") != std::string::npos, "empty selector error was not explicit"); + + teamserverapi::OperationAck ack; + require(service.deleteCredential(selector, &ack).ok(), "empty selector delete RPC failed"); + require(ack.status() == teamserverapi::KO, "empty selector unexpectedly deleted a credential"); + + selector.set_credential_id("abcdef00"); + require(service.getCredential(selector, &detail).ok(), "ambiguous selector get RPC failed"); + require(detail.status() == teamserverapi::KO, "ambiguous selector unexpectedly returned a credential"); + require(detail.message().find("ambiguous") != std::string::npos, "ambiguous selector error was not explicit"); + require(service.deleteCredential(selector, &ack).ok(), "ambiguous selector delete RPC failed"); + require(ack.status() == teamserverapi::KO, "ambiguous selector unexpectedly deleted a credential"); + + std::vector remaining; + require(service.listCredentials(teamserverapi::CredentialQuery(), [&](const teamserverapi::CredentialSummary& summary) + { + remaining.push_back(summary); + return true; + }).ok(), "selector fixture list failed"); + require(remaining.size() == 2, "invalid selectors changed the vault contents"); +} + +void testCorruptVaultFailsClosed() +{ + ScopedPath tempRoot(makeTempDirectory("corrupt")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + const std::string originalContent = "{not-valid-json\n"; + { + std::ofstream output(runtimeConfig.credentialVaultPath); + output << originalContent; + } + + TeamServerCredentialVaultService service(makeLogger(), runtimeConfig); + teamserverapi::CredentialUpsertRequest request; + request.set_username("must-not-be-written"); + teamserverapi::CredentialSecret* password = request.add_secrets(); + password->set_name("password"); + password->set_value("secret"); + teamserverapi::OperationAck ack; + require(service.addCredential(request, &ack).ok(), "corrupt vault add RPC failed"); + require(ack.status() == teamserverapi::KO, "corrupt vault accepted a mutation"); + + std::ifstream input(runtimeConfig.credentialVaultPath); + const std::string content((std::istreambuf_iterator(input)), std::istreambuf_iterator()); + require(content == originalContent, "corrupt vault was overwritten after a failed load"); +} + +void testWrongKeyFailsClosed() +{ + ScopedPath tempRoot(makeTempDirectory("wrong-key")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + TeamServerCredentialVaultService initialService(makeLogger(), runtimeConfig); + + teamserverapi::CredentialUpsertRequest initialRequest; + initialRequest.set_username("alice"); + teamserverapi::CredentialSecret* initialPassword = initialRequest.add_secrets(); + initialPassword->set_name("password"); + initialPassword->set_value("original-secret"); + teamserverapi::OperationAck ack; + require(initialService.addCredential(initialRequest, &ack).ok(), "wrong-key fixture add RPC failed"); + require(ack.status() == teamserverapi::OK, "wrong-key fixture add failed"); + + std::ifstream vaultBefore(runtimeConfig.credentialVaultPath); + const std::string originalVault((std::istreambuf_iterator(vaultBefore)), std::istreambuf_iterator()); + { + std::ofstream keyFile(runtimeConfig.credentialVaultKeyFile, std::ios::out | std::ios::trunc); + keyFile << std::string(64, '0') << "\n"; + } + + TeamServerCredentialVaultService reloadedService(makeLogger(), runtimeConfig); + teamserverapi::CredentialUpsertRequest rejectedRequest; + rejectedRequest.set_username("bob"); + teamserverapi::CredentialSecret* rejectedPassword = rejectedRequest.add_secrets(); + rejectedPassword->set_name("password"); + rejectedPassword->set_value("must-not-be-written"); + require(reloadedService.addCredential(rejectedRequest, &ack).ok(), "wrong-key add RPC failed"); + require(ack.status() == teamserverapi::KO, "wrong key accepted a mutation"); + + std::ifstream vaultAfter(runtimeConfig.credentialVaultPath); + const std::string currentVault((std::istreambuf_iterator(vaultAfter)), std::istreambuf_iterator()); + require(currentVault == originalVault, "vault was overwritten after decryption failed"); +} + +void testMissingKeyFailsClosedWithoutGeneratingReplacement() +{ + ScopedPath tempRoot(makeTempDirectory("missing-key")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + TeamServerCredentialVaultService initialService(makeLogger(), runtimeConfig); + + teamserverapi::CredentialUpsertRequest initialRequest; + initialRequest.set_username("alice"); + teamserverapi::CredentialSecret* initialPassword = initialRequest.add_secrets(); + initialPassword->set_name("password"); + initialPassword->set_value("original-secret"); + teamserverapi::OperationAck ack; + require(initialService.addCredential(initialRequest, &ack).ok(), "missing-key fixture add RPC failed"); + require(ack.status() == teamserverapi::OK, "missing-key fixture add failed"); + + std::ifstream vaultBefore(runtimeConfig.credentialVaultPath); + const std::string originalVault((std::istreambuf_iterator(vaultBefore)), std::istreambuf_iterator()); + require(fs::remove(runtimeConfig.credentialVaultKeyFile), "could not remove vault key fixture"); + + TeamServerCredentialVaultService reloadedService(makeLogger(), runtimeConfig); + teamserverapi::CredentialUpsertRequest rejectedRequest; + rejectedRequest.set_username("bob"); + require(reloadedService.addCredential(rejectedRequest, &ack).ok(), "missing-key add RPC failed"); + require(ack.status() == teamserverapi::KO, "missing key accepted a mutation"); + require(!fs::exists(runtimeConfig.credentialVaultKeyFile), "missing key was silently replaced"); + + std::ifstream vaultAfter(runtimeConfig.credentialVaultPath); + const std::string currentVault((std::istreambuf_iterator(vaultAfter)), std::istreambuf_iterator()); + require(currentVault == originalVault, "vault was overwritten after its key was missing"); +} + +void testFailedSaveRollsBackMutations() +{ + ScopedPath tempRoot(makeTempDirectory("rollback")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + TeamServerCredentialVaultService service(makeLogger(), runtimeConfig); + + teamserverapi::CredentialUpsertRequest initialRequest; + initialRequest.set_credential_id("1111111111111111"); + initialRequest.set_username("alice"); + teamserverapi::CredentialSecret* initialPassword = initialRequest.add_secrets(); + initialPassword->set_name("password"); + initialPassword->set_value("secret"); + teamserverapi::OperationAck ack; + require(service.addCredential(initialRequest, &ack).ok(), "rollback fixture add RPC failed"); + require(ack.status() == teamserverapi::OK, "rollback fixture add failed"); + + require(::chmod(runtimeConfig.credentialVaultDirectoryPath.c_str(), S_IRUSR | S_IXUSR) == 0, "could not make vault directory read-only"); + + teamserverapi::CredentialSelector revealSelector; + revealSelector.set_credential_id("1111111111111111"); + revealSelector.set_reveal_secret(true); + teamserverapi::CredentialDetail revealDetail; + require(service.getCredential(revealSelector, &revealDetail).ok(), "failed-audit reveal RPC failed"); + require(revealDetail.status() == teamserverapi::KO, "credential was revealed without persisting its audit event"); + require(revealDetail.secrets_size() == 0, "failed-audit reveal returned a secret"); + + teamserverapi::CredentialUpsertRequest rejectedRequest; + rejectedRequest.set_credential_id("2222222222222222"); + rejectedRequest.set_username("bob"); + teamserverapi::CredentialSecret* rejectedPassword = rejectedRequest.add_secrets(); + rejectedPassword->set_name("password"); + rejectedPassword->set_value("secret"); + require(service.addCredential(rejectedRequest, &ack).ok(), "failed-save add RPC failed"); + require(ack.status() == teamserverapi::KO, "failed save reported a successful add"); + + teamserverapi::CredentialSelector selector; + selector.set_credential_id("1111111111111111"); + require(service.deleteCredential(selector, &ack).ok(), "failed-save delete RPC failed"); + require(ack.status() == teamserverapi::KO, "failed save reported a successful delete"); + + require(::chmod(runtimeConfig.credentialVaultDirectoryPath.c_str(), S_IRWXU) == 0, "could not restore vault directory permissions"); + + std::vector credentials; + require(service.listCredentials(teamserverapi::CredentialQuery(), [&](const teamserverapi::CredentialSummary& summary) + { + credentials.push_back(summary); + return true; + }).ok(), "rollback list failed"); + require(credentials.size() == 1, "failed mutations changed the in-memory vault"); + require(credentials[0].credential_id() == "1111111111111111", "failed delete was not rolled back"); +} + +void testUpdateMaskCanClearMetadata() +{ + ScopedPath tempRoot(makeTempDirectory("clear-fields")); + TeamServerRuntimeConfig runtimeConfig = makeRuntimeConfig(tempRoot.path()); + TeamServerCredentialVaultService service(makeLogger(), runtimeConfig); + + teamserverapi::CredentialUpsertRequest addRequest; + addRequest.set_credential_id("3333333333333333"); + addRequest.set_username("alice"); + addRequest.set_domain("CORP"); + addRequest.set_description("temporary description"); + addRequest.set_expires_at("2999-01-01T00:00:00Z"); + addRequest.add_tags("temporary"); + teamserverapi::CredentialSecret* password = addRequest.add_secrets(); + password->set_name("password"); + password->set_value("secret"); + teamserverapi::OperationAck ack; + require(service.addCredential(addRequest, &ack).ok(), "clear-fields fixture add RPC failed"); + require(ack.status() == teamserverapi::OK, "clear-fields fixture add failed"); + + teamserverapi::CredentialUpsertRequest updateRequest; + updateRequest.set_credential_id("33333333"); + updateRequest.add_update_fields("domain"); + updateRequest.add_update_fields("description"); + updateRequest.add_update_fields("expires_at"); + updateRequest.add_update_fields("tags"); + require(service.updateCredential(updateRequest, &ack).ok(), "clear-fields update RPC failed"); + require(ack.status() == teamserverapi::OK, "clear-fields update failed: " + ack.message()); + + teamserverapi::CredentialSelector selector; + selector.set_credential_id("33333333"); + teamserverapi::CredentialDetail detail; + require(service.getCredential(selector, &detail).ok(), "clear-fields get RPC failed"); + require(detail.status() == teamserverapi::OK, "clear-fields get failed"); + require(detail.summary().domain().empty(), "update mask did not clear domain"); + require(detail.summary().description().empty(), "update mask did not clear description"); + require(detail.summary().expires_at().empty(), "update mask did not clear expiration"); + require(detail.summary().tags().empty(), "update mask did not clear tags"); + require(detail.summary().secret_fields_size() == 1, "metadata update unexpectedly cleared secrets"); +} } // namespace int main() @@ -231,5 +466,11 @@ int main() testAddListRevealAndPersistence(); testTerminalIntegration(); testUpdateDeleteAndExpiredFiltering(); + testRejectsEmptyAndAmbiguousSelectors(); + testCorruptVaultFailsClosed(); + testWrongKeyFailsClosed(); + testMissingKeyFailsClosedWithoutGeneratingReplacement(); + testFailedSaveRollsBackMutations(); + testUpdateMaskCanClearMetadata(); return 0; }