diff --git a/Ghidra/Configurations/Public_Release/src/global/docs/WhatsNew.md b/Ghidra/Configurations/Public_Release/src/global/docs/WhatsNew.md index 051f9972e3..6734410c3a 100644 --- a/Ghidra/Configurations/Public_Release/src/global/docs/WhatsNew.md +++ b/Ghidra/Configurations/Public_Release/src/global/docs/WhatsNew.md @@ -15,21 +15,25 @@ applied Ghidra SRE capabilities to a variety of problems that involve analyzing generating deep insights for NSA analysts who seek a better understanding of potential vulnerabilities in networks and systems. -# What's New in Ghidra 12.1 +# What's New in Ghidra 12.2 This release includes new features, enhancements, performance improvements, quite a few bug fixes, and many pull-request contributions. Thanks to all those who have contributed their time, thoughts, and code. The Ghidra user community thanks you too! +Major changes have been made to Ghidra Server and BSim PostgreSQL Server deployment and TLS/SSL +certificate requirements. TLS/SSL server authentication is now enforced for all client/server +connections. See __TLS/SSL Client/Server Changes__ below. + ### The not-so-fine print: Please Read! -Ghidra 12.1 is fully backward compatible with project data from previous releases. However, programs -and data type archives which are created or modified in 12.1 will not be usable by an earlier Ghidra +Ghidra 12.2 is fully backward compatible with project data from previous releases. However, programs +and data type archives which are created or modified in 12.2 may not be usable by an earlier Ghidra version. **IMPORTANT:** Jython support is not supported by default but is included with the release as an extension. An extra step is required to install it. If you have Ghidra Jython scripts, you must either install the Jython Extension, convert your scripts to Python and run with PyGhidra, or convert your scripts to JAVA. -**IMPORTANT:** Ghidra 12.1 requires, at minimum, JDK 21 to run. +**IMPORTANT:** Ghidra 12.2 requires, at minimum, JDK 25 to run. **IMPORTANT:** To use the Debugger or do a full source distribution build, you will need Python3 (3.9 to 3.14 supported) installed on your system. @@ -47,7 +51,7 @@ libraries and operating systems (e.g., CentOS 7.x) may also run into compatibili launching native executables such as the Decompiler and GNU Demangler which may necessitate a rebuild of native components. -**NOTE:** Programs imported with a Ghidra beta version or code built directly from source code +**NOTE:** Programs imported with a Ghidra Beta version or code built directly from source code outside of a release tag may not be compatible, and may have flaws that won't be corrected by using this new release. Any programs analyzed from a beta or other local master source build should be considered experimental and re-imported and analyzed with a release version. @@ -58,95 +62,78 @@ process that will provide better results than prior Ghidra versions. You might fresh import of any program you will continue to reverse engineer to see if the latest Ghidra provides better results. -**NOTE:** Ghidra Server: The Ghidra 12.1 server is compatible with older Ghidra 11.3.2 clients and +**NOTE:** Ghidra Server: The Ghidra 12.2 server is compatible with older Ghidra 11.3.2 clients and later, although the presence of any newer link-files within a repository may not be handled properly by client versions prior to 12.0, which lack support for the newer storage format. Ghidra 12.1 clients require Ghidra Server version 12.1/12.0.5 or newer compatible version. **NOTE:** Ghidra Server: Due to security fixes made to Ghidra and the Ghidra Server it is highly -recommended that older installation versions be updated to this latest release. +recommended that older installation versions be updated to this latest release. To ensure compatibility, +older client version of Ghidra should also be upgraded. ## Security Related Fixes -### RMI Serialization Filter Improvements -RMI Serialization filters for the Ghidra Server have been tightened and similar filters have been -added to Ghidra client applications which may communicate with a Ghidra Server. Please report -any unexpected *InvalidClassException* errors, which may occur, to the Ghidra team. If this does occur, -please check your Ghidra Server or application log files for entries which indicate any filter -rejections and the name of the offending class. +### TLS/SSL Client/Server Changes -### Ghidra Server - PKI Authentication Vulnerability -For those Ghidra Server deployments which utilize PKI Authentication mode (-a2), a logic bug -within the authentication callback to the server could allow an attacker to authenticate as a -different user without having access to their private key. Prior to completing the forged -authentication callback, the attacker would still need to successfully complete a fully authenticated -TLS connection with the Ghidra Server based on the installed Certificate Authorities (CAs). +Ghidra Server and BSim PostgreSQL Server deployments now highly encourage the use of a CA-signed +server certificate. In addition, Ghidra clients will now enforce server-authentication +for all SSL/TLS connections. This was previously not the case with earlier versions of Ghidra. +This server-authentication also applies to accessing servers accessed via the loopback/localhost +interface, although the property `ghidra.disable.loopback.server.authentication` can be set `true` in +`support/launch.properties` file to disable such local server authentication for testing. -## Bitfields -The Decompiler now recovers and displays the names of **bitfield** components in structured -data-types, when analyzing code that manipulates them. +A suitable keystore must be obtained from a CA signing-authority or a self-signed certificate file may +be generated but is not preferred. If needed, the new `server/certTool` command provided with Ghidra +may be used to assist with the keystore request and generation. -Low-level details of how code isolates an individual bitfield are simplified away in Decompiler -output. Instead, the bitfield is displayed as a single logical value, by name, using standard field -access notation. Both expressions that *read from* or *write to* a bitfield can be recovered. +Each client must ensure that trusted certificates are added to an appropriate trust store. +Ghidra clients now support the use of OS managed certificate trust stores as well as default trust stores +supplied with the Java installation. For Windows and macOS, the system provided `User Certificate +Manager` may be launched from the Ghidra projct window (Edit -> Manage Certificates...). For Unix/Linux +the property `ghidra.unix.default.cacerts` may be optionally specified in `support/launch.properties` to +identify a directory path where unencrypted PEM or DER trusted certificate files may be added. In the case of a +server which uses a self-signed certificate, that certificate would need to be added by each client as a trusted +certificate. Otherwise, all the CA certificates in the server's CA-chain should be added if not already +present. -Many optimized expressions that read, write, or compare multiple bitfields at once can also be -broken out so that the individual bitfields are visible. +#### Ghidra Server -## Objective-C -The old Objective-C analyzers: -* Objective-C 2 Class -* Objective-C 2 Decompiler Message -* Objective-C Message (Prototype) +If the `server/server.conf` file does not specify a `ghidra.keystore` the server will continue to auto-generate +a temporary self-signed server certificate. However, when this occurs the server will now only listen +for loopback connections on the localhost interface. If remote connections are required, a proper +keystore must be specified. If local access only is acceptable, a Ghidra client may set the +`ghidra.disable.loopback.server.authentication=true` property in the `support/launch.properties` file with caution. +Otherwise, a keystore must be generated. -have been been reworked and replaced with versions that are more compatible with modern -Objective-C binaries: -* Objective-C Type Metadata Analyzer -* Objective-C Message Analyzer +See `server/svrREADME.md or server/svrREADME.html for more details. -Where possible, calls to `_objc_msgSend()` and its variations (including `_objc_msgSend$` stubs) -have been overridden to reference the actual target method (if discoverable), which results in a -much more user-friendly decompilation. +#### BSim PostgreSQL Server -Additionally, a variety of AARCH64 call-fixups have been implemented which further clean up -decompilation, hiding much of the noise that things like Automatic Reference Counting (ARC) can -generate. +A BSim PostgreSQL data directory that was configured with a previous version of Ghidra will +not have any new constraints other than client connections now performing server +authentication. If using a self-signed certificate it will need to be added to client +trust stores or a properly signed server certificate/keystore obtained. -## Debuginfod -We've added support for downloading DWARF debug files from HTTP[s] debuginfod servers, as well as -searching the user's `$HOME/.cache/debuginfod_client` directory. You can configure these options in -the Code Browser tool's **Edit | DWARF External Debug Config** menu. +New BSim PostgreSQL deployments should specify a server keystore when initialized. If a keystore +is not specified, the server will use an auto-generated self-signed certificate which will need to +be added to client trust stores. When either a keystore is not specified, or the `trust` authentication +mode is used (`--auth=trust`), the server will be configured to listen to loopback connections on the +localhost interface only. -## Microsoft Demangler -We've added **Output Options** to the Microsoft Demangler to control the demangled output -presentation, changing it from the standard form. +See Ghidra GUI Help Content related to BSim Database Configuration and `bsim_ctl` for more details. -One option controls the inclusion of user-defined-type tags (e.g., "struct") when the type is used -as a function or template argument. When the tags are not applied, it can reduce the bifurcation -of symbols within namespaces where some namespaces have the tags and others do not. This can happen -when non-mangled symbols do not include the tag and demangled symbols do. +## BSim PostgreSQL Deployment and Control (bsim_ctl) -Another option controls whether the standard **\`anonymous namespace'** is presented in a -**_anon_ABCD01234** form using its encoded anonymous namespace number. When the new form is used, -it can reduce the commingling of symbols from two distinct anonymous namespaces into one generic -**\`anonymous namespace'**. Note, however, that non-mangled symbols with the generic -**\`anonymous namespace'** (or one of its variants) can still be found in a program, coming from -other sources, such as PDB. There is currently no simple way to try to match these with the new -encoded form; thus, using the encoded form can also create bifurcation in the namespace. +Extensive changes have been made to the BSim PostgreSQL control script. New `bsim_ctl` commands +have been added for initializing and reconfiguring a server deployment (`init`, `configure`). Once +a deployment is configured, the following commands are used to manage its state: `start`, `stop`, and +`restart`. In addition, the ability to install as a Linux Service has been added using the commands +`install-service` and `uninstall-service`. A new command `listusers` has also been added to aid with +user management. -## Processors -Added the Hexagon Processor module. The instruction syntax is modified from the Hexagon manual to better -fit Ghidra's mnemonic and operand Listing API. This processor also introduces the first use of Ghidra's -Sleigh **crossbuild** feature which is used for weaving pcode for parallel processor architectures such -as the Hexagon. - -There have been a significant number of missing/extension instructions added to the ARM, AARCH64, -and X86 processors. Additionally since 12.0 there a myriad of processor specification bugs have been fixed. - -## Jython Extension -Jython support is now delivered as a Ghidra Extension, which means an extra step is required to -install it. If you require Jython, simply go to `File -> Install Extensions` in the Ghidra -Front End GUI and check "Jython". Restart Ghidra and Jython support will be enabled. +When initializing or configuring a PostgreSQL server, `password` authentication mode is now the default +if the `--auth` option is not specified. This differs from previous releases which defaulted to `trust` +authentication. In general, use of `trust` authentication should be avoided. ## Additional Bug Fixes and Enhancements Numerous other new features, improvements, and bug fixes are fully listed in the diff --git a/Ghidra/Features/BSim/data/serverconfig.xml b/Ghidra/Features/BSim/data/serverconfig.xml index c37f817823..0efc928f79 100755 --- a/Ghidra/Features/BSim/data/serverconfig.xml +++ b/Ghidra/Features/BSim/data/serverconfig.xml @@ -1,10 +1,23 @@ - 2GB - 16MB - 30min + + 2GB + 16MB + 30min '*' - on - + on + 'server.crt' + 'server.key' + 'TLSv1.2' + + 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384' + on + on + 'log' scram-sha-256 diff --git a/Ghidra/Features/BSim/src/main/help/help/topics/BSim/CommandLineReference.html b/Ghidra/Features/BSim/src/main/help/help/topics/BSim/CommandLineReference.html index af335bbe52..08f3f5423c 100644 --- a/Ghidra/Features/BSim/src/main/help/help/topics/BSim/CommandLineReference.html +++ b/Ghidra/Features/BSim/src/main/help/help/topics/BSim/CommandLineReference.html @@ -39,109 +39,358 @@
 
-    bsim_ctl start           </datadir-path> [--auth|-a pki|password|trust] [--noLocalAuth] [--cafile </cacert-path>] [--dn "<distinguished-name>"]
-    bsim_ctl stop            </datadir-path> [--force]
-    bsim_ctl status          </datadir-path>
-    bsim_ctl adduser         </datadir-path> <username> [--dn "<distinguished-name>"]
-    bsim_ctl dropuser        </datadir-path> <username>
-    bsim_ctl resetpassword   <username>
-    bsim_ctl changeauth      </datadir-path> [--auth|-a pki|password|trust] [--noLocalAuth] [--cafile </cacert-path>] [--dn "<distinguished-name>"]
-    bsim_ctl changeprivilege <username> admin|user
-    
-    Global Options:
-        --port|-p <portnum>
+    bsim_ctl init              </datadir-path> [--keystore|-k "</keystore-path>"] [--auth|-a password|pki|trust] [--noLocalAuth]
+                                               [--cafile|-ca "</cacert-path>"] [--port|-p <portnum>] [--os-user <account>]
+    bsim_ctl configure         </datadir-path> [--keystore|-k "</keystore-path>"] [--auth|-a password|pki|trust] [--noLocalAuth]
+                                               [--cafile|-ca "</cacert-path>"] [--port|-p <portnum>]
+    bsim_ctl start             </datadir-path>
+    bsim_ctl stop              </datadir-path> [--force|-f]
+    bsim_ctl restart           </datadir-path> [--force|-f]
+    bsim_ctl status            </datadir-path>
+    bsim_ctl install-service   </datadir-path>   (root only)
+    bsim_ctl uninstall-service </datadir-path>   (root only)
+    bsim_ctl adduser           </datadir-path> <username> [--dn|-dn "<distinguished-name>"]
+    bsim_ctl dropuser          </datadir-path> <username>
+    bsim_ctl listusers         </datadir-path>
+    bsim_ctl resetpassword     <username> [--port|-p <portnum>]
+    bsim_ctl changeprivilege   <username> admin|user [--port|-p <portnum>]
+
+    Global options:
         --user|-u <username>
         --cert </certfile-path>
+        --verbose|-v
+
+    NOTES:
+	
+    1. The server must be started for the following commands to work: 
+       'configure', 'adduser', 'dropuser', 'listusers', 'resetpassword', 'changeprivilege'
+	
+    2. Options with values may also be specified using the form: --option=value
+	
+    3. If the --port option is omitted or has a negative value the default PostgreSQL port 5432 will be used.
+	
+    4. A 'configure' change takes effect when the server is next restarted; the invoking user must be
+       able to authenticate with the admin role before any authentication change is permitted.
+	   
+    5. The --cert option is required by 'init' and 'configure' for PKI authentication; the admin user's
+       distinguished name (DN) and common name (CN) are obtained from that certificate and verified
+       against the --cafile certificate authorities.  The --dn option is only used by 'adduser', where it
+       is required when PKI authentication is used.
+	   
+    6. The --cafile file must be an unencrypted PEM file which provides a complete chain of trust for
+       every certificate authority it contains.  It is required by 'init' for PKI authentication, and
+       may be omitted by 'configure' to retain the authorities already installed.
+	   
+    7. A PKI server initialized by this version of Ghidra matches each user's full certificate
+       distinguished name (DN), whereas one initialized by Ghidra 12.1.x or older continues to match only
+       the common name (CN) which its existing user entries were registered with.  'configure' and
+       'adduser' follow whichever the server is configured for, and 'status'/'listusers' report it.
+       Specify --dn in RFC 2253 form, exactly as the certificate subject is rendered by the openssl command:
+          openssl x509 -noout -subject -nameopt RFC2253 -in <certfile>
+
 
 

bsim_ctl is a command-line utility for - starting and stopping a BSim server using the PostgreSQL back-end. The utility cannot be - used with either an Elasticsearch server or a local H2 database. - All commands must be run on the machine hosting the server. + initializing, configuring, and controlling a BSim server using the PostgreSQL back-end. The utility cannot be + used with either an Elasticsearch server or a local H2 database. + All commands must be run on the machine hosting the server. Optional parameters for a given command are indicated by square brackets '[' and ']' and always start with either '-' or '--' characters. If an associated value is required and contains space characters it should be enclosed in double quotes. Options which require a value may be separated by a space or a equal "=" character (e.g., - --auth=password). + --auth=password).

+ +

All server configuration is performed through the init and configure commands, which manage the generated + postgresql.conf, pg_hba.conf, and pg_ident.conf + files on the operator's behalf. The start + command performs no configuration and simply starts a previously initialized server.

-
start
+
init
-

Initializes and starts a PostgreSQL server. The command-line must include a path - to the data directory for the server, which must exist. If a server had run - previously and populated this directory, this command simply restarts the server - using the preexisting data and configuration; otherwise, a new database is - initialized. The user performing the initial start is automatically added to the - database with admin privileges.

+

Initializes a new PostgreSQL data directory for BSim and prepares it for use. The + command-line must include a path to the data directory, which is created if it does + not already exist (an existing directory must be empty). The database is initialized, + the configuration and authentication files are tuned, the server certificate is + installed, and the BSim lshvector extension is + enabled; the server is then left stopped. Use + start to run it. The PostgreSQL + administrative role (the global --user + option, defaulting to the invoking user) is created with admin privileges.

-

During a restart, any authentication options (with the exception of the global - --cert option) are unnecessary and will - be ignored. The PostgreSQL server will be restarted with the already established - settings. To actually change the settings, use the changeauth command before restarting.

+

--os-user <account> - specifies the operating-system account that + will own the data directory and run the postgres + process. This option may only be used when running as root (via sudo) + and is required in that case; all data-directory + files are created owned by this account. When not running as root the option must be + omitted and the invoking user becomes the owner. This OS account is distinct from the + PostgreSQL administrative role established by the --user option.

+ +

--keystore|-k </keystore-path> - specifies a password-protected + PKCS#12 (or JKS) key store containing a CA-signed server certificate and private key + (for example, one produced by the certTool + utility). The key store password is prompted for on the console; the certificate chain + and private key are extracted to server.crt and server.key + within the data directory. When a key store is supplied the server listens on all + interfaces and accepts remote connections. If --keystore is omitted, a self-signed certificate + restricted to the loopback interface is generated, the server binds to localhost only, and remote client access is disabled (a + warning is issued for those options which only affect remote access and therefore + have no effect). The authentication type given by --auth still applies to local (loopback) + connections and is recorded, so that it also applies to remote clients should + remote access be enabled later with --keystore.

--auth|-a <type> - specifies the authentication type (pki | - password | trust) for a new database: trust for no authentication, password for password authentication, and pki for authentication using public key certificates. - With the pki setting, both the --cafile and the --dn options also need to be provided; additionally - the --cert option must be provided unless - the --noLocalAuth option is also - given.

+ "emphasis"><type> - specifies the authentication type ( + password | pki | trust): + password for password authentication (default mode), + pki for authentication using public key certificates, and + trust for no authentication. + Use of trust mode is highly discouraged. + With the pki setting, + both the --cafile and the global + --cert options must also be provided; the + admin user's Distinguished Name (DN) and Common Name (CN) are obtained from the + --cert certificate and no --dn option is accepted. The + authentication type is recorded within the data directory (in bsim-config.properties, which must not be edited) so that + later commands such as adduser and + status can determine how the server + was configured.

+ +

A server initialized with --auth pki identifies each user by their + full Distinguished Name, which is registered + within pg_ident.conf and matched against the certificate a client + presents. A Common Name alone is not a unique identity - it is chosen by whoever + issues a certificate, and two authorities within the same root.crt may issue certificates carrying the same Common + Name to different people. A server which was initialized by Ghidra 12.1.x or older + continues to match only the Common Name, because the user entries it already + holds were registered that way; see adduser.

--noLocalAuth - used together with the --auth option causes - authentication to not be required for local connections, i.e. localhost.

+ authentication to not be required for local (loopback) connections.

-

--cafile Only local authentication is + affected — the --auth mode is + still recorded, still governs remote connections should a key store later enable + them, and still determines what adduser establishes for each user (a password, or + a certificate identity mapping). Those credentials are therefore already in place + if configure later enables remote + access; without them every user would be unable to authenticate at that point. + Local authentication becomes subject to the configured mode once a --keystore is supplied.

+ +

--cafile|-ca </cafile-path> - specifies an absolute path to a certificate authority file and is required for --auth pki. This file should contain the - certificates the PostgreSQL server will use to authenticate in PEM format - concatenated together.

+ "command">--auth pki. This file contains the certificate + authorities used to verify client certificates and is installed within the data + directory as root.crt. PostgreSQL requires it + to be an unencrypted PEM file (Base64 encoded + certificates concatenated together) which itself provides a complete chain of trust, + since the server does not look beyond this file when verifying a certificate a client + presents. It is fully loaded and validated each time it is used:

-

--dn <distinguished-name> - specifies the Distinguished - Name for the admin user and is required for - --auth pki.

+
    +
  • +

    a certificate which is not a certificate authority, or an authority whose + chain of trust cannot be traced to a root certificate authority within the same + file, is reported as an error (all such certificates are identified);

    +
  • + +
  • +

    an expired certificate authority is reported as a warning, as is an authority + which is not permitted to sign certificates.

    +
  • +
+ +

The global --cert certificate is + verified against these same authorities, exactly as the server will verify it, so + that a certificate the server would reject is identified before the configuration + takes effect.

--port|-p <portnum> - specifies the port the PostgreSQL server will - listen on. For port numbers other than the default 5432, URLs and other - command-lines must explicitly specify the port, when connecting to the server. This - option only effects the initial start of a server. For subsequent (re)starts this - option is ignored, and the server will continue to listen on the same port - specified in the initial start. Use changeauth to change the port of a server after - its initial start.

+ "emphasis"><portnum> - specifies the port that the PostgreSQL server + listens to (default 5432). For non-default ports, URLs and other command-lines + connecting to the server must explicitly specify the port. Use configure to change the port after + initialization if needed. The default port 5432 will be used if a invalid + value is specified.

+
+ +
configure
+ +
+

Changes the configuration of a previously initialized server. The path to the data + directory must be specified and the server must be running. Accepts the same options as init except --os-user: --auth, --noLocalAuth, --cafile, --keystore, and the global --port. Supplying --keystore installs a new server certificate and + enables remote access; omitting it preserves the current binding.

+ +

Supplying --cafile replaces the + installed certificate authorities (root.crt) and, because every client certificate must then + be issued by one of the incoming authorities, requires the admin user's --cert so that it can be verified against them + first. Omitting the option retains the authorities already installed, which are + re-loaded and re-validated so that a chain which has become stale (an expired + authority) is reported.

+ +

The server is left running and the changes take effect when it is next + restarted. The server must be running + because any change to the authentication requires that the invoking user first + authenticate with the admin role (using the + authentication currently in effect, so the global --user and --cert options may be needed), and because any + credential the change requires must be put in place before the restart:

+ +
    +
  • +

    Changing to --auth password + when the admin user has no database password established will prompt for a new + password on the console and set it. Any other existing user may also lack a + password and can be given one with resetpassword after the restart.

    +
  • + +
  • +

    Changing to --auth pki + requires the admin user's certificate (the global --cert option) and establishes their + PostgreSQL identity mapping from it. As with init, no --dn option is accepted: the Distinguished Name + is taken from the certificate so that the mapping cannot disagree with the + certificate which will be presented, and it is verified against the --cafile certificate authority. Any other + existing user requires a mapping too, which can be added with adduser and its --dn option after the restart.

    +
  • +
+ +

Older BSim PostgreSQL databases with PKI authentication relied on user's + Common Name (CN), whereas newer databases rely on the user's Distinguished + Name (DN) which is more unique and generally contains a CN. This is a property + of the server and configure preserves + it: a server which already uses PKI keeps matching whichever it was configured for, + since the identity mappings registered for its users cannot be re-derived (each + user's certificate is held by that user). A server which is being changed + to PKI has no such mappings and so is + configured to match the full Distinguished Name (DN). + The one in effect is reflected by the commands: status, and listusers.

+ +

NOTE: a change to --auth trust removes authentication entirely + and so does not require the admin role to be authenticated. This is the means of + recovering from a forgotten password or an unavailable certificate: change to + trust, restart, re-establish the credential + (resetpassword), then change back.

+ +
+ +
start
+ +
+

Starts a previously initialized PostgreSQL server. The path to the data directory + must be provided and must already have been initialized with init; otherwise the command fails with guidance to run + init. This command takes no options and + performs no configuration. If a service has been installed for the data directory (see + install-service) the request is delegated + to systemctl and must be run as root.

stop
-

Stops a currently running PostgreSQL server. The path to the actively used data - directory must be provided. By default, shutdown will wait until existing - connections to the database have been closed.

+

Stops a running PostgreSQL server. The path to the data directory must be + provided. By default, shutdown waits until existing connections are closed. If a + service is installed the request is delegated to systemctl and must be run as root.

-

--force - causes existing - connections to be forcibly closed and the PostgreSQL server to shut down - immediately.

+

--force|-f - causes existing + connections to be forcibly closed and the server to shut down immediately.

+
+ +
restart
+ +
+

Restarts a previously initialized PostgreSQL server (equivalent to a stop followed + by a start), performing no configuration. The path to the data directory must be + provided. If a service is installed the request is delegated to systemctl and must be run as root.

+ +

--force|-f - uses fast shutdown mode for + the stop phase (existing connections are forcibly closed).

status
-

Retrieves the status of a PostgreSQL server (running/down). The path to the - actively used data directory must be provided.

+

Reports the status of a server: the data directory owner, the port, the + authentication type in effect (and, for --auth pki, whether users are identified by + their full Distinguished Name or by their Common Name alone), whether remote access + is enabled, whether a service is installed (and enabled), and the running state. The + path to the data directory must be provided.

+
+ +
install-service
+ +
+

Installs a systemd service that supervises the + server for the specified (initialized) data directory, enabling it to start + automatically at boot. Only supported on Linux and must be run as root (via sudo). + The service runs the postgres process as the data + directory owner. The server must not be running when the service is installed. Once + installed, the start, stop, restart, and status commands operate through the service + (start/stop/restart then require root).

+
+ +
uninstall-service
+ +
+

Stops, disables, and removes the systemd + service previously installed for the specified data directory (Linux, root only). The + data directory and its contents are left intact.

adduser
@@ -153,12 +402,30 @@ (read-only) privileges, unless a subsequent changeprivilege command is used.

-

--dn <distinguished-name> - specifies the Distinguished Name of the new user, - which is required if the database enabled --dn|-dn <distinguished-name> - specifies the Distinguished Name (DN) + of the new user, which is required if the database enabled --auth pki. This option can be used to provide a - Distinguished Name to a preexisting user, if the PostgreSQL server's authentication + Distinguished Name to a pre-existing user, if the PostgreSQL server's authentication strategy is changed.

+ +

IMPORTANT: The specified DN must match exactly how it is specified within + the user's certificate that they will use when connecting to the PostgreSQL server. It is + case-senstive and any spaces must be preserved. Give it in RFC 2253 form - the form in which the server renders the + certificate subject - which for a given certificate is reported by:

+ +

openssl x509 -noout -subject + -nameopt RFC2253 -in certfile

+ +

What is registered for the user depends upon how the server itself was configured + (see init). Normally the full + Distinguished Name is registered; for a server initialized by Ghidra 12.1.x or older + only the Common Name contained within the + supplied Distinguished Name is registered (and the Distinguished Name must + therefore contain one). Either way the name actually registered is reported, and can + be reviewed later with listusers.

changeauth
+ "bold">listusers
-

Change the configuration of a previously initialized PostgreSQL server. The path - to the server's data directory must be specified. The server must not currently be - running to use this command, which only takes effect after a restart. Options have - the same meaning as for the start - command.

+

List all users defined on the PostgreSQL server along with their role. The path + to the actively used data directory must be specified, and the server must be + running. Each user is reported as admin if it + has administrative (superuser) privileges, or user otherwise (see changeprivilege).

-

--port|-p <portnum> - changes the port the PostgreSQL server will - listen on. If this option is not present, the server will continue to listen on the - same port.

- -

--auth|-a <type> - changes the authentication type (pki | - password | trust) used by the PostgreSQL server. No change is made if the - option is not present. If the option is present, omitting the --noLocalAuth causes local connections to require - authentication. This command does not affect the presence or absence of passwords - or Distinguished Names for existing users.

- -

--cafile </cafile-path> - specifies an absolute path to a - certificate authority file and is required for --auth pki. This file should contain the - certificates the PostgreSQL server will use to authenticate in PEM format - concatenated together.

- -

--dn <distinguished-name> - specifies the Distinguished Name for the admin - user and is required for --auth pki.

+

If the server is configured for --auth pki, the certificate name registered + for each user within pg_ident.conf is listed as well - their full + Distinguished Name, or their Common Name for a server which matches only that (see + init) - with the column heading + indicating which. A user shown as <none registered> has no certificate mapping and + will be unable to authenticate; one can be established with adduser and its --dn option.

These options apply to all the bsim_ctl commands that connect to an active - PostgreSQL server: start, init, adduser, dropuser, listusers, resetpassword, and changeprivilege.

@@ -247,6 +503,9 @@ "emphasis"></certfile-path>
- provides the absolute file path to the user's certificate when connecting to a PostgreSQL server that requires PKI authentication.

+ +

--verbose|-v  - enable verbose logging + of all system commands executed during processing (e.g., pg_ctl execution).

@@ -265,11 +524,12 @@
-
+
     bsim createdatabase  <bsimURL> <config_template> [--name|-n "<name>"] [--owner|-o "<owner>"] [--description|-d "<text>"] [--nocallgraph]
+    bsim dropdatabase    <bsimURL> [--force|-f]
+    bsim listdatabases   <bsimURL>
     bsim setmetadata     <bsimURL> [--name|-n "<name>"] [--owner|-o "<owner>"] [--description|-d "<text>"]
     bsim getmetadata     <bsimURL>
-    bsim dropdatabase    <bsimURL> [--force]
     bsim addexecategory  <bsimURL> <category_name> [--date]
     bsim addfunctiontag  <bsimURL> <tag_name>
     bsim dropindex       <bsimURL>
@@ -288,7 +548,8 @@
     bsim delete          <bsimURL> [--md5|-m <hash>] [--name|-n <exe_name> [--arch|-a <languageID>] [--compiler <cspecID>]]
     bsim listfuncs       <bsimURL> [--md5|-m <hash>] [--name|-n <exe_name> [--arch|-a <languageID>] [--compiler <cspecID>]] [--printselfsig] [--callgraph] [--printjustexe] [--maxfunc <max_count>]
     bsim dumpsigs        <bsimURL> </xmldirectory> [--md5|-m <hash>] [--name|-n <exe_name> [--arch|-a <languageID>] [--compiler <cspecID>]]
-    
+    bsim changepassword  <bsimURL>
+
     Global options:
         --user|-u <username>
         --cert <certfile-path>
@@ -362,7 +623,7 @@
                 

Deletes the specified repository. A BSim server URL is required. The database name is taken from the path element of the URL. -

--force - skips the confirmation +

--force|-f - skips the confirmation dialog and deletes the database. Default is to ask for confirmation.

@@ -574,6 +835,29 @@ have a fabricated MD5 which is based on its name.

+
listdatabases
+ +
+

List all BSim databases hosted by a server. Every discovered database that + appears to be a BSim database (i.e., that contains the expected key BSim tables such + as vectable and archtable, or the equivalent Elasticsearch indexes) + is reported along with the details that were originally supplied when it was created + with createdatabase: its formal name, + owner, description, whether call-graph information is tracked, and any executable + categories, function tags, and date column that have been defined.

+ +

For the postgresql, + elastic, and + https URL forms the + /<dbname> element is optional (see + “BSim Database + URLs”). When it is omitted, all BSim databases hosted by the server are + listed; when a database name is supplied, only that single database is listed. A + file (H2) URL must always specify a + specific database.

+
+
getexecount
@@ -702,6 +986,32 @@ +
changepassword
+ +
+

Change the BSim database password of the connecting user. A BSim URL specifying + the repository must be provided. The user must first authenticate with the server + using their current credentials, since the password change is issued over + the resulting connection. The user whose password is changed is the one used to + establish that connection, which is the user named by the URL or by the --user option, and otherwise defaults to the + user name reported by the operating system.

+ +

Only the postgresql, elastic, and https URL forms are supported, and the server must + be configured for password authentication (see “Security and Authentication”). A file (H2) database has no user password and is not + supported.

+ +

The new password is prompted for on the console and must be entered twice. If the + two entries do not match the prompt is repeated. A password may not be specified on + the command line.

+
+
--Global Options--
@@ -847,7 +1157,16 @@

The use of the https and elastic is equivalent.

- + +

Note: The trailing + /<dbname> is required for all commands except + listdatabases, for which it is optional when + using the postgresql, elastic, or https protocols. Omitting the + database name with listdatabases lists every + BSim database hosted by the server, while supplying one lists only that database. A + file (H2) URL must always specify a specific <dbname>.

+

Tip: The inclusion of a <username> within a BSim URL supercedes the concurrent use of the --user diff --git a/Ghidra/Features/BSim/src/main/help/help/topics/BSim/DatabaseConfiguration.html b/Ghidra/Features/BSim/src/main/help/help/topics/BSim/DatabaseConfiguration.html index d4c36bdb38..38055031cc 100644 --- a/Ghidra/Features/BSim/src/main/help/help/topics/BSim/DatabaseConfiguration.html +++ b/Ghidra/Features/BSim/src/main/help/help/topics/BSim/DatabaseConfiguration.html @@ -107,13 +107,13 @@ in the module directory Ghidra/Features/BSim/support that builds both the PostgreSQL server and the BSim extension from source and prepares the installation for use with Ghidra. If not already included in the Ghidra installation, the source distribution - file, currently postgresql-15.13.tar.gz, can be obtained from the PostgreSQL + file, currently postgresql-15.18.tar.gz, can be obtained from the PostgreSQL website at

-
https://www.postgresql.org/ftp/source/v15.13 + https://www.postgresql.org/ftp/source/v15.18
@@ -122,12 +122,12 @@

The steps to build the PostgreSQL server with the BSim extension then are:

1) If not already present, place the PostgreSQL source distribution file - postgresql-15.13.tar.gz in the Ghidra installation at

+ postgresql-15.18.tar.gz in the Ghidra installation at

-
$(ROOT)/Ghidra/Features/BSim/support/postgresql-15.13.tar.gz + $(ROOT)/Ghidra/Features/BSim/support/postgresql-15.18.tar.gz
@@ -210,58 +210,112 @@
-

The basic start-up and shut-down is accomplished with the same command-line script, - which takes either the keyword start or - stop as the first parameter. The second - parameter must be an absolute path to the chosen data directory.

+

A BSim PostgreSQL server is prepared in two steps: a one-time init that creates and configures the data directory, + followed by start to run the server. All + operations take an absolute path to the chosen data directory. Configuration is performed + entirely through the bsim_ctl init and + configure commands; the PostgreSQL + configuration files are managed on your behalf.

+ +

To initialize a new server, run init once + against an empty (or not-yet-existing) data directory. This creates the data directory, + tunes the configuration, installs the server certificate, and enables the BSim extension, + leaving the server stopped:

- - - - - - - - - - - - - +
$(ROOT)/support/bsim_ctl start - /path/to/datadir
$(ROOT)/support/bsim_ctl start /path/to/datadir - --port 8000
$(ROOT)/support/bsim_ctl stop - /path/to/datadir
$(ROOT)/support/bsim_ctl stop /path/to/datadir - force$(ROOT)/support/bsim_ctl init /path/to/datadir
-

The data directory should already exist and should initially not contain any files. - The first time a server is started for a particular data directory, a large number of - configuration files and other sub-directories associated with the PostgreSQL server - will automatically be created. Upon subsequent restarts the existing configuration will - be reused.

+

The data directory is created owned by the operating-system account that will run the + server. When init is run as an ordinary user, + that user becomes the owner. When run as root (via + sudo), the --os-user option is required and names a dedicated, + non-root service account that will own the data directory and run the server. All + subsequent bsim_ctl commands for a data + directory must be run either by that owner or by root.

-

The start command can take an optional - --port parameter. This can be used to specify - a non-standard port for the PostgreSQL server to listen on. In this case, any - subsequent reference to the BSim server, in the Ghidra client, or with the bsim command described below, must specify the port. - When using the bsim command, a - non-default port must be explicitly specified with the BSim By default (no --keystore) the server is + secured with a self-signed certificate and binds to the loopback interface only, which is appropriate for local + single-user use. To accept remote client connections, supply a CA-signed server + certificate in a password-protected keystore with the --keystore option (see “Security and Authentication”).

+ +

WARNING! Deploying the PostgreSQL server with a self-signed + certificate (that is, without the --keystore option) requires every client to waive + server authentication for loopback connections, since no client trusts such a + certificate. That waiver is only appropriate where every account on the local machine is + trusted. See “Security and + Authentication” for the VM property involved and what it gives up.

+ +

Once initialized, the server is started, stopped, restarted, and queried with:

+ +
+ + + + + + + + + + + + + +
$(ROOT)/support/bsim_ctl start /path/to/datadir
$(ROOT)/support/bsim_ctl stop /path/to/datadir [--force|-f]
$(ROOT)/support/bsim_ctl restart /path/to/datadir [--force|-f]
$(ROOT)/support/bsim_ctl status /path/to/datadir
+
+ +

The start command takes no options and + performs no configuration; if the data directory has not been initialized it reports an + error directing you to run init. To use a + non-standard port, specify --port at + init (or configure) time; any subsequent reference to the server, + in the Ghidra client or with the bsim command, + must then specify that port with the postgresql:// URL (see “Ghidra and BSim URLs” for more - details).

+ "CommandLineReference.html#URLs">“Ghidra and BSim URLs”).

-

The stop command can take the keyword - force as an optional parameter. Without - this, the shutdown of the server will wait until all currently connected clients finish - their sessions. Adding this parameter will cause all clients to be disconnected - immediately, rolling back any transactions, and the server will shutdown - immediately.

+

The --force option to stop and restart disconnects clients immediately, rolling back any + in-progress transactions, rather than waiting for connections to finish.

+ +

Running as a systemd Service (Linux): the + server can be installed as a persistent systemd + service so that it starts automatically at boot, running the postgres process as the data directory owner. Installing and + removing the service must be done as root (via + sudo), and the server must be stopped when the + service is installed:

+ +
+ + + + + + + +
sudo $(ROOT)/support/bsim_ctl install-service /path/to/datadir
sudo $(ROOT)/support/bsim_ctl uninstall-service /path/to/datadir
+
+ +

Once a service is installed, the start, + stop, and restart commands are delegated to systemctl and must be run as root; status reports the service installation and running + state.

@@ -283,6 +337,98 @@ communications in transit are always encrypted regardless of the authentication settings.

+

The SSL/TLS server certificate is established at + init time and is independent of the client + authentication method described below. If a password-protected keystore is supplied with + the --keystore option, its CA-signed + certificate and private key are installed and the server listens on all interfaces, + allowing remote clients to authenticate the server. If no keystore is supplied, a + self-signed certificate is generated and the server binds to the loopback interface only; + remote access is disabled.

+ +

IMPORTANT: Connections to a newly established + server will fail unless the server certificate issuer is considered a trusted certificate + issuer (.e.g., Certificate Authority (CA)). A local server reached via a + localhost name or address is authenticated exactly + like any other server by default.

+ + +

When a self-signed certificate is used by a server, + client systems must be configured in one of two ways: 1) add the self-signed server certificate + to their set of trusted certificates, or 2) if server is limited to localhost use only local server + authentication may be waived by setting the following VM property (see + support/launch.properties):

+ +
+ + + + +
-Dghidra.disable.loopback.server.authentication=true
+
+ +

Waive it only where every account on the local machine is trusted. A loopback + connection is not by itself proof of the server's identity: a rogue local process which + binds the port ahead of the intended server would be accepted in its place, together + with any credential the client then sends it (the administrative database password among + them). A deployment which cannot accept that should supply a --keystore with a CA-signed certificate instead, which + requires no waiver. Remote deployments must always supply one.

+ +

Where the server presents a CA-signed certificate from a --keystore, a client authenticates it using the trust + established for that client: the operating system trust store and the Java default trust + store. A certificate issued by an authority those already contain requires no client + configuration at all. For an internal authority which they do not contain, the client has + three options:

+ +
+
+
+
add the authority to the OS trust store
+ +
+

Leaves the defaults intact, so all other authorities continue to be trusted. + On Unix this requires root access.

+
+ +
-Dghidra.unix.default.cacerts=<path>
+ +
+

Names an additional file or directory of CA certificates which is + added to the OS trust store as it is + built, so the defaults are augmented rather than replaced, and no root access is + needed. Applies to Unix only.

+
+ +
-Dghidra.cacerts=<path>
+ +
+

Names a CA certificates file which is used exclusively — the OS and Java default trust + stores are then ignored, for this and every other connection the client makes. + Every authority the client relies upon must therefore be present within that + file. Use this to deliberately restrict a deployment to its own authorities, not + merely to add one.

+
+
+
+
+ +

NOTE: the bsim and bsim_ctl utilities set ghidra.cacerts automatically if the property has not + been specified and a file named cacerts is present within a Ghidra + installation root directory. Placing such a file there therefore restricts these + utilities to the authorities it contains. Both properties are described in + support/launch.properties.

+

PostgreSQL uses the concept of roles to grant access privileges based on particular users. Generally, a user's role is determined by the username used to establish the connection. @@ -293,47 +439,49 @@

BSim supports three different authentication methods, when connecting as a client or during database ingest and maintenance. This method is established for a server by the - initial start command.

+ init command.

-
trust
+
password
-

bsim_ctl start /path/to/datadir - --auth trust

+

bsim_ctl init /path/to/datadir --auth password

-

This is currently the default. No authentication is performed and privilege - is granted based on the user name presented. Masquerading is possible.

-
- -
password
- -
-

bsim_ctl start /path/to/datadir - --auth password

- -

Users are authenticated via password. A default password 'changeme' is +

This is currently the default authentication mode if --auth is not specified. + Users are authenticated via password. A default password 'changeme' is established when the new user is created. Passwords can be changed by the user - from the BSim client or can be reset by an administrator via the bsim changepassword command (see “bsim”), or + can be reset by an administrator via the resetpassword command.

pki
-

bsim_ctl start /path/to/datadir --auth pki - --cafile "/path/to/rootcert"

+

bsim_ctl init /path/to/datadir --auth pki + --cafile "/path/to/rootcert" --cert "/path/to/admincert.p12"

Users are authenticated by PKI certificates. Upon initialization, the BSim server must be provided (via the --cafile option) a file containing the public keys - for the certificate authorities used to issue user's certificates. The file - consists of the authoritative certificates in PEM format concatenated - together.

+ for the certificate authorities used to issue user certificates. The file + consists of the authoritative certificates in unencrypted PEM format concatenated + together, and must provide a complete chain of trust for each of those + authorities (any intermediate certificate authority must be accompanied by the + certificates of its issuers, up to and including a root certificate authority), + since the server does not look beyond this file when verifying a certificate a + client presents.

+ +

The administrative user's own certificate must also be provided, via the + global --cert option. It supplies + the credential used to connect to the new server, and its Distinguished Name is + what gets associated with the administrative user name — so no + --dn option is given to + init.

BSim users must register their certificate with the Ghidra client using the Edit->Set PKI Certificate... menu @@ -351,41 +499,89 @@ "command">--dn option. See “Adding Users to the Database”.

+ +

A server initialized this way identifies each user by their + full Distinguished Name. A Common Name on + its own is not a unique identity — it is chosen by whoever issues a + certificate, and two authorities within the same --cafile may issue certificates carrying the + same Common Name to different people, which would let one of them connect as the + other. A server initialized by an Ghidra 12.1.x or older matches only the + Common Name and continues to do so; see “Adding Users to the + Database”.

+ +
trust
+
+

bsim_ctl init /path/to/datadir + --auth trust

+ +

No authentication is performed and privilege is granted based soley on the + user name presented. Masquerading is possible; so use of this authentication + method is highly discouraged and intended for testing only. When this mode + is configured, the PostgrSQL server will listen on the loopback/localhost + interface only.

+
-

The authentication method should be established once, the first time the start command is issued for the server on an - empty data directory. Subsequent restarts of the server will not change these settings. - If the settings really need to be changed, the changeauth command can be issued. It takes the same - options as the start command and can only - be run if the server is shutdown first.

+

The authentication method is established when the data directory is initialized with + the init command; starting the server does + not change it. If the settings really need to be changed, the configure command can be issued. It takes the same + options as the init command and must be + run while the server is running, so that the + administrator can be authenticated and any credential the new authentication method + requires can be established before the change takes effect. The server is left running + and the change takes effect when it is next restarted.

-
$(ROOT)/support/bsim_ctl changeauth + $(ROOT)/support/bsim_ctl configure /datadir/path --auth password
-

Using the changeauth command on a +

Using the configure command on a server with an established set of users will likely require other disruptive changes to create passwords or associate Distinguished Names with users, if they didn't exist - before.

+ before. The administrator's own credential is handled by configure itself: a change to password prompts for a new password if they have none, and a + change to pki requires their certificate (the + global --cert option) so their identity + mapping can be established from it. + Every other user must then be given a password with resetpassword, or a Distinguished Name with + adduser, once the server is restarted.

If it is determined that only the database administrators have OS level, local, access to the server's host machine, they can choose to use the noLocalAuth option as part of the start or changeauth commands. This disables authentication for + "command">init or configure commands. This disables authentication for users connecting to the server by the 'localhost' interface. This may facilitate the use of scripts for ingest etc., where working with passwords is cumbersome. Authentication is still enforced for any remote connection.

+ +

NOTE: A host whose local + accounts are not all trusted should be given a --keystore so that the configured authentication + applies to local connections too.

+ +

Only local authentication is affected. The --auth mode is still recorded and continues to + determine what adduser establishes for + each user — a password, or a certificate identity mapping — so those + credentials are already in place if configure is later used with a --keystore to enable remote access. Users should + therefore continue to be added in accordance with the configured authentication mode, + even though local connections are not currently authenticated.

@@ -397,8 +593,10 @@
-

The username used to start the server for the first time, causing the initialization - of the data directory, becomes the administrator for that server. No other +

The PostgreSQL administrative role established when the server is initialized (the + --user option of bsim_ctl init, defaulting to the invoking user) becomes + the administrator for that server. No other username/role is initially known to the server. New usernames/roles can be added to the server using the following command:

@@ -411,19 +609,55 @@ $(ROOT)/support/bsim_ctl adduser /path/to/datadir username --dn "C=US,ST=MD,CN=Firstname User" + "emphasis">username --dn "CN=Firstname User,ST=MD,C=US"

If password authentication has been set for the server, the new user's password will - initially be set to 'changeme'. If PKI authentication has been set for the server, The - Distinguished Name, as bound to the new user's certificated must be provided when + initially be set to 'changeme'. If PKI authentication has been set for the server, the + Distinguished Name, as bound to the new user's certificate, must be provided when issuing the adduser command, via the --dn option. The Distinguished Name must be presented as a string containing a comma separated sequence of attribute/value pairs - that uniquely identifies a certificate. Currently, the Common Name (CN=) is the only - attribute inspected by the PostgreSQL server, so other attributes can be omitted.

+ that uniquely identifies a certificate.

+ +

IMPORTANT: the server matches the + entire Distinguished Name, so no attribute may be + omitted, and it must be given exactly as the server renders the subject of the user's + certificate: in RFC 2253 form (most specific + attribute first, no spaces around the separating commas), matching case and spacing. No + normalization is performed on either side of the comparison, so specifying it correctly + is the administrator's responsibility. For a given certificate the required form is + reported by:

+ +
+ + + + +
openssl x509 -noout -subject + -nameopt RFC2253 -in certfile
+
+ +

The name actually registered is echoed by adduser, and the name registered for every user can be + reviewed at any time with the listusers + command — which together are the means of confirming a Distinguished Name was + entered correctly. A user whose registered name does not match the certificate they + present will be unable to connect; re-running adduser for that user replaces it.

+ +

NOTE: a server which was initialized by + Ghidra 12.1.x or older matches only the Common Name (CN) taken from the Distinguished + Name, rather than the whole of it. Such a server keeps doing so, because + the entries it already holds for its users were registered that way and cannot be + re-derived (each user's certificate is held by that user). adduser follows whichever the server is configured + for, so the --dn option is supplied the + same way in either case; listusers and + status report which is in use.

New users are by default only given user permissions, meaning that they can only place @@ -465,8 +699,42 @@ -

The most important configuration parameters in postgresql.conf are:

+

Within postgresql.conf, BSim appends two clearly + delimited blocks after the standard PostgreSQL defaults:

+ +
+
+
+
# BSIM-MANAGED-BEGIN + … # BSIM-MANAGED-END
+ +
+

The BSim-managed settings (SSL/TLS, interface binding, client authentication, + and logging). Do not edit this block - it + is regenerated every time configure + is run, so changes made here are lost. Use bsim_ctl configure to change these settings.

+
+ +
# BSIM-TUNABLE-BEGIN + … # BSIM-TUNABLE-END
+ +
+

The performance-tuning settings. This block is written once at init and is preserved by configure. To tune an existing server, edit the + values in this block and restart the server.

+
+
+
+
+ +

More generally, any setting outside the managed + block - the tunable block, or the standard PostgreSQL defaults above it - may be edited and + is preserved across a configure; only the + managed block is regenerated. The performance parameters provided in the tunable block + are:

@@ -482,27 +750,31 @@
max_wal_size, checkpoint_timeout
+ "bold">work_mem
-

These control how often the server forces database pages to be written back - out to the file-system. The defaults are set to minimize disk writes when - ingesting large numbers of records in one session. There should be little - reason to change these values.

+

The maximum memory used by an internal hash table or sort operation before it + spills to temporary disk files.

ssl_min_protocol_version
+ "bold">checkpoint_timeout
-

This controls the minimum SSL/TLS protocol version used when the server negotiates a connection. - The current default is 'TLSv1.2'

+

Controls how often the server forces database pages to be written back out to + the file-system. The default is set to minimize disk writes when ingesting large + numbers of records in one session; there is usually little reason to change it.

+

The minimum TLS version and cipher suites live in the managed block (currently + ssl_min_protocol_version = 'TLSv1.2') and, + like the other managed settings, are established by init/configure rather than edited directly.

+

The pg_hba.conf file is used to configure which connections the server accepts for a particular outward facing IP address and what security mechanisms are enforced for those connections. Currently all addresses are @@ -514,7 +786,12 @@

Warning

-

By default, the server accepts all connections from all users.

+

With the default --auth trust + setting the server accepts all connections from all users. Without a --keystore the server binds to the loopback interface + only, so this applies to local connections; supplying a keystore enables remote access, + for which a stronger --auth mode should be + used.

@@ -527,15 +804,45 @@ -

There is a serverconfig.xml which contains a few of - the default configuration values that are most crucial for the BSim Database. Beware: This file is currently parsed only once - for the entire lifetime of a particular data - directory: it is read only when the data directory is first initialized, i.e. the first - time the bsim_ctl start command is - invoked on the empty directory. This file is intended to provide reasonable defaults - that are different from the standard PostgreSQL defaults. To provide site specific - configuration, changes should be made to the normal PostgreSQL configuration files.

+

The installation-wide default configuration values are held in a serverconfig.xml template (in $(ROOT)/Ghidra/Features/BSim/data), analogous to the Ghidra + Server's server.conf. It provides reasonable defaults that + differ from the standard PostgreSQL defaults and is read by init (and by configure when it regenerates the managed block). + Entries marked tunable="true" (the + performance settings) are written to the editable tunable block of a new data directory's + postgresql.conf.

+ +

There are therefore two ways to adjust the performance settings:

+ +
+
+
+
Existing data + directory
+ +
+

Edit the values in the tunable block (between # BSIM-TUNABLE-BEGIN and # BSIM-TUNABLE-END) of that directory's postgresql.conf, then restart the server. These edits are + preserved across configure.

+
+ +
New data + directories
+ +
+

Edit the corresponding serverconfig.xml entry + before running init; the new value becomes the installation-wide + default for any data directory subsequently initialized.

+
+
+
+
@@ -557,9 +864,13 @@ default for PostgreSQL). This does not consider network firewall devices which may also impact connectivity.

-

-    sudo firewall-cmd --permanent --add-port=5432/tcp && sudo firewall-cmd --reload
-			
+
+ + + + +
sudo firewall-cmd --permanent --add-port=5432/tcp && sudo firewall-cmd --reload
+

NOTE: The above Linux firewall command assumes the firewalld package has been installed on the system.

@@ -601,8 +912,7 @@

In order to make use of Elasticsearch with BSim, the database administrator must install the lsh.zip plug-in as part of the Elasticsearch deployment. The plug-in is available in the Ghidra extension named BSimElasticPlugin - (<ghidra-install-dir>/Extensions/Ghidra/ghidra_11.2.1_U_20241105_BSimElasticPlugin.zip). + class="emphasis">BSimElasticPlugin. The extension may be unzipped to a temporary location or use Ghidra's Install Extensions capability which will unpack it into the users platform-specific config directory indicated in the detail view of the Install Extensions window. The extension is not @@ -684,6 +994,11 @@ curl -k -u elastic:XXXXXX -X POST "https://localhost:9200/_security/user/ghidrau built-in role viewer, as in the example above, can be used to grant users read-only access to a database. The built-in superuser role grants administrator privileges.

+ +

Once a user has been created they may change their own password from the BSim client, or + from the command line with the bsim + changepassword command (see “bsim”).

@@ -732,9 +1047,13 @@ curl -k -u elastic:XXXXXX -X POST "https://localhost:9200/_security/user/ghidrau default for elasticsearch). This does not consider network firewall devices which may also impact connectivity.

-

-    sudo firewall-cmd --permanent --add-port=9200/tcp && sudo firewall-cmd --reload
-			
+
+ + + + +
sudo firewall-cmd --permanent --add-port=9200/tcp && sudo firewall-cmd --reload
+

NOTE: The above Linux firewall command assumes the firewalld package has been installed on the system.

diff --git a/Ghidra/Features/BSim/src/main/java/ghidra/features/bsim/query/BSimControlLaunchable.java b/Ghidra/Features/BSim/src/main/java/ghidra/features/bsim/query/BSimControlLaunchable.java index ac1d53e3b9..a6cd65366a 100644 --- a/Ghidra/Features/BSim/src/main/java/ghidra/features/bsim/query/BSimControlLaunchable.java +++ b/Ghidra/Features/BSim/src/main/java/ghidra/features/bsim/query/BSimControlLaunchable.java @@ -16,18 +16,20 @@ package ghidra.features.bsim.query; import java.io.*; -import java.net.Authenticator; -import java.net.InetAddress; import java.nio.file.Files; +import java.nio.file.StandardCopyOption; import java.nio.file.attribute.PosixFilePermissions; import java.security.*; -import java.security.KeyStore.PasswordProtection; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; import java.sql.*; import java.util.*; +import java.util.Date; +import javax.naming.InvalidNameException; import javax.naming.ldap.LdapName; -import javax.naming.ldap.Rdn; -import javax.security.auth.DestroyFailedException; +import javax.net.ssl.X509ExtendedKeyManager; import org.apache.commons.lang3.StringUtils; import org.postgresql.core.Utils; @@ -39,9 +41,9 @@ import ghidra.GhidraLaunchable; import ghidra.features.bsim.query.ingest.BSimLaunchable; import ghidra.framework.*; import ghidra.framework.client.ClientUtil; -import ghidra.net.PKIUtils; +import ghidra.framework.client.HeadlessClientAuthenticator; +import ghidra.net.*; import ghidra.util.Msg; -import ghidra.util.exception.AssertException; import ghidra.util.exception.CancelledException; import ghidra.util.xml.SpecXmlUtils; import ghidra.xml.NonThreadedXmlPullParserImpl; @@ -58,23 +60,33 @@ public class BSimControlLaunchable implements GhidraLaunchable { public final static String COMMAND_CHANGE_PRIVILEGE = "changeprivilege"; public final static String COMMAND_ADDUSER = "adduser"; public final static String COMMAND_DROPUSER = "dropuser"; - public final static String COMMAND_CHANGEAUTH = "changeauth"; + public final static String COMMAND_LISTUSERS = "listusers"; + public final static String COMMAND_INIT = "init"; + public final static String COMMAND_CONFIGURE = "configure"; + public final static String COMMAND_RESTART = "restart"; + public final static String COMMAND_INSTALL_SERVICE = "install-service"; + public final static String COMMAND_UNINSTALL_SERVICE = "uninstall-service"; // Options that require a value argument public static final String CAFILE_OPTION = "--cafile"; public static final String AUTH_OPTION = "--auth"; public static final String DN_OPTION = "--dn"; + public static final String KEYSTORE_OPTION = "--keystore"; + public static final String OS_USER_OPTION = "--os-user"; + public static final String PORT_OPTION = "--port"; // Global options that require a value argument - public static final String PORT_OPTION = "--port"; public static final String USER_OPTION = "--user"; public static final String CERT_OPTION = "--cert"; + public static final String VERBOSE_OPTION = "--verbose"; // Define set of options that require a second value argument private static final Set VALUE_OPTIONS = - Set.of(PORT_OPTION, USER_OPTION, CERT_OPTION, CAFILE_OPTION, AUTH_OPTION, DN_OPTION); + Set.of(USER_OPTION, CERT_OPTION, CAFILE_OPTION, AUTH_OPTION, DN_OPTION, + KEYSTORE_OPTION, OS_USER_OPTION, PORT_OPTION); - private static final Set GLOBAL_OPTIONS = Set.of(PORT_OPTION, USER_OPTION, CERT_OPTION); + private static final Set GLOBAL_OPTIONS = + Set.of(USER_OPTION, CERT_OPTION, VERBOSE_OPTION); // Boolean options public static final String NO_LOCAL_AUTH_OPTION = "--noLocalAuth"; @@ -82,37 +94,53 @@ public class BSimControlLaunchable implements GhidraLaunchable { private static final Map SHORTCUT_OPTION_MAP = new HashMap<>(); static { + // NOTE: --cert intentionally has no shortcut (-c is used by --config within BSimLaunchable) SHORTCUT_OPTION_MAP.put("-a", AUTH_OPTION); + SHORTCUT_OPTION_MAP.put("-ca", CAFILE_OPTION); + SHORTCUT_OPTION_MAP.put("-dn", DN_OPTION); + SHORTCUT_OPTION_MAP.put("-f", FORCE_OPTION); + SHORTCUT_OPTION_MAP.put("-k", KEYSTORE_OPTION); SHORTCUT_OPTION_MAP.put("-p", PORT_OPTION); SHORTCUT_OPTION_MAP.put("-u", USER_OPTION); + SHORTCUT_OPTION_MAP.put("-v", VERBOSE_OPTION); } //@formatter:off // Populate ALLOWED_OPTION_MAP for each command - private static final Set START_OPTIONS = - Set.of(AUTH_OPTION, DN_OPTION, NO_LOCAL_AUTH_OPTION, CAFILE_OPTION); - private static final Set STOP_OPTIONS = - Set.of(FORCE_OPTION); + private static final Set INIT_OPTIONS = + Set.of(AUTH_OPTION, NO_LOCAL_AUTH_OPTION, CAFILE_OPTION, KEYSTORE_OPTION, + PORT_OPTION, OS_USER_OPTION); + private static final Set CONFIGURE_OPTIONS = + Set.of(AUTH_OPTION, NO_LOCAL_AUTH_OPTION, CAFILE_OPTION, KEYSTORE_OPTION, + PORT_OPTION); + private static final Set START_OPTIONS = Set.of(); + private static final Set STOP_OPTIONS = Set.of(FORCE_OPTION); + private static final Set RESTART_OPTIONS = Set.of(FORCE_OPTION); private static final Set STATUS_OPTIONS = Set.of(); - private static final Set RESET_PASSWORD_OPTIONS = Set.of(); - private static final Set CHANGE_PRIVILEGE_OPTIONS = Set.of(); - private static final Set ADDUSER_OPTIONS = - Set.of(DN_OPTION); + private static final Set INSTALL_SERVICE_OPTIONS = Set.of(); + private static final Set UNINSTALL_SERVICE_OPTIONS = Set.of(); + private static final Set RESET_PASSWORD_OPTIONS = Set.of(PORT_OPTION); + private static final Set CHANGE_PRIVILEGE_OPTIONS = Set.of(PORT_OPTION); + private static final Set ADDUSER_OPTIONS = Set.of(DN_OPTION); private static final Set DROPUSER_OPTIONS = Set.of(); - private static final Set CHANGEAUTH_OPTIONS = Set.of( - AUTH_OPTION, DN_OPTION, NO_LOCAL_AUTH_OPTION, CAFILE_OPTION); + private static final Set LISTUSERS_OPTIONS = Set.of(); //@formatter:on private static final Map> ALLOWED_OPTION_MAP = new HashMap<>(); static { + ALLOWED_OPTION_MAP.put(COMMAND_INIT, INIT_OPTIONS); + ALLOWED_OPTION_MAP.put(COMMAND_CONFIGURE, CONFIGURE_OPTIONS); ALLOWED_OPTION_MAP.put(COMMAND_START, START_OPTIONS); ALLOWED_OPTION_MAP.put(COMMAND_STOP, STOP_OPTIONS); + ALLOWED_OPTION_MAP.put(COMMAND_RESTART, RESTART_OPTIONS); ALLOWED_OPTION_MAP.put(COMMAND_STATUS, STATUS_OPTIONS); + ALLOWED_OPTION_MAP.put(COMMAND_INSTALL_SERVICE, INSTALL_SERVICE_OPTIONS); + ALLOWED_OPTION_MAP.put(COMMAND_UNINSTALL_SERVICE, UNINSTALL_SERVICE_OPTIONS); ALLOWED_OPTION_MAP.put(COMMAND_RESET_PASSWORD, RESET_PASSWORD_OPTIONS); ALLOWED_OPTION_MAP.put(COMMAND_CHANGE_PRIVILEGE, CHANGE_PRIVILEGE_OPTIONS); ALLOWED_OPTION_MAP.put(COMMAND_ADDUSER, ADDUSER_OPTIONS); ALLOWED_OPTION_MAP.put(COMMAND_DROPUSER, DROPUSER_OPTIONS); - ALLOWED_OPTION_MAP.put(COMMAND_CHANGEAUTH, CHANGEAUTH_OPTIONS); + ALLOWED_OPTION_MAP.put(COMMAND_LISTUSERS, LISTUSERS_OPTIONS); } private final static String POSTGRES = "postgresql"; @@ -121,27 +149,59 @@ public class BSimControlLaunchable implements GhidraLaunchable { private final static String POSTGRES_CONFIGFILE = "postgresql.conf"; private final static String POSTGRES_CONNECTFILE = "pg_hba.conf"; private final static String POSTGRES_IDENTFILE = "pg_ident.conf"; - private final static String POSTGRES_ROOTCA = "root.crt"; + private static final String POSTGRES_CERTFILE = "server.crt"; // always required + private static final String POSTGRES_KEYFILE = "server.key"; // always required + private final static String POSTGRES_ROOTCA = "root.crt"; // only needed for PKI authentication mode + private final static String SERVICE_MARKER = "bsim-service.properties"; + private final static String SYSTEMD_SYSTEM_DIR = "/etc/systemd/system"; + + // BSim deployment configuration record: holds only those settings which cannot be recovered + // from the PostgreSQL configuration files (see recoverConfigurationParameters) + private final static String BSIM_CONFIG_FILE = "bsim-config.properties"; + private final static int BSIM_CONFIG_VERSION = 1; +// TODO: add GHIDRA_VERSION to config file (version used to update config) + private final static String PROP_CONFIG_VERSION = "configVersion"; + private final static String PROP_AUTH_MODE = "authMode"; + + private final static int MAX_DN_ENTRY_LENGTH = 256; private final static String PASSWORD_METHOD = "scram-sha-256"; private final static String TRUST_METHOD = "trust"; private final static String CERTIFICATE_METHOD = "cert"; private final static String CERTIFICATE_OPTIONS = "map=mymap clientcert=verify-full"; -// private final static String CERTIFICATE_OPTIONS = "map=mymap clientcert=1"; // For PKI certificates prior to PostgreSQL 12 + + // The name which the PostgreSQL server matches against its identity map (pg_ident.conf) when a + // client presents a certificate. Appending clientname=DN matches the certificate's full + // distinguished name (PostgreSQL 12 and later), rendered in RFC 2253 form; its absence leaves + // the PostgreSQL default of matching only the common name, which is what legacy BSim + // deployments were established with (see recoverCertificateNameMode). + // + // NOTE: PostgreSQL permits this option on "hostssl" entries ONLY - it rejects the whole + // connection file otherwise, leaving the server unable to start. Every entry BSim generates is + // hostssl (the UNIX-socket "local" entry of serverconfig.xml is commented out, so "local" here + // means loopback TLS), which must remain the case for the entries written by tuneConfig. + private final static String CLIENTNAME_OPTION = "clientname"; + private final static String CLIENTNAME_DN_OPTION = CLIENTNAME_OPTION + "=DN"; private final static String POSTGRES_MAP_IDENTIFIER = "mymap"; + + // Client certificate key types considered when recovering the admin user's PKI identity + private final static String[] CLIENT_KEY_TYPES = { PKIUtils.RSA_TYPE, "EC" }; private final static String DEFAULT_PASSWORD = "changeme"; + private final static int AUTHENTICATION_NONE = 0; private final static int AUTHENTICATION_PASSWORD = 1; private final static int AUTHENTICATION_PKI = 2; private GhidraApplicationLayout layout; + private boolean verbose; // If enable, output command execution details to console private File dataDirectory; // Directory containing postgres datafiles private File postgresRoot; // Directory containing postgres software private File postgresControl; // "pg_ctl" utility within postgres software - private File certAuthorityFile; // Certificate authority file provided by the user + private File certAuthorityFile; // Certificate authority file provided by the user (--cafile) + private File activeCertAuthorityFile; // CA file loaded/validated (--cafile or installed root.crt) + private List certAuthorities; // Validated CAs from activeCertAuthorityFile private String certParameter; // Path to certificate provided by user - private String distinguishedName; // Certificate distinguished name provided by the user - private String commonName; // Common name extracted from distinguishedName + private File keystoreFile; // Server certificate key store provided by user (--keystore) private String connectingUserName; // User-name used to establish connection private String specifiedUserName; // -username- (add/drop) operation is being performed on private boolean adminPrivilegeRequested; // true is attempting to give user admin privileges @@ -152,9 +212,24 @@ public class BSimControlLaunchable implements GhidraLaunchable { private int localAuthentication; // Type of authentication required for local connections private int hostAuthentication; // Type of authentication for remote connections private boolean authConfigPresent; // True if the [auth=..] option or the [--noLocalAuth] is present + private boolean authOptionPresent; // True if the [--auth] option is present + private boolean remoteAccessConfigured; // True if recovered config binds a non-loopback interface + private boolean useDistinguishedName; // True if the PKI identity map registers the full DN, false for CN only private File passwordFile; // File containing newly established password private char[] adminPasswordData; // Password data being sent to postgres server for authentication + // Principal data when adding a user for PKI authentication mode + private String distinguishedName; // Certificate Distinguished Name (DN) of user + private String commonName; // Common Name (CN) of user extracted from DN (legacy only) + + // Deployment identity / privilege model (Linux) + private String osUserOption; // --os-user value (OS account; init only, root only) + private String ownerName; // OWNER: OS account owning data directory & postgres process + private String ownerGroup; // OWNER's primary group + private String invokingUserName; // OS account invoking bsim_ctl (id -un) + private Boolean runningAsRoot; // cached root check (id -u == 0) + private boolean dropPrivileges; // true when root must run postgres tools/files as OWNER + // Database connection that can be persisted so we don't need to recreate one // for every call. private Connection localConnection; @@ -166,11 +241,15 @@ public class BSimControlLaunchable implements GhidraLaunchable { } private void clearParams() { + verbose = false; dataDirectory = null; postgresRoot = null; postgresControl = null; certAuthorityFile = null; + activeCertAuthorityFile = null; + certAuthorities = null; certParameter = null; + keystoreFile = null; distinguishedName = null; commonName = null; connectingUserName = null; @@ -180,11 +259,20 @@ public class BSimControlLaunchable implements GhidraLaunchable { loadLibraryVar = null; loadLibraryValue = null; port = -1; - localAuthentication = AUTHENTICATION_NONE; - hostAuthentication = AUTHENTICATION_NONE; + localAuthentication = AUTHENTICATION_PASSWORD; + hostAuthentication = AUTHENTICATION_PASSWORD; authConfigPresent = false; + authOptionPresent = false; + remoteAccessConfigured = false; + useDistinguishedName = true; // new deployments register the full DN (see recoverCertificateNameMode) passwordFile = null; adminPasswordData = null; + osUserOption = null; + ownerName = null; + ownerGroup = null; + invokingUserName = null; + runningAsRoot = null; + dropPrivileges = false; } /** @@ -199,33 +287,41 @@ public class BSimControlLaunchable implements GhidraLaunchable { String command = params[slot++]; switch (command) { + + case COMMAND_INIT: + scanDataDirectory(params, slot++, false); + break; + + case COMMAND_CONFIGURE: case COMMAND_START: - scanDataDirectory(params, slot++); - break; case COMMAND_STOP: - scanDataDirectory(params, slot++); - break; + case COMMAND_RESTART: case COMMAND_STATUS: - scanDataDirectory(params, slot++); + case COMMAND_INSTALL_SERVICE: + case COMMAND_UNINSTALL_SERVICE: + case COMMAND_LISTUSERS: + scanDataDirectory(params, slot++, true); break; + case COMMAND_ADDUSER: - scanDataDirectory(params, slot++); + scanDataDirectory(params, slot++, true); scanUsername(params, slot++); break; + case COMMAND_DROPUSER: - scanDataDirectory(params, slot++); + scanDataDirectory(params, slot++, true); scanUsername(params, slot++); break; + case COMMAND_RESET_PASSWORD: scanUsername(params, slot++); break; - case COMMAND_CHANGEAUTH: - scanDataDirectory(params, slot++); - break; + case COMMAND_CHANGE_PRIVILEGE: scanUsername(params, slot++); scanPrivilege(params, slot++); break; + default: throw new IllegalArgumentException("Unknown command: " + command); } @@ -297,7 +393,7 @@ public class BSimControlLaunchable implements GhidraLaunchable { switch (option) { case PORT_OPTION: - port = parsePositiveIntegerOption(optionName, value); + port = parsePortValue(optionName, value, BSimServerInfo.DEFAULT_POSTGRES_PORT); break; case USER_OPTION: connectingUserName = value; @@ -305,32 +401,26 @@ public class BSimControlLaunchable implements GhidraLaunchable { case CERT_OPTION: certParameter = value; break; + case KEYSTORE_OPTION: + keystoreFile = new File(value); + break; + case OS_USER_OPTION: + osUserOption = value; + break; case CAFILE_OPTION: certAuthorityFile = new File(value); break; case AUTH_OPTION: authConfigPresent = true; - String type = value; - if (type.equals("pki")) { - hostAuthentication = AUTHENTICATION_PKI; - localAuthentication = AUTHENTICATION_PKI; - } - else if (type.equals("password")) { - hostAuthentication = AUTHENTICATION_PASSWORD; - localAuthentication = AUTHENTICATION_PASSWORD; - } - else if (type.equals("trust") || type.equals("none")) { - hostAuthentication = AUTHENTICATION_NONE; - localAuthentication = AUTHENTICATION_NONE; - } - else { - throw new IllegalArgumentException("Unknown authentication method: " + - type + " : options are trust, password or pki"); - } + authOptionPresent = true; + // --auth establishes the remote (host) authentication mode; local connections + // use the same mode unless downgraded by --noLocalAuth below + hostAuthentication = parseAuthMode(value); + localAuthentication = hostAuthentication; break; case DN_OPTION: distinguishedName = value; - validateDistinguishedName(); + validateDistinguishedName(); // also assigns commonName if present break; case NO_LOCAL_AUTH_OPTION: sawNoLocalAuth = true; @@ -338,6 +428,9 @@ public class BSimControlLaunchable implements GhidraLaunchable { case FORCE_OPTION: forceShutdown = true; break; + case VERBOSE_OPTION: + verbose = true; + break; default: throw new AssertionError("Missing option handling: " + option); } @@ -347,6 +440,7 @@ public class BSimControlLaunchable implements GhidraLaunchable { authConfigPresent = true; localAuthentication = AUTHENTICATION_NONE; } + if (connectingUserName == null) { connectingUserName = ClientUtil.getUserName(); } @@ -363,11 +457,12 @@ public class BSimControlLaunchable implements GhidraLaunchable { } } - private int parsePositiveIntegerOption(String option, String optionValue) { + private int parsePortValue(String option, String optionValue, int defaultValue) { try { int value = Integer.valueOf(optionValue); - if (value < 0) { - throw new IllegalArgumentException("Negative value not permitted for " + option); + if (value <= 0 || value > 65535) { + System.out.println("Invalid " + option + " value ignored - using default: " + defaultValue); + value = defaultValue; } return value; } @@ -377,27 +472,122 @@ public class BSimControlLaunchable implements GhidraLaunchable { } /** - * Verify that the given file is a PEM certificate - * @param testFile the file to test - * @return true if testFile looks like a PEM certificate - * @throws IOException if there is a problem reading the given file + * Translate an authentication mode name, as specified with the {@code --auth} option or as + * recorded within the BSim configuration record, to its corresponding authentication constant. + * @param mode the authentication mode name (pki, password, trust or none) + * @return AUTHENTICATION_PKI, AUTHENTICATION_PASSWORD or AUTHENTICATION_NONE + * @throws IllegalArgumentException if the mode name is not recognized */ - private static boolean verifyPEMFormat(File testFile) throws IOException { - BufferedReader reader = new BufferedReader(new FileReader(testFile)); - try { - // All we currently do is search for the certificate header in the first 200 lines - for (int i = 0; i < 200; ++i) { - String line = reader.readLine(); - if (line == null) { - break; - } - if (line.startsWith(PKIUtils.BEGIN_CERT)) { - return true; - } - } + private static int parseAuthMode(String mode) throws IllegalArgumentException { + switch (mode.trim()) { + case "pki": + return AUTHENTICATION_PKI; + case "password": + return AUTHENTICATION_PASSWORD; + case "trust": + case "none": + return AUTHENTICATION_NONE; + default: + throw new IllegalArgumentException("Unknown authentication method: " + mode + + " : options are trust, password or pki"); } - finally { - reader.close(); + } + + /** + * Translate an authentication constant to its {@code --auth} option mode name. + * @param authentication AUTHENTICATION_PKI, AUTHENTICATION_PASSWORD or AUTHENTICATION_NONE + * @return the corresponding authentication mode name + */ + private static String authModeName(int authentication) { + switch (authentication) { + case AUTHENTICATION_PKI: + return "pki"; + case AUTHENTICATION_PASSWORD: + return "password"; + case AUTHENTICATION_NONE: + return "trust"; + default: + throw new AssertionError("Unsupported authentication type: " + authentication); + } + } + + /** + * @return the {@value #CERTIFICATE_METHOD} method options for {@value #POSTGRES_CONNECTFILE}, + * which request matching of the full distinguished name unless the deployment registers only + * the common name (see {@link #recoverCertificateNameMode(ServerConfig)}) + */ + private String certificateMethodOptions() { + return useDistinguishedName ? CERTIFICATE_OPTIONS + ' ' + CLIENTNAME_DN_OPTION + : CERTIFICATE_OPTIONS; + } + + /** + * @return the certificate name which the server will match against its identity map for the + * user whose PKI identity is currently established (see {@link #establishPkiIdentity()} and + * the {@code --dn} option): their full distinguished name, or only its common name for a + * deployment which registers that instead + */ + private String certificateSystemName() { + return useDistinguishedName ? distinguishedName : commonName; + } + + /** + * @return a description of the certificate name registered within the deployment's identity + * map, for reporting purposes + */ + private String certificateNameLabel() { + return useDistinguishedName ? "Distinguished Name (DN)" : "Common Name (CN)"; + } + + /** + * Determine whether the deployment's identity map ({@value #POSTGRES_IDENTFILE}) registers each + * user's full distinguished name or only their common name, which is decided by the + * {@value #CLIENTNAME_OPTION} option of the {@value #CERTIFICATE_METHOD} entry within + * {@value #POSTGRES_CONNECTFILE}. + *

+ * That entry is authoritative because it is what the server itself applies when matching a + * presented certificate. Legacy deployments were established with the PostgreSQL default of + * matching only the common name and must retain it: their users are already registered that + * way, and the mappings cannot be re-derived here since the certificates they were taken from + * are held by those users rather than by the administrator. + * @param serverConfig the recovered connection file configuration + * @return true if the full distinguished name is registered, false for the common name only + */ + private boolean recoverCertificateNameMode(ServerConfig serverConfig) { + if (CERTIFICATE_METHOD.equals(serverConfig.getLocalAuthentication())) { + return isClientNameDN(serverConfig.getLocalAuthenticationOptions()); + } + if (CERTIFICATE_METHOD.equals(serverConfig.getHostAuthentication())) { + return isClientNameDN(serverConfig.getHostAuthenticationOptions()); + } + // There is no certificate entry to recover from: either pki is not configured at all, or it + // is configured only for remote connections which are currently disabled (no entry is + // retained for those). In neither case is the server presently matching any user's + // certificate, so the new-deployment default applies. + return true; + } + + /** + * Determine whether a set of {@value #CERTIFICATE_METHOD} method options, as they appear within + * {@value #POSTGRES_CONNECTFILE}, select matching of the full distinguished name. + *

+ * The value is accepted without regard to case, whereas PostgreSQL requires it upper case. A + * lower case value is therefore taken as the distinguished name having been intended, so that + * the intent is preserved (and the option rewritten as PostgreSQL requires it) rather than + * quietly reverting a hand-edited entry to common name matching. + * @param options the authentication method options, which may be null + * @return true if {@value #CLIENTNAME_DN_OPTION} is specified; false for the PostgreSQL default + * of matching only the certificate common name + */ + private static boolean isClientNameDN(String options) { + if (options == null) { + return false; + } + for (String option : options.trim().split("\\s+")) { + int ix = option.indexOf('='); + if (ix > 0 && option.substring(0, ix).equalsIgnoreCase(CLIENTNAME_OPTION)) { + return option.substring(ix + 1).replace("\"", "").equalsIgnoreCase("DN"); + } } return false; } @@ -405,28 +595,32 @@ public class BSimControlLaunchable implements GhidraLaunchable { /** * Parse the -distinguishedName- String, verifying it is has the correct format for a * X509 certificate distinguished name. Try to extract the common name portion of the - * distinguished name and assign it to -commonName- - * @throws IllegalArgumentException if the distinguished name is improperly formatted or the common name is missing + * distinguished name and assign it to {@code commonName}, which is left null if the distinguished + * name does not specify one. + *

+ * The common name is only required by a deployment whose identity map registers it in place of + * the full distinguished name (see {@link #recoverCertificateNameMode(ServerConfig)}), which is + * not known until the server's configuration has been recovered; it is enforced at the point of + * use by {@link #addUserCommand()} rather than here. + * @throws IllegalArgumentException if the distinguished name is improperly formatted */ private void validateDistinguishedName() throws IllegalArgumentException { - if (distinguishedName == null) { - return; + if (distinguishedName == null || distinguishedName.trim().isEmpty()) { + throw new IllegalArgumentException("--dn input cannot be null or empty."); + } + if (distinguishedName.length() > MAX_DN_ENTRY_LENGTH) { // enforce reasonable length limits + throw new IllegalArgumentException("--dn input exceeds maximum allowed length: " + + distinguishedName.length() + " > " + MAX_DN_ENTRY_LENGTH); } commonName = null; + LdapName ldapName; try { - LdapName ldapName = new LdapName(distinguishedName); - for (Rdn rdn : ldapName.getRdns()) { - if (rdn.getType().equalsIgnoreCase("CN")) { - commonName = rdn.getValue().toString(); - break; - } - } - if (commonName == null) { - throw new IllegalArgumentException("Missing common name attribute"); - } + ldapName = new LdapName(distinguishedName); + distinguishedName = ldapName.toString(); // ensure DN is properly escaped + commonName = PKIUtils.getCommonName(distinguishedName); } - catch (Exception e) { - throw new IllegalArgumentException("Improperly formatted distinguished name"); + catch (InvalidNameException e) { + throw new IllegalArgumentException("Improperly formatted --dn distinguished name"); } } @@ -443,18 +637,19 @@ public class BSimControlLaunchable implements GhidraLaunchable { command.add("-p"); command.add(Integer.toString(port)); } - int ret = runCommand(null, command, loadLibraryVar, loadLibraryValue); + int ret = runPostgresCommand(command); return (ret == 0); } private char[] requestPassword(String prompt) { - String host = "localhost"; - InetAddress addr = InetAddress.getLoopbackAddress(); - String protocol = "postgresql"; - String scheme = "NO_NAME"; - return Authenticator - .requestPasswordAuthentication(host, addr, port, protocol, prompt, scheme) - .getPassword(); + try { + return HeadlessClientAuthenticator.getPassword(null, prompt); + } + catch (IOException e) { + System.err.println("Password entry error: " + e.getMessage()); + System.exit(-1); + return null; + } } /** @@ -466,11 +661,11 @@ public class BSimControlLaunchable implements GhidraLaunchable { */ private void establishAdminPassword() throws IOException { for (;;) { - adminPasswordData = requestPassword("Set admin(" + connectingUserName + ") password:"); + adminPasswordData = requestPassword("Set " + connectingUserName + " (admin) DB password:"); if (adminPasswordData == null) { - throw new IOException("Unable to obtain password"); + throw new IOException("Failed to obtain password"); } - char[] repeatPass = requestPassword("Please re-enter password:"); + char[] repeatPass = requestPassword("Please re-enter DB password:"); boolean match = comparePasswordData(adminPasswordData, repeatPass); clearPasswordData(repeatPass); if (match) { @@ -553,53 +748,125 @@ public class BSimControlLaunchable implements GhidraLaunchable { throws IOException, GeneralSecurityException { String alias = "bsimroot"; - char[] password = "unusedpassword".toCharArray(); - - PasswordProtection pp = new PasswordProtection(password); + char[] pwd = DefaultKeyManagerFactory.DEFAULT_PASSWORD.toCharArray(); try { - // TODO: should subjectAlternativeNames be supported? - KeyStore keyStore = PKIUtils.createKeyStore(alias, "CN=BSimServer", 365 * 2, null, - null, "JKS", null, password); + // Self-signed fallback: restrict SANs to loopback names only (server binds loopback). + KeyStore keyStore = PKIUtils.createKeyStore(alias, "CN=BSimServer", 365, null, + false, null, "JKS", List.of("127.0.0.1", "localhost"), pwd); PKIUtils.exportX509Certificates(keyStore.getCertificateChain(alias), certFile); - Key key = keyStore.getKey(alias, password); - - try (FileOutputStream fout = new FileOutputStream(passFile); - PrintWriter writer = new PrintWriter(fout)) { - writer.print("-----BEGIN PRIVATE KEY-----"); - writer.println(); - String base64 = Base64.getEncoder().encodeToString(key.getEncoded()); - while (base64.length() != 0) { - int endIndex = Math.min(44, base64.length()); - String line = base64.substring(0, endIndex); - writer.println(line); - base64 = base64.substring(endIndex); - } - writer.println("-----END PRIVATE KEY-----"); - writer.println(); - } - - passFile.setExecutable(false, false); // Clear execute permission for everybody - passFile.setReadable(false, false); // Clear read permission for everybody - passFile.setWritable(false, false); // Clear write permission for everybody - passFile.setReadable(true, true); // Let owner read the file + Key key = keyStore.getKey(alias, pwd); + writePrivateKeyPem(key, passFile); } catch (NoSuchAlgorithmException | UnrecoverableEntryException e) { throw new KeyStoreException("Failed to generate BSim server certificate", e); } finally { - Arrays.fill(password, ' '); - try { - pp.destroy(); - } - catch (DestroyFailedException e) { - throw new AssertException(e); // unexpected for simple password clearing - } + Arrays.fill(pwd, (char) 0); } } /** - * Create a local connection to a postgres server. A full SSL connection is created using + * Import a server certificate and private key from a user-provided, password-protected + * PKCS#12 (or JKS) key store, writing PEM {@link #POSTGRES_CERTFILE} (certificate chain) and + * {@link #POSTGRES_KEYFILE} (private key) for PostgreSQL. The key store password is prompted for on + * the console and never accepted on the command line. + * @param keyStoreSource the user-provided key store file + * @param certFile PEM output for the certificate chain (server.crt) + * @param passFile PEM output for the private key (server.key, owner-read-only) + * @throws IOException if the key store cannot be read (e.g., wrong password) or a file write fails + * @throws GeneralSecurityException if the key store lacks a usable server key/certificate + */ + private void importServerCertificate(File keyStoreSource, File certFile, File passFile) + throws IOException, GeneralSecurityException { + if (!keyStoreSource.isFile()) { + throw new IOException("Key store file not found: " + keyStoreSource.getAbsolutePath()); + } + char[] password = + requestPassword("Enter key store password (" + keyStoreSource.getName() + "):"); + if (password == null) { + throw new IOException("Key store password required"); + } + try { + KeyStore keyStore = + PKIUtils.getKeyStoreInstance(keyStoreSource.getAbsolutePath(), password); + String alias = null; + Enumeration aliases = keyStore.aliases(); + while (aliases.hasMoreElements()) { + String a = aliases.nextElement(); + if (keyStore.isKeyEntry(a)) { + if (alias != null) { + throw new GeneralSecurityException( + "Key store contains multiple private key entries; expected exactly one"); + } + alias = a; + } + } + if (alias == null) { + throw new GeneralSecurityException( + "No private key entry found in key store: " + keyStoreSource.getName()); + } + Certificate[] chain = keyStore.getCertificateChain(alias); + if (chain == null || chain.length == 0) { + throw new GeneralSecurityException( + "No certificate chain found for key store entry: " + alias); + } + if (chain[0] instanceof X509Certificate leaf) { + leaf.checkValidity(); // throws if expired or not yet valid + List eku = leaf.getExtendedKeyUsage(); + if (eku != null && !eku.contains("1.3.6.1.5.5.7.3.1")) { // id-kp-serverAuth + System.out.println( + "Warning: server certificate is missing 'serverAuth' extended key usage"); + } + System.out.println( + "Imported server certificate: " + leaf.getSubjectX500Principal().getName()); + } + Key key = keyStore.getKey(alias, password); + if (key == null) { + throw new GeneralSecurityException( + "Unable to recover private key from key store entry: " + alias); + } + PKIUtils.exportX509Certificates(chain, certFile); + writePrivateKeyPem(key, passFile); + } + finally { + Arrays.fill(password, (char) 0); + } + } + + /** + * Write a private key to a file in unencrypted PKCS#8 PEM form and restrict its permissions to + * owner-read-only (required by PostgreSQL for {@code ssl_key_file}). + * @param key the private key + * @param passFile the output file (server.key) + * @throws IOException if the file cannot be written + */ + private static void writePrivateKeyPem(Key key, File passFile) throws IOException { + // The key is written unencrypted (PostgreSQL requires this of ssl_key_file), so the file + // must be owner-only from the moment it exists - restricting it after the key had been + // written would leave it readable by other local users in between + try (OutputStream fout = FileUtilities.newOwnerPrivateFileOutputStream(passFile); + PrintWriter writer = new PrintWriter(fout)) { + writer.print("-----BEGIN PRIVATE KEY-----"); + writer.println(); + String base64 = Base64.getEncoder().encodeToString(key.getEncoded()); + while (base64.length() != 0) { + int endIndex = Math.min(44, base64.length()); + String line = base64.substring(0, endIndex); + writer.println(line); + base64 = base64.substring(endIndex); + } + writer.println("-----END PRIVATE KEY-----"); + writer.println(); + } + passFile.setExecutable(false, false); // Clear execute permission for everybody + passFile.setReadable(false, false); // Clear read permission for everybody + passFile.setWritable(false, false); // Clear write permission for everybody + passFile.setReadable(true, true); // Let owner read the file + } + + /** + * Create a local connection to a PostgreSQL server. A full SSL connection is created using * Ghidra's infrastructure. If the initial connection fails because password authentication * was requested, collect the administrative password from the user, and try the connection again * @return the established connection object. Respect any command-line "port= .." option. @@ -613,7 +880,7 @@ public class BSimControlLaunchable implements GhidraLaunchable { properties.setProperty("user", connectingUserName); StringBuilder buffer = new StringBuilder(); buffer.append("jdbc:postgresql://localhost"); - if ((port != -1) && (port != 5432)) { // Non-default port + if ((port != -1) && (port != BSimServerInfo.DEFAULT_POSTGRES_PORT)) { // Non-default port buffer.append(':'); buffer.append(port); } @@ -630,7 +897,7 @@ public class BSimControlLaunchable implements GhidraLaunchable { throw e; } } - adminPasswordData = requestPassword("User " + connectingUserName + " password:"); + adminPasswordData = requestPassword(connectingUserName + " (admin) DB password:"); if (adminPasswordData == null) { throw new IOException("Unable to obtain password"); } @@ -688,7 +955,9 @@ public class BSimControlLaunchable implements GhidraLaunchable { */ private int runCommand(File directory, List command, String envvar, String value) throws IOException, InterruptedException { - System.out.println("Command: " + command); + if (verbose) { + System.out.println("Command: " + command); + } ProcessBuilder processBuilder = new ProcessBuilder(command); processBuilder.directory(directory); // Set the working directory if (envvar != null) { @@ -697,10 +966,13 @@ public class BSimControlLaunchable implements GhidraLaunchable { } Process process = processBuilder.start(); - new IOThread(process.getInputStream(), true).start(); + IOThread inThread = new IOThread(process.getInputStream(), true); + inThread.start(); + IOThread errThread = new IOThread(process.getErrorStream(), false); errThread.start(); errThread.join(); // Ensure all stderr output is processed to avoid mixed-up console output + inThread.join(); int retval = process.waitFor(); return retval; @@ -714,18 +986,24 @@ public class BSimControlLaunchable implements GhidraLaunchable { * @param inHbaFile is the original pg_hba.conf file * @param outHbaFile will hold the new modified version of pg_hba.conf * @param serverConfigFile is the XML file holding ghidra specific BSim configuration options + * @param keystorePresent true if a server certificate key store is configured (enables remote + * access and all-interface binding); false for loopback-only self-signed operation + * @param initialize true at init (generate the full config including the user-tunable block); + * false at reconfigure (regenerate only the managed block, preserving the tunable block) * @throws SAXException if the xml pull parser cannot be created * @throws IOException if the authentication fails */ private void tuneConfig(File inputFile, File outputFile, File inHbaFile, File outHbaFile, - File serverConfigFile) throws SAXException, IOException { + File serverConfigFile, boolean keystorePresent, boolean initialize) + throws SAXException, IOException { ErrorHandler handler = SpecXmlUtils.getXmlHandler(); XmlPullParser parser = new NonThreadedXmlPullParserImpl(serverConfigFile, handler, false); ServerConfig serverConfig = new ServerConfig(); serverConfig.restoreXml(parser); - if ((port != -1) && (port != 5432)) { + if ((port != -1) && (port != BSimServerInfo.DEFAULT_POSTGRES_PORT)) { serverConfig.addKey("port", Integer.toString(port)); } + if (localAuthentication == AUTHENTICATION_NONE) { serverConfig.setLocalAuthentication(TRUST_METHOD, null); } @@ -733,27 +1011,59 @@ public class BSimControlLaunchable implements GhidraLaunchable { serverConfig.setLocalAuthentication(PASSWORD_METHOD, null); } else if (localAuthentication == AUTHENTICATION_PKI) { - serverConfig.setLocalAuthentication(CERTIFICATE_METHOD, CERTIFICATE_OPTIONS); + serverConfig.setLocalAuthentication(CERTIFICATE_METHOD, certificateMethodOptions()); } else { throw new IOException("Unsupported local authentication type"); } - if (hostAuthentication == AUTHENTICATION_NONE) { - serverConfig.setHostAuthentication(TRUST_METHOD, null); + + // Interface binding and remote access are derived from server-certificate (key store) + // presence and authentication mode. When either a key store has not been specified + // or using 'trust' hostAuthentication server will listen to loopback interfaces only + // and remote access is removed entirely. + boolean listenLocalOnly = true; + if (keystorePresent) { + if (hostAuthentication == AUTHENTICATION_NONE) { + serverConfig.setHostAuthentication(TRUST_METHOD, null); + } + else if (hostAuthentication == AUTHENTICATION_PASSWORD) { + serverConfig.setHostAuthentication(PASSWORD_METHOD, null); + } + else if (hostAuthentication == AUTHENTICATION_PKI) { + serverConfig.setHostAuthentication(CERTIFICATE_METHOD, certificateMethodOptions()); + } + else { + throw new IOException("Unsupported host authentication type"); + } + if (hostAuthentication == AUTHENTICATION_NONE) { + System.out.println("Warning: remote access is enabled without client " + + "authentication (trust); consider --auth password or --auth pki"); + } + else { + listenLocalOnly = false; + } } - else if (hostAuthentication == AUTHENTICATION_PASSWORD) { - serverConfig.setHostAuthentication(PASSWORD_METHOD, null); - } - else if (hostAuthentication == AUTHENTICATION_PKI) { - serverConfig.setHostAuthentication(CERTIFICATE_METHOD, CERTIFICATE_OPTIONS); + + if (listenLocalOnly) { + warnLoopbackOnly(); + serverConfig.removeHostAuthentication(); // loopback-only: no remote access permitted + serverConfig.addKey("listen_addresses", "'localhost'"); } else { - throw new IOException("Unsupported host authentication type"); + serverConfig.addKey("listen_addresses", "'*'"); } + if (hostAuthentication == AUTHENTICATION_PKI || localAuthentication == AUTHENTICATION_PKI) { - serverConfig.addKey("ssl_ca_file", '\'' + POSTGRES_ROOTCA + '\''); // Turn on certificate authority + serverConfig.addKey("ssl_ca_file", '\'' + POSTGRES_ROOTCA + '\''); // Turn on certificate authority + } + if (initialize) { + serverConfig.writePostgresConfig(inputFile, outputFile, true); + } + else { + // Reconfigure: regenerate only the managed block within the current postgresql.conf, + // preserving the user-editable performance-tuning block and any other edits. + serverConfig.writePostgresConfig(outputFile, outputFile, false); } - serverConfig.patchConfig(inputFile, outputFile); serverConfig.patchConnect(inHbaFile, outHbaFile); } @@ -800,17 +1110,27 @@ public class BSimControlLaunchable implements GhidraLaunchable { private void recoverConfigurationParameters(File configFile, File hbaFile) throws IOException { ServerConfig serverConfig = new ServerConfig(); serverConfig.addKey("port", ""); + serverConfig.addKey("listen_addresses", ""); serverConfig.scanConfig(configFile); String value = serverConfig.getValue("port"); - int scannedPort = 5432; + if (value.length() != 0) { - scannedPort = Integer.parseInt(value); + try { + port = Integer.parseInt(value); + } + catch (NumberFormatException e) { + // ignore + } + if (port <= 0 || port > 65535) { + throw new IOException( + "Server has an invalid port assignment. Change in " + POSTGRES_CONFIGFILE); + } } - if (port != -1 && (scannedPort != port)) { - throw new IOException("Server is configured to run on port " + - Integer.toString(scannedPort) + ": Change in " + POSTGRES_CONFIGFILE); + else { + port = BSimServerInfo.DEFAULT_POSTGRES_PORT; } - port = scannedPort; + + remoteAccessConfigured = isRemoteListen(serverConfig.getValue("listen_addresses")); serverConfig.scanConnect(hbaFile); String localMethod = serverConfig.getLocalAuthentication(); @@ -833,86 +1153,1042 @@ public class BSimControlLaunchable implements GhidraLaunchable { else if (hostMethod.equals(CERTIFICATE_METHOD)) { hostAuthentication = AUTHENTICATION_PKI; } + + // Whether each user is registered within the identity map by their full distinguished name + // or by their common name alone follows the deployment as it was configured, so that the + // user entries an existing deployment already holds remain the ones the server matches + useDistinguishedName = recoverCertificateNameMode(serverConfig); + + // The non-local (remote) connection entry is only present when remote access is enabled + // (i.e., a server certificate key store is configured). Without it the configured --auth + // mode cannot be recovered from the connection file and must come from the BSim + // configuration record. When the entry is present the connection file remains + // authoritative, with the record serving only as a consistency check. + if (hostMethod == null) { + int recordedAuth = readRecordedAuthMode(); + if (recordedAuth >= 0) { + hostAuthentication = recordedAuth; + } + } } /** - * Make sure certificate authority needed for pki was provided by user, otherwise throw exception - * @throws IOException if the cert file is invalid - * @throws GeneralSecurityException if the cert file is not a valid certificate + * Recover the {@code --auth} mode which was recorded within the data directory when the server + * was initialized or last configured. This record is the only source for the configured mode + * when remote access is disabled, since no remote connection entry is retained within + * {@value #POSTGRES_CONNECTFILE} in that case. + * @return the recorded authentication constant, or -1 if no mode has been recorded (which is + * the case for a deployment initialized before this record was introduced) + * @throws IOException if the record cannot be read, is malformed, or was written by a newer + * version of Ghidra + */ + private int readRecordedAuthMode() throws IOException { + File file = new File(dataDirectory, BSIM_CONFIG_FILE); + if (!file.isFile()) { + return -1; + } + Properties props = new Properties(); + try (FileInputStream in = new FileInputStream(file)) { + props.load(in); + } + String version = props.getProperty(PROP_CONFIG_VERSION, "1").trim(); + try { + if (Integer.parseInt(version) > BSIM_CONFIG_VERSION) { + throw new IOException(dataDirectory.getAbsolutePath() + + " was configured by a newer version of Ghidra (" + BSIM_CONFIG_FILE + + " version " + version + ")"); + } + } + catch (NumberFormatException e) { + throw new IOException("Invalid " + PROP_CONFIG_VERSION + " in " + + file.getAbsolutePath() + ": " + version); + } + String mode = props.getProperty(PROP_AUTH_MODE); + if (mode == null) { + return -1; + } + try { + return parseAuthMode(mode); + } + catch (IllegalArgumentException e) { + throw new IOException( + "Invalid " + PROP_AUTH_MODE + " in " + file.getAbsolutePath() + ": " + mode); + } + } + + /** + * Record the configured {@code --auth} mode within the data directory so it can be recovered by + * subsequent invocations. Only the mode itself is recorded; the effective local and host + * authentication are derived from it where needed, with the local authentication (and hence any + * {@code --noLocalAuth} downgrade) always recoverable from {@value #POSTGRES_CONNECTFILE}. + *

+ * This is a no-op if the mode is not actually known (see {@code authModeKnown}), so that an + * assumed mode is never recorded as though it had been configured. + * @throws IOException if the record cannot be written + * @throws InterruptedException if interrupted while adjusting ownership/permissions + */ + private void saveDeploymentConfig() throws IOException, InterruptedException { + Properties props = new Properties(); + props.setProperty(PROP_CONFIG_VERSION, Integer.toString(BSIM_CONFIG_VERSION)); + props.setProperty(PROP_AUTH_MODE, authModeName(hostAuthentication)); + File file = new File(dataDirectory, BSIM_CONFIG_FILE); + File tmpFile = new File(dataDirectory, BSIM_CONFIG_FILE + ".tmp"); + try (FileOutputStream out = new FileOutputStream(tmpFile)) { + props.store(out, "BSim PostgreSQL configuration - do not edit"); + } + Files.move(tmpFile.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING); + setPosixMode(file, "600"); + if (dropPrivileges) { + chownToOwner(file, false); + } + } + + /** + * Determine whether a {@code listen_addresses} value binds any non-loopback interface. + * @param listen the recovered listen_addresses value (may be quoted or null) + * @return true if the value indicates remote (non-loopback) binding + */ + private static boolean isRemoteListen(String listen) { + if (listen == null) { + return false; + } + String v = listen.trim().replace("'", ""); + if (v.isEmpty()) { + return false; // unset -> PostgreSQL default is loopback + } + return !NetworkUtils.isLoopbackAddress(v); + } + + private void warnLoopbackOnly() { + System.out.println( + "WARNING: server will listen to localhost connections only when keystore " + + "has not been configured or using 'trust' auhthentication."); + } + + /** + * When pki authentication is not in effect, warn that a specified certificate authority file is + * not used. The authority is only consulted to authenticate client certificates, so unless + * either connection type uses pki it would neither be installed nor have any effect. + */ + private void warnUnusedCertAuthorityOption() { + if (certAuthorityFile != null) { + System.out.println("Warning: without 'pki' authentication the following option is ignored: " + + CAFILE_OPTION); + } + } + + // ================================================================================== + // Deployment identity and privilege model (Linux) + // + // OWNER is the OS account that owns the data directory and runs the postgres process. For + // "init" it is established from --os-user (root) or the invoking user (non-root); for all other + // data-directory commands it is read from the data directory owner. When running as root + // against a data directory owned by a different account, postgres tools and file operations are + // performed as OWNER (via runuser) and any root-created files are re-owned to OWNER. + // ================================================================================== + + /** + * @return true if bsim_ctl is running with effective UID 0 (root) + */ + private boolean isRoot() throws IOException, InterruptedException { + if (runningAsRoot == null) { + runningAsRoot = "0".equals(execCapture(List.of("id", "-u"))); + } + return runningAsRoot; + } + + /** + * @return the name of the OS account invoking bsim_ctl + */ + private String getInvokingUserName() throws IOException, InterruptedException { + if (invokingUserName == null) { + invokingUserName = execCapture(List.of("id", "-un")); + } + return invokingUserName; + } + + /** + * Run a command and return its trimmed standard output, throwing if it exits non-zero. + * @param command the command and arguments + * @return the trimmed standard output + * @throws IOException if the command cannot be run or exits non-zero + * @throws InterruptedException if interrupted while waiting + */ + private static String execCapture(List command) + throws IOException, InterruptedException { + Process process = new ProcessBuilder(command).start(); + byte[] out = process.getInputStream().readAllBytes(); + process.getErrorStream().readAllBytes(); // drain stderr + int rc = process.waitFor(); + if (rc != 0) { + throw new IOException("Command failed (exit " + rc + "): " + String.join(" ", command)); + } + return new String(out, "UTF-8").trim(); + } + + /** + * @param user an account name + * @return true if the account exists on this system + * @throws InterruptedException if interrupted + */ + private static boolean accountExists(String user) throws InterruptedException { + try { + Process process = new ProcessBuilder("id", user).start(); + process.getInputStream().readAllBytes(); + process.getErrorStream().readAllBytes(); + return process.waitFor() == 0; + } + catch (IOException e) { + return false; + } + } + + /** + * @param user an account name + * @return the account's primary group name + * @throws IOException if the group cannot be determined + * @throws InterruptedException if interrupted + */ + private static String getPrimaryGroup(String user) throws IOException, InterruptedException { + return execCapture(List.of("id", "-gn", user)); + } + + /** + * @param file a file or directory + * @return the name of the file's owning account + * @throws IOException if the owner cannot be determined + */ + private static String getFileOwner(File file) throws IOException { + return Files.getOwner(file.toPath()).getName(); + } + + /** + * Establish the data-directory OWNER and enforce the invocation authorization rules (design + * section 4). Must be called before any postgres tool invocation or data-directory file + * operation. + * @param newDataDir true for "init" (OWNER from --os-user / invoker); false for commands + * operating on an existing data directory (OWNER = data directory owner) + * @param requireRoot true if the command may only be run as root + * @throws IOException if authorization fails or the owner cannot be established + * @throws InterruptedException if interrupted while resolving identities + */ + private void resolveDeployment(boolean newDataDir, boolean requireRoot) + throws IOException, InterruptedException { + boolean root = isRoot(); + String invoker = getInvokingUserName(); + if (requireRoot && !root) { + throw new IOException("This command must be run as root (use sudo)"); + } + if (newDataDir) { + if (root) { + if (osUserOption == null) { + throw new IOException("--os-user is required when running \"" + + COMMAND_INIT + "\" as root"); + } + if (!accountExists(osUserOption)) { + throw new IOException("Account does not exist: " + osUserOption); + } + ownerName = osUserOption; + } + else { + if (osUserOption != null) { + throw new IOException("--os-user may only be specified when running as root"); + } + ownerName = invoker; + } + } + else { + if (!dataDirectory.exists()) { + throw new IOException( + "Data directory does not exist: " + dataDirectory.getAbsolutePath()); + } + ownerName = getFileOwner(dataDirectory); + if (!root && !ownerName.equals(invoker)) { + throw new IOException("This command must be run by the data directory owner (" + + ownerName + ") or root"); + } + if (root && osUserOption != null && !osUserOption.equals(ownerName)) { + throw new IOException("--os-user (" + osUserOption + + ") does not match the data directory owner (" + ownerName + ")"); + } + } + if ("root".equals(ownerName)) { + throw new IOException("PostgreSQL may not run as root; specify a non-root --os-user"); + } + ownerGroup = getPrimaryGroup(ownerName); + dropPrivileges = root && !ownerName.equals(invoker); + } + + /** + * Run a PostgreSQL tool (initdb, pg_ctl, pg_isready, ...), dropping privileges to OWNER via + * {@code runuser} when running as root against a data directory owned by a different account. + * @param command the tool command and arguments + * @return the tool exit status + * @throws IOException if the tool cannot be run + * @throws InterruptedException if interrupted while waiting + */ + private int runPostgresCommand(List command) throws IOException, InterruptedException { + List toRun = command; + if (dropPrivileges) { + toRun = new ArrayList<>(); + toRun.add("runuser"); + toRun.add("-u"); + toRun.add(ownerName); + toRun.add("--"); + toRun.add("env"); + if (loadLibraryVar != null) { + toRun.add(loadLibraryVar + "=" + loadLibraryValue); + } + toRun.addAll(command); + } + return runCommand(null, toRun, loadLibraryVar, loadLibraryValue); + } + + /** + * Create a new, empty data directory owned by OWNER with 0700 permissions (for "init"). + * @throws IOException if the directory exists and is not empty, or cannot be created + * @throws InterruptedException if interrupted while adjusting ownership/permissions + */ + private void createDataDirectory() throws IOException, InterruptedException { + if (dataDirectory.exists()) { + String[] entries = dataDirectory.list(); + if (entries != null && entries.length != 0) { + throw new IOException( + "Data directory is not empty: " + dataDirectory.getAbsolutePath()); + } + } + else if (!dataDirectory.mkdirs()) { + throw new IOException( + "Failed to create data directory: " + dataDirectory.getAbsolutePath()); + } + setPosixMode(dataDirectory, "700"); + if (dropPrivileges) { + chownToOwner(dataDirectory, false); + } + } + + /** + * After a root-run mutation of the data directory, recursively assign ownership to OWNER and + * re-assert sensitive permissions. A no-op when not dropping privileges. + * @throws IOException if a chown/chmod fails + * @throws InterruptedException if interrupted + */ + private void normalizeOwnership() throws IOException, InterruptedException { + if (!dropPrivileges) { + return; + } + chownToOwner(dataDirectory, true); + setPosixMode(dataDirectory, "700"); + File serverKey = new File(dataDirectory, "server.key"); + if (serverKey.isFile()) { + setPosixMode(serverKey, "600"); + } + } + + private void setPosixMode(File file, String mode) throws IOException, InterruptedException { + List command = new ArrayList<>(List.of("chmod", mode, file.getAbsolutePath())); + if (runCommand(null, command, null, null) != 0) { + throw new IOException("Failed to set permissions on " + file.getAbsolutePath()); + } + } + + private void chownToOwner(File file, boolean recursive) + throws IOException, InterruptedException { + List command = new ArrayList<>(); + command.add("chown"); + if (recursive) { + command.add("-R"); + } + command.add(ownerName + ":" + ownerGroup); + command.add(file.getAbsolutePath()); + if (runCommand(null, command, null, null) != 0) { + throw new IOException("Failed to set ownership of " + file.getAbsolutePath()); + } + } + + // ================================================================================== + // systemd service management (Linux, root only) + // ================================================================================== + + /** + * @return true if running on Linux (where systemd service management is supported) + */ + private static boolean isLinux() { + return Platform.CURRENT_PLATFORM.getOperatingSystem() == OperatingSystem.LINUX; + } + + /** + * @return the deterministic systemd unit name for the current data directory + */ + private String getServiceUnitName() { + String base = dataDirectory.getName().replaceAll("[^A-Za-z0-9]", "-"); + String hash = Integer.toHexString(dataDirectory.getAbsolutePath().hashCode() & 0x7fffffff); + return "bsim-postgresql-" + base + "-" + hash + ".service"; + } + + /** + * @return true if a service marker is present for the current data directory + */ + private boolean isServiceInstalled() { + return new File(dataDirectory, SERVICE_MARKER).isFile(); + } + + /** + * @return the service marker properties, or null if no marker is present + * @throws IOException if the marker cannot be read + */ + private Properties readServiceMarker() throws IOException { + File marker = new File(dataDirectory, SERVICE_MARKER); + if (!marker.isFile()) { + return null; + } + Properties props = new Properties(); + try (FileInputStream in = new FileInputStream(marker)) { + props.load(in); + } + return props; + } + + /** + * Write the service marker recording the installed unit and OWNER. + * @param unitName the installed systemd unit name + * @throws IOException if the marker cannot be written + * @throws InterruptedException if interrupted while adjusting ownership + */ + private void writeServiceMarker(String unitName) throws IOException, InterruptedException { + Properties props = new Properties(); + props.setProperty("unitName", unitName); + props.setProperty("scope", "system"); + props.setProperty("user", ownerName); + props.setProperty("pgHome", postgresRoot.getAbsolutePath()); + File marker = new File(dataDirectory, SERVICE_MARKER); + try (FileOutputStream out = new FileOutputStream(marker)) { + props.store(out, "BSim PostgreSQL service marker - do not edit"); + } + setPosixMode(marker, "600"); + if (dropPrivileges) { + chownToOwner(marker, false); + } + } + + /** + * @return the generated systemd unit file contents for the current data directory + */ + private String generateLinuxSystemdServiceUnit() { + String datadir = dataDirectory.getAbsolutePath(); + String pgctl = postgresControl.getAbsolutePath(); + String pgLib = new File(postgresRoot, "lib").getAbsolutePath(); + String logfile = new File(dataDirectory, "logfile").getAbsolutePath(); + String pidfile = new File(dataDirectory, "postmaster.pid").getAbsolutePath(); + StringBuilder sb = new StringBuilder(); + sb.append("[Unit]\n"); + sb.append("Description=BSim PostgreSQL database (").append(datadir).append(")\n"); + sb.append("After=network-online.target\n"); + sb.append("Wants=network-online.target\n\n"); + sb.append("[Service]\n"); + sb.append("Type=forking\n"); + sb.append("User=").append(ownerName).append('\n'); + sb.append("Group=").append(ownerGroup).append('\n'); + // Environment= and Exec* command lines are whitespace-split by systemd, so any value that + // may contain spaces (e.g., a Ghidra installation path) must be double-quoted. PIDFile=, + // Description=, User=, and Group= take the literal remainder of the line and are not quoted. + sb.append("Environment=").append(sdQuote(loadLibraryVar + "=" + pgLib)).append('\n'); + sb.append("Environment=").append(sdQuote("PGDATA=" + datadir)).append('\n'); + sb.append("PIDFile=").append(pidfile).append('\n'); + sb.append("ExecStart=").append(sdQuote(pgctl)).append(" start -D ").append(sdQuote(datadir)) + .append(" -w -s -l ").append(sdQuote(logfile)).append('\n'); + sb.append("ExecStop=").append(sdQuote(pgctl)).append(" stop -D ").append(sdQuote(datadir)) + .append(" -m fast -s -w\n"); + sb.append("ExecReload=").append(sdQuote(pgctl)).append(" reload -D ").append( + sdQuote(datadir)).append(" -s\n"); + sb.append("Restart=on-failure\n"); + sb.append("RestartSec=5\n"); + sb.append("TimeoutStartSec=120\n"); + sb.append("TimeoutStopSec=120\n\n"); + sb.append("[Install]\n"); + sb.append("WantedBy=multi-user.target\n"); + return sb.toString(); + } + + /** + * Quote a systemd command-line argument or {@code Environment=} assignment so embedded + * whitespace (e.g., a Ghidra installation path containing spaces) is preserved. systemd + * performs C-style unescaping within double quotes, so backslashes and double quotes are + * escaped. + * @param value the raw value + * @return the double-quoted, escaped value + */ + private static String sdQuote(String value) { + return '"' + value.replace("\\", "\\\\").replace("\"", "\\\"") + '"'; + } + + /** + * Run a systemctl command. + * @param args systemctl subcommand and arguments + * @return the systemctl exit status + * @throws IOException if systemctl cannot be run + * @throws InterruptedException if interrupted + */ + private int runSystemctl(String... args) throws IOException, InterruptedException { + List command = new ArrayList<>(); + command.add("systemctl"); + for (String a : args) { + command.add(a); + } + return runCommand(null, command, null, null); + } + + /** + * Run a command and return its trimmed standard output, ignoring a non-zero exit status. + * @param command the command and arguments + * @return the trimmed standard output (empty on failure) + * @throws InterruptedException if interrupted + */ + private static String execCaptureAllowFail(List command) throws InterruptedException { + try { + Process process = new ProcessBuilder(command).start(); + byte[] out = process.getInputStream().readAllBytes(); + process.getErrorStream().readAllBytes(); + process.waitFor(); + return new String(out, "UTF-8").trim(); + } + catch (IOException e) { + return ""; + } + } + + /** + * @param subcommand a systemctl query subcommand (e.g., is-enabled, is-active) + * @param unit the unit name + * @return the reported state word (e.g., enabled, active), or empty + * @throws InterruptedException if interrupted + */ + private static String systemctlQuery(String subcommand, String unit) + throws InterruptedException { + return execCaptureAllowFail(List.of("systemctl", subcommand, unit)); + } + + /** + * @return true if a postmaster is running against the current data directory + * @throws IOException if pg_ctl cannot be run + * @throws InterruptedException if interrupted + */ + private boolean isPostmasterRunning() throws IOException, InterruptedException { + List command = new ArrayList<>(); + command.add(postgresControl.getAbsolutePath()); + command.add("status"); + command.add("-D"); + command.add(dataDirectory.getAbsolutePath()); + return runPostgresCommand(command) == 0; // 0 = running, 3 = stopped + } + + /** + * Install a systemd service for the current (initialized) data directory. Root only. + * @throws IOException if installation fails + * @throws InterruptedException if interrupted + */ + private void installServiceCommand() throws IOException, InterruptedException { + if (!isLinux()) { + throw new IOException("Service installation is only supported on Linux (systemd)"); + } + resolveDeployment(false, true); // require root; OWNER from data directory owner + discoverPostgresInstall(); + File configFile = new File(dataDirectory, POSTGRES_CONFIGFILE); + if (!configFile.exists()) { + throw new IOException("Data directory not initialized: run \"bsim_ctl " + COMMAND_INIT + + " " + dataDirectory.getAbsolutePath() + "\" first"); + } + if (isServiceInstalled()) { + throw new IOException("A service is already installed for this data directory; run \"" + + COMMAND_UNINSTALL_SERVICE + "\" first"); + } + if (isPostmasterRunning()) { + throw new IOException( + "Server is running; stop it before installing the service (bsim_ctl stop)"); + } + String unit = getServiceUnitName(); + File unitFile = new File(SYSTEMD_SYSTEM_DIR, unit); + if (unitFile.exists()) { + throw new IOException("Service unit already exists: " + unitFile.getAbsolutePath()); + } + try (FileWriter writer = new FileWriter(unitFile)) { + writer.write(generateLinuxSystemdServiceUnit()); + } + setPosixMode(unitFile, "644"); + if (runSystemctl("daemon-reload") != 0) { + throw new IOException("systemctl daemon-reload failed"); + } + if (runSystemctl("enable", unit) != 0) { + throw new IOException("systemctl enable failed for " + unit); + } + writeServiceMarker(unit); + System.out.println("Installed service: " + unit + " (User=" + ownerName + ")"); + System.out.println("Run \"bsim_ctl " + COMMAND_START + " " + dataDirectory.getAbsolutePath() + + "\" to start it."); + } + + /** + * Uninstall the systemd service for the current data directory. Root only. Leaves the data + * directory intact. + * @throws IOException if uninstallation fails + * @throws InterruptedException if interrupted + */ + private void uninstallServiceCommand() throws IOException, InterruptedException { + if (!isLinux()) { + throw new IOException("Service uninstall is only supported on Linux (systemd)"); + } + resolveDeployment(false, true); // require root + Properties marker = readServiceMarker(); + String unit; + if (marker != null) { + unit = marker.getProperty("unitName"); + } + else { + unit = getServiceUnitName(); + System.out.println("Warning: service marker not found; using derived unit name " + unit); + } + runSystemctl("stop", unit); // ignore failure (may not be running) + runSystemctl("disable", unit); // ignore failure (may not be enabled) + File unitFile = new File(SYSTEMD_SYSTEM_DIR, unit); + if (unitFile.exists() && !unitFile.delete()) { + throw new IOException("Failed to remove unit file: " + unitFile.getAbsolutePath()); + } + runSystemctl("daemon-reload"); + File markerFile = new File(dataDirectory, SERVICE_MARKER); + if (markerFile.exists() && !markerFile.delete()) { + throw new IOException("Failed to remove service marker: " + markerFile.getAbsolutePath()); + } + System.out.println("Uninstalled service: " + unit); + } + + /** + * Delegate a lifecycle action (start/stop/restart) to systemctl for an installed service. + * @param action the systemctl action + * @throws IOException if not root or the action fails + * @throws InterruptedException if interrupted + */ + private void systemctlLifecycle(String action) throws IOException, InterruptedException { + if (!isRoot()) { + throw new IOException("Server is installed as a service; \"" + action + + "\" must be run as root (use sudo)"); + } + Properties marker = readServiceMarker(); + if (marker == null) { + throw new IOException("Service marker missing for " + dataDirectory.getAbsolutePath()); + } + String unit = marker.getProperty("unitName"); + if (runSystemctl(action, unit) != 0) { + throw new IOException("systemctl " + action + " failed for " + unit); + } + System.out.println("Service " + action + " complete: " + unit); + } + + /** + * Establish the admin user's PKI identity for the authentication mode being configured: their + * distinguished name, and its common name, are obtained from the client certificate specified + * with the global {@code --cert} option and verified against the certificate authorities which + * the PostgreSQL server will use (see {@link #checkCertAuthorityFile()}). + *

+ * The identity is always derived from the certificate itself rather than accepted as text, so + * that the identity mapping written to {@value #POSTGRES_IDENTFILE} is guaranteed to match the + * certificate the admin user will actually present; a mistyped name would otherwise leave them + * unable to authenticate once the configuration takes effect. + *

+ * The certificate key store is opened, and its password prompted for, during application + * initialization (see {@link #initializeApplication()}) and the resulting key manager is cached + * by {@link DefaultKeyManagerFactory}, so neither this method nor the subsequent SSL connection + * to the server requires a further password entry. + * @throws IOException if the certificate authority file is missing or unreadable + * @throws GeneralSecurityException if no client certificate is available, it has expired, it + * does not specify a common name, or the server would not accept it + */ + private void establishPkiIdentity() throws IOException, GeneralSecurityException { + checkCertAuthorityFile(); + if (certParameter == null) { + throw new GeneralSecurityException("PKI authentication requires the certificate of " + + connectingUserName + " (admin) to be specified: " + CERT_OPTION + + " "); + } + X509ExtendedKeyManager keyManager = DefaultKeyManagerFactory.getKeyManager(); + X509Certificate[] chain = null; + if (keyManager != null) { + // Uses the key manager which was established, and cached, when the key store was + // opened during application initialization - no additional password entry occurs + String alias = keyManager.chooseClientAlias(CLIENT_KEY_TYPES, null, null); + if (alias != null) { + chain = keyManager.getCertificateChain(alias); + } + } + if (chain == null || chain.length == 0) { + throw new GeneralSecurityException("Unable to obtain a PKI certificate from " + + CERT_OPTION + " key store: " + certParameter); + } + X509Certificate cert = chain[0]; + cert.checkValidity(); // throws if expired or not yet valid + // RFC 2253 form, which is how the server renders the subject of a presented certificate + distinguishedName = cert.getSubjectX500Principal().getName(); + commonName = null; + if (!useDistinguishedName) { + // Legacy deployments used Common Name (CN) instead of Distinguished Name (DN) for users + try { + commonName = PKIUtils.getCommonName(distinguishedName); + } + catch (InvalidNameException e) { + throw new GeneralSecurityException( + "Failed to extract common name (CN) from certificate", e); + } + if (commonName == null) { + throw new GeneralSecurityException( + "Certificate DN does not contain common name (CN): " + distinguishedName); + } + } + verifyIssuedByCertAuthority(cert); + System.out.println( + "Using certificate for " + connectingUserName + " (admin): " + distinguishedName); + } + + /** + * Verify that the specified client certificate will be accepted by the PostgreSQL server, which + * authenticates it (under {@code clientcert=verify-full}) using only those certificate + * authorities contained within its {@value #POSTGRES_ROOTCA} ({@code ssl_ca_file}) - the set + * established and validated by {@link #checkCertAuthorityFile()}. + *

+ * Only that set is considered: a certificate authority which happens to be embedded within the + * {@code --cert} key store's own certificate chain is unknown to the server, so accepting it + * here would report a certificate as usable which the server will subsequently reject. + * @param cert the client certificate to be verified + * @throws GeneralSecurityException if the certificate would not be accepted by the server + */ + private void verifyIssuedByCertAuthority(X509Certificate cert) + throws GeneralSecurityException { + + String reject = "Certificate for " + connectingUserName + " (" + + cert.getSubjectX500Principal().getName() + ") will not be accepted by the server: "; + + // Trace the chain as the server will, using only the authorities it has been given. This + // is done ahead of the PKIX validation below to identify precisely where the chain breaks. + List chain; + try { + chain = traceChainOfTrust(cert, certAuthorities); + } + catch (CertificateException e) { + throw new CertificateException(reject + e.getMessage()); + } + if (chain.isEmpty() && !certAuthorities.contains(cert)) { + throw new CertificateException(reject + "certificate is self-signed and is not one of " + + "the authorities within " + activeCertAuthorityFile.getAbsolutePath()); + } + + // Full PKIX validation against the same authorities, which additionally applies those + // constraints the server's own verification imposes (client authentication key usage, + // authority path length, ...) + try { + PKIUtils.getTrustManager(activeCertAuthorityFile) + .checkClientTrusted(new X509Certificate[] { cert }, + cert.getPublicKey().getAlgorithm()); + } + catch (IOException e) { + throw new GeneralSecurityException("Failed to read certificate authority file: " + + activeCertAuthorityFile.getAbsolutePath(), e); + } + catch (CertificateException e) { + throw new CertificateException(reject + e.getMessage()); + } + + // An expired authority within the chain is reported here as well as by the file validation, + // since it is this certificate which the server will refuse once the authority lapses + Date now = new Date(); + for (X509Certificate caCert : chain) { + if (now.after(caCert.getNotAfter())) { + System.out.println("Warning: the certificate of " + connectingUserName + + " depends upon an EXPIRED certificate authority: " + certName(caCert)); + } + } + } + + /** + * Establish, and fully validate, the set of certificate authorities which the PostgreSQL server + * will use to authenticate client certificates. The authorities are read from the file given + * by {@code --cafile} or, when that option is omitted, from the {@value #POSTGRES_ROOTCA} + * previously installed within the data directory by {@code init} or {@code configure}. + *

+ * PostgreSQL requires this file to be an unencrypted concatenation of PEM encoded certificates + * which itself provides a complete chain of trust for every authority it contains, since the + * server does not look beyond the file when building the chain for a presented client + * certificate. It is validated accordingly, and re-validated each time it is used since a + * chain which was complete when installed can become stale: an expired (or not yet valid) + * authority produces a warning, whereas a certificate which is not a certificate authority, or + * an authority whose chain cannot be traced to a root within the same file, is an error. + * @throws IOException if no certificate authority file is available or it cannot be read + * @throws GeneralSecurityException if the file does not provide a usable set of authorities */ private void checkCertAuthorityFile() throws IOException, GeneralSecurityException { - if (certAuthorityFile == null) { - throw new IOException( - "PKI authentication requested, but certificate authority file not provided"); + if (certAuthorities != null) { + return; // already established and validated } - if (!certAuthorityFile.isFile()) { - throw new IOException( - certAuthorityFile.getAbsolutePath() + " is not a valid certification authority"); + activeCertAuthorityFile = certAuthorityFile; + if (activeCertAuthorityFile == null) { + // The option may be omitted when reconfiguring a deployment which already has one + File rootCA = dataDirectory != null ? new File(dataDirectory, POSTGRES_ROOTCA) : null; + if (rootCA == null || !rootCA.isFile()) { + throw new IOException("PKI authentication requires a certificate authority file: " + + CAFILE_OPTION + " "); + } + activeCertAuthorityFile = rootCA; } - if (!verifyPEMFormat(certAuthorityFile)) { - throw new GeneralSecurityException( - "File " + certAuthorityFile.getName() + " does not appear to be a certificate"); + else if (!activeCertAuthorityFile.isFile()) { + throw new IOException("Certificate authority file not found: " + + activeCertAuthorityFile.getAbsolutePath()); + } + System.out.println( + "Certificate authority file: " + activeCertAuthorityFile.getAbsolutePath()); + List certs = + PKIUtils.loadX509PemCertificates(activeCertAuthorityFile); + if (certs.isEmpty()) { + // A DER encoded certificate, a keystore, or an encrypted file lands here, none of which + // the server is able to read + throw new CertificateException("File does not contain any PEM encoded certificate " + + "(an unencrypted PEM certificate file is required): " + + activeCertAuthorityFile.getAbsolutePath()); + } + certAuthorities = validateCertAuthorities(certs); + } + + /** + * Validate the certificates read from the certificate authority file, reporting the trust + * status of each, and reduce them to the set of authorities the PostgreSQL server will use. + * All defects are reported together so that a single run identifies everything which must be + * corrected within the file. + * @param certs all certificates read from the certificate authority file + * @return the certificate authorities, each with a complete chain of trust within the file + * @throws GeneralSecurityException if the file contains a certificate which is not a + * certificate authority, or an authority whose chain of trust is incomplete + */ + private List validateCertAuthorities(List certs) + throws GeneralSecurityException { + + List errors = new ArrayList<>(); + List caCerts = new ArrayList<>(); + for (X509Certificate cert : certs) { + // The basicConstraints CA indication is required of every certificate within the file; + // the server will not build a chain through any other certificate, and a client + // certificate placed here in error would be silently ignored + if (cert.getBasicConstraints() == -1) { + errors.add("Not a certificate authority (CA): " + certName(cert)); + } + else { + caCerts.add(cert); + } + } + for (X509Certificate caCert : caCerts) { + boolean selfSigned = isSelfSigned(caCert); + try { + traceChainOfTrust(caCert, caCerts); + reportCertificate(selfSigned ? "Root certificate authority" + : "Intermediate certificate authority", caCert); + } + catch (CertificateException e) { + errors.add(e.getMessage()); + } + boolean[] keyUsage = caCert.getKeyUsage(); + if (keyUsage != null && keyUsage.length > 5 && !keyUsage[5]) { // keyCertSign + System.out.println("Warning: certificate authority is not permitted to sign " + + "certificates (keyCertSign key usage is absent): " + certName(caCert)); + } + } + if (!errors.isEmpty()) { + StringBuilder buffer = new StringBuilder("Invalid certificate authority file: "); + buffer.append(activeCertAuthorityFile.getAbsolutePath()); + for (String error : errors) { + buffer.append("\n ").append(error); + } + throw new CertificateException(buffer.toString()); + } + return caCerts; + } + + /** + * Trace the chain of trust for the specified certificate through the given set of certificate + * authorities, verifying the signature of each certificate against its issuer. + * @param cert the certificate whose chain of trust is to be traced (need not itself be a + * certificate authority) + * @param caCerts the certificate authorities available for building the chain + * @return the issuing authorities, ordered from the immediate issuer of cert through to the + * self-signed root; empty if cert is itself self-signed + * @throws CertificateException if the chain cannot be traced to a self-signed root within + * caCerts + */ + private List traceChainOfTrust(X509Certificate cert, + List caCerts) throws CertificateException { + + List chain = new ArrayList<>(); + X509Certificate current = cert; + while (!isSelfSigned(current)) { + X509Certificate issuer = findIssuer(current, caCerts); + if (issuer == null) { + throw new CertificateException("Incomplete chain of trust for " + certName(cert) + + ": issuing authority is not within " + activeCertAuthorityFile.getName() + + ": " + current.getIssuerX500Principal().getName()); + } + if (issuer.equals(cert) || chain.contains(issuer)) { + throw new CertificateException("Circular chain of trust for " + certName(cert) + + " at authority: " + certName(issuer)); + } + chain.add(issuer); + current = issuer; + } + return chain; + } + + /** + * Locate the certificate authority which issued the specified certificate. + * @param cert the issued certificate + * @param caCerts the certificate authorities to be searched + * @return the issuing authority, or null if none of them signed cert + */ + private static X509Certificate findIssuer(X509Certificate cert, + List caCerts) { + for (X509Certificate caCert : caCerts) { + if (!cert.getIssuerX500Principal().equals(caCert.getSubjectX500Principal())) { + continue; + } + try { + cert.verify(caCert.getPublicKey()); + return caCert; + } + catch (GeneralSecurityException e) { + // Keep looking: more than one authority may carry this name (e.g., a renewed + // authority issued with a new key pair) + } + } + return null; + } + + /** + * @param cert a certificate + * @return true if the certificate was issued, and signed, by itself (a root) + */ + private static boolean isSelfSigned(X509Certificate cert) { + if (!cert.getSubjectX500Principal().equals(cert.getIssuerX500Principal())) { + return false; + } + try { + cert.verify(cert.getPublicKey()); + return true; + } + catch (GeneralSecurityException e) { + return false; } } /** - * Locate the PostgreSQL configuration and authentication files (postgresql.conf and pg_hba.conf) - * and recover the settings pertinent to BSimControl. If the data directory has not been initialized yet, - * run PostgreSQL's init command to perform the initialization and then tailor the configuration - * based on BSimControl's command-line options and the Ghidra specific configuration options - * @throws IOException if the module data file cannot be retrieved + * Report a certificate and its validity period on the console, as a warning if it is not + * currently within that period. + * @param label description of the certificate's role + * @param cert the certificate to be reported + */ + private static void reportCertificate(String label, X509Certificate cert) { + Date now = new Date(); + String detail = label + ": " + certName(cert); + if (now.after(cert.getNotAfter())) { + System.out.println("Warning: " + detail + " EXPIRED " + cert.getNotAfter()); + } + else if (now.before(cert.getNotBefore())) { + System.out.println( + "Warning: " + detail + " is not valid until " + cert.getNotBefore()); + } + else { + System.out.println(" " + detail + ", expires " + cert.getNotAfter()); + } + } + + /** + * @param cert a certificate + * @return the certificate's subject name and serial number, for reporting purposes + */ + private static String certName(X509Certificate cert) { + return cert.getSubjectX500Principal().getName() + " [S/N " + + cert.getSerialNumber().toString(16) + "]"; + } + + /** + * Load configuration parameters from an already-initialized data directory. + * @throws IOException if the data directory has not been initialized + */ + private void loadConfiguration() throws IOException { + File configFile = new File(dataDirectory, POSTGRES_CONFIGFILE); + File hbaFile = new File(dataDirectory, POSTGRES_CONNECTFILE); + if (!configFile.exists()) { + throw new IOException("Data directory not initialized: run \"bsim_ctl " + COMMAND_INIT + + " " + dataDirectory.getAbsolutePath() + "\" first"); + } + recoverConfigurationParameters(configFile, hbaFile); + } + + /** + * Initialize a new (uninitialized) data directory: run PostgreSQL's init command, tailor the + * configuration files based on BSimControl's command-line options and the Ghidra specific + * configuration options, and generate the server SSL certificate. The data directory must not + * already be initialized. + * @returns server certificate used for deployment + * @throws IOException if the data directory is already initialized or a file operation fails * @throws InterruptedException if the postgres command is interrupted * @throws SAXException if tuneConfig fails * @throws GeneralSecurityException if the cert file cannot be processed */ - private void initializeDataDirectory() + private File initializeNewDataDirectory() throws IOException, InterruptedException, SAXException, GeneralSecurityException { File configFile = new File(dataDirectory, POSTGRES_CONFIGFILE); File hbaFile = new File(dataDirectory, POSTGRES_CONNECTFILE); if (configFile.exists()) { - recoverConfigurationParameters(configFile, hbaFile); - return; + throw new IOException( + "Data directory already exists: " + dataDirectory.getAbsolutePath()); } + + System.out.println("Initializing data directory: " + dataDirectory.getAbsolutePath()); + File serverConfigFile = Application.getModuleDataFile("serverconfig.xml").getFile(false); if (hostAuthentication == AUTHENTICATION_PKI) { - checkCertAuthorityFile(); - System.out.println("Remote client authentication with PKI certificates"); + // NOTE: the certificate authority and the admin user's distinguished name were both + // established/validated by establishPkiIdentity + System.out.println("PostgreSQL host authentication mode: pki"); + System.out.println("Adding " + connectingUserName + "(admin) with certificate " + + certificateNameLabel() + ": " + certificateSystemName()); } else if (hostAuthentication == AUTHENTICATION_PASSWORD) { - System.out.println("Remote client authentication via password"); + System.out.println("PostgreSQL host authentication mode: password"); + establishAdminPassword(); + if (dropPrivileges) { + chownToOwner(passwordFile, false); // initdb runs as OWNER and must read pwfile + } } else { - System.out.println("No client authentication"); + System.out.println("PostgreSQL host authentication mode: trust (no authentication)"); } - System.out.println("Initializing data directory"); + List command = new ArrayList(); command.add(postgresControl.getAbsolutePath()); command.add("init"); command.add("-o"); + command.add("-A " + PASSWORD_METHOD); // specified during init to avoid warnings + command.add("-o"); command.add("'--username=" + connectingUserName + '\''); if (hostAuthentication == AUTHENTICATION_PASSWORD) { - establishAdminPassword(); command.add("-o"); command.add("'--pwfile=" + passwordFile.getAbsolutePath() + '\''); } - else if (hostAuthentication == AUTHENTICATION_PKI) { - if (commonName == null) { - throw new GeneralSecurityException( - "Distinguished name option (--dn) required for " + connectingUserName); - } - checkCertAuthorityFile(); - } command.add("-D"); command.add(dataDirectory.getAbsolutePath()); - int res = runCommand(null, command, loadLibraryVar, loadLibraryValue); + int res = runPostgresCommand(command); if (res != 0) { throw new IOException("Error initializing postgres database"); } File configCopy = new File(dataDirectory, POSTGRES_CONFIGFILE + ".orig"); if (hostAuthentication == AUTHENTICATION_PKI || localAuthentication == AUTHENTICATION_PKI) { + // The authority file was established, and validated, by establishPkiIdentity File rootCA = new File(dataDirectory, POSTGRES_ROOTCA); - FileUtilities.copyFile(certAuthorityFile, rootCA, false, null); + FileUtilities.copyFile(activeCertAuthorityFile, rootCA, false, null); addCertificateName(connectingUserName); } @@ -927,11 +2203,25 @@ public class BSimControlLaunchable implements GhidraLaunchable { if (!hbaFile.renameTo(hbaCopy)) { throw new IOException("Error copying original connection file"); } - // Patch the configuration - tuneConfig(configCopy, configFile, hbaCopy, hbaFile, serverConfigFile); - System.out.println("Generating servers SSL certificate"); - generateSelfSignedCertificate(new File(dataDirectory, "server.crt"), - new File(dataDirectory, "server.key")); + // Patch the configuration; interface binding and cert source follow key store presence. + boolean keystorePresent = (keystoreFile != null); + tuneConfig(configCopy, configFile, hbaCopy, hbaFile, serverConfigFile, keystorePresent, true); + File serverCert = new File(dataDirectory, POSTGRES_CERTFILE); + File serverKey = new File(dataDirectory, POSTGRES_KEYFILE); + if (keystorePresent) { + System.out.println("Importing PostgreSQL server certificate from key store"); + importServerCertificate(keystoreFile, serverCert, serverKey); + } + else { + System.out.println("Generating self-signed (loopback-only) PostgreSQL server certificate"); + generateSelfSignedCertificate(serverCert, serverKey); + System.out.println( + "NOTE: BSim clients should add server certificate as trusted certificate:\n " + + serverCert.getAbsolutePath()); + } + saveDeploymentConfig(); // record the --auth mode for subsequent invocations + normalizeOwnership(); // re-own any root-created files to OWNER + return serverCert; } /** @@ -942,17 +2232,23 @@ public class BSimControlLaunchable implements GhidraLaunchable { * @throws IllegalArgumentException if the data directory is invalid * @throws IOException if the canonical file cannot be retrieved */ - private void scanDataDirectory(String[] params, int slot) + private void scanDataDirectory(String[] params, int slot, boolean mustExist) throws IllegalArgumentException, IOException { if (params.length <= slot) { throw new IllegalArgumentException("Missing data directory"); } dataDirectory = new File(params[slot]); - if (!dataDirectory.isDirectory()) { - throw new IllegalArgumentException( - "Data directory " + dataDirectory.getAbsolutePath() + " does not exist"); + if (mustExist) { + if (!dataDirectory.isDirectory()) { + throw new IllegalArgumentException( + "Data directory " + dataDirectory.getAbsolutePath() + " does not exist"); + } + dataDirectory = dataDirectory.getCanonicalFile(); + } + else if (dataDirectory.exists()) { + throw new IllegalArgumentException( + "New data directory " + dataDirectory.getAbsolutePath() + " must not exist"); } - dataDirectory = dataDirectory.getCanonicalFile(); } /** @@ -991,26 +2287,88 @@ public class BSimControlLaunchable implements GhidraLaunchable { } /** - * Start a PostgreSQL server, configured for BSim, on the local host. - * If the data directory is already populated, the server process is simply restarted. - * If the data directory is empty, a new server configuration is established, and the server is started. - * Authentication may be necessary, either via password or certificate, in order to enable - * the BSim extension on the server - * - * @throws IOException if postgres cannot be started - * @throws InterruptedException if the process fails during the run - * @throws SAXException if the data directory cannot be initialized - * @throws GeneralSecurityException if the authentication fails + * Initialize a new PostgreSQL data directory configured for BSim, then briefly start the + * server to enable the BSim (lshvector) extension before leaving it stopped. The data + * directory must not already be initialized (use {@link #configureCommand()} to change an + * existing configuration). + * @throws IOException if initialization fails + * @throws InterruptedException if a postgres command is interrupted + * @throws SAXException if the configuration cannot be tuned + * @throws GeneralSecurityException if authentication/certificate handling fails */ - private void startCommand() + private void initCommand() throws IOException, InterruptedException, SAXException, GeneralSecurityException { + resolveDeployment(true, false); discoverPostgresInstall(); - initializeDataDirectory(); - - if (localAuthentication == AUTHENTICATION_PKI && certParameter == null) { - throw new GeneralSecurityException( - "Path to certificate necessary to start server (--cert /path/to/cert)"); + File configFile = new File(dataDirectory, POSTGRES_CONFIGFILE); + if (configFile.exists()) { + throw new IOException("Data directory already initialized: use \"" + COMMAND_CONFIGURE + + "\" to change settings or \"" + COMMAND_START + "\" to run the server"); } + + if (hostAuthentication == AUTHENTICATION_PKI || localAuthentication == AUTHENTICATION_PKI) { + // The admin user's certificate establishes both their DN/CN identity mapping and the + // credential used for the local connection which enables the BSim extension below + establishPkiIdentity(); + } + else { + warnUnusedCertAuthorityOption(); + } + + createDataDirectory(); + File serverCrt = initializeNewDataDirectory(); + + // Briefly start the server to enable the BSim extension, then leave it stopped. + pgCtlStart(); + + // Trust server certificate to ensure the connection to enable BSim LSHExtension succeeds + System.setProperty(DefaultTrustManagerFactory.GHIDRA_CACERTS_PATH_PROPERTY, serverCrt.getAbsolutePath()); + DefaultSSLContextInitializer.initialize(true); + + boolean extensionEnabled = true; + try { + enableLSHExtension(); + System.out.println("BSim extension enabled"); + } + catch (SQLException | IOException e) { + System.out.println(e.getMessage()); + extensionEnabled = false; + } + + pgCtlStop(!extensionEnabled); // Force a shutdown if extension isn't enabled + + if (!extensionEnabled) { + throw new IOException("Initialization failed: BSim extension could not be enabled"); + } + + System.out.println("Initialization complete; run \"bsim_ctl " + COMMAND_START + " " + + dataDirectory.getAbsolutePath() + "\" to start the server"); + } + + /** + * Start a previously-initialized PostgreSQL server on the local host. No configuration is + * performed; the data directory must already have been initialized with {@code bsim_ctl init}. + * @throws IOException if the data directory is not initialized or the server cannot be started + * @throws InterruptedException if the process fails during the run + */ + private void startCommand() throws IOException, InterruptedException { + resolveDeployment(false, false); + discoverPostgresInstall(); + if (isServiceInstalled()) { + systemctlLifecycle("start"); + return; + } + loadConfiguration(); + pgCtlStart(); + System.out.println("Server started"); + } + + /** + * Run "pg_ctl start" for the current data directory, waiting for the server to be ready. + * @throws IOException if the server cannot be started + * @throws InterruptedException if the process is interrupted + */ + private void pgCtlStart() throws IOException, InterruptedException { File logFile = new File(dataDirectory, "logfile"); List command = new ArrayList(); command.add(postgresControl.getAbsolutePath()); @@ -1020,27 +2378,50 @@ public class BSimControlLaunchable implements GhidraLaunchable { command.add(dataDirectory.getAbsolutePath()); command.add("-l"); command.add(logFile.getAbsolutePath()); - int res = runCommand(null, command, loadLibraryVar, loadLibraryValue); + int res = runPostgresCommand(command); if (res != 0) { throw new IOException("Could not start postgres server process"); } + } - System.out.println("Server started"); - boolean extensionEnabled = true; - try { - enableLSHExtension(); + /** + * Restart a previously-initialized PostgreSQL server on the local host. No configuration is + * performed. + * @throws IOException if the data directory is not initialized or the server cannot be restarted + * @throws InterruptedException if the process fails during the run + */ + private void restartCommand() throws IOException, InterruptedException { + resolveDeployment(false, false); + discoverPostgresInstall(); + if (isServiceInstalled()) { + systemctlLifecycle("restart"); + return; } - catch (SQLException e) { - System.out.println(e.getMessage()); - extensionEnabled = false; + + if (!isPostmasterRunning()) { + startCommand(); + return; } - if (extensionEnabled) { - System.out.println("BSim extension enabled"); + + loadConfiguration(); + File logFile = new File(dataDirectory, "logfile"); + List command = new ArrayList(); + command.add(postgresControl.getAbsolutePath()); + command.add("restart"); + command.add("-w"); + command.add("-D"); + command.add(dataDirectory.getAbsolutePath()); + command.add("-l"); + command.add(logFile.getAbsolutePath()); + if (forceShutdown) { + command.add("-m"); + command.add("fast"); } - else { - forceShutdown = true; // Force a shutdown, because extension isn't enabled - stopCommand(); + int res = runPostgresCommand(command); + if (res != 0) { + throw new IOException("Could not restart postgres server process"); } + System.out.println("Server restarted"); } /** @@ -1050,21 +2431,42 @@ public class BSimControlLaunchable implements GhidraLaunchable { * @throws InterruptedException if the stop command is interrupted */ private void stopCommand() throws IOException, InterruptedException { + resolveDeployment(false, false); discoverPostgresInstall(); + if (isServiceInstalled()) { + systemctlLifecycle("stop"); + return; + } + if (!isPostmasterRunning()) { + System.out.println("Server has not been started"); + } + else { + pgCtlStop(forceShutdown); + System.out.println("Server shutdown complete"); + } + } + + /** + * Run "pg_ctl stop" for the current data directory. + * @param fast if true use "fast" shutdown mode (does not wait for clients to disconnect, + * all active transactions are rolled back) + * @throws IOException if the server cannot be stopped + * @throws InterruptedException if the process is interrupted + */ + private void pgCtlStop(boolean fast) throws IOException, InterruptedException { List command = new ArrayList(); command.add(postgresControl.getAbsolutePath()); command.add("stop"); command.add("-D"); command.add(dataDirectory.getAbsolutePath()); - if (forceShutdown) { + if (fast) { command.add("-m"); command.add("fast"); // Does not wait for clients to disconnect, all active transactions rolled back } - int res = runCommand(null, command, loadLibraryVar, loadLibraryValue); + int res = runPostgresCommand(command); if (res != 0) { throw new IOException("Error shutting down postgres server process"); } - System.out.println("Server shutdown complete"); } /** @@ -1073,21 +2475,45 @@ public class BSimControlLaunchable implements GhidraLaunchable { * @throws InterruptedException if the status command is interrupted */ private void statusCommand() throws IOException, InterruptedException { + resolveDeployment(false, false); discoverPostgresInstall(); - List command = new ArrayList(); - command.add(postgresControl.getAbsolutePath()); - command.add("status"); - command.add("-D"); - command.add(dataDirectory.getAbsolutePath()); - int res = runCommand(null, command, loadLibraryVar, loadLibraryValue); - if (res == 0) { - System.out.println("Server running"); + loadConfiguration(); + + System.out.println("BSim PostgreSQL status: " + dataDirectory.getAbsolutePath()); + System.out.println(" Owner : " + ownerName); + System.out.println(" Port : " + port); + + String authMode = authModeName(hostAuthentication); + System.out.println(" Authentication : " + authMode + " (local connections: " + + authModeName(localAuthentication) + ")"); + if (hostAuthentication == AUTHENTICATION_PKI || localAuthentication == AUTHENTICATION_PKI) { + System.out.println(" Certificate ID : " + certificateNameLabel()); } - else if (res == 3) { - System.out.println("Server down"); + System.out.println(" Remote access : " + + (remoteAccessConfigured ? "ENABLED" : "DISABLED (loopback only)")); + + boolean running = isPostmasterRunning(); + Properties marker = readServiceMarker(); + if (marker != null) { + String unit = marker.getProperty("unitName"); + String enabled = isLinux() ? systemctlQuery("is-enabled", unit) : "unknown"; + System.out.println( + " Service : INSTALLED unit=" + unit + " scope=system " + enabled); + String svcUser = marker.getProperty("user"); + if (svcUser != null && !svcUser.equals(ownerName)) { + System.out.println(" WARNING : data directory owner (" + ownerName + + ") != service user (" + svcUser + ")"); + } + String active = isLinux() ? systemctlQuery("is-active", unit) : "unknown"; + + System.out.println(" Status : " + + (running ? "RUNNING (via service)" : "STOPPED") + " [systemctl status: " + + active + "]"); } else { - throw new IOException("Error getting postgres server status"); + System.out.println(" Service : NOT INSTALLED"); + System.out.println(" Status : " + + (running ? "RUNNING (standalone)" : "STOPPED")); } } @@ -1104,15 +2530,17 @@ public class BSimControlLaunchable implements GhidraLaunchable { command.add("-D"); command.add(dataDirectory.getAbsolutePath()); command.add("-s"); - int res = runCommand(null, command, loadLibraryVar, loadLibraryValue); + int res = runPostgresCommand(command); if (res != 0) { throw new IOException("Error creating new user"); } } /** - * Update the PostgreSQL identity map (pg_ident.conf) adding a map from - * the currently active -commonName- to -username- + * Update the PostgreSQL identity map (pg_ident.conf) adding a map from the currently active + * certificate name - the full {@code codistinguishedName}, or only its {@code commonName} for a deployment + * which registers that instead (see {@link #recoverCertificateNameMode(ServerConfig)}) - to + * {@code username}. * @param username the user name to add * @throws IOException if the postgres ident file is invalid */ @@ -1122,8 +2550,8 @@ public class BSimControlLaunchable implements GhidraLaunchable { if (!identFile.isFile()) { throw new IOException("Missing ident file: " + identFile.getAbsolutePath()); } - ServerConfig.patchIdent(identFile, copyFile, POSTGRES_MAP_IDENTIFIER, commonName, username, - true); + ServerConfig.patchIdent(identFile, copyFile, POSTGRES_MAP_IDENTIFIER, + certificateSystemName(), username, true); FileUtilities.copyFile(copyFile, identFile, false, null); } @@ -1137,20 +2565,29 @@ public class BSimControlLaunchable implements GhidraLaunchable { * @throws Exception if there's a problem initializing the Application of discovering the Postgres installation */ private void addUserCommand() throws GeneralSecurityException, Exception { + resolveDeployment(false, false); discoverPostgresInstall(); - initializeDataDirectory(); // Needed to pick up authentication settings - if (hostAuthentication == AUTHENTICATION_PKI) { - if (distinguishedName == null || commonName == null) { - throw new GeneralSecurityException("Distinguished name required (dn=\"..\")"); + loadConfiguration(); // Needed to pick up authentication settings + boolean pkiAuthentication = (hostAuthentication == AUTHENTICATION_PKI + || localAuthentication == AUTHENTICATION_PKI); + if (pkiAuthentication) { + if (distinguishedName == null) { + throw new GeneralSecurityException("The distinguished name (" + DN_OPTION + + ") of the user's certificate is required"); + } + // Only a legacy deployment which registers the common name alone requires the distinguished + // name to carry one + if (!useDistinguishedName && commonName == null) { + throw new GeneralSecurityException( + "The distinguished name (" + DN_OPTION + + ") does not have a required common name (CN): " + distinguishedName); } } - StringBuilder resultMessage = new StringBuilder(); - resultMessage.append("Added user: "); - resultMessage.append(specifiedUserName); - boolean resetPassword = (hostAuthentication == AUTHENTICATION_PASSWORD); + boolean resetPassword = (hostAuthentication == AUTHENTICATION_PASSWORD + || localAuthentication == AUTHENTICATION_PASSWORD); adminPasswordData = null; - + localConnection = getOrCreateLocalConnection(); StringBuilder buffer = new StringBuilder(); @@ -1160,13 +2597,14 @@ public class BSimControlLaunchable implements GhidraLaunchable { try (Statement st = localConnection.createStatement()) { st.executeUpdate(buffer.toString()); + System.out.println("Added user '" + specifiedUserName + "'"); } catch (SQLException err) { - if (!err.getMessage().contains("already exists")) { // Suppress already exists error message + if (!err.getMessage().contains("already exists")) { // Suppress already exists exception throw err; } - resultMessage.append(" (already present)"); // Record that user is already added resetPassword = false; + System.err.println("User '" + specifiedUserName + "' already exists"); } finally { if (resetPassword) { @@ -1175,13 +2613,14 @@ public class BSimControlLaunchable implements GhidraLaunchable { localConnection.close(); } - if (hostAuthentication == AUTHENTICATION_PKI) { + if (pkiAuthentication) { addCertificateName(specifiedUserName); + normalizeOwnership(); // pg_ident.conf rewritten in-process; re-own to OWNER reloadIdent(); - System.out.println("Linking distinguished name to user: " + specifiedUserName); + System.out.println("Linking certificate " + certificateNameLabel() + " '" + + certificateSystemName() + "' to user: " + specifiedUserName); return; } - System.out.println(resultMessage.toString()); } /** @@ -1199,7 +2638,7 @@ public class BSimControlLaunchable implements GhidraLaunchable { } } catch (SQLException | IOException e) { - Msg.error(this, "Error creating connection to Postgres database", e); + System.out.println("Error creating connection to local Postgres database"); throw e; } return localConnection; @@ -1213,8 +2652,9 @@ public class BSimControlLaunchable implements GhidraLaunchable { * @throws Exception */ private void dropUserCommand() throws Exception { + resolveDeployment(false, false); discoverPostgresInstall(); - initializeDataDirectory(); + loadConfiguration(); boolean userDoesNotExist = false; localConnection = getOrCreateLocalConnection(); StringBuilder buffer = new StringBuilder(); @@ -1240,9 +2680,12 @@ public class BSimControlLaunchable implements GhidraLaunchable { if (!identFile.isFile()) { throw new IOException("Missing ident file: " + identFile.getAbsolutePath()); } - ServerConfig.patchIdent(identFile, copyFile, POSTGRES_MAP_IDENTIFIER, commonName, + // An entry is removed by the role it maps to, so the certificate name it was registered + // with is neither needed nor available here + ServerConfig.patchIdent(identFile, copyFile, POSTGRES_MAP_IDENTIFIER, null, specifiedUserName, false); FileUtilities.copyFile(copyFile, identFile, false, null); + normalizeOwnership(); // pg_ident.conf rewritten in-process; re-own to OWNER reloadIdent(); } if (userDoesNotExist) { @@ -1254,35 +2697,134 @@ public class BSimControlLaunchable implements GhidraLaunchable { } /** - * The data directory for a server (which must not be running) is reconfigured - * with new local and remote authentication options, and the port may be reconfigured as well. - * Database records are unaltered. - * The user submits "auth=..", "localAuth=..", and "port=.." options on the command line: - * among those submitted, any option that doesn't match the current configuration is changed. - * @throws IOException if the postgres installation cannot be found - * @throws InterruptedException if the postgres installation cannot be found - * @throws SAXException if the {@link #tuneConfig(File, File, File, File, File)} call fails - * @throws GeneralSecurityException if there is no Distinguished Name supplied + * List all defined PostgreSQL users (login roles) on the currently running server along with + * their role. A local connection is established and {@code pg_roles} is queried; each login + * role is reported as "admin" if it has superuser privileges (as granted by + * {@link #changePrivilegeCommand()}), otherwise "user". + *

+ * When the deployment is configured for PKI authentication the certificate name registered for + * each user within the identity map ({@value #POSTGRES_IDENTFILE}) is reported as well: their + * full distinguished name, or only its common name for a deployment which registers that + * instead (see {@link #recoverCertificateNameMode(ServerConfig)}). A user with no registered + * name is unable to authenticate and is reported as such. + * @throws Exception if there's a problem getting a connection to the Postgres database */ - private void changeAuthCommand() - throws IOException, InterruptedException, SAXException, GeneralSecurityException { + private void listUsersCommand() throws Exception { + resolveDeployment(false, false); + loadConfiguration(); // Needed to pick up the configured port and authentication + boolean pkiAuthentication = (hostAuthentication == AUTHENTICATION_PKI + || localAuthentication == AUTHENTICATION_PKI); + Map> certificateNames = + pkiAuthentication ? readCertificateNames() : Map.of(); + localConnection = getOrCreateLocalConnection(); + String query = + "SELECT rolname, rolsuper FROM pg_roles WHERE rolcanlogin ORDER BY rolname"; + try (Statement st = localConnection.createStatement(); + ResultSet rs = st.executeQuery(query)) { + System.out.println("Defined users: " + dataDirectory.getAbsolutePath()); + if (!pkiAuthentication) { + System.out.println(String.format(" %-32s %s", "USER", "ROLE")); + } + else { + System.out.println(String.format(" %-32s %-6s %s", "USER", "ROLE", + useDistinguishedName ? "CERTIFICATE DN" : "CERTIFICATE CN")); + } + while (rs.next()) { + String rolname = rs.getString("rolname"); + String role = rs.getBoolean("rolsuper") ? "admin" : "user"; + if (!pkiAuthentication) { + System.out.println(String.format(" %-32s %s", rolname, role)); + continue; + } + List names = certificateNames.getOrDefault(rolname, List.of()); + System.out.println(String.format(" %-32s %-6s %s", rolname, role, + names.isEmpty() ? "" : names.get(0))); + for (int i = 1; i < names.size(); i++) { // user registered with more than one + System.out.println(String.format(" %-32s %-6s %s", "", "", names.get(i))); + } + } + } + finally { + localConnection.close(); + } + } + + /** + * Read the certificate identity mappings which are registered within the PostgreSQL identity + * file ({@value #POSTGRES_IDENTFILE}) of the current data directory. + * @return the certificate names registered for each database user (role) + * @throws IOException if the identity file is missing or cannot be parsed + */ + private Map> readCertificateNames() throws IOException { + File identFile = new File(dataDirectory, POSTGRES_IDENTFILE); + if (!identFile.isFile()) { + throw new IOException("Missing ident file: " + identFile.getAbsolutePath()); + } + return ServerConfig.scanIdent(identFile, POSTGRES_MAP_IDENTIFIER); + } + + /** + * The data directory for a server (which must be running) is reconfigured with new local and + * remote authentication options, and the port may be reconfigured as well. Database records + * are unaltered. The user submits the {@code --auth}, {@code --noLocalAuth}, + * {@code --cafile}, {@code --keystore} and {@code --port} options on the command line: among + * those submitted, any option that doesn't match the current configuration is changed. + *

+ * The server must be running so that the invoking user can be authenticated with the admin role + * before any authentication change is permitted, and so that any credential the change requires + * (a password, or a certificate identity mapping) is put in place first. The configuration + * files are only consumed by the server when it starts, so the server is left running and the + * changes take effect when it is next restarted. + * @throws IOException if the server is not running or a file operation fails + * @throws SAXException if the {@link #tuneConfig(File, File, File, File, File, boolean, boolean)} call fails + * @throws GeneralSecurityException if certificate or distinguished name handling fails + * @throws Exception if the admin connection cannot be established + */ + private void configureCommand() throws Exception { + resolveDeployment(false, false); discoverPostgresInstall(); File configFile = new File(dataDirectory, POSTGRES_CONFIGFILE); File hbaFile = new File(dataDirectory, POSTGRES_CONNECTFILE); if (!configFile.exists()) { - throw new IOException("Data directory not initialized: run \"bsim_ctl start\" first"); + throw new IOException("Data directory not initialized: run \"bsim_ctl " + COMMAND_INIT + + " " + dataDirectory.getAbsolutePath() + "\" first"); } int requestedLocalAuth = localAuthentication; int requestedHostAuth = hostAuthentication; int requestedPort = port; port = -1; recoverConfigurationParameters(configFile, hbaFile); - if (isServerRunning()) { - throw new IOException("Cannot modify settings on running server"); + if (!isServerRunning()) { + throw new IOException("Server must be running to change its configuration: run " + + "\"bsim_ctl " + COMMAND_START + " " + dataDirectory.getAbsolutePath() + "\" first"); } - if ((!authConfigPresent || (requestedHostAuth == hostAuthentication && - requestedLocalAuth == localAuthentication)) && - (requestedPort == -1 || requestedPort == port)) { + + // The --auth option establishes both the host and local authentication, whereas + // --noLocalAuth on its own only downgrades local authentication and must leave the + // configured authentication mode as-is + boolean hostAuthChange = authOptionPresent && (requestedHostAuth != hostAuthentication); + boolean localAuthChange = authConfigPresent && (requestedLocalAuth != localAuthentication); + int newLocalAuth = localAuthChange ? requestedLocalAuth : localAuthentication; + int newHostAuth = hostAuthChange ? requestedHostAuth : hostAuthentication; + + // A key store establishes the server certificate and enables remote access; one already + // installed by a previous configuration remains in place, which is what a recovered + // non-loopback binding indicates. + boolean keystorePresent = (keystoreFile != null) || remoteAccessConfigured; + + boolean newLocalPki = localAuthChange && (newLocalAuth == AUTHENTICATION_PKI); + boolean newRemotePki = hostAuthChange && (newHostAuth == AUTHENTICATION_PKI); + boolean pkiInEffect = + (newLocalAuth == AUTHENTICATION_PKI) || (newHostAuth == AUTHENTICATION_PKI); + + // A newly specified certificate authority file replaces the authorities the server trusts + // (root.crt) and is therefore a change in its own right, provided pki is in effect + boolean certAuthorityChange = (certAuthorityFile != null) && pkiInEffect; + if (!pkiInEffect) { + warnUnusedCertAuthorityOption(); + } + if (!hostAuthChange && !localAuthChange && !certAuthorityChange && + (requestedPort == -1 || requestedPort == port) && keystoreFile == null) { System.out.println("No changes to make"); return; } @@ -1298,57 +2840,95 @@ public class BSimControlLaunchable implements GhidraLaunchable { throw new IOException( "Original connection file not present: " + hbaCopy.getAbsolutePath()); } + boolean newPasswordAuth = (localAuthChange && newLocalAuth == AUTHENTICATION_PASSWORD) || + (hostAuthChange && newHostAuth == AUTHENTICATION_PASSWORD); + + // A transition to pki requires the admin user's certificate identity, so that their + // PostgreSQL identity mapping is established for the new authentication mode. Replacing + // the certificate authorities of a deployment which already uses pki requires it as well: + // their certificate must be issued by the incoming authority, and the mapping must match + // the certificate they will present, or the change would lock them out of the server. + if (newLocalPki || newRemotePki || certAuthorityChange) { + establishPkiIdentity(); + } + + // Any authentication change requires that the invoking user prove they hold the admin role, + // unless authentication is being removed entirely - which is also the means of recovering + // from a lost password or certificate + if ((hostAuthChange || localAuthChange || certAuthorityChange) && + (newLocalAuth != AUTHENTICATION_NONE || newHostAuth != AUTHENTICATION_NONE)) { + localConnection = verifyAdminAuthentication(); + try { + // A transition to password authentication when the admin user has no password + // established would leave them unable to connect once the change takes effect + if (newPasswordAuth) { + if (!hasPassword(localConnection)) { + establishNewPassword(localConnection); + } + else { + System.out.println("Existing DB password for user '" + connectingUserName + + "' remains in effect"); + } + } + } + finally { + localConnection.close(); + } + } + + // Beyond this point the configuration is modified; the running server is unaffected since + // it only consumes these files when started if (requestedPort != -1 && requestedPort != port) { port = requestedPort; System.out.println("Server will now listen on port " + Integer.toString(port)); } - boolean newRemotePki = false; // Are we newly enabling remote pki - boolean newLocalPki = false; // Are we newly enabling local pki - - if (authConfigPresent && requestedLocalAuth != localAuthentication) { - localAuthentication = requestedLocalAuth; - System.out.print("New local authentication: "); - if (localAuthentication == AUTHENTICATION_PASSWORD) { - System.out.println("password"); - } - else if (localAuthentication == AUTHENTICATION_PKI) { - System.out.println("pki"); - newLocalPki = true; - if (commonName == null) { - throw new GeneralSecurityException("Distinguished name required (dn=\"..\")"); - } - } - else if (localAuthentication == AUTHENTICATION_NONE) { - System.out.println("none"); - } + if (localAuthChange) { + localAuthentication = newLocalAuth; + System.out.println("New local authentication: " + authModeName(localAuthentication)); } - if (authConfigPresent && requestedHostAuth != hostAuthentication) { - hostAuthentication = requestedHostAuth; - System.out.print("New host authentication: "); - if (hostAuthentication == AUTHENTICATION_NONE) { - System.out.println("none"); - } - else if (hostAuthentication == AUTHENTICATION_PASSWORD) { - System.out.println("password"); - } - else if (hostAuthentication == AUTHENTICATION_PKI) { - System.out.println("pki"); - newRemotePki = true; - } - else { - System.out.println("unknown"); + if (hostAuthChange) { + hostAuthentication = newHostAuth; + System.out.println("New host authentication: " + authModeName(hostAuthentication)); + } + if (newLocalPki || newRemotePki || certAuthorityChange) { + if (certAuthorityChange) { + // Install the newly specified authorities; without the option the authorities + // already installed (and re-validated above) remain in place + File rootCA = new File(dataDirectory, POSTGRES_ROOTCA); + FileUtilities.copyFile(certAuthorityFile, rootCA, false, null); + System.out.println("Installed certificate authority file: " + POSTGRES_ROOTCA); } + addCertificateName(connectingUserName); + System.out.println("Linking certificate " + certificateNameLabel() + " '" + + certificateSystemName() + "' to user: " + connectingUserName); + } + + tuneConfig(configCopy, configFile, hbaCopy, hbaFile, serverConfigFile, keystorePresent, false); + + File certFile = new File(dataDirectory, POSTGRES_CERTFILE); + File keyFile = new File(dataDirectory, POSTGRES_KEYFILE); + if (keystoreFile != null) { + System.out.println("Importing server certificate from key store"); + importServerCertificate(keystoreFile, certFile, keyFile); + } + saveDeploymentConfig(); // record the --auth mode for subsequent invocations + normalizeOwnership(); // re-own any root-created files to OWNER + + if (newPasswordAuth) { + System.out.println("NOTE: any other existing user may not have a DB password; after " + + "restarting use the " + COMMAND_RESET_PASSWORD + " command for each"); } if (newLocalPki || newRemotePki) { - checkCertAuthorityFile(); - File rootCA = new File(dataDirectory, POSTGRES_ROOTCA); - FileUtilities.copyFile(certAuthorityFile, rootCA, false, null); + System.out.println("NOTE: any other existing user requires a certificate identity " + + "mapping; after restarting use the " + COMMAND_ADDUSER + " command with the " + + DN_OPTION + " option for each"); } - if (newLocalPki) { - addCertificateName(connectingUserName); + else if (certAuthorityChange) { + System.out.println("NOTE: after restarting, every other user must present a certificate " + + "issued by an authority within the installed " + POSTGRES_ROOTCA); } - - tuneConfig(configCopy, configFile, hbaCopy, hbaFile, serverConfigFile); + System.out.println("Configuration updated; changes take effect when the server is " + + "restarted: bsim_ctl " + COMMAND_RESTART + " " + dataDirectory.getAbsolutePath()); } /** @@ -1365,6 +2945,111 @@ public class BSimControlLaunchable implements GhidraLaunchable { buffer.append(DEFAULT_PASSWORD); buffer.append('\''); executeSQLStatement(pdb, buffer.toString()); + System.out.println("DB password for user '" + username + "' reset to '" + DEFAULT_PASSWORD + "'"); + } + + /** + * Verify that the invoking user is able to authenticate with the running server, using the + * authentication currently configured, and holds the admin role. This is required before any + * authentication change is made so that an administrator cannot lock themself, or others, out + * of a server they are unable to access. + * @return the established admin connection which the caller must close + * @throws IOException if the user cannot be authenticated or lacks the admin role + * @throws Exception if the connection cannot be established + */ + private Connection verifyAdminAuthentication() throws Exception { + Connection pdb = getOrCreateLocalConnection(); + boolean verified = false; + try (Statement st = pdb.createStatement(); + ResultSet rs = st.executeQuery( + "SELECT rolsuper FROM pg_roles WHERE rolname = CURRENT_USER")) { + if (!rs.next()) { + throw new IOException( + "Unable to determine role privilege for user: " + connectingUserName); + } + if (!rs.getBoolean(1)) { + throw new IOException("User '" + connectingUserName + + "' does not have the admin role required to change the configuration"); + } + verified = true; + } + finally { + if (!verified) { + pdb.close(); + } + } + System.out.println("Authenticated '" + connectingUserName + "' (admin)"); + return pdb; + } + + /** + * Determine if the connected user has a database password established. Used when transitioning + * to password authentication to detect the case where they would otherwise be unable to + * authenticate after the change takes effect. + * @param pdb is the connection over which to issue the query + * @return true if a password is established, false if not (or if it cannot be determined) + */ + private boolean hasPassword(Connection pdb) { + try (Statement st = pdb.createStatement(); + ResultSet rs = st.executeQuery( + "SELECT rolpassword IS NOT NULL FROM pg_authid WHERE rolname = CURRENT_USER")) { + return rs.next() && rs.getBoolean(1); + } + catch (SQLException e) { + Msg.warn(this, "Unable to determine if a password is established for " + + connectingUserName + ": " + e.getMessage()); + return false; // assume not established so a new password is requested + } + } + + /** + * Prompt for, and establish, a new database password for the connected admin user. The + * password must be in place before the transition to password authentication takes effect, + * which occurs when the server is next restarted. + * @param pdb is the connection over which to issue the command + * @throws IOException if a new password cannot be obtained + * @throws SQLException if the password cannot be established + */ + private void establishNewPassword(Connection pdb) throws IOException, SQLException { + char[] newPassword = requestNewPassword(); + try { + StringBuilder buffer = new StringBuilder(); + buffer.append("ALTER ROLE "); + Utils.escapeIdentifier(buffer, connectingUserName); + buffer.append(" WITH PASSWORD '"); + Utils.escapeLiteral(buffer, new String(newPassword), true); + buffer.append('\''); + executeSQLStatement(pdb, buffer.toString()); + } + finally { + clearPasswordData(newPassword); + } + System.out.println("DB password established for user '" + connectingUserName + "'"); + } + + /** + * Prompt on the console for a new database password, which must be entered twice and match. + * @return the new password which must be cleared by the caller when done using it + * @throws IOException if a password entry could not be obtained + */ + private char[] requestNewPassword() throws IOException { + for (;;) { + char[] newPassword = + requestPassword("New " + connectingUserName + " (admin) DB password:"); + if (newPassword == null) { + throw new IOException("Failed to obtain password"); + } + char[] repeatPassword = requestPassword("Please re-enter DB password:"); + boolean match = + newPassword.length != 0 && comparePasswordData(newPassword, repeatPassword); + clearPasswordData(repeatPassword); + if (match) { + return newPassword; + } + boolean blank = newPassword.length == 0; + clearPasswordData(newPassword); + System.out.println(blank ? "Password may not be blank" : "Passwords do not match"); + } } /** @@ -1423,23 +3108,38 @@ public class BSimControlLaunchable implements GhidraLaunchable { initializeApplication(); switch (command) { + case COMMAND_INIT: + initCommand(); + break; case COMMAND_START: startCommand(); break; case COMMAND_STOP: stopCommand(); break; + case COMMAND_RESTART: + restartCommand(); + break; case COMMAND_STATUS: statusCommand(); break; + case COMMAND_INSTALL_SERVICE: + installServiceCommand(); + break; + case COMMAND_UNINSTALL_SERVICE: + uninstallServiceCommand(); + break; case COMMAND_ADDUSER: addUserCommand(); break; case COMMAND_DROPUSER: dropUserCommand(); break; - case COMMAND_CHANGEAUTH: - changeAuthCommand(); + case COMMAND_LISTUSERS: + listUsersCommand(); + break; + case COMMAND_CONFIGURE: + configureCommand(); break; case COMMAND_RESET_PASSWORD: passwordCommand(); @@ -1456,7 +3156,7 @@ public class BSimControlLaunchable implements GhidraLaunchable { cleanupPasswordData(); } catch (IOException e) { - e.printStackTrace(); + Msg.error(this, e.getMessage(), e); } } } @@ -1464,22 +3164,50 @@ public class BSimControlLaunchable implements GhidraLaunchable { private static void printUsage() { //@formatter:off System.err.println("\n" + - "USAGE: bsim_ctl [command] required-args... [OPTIONS...}\n\n" + - " start [--auth|-a pki|password|trust] [--noLocalAuth] [--cafile \"\"] [--dn \"\"]\n" + - " stop [--force]\n" + + "USAGE: bsim_ctl [command] required-args... [OPTIONS...]\n\n" + + " init [--keystore|-k \"\"] [--auth|-a pki|password|trust] [--noLocalAuth]\n"+ + " [--cafile|-ca \"\"] [--port|-p ] [--os-user ]\n" + + " configure [--keystore|-k \"\"] [--auth|-a pki|password|trust] [--noLocalAuth]\n" + + " [--cafile|-ca \"\"] [--port|-p ]\n" + + " start \n" + + " stop [--force|-f]\n" + + " restart [--force|-f]\n" + " status \n" + - " adduser [--dn \"\"]\n" + + " install-service (root only)\n" + + " uninstall-service (root only)\n" + + " adduser [--dn|-dn \"\"]\n" + " dropuser \n" + - " changeauth [--auth|-a pki|password|trust] [--noLocalAuth] [--cafile \"\"] [--dn \"\"]\n" + - " resetpassword \n" + - " changeprivilege admin|user\n" + - "\n" + - "Global options:\n" + - " --port|-p \n" + - " --user|-u \n" + - " --cert \n" + + " listusers \n" + + " resetpassword [--port|-p ]\n" + + " changeprivilege admin|user [--port|-p ]\n" + "\n" + - "NOTE: Options with values may also be specified using the form: --option=value\n"); + " Global options:\n" + + " --user|-u \n" + + " --cert \n" + + " --verbose|-v\n" + + "\n" + + "NOTES:\n\n" + + "1. The server must be started for the following commands to work:\n" + + " 'configure', 'adduser', 'dropuser', 'listusers', 'resetpassword', 'changeprivilege'\n\n" + + "2. Options with values may also be specified using the form: --option=value\n\n" + + "3. If the --port option is omitted or has a negative value the default PostgreSQL port 5432 will be used.\n\n" + + "4. A 'configure' change takes effect when the server is next restarted; the invoking user must be\n" + + " able to authenticate with the admin role before any authentication change is permitted.\n\n" + + "5. The --cert option is required by 'init' and 'configure' for PKI authentication; the admin user's\n" + + " distinguished name (DN) and common name (CN) are obtained from that certificate and verified\n" + + " against the --cafile certificate authorities. The --dn option is only used by 'adduser', where it\n" + + " is required when PKI authentication is used.\n\n" + + "6. The --cafile file must be an unencrypted PEM file which provides a complete chain of trust for\n" + + " every certificate authority it contains. It is required by 'init' for PKI authentication, and\n" + + " may be omitted by 'configure' to retain the authorities already installed.\n\n" + + "7. A PKI server initialized by this version of Ghidra matches each user's full certificate\n" + + " distinguished name (DN), whereas one initialized by an earlier version continues to match only\n" + + " the common name (CN) which its existing user entries were registered with. 'configure' and\n" + + " 'adduser' follow whichever the server is configured for, and 'status'/'listusers' report it.\n" + + " Specify --dn in RFC 2253 form, exactly as the certificate subject is rendered by the openssl command:\n" + + " openssl x509 -noout -subject -nameopt RFC2253 -in \n\n" + ); + //@formatter:on } @@ -1508,13 +3236,17 @@ public class BSimControlLaunchable implements GhidraLaunchable { System.err.println(e.getMessage()); } catch (GeneralSecurityException e) { - System.err.println("Error establishing server certificate"); + System.err.println("Certificate error"); System.err.println(e.getMessage()); } catch (IllegalArgumentException e) { System.err.println("Error in command line arguments"); System.err.println(e.getMessage()); } + catch (IOException e) { + System.err.println("Error in command processing"); + System.err.println(e.getMessage()); + } catch (Exception e) { System.err.println("Unexpected error"); e.printStackTrace(); diff --git a/Ghidra/Features/BSim/src/main/java/ghidra/features/bsim/query/ServerConfig.java b/Ghidra/Features/BSim/src/main/java/ghidra/features/bsim/query/ServerConfig.java index 8d81512337..1b3ad6561d 100755 --- a/Ghidra/Features/BSim/src/main/java/ghidra/features/bsim/query/ServerConfig.java +++ b/Ghidra/Features/BSim/src/main/java/ghidra/features/bsim/query/ServerConfig.java @@ -4,9 +4,9 @@ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -16,10 +16,10 @@ package ghidra.features.bsim.query; import java.io.*; +import java.util.*; import java.util.Map.Entry; -import java.util.TreeMap; -import java.util.TreeSet; +import ghidra.net.NetworkUtils; import ghidra.xml.XmlElement; import ghidra.xml.XmlPullParser; @@ -30,9 +30,16 @@ import ghidra.xml.XmlPullParser; * the identification map (pg_ident.conf) */ public class ServerConfig { - private TreeMap keyValue = new TreeMap<>(); // Values we want set in the configuration file + private TreeMap keyValue = new TreeMap<>(); // Managed values set in the configuration file + private TreeMap tunableKeyValue = new TreeMap<>(); // User-tunable performance values (init-only) private TreeSet connectSet = new TreeSet<>(); // Entries we want in the connection file + // Markers delimiting the two BSim-generated blocks within postgresql.conf + private static final String MANAGED_BEGIN = "# BSIM-MANAGED-BEGIN"; + private static final String MANAGED_END = "# BSIM-MANAGED-END"; + private static final String TUNABLE_BEGIN = "# BSIM-TUNABLE-BEGIN"; + private static final String TUNABLE_END = "# BSIM-TUNABLE-END"; + /** * Class that holds a single configuration option from the PostgreSQL configuration file */ @@ -193,15 +200,7 @@ public class ServerConfig { if (type.equals("local")) { // UNIX socket return true; } - if (address != null) { - if (address.equals("127.0.0.1/32")) { // IPv4 localhost - return true; - } - if (address.equals("::1/128")) { // IPv6 localhost - return true; - } - } - return false; + return NetworkUtils.isLoopbackAddress(address); } /** @@ -458,8 +457,14 @@ public class ServerConfig { XmlElement el = parser.start(); if (el.getName().equals("config")) { String key = el.getAttribute("key"); + boolean tunable = "true".equalsIgnoreCase(el.getAttribute("tunable")); String val = parser.end().getText(); - keyValue.put(key, val); + if (tunable) { + tunableKeyValue.put(key, val); + } + else { + keyValue.put(key, val); + } } else if (el.getName().equals("connect")) { ConnectLine connLine = new ConnectLine(); @@ -554,6 +559,94 @@ public class ServerConfig { } } + /** + * Generate or update postgresql.conf using two clearly-delimited blocks appended after the + * pristine initdb configuration (later settings override earlier ones in postgresql.conf): + *

    + *
  • a managed block ({@link #MANAGED_BEGIN}..{@link #MANAGED_END}) holding the + * BSim-controlled settings (SSL, interface binding, authentication-related, logging) which is + * regenerated on every reconfigure and must not be hand-edited; and
  • + *
  • a tunable block ({@link #TUNABLE_BEGIN}..{@link #TUNABLE_END}) holding the + * performance-tuning settings, written once at initialization and preserved thereafter, which + * an administrator may edit.
  • + *
+ * At initialization the file is generated from the initdb original with both blocks appended. + * On reconfigure only the managed block is regenerated in place; the tunable block and any + * other edits are preserved. The entire input is read before writing, so {@code inFile} and + * {@code outFile} may be the same path. + * @param inFile the input configuration file (the initdb original at init; the current + * postgresql.conf at reconfigure) + * @param outFile the configuration file to write (may be the same path as inFile) + * @param initialize true to also (re)write the tunable block from the template defaults + * @throws IOException if the file cannot be read or written + */ + public void writePostgresConfig(File inFile, File outFile, boolean initialize) + throws IOException { + + List lines = new ArrayList<>(); + try (BufferedReader reader = new BufferedReader(new FileReader(inFile))) { + String line; + while ((line = reader.readLine()) != null) { + lines.add(line); + } + } + + List out = new ArrayList<>(); + boolean managedReplaced = false; + for (int i = 0; i < lines.size(); ++i) { + String line = lines.get(i); + if (line.trim().equals(MANAGED_BEGIN)) { + appendManagedBlock(out); // regenerate managed block in place + managedReplaced = true; + while (i < lines.size() && !lines.get(i).trim().equals(MANAGED_END)) { + ++i; // skip old managed block through MANAGED_END + } + continue; + } + out.add(line); + } + if (!managedReplaced) { + out.add(""); + appendManagedBlock(out); + } + if (initialize) { + out.add(""); + appendTunableBlock(out); + } + + try (FileWriter writer = new FileWriter(outFile)) { + for (String line : out) { + writer.write(line); + writer.write('\n'); + } + } + } + + private void appendManagedBlock(List out) { + out.add(MANAGED_BEGIN); + out.add("# " + "=".repeat(76)); + out.add("# BSim-managed settings - DO NOT EDIT."); + out.add("# This block is regenerated by 'bsim_ctl configure'; edits here are lost."); + out.add("# " + "=".repeat(76)); + for (Entry entry : keyValue.entrySet()) { + out.add(entry.getKey() + " = " + entry.getValue()); + } + out.add(MANAGED_END); + } + + private void appendTunableBlock(List out) { + out.add(TUNABLE_BEGIN); + out.add("# " + "=".repeat(76)); + out.add("# BSim performance tuning - SAFE TO EDIT."); + out.add("# Written once at 'init' and preserved by 'configure'. Adjust these values for"); + out.add("# your hardware/workload, then restart the server for changes to take effect."); + out.add("# " + "=".repeat(76)); + for (Entry entry : tunableKeyValue.entrySet()) { + out.add(entry.getKey() + " = " + entry.getValue()); + } + out.add(TUNABLE_END); + } + /** * Read in a connection file and write out an altered version of the file where: * 1) Any entry that matches something in connectSet, has its authentication method altered @@ -658,6 +751,34 @@ public class ServerConfig { } } + /** + * Read the identity mappings which are registered within a specific map of pg_ident.conf. + * @param inFile is a copy of pg_ident.conf to read + * @param mapName is the map whose entries are to be returned + * @return the system names (map from) registered for each database role (map to), in the order + * they appear within the file. PostgreSQL permits a role to be mapped from more than one + * system name, so each role is given all of its entries. + * @throws IOException if the file cannot be read or parsed + */ + public static Map> scanIdent(File inFile, String mapName) + throws IOException { + Map> identMap = new LinkedHashMap<>(); + try (BufferedReader reader = new BufferedReader(new FileReader(inFile))) { + for (;;) { + String line = reader.readLine(); + if (line == null) { + break; // End of file reached + } + IdentLine identLine = new IdentLine(); + if (identLine.parse(line) && identLine.mapName.equals(mapName)) { + identMap.computeIfAbsent(identLine.roleName, role -> new ArrayList<>()) + .add(identLine.systemName); + } + } + } + return identMap; + } + /** * Add a key/value pair directly into the configuration file * @param key the key to add/update @@ -752,6 +873,20 @@ public class ServerConfig { return null; } + /** + * @return the authentication method options of the local (loopback) connection entry whose + * method is returned by {@link #getLocalAuthentication()}, or null if there is no such entry + * or it specifies no options + */ + public String getLocalAuthenticationOptions() { + for (ConnectLine connLine : connectSet) { + if (connLine.isLocal()) { + return connLine.options; + } + } + return null; + } + public void setLocalAuthentication(String val, String options) { for (ConnectLine connLine : connectSet) { if (connLine.isLocal()) { @@ -770,6 +905,20 @@ public class ServerConfig { return null; } + /** + * @return the authentication method options of the remote (non-loopback) connection entry whose + * method is returned by {@link #getHostAuthentication()}, or null if there is no such entry + * (remote access disabled) or it specifies no options + */ + public String getHostAuthenticationOptions() { + for (ConnectLine connLine : connectSet) { + if (connLine.type.equals("hostssl") && !connLine.isLocal()) { + return connLine.options; + } + } + return null; + } + public void setHostAuthentication(String val, String options) { for (ConnectLine connLine : connectSet) { if (connLine.type.equals("hostssl") && !connLine.isLocal()) { @@ -778,4 +927,12 @@ public class ServerConfig { } } } + + /** + * Remove the remote (non-loopback) hostssl connection entry so that only loopback access + * remains. Used when no server certificate key store is configured. + */ + public void removeHostAuthentication() { + connectSet.removeIf(connLine -> connLine.type.equals("hostssl") && !connLine.isLocal()); + } } diff --git a/Ghidra/Features/BSim/src/main/java/ghidra/features/bsim/query/client/PostgresFunctionDatabase.java b/Ghidra/Features/BSim/src/main/java/ghidra/features/bsim/query/client/PostgresFunctionDatabase.java index 2a4e4b2686..b016df551b 100755 --- a/Ghidra/Features/BSim/src/main/java/ghidra/features/bsim/query/client/PostgresFunctionDatabase.java +++ b/Ghidra/Features/BSim/src/main/java/ghidra/features/bsim/query/client/PostgresFunctionDatabase.java @@ -16,6 +16,7 @@ package ghidra.features.bsim.query.client; import java.io.IOException; +import java.net.URI; import java.net.URL; import java.sql.*; import java.util.*; @@ -302,7 +303,92 @@ public final class PostgresFunctionDatabase } /** - * + * Enumerate all PostgreSQL databases hosted on the server identified by the given URL which + * appear to be BSim databases. A database is considered a BSim database if its {@code public} + * schema contains a few key BSim tables (such as {@code vectable} and {@code archtable}). The + * connection details (host, port, and any user information) are taken from the URL; any path + * (database name) element is ignored since the server-wide {@code postgres} database is used to + * enumerate candidate databases. + * + * @param uri host URL identifying the PostgreSQL server to query + * @param connectingUserName default user name to use when the URL does not specify one + * (may be {@code null}) + * @return a list of BSim databases found on the server, as {@link BSimServerInfo} objects + * @throws SQLException if there is a problem communicating with the server + */ + public static List getBSimServerInfos(URI uri, String connectingUserName) + throws SQLException { + + String userInfo = uri.getUserInfo(); + if ((userInfo == null || userInfo.isBlank()) && connectingUserName != null && + !connectingUserName.isBlank()) { + userInfo = connectingUserName; + } + + BSimServerInfo defaultServerInfo = new BSimServerInfo(DBType.postgres, userInfo, + uri.getHost(), uri.getPort(), DEFAULT_DATABASE_NAME); + BSimPostgresDataSource defaultDs = + BSimPostgresDBConnectionManager.getDataSource(defaultServerInfo); + + // Enumerate all candidate databases from the default 'postgres' database + List candidateNames = new ArrayList<>(); + try (Connection c = defaultDs.getConnection(); Statement st = c.createStatement()) { + try (ResultSet rs = st.executeQuery("SELECT datname FROM pg_database " + + "WHERE datistemplate = false AND datallowconn = true ORDER BY datname")) { + while (rs.next()) { + String name = rs.getString(1); + if (!DEFAULT_DATABASE_NAME.equals(name)) { + candidateNames.add(name); + } + } + } + } + + // Inspect each candidate's schema for the key BSim tables + List bsimDatabases = new ArrayList<>(); + for (String dbName : candidateNames) { + BSimServerInfo candidateInfo = new BSimServerInfo(DBType.postgres, + defaultServerInfo.getUserInfo(), defaultServerInfo.getServerName(), + defaultServerInfo.getPort(), dbName); + BSimPostgresDataSource candidateDs = + BSimPostgresDBConnectionManager.getDataSource(candidateInfo); + // Reuse credentials already established with the default database (if applicable) + candidateDs.initializeFrom(defaultDs); + try (Connection c = candidateDs.getConnection(); Statement st = c.createStatement()) { + if (isBSimDatabaseSchema(st)) { + bsimDatabases.add(candidateInfo); + } + } + catch (SQLException e) { + // Unable to inspect candidate (e.g., access restricted) - skip it + Msg.debug(PostgresFunctionDatabase.class, + "Skipping database '" + dbName + "': " + e.getMessage()); + } + } + return bsimDatabases; + } + + /** + * Spot check the {@code public} schema reachable via the given statement for a few key BSim + * table names that always exist within a BSim database. + * @param st an active statement on the database to inspect + * @return true if the database appears to be a BSim database + * @throws SQLException if there is a problem executing the query + */ + private static boolean isBSimDatabaseSchema(Statement st) throws SQLException { + Set tableNames = new HashSet<>(); + try (ResultSet rs = st.executeQuery("SELECT table_name FROM information_schema.tables " + + "WHERE table_schema = 'public'")) { + while (rs.next()) { + tableNames.add(rs.getString(1)); + } + } + return tableNames.contains("vectable") && tableNames.contains("archtable") && + tableNames.contains("keyvaluetable") && tableNames.contains("desctable"); + } + + /** + * * @throws SQLException if there is a problem creating or executing the query */ private void dropIndex(Connection c) throws SQLException { diff --git a/Ghidra/Features/BSim/src/main/java/ghidra/features/bsim/query/elastic/ElasticConnection.java b/Ghidra/Features/BSim/src/main/java/ghidra/features/bsim/query/elastic/ElasticConnection.java index 0d0acabbb5..7b8f30a527 100755 --- a/Ghidra/Features/BSim/src/main/java/ghidra/features/bsim/query/elastic/ElasticConnection.java +++ b/Ghidra/Features/BSim/src/main/java/ghidra/features/bsim/query/elastic/ElasticConnection.java @@ -415,4 +415,39 @@ public class ElasticConnection { } } } + + /** + * Send a body-less request to the server that is not specific to any repository. + * Intended for general server queries such as index enumeration (e.g., {@code /_aliases}). + * @param command is the type of command + * @param path is the specific URL path (relative to the host) receiving the command + * @return the response as parsed JsonObject + * @throws ElasticException for any problems with the connection + */ + public JsonObject executeRawURIOnly(String command, String path) throws ElasticException { + HttpURLConnection connection = null; + try { + URL httpURL = new URL(hostURL + path); + connection = (HttpURLConnection) httpURL.openConnection(); + connection.setRequestMethod(command); + connection.setDoOutput(true); + lastResponseCode = connection.getResponseCode(); + JsonObject resp = grabResponse(connection); + if (!lastRequestSuccessful()) { + throw new ElasticException(parseErrorJSON(resp)); + } + return resp; + } + catch (IOException e) { + throw new ElasticException("Error sending request: " + e.getMessage()); + } + catch (JsonParseException e) { + throw new ElasticException("Error parsing response: " + e.getMessage()); + } + finally { + if (connection != null) { + connection.disconnect(); + } + } + } } diff --git a/Ghidra/Features/BSim/src/main/java/ghidra/features/bsim/query/elastic/ElasticDatabase.java b/Ghidra/Features/BSim/src/main/java/ghidra/features/bsim/query/elastic/ElasticDatabase.java index 3fbcfe89ae..0e1512cab4 100755 --- a/Ghidra/Features/BSim/src/main/java/ghidra/features/bsim/query/elastic/ElasticDatabase.java +++ b/Ghidra/Features/BSim/src/main/java/ghidra/features/bsim/query/elastic/ElasticDatabase.java @@ -18,6 +18,7 @@ package ghidra.features.bsim.query.elastic; import java.io.IOException; import java.io.StringReader; import java.net.MalformedURLException; +import java.net.URI; import java.net.URL; import java.util.*; import java.util.Map.Entry; @@ -2085,6 +2086,79 @@ public class ElasticDatabase implements FunctionDatabase { return initialized; } + /** + * Enumerate all Elasticsearch repositories (BSim "named indexes") hosted on the server + * identified by the given URL which appear to be BSim databases. A repository is considered a + * BSim database if it has both the {@code _vector} and + * {@code _executable} indexes. The connection details (host, port, and any user + * information) are taken from the URL; any path (repository name) element is ignored. + * + * @param uri host URL identifying the Elasticsearch server to query + * @param connectingUserName default user name to use when the URL does not specify one + * (may be {@code null}) + * @return a list of BSim databases found on the server, as {@link BSimServerInfo} objects + * @throws ElasticException if there is a problem communicating with the server + */ + public static List getBSimServerInfos(URI uri, String connectingUserName) + throws ElasticException { + + String userInfo = uri.getUserInfo(); + if ((userInfo == null || userInfo.isBlank()) && connectingUserName != null && + !connectingUserName.isBlank()) { + userInfo = connectingUserName; + } + + String host = uri.getHost(); + int port = uri.getPort(); + int effectivePort = port > 0 ? port : BSimServerInfo.DEFAULT_ELASTIC_PORT; + String baseURL = "https://" + host + ":" + effectivePort; + + List bsimDatabases = new ArrayList<>(); + for (String repository : getBSimRepositoryNames(baseURL)) { + bsimDatabases.add(new BSimServerInfo(DBType.elastic, userInfo, host, port, repository)); + } + return bsimDatabases; + } + + /** + * Enumerate the names of all Elasticsearch repositories (BSim "named indexes") hosted on the + * given server which appear to be BSim databases. A repository is considered a BSim database + * if it has both the {@code _vector} and {@code _executable} indexes. + * No specific repository is contacted; only the server-wide index listing is examined. + * + * @param baseURL the base server URL (e.g. {@code https://hostname:9200}) without any repository + * path element + * @return a sorted list of BSim repository (named index) names found on the server + * @throws ElasticException if there is a problem communicating with the server + */ + private static List getBSimRepositoryNames(String baseURL) throws ElasticException { + ElasticConnection connection = new ElasticConnection(baseURL, ""); + JsonObject resp = connection.executeRawURIOnly(ElasticConnection.GET, "/_aliases"); + + Set indexNames = new HashSet<>(); + for (String key : resp.keySet()) { + indexNames.add(key); + } + + String vectorSuffix = "_vector"; + List repositories = new ArrayList<>(); + for (String indexName : indexNames) { + if (!indexName.endsWith(vectorSuffix)) { + continue; + } + String repository = indexName.substring(0, indexName.length() - vectorSuffix.length()); + if (repository.isEmpty()) { + continue; + } + // Spot check for a companion BSim index that always exists alongside the vector index + if (indexNames.contains(repository + "_executable")) { + repositories.add(repository); + } + } + Collections.sort(repositories); + return repositories; + } + /** * Read database configuration ("keyvalue" documents) into a key/value pair map. * @return the populated map diff --git a/Ghidra/Features/BSim/src/main/java/ghidra/features/bsim/query/ingest/BSimLaunchable.java b/Ghidra/Features/BSim/src/main/java/ghidra/features/bsim/query/ingest/BSimLaunchable.java index e020db8496..80ce6d6a8a 100644 --- a/Ghidra/Features/BSim/src/main/java/ghidra/features/bsim/query/ingest/BSimLaunchable.java +++ b/Ghidra/Features/BSim/src/main/java/ghidra/features/bsim/query/ingest/BSimLaunchable.java @@ -17,6 +17,7 @@ package ghidra.features.bsim.query.ingest; import java.io.*; import java.net.*; +import java.sql.SQLException; import java.util.*; import org.apache.commons.lang3.StringUtils; @@ -25,7 +26,11 @@ import org.xml.sax.SAXException; import ghidra.GhidraApplicationLayout; import ghidra.GhidraLaunchable; import ghidra.features.bsim.query.*; +import ghidra.features.bsim.query.BSimServerInfo.DBType; +import ghidra.features.bsim.query.client.PostgresFunctionDatabase; import ghidra.features.bsim.query.description.ExecutableRecord; +import ghidra.features.bsim.query.elastic.ElasticDatabase; +import ghidra.features.bsim.query.elastic.ElasticException; import ghidra.features.bsim.query.protocol.ExeSpecifier; import ghidra.features.bsim.query.protocol.QueryName; import ghidra.framework.*; @@ -72,8 +77,10 @@ public class BSimLaunchable implements GhidraLaunchable { private static final String COMMAND_DELETE = defineCommand("delete"); private static final String COMMAND_LIST_FUNCTIONS = defineCommand("listfuncs"); private static final String COMMAND_LIST_EXES = defineCommand("listexes"); + private static final String COMMAND_LIST_DATABASES = defineCommand("listdatabases"); private static final String COMMAND_GET_EXE_COUNT = defineCommand("getexecount"); private static final String COMMAND_DUMP_SIGS = defineCommand("dumpsigs"); + private static final String COMMAND_CHANGE_PASSWORD = defineCommand("changepassword"); private static Set COMMANDS_WITH_REPO_ACCESS = Set.of(COMMAND_GENERATE_SIGS, COMMAND_GENERATE_UPDATES); @@ -121,6 +128,7 @@ public class BSimLaunchable implements GhidraLaunchable { SHORTCUT_OPTION_MAP.put("-b", BSIM_URL_OPTION); SHORTCUT_OPTION_MAP.put("-c", CONFIG_OPTION); SHORTCUT_OPTION_MAP.put("-d", DESCRIPTION_OPTION); + SHORTCUT_OPTION_MAP.put("-f", DROP_DATABASE_FORCE_OPTION); SHORTCUT_OPTION_MAP.put("-l", LIMIT_OPTION); SHORTCUT_OPTION_MAP.put("-m", MD5_OPTION); SHORTCUT_OPTION_MAP.put("-n", NAME_OPTION); @@ -130,7 +138,7 @@ public class BSimLaunchable implements GhidraLaunchable { //SHORTCUT_OPTION_MAP.put("", OVERRIDE_OPTION); //SHORTCUT_OPTION_MAP.put("", MAX_FUNC_OPTION); //SHORTCUT_OPTION_MAP.put("", COMPILER_OPTION); - //SHORTCUT_OPTION_MAP.put("", CERT_OPTION); + // NOTE: CERT_OPTION intentionally has no shortcut ("-c" is used by CONFIG_OPTION) } //@formatter:off @@ -161,8 +169,10 @@ public class BSimLaunchable implements GhidraLaunchable { Set.of(MD5_OPTION, NAME_OPTION, ARCH_OPTION, COMPILER_OPTION, PRINT_SELF_SIGNIFICANCE_OPTION, CALL_GRAPH_OPTION, PRINT_JUST_EXE_OPTION, MAX_FUNC_OPTION); private static final Set GET_EXECUTABLES_OPTIONS = Set.of(MD5_OPTION, NAME_OPTION, ARCH_OPTION, COMPILER_OPTION, SORT_COL_OPTION, LIMIT_OPTION, INCLUDE_LIBS_OPTION); - private static final Set GET_EXECUTABLES_COUNT_OPTIONS = + private static final Set GET_EXECUTABLES_COUNT_OPTIONS = Set.of(MD5_OPTION, NAME_OPTION, ARCH_OPTION, COMPILER_OPTION, INCLUDE_LIBS_OPTION); + private static final Set LIST_DATABASES_OPTIONS = Set.of(); // global options only + private static final Set CHANGE_PASSWORD_OPTIONS = Set.of(); // global options only //@formatter:on private static final Map> ALLOWED_OPTION_MAP = new HashMap<>(); @@ -183,8 +193,10 @@ public class BSimLaunchable implements GhidraLaunchable { ALLOWED_OPTION_MAP.put(COMMAND_DELETE, DELETE_OPTIONS); ALLOWED_OPTION_MAP.put(COMMAND_LIST_FUNCTIONS, LIST_FUNCTIONS_OPTIONS); ALLOWED_OPTION_MAP.put(COMMAND_LIST_EXES, GET_EXECUTABLES_OPTIONS); + ALLOWED_OPTION_MAP.put(COMMAND_LIST_DATABASES, LIST_DATABASES_OPTIONS); ALLOWED_OPTION_MAP.put(COMMAND_GET_EXE_COUNT, GET_EXECUTABLES_COUNT_OPTIONS); ALLOWED_OPTION_MAP.put(COMMAND_DUMP_SIGS, DUMP_SIGS_OPTIONS); + ALLOWED_OPTION_MAP.put(COMMAND_CHANGE_PASSWORD, CHANGE_PASSWORD_OPTIONS); } private URL ghidraURL; @@ -480,6 +492,11 @@ public class BSimLaunchable implements GhidraLaunchable { bsimURL = BSimClientFactory.deriveBSimURL(urlstring); doListExes(subParams); } + else if (COMMAND_LIST_DATABASES.equals(command)) { + // NOTE: URL parsing is handled within doListDatabases since a host-only URL + // (no database name) is permitted for the postgresql and elasticsearch protocols. + doListDatabases(urlstring, subParams); + } else if (COMMAND_GET_EXE_COUNT.equals(command)) { bsimURL = BSimClientFactory.deriveBSimURL(urlstring); doGetCount(subParams); @@ -488,6 +505,10 @@ public class BSimLaunchable implements GhidraLaunchable { bsimURL = BSimClientFactory.deriveBSimURL(urlstring); doDumpSigs(subParams); } + else if (COMMAND_CHANGE_PASSWORD.equals(command)) { + bsimURL = BSimClientFactory.deriveBSimURL(urlstring); + doChangePassword(subParams); + } else { throw new IllegalArgumentException("Unknown command: " + command); } @@ -863,9 +884,89 @@ public class BSimLaunchable implements GhidraLaunchable { } } + /** + * Display the BSim databases discovered for a given BSim URL. For the postgresql and + * elasticsearch (elastic/https) protocols the URL may specify only a host, in which case all + * databases/named-indexes on that host which appear to be BSim databases are listed. If the + * URL includes a specific database name, only that database is listed. An H2 (file) URL must + * always specify a specific database. For each discovered database the creation-time details + * (see {@code createdatabase}) are displayed. + * + * @param urlstring the BSim URL, which may be host-only for postgresql/elasticsearch + * @param params the command-line params (none expected) + * @throws IOException if there's an error establishing a connection or querying the server + */ + private void doListDatabases(String urlstring, List params) throws IOException { + if (!params.isEmpty()) { + throw new IllegalArgumentException("Unexpected parameter: " + params.get(0)); + } + + URI uri; + try { + uri = new URI(urlstring); + } + catch (URISyntaxException e) { + throw new MalformedURLException("Invalid BSim URL: " + urlstring); + } + + // If the URL specifies a database name (path element) simply list that database. For the + // postgresql and elasticsearch (elastic/https) protocols a host-only URL is also permitted, + // in which case all BSim databases hosted by the server are enumerated. An H2 (file) URL + // must always specify a specific database. See the "BSim Database URLs" section of the + // command-line reference for details. + String path = uri.getPath(); + if (!StringUtils.isBlank(path) && !path.equals("/")) { + listSingleDatabase(new BSimServerInfo(uri.toURL())); + return; + } + + String connectingUserName = optionValueMap.get(USER_OPTION); + String protocol = uri.getScheme(); + List databases; + try { + if ("postgresql".equals(protocol)) { + databases = PostgresFunctionDatabase.getBSimServerInfos(uri, connectingUserName); + } + else if ("elastic".equals(protocol) || "https".equals(protocol)) { + databases = ElasticDatabase.getBSimServerInfos(uri, connectingUserName); + } + else { + throw new IllegalArgumentException( + "A specific database must be specified within the BSim URL for protocol: " + + protocol); + } + } + catch (SQLException | ElasticException e) { + throw new IOException("Failed to enumerate BSim databases: " + e.getMessage()); + } + + Msg.info(this, "Found " + databases.size() + " BSim database(s) on " + uri.getHost()); + for (BSimServerInfo serverInfo : databases) { + listSingleDatabase(serverInfo); + } + } + + /** + * Connect to a single BSim database and display its creation-time details. Any failure to + * connect or read the database (e.g., it is not a BSim database or access is restricted) is + * reported as a warning so that enumeration of remaining databases can continue. + * + * @param serverInfo the BSim server info identifying the database + */ + private void listSingleDatabase(BSimServerInfo serverInfo) { + String connectingUserName = optionValueMap.get(USER_OPTION); + try (BulkSignatures bsim = new BulkSignatures(serverInfo, connectingUserName)) { + bsim.printDatabaseInfo(); + } + catch (IOException e) { + Msg.warn(this, "Unable to read BSim database " + serverInfo.getShortDBName() + ": " + + e.getMessage()); + } + } + /** * Print the number of records in the database that match the filter criteria. - * + * * @param params the command-line params * @throws IOException if there's an error establishing the database connection * @throws LSHException if there's an error issuing the query @@ -1000,6 +1101,109 @@ public class BSimLaunchable implements GhidraLaunchable { } } + /** + * Change the password of the user which is used to establish the BSim database connection. + * The user must first successfully authenticate with the server using their current + * credentials, since the password change is issued over the resulting connection. Only the + * {@link DBType#postgres postgresql} and {@link DBType#elastic elastic}/https database types + * support a password change. The new password is prompted for twice on the console and must + * match. + * + * @param params the command-line params (none expected) + * @throws IOException if there's an error establishing the database connection, obtaining the + * new password, or if the password change is rejected by the server + */ + private void doChangePassword(List params) throws IOException { + if (!params.isEmpty()) { + throw new IllegalArgumentException("Unexpected parameter: " + params.get(0)); + } + + BSimServerInfo serverInfo = getServerInfoWithUserOption(new BSimServerInfo(bsimURL)); + + DBType dbType = serverInfo.getDBType(); + if (dbType != DBType.postgres && dbType != DBType.elastic) { + throw new IllegalArgumentException( + "Password change not supported for BSim DB type: " + dbType); + } + + try (FunctionDatabase db = BSimClientFactory.buildClient(bsimURL, true)) { + if (!db.initialize()) { + throw new IOException(db.getLastError().message); + } + if (!db.isPasswordChangeAllowed()) { + throw new IOException("Password change not supported by BSim DB: " + serverInfo); + } + + String userName = db.getUserName(); + char[] newPassword = requestNewPassword(userName); + try { + String errorMessage = db.changePassword(newPassword); + if (errorMessage != null) { + throw new IOException("Password change failed: " + errorMessage); + } + } + finally { + Arrays.fill(newPassword, '\0'); + } + Msg.info(this, "Password changed for user '" + userName + "' on " + serverInfo); + } + } + + /** + * Apply the global {@code --user} option to the specified BSim server info. If the BSim URL + * already stipulates a user name the option is ignored with a warning, mirroring the behavior + * of {@link BulkSignatures}. + * + * @param serverInfo BSim server info derived from the specified BSim URL + * @return server info which reflects the connecting user name to be used + */ + private BSimServerInfo getServerInfoWithUserOption(BSimServerInfo serverInfo) { + String connectingUserName = optionValueMap.get(USER_OPTION); + if (StringUtils.isBlank(connectingUserName)) { + return serverInfo; + } + if (!serverInfo.hasDefaultLogin()) { + String userName = serverInfo.getUserName(); + if (!userName.equals(connectingUserName)) { + Msg.warn(this, "BSim DB server info specifies user '" + userName + + "'. Ignoring user name option: '" + connectingUserName + "'"); + } + return serverInfo; + } + return new BSimServerInfo(serverInfo.getDBType(), connectingUserName, + serverInfo.getServerName(), serverInfo.getPort(), serverInfo.getDBName()); + } + + /** + * Prompt the user on the console for a new database password. The password must be entered + * twice and both entries must match, otherwise the user is prompted again. + * + * @param userName name of the user whose password is being changed + * @return the new password which must be cleared by the caller when done using it + * @throws IOException if a password entry could not be obtained + */ + private char[] requestNewPassword(String userName) throws IOException { + for (;;) { + char[] newPassword = HeadlessClientAuthenticator.getPassword(null, + "New BSim DB password for user '" + userName + "':"); + if (newPassword == null) { + throw new IOException("Failed to obtain new password"); + } + char[] repeatPassword = + HeadlessClientAuthenticator.getPassword(null, "Please re-enter new DB password:"); + boolean match = newPassword.length != 0 && Arrays.equals(newPassword, repeatPassword); + if (repeatPassword != null) { + Arrays.fill(repeatPassword, '\0'); + } + if (match) { + return newPassword; + } + Arrays.fill(newPassword, '\0'); + System.out.println( + newPassword.length == 0 ? "Password may not be blank" : "Passwords do not match"); + } + } + private static void printMaxMemory() { // division is used since default case may not use even multiples of 1024 long maxMemoryBytes = Runtime.getRuntime().maxMemory(); @@ -1022,7 +1226,8 @@ public class BSimLaunchable implements GhidraLaunchable { System.err.println("\n" + "USAGE: bsim [command] required-args... [OPTIONS...]\n" + " createdatabase [--name|-n \"\"] [--owner|-o \"\"] [--description|-d \"\"] [--nocallgraph]\n" + - " dropdatabase [--force]\n" + + " dropdatabase [--force|-f]\n" + + " listdatabases \n" + " setmetadata [--name|-n \"\"] [--owner|-o \"\"] [--description|-d \"\"]\n" + " getmetadata \n" + " addexecategory [--date]\n" + @@ -1038,11 +1243,12 @@ public class BSimLaunchable implements GhidraLaunchable { " generateupdates --bsim|-b [--commit] [--overwrite]\n" + " generateupdates --bsim|-b \n" + " commitupdates \n" + - " listexes [--md5|-m ] [--name|-n ] [--arch|-a ] [--compiler ] [--sortcol|-s md5|name] [--limit|-l ] [--includelibs]\n" + - " getexecount [--md5|-m ] [--name|-n ] [--arch|-a ] [--compiler ] [--includelibs]\n" + + " listexes [--md5|-m ] [--name|-n ] [--arch|-a ] [--compiler ] [--sortcol|-s md5|name] [--limit|-l ] [--includelibs]\n" + + " getexecount [--md5|-m ] [--name|-n ] [--arch|-a ] [--compiler ] [--includelibs]\n" + " delete [--md5|-m ] [--name|-n [--arch|-a ] [--compiler ]]\n" + " listfuncs [--md5|-m ] [--name|-n [--arch|-a ] [--compiler ]] [--printselfsig] [--callgraph] [--printjustexe] [--maxfunc ]\n" + - " dumpsigs [--md5|-m ] [--name|-n [--arch|-a ] [--compiler ]]\n" + + " dumpsigs [--md5|-m ] [--name|-n [--arch|-a ] [--compiler ]]\n" + + " changepassword \n" + "\n" + "Global options:\n" + " --user|-u \n" + @@ -1057,6 +1263,16 @@ public class BSimLaunchable implements GhidraLaunchable { " https://[username@][:]/\n" + " file:/[/]\n" + "\n" + + " NOTE: For the 'listdatabases' command the '/' is optional for the postgresql,\n" + + " elastic, and https URL forms. When omitted, all BSim databases hosted by the\n" + + " server are listed; when dbname is supplied, only the named database is listed.\n" + + " A file (H2) URL must always specify a .\n" + + "\n" + + " NOTE: The 'changepassword' command is only supported for the postgresql, elastic and\n" + + " https URL forms and requires that the server be configured for password\n" + + " authentication. The password changed is that of the connecting user (see the\n" + + " --user option) and the new password is prompted for on the console.\n" + + "\n" + "Ghidra URL Forms (ghidraURL):\n" + " ghidra://[:]/[/]\n" + " ghidra:/[/][?/]\n" + diff --git a/Ghidra/Features/BSim/src/main/java/ghidra/features/bsim/query/ingest/BulkSignatures.java b/Ghidra/Features/BSim/src/main/java/ghidra/features/bsim/query/ingest/BulkSignatures.java index ea8944721c..97792792d6 100755 --- a/Ghidra/Features/BSim/src/main/java/ghidra/features/bsim/query/ingest/BulkSignatures.java +++ b/Ghidra/Features/BSim/src/main/java/ghidra/features/bsim/query/ingest/BulkSignatures.java @@ -782,7 +782,7 @@ public class BulkSignatures implements AutoCloseable { /** * Prints the metadata. - * + * * @throws IOException if there's an error establishing the database connection */ public void printMetadata() throws IOException { @@ -793,6 +793,45 @@ public class BulkSignatures implements AutoCloseable { Msg.info(this, " Description: " + info.description); } + /** + * Connect to the BSim database identified by this instance's server info and print the + * database information which was originally specified at creation time (see + * {@code bsim createdatabase}). This is intended for use by command-line clients listing + * BSim databases. + * + * @throws IOException if there's an error establishing the database connection or the + * referenced database does not appear to be a BSim database + */ + public void printDatabaseInfo() throws IOException { + DatabaseInformation info = establishQueryServerConnection(false); + Msg.info(this, formatDatabaseInfo(bsimServerInfo, info)); + } + + /** + * Format the creation-time details of a BSim database for display. + * + * @param serverInfo the BSim server info identifying the database + * @param info the database information + * @return a formatted multi-line description + */ + private String formatDatabaseInfo(BSimServerInfo serverInfo, DatabaseInformation info) { + // TODO: Verify / consolidate with printMetadata above + StringBuilder buf = new StringBuilder(); + buf.append("BSim Database: "); + buf.append(serverInfo.getShortDBName()); + buf.append("\n"); + buf.append(" Name: "); + buf.append(info.databasename); + buf.append("\n"); + buf.append(" Owner: "); + buf.append(info.owner); + buf.append("\n"); + buf.append(" Description: "); + buf.append(info.description); + buf.append("\n"); + return buf.toString(); + } + /** * Performs the work of installing a new category name. This will build the query * object, establish the database connection, and perform the query. diff --git a/Ghidra/Features/BSim/src/main/java/ghidra/features/bsim/query/ingest/HeadlessBSimApplicationConfiguration.java b/Ghidra/Features/BSim/src/main/java/ghidra/features/bsim/query/ingest/HeadlessBSimApplicationConfiguration.java index bcd7312d7f..ea10155fb4 100644 --- a/Ghidra/Features/BSim/src/main/java/ghidra/features/bsim/query/ingest/HeadlessBSimApplicationConfiguration.java +++ b/Ghidra/Features/BSim/src/main/java/ghidra/features/bsim/query/ingest/HeadlessBSimApplicationConfiguration.java @@ -18,6 +18,8 @@ package ghidra.features.bsim.query.ingest; import java.io.File; import java.util.List; +import org.apache.commons.lang3.StringUtils; + import generic.jar.ResourceFile; import ghidra.framework.*; import ghidra.framework.remote.GhidraObjectInputFilter; @@ -50,10 +52,17 @@ public class HeadlessBSimApplicationConfiguration extends ApplicationConfigurati } /** - * Locate certs file within the Ghidra root directory. If found this will be used - * for initializing the ApplicationTrustManager used for SSL/PKI. + * Locate 'cacerts' file within the Ghidra root directory if 'ghidra.cacerts' property has not + * already been specified. If found this will be used to establish the property which will be + * used by {@link DefaultTrustManagerFactory}. */ private void locateCACertsFile() { + + String cacertsPath = System.getProperty(DefaultTrustManagerFactory.GHIDRA_CACERTS_PATH_PROPERTY); + if (!StringUtils.isBlank(cacertsPath)) { + return; // property will be used by DefaultTrustManagerFactory + } + for (ResourceFile appRoot : Application.getApplicationRootDirectories()) { File cacertsFile = new File(appRoot.getAbsolutePath(), "cacerts"); if (cacertsFile.isFile()) { diff --git a/Ghidra/Features/BSim/support/make-postgres.sh b/Ghidra/Features/BSim/support/make-postgres.sh index d20c35dd6d..6a2646a2fb 100755 --- a/Ghidra/Features/BSim/support/make-postgres.sh +++ b/Ghidra/Features/BSim/support/make-postgres.sh @@ -50,8 +50,8 @@ POSTGRES=postgresql-15.18 POSTGRES_GZ=${POSTGRES}.tar.gz POSTGRES_CONFIG_OPTIONS="--disable-rpath --with-openssl" -DIR=$(cd `dirname $0`; pwd)/.. -echo $DIR +DIR="$(cd "$(dirname "$0")" && pwd)/.." +echo "$DIR" POSTGRES_GZ_PATH=${DIR}/../../../../ghidra.bin/Ghidra/Features/BSim/${POSTGRES_GZ} if [ ! -f "${POSTGRES_GZ_PATH}" ]; then @@ -68,14 +68,14 @@ fi OS=`uname -s` ARCH=`uname -m` -cd ${DIR} +cd "${DIR}" mkdir -p build > /dev/null if [ ! -d build/${POSTGRES} ]; then # Unpack postgres source distro into build echo "Unpacking postgresql source: ${POSTGRES_GZ_PATH}" - $(cd build; tar -xzf ${POSTGRES_GZ_PATH} ) + (cd build && tar -xzf "${POSTGRES_GZ_PATH}") fi # Build postgresql @@ -106,14 +106,14 @@ fi echo "Platform: $OSDIR" # Install within build/os -INSTALL_DIR=${DIR}/build/os/${OSDIR}/postgresql -rm -rf ${INSTALL_DIR} > /dev/null +INSTALL_DIR="${DIR}/build/os/${OSDIR}/postgresql" +rm -rf "${INSTALL_DIR}" > /dev/null make distclean # Configure postgres -./configure ${POSTGRES_CONFIG_OPTIONS} --prefix=${INSTALL_DIR} +./configure ${POSTGRES_CONFIG_OPTIONS} --prefix="${INSTALL_DIR}" if [ $? != 0 ]; then exit $? fi @@ -143,7 +143,7 @@ cp src/lshvector/* build/lshvector cp src/lshvector/c/* build/lshvector cd build/lshvector -make -f Makefile.lshvector install PG_CONFIG=${INSTALL_DIR}/bin/pg_config +make -f Makefile.lshvector install PG_CONFIG="${INSTALL_DIR}/bin/pg_config" if [ $? = 0 ]; then echo "Completed build and install of lshvector postgresql plugin" diff --git a/Ghidra/Features/Base/.launch/Ghidra.launch b/Ghidra/Features/Base/.launch/Ghidra.launch index dde01f1f2b..a6308912f4 100644 --- a/Ghidra/Features/Base/.launch/Ghidra.launch +++ b/Ghidra/Features/Base/.launch/Ghidra.launch @@ -31,5 +31,5 @@ - + diff --git a/Ghidra/Features/Base/src/main/help/help/topics/CParserPlugin/CParser.htm b/Ghidra/Features/Base/src/main/help/help/topics/CParserPlugin/CParser.htm index a22b7068f7..09f496cbcd 100644 --- a/Ghidra/Features/Base/src/main/help/help/topics/CParserPlugin/CParser.htm +++ b/Ghidra/Features/Base/src/main/help/help/topics/CParserPlugin/CParser.htm @@ -67,7 +67,7 @@

The C-Parser has been successfully used on Visual Studio, GCC, and Objective-C header - files.  The include files for GCC, Windows, MacOS, and ANSI C were all parsed with the + files.  The include files for GCC, Windows, macOS, and ANSI C were all parsed with the C-Parser plugin.  Most vanilla C-Header files can be parsed using the C-Parser.  However, just as in C software development the correct include order and "-D" pre-defines must be specified.  Getting this correct can be much like porting an application from diff --git a/Ghidra/Features/Base/src/main/help/help/topics/FrontEndPlugin/Ghidra_Front_end_Menus.htm b/Ghidra/Features/Base/src/main/help/help/topics/FrontEndPlugin/Ghidra_Front_end_Menus.htm index 6e7d68a290..9e0d442d16 100644 --- a/Ghidra/Features/Base/src/main/help/help/topics/FrontEndPlugin/Ghidra_Front_end_Menus.htm +++ b/Ghidra/Features/Base/src/main/help/help/topics/FrontEndPlugin/Ghidra_Front_end_Menus.htm @@ -100,7 +100,15 @@

 

-

PKI Certificate  

+

Manage Certificates (Windows and macOS only) 

+ +
+

On Windows and macOS systems the menu action EditManage Certificates... + may be used to launch the system-provided user Certificate Manager. This dialog may be used to specify both user and + trusted certificates.

+
+ +

Set PKI Certificate  

The Ghidra Server can be set up to perform user authentication using PKI @@ -120,10 +128,26 @@

-

If the Ghidra Server - is not using PKI Certificates for user authentication, you can ignore this menu option.

+

If the Ghidra Server, or other server, + is not using PKI Certificates for user authentication, you can ignore this menu option + since the certificate keystore will not be used.

- + +
+

Specifying the single user certificate + keystore in this fashion will prevent the OS managed keystore from being used + (applied to Windows and macOS only).

+
+ +

Clear PKI Certificate  

+ +
+

If a PKI Certificate keystore had previously been set, it may be cleared using the + menu action EditClear PKI Certificate... + if no longer needed or to allow OS managed certificates to be used.

+
+ +

Exiting Ghidra

diff --git a/Ghidra/Features/Base/src/main/java/ghidra/framework/HeadlessGhidraApplicationConfiguration.java b/Ghidra/Features/Base/src/main/java/ghidra/framework/HeadlessGhidraApplicationConfiguration.java index 45e05c88d0..dbe8d54f33 100644 --- a/Ghidra/Features/Base/src/main/java/ghidra/framework/HeadlessGhidraApplicationConfiguration.java +++ b/Ghidra/Features/Base/src/main/java/ghidra/framework/HeadlessGhidraApplicationConfiguration.java @@ -18,6 +18,8 @@ package ghidra.framework; import java.io.File; import java.util.List; +import org.apache.commons.lang3.StringUtils; + import generic.jar.ResourceFile; import ghidra.GhidraClassLoader; import ghidra.framework.preferences.Preferences; @@ -91,10 +93,17 @@ public class HeadlessGhidraApplicationConfiguration extends ApplicationConfigura } /** - * Locate certs file within the Ghidra root directory. If found this will be used - * for initializing the ApplicationTrustManager used for SSL/PKI. + * Locate 'cacerts' file within the Ghidra root directory if 'ghidra.cacerts' property has not + * already been specified. If found this will be used to establish the property which will be + * used by {@link DefaultTrustManagerFactory}. */ private void locateCACertsFile() { + + String cacertsPath = System.getProperty(DefaultTrustManagerFactory.GHIDRA_CACERTS_PATH_PROPERTY); + if (!StringUtils.isBlank(cacertsPath)) { + return; // property will be used by DefaultTrustManagerFactory + } + for (ResourceFile appRoot : Application.getApplicationRootDirectories()) { File cacertsFile = new File(appRoot.getAbsolutePath(), "cacerts"); if (cacertsFile.isFile()) { diff --git a/Ghidra/Features/GhidraGo/src/main/help/help/topics/GhidraGo/GhidraGo.html b/Ghidra/Features/GhidraGo/src/main/help/help/topics/GhidraGo/GhidraGo.html index 0194d6907f..711b789e48 100644 --- a/Ghidra/Features/GhidraGo/src/main/help/help/topics/GhidraGo/GhidraGo.html +++ b/Ghidra/Features/GhidraGo/src/main/help/help/topics/GhidraGo/GhidraGo.html @@ -70,7 +70,7 @@ body {

GhidraGo passes information through a simple filesystem mechanism vice an open port for - security and simplicity. GhidraGo works on Windows, Linux, and MacOS. + security and simplicity. GhidraGo works on Windows, Linux, and macOS.

GhidraURL's have the format:

diff --git a/Ghidra/Features/GhidraServer/src/main/java/ghidra/server/CommandProcessor.java b/Ghidra/Features/GhidraServer/src/main/java/ghidra/server/CommandProcessor.java index edff61a2f8..56372aa6cb 100644 --- a/Ghidra/Features/GhidraServer/src/main/java/ghidra/server/CommandProcessor.java +++ b/Ghidra/Features/GhidraServer/src/main/java/ghidra/server/CommandProcessor.java @@ -15,8 +15,20 @@ */ package ghidra.server; -import java.io.*; -import java.util.*; +import java.io.File; +import java.io.FileFilter; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.channels.FileLock; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; import javax.security.auth.x500.X500Principal; @@ -28,11 +40,13 @@ import ghidra.framework.remote.User; import ghidra.framework.store.local.LocalFileSystem; import ghidra.util.exception.DuplicateNameException; import utilities.util.FileUtilities; +import utility.function.ExceptionalCallback; +import utility.function.ExceptionalConsumer; /** - * CommandProcessor provides server processing of commands - * queued by the {@link ServerAdmin} class which corresponds to the svrAdmin - * shell command. + * CommandProcessor provides server processing of commands queued + * by the {@link ServerAdmin} class which corresponds to the + * svrAdmin shell command. */ public class CommandProcessor { static final Logger log = LogManager.getLogger(CommandProcessor.class); @@ -48,34 +62,60 @@ public class CommandProcessor { static final String PASSWORD_OPTION = "--p"; // applies to add and reset commands private static final String ADMIN_CMD_DIR = LocalFileSystem.HIDDEN_DIR_PREFIX + "admin"; + private static final String COMMAND_FILE_PREFIX = "seq"; private static final String COMMAND_FILE_EXT = ".cmd"; + private static final String BAD_COMMAND_FILE_EXT = ".bad"; + private static final long SEQUENCE_WRAP_POINT = Long.MAX_VALUE >>> 1; -// private static final int LOCK_TIMEOUT = 30000; - - /** - * Command file filter - */ - static final FileFilter CMD_FILE_FILTER = - f -> f.isFile() && f.getName().endsWith(COMMAND_FILE_EXT); - - /** - * File date comparator - */ - static final Comparator FILE_DATE_COMPARATOR = (f1, f2) -> { - long t1 = f1.lastModified(); - long t2 = f2.lastModified(); - long diff = t1 - t2; - if (diff == 0) { - return 0; - } - return diff < 0 ? -1 : 1; - }; + private static final String LOCK_NAME = "command.lock"; + // No construct - static utility private CommandProcessor() { } + /** + * Command file filter. A rejected command file (see {@link #rejectCommandFile(File)}) no + * longer ends with {@value #COMMAND_FILE_EXT} and so is excluded here, leaving it neither + * processed nor counted as a queued command. + */ + static final FileFilter CMD_FILE_FILTER = f -> f.isFile() && + f.getName().endsWith(COMMAND_FILE_EXT); + + /** + * Command file sequence comparator, establishing the order in which queued commands are + * processed. Only files whose name yields a sequence number may be compared, so any file + * rejected by {@link #getCommandSequence(File)} must be filtered out beforehand. + */ + static final Comparator FILE_SEQUENCE_COMPARATOR = + (f1, f2) -> Long.compare(getCommandSequence(f1), getCommandSequence(f2)); + + /** + * Recover the sequence number encoded within a command file name (see + * {@link #writeCommands(List, File)}). + * @param f command file + * @return the sequence number, or -1 if the name does not encode one + */ + private static long getCommandSequence(File f) { + String name = f.getName(); + if (name.startsWith(COMMAND_FILE_PREFIX) && name.endsWith(COMMAND_FILE_EXT)) { + String seqStr = name.substring(COMMAND_FILE_PREFIX.length(), + name.length() - COMMAND_FILE_EXT.length()); + try { + long seq = Long.parseLong(seqStr, 16); + if (seq >= 0) { + return seq; + } + } + catch (NumberFormatException e) { + // fall-through to rejection below + } + } + return -1; + } + /** * Split a command string into individual arguments. + * * @param cmd command string * @return array of command arguments */ @@ -98,8 +138,7 @@ public class CommandProcessor { argList.add(cmd.substring(startIx, endIx)); startIx = ++endIx; insideQuote = false; - } - else { + } else { ++endIx; } } @@ -113,16 +152,16 @@ public class CommandProcessor { /** * Process the specified command. + * * @param repositoryMgr server's repository manager - * @param cmd command string + * @param cmd command string * @throws IOException if IO error occurs while processing command */ - private static void processCommand(RepositoryManager repositoryMgr, String cmd) - throws IOException { + private static void processCommand(RepositoryManager repositoryMgr, String cmd) throws IOException { UserManager userMgr = repositoryMgr.getUserManager(); String[] args = splitCommand(cmd); switch (args[0]) { - case ADD_USER_COMMAND: // add user + case ADD_USER_COMMAND: // add user String sid = args[1]; char[] pwdHash = null; if (args.length == 4 && args[2].contentEquals(PASSWORD_OPTION)) { @@ -130,8 +169,7 @@ public class CommandProcessor { } try { userMgr.addUser(sid, pwdHash); - } - catch (DuplicateNameException e) { + } catch (DuplicateNameException e) { log.error("Add User Failed: " + e.getMessage()); } break; @@ -149,11 +187,9 @@ public class CommandProcessor { } if (!userMgr.resetPassword(sid, pwdHash)) { log.info("Failed to reset password for user '" + sid + "'"); - } - else if (pwdHash != null) { + } else if (pwdHash != null) { log.info("User '" + sid + "' password reset to specified password"); - } - else { + } else { log.info("User '" + sid + "' password reset to default password"); } break; @@ -162,12 +198,10 @@ public class CommandProcessor { X500Principal x500User = new X500Principal(args[2]); if (userMgr.isValidUser(sid)) { userMgr.setDistinguishedName(sid, x500User); - } - else { + } else { try { userMgr.addUser(sid, x500User); - } - catch (DuplicateNameException e) { + } catch (DuplicateNameException e) { log.error("Add User Failed: " + e.getMessage()); return; } @@ -179,8 +213,7 @@ public class CommandProcessor { int permission = parsePermission(args[2]); String repName = args[3]; if (!userMgr.isValidUser(sid)) { - log.error("Failed to grant access for '" + sid + - "', user has not been added to server."); + log.error("Failed to grant access for '" + sid + "', user has not been added to server."); return; } if (permission < 0) { @@ -189,8 +222,7 @@ public class CommandProcessor { } Repository rep = repositoryMgr.getRepository(repName); if (rep == null) { - log.error("Failed to grant access for '" + sid + "', repository '" + repName + - "' not found."); + log.error("Failed to grant access for '" + sid + "', repository '" + repName + "' not found."); return; } rep.setUserPermission(sid, permission); @@ -200,8 +232,7 @@ public class CommandProcessor { repName = args[2]; rep = repositoryMgr.getRepository(repName); if (rep == null) { - log.error("Failed to revoke access for '" + sid + "', repository '" + repName + - "' not found."); + log.error("Failed to revoke access for '" + sid + "', repository '" + repName + "' not found."); return; } rep.removeUser(sid); @@ -224,6 +255,10 @@ public class CommandProcessor { return -1; } + static File getCommandDir(File serverRootDir) { + return new File(serverRootDir, ADMIN_CMD_DIR); + } + static File getOrCreateCommandDir(File serverRootDir) { if (!serverRootDir.isDirectory() || !serverRootDir.canWrite()) { System.err.println("Insufficient privilege or server not started!"); @@ -239,67 +274,181 @@ public class CommandProcessor { /** * Process all queued commands for the specified server. + *

+ * Commands are processed in the order they were queued, which is established by the sequence + * number within each command file name. A file whose name does not provide one is rejected + * (see {@link #rejectCommandFile(File)}): its position within the queue is unknown, and + * processing it out of order could apply user and permission changes in the wrong sequence. + * * @param repositoryMgr server's repository manager * @throws IOException */ static void processCommands(RepositoryManager repositoryMgr) throws IOException { - File cmdDir = getOrCreateCommandDir(repositoryMgr.getRootDir()); - File[] files = cmdDir.listFiles(CMD_FILE_FILTER); - if (files == null) { - log.error("Failed to access command queue " + cmdDir.getAbsolutePath() + - ": possible permission problem"); - return; - } - if (files.length == 0) { + File cmdDir = getCommandDir(repositoryMgr.getRootDir()); + if (!cmdDir.isDirectory()) { return; } + withLock(cmdDir, () -> { + File[] allFiles = cmdDir.listFiles(CMD_FILE_FILTER); + if (allFiles == null) { + log.error( + "Failed to access command queue " + cmdDir.getAbsolutePath() + ": possible permission problem"); + return; + } - log.info("Processing queued commands"); - Arrays.sort(files, FILE_DATE_COMPARATOR); - for (File file : files) { - List cmdList = FileUtilities.getLines(file); - for (String cmdStr : cmdList) { - if (cmdStr.isBlank()) { + // Reject any command file whose name does not establish its place in the queue + List files = new ArrayList<>(); + for (File file : allFiles) { + if (getCommandSequence(file) < 0) { + rejectCommandFile(file); continue; } - try { - processCommand(repositoryMgr, cmdStr.trim()); - } - catch (ArrayIndexOutOfBoundsException e) { - log.error("Error occured processing command: " + cmdStr); - } + files.add(file); } - file.delete(); + + if (files.isEmpty()) { + return; + } + + log.info("Processing queued commands"); + files.sort(FILE_SEQUENCE_COMPARATOR); + + for (File file : files) { + List cmdList = FileUtilities.getLines(file); + + for (String cmdStr : cmdList) { + if (cmdStr.isBlank()) { + continue; + } + try { + processCommand(repositoryMgr, cmdStr.trim()); + } catch (ArrayIndexOutOfBoundsException e) { + log.error("Error occured processing command: " + cmdStr); + } + } + file.delete(); + } + }); + } + + /** + * Reject a command file whose name does not establish its place within the queue, by appending + * the {@value #BAD_COMMAND_FILE_EXT} extension to its name. The renamed file no longer + * matches {@link #CMD_FILE_FILTER}, so its content is retained for an administrator to inspect + * while it is neither processed nor reported again. + *

+ * A failure to rename is reported and otherwise ignored so that the remaining queued commands + * are still processed; the file will be reported again on the next pass. + * + * @param file command file to be rejected + */ + private static void rejectCommandFile(File file) { + File badFile = new File(file.getParentFile(), file.getName() + BAD_COMMAND_FILE_EXT); + try { + Files.move(file.toPath(), badFile.toPath(), StandardCopyOption.REPLACE_EXISTING); + log.error("Unrecognized command file was not processed and has been renamed to '" + + badFile.getName() + "': " + file.getAbsolutePath()); + } + catch (IOException e) { + log.error("Unrecognized command file was not processed and could not be renamed to '" + + badFile.getName() + "' (" + e.getMessage() + "): " + file.getAbsolutePath()); + } + } + + /** + * Check for need to wrap sequence number, but only do so if no command are + * currently queued + */ + private static long checkSequence(File cmdDir, long seq) throws FileNotFoundException { + if (seq >= 0 && seq < SEQUENCE_WRAP_POINT) { + return seq; + } + File[] files = cmdDir.listFiles(CMD_FILE_FILTER); + if (files == null) { + throw new FileNotFoundException("Missing command directory: " + cmdDir); + } + return files.length == 0 ? 1 : seq; + } + + /** + * Reads the current sequence, increments it, performs an write action while + * locked, updates the lock file, and flushes to disk. + */ + @SuppressWarnings("unused") // relates to 'lock' variable + private static void withSequenceLock(File cmdDir, ExceptionalConsumer writeAction) + throws IOException { + // Open channel for both reading and writing + Path lockFilePath = new File(cmdDir, LOCK_NAME).toPath(); + try (FileChannel channel = FileChannel.open(lockFilePath, StandardOpenOption.READ, StandardOpenOption.WRITE, + StandardOpenOption.CREATE); FileLock lock = channel.lock()) { + + ByteBuffer buffer = ByteBuffer.allocate(Long.BYTES); + long currentSeq = 0; + if (channel.size() >= Long.BYTES) { + channel.read(buffer, 0); // Reads file offset 0 into buffer + currentSeq = buffer.getLong(0); // Absolute index read (no flip needed!) + } + + // Increment command sequence number + long newSeq = checkSequence(cmdDir, currentSeq + 1); + + // Invoke write action + writeAction.accept(newSeq); + + // Update stored sequence number + buffer.putLong(0, newSeq); // Absolute index write (no flip needed!) + buffer.clear(); // reset file position to start + channel.write(buffer, 0); // Writes buffer to file offset 0 + channel.force(true); + } + } + + /** + * Holds the lock open while performing a read action + */ + @SuppressWarnings("unused") + private static void withLock(File cmdDir, ExceptionalCallback readAction) throws IOException { + // Open channel for both reading and writing + Path lockFilePath = new File(cmdDir, LOCK_NAME).toPath(); + try (FileChannel channel = FileChannel.open(lockFilePath, StandardOpenOption.READ, StandardOpenOption.WRITE, + StandardOpenOption.CREATE); FileLock lock = channel.lock()) { + + // Invoke read action + readAction.call(); } } /** * Store a list of command strings to a new command file. + * * @param cmdList list of command strings - * @param cmdDir command file directory (must exist) + * @param cmdDir command file directory (must exist) * @throws IOException */ static void writeCommands(List cmdList, File cmdDir) throws IOException { - File cmdTempFile = null; - try { - // Write command to temp file - cmdTempFile = File.createTempFile("adm", ".tmp", cmdDir); - FileUtils.writeLines(cmdTempFile, cmdList); - // Rename temp file to *.cmd file - String cmdFilename = cmdTempFile.getName(); - cmdFilename = cmdFilename.substring(0, cmdFilename.length() - 4) + COMMAND_FILE_EXT; - File cmdFile = new File(cmdTempFile.getParentFile(), cmdFilename); - if (!cmdTempFile.renameTo(cmdFile)) { - throw new IOException("file error"); + withSequenceLock(cmdDir, s -> { + File cmdTempFile = null; + try { + // Write command to temporary file + cmdTempFile = File.createTempFile("cmd", ".tmp", cmdDir); + FileUtils.writeLines(cmdTempFile, cmdList); + + // Rename temporary file to "seq.cmd" file + String hexSequenceStr = "%016X".formatted(s); + String cmdFilename = COMMAND_FILE_PREFIX + hexSequenceStr + COMMAND_FILE_EXT; + File cmdFile = new File(cmdTempFile.getParentFile(), cmdFilename); + if (!cmdTempFile.renameTo(cmdFile)) { + throw new IOException("file error"); + } + cmdTempFile = null; + + } finally { + if (cmdTempFile != null) { + cmdTempFile.delete(); + } } - cmdTempFile = null; - } - finally { - if (cmdTempFile != null) { - cmdTempFile.delete(); - } - } + }); } } diff --git a/Ghidra/Features/GhidraServer/src/main/java/ghidra/server/ServerAdmin.java b/Ghidra/Features/GhidraServer/src/main/java/ghidra/server/ServerAdmin.java index fe0a217f84..a29db8cda1 100644 --- a/Ghidra/Features/GhidraServer/src/main/java/ghidra/server/ServerAdmin.java +++ b/Ghidra/Features/GhidraServer/src/main/java/ghidra/server/ServerAdmin.java @@ -26,6 +26,7 @@ import ghidra.GhidraApplicationLayout; import ghidra.GhidraLaunchable; import ghidra.framework.Application; import ghidra.framework.ApplicationConfiguration; +import ghidra.framework.client.HeadlessClientAuthenticator; import ghidra.util.Msg; import ghidra.util.NamingUtilities; @@ -258,8 +259,8 @@ public class ServerAdmin implements GhidraLaunchable { try { while (true) { System.out.println("Enter password for user '" + userSID + "'"); - pwd1 = getPassword("New password: ", true); - pwd2 = getPassword("Retype new password: ", false); + pwd1 = HeadlessClientAuthenticator.getPassword(null, "New password:"); + pwd2 = HeadlessClientAuthenticator.getPassword(null, "Retype new password:"); if (Arrays.equals(pwd1, pwd2)) { break; } @@ -285,57 +286,6 @@ public class ServerAdmin implements GhidraLaunchable { } } - private char[] getPassword(String prompt, boolean echoWarn) throws IOException { - - boolean success = false; - char[] password = null; - int c; - try { - Console cons = System.console(); - if (cons != null) { - password = cons.readPassword(prompt); - } - else { - if (echoWarn) { - System.out.println("*** WARNING! Password entry will NOT be masked ***"); - } - - System.out.print(prompt); - - while (true) { - c = System.in.read(); - if (c <= 0 || c == '\n') { - break; - } - if (c == '\r') { - continue; - } - if (password == null) { - password = new char[1]; - } - else { - char[] newPass = new char[password.length + 1]; - // copy prior entry into expanded array and clear old array - for (int i = 0; i < password.length; i++) { - newPass[i] = password[i]; - password[i] = 0; - } - password = newPass; - } - password[password.length - 1] = (char) c; - } - } - success = true; - return password; - - } - finally { - if (!success && password != null) { - Arrays.fill(password, (char) 0); - } - } - } - /** * Determine if option specified as args[argOffset] * @param args command line args @@ -539,7 +489,7 @@ public class ServerAdmin implements GhidraLaunchable { System.err.println(msg); } String invocationName = System.getProperty(INVOCATION_NAME_PROPERTY); - System.err.println("Usage: " + + System.err.println("\nUsage: " + (invocationName != null ? invocationName : "java " + ServerAdmin.class.getName()) + (invocationName != null ? "" : " ") + " [] [] ..."); System.err.println("\nSupported commands:"); diff --git a/Ghidra/Features/GhidraServer/src/main/java/ghidra/server/UserManager.java b/Ghidra/Features/GhidraServer/src/main/java/ghidra/server/UserManager.java index a57d8ad983..fb7d0c6c87 100644 --- a/Ghidra/Features/GhidraServer/src/main/java/ghidra/server/UserManager.java +++ b/Ghidra/Features/GhidraServer/src/main/java/ghidra/server/UserManager.java @@ -93,7 +93,7 @@ public class UserManager { readUserListIfNeeded(); clearExpiredPasswords(); int size = userList.size(); - log.info("User file contains " + size + (size == 1 ? "entry" : "entries")); + log.info("User file contains " + size + (size == 1 ? " entry" : " entries")); } catch (FileNotFoundException e) { log.error("Existing User file not found."); diff --git a/Ghidra/Features/GhidraServer/src/main/java/ghidra/server/remote/GhidraServer.java b/Ghidra/Features/GhidraServer/src/main/java/ghidra/server/remote/GhidraServer.java index 9c89cbc4d8..72fb6bef32 100644 --- a/Ghidra/Features/GhidraServer/src/main/java/ghidra/server/remote/GhidraServer.java +++ b/Ghidra/Features/GhidraServer/src/main/java/ghidra/server/remote/GhidraServer.java @@ -15,18 +15,32 @@ */ package ghidra.server.remote; -import static ghidra.server.remote.GhidraServer.AuthMode.*; +import static ghidra.server.remote.GhidraServer.AuthMode.JAAS_LOGIN; +import static ghidra.server.remote.GhidraServer.AuthMode.NO_AUTH_LOGIN; +import static ghidra.server.remote.GhidraServer.AuthMode.PASSWORD_FILE_LOGIN; +import static ghidra.server.remote.GhidraServer.AuthMode.PKI_LOGIN; -import java.io.*; -import java.net.*; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.net.InetAddress; +import java.net.NetworkInterface; +import java.net.ServerSocket; +import java.net.SocketException; +import java.net.UnknownHostException; import java.rmi.NoSuchObjectException; import java.rmi.RemoteException; import java.rmi.registry.LocateRegistry; import java.rmi.registry.Registry; -import java.rmi.server.*; +import java.rmi.server.RMIClientSocketFactory; +import java.rmi.server.RMIServerSocketFactory; +import java.rmi.server.UnicastRemoteObject; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; -import java.util.*; +import java.util.Collection; +import java.util.Date; +import java.util.Enumeration; +import java.util.List; import javax.net.ssl.X509ExtendedKeyManager; import javax.rmi.ssl.SslRMIClientSocketFactory; @@ -47,11 +61,23 @@ import generic.jar.ResourceFile; import generic.random.SecureRandomFactory; import ghidra.framework.Application; import ghidra.framework.ApplicationConfiguration; -import ghidra.framework.remote.*; -import ghidra.net.*; +import ghidra.framework.remote.GhidraObjectInputFilter; +import ghidra.framework.remote.GhidraPrincipal; +import ghidra.framework.remote.GhidraServerHandle; +import ghidra.framework.remote.RemoteRepositoryServerHandle; +import ghidra.net.DefaultKeyManagerFactory; +import ghidra.net.DefaultSSLContextInitializer; +import ghidra.net.DefaultTrustManagerFactory; +import ghidra.net.PKIUtils; import ghidra.server.RepositoryManager; import ghidra.server.UserManager; -import ghidra.server.security.*; +import ghidra.server.security.AnonymousAuthenticationModule; +import ghidra.server.security.AuthenticationModule; +import ghidra.server.security.JAASAuthenticationModule; +import ghidra.server.security.Krb5ActiveDirectoryAuthenticationModule; +import ghidra.server.security.PKIAuthenticationModule; +import ghidra.server.security.PasswordFileAuthenticationModule; +import ghidra.server.security.SSHAuthenticationModule; import ghidra.server.stream.BlockStreamServer; import ghidra.server.stream.RemoteBlockStreamHandle; import ghidra.util.SystemUtilities; @@ -73,6 +99,8 @@ public class GhidraServer extends UnicastRemoteObject implements GhidraServerHan private static final String TLS_SERVER_PROTOCOLS_PROPERTY = "ghidra.tls.server.protocols"; private static final String TLS_ENABLED_CIPHERS_PROPERTY = "jdk.tls.server.cipherSuites"; + private static final String LOCALHOST_ADDRESS = "127.0.0.1"; + private static SslRMIServerSocketFactory serverSocketFactory; private static SslRMIClientSocketFactory clientSocketFactory; private static InetAddress bindAddress; @@ -81,7 +109,7 @@ public class GhidraServer extends UnicastRemoteObject implements GhidraServerHan private static String HELP_FILE = "ServerHelp.txt"; private static String USAGE_ARGS = - "[-ip ] [-ipAlt [,...]] [-i #.#.#.#] [-p#] [-n] [-a#] [-d]" + + "[-ip ] [-i #.#.#.#] [-p#] [-n] [-a#] [-d]" + " [-e] [-jaas ] [-u] [-autoProvision] [-anonymous] [-ssh] "; private static final String RMI_SERVER_PROPERTY = "java.rmi.server.hostname"; @@ -382,7 +410,7 @@ public class GhidraServer extends UnicastRemoteObject implements GhidraServerHan RemoteLoggingUtil.log( "Failed to instantiate RepositoryServerHandleImpl: " + e.getMessage(), username); - e.printStackTrace(); + RemoteLoggingUtil.logException(e); throw new RemoteException("Remote server handle error (see server log)"); } } @@ -543,7 +571,7 @@ public class GhidraServer extends UnicastRemoteObject implements GhidraServerHan int defaultPasswordExpiration = -1; boolean autoProvision = false; File jaasConfigFile = null; - Set altNames = new TreeSet<>(); + String hostname = null; // Network name resolution disabled by default InetNameLookup.setLookupEnabled(false); @@ -593,27 +621,8 @@ public class GhidraServer extends UnicastRemoteObject implements GhidraServerHan System.exit(-1); } } - else if (s.startsWith("-ipAlt")) { // self-signed cert alt subject names - int nextArgIndex = i + 1; - String hostname; - if (s.length() == 6 && nextArgIndex < args.length) { - hostname = args[++i]; - } - else { - hostname = s.substring(6); - } - for (String h : hostname.trim().split(";")) { - h = h.trim(); - if (h.length() == 0 || h.startsWith("-")) { - displayUsage("Missing -ipAlt altName"); - System.exit(-1); - } - altNames.add(h); - } - } else if (s.startsWith("-ip")) { // setting server remote access hostname int nextArgIndex = i + 1; - String hostname; if (s.length() == 3 && nextArgIndex < args.length) { hostname = args[++i]; } @@ -744,6 +753,13 @@ public class GhidraServer extends UnicastRemoteObject implements GhidraServerHan } } + if (authMode == PKI_LOGIN && StringUtils.isBlank( + System.getProperty(DefaultTrustManagerFactory.GHIDRA_CACERTS_PATH_PROPERTY))) { + displayUsage("PKI authentication (-a2) requires the trusted CA certificates file " + + "to be specified with the 'ghidra.cacerts' VM property"); + System.exit(-1); + } + try { serverRoot = serverRoot.getCanonicalFile(); } @@ -792,32 +808,51 @@ public class GhidraServer extends UnicastRemoteObject implements GhidraServerHan // } try { - // Ensure that remote access hostname is properly set for RMI registration - String hostname = initRemoteAccessHostname(); log.info("Ghidra Server " + Application.getApplicationVersion()); - log.info(" Server remote access address: " + hostname); - if (bindAddress == null) { - log.info(" Server listening on all interfaces"); - } - else { - log.info(" Server listening on interface: " + bindAddress.getHostAddress()); - } String preferredKeyStore = DefaultKeyManagerFactory.getPreferredKeyStore(); if (StringUtils.isBlank(preferredKeyStore)) { - // keystore has not been identified - use self-signed certificate + // When keystore has not been specified - use self-signed certificate with localhost/127.0.0.1 only + log.warn("Ghidra Server keystore not identified."); + log.warn("Server will bind to 127.0.0.1 listening to localhost requests only."); + + if (hostname != null) { + log.warn(" -ip hostname option ignored when self-signed certificate is used"); + } + hostname = LOCALHOST_ADDRESS; + + if (bindAddress != null && !bindAddress.isLoopbackAddress()) { + log.warn( + " -i non-loopback interface bind address ignored: " + bindAddress); + } + bindAddress = InetAddress.getByName(LOCALHOST_ADDRESS); + + System.setProperty(RMI_SERVER_PROPERTY, hostname); + + // Setup for self-signed server certificate generation bound to localhost log.info(" Generating self-signed certificate..."); - initSelfSignedCertificateData(hostname, altNames); + DefaultKeyManagerFactory.setDefaultIdentity(new X500Principal("CN=GhidraServer")); + DefaultKeyManagerFactory.addSubjectAlternativeName(hostname); + if (!hostname.equals(bindAddress.getHostAddress())) { + DefaultKeyManagerFactory + .addSubjectAlternativeName(bindAddress.getHostAddress()); + } } else { log.info(" Using server certificate keystore: " + preferredKeyStore); - if (!altNames.isEmpty()) { - log.warn(" -ipAlt use ignored with installed server certificate"); + hostname = initRemoteAccessHostname(); + + log.info(" Server remote access address: " + hostname); + if (bindAddress == null) { + log.info(" Server listening on all interfaces"); + } + else { + log.info(" Server listening on interface: " + bindAddress.getHostAddress()); } } - if (!DefaultKeyManagerFactory.initialize()) { + if (!DefaultKeyManagerFactory.initialize(true)) { log.fatal("Failed to initialize PKI/SSL keystore"); System.exit(0); } @@ -902,32 +937,8 @@ public class GhidraServer extends UnicastRemoteObject implements GhidraServerHan } } - private static void initSelfSignedCertificateData(String preferredHostname, - Set altNames) throws SocketException { - - DefaultKeyManagerFactory.setDefaultIdentity(new X500Principal("CN=GhidraServer")); - DefaultKeyManagerFactory.addSubjectAlternativeName(preferredHostname); - - // Collect alternate hostnames for inclusion in certificate - Enumeration nets = NetworkInterface.getNetworkInterfaces(); - while (nets.hasMoreElements()) { - NetworkInterface netint = nets.nextElement(); - Enumeration addrs = netint.getInetAddresses(); - while (addrs.hasMoreElements()) { - InetAddress addr = addrs.nextElement(); - altNames.add(addr.getHostAddress()); - altNames.add(addr.getHostName()); - altNames.add(addr.getCanonicalHostName()); - } - } - altNames.remove(preferredHostname); // already added as first entry - for (String name : altNames) { - DefaultKeyManagerFactory.addSubjectAlternativeName(name); - } - } - /** - * Log server certiifcates + * Log server certificates * @param keyType * @return number of certificates that support signing */ diff --git a/Ghidra/Features/GhidraServer/src/main/java/ghidra/server/remote/RemoteLoggingUtil.java b/Ghidra/Features/GhidraServer/src/main/java/ghidra/server/remote/RemoteLoggingUtil.java index 9b973c0cc5..bbad09d0d0 100644 --- a/Ghidra/Features/GhidraServer/src/main/java/ghidra/server/remote/RemoteLoggingUtil.java +++ b/Ghidra/Features/GhidraServer/src/main/java/ghidra/server/remote/RemoteLoggingUtil.java @@ -15,17 +15,24 @@ */ package ghidra.server.remote; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.*; import ghidra.server.RepositoryManager; public class RemoteLoggingUtil { private static Logger log = LogManager.getLogger(GhidraServer.class); + + /** + * Dump an exception stack trace to the server log as an error + * @param e exception + */ + public static void logException(Exception e) { + log.catching(Level.ERROR, e); + } /** - * Generate log message that contains inforamtion message. + * Generate log message that contains information message. * * General format where client host may be omitted if unable to determine: *

diff --git a/Ghidra/Features/GhidraServer/src/main/java/ghidra/server/security/PKIAuthenticationModule.java b/Ghidra/Features/GhidraServer/src/main/java/ghidra/server/security/PKIAuthenticationModule.java
index c1543a6311..42bc0eadc0 100644
--- a/Ghidra/Features/GhidraServer/src/main/java/ghidra/server/security/PKIAuthenticationModule.java
+++ b/Ghidra/Features/GhidraServer/src/main/java/ghidra/server/security/PKIAuthenticationModule.java
@@ -162,10 +162,10 @@ public class PKIAuthenticationModule implements AuthenticationModule {
 					if (!anonymousAllowed) {
 						throw new FailedLoginException("Distinguished name is unknown");
 					}
-					log.log(Level.WARN, "Know user's DN not found (" + username + ") ");
+					log.log(Level.WARN, "Missing User DN registration (" + username + ") ");
 					username = UserManager.ANONYMOUS_USERNAME;
 				}
-				else { // if (!certChain[0].getSubjectX500Principal().equals(dn.asX500Principal())) {
+				else { // log user DN and check for anonymous access
 					userMgr.logUnknownDN(username, certChain[0].getSubjectX500Principal());
 					if (!anonymousAllowed) {
 						throw new FailedLoginException(
diff --git a/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/client/HeadlessClientAuthenticator.java b/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/client/HeadlessClientAuthenticator.java
index 0d38cd7bb3..d44eb5ed59 100644
--- a/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/client/HeadlessClientAuthenticator.java
+++ b/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/client/HeadlessClientAuthenticator.java
@@ -19,6 +19,7 @@ import java.awt.Component;
 import java.io.*;
 import java.net.*;
 import java.security.InvalidKeyException;
+import java.util.Arrays;
 
 import javax.security.auth.callback.*;
 
@@ -97,7 +98,7 @@ public class HeadlessClientAuthenticator implements ClientAuthenticator {
 			if (StringUtils.isBlank(prompt) || "security".equals(prompt)) {
 				prompt = "Password for " + name + ":";
 			}
-			return new PasswordAuthentication(name, getPassword(usage, prompt));
+			return new PasswordAuthentication(name, getAuthenticatorPassword(usage, prompt));
 		}
 	};
 
@@ -188,14 +189,46 @@ public class HeadlessClientAuthenticator implements ClientAuthenticator {
 		}
 	}
 
-	private char[] getPassword(String usage, String prompt) {
+	/**
+	 * Prompt user on system console for a password entry.  If {@code passwordPromptAllowed}
+	 * is {@code false} {@value #BADPASSWORD} will be returned.
+	 * 

+ * NOTE: Any returned password should be properly cleared when done using it. + * An error + * + * @param usage text line before prompt (may be null) + * @param prompt password prompt on same line as entry (required) + * @return password entry + */ + private char[] getAuthenticatorPassword(String usage, String prompt) { if (!passwordPromptAllowed) { Msg.warn(this, "Headless client not configured to supply required password"); return BADPASSWORD; } + + try { + return getPassword(usage, prompt); + } + catch (IOException e) { + Msg.error(HeadlessClientAuthenticator.class, "Error reading password entry from standard-input", e); + return new char[0]; + } + } + + /** + * Prompt user on system console for a password entry. + * NOTE: Any returned password should be properly cleared when done using it. + * + * @param usage text line before prompt (may be null) + * @param prompt password prompt on same line as entry (required) + * @return password entry + * @throws IOException if system console is unavailable and an error occurs reading from stdin + */ + public static char[] getPassword(String usage, String prompt) throws IOException { char[] password = null; + boolean success = false; int c; try { @@ -242,10 +275,13 @@ public class HeadlessClientAuthenticator implements ClientAuthenticator { password[password.length - 1] = (char) c; } } + success = true; } - catch (IOException e) { - Msg.error(this, "Error reading standard-input for password", e); - } + finally { + if (!success) { + Arrays.fill(password, (char) 0); + } + } return password; } @@ -298,7 +334,7 @@ public class HeadlessClientAuthenticator implements ClientAuthenticator { // Ignore prompt specified by passCb String prompt = "Password for " + userName + ":"; - char[] password = getPassword(usage, prompt); + char[] password = getAuthenticatorPassword(usage, prompt); passCb.setPassword(password); return password != null; } @@ -323,7 +359,7 @@ public class HeadlessClientAuthenticator implements ClientAuthenticator { } return null; } - return getPassword("Certificate keystore: " + keystorePath, "Keystore password: "); + return getAuthenticatorPassword("Certificate keystore: " + keystorePath, "Keystore password:"); } @Override diff --git a/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/client/ServerConnectTask.java b/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/client/ServerConnectTask.java index 87de89dc07..5db3977684 100644 --- a/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/client/ServerConnectTask.java +++ b/Ghidra/Framework/FileSystem/src/main/java/ghidra/framework/client/ServerConnectTask.java @@ -16,9 +16,11 @@ package ghidra.framework.client; import java.io.Closeable; +import java.io.EOFException; import java.io.IOException; import java.net.Socket; import java.net.SocketAddress; +import java.net.SocketException; import java.net.UnknownHostException; import java.rmi.*; import java.rmi.registry.LocateRegistry; @@ -26,6 +28,7 @@ import java.rmi.registry.Registry; import java.security.cert.Certificate; import java.util.HashSet; +import javax.net.ssl.SSLException; import javax.net.ssl.SSLHandshakeException; import javax.net.ssl.SSLSocket; import javax.rmi.ssl.SslRMIClientSocketFactory; @@ -156,6 +159,7 @@ class ServerConnectTask extends Task { throws IOException, CancelledException { GhidraServerHandle gsh = null; + Certificate[] clientCerts = null; boolean canCancel = monitor.isCancelEnabled(); // original state try { @@ -163,7 +167,7 @@ class ServerConnectTask extends Task { // This is intended to work around an RMI issue where a continuous // retry condition can occur when a user cancels the password entry // for their keystore which should cancel any connection attempt - testServerSSLConnection(server, monitor); + clientCerts = testServerSSLConnection(server, monitor); monitor.setCancelEnabled(false); monitor.setMessage("Connecting..."); @@ -196,6 +200,26 @@ class ServerConnectTask extends Task { return null; } } + if (isConnectionRejectedByServer(e)) { + // Connection was established then terminated by the server immediately + // after a successful SSL test connection - assume TLS-level client + // authentication rejection (e.g., PKI authentication mode). + if (clientCerts != null) { + throw new IOException( + "Client PKI certificate was rejected - connection terminated by server: " + + server, + e); + } + if (DefaultKeyManagerFactory.getPreferredKeyStore() != null) { + // Keystore is configured but no certificate was presented - + // assume user cancelled keystore password entry + return null; + } + throw new IOException( + "User PKI Certificate not installed - connection terminated by server: " + + server, + e); + } throw e; } finally { @@ -205,6 +229,34 @@ class ServerConnectTask extends Task { return gsh; } + /** + * Determine if a remote exception corresponds to a connection which was successfully + * established then immediately terminated by the server. Following a successful + * {@link #testServerSSLConnection}, this indicates a TLS-level rejection of the client + * by the server (e.g., required client certificate missing or untrusted). With TLS 1.3 + * such a rejection may surface as an abrupt connection abort/reset without a readable + * SSL alert. A connection refusal or timeout does not qualify. + * @param e remote exception thrown during initial RMI interaction + * @return true if connection was established then rejected by the server + */ + private static boolean isConnectionRejectedByServer(RemoteException e) { + Throwable cause = e.getCause(); + while (cause != null) { + if (cause instanceof java.net.ConnectException) { + return false; // connection refused - server port not listening + } + if (cause instanceof SocketException || cause instanceof EOFException) { + return true; + } + if (cause instanceof SSLException && cause.getMessage() != null && + cause.getMessage().contains("certificate_required")) { + return true; // TLS 1.3 client-auth alert survived the connection teardown + } + cause = cause.getCause(); + } + return false; + } + private static class ConnectCancelledListener implements CancelledListener, Closeable { private TaskMonitor monitor; @@ -376,11 +428,14 @@ class ServerConnectTask extends Task { /** * Initiate an SSLSocket connection in order to ensure that any neccesary client/server - * certificate validation is performed. - * @param server server to which connection should be verified. For the Ghidra Server + * certificate validation is performed. NOTE: with TLS 1.3 a successful handshake does + * not indicate that the server has accepted a required client certificate; server-side + * rejection must be inferred if the subsequent RMI connection is terminated by the + * server (see {@link #isConnectionRejectedByServer(RemoteException)}). + * @param server server to which connection should be verified. For the Ghidra Server * this should correspond to the RMI Registry port {@link GhidraServerHandle#DEFAULT_PORT}. * @param monitor connection task monitor - * @return certificate chain of server + * @return client certificate chain which was presented to the server, or null if none * @throws IOException if connection failure occurs * @throws CancelledException if connection attempt is cancelled */ @@ -410,10 +465,10 @@ class ServerConnectTask extends Task { ConnectCancelledListener cancelListener = new ConnectCancelledListener(monitor, () -> forceClose(socket))) { // Complete SSL handshake to trigger client keystore access if required - // which will give user ability to cancel without involving RMI which + // which will give user ability to cancel without involving RMI which // will avoid RMI reconnect attempts socket.startHandshake(); - return socket.getSession().getPeerCertificates(); + return socket.getSession().getLocalCertificates(); } finally { monitor.checkCancelled(); // circumvent any IOException which may have occured diff --git a/Ghidra/Framework/Generic/src/main/java/ghidra/net/ApplicationKeyManagerFactory.java b/Ghidra/Framework/Generic/src/main/java/ghidra/net/ApplicationKeyManagerFactory.java index 2fc3472107..7d5a728026 100644 --- a/Ghidra/Framework/Generic/src/main/java/ghidra/net/ApplicationKeyManagerFactory.java +++ b/Ghidra/Framework/Generic/src/main/java/ghidra/net/ApplicationKeyManagerFactory.java @@ -25,6 +25,7 @@ import java.util.*; import javax.net.ssl.*; import generic.hash.HashUtilities; +import ghidra.framework.OperatingSystem; import ghidra.security.KeyStorePasswordProvider; import ghidra.util.Msg; import ghidra.util.exception.CancelledException; @@ -189,7 +190,7 @@ public class ApplicationKeyManagerFactory { throw new KeyStoreException("PKI X509 Certificate not found"); } boolean[] keyUsage = x509Cert.getKeyUsage(); - if (!keyUsage[0]) { + if (keyUsage != null && !keyUsage[0]) { throw new KeyStoreException("PKI key store must contain Digital Signing certificate"); } @@ -205,6 +206,97 @@ public class ApplicationKeyManagerFactory { throw new KeyStoreException("Unsupported keystore"); } + /** + * Get a key manager backed by the OS-managed user keystore for the current platform + * (Windows-MY on Windows, Apple KeychainStore on macOS). + * Private key access is guarded by the OS, so no keystore password is used. + * Alias selection from a multi-entry store is deferred to the platform + * {@link X509KeyManager} which will filter by key type and requested CA issuers. + *

+ * NOTE: The resulting key manager is intentionally not cached since OS keystore + * contents may be changed externally; a fresh instance is produced on each call. + * + * @return X509 key manager, or null if the current platform has no OS-managed keystore + * or the store contains no usable private-key entry (i.e., no X509 certificate + * key entry whose keyUsage extension, if present, permits digitalSignature). + * @throws KeyStoreException if a failure occurs while accessing the OS keystore + */ + static X509KeyManager getOSKeyManager() throws KeyStoreException { + + KeyStore keyStore; + String storeName; + try { + if (OperatingSystem.CURRENT_OPERATING_SYSTEM == OperatingSystem.WINDOWS) { + storeName = "Windows-MY"; + keyStore = KeyStore.getInstance(storeName); + } + else if (OperatingSystem.CURRENT_OPERATING_SYSTEM == OperatingSystem.MAC_OS_X) { + storeName = "KeychainStore"; + keyStore = KeyStore.getInstance(storeName, "Apple"); + } + else { + return null; // no OS-managed keystore for platform + } + keyStore.load(null, null); // populate from OS key store + } + catch (GeneralSecurityException | IOException e) { + throw new KeyStoreException("Failed to open OS key store", e); + } + + if (!hasUsableKeyEntry(keyStore)) { + return null; + } + + try { + KeyManagerFactory kmf = + KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + try { + kmf.init(keyStore, null); // OS guards key access; no password + } + catch (GeneralSecurityException e) { + // some KeychainStore implementations reject a null password + kmf.init(keyStore, new char[0]); + } + for (KeyManager keyManager : kmf.getKeyManagers()) { + if (keyManager instanceof X509KeyManager x509KeyManager) { + Msg.info(ApplicationKeyManagerFactory.class, + "Using OS-managed key store: " + storeName); + return x509KeyManager; + } + } + } + catch (GeneralSecurityException e) { + throw new KeyStoreException("Failed to process OS key store: " + storeName, e); + } + return null; + } + + /** + * Determine if the specified keystore contains at least one usable identity: + * a private-key entry whose X509 certificate keyUsage extension, if present, + * permits digitalSignature. + * + * @param keyStore loaded keystore to be examined + * @return true if a usable private-key entry was found + * @throws KeyStoreException if a failure occurs while accessing the keystore + */ + private static boolean hasUsableKeyEntry(KeyStore keyStore) throws KeyStoreException { + Enumeration aliases = keyStore.aliases(); + while (aliases.hasMoreElements()) { + String alias = aliases.nextElement(); + if (!keyStore.isKeyEntry(alias)) { + continue; + } + if (keyStore.getCertificate(alias) instanceof X509Certificate x509Cert) { + boolean[] keyUsage = x509Cert.getKeyUsage(); + if (keyUsage == null || keyUsage[0]) { + return true; + } + } + } + return false; + } + private static void disposePassword(char[] password) { if (password != null) { Arrays.fill(password, (char) 0); diff --git a/Ghidra/Framework/Generic/src/main/java/ghidra/net/CertTool.java b/Ghidra/Framework/Generic/src/main/java/ghidra/net/CertTool.java new file mode 100644 index 0000000000..8eeb7b05f4 --- /dev/null +++ b/Ghidra/Framework/Generic/src/main/java/ghidra/net/CertTool.java @@ -0,0 +1,772 @@ +/* ### + * IP: GHIDRA + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ghidra.net; + +import java.io.*; +import java.security.*; +import java.security.cert.*; +import java.security.cert.Certificate; +import java.util.*; + +import javax.security.auth.x500.X500Principal; + +import org.bouncycastle.asn1.pkcs.PKCSObjectIdentifiers; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x500.X500NameBuilder; +import org.bouncycastle.asn1.x500.style.BCStyle; +import org.bouncycastle.asn1.x509.*; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cms.CMSSignedData; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.openssl.*; +import org.bouncycastle.openssl.jcajce.*; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.pkcs.PKCS10CertificationRequest; +import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequestBuilder; +import org.bouncycastle.util.IPAddress; +import org.bouncycastle.util.Store; +import org.bouncycastle.util.io.pem.PemObject; +import org.bouncycastle.util.io.pem.PemWriter; + +import ghidra.GhidraApplicationLayout; +import ghidra.GhidraLaunchable; +import ghidra.framework.Application; +import ghidra.framework.ApplicationConfiguration; +import utilities.util.FileUtilities; + +public class CertTool implements GhidraLaunchable { + + static { + Security.addProvider(new BouncyCastleProvider()); + } + + private static final String INVOCATION_NAME_PROPERTY = "CertTool.invocation"; + private static int MIN_PWD_LENGTH = 4; + + // Provided for testing only +// public static void main(String[] args) throws IOException { +// CertTool certTool = new CertTool(); +// certTool.launch(new GhidraApplicationLayout(), args); +// } + + @Override + public void launch(GhidraApplicationLayout layout, String[] args) { + + // Perform static initializations if not already initialized + // Some tests invoke main method directly which have already initialized + // Application + if (!Application.isInitialized()) { + ApplicationConfiguration configuration = new ApplicationConfiguration(); + configuration.setInitializeLogging(false); + Application.initializeApplication(layout, configuration); + } + + int rc = execute(args); + System.exit(rc); + } + + private int execute(String[] args) { + if (args.length < 1) { + displayUsage(null); + return -1; + } + + String command = args[0]; + try { + if ("request".equals(command)) { + return handleRequestCommand(args); + } + if ("pkcs12".equals(command)) { + return handlePkcs12Command(args); + } + displayUsage("Unknown command: " + command); + } + catch (Exception e) { + System.err.println("Error executing command: " + e.getMessage()); + } + return -1; + } + + private String getNextArg(String[] args, String optionName, int index, String currentValue) + throws IllegalArgumentException { + if (currentValue != null) { + throw new IllegalArgumentException("Duplicate option " + optionName); + } + if (index >= args.length) { + throw new IllegalArgumentException("Missing " + optionName + " argument"); + } + return args[index]; + } + + private int handleRequestCommand(String[] args) throws Exception { + String keyFile = null; + try { + for (int i = 1; i < args.length; i++) { + if ("-outkey".equals(args[i])) { + keyFile = getNextArg(args, "-outkey", ++i, keyFile); + } + else { + throw new IllegalArgumentException("Unexpected argument: " + args[i]); + } + } + } + catch (IllegalArgumentException e) { + System.err.println(e.getMessage()); + return -1; + } + + if (keyFile == null) { + displayUsage("Missing -outkey option"); + return -1; + } + + if (!checkOverwrite(new File(keyFile))) { + return -1; + } + + // Generate key pair + KeyPairGenerator keyGen = KeyPairGenerator.getInstance(PKIUtils.RSA_TYPE, "BC"); + keyGen.initialize(PKIUtils.KEY_SIZE); + KeyPair keyPair = keyGen.generateKeyPair(); + + // Prompt for DN information + Map dnMap = promptForDnInfo(); + + // Save private key + if (!savePrivateKey(keyFile, keyPair.getPrivate())) { + System.err.println("Request generation failed!"); + return -1; + } + + // Output private key in encrypted PEM format + System.out.println("Private key saved to: " + keyFile); + + // Define the Distinguished Name (DN) using X500Name + X500Name subject = getDN(dnMap); + X500Principal principal = new X500Principal(subject.getEncoded()); + String dn = principal.getName(); // CN first + + System.out.println("Generating certificate request for: " + dn); + + // Create the JcaPKCS10CertificationRequestBuilder + JcaPKCS10CertificationRequestBuilder csrBuilder = + new JcaPKCS10CertificationRequestBuilder(subject, + keyPair.getPublic()); + + // Add Extensions to the CSR + ExtensionsGenerator extGen = new ExtensionsGenerator(); + + KeyUsage keyUsage = new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyEncipherment); + extGen.addExtension(Extension.keyUsage, true, // Is Critical? Yes, standard practice for Key Usage + keyUsage); + + // Add extended usage to include client and server authentication + KeyPurposeId[] usages = new KeyPurposeId[] { KeyPurposeId.id_kp_serverAuth, // TLS Web Server Authentication + KeyPurposeId.id_kp_clientAuth // TLS Web Client Authentication + }; + ExtendedKeyUsage extendedKeyUsage = new ExtendedKeyUsage(usages); + extGen.addExtension(Extension.extendedKeyUsage, false, extendedKeyUsage); + + // Add Subject Alternative Name(s) (SANs) --- + GeneralNames subjectAltNames = getSubjectAlternativeNames(dnMap); + if (subjectAltNames != null) { + extGen.addExtension(Extension.subjectAlternativeName, false, // Is Critical? No, typically false for SAN + subjectAltNames); + } + + csrBuilder.addAttribute(PKCSObjectIdentifiers.pkcs_9_at_extensionRequest, + extGen.generate()); + + // Create a Content Signer using the private key + ContentSigner signer = + new JcaContentSignerBuilder(PKIUtils.SIGNING_ALGORITHM).setProvider("BC") + .build(keyPair.getPrivate()); + + // Build the PKCS#10 CSR object + PKCS10CertificationRequest csr = csrBuilder.build(signer); + + // Convert the CSR to PEM format for distribution + StringWriter stringWriter = new StringWriter(); + try (PemWriter pemWriter = new PemWriter(stringWriter)) { + pemWriter.writeObject(new PemObject("CERTIFICATE REQUEST", csr.getEncoded())); + } + + // Print the final CSR + System.out.println("\n" + stringWriter.toString()); + + System.out.println(""" + The above request data may be submitted to your certificate + authority to obtain a signed certificate. Once you obtain + your signed certificate it may be combined with the key file, + that was generated and stored above, into a pkcs12 (*.p12) + keystore using the following command using appropriate + filenames (omit file-extension for -out file): + """); + + String invocationName = System.getProperty(INVOCATION_NAME_PROPERTY); + System.out.println( + " " + invocationName + " pkcs12 -inkey server.key -cert server.crt -out server\n"); + + return 0; + } + + private int handlePkcs12Command(String[] args) throws Exception { + boolean selfSigned = false; + String keyFile = null; + String certFile = null; + String outFile = null; + String caFile = null; + try { + for (int i = 1; i < args.length; i++) { + if ("-self-signed".equals(args[i])) { + selfSigned = true; + } + else if ("-inkey".equals(args[i])) { + keyFile = getNextArg(args, "-inkey", ++i, keyFile); + } + else if ("-cert".equals(args[i])) { + certFile = getNextArg(args, "-cert", ++i, certFile); + } + else if ("-out".equals(args[i])) { + outFile = getNextArg(args, "-out", ++i, outFile); + } + else if ("-cachain".equals(args[i])) { + caFile = getNextArg(args, "-cachain", ++i, caFile); + } + else { + throw new IllegalArgumentException("Unexpected argument: " + args[i]); + } + } + } + catch (IllegalArgumentException e) { + System.err.println(e.getMessage()); + return -1; + } + + if (selfSigned && keyFile == null && certFile == null && outFile != null && + caFile == null) { + return handleSelfSignedCommand(outFile); + } + if (!selfSigned && keyFile != null && certFile != null && outFile != null) { + return handlePkcs12StoreCommand(keyFile, certFile, caFile, outFile); + } + displayUsage( + "Invalid pkcs12 command. Use -self-signed or provide -inkey/-cert/-out options."); + return -1; + } + + private int handleSelfSignedCommand(String outFile) throws Exception { + + if (endsWithKnownExtension(outFile)) { + return -1; + } + + File certFile = new File(outFile + ".crt"); + File pkcs12File = new File(outFile + ".p12"); + + if (!checkOverwrite(certFile) || !checkOverwrite(pkcs12File)) { + return -1; + } + + // Prompt for DN information + Map dnMap = promptForDnInfo(); + + // Get the Distinguished Name (DN) using X500Name + X500Name subject = getDN(dnMap); + X500Principal principal = new X500Principal(subject.getEncoded()); + String dn = principal.getName(); // CN first + + System.out.println("Generating self-signed certificate for: " + dn); + + char[] pwd = promptForNewPassword("Enter new keystore password:"); + if (pwd == null) { + return -1; + } + try { + String alias = dnMap.get("CN"); + KeyStore keyStore = + PKIUtils.createKeyStore(alias, dn, 365, null, false, pkcs12File, "PKCS12", + getSubjectAlternativeNameList(dnMap), pwd); + + System.out.println("Self-signed keystore generated: " + pkcs12File); + + Certificate certificate = keyStore.getCertificate(alias); + PKIUtils.exportX509Certificates(new Certificate[] { certificate }, certFile); + + System.out.println("Self-signed certificate generated: " + certFile); + } + finally { + Arrays.fill(pwd, (char) 0); + } + return 0; + } + + private int handlePkcs12StoreCommand(String keyFile, String certFile, String caFile, + String outFile) + throws Exception { + + File pkcs12File; + if (outFile.endsWith(".p12")) { + pkcs12File = new File(outFile); + } + else { + if (endsWithKnownExtension(outFile)) { + return -1; + } + pkcs12File = new File(outFile + ".p12"); + } + + if (!checkOverwrite(pkcs12File)) { + return -1; + } + + // Load the private key + // PrivateKey privateKey = loadPrivateKey(keyFile); + KeyPair keyPair = loadKeyPair(keyFile); + + // Load the certificate + X509Certificate[] certChain = loadCertificateChain(certFile); + + // Verify corresponding key + X509Certificate subjectCert = certChain[0]; + if (!keyPair.getPublic().equals(subjectCert.getPublicKey())) { + System.err.println("Certificate file does not correspond to key file!"); + return -1; + } + + if (caFile != null) { + + if (certChain.length != 1) { + System.err.println( + "Expected only one certificate in -cert file when -caFile specified."); + return -1; + } + + X509Certificate[] caChain = null; + + // Try as simple concatenation of certificates + try { + caChain = loadCertificateChain(caFile); + } + catch (Exception e) { + // ignore + } + + if (caChain == null) { + try { + caChain = loadPkcs7CAChain(caFile); + } + catch (Exception e) { + // ignore + } + } + + if (caChain == null) { + System.err.println("Failed to load CA Chain file."); + return -1; + } + + // If supplied CA chain file contains subject certificate - certChain[0] + // use it as the complete certChain, otherwise concatenate. + if (caChain[0].getSubjectX500Principal() + .equals(certChain[0].getSubjectX500Principal()) && + caChain[0].getSerialNumber().equals(certChain[0].getSerialNumber())) { + certChain = caChain; + } + else { + X509Certificate[] chain = new X509Certificate[caChain.length + 1]; + chain[0] = certChain[0]; + System.arraycopy(caChain, 0, chain, 1, caChain.length); + certChain = chain; + } + } + + if (!verifyChainWithStrictRoot(certChain)) { + return -1; + } + + char[] pwd = promptForNewPassword("Enter new keystore password:"); + if (pwd == null) { + return -1; + } + try { + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + keyStore.load(null, null); + keyStore.setKeyEntry("mykey", keyPair.getPrivate(), pwd, certChain); + PKIUtils.saveKeyStore(keyStore, pkcs12File, pwd); + } + finally { + Arrays.fill(pwd, (char) 0); + } + + return 0; + } + + private static boolean verifyChainWithStrictRoot(X509Certificate[] fullChain) { + if (fullChain == null || fullChain.length <= 1) { + System.err.println("Incomplete certificate chain!"); + System.err.println("Concatenate all CAs in chain with Root last."); + if (fullChain.length == 1) { + String issuerDN = fullChain[0].getIssuerX500Principal().getName(); + System.err.println("Subject certificate issued by: " + issuerDN); + } + return false; + } + + try { + // Isolate and verify the assumed root certificate (end of chain) + X509Certificate rootCert = fullChain[fullChain.length - 1]; + rootCert.checkValidity(); + if (!rootCert.getSubjectX500Principal().equals(rootCert.getIssuerX500Principal())) { + System.err.println( + "Root verification failed: The last certificate is not self-signed."); + return false; + } + try { + rootCert.verify(rootCert.getPublicKey()); // Check its signature + } + catch (Exception e) { + System.err.println( + "Root verification failed: Signature does not match its public key."); + return false; + } + if (rootCert.getBasicConstraints() < 0) { + System.err.println( + "Root verification failed: Certificate lacks CA basic constraints."); + return false; + } + + // Isolate the remainder of the chain and establish trust root + // Subject certificate is first and must be removed from trust chain + List validationPath = + Arrays.asList(fullChain).subList(1, fullChain.length - 1); + TrustAnchor anchor = new TrustAnchor(rootCert, null); + Set trustAnchors = new HashSet<>(Collections.singletonList(anchor)); + + // Run standard PKIX path validation on the remaining chain + PKIXParameters params = new PKIXParameters(trustAnchors); + params.setRevocationEnabled(false); + + CertificateFactory factory = CertificateFactory.getInstance("X.509"); + CertPath certPath = factory.generateCertPath(validationPath); + + CertPathValidator validator = CertPathValidator.getInstance("PKIX"); + validator.validate(certPath, params); + + return true; + + } + catch (Exception e) { + System.err.println("Incomplete or invalid certificate chain: " + e.getMessage()); + return false; + } + } + + private boolean savePrivateKey(String keyFile, PrivateKey privateKey) throws Exception { + char[] pwd = promptForNewPassword("Enter new key password:"); + if (pwd == null) { + return false; + } + try { + File file = new File(keyFile); + // Create file with owner-only permissions. + // The file is never readable by other local users while it holds key material + try (OutputStream out = FileUtilities.newOwnerPrivateFileOutputStream(file); + Writer fw = new OutputStreamWriter(out)) { + JcaPEMWriter pemWriter = new JcaPEMWriter(fw); + pemWriter.writeObject(privateKey, + new JcePEMEncryptorBuilder("AES-256-CBC").build(pwd)); + pemWriter.flush(); + } + return true; + } + finally { + Arrays.fill(pwd, (char) 0); + } + } + + private KeyPair loadKeyPair(String keyFile) throws Exception { + try (FileReader fileReader = new FileReader(keyFile); + PEMParser pemParser = new PEMParser(fileReader)) { + + Object parsedObject = pemParser.readObject(); + + if (parsedObject == null) { + throw new IOException("The PEM file is empty or invalid."); + } + + // Assume the key is encrypted + if (parsedObject instanceof PEMEncryptedKeyPair) { + // Force user prompt + char[] pwd = promptForPassword("Enter Key Decryption Password:"); + try { + // Build the Decryptor using the password + PEMDecryptorProvider decryptorProvider = + new JcePEMDecryptorProviderBuilder().setProvider("BC").build(pwd); + + // Decrypt the key pair container + PEMKeyPair decryptedKeyPair = ((PEMEncryptedKeyPair) parsedObject) + .decryptKeyPair(decryptorProvider); + + // Convert the entire PEMKeyPair to a standard JCA java.security.KeyPair object + return new JcaPEMKeyConverter().setProvider("BC").getKeyPair(decryptedKeyPair); + } + finally { + Arrays.fill(pwd, (char) 0); + } + } + + throw new IOException( + "Unsupported keystore: " + parsedObject.getClass().getSimpleName()); + } + } + + private X509Certificate[] loadCertificateChain(String certFile) throws Exception { + try (FileInputStream fis = new FileInputStream(certFile)) { + CertificateFactory cf = CertificateFactory.getInstance("X.509", "BC"); + Collection certs = + cf.generateCertificates(fis); + return certs.toArray(new X509Certificate[0]); + } + } + + private static X509Certificate[] loadPkcs7CAChain(String filePath) throws Exception { + try (InputStream fis = new FileInputStream(filePath)) { + // Read the full stream bytes + byte[] p7bBytes = fis.readAllBytes(); + + // Parse the PKCS#7 structure + CMSSignedData signedData = new CMSSignedData(p7bBytes); + Store certStore = signedData.getCertificates(); + Collection matches = certStore.getMatches(null); + + // Convert Bouncy Castle holders to java.security.X509Certificate + JcaX509CertificateConverter converter = + new JcaX509CertificateConverter().setProvider("BC"); + List chainList = new ArrayList<>(); + + for (X509CertificateHolder holder : matches) { + chainList.add(converter.getCertificate(holder)); + } + + return chainList.toArray(new X509Certificate[0]); + } + } + + private GeneralNames getSubjectAlternativeNames(Map dnMap) { + List subjectAltNames = getSubjectAlternativeNameList(dnMap); + if (subjectAltNames == null) { + return null; + } + GeneralName[] generalNames = new GeneralName[subjectAltNames.size()]; + for (int i = 0; i < generalNames.length; i++) { + String san = subjectAltNames.get(i); + int type = IPAddress.isValid(san) ? GeneralName.iPAddress : GeneralName.dNSName; + generalNames[i] = new GeneralName(type, san); + } + return new GeneralNames(generalNames); + } + + private List getSubjectAlternativeNameList(Map dnMap) { + String sans = dnMap.get("SANs"); + if (sans != null && !sans.trim().isEmpty()) { + List list = new ArrayList<>(); + String[] sanArray = sans.split(","); + for (String san : sanArray) { + san = san.trim(); + if (!san.isEmpty()) { + list.add(san); + } + } + if (!list.isEmpty()) { + return list; + } + } + return null; + } + + private static Collection knownExtensions = + Set.of(".p12", ".key", ".crt", ".cert", ".jks", ".pem"); + + private boolean endsWithKnownExtension(String filename) { + String name = filename.toLowerCase(); + for (String ext : knownExtensions) { + if (name.endsWith(ext)) { + System.err.println( + "The -out file should not specify filename extension (" + ext + ")"); + return true; + } + } + return false; + } + + private boolean checkOverwrite(File file) { + if (file.exists()) { + Scanner scanner = new Scanner(System.in); + System.out.println("File already exists: " + file.getAbsolutePath()); + System.out.print("Overwrite file? [n]:"); + String resp = scanner.nextLine().toLowerCase(); + if (!"y".equals(resp) && !"yes".equals(resp)) { + return false; + } + if (!file.delete()) { + System.err.println("Failed to remove file."); + return false; + } + } + return true; + } + + private X500Name getDN(Map dnMap) { + + X500NameBuilder builder = new X500NameBuilder(BCStyle.INSTANCE); + + String c = dnMap.get("C").trim(); + if (c.length() != 0) { + builder.addRDN(BCStyle.C, c); + } + + String st = dnMap.get("ST").trim(); + if (st.length() != 0) { + builder.addRDN(BCStyle.ST, st); + } + + String o = dnMap.get("O").trim(); + if (o.length() != 0) { + builder.addRDN(BCStyle.O, o); + } + + // Add OU elements in canonical order + String ous = dnMap.get("OU"); + String[] ouArray = ous.split(","); + for (int i = ouArray.length - 1; i >= 0; i--) { + String ou = ouArray[i].trim(); + if (!ou.isEmpty()) { + builder.addRDN(BCStyle.OU, ou); + } + } + + builder.addRDN(BCStyle.CN, dnMap.get("CN")); + + return builder.build(); + } + + private Map promptForDnInfo() { + Map dnMap = new HashMap<>(); + + Scanner scanner = new Scanner(System.in); + + System.out.println("\nEnter Certificate information:"); + System.out.print("Country Name (2 letter code, optional) []: "); + dnMap.put("C", scanner.nextLine().trim()); + + System.out.print("State or Province Name (optional) []: "); + dnMap.put("ST", scanner.nextLine().trim()); + + System.out.print("Organization Name (optional) []: "); + dnMap.put("O", scanner.nextLine().trim()); + + System.out.print("Organizational Unit Name(s) (comma separated, optional) []: "); + dnMap.put("OU", scanner.nextLine()); + + String cn = ""; + while (cn.isEmpty()) { + System.out.print("Common Name (e.g. server FQDN or your name): "); + cn = scanner.nextLine().trim(); + } + dnMap.put("CN", cn); + + System.out.print("Subject Alternative Names (comma separated) []: "); + dnMap.put("SANs", scanner.nextLine()); + + return dnMap; + } + + private char[] promptForNewPassword(String passwordPrompt) { + char[] pwd = new char[0]; + while (pwd.length < MIN_PWD_LENGTH) { + if (pwd.length != 0) { + System.err.println("Password too short!"); + } + Arrays.fill(pwd, (char) 0); + pwd = promptForPassword(passwordPrompt); + } + char[] pwdRepeat = promptForPassword("Reenter password:"); + try { + if (!Arrays.equals(pwd, pwdRepeat)) { + System.err.println("Passwords differ!"); + Arrays.fill(pwd, (char) 0); + return null; + } + } + finally { + Arrays.fill(pwdRepeat, (char) 0); + } + return pwd; + } + + private char[] promptForPassword(String passwordPrompt) { + + Console console = System.console(); + if (console == null) { + + // Couldn't get console instance, passwords will be in the clear + passwordPrompt = + "*** WARNING! Password entry will NOT be masked ***\n" + passwordPrompt; + System.out.print(passwordPrompt); + System.out.flush(); + + BufferedReader reader = new BufferedReader(new InputStreamReader(System.in)); + try { + return reader.readLine().toCharArray(); + } + catch (IOException e) { + throw new RuntimeException(e); + } + } + + return console.readPassword("%s", passwordPrompt); + } + + /** + * Display an optional message followed by usage syntax. + * + * @param msg optional error message to proceed usage display + */ + private void displayUsage(String msg) { + if (msg != null) { + System.err.println(msg); + } + String invocationName = System.getProperty(INVOCATION_NAME_PROPERTY); + System.err.println("\nUsage: " + invocationName + " [options]"); + System.err.println("Supported commands:"); + System.err.println(" request -outkey "); + System.err.println( + " Generate a new private key and a corresponding certificate request"); + System.err.println( + " pkcs12 -inkey -cert [-cachain ] -out "); + System.err.println( + " Generate a pkcs12 (p12) keystore from a private key and signed-certificate\n" + + " chain (omit -out file extension)"); + System.err.println(" pkcs12 -self-signed -out "); + System.err.println( + " Generate a new self-signed certificate and private key (omit -out file extension)"); + System.err.println(); + } +} diff --git a/Ghidra/Framework/Generic/src/main/java/ghidra/net/DefaultKeyManagerFactory.java b/Ghidra/Framework/Generic/src/main/java/ghidra/net/DefaultKeyManagerFactory.java index 0ae8b6d284..001e6c04f4 100644 --- a/Ghidra/Framework/Generic/src/main/java/ghidra/net/DefaultKeyManagerFactory.java +++ b/Ghidra/Framework/Generic/src/main/java/ghidra/net/DefaultKeyManagerFactory.java @@ -16,6 +16,7 @@ package ghidra.net; import java.net.Socket; +import java.net.SocketAddress; import java.security.*; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; @@ -28,6 +29,7 @@ import javax.security.auth.x500.X500Principal; import org.apache.commons.lang3.StringUtils; +import ghidra.framework.Application; import ghidra.framework.preferences.Preferences; import ghidra.util.Msg; import ghidra.util.SystemUtilities; @@ -36,11 +38,31 @@ import ghidra.util.exception.CancelledException; /** * {@link DefaultKeyManagerFactory} provides access to the default application key manager * associated with the preferred keystore file specified by the {@link #KEYSTORE_PATH_PROPERTY} - * system property or set with {@link #setDefaultKeyStore(String, boolean)}. + * system property or set with {@link #setDefaultKeyStore(String, boolean)}. + *

+ * Keystore selection depends on the role established via {@link #initialize(boolean)} + * (client mode is the default for all lazy initialization paths): + *

    + *
  • Explicit keystore (client or server) - a keystore file specified via the + * {@link #KEYSTORE_PATH_PROPERTY} system property, user preference, or + * {@link #setDefaultKeyStore(String, boolean)} is always used when set. The + * {@link #KEYSTORE_PASSWORD_PROPERTY} applies to such file-based keystores only. This is + * treated as a default password to access the default keystore file if specified. In the + * absence of a configured password provider (e.g., server) it must be specified. + *
  • + *
  • Server - without an explicit keystore, a self-signed certificate is generated + * using the identity established with {@link #setDefaultIdentity(X500Principal)}, otherwise + * a default name is used. The server may impose use restrictions when an automatic self-signed + * certificate is used and will cause client-side server-authentication issues if accessed + * remotely. The OS-managed keystore is never used by the Ghidra Server.
  • + *
  • Client - without an explicit keystore, the OS-managed user keystore is used + * when available (Windows-MY on Windows, Apple KeychainStore on + * macOS).
  • + *
*

* NOTE: Since {@link SslRMIClientSocketFactory} and {@link SSLServerSocketFactory} employ a * static cache of a default {@link SSLSocketFactory}, with its default {@link SSLContext}, we - * must utilize a wrapped implementation of the associated {@link X509ExtendedKeyManager} so that + * must utilize a wrapped implementation of the associated {@link X509ExtendedKeyManager} so that * an updated keystore is used by the existing default {@link SSLSocketFactory}. */ public class DefaultKeyManagerFactory { @@ -66,6 +88,10 @@ public class DefaultKeyManagerFactory { private static X500Principal defaultIdentity; private static List defaultSubjectAlternativeNames; + // True if this JVM is acting as the Ghidra Server (see initialize(boolean)). + // Factory-level state so it survives key manager invalidation/re-init cycles. + private static boolean serverMode = false; + // Factory maintains a single X509 key manager private static final DefaultX509KeyManager keyManagerWrapper = new DefaultX509KeyManager(); @@ -99,10 +125,12 @@ public class DefaultKeyManagerFactory { * * @param path keystore file path or null to clear current key store and preference. * @param savePreference if true will be saved as user preference - * @return true if successful else false if error occured (see log). + * @return true if successful else false if error occurred (see log). */ public static synchronized boolean setDefaultKeyStore(String path, boolean savePreference) { + // NOTE: Should consider throwing exception instead of returning boolean + if (System.getProperty(KEYSTORE_PATH_PROPERTY) != null) { Msg.showError(DefaultKeyManagerFactory.class, null, "Set KeyStore Failed", "PKI KeyStore was set via system property and can not be changed"); @@ -112,35 +140,57 @@ public class DefaultKeyManagerFactory { path = prunePath(path); try { - boolean keyInitialized = keyManagerWrapper.init(path); - - if (savePreference && (path == null || keyInitialized)) { + keyManagerWrapper.init(path); + if (savePreference) { Preferences.setProperty(KEYSTORE_PATH_PROPERTY, path); Preferences.store(); } - return keyInitialized; + return true; } catch (CancelledException e) { - // ignore - keystore left unchanged - return false; + // ignore } + catch (GeneralSecurityException e) { + Msg.showError(DefaultKeyManagerFactory.class, null, "Set KeyStore Failed", + "Failed to create PKI key manager: " + e.getMessage()); + } + return false; // keystore left unchanged } /** - * Determine if active key manager is utilizing a generated self-signed certificate. - * + * Determine if active key manager is utilizing a generated self-signed certificate (server only) + * client certificate. + * * @return true if using self-signed certificate. */ - public static synchronized boolean usingGeneratedSelfSignedCertificate() { + public static boolean usingGeneratedSelfSignedCertificate() { return keyManagerWrapper.usingGeneratedSelfSignedCertificate(); } + /** + * Determine if active key manager is utilizing the OS-managed user keystore + * (Windows-MY on Windows, Apple KeychainStore on macOS). + * + * @return true if using the OS-managed keystore. + */ + public static boolean usingOSManagedKeyStore() { + return keyManagerWrapper.usingOSManagedKeyStore(); + } + + /** + * Determine if active key manager is utilizing the file-based user keystore. + * @return true if using the file-based user keystore. + */ + public static boolean usingFileKeyStore() { + return keyManagerWrapper.usingFileKeyStore(); + } + /** * Set the default self-signed principal identity to be used during initialization * if no keystore defined. Current application key manager will be invalidated. * (NOTE: this is intended for server use only when client will not be performing * CA validation). - * + * * @param identity if not null and a KeyStore path has not be set, this * identity will be used to generate a self-signed certificate and private key */ @@ -175,17 +225,54 @@ public class DefaultKeyManagerFactory { * Initialize key manager if needed. Doing this explicitly independent of an SSL connection * allows application to bail before initiating connection. This will get handshake failure * if user forgets keystore password or other keystore problem. + *

+ * The current role is retained (client mode unless {@link #initialize(boolean)} was + * previously invoked with true). In client mode, when no keystore has been + * specified, the OS-managed keystore will be used if available. Otherwise no keystore + * is used. + * * @return true if key manager initialized, otherwise false */ - public synchronized static boolean initialize() { + public static boolean initialize() { try { - return keyManagerWrapper.init(); + keyManagerWrapper.init(); + return true; } catch (CancelledException e) { - return false; + // ignore } + catch (Exception e) { + logInitError(e); + } + return false; // keystore left unchanged } + /** + * Initialize key manager if needed, indicating whether this JVM is acting as a server + * or client. In server mode the OS-managed keystore is never used. If server mode and + * without an explicit keystore path, a self-signed certificate keystore will be generated + * using the specified {@link #setDefaultIdentity(X500Principal) identity} or a default identity. + * If the role differs from the current mode any existing key manager will be invalidated and + * re-initialized. + * + * @param isServer true if initializing for the Ghidra Server, false for client use. + * @return true if key manager initialized, otherwise false + */ + public synchronized static boolean initialize(boolean isServer) { + if (serverMode != isServer) { + serverMode = isServer; + keyManagerWrapper.invalidateKey(); + } + return initialize(); + } + + /** + * {@return true if key manager factory has been initialized for dedicated server use} + */ + public static boolean isServerMode() { + return serverMode; + } + /** * Invalidate the existing default key manager. */ @@ -200,7 +287,7 @@ public class DefaultKeyManagerFactory { * @return active keystore path or null if currently not running with a keystore or * one has not been set. */ - public static synchronized String getPreferredKeyStore() { + public static String getPreferredKeyStore() { String path = prunePath(System.getProperty(KEYSTORE_PATH_PROPERTY)); if (path == null && !SystemUtilities.isInHeadlessMode()) { path = prunePath(Preferences.getProperty(KEYSTORE_PATH_PROPERTY)); @@ -209,10 +296,10 @@ public class DefaultKeyManagerFactory { } /** - * Get the default/preferred key store path. - * @return default key store path or null if not set + * Get the default/preferred key store file path. + * @return default key store file path or null if not set */ - public static synchronized String getKeyStore() { + public static String getKeyStore() { return keyManagerWrapper.getKeyStore(); } @@ -220,10 +307,15 @@ public class DefaultKeyManagerFactory { * Get the lazy default key manager associated with the preferred key store. * @return default key manager or null if not initialized */ - public static synchronized X509ExtendedKeyManager getKeyManager() { + public static X509ExtendedKeyManager getKeyManager() { return keyManagerWrapper; } + private static void logInitError(Exception e) { + Msg.showError(DefaultKeyManagerFactory.class, null, "Key Manager Initialization Failure", + "Failed to create PKI key manager: " + e.getMessage()); + } + /** * DefaultKeyManager provides a wrapper for the X509 wrappedKeyManager whose * instantiation is delayed until needed. When a wrapper method is first invoked, the @@ -232,119 +324,253 @@ public class DefaultKeyManagerFactory { */ private static class DefaultX509KeyManager extends X509ExtendedKeyManager { - private X509KeyManager wrappedKeyManager; - private String keystorePath; - private boolean isSelfSigned = false; + private record KeyManagerRecord(X509KeyManager wrappedKeyManager, String keystorePath, + boolean isSelfSigned, boolean isOSManaged) {} + + private static final KeyManagerRecord UNINITIALIZED = + new KeyManagerRecord(null, null, false, false); + + // Data record is used to allow atomic switching of keystore data + private volatile KeyManagerRecord keyManagerRecord = UNINITIALIZED; + + private DefaultX509KeyManager() { + invalidateKey(); + } @Override public String chooseEngineServerAlias(String keyType, Principal[] issuers, SSLEngine engine) { - return super.chooseEngineServerAlias(keyType, issuers, engine); + try { + KeyManagerRecord keyMgrRec = init(); + String alias = null; + if (keyMgrRec.wrappedKeyManager instanceof X509ExtendedKeyManager extKeyMgr) { + alias = extKeyMgr.chooseEngineServerAlias(keyType, issuers, engine); + } + else if (keyMgrRec.wrappedKeyManager != null) { + alias = keyMgrRec.wrappedKeyManager.chooseServerAlias(keyType, issuers, null); + } + return alias; + } + catch (CancelledException e) { + // ignore + } + catch (Exception e) { + logInitError(e); + } + return null; } @Override public String chooseEngineClientAlias(String[] keyType, Principal[] issuers, SSLEngine engine) { - return super.chooseEngineClientAlias(keyType, issuers, engine); - } - - @Override - public synchronized String chooseClientAlias(String[] keyType, Principal[] issuers, - Socket socket) { try { - init(); + KeyManagerRecord keyMgrRec = init(); + String alias = null; + if (keyMgrRec.wrappedKeyManager instanceof X509ExtendedKeyManager extKeyMgr) { + alias = extKeyMgr.chooseEngineClientAlias(keyType, issuers, engine); + } + else if (keyMgrRec.wrappedKeyManager != null) { + alias = keyMgrRec.wrappedKeyManager.chooseClientAlias(keyType, issuers, null); + } + if (alias == null) { + warnNoClientCert(engine); + } + return alias; } catch (CancelledException e) { // ignore } - if (wrappedKeyManager == null) { - return null; + catch (Exception e) { + logInitError(e); } - return wrappedKeyManager.chooseClientAlias(keyType, issuers, socket); + return null; } @Override - public synchronized String chooseServerAlias(String keyType, Principal[] issuers, + public String chooseClientAlias(String[] keyType, Principal[] issuers, Socket socket) { try { - init(); + KeyManagerRecord keyMgrRec = init(); + String alias = null; + if (keyMgrRec.wrappedKeyManager != null) { + alias = keyMgrRec.wrappedKeyManager.chooseClientAlias(keyType, issuers, socket); + } + if (alias == null) { + warnNoClientCert(socket); + } + return alias; } catch (CancelledException e) { // ignore } - if (wrappedKeyManager == null) { - return null; + catch (Exception e) { + logInitError(e); } - return wrappedKeyManager.chooseServerAlias(keyType, issuers, socket); + return null; + } + + @Override + public String chooseServerAlias(String keyType, Principal[] issuers, + Socket socket) { + try { + KeyManagerRecord keyMgrRec = init(); + String alias = null; + if (keyMgrRec.wrappedKeyManager != null) { + alias = keyMgrRec.wrappedKeyManager.chooseServerAlias(keyType, issuers, socket); + } + return alias; + } + catch (CancelledException e) { + // ignore + } + catch (Exception e) { + logInitError(e); + } + return null; + } + + private void warnNoClientCert(Socket socket) { + if (socket != null) { + Msg.warn(this, + "No suitable user PKI certificate available for authentication to server: " + + getPeerEndpoint(socket)); + } + } + + private void warnNoClientCert(SSLEngine engine) { + if (engine != null) { + Msg.warn(this, + "No suitable user PKI certificate available for authentication to server: " + + getPeerEndpoint(engine)); + } + } + + /** + * Get remote endpoint description for the specified connected socket. + * @param socket connection socket + * @return remote endpoint description + */ + private static String getPeerEndpoint(Socket socket) { + SocketAddress addr = socket.getRemoteSocketAddress(); + return addr != null ? addr.toString() : ""; + } + + /** + * Get remote endpoint description for the specified SSL engine. + * @param engine SSL engine + * @return remote endpoint description + */ + private static String getPeerEndpoint(SSLEngine engine) { + String host = engine.getPeerHost(); + return host != null ? (host + ":" + engine.getPeerPort()) : ""; } @Override public String[] getClientAliases(String keyType, Principal[] issuers) { try { - init(); + KeyManagerRecord keyMgrRec = init(); + if (keyMgrRec.wrappedKeyManager != null) { + return keyMgrRec.wrappedKeyManager.getClientAliases(keyType, issuers); + } } catch (CancelledException e) { // ignore } - if (wrappedKeyManager == null) { - return null; + catch (Exception e) { + logInitError(e); } - return wrappedKeyManager.getClientAliases(keyType, issuers); + return null; } @Override public String[] getServerAliases(String keyType, Principal[] issuers) { try { - init(); + KeyManagerRecord keyMgrRec = init(); + if (keyMgrRec.wrappedKeyManager != null) { + return keyMgrRec.wrappedKeyManager.getServerAliases(keyType, issuers); + } } catch (CancelledException e) { // ignore } - if (wrappedKeyManager == null) { - return null; + catch (Exception e) { + logInitError(e); } - return wrappedKeyManager.getServerAliases(keyType, issuers); + return null; } @Override public X509Certificate[] getCertificateChain(String alias) { - if (wrappedKeyManager == null) { - return null; + try { + KeyManagerRecord keyMgrRec = init(); + if (keyMgrRec.wrappedKeyManager != null) { + return keyMgrRec.wrappedKeyManager.getCertificateChain(alias); + } } - return wrappedKeyManager.getCertificateChain(alias); + catch (CancelledException e) { + // ignore + } + catch (Exception e) { + logInitError(e); + } + return null; } @Override public PrivateKey getPrivateKey(String alias) { - if (wrappedKeyManager == null) { - return null; + try { + KeyManagerRecord keyMgrRec = init(); + if (keyMgrRec.wrappedKeyManager != null) { + return keyMgrRec.wrappedKeyManager.getPrivateKey(alias); + } } - return wrappedKeyManager.getPrivateKey(alias); + catch (CancelledException e) { + // ignore + } + catch (Exception e) { + logInitError(e); + } + return null; } /** * Invalidate the active keystore and key manager */ - private synchronized void invalidateKey() { - wrappedKeyManager = null; - keystorePath = null; - isSelfSigned = false; + private void invalidateKey() { + keyManagerRecord = UNINITIALIZED; } /** * Return active keystore path or preferred keystore path if not yet initialized. * @return active keystore path or preferred keystore path if not yet initialized. */ - private synchronized String getKeyStore() { - return keystorePath != null ? keystorePath : getPreferredKeyStore(); + private String getKeyStore() { + String path = keyManagerRecord.keystorePath; + return path != null ? path : getPreferredKeyStore(); } /** * Determine if active key manager is utilizing a generated self-signed certificate. * @return true if using self-signed certificate. */ - private synchronized boolean usingGeneratedSelfSignedCertificate() { - return wrappedKeyManager != null && isSelfSigned; + private boolean usingGeneratedSelfSignedCertificate() { + return keyManagerRecord.isSelfSigned(); + } + + /** + * Determine if active key manager is utilizing the OS-managed user keystore. + * @return true if using the OS-managed keystore. + */ + private boolean usingOSManagedKeyStore() { + return keyManagerRecord.isOSManaged(); + } + + /** + * Determine if active key manager is utilizing the file-based user keystore. + * @return true if using the file-based user keystore. + */ + private boolean usingFileKeyStore() { + return keyManagerRecord.keystorePath() != null; } /** @@ -353,18 +579,17 @@ public class DefaultKeyManagerFactory { * If the x509KeyManager already exists, this method has no affect. If the * keystorePath has not already been set, the getPreferredKeyStore() * method will be invoked to obtain the keystore which should be used in establishing the - * wrappedKeyManager. If no keystore has been identified and the Default Identity - * has been set, a self-signed certificate will be generated. If nothing has been set, the - * wrappedKeyManager will remain null and false will be returned. If an error occurs it + * wrappedKeyManager. If no keystore has been identified, keystore selection + * is based upon the current role (see {@link #init(String)}). If an error occurs it * will be logged and key managers will remain uninitialized. - * @return true if key manager initialized successfully or was previously initialized, else - * false if keystore path has not been set and default identity for self-signed certificate - * has not be established (see {@link DefaultKeyManagerFactory#setDefaultIdentity(X500Principal)}). + * @return KeyManagerRecord key manager initialized successfully or was previously initialized * @throws CancelledException user cancelled keystore password entry request + * @throws GeneralSecurityException if key manager initialization failed */ - private synchronized boolean init() throws CancelledException { - if (wrappedKeyManager != null) { - return true; + private KeyManagerRecord init() throws GeneralSecurityException, CancelledException { + KeyManagerRecord keyMgrRec = keyManagerRecord; + if (keyMgrRec != UNINITIALIZED) { + return keyMgrRec; } return init(getPreferredKeyStore()); } @@ -372,63 +597,106 @@ public class DefaultKeyManagerFactory { /** * Initialize the default x509KeyManager singleton wrappedKeyManager using the specified path. * If the x509KeyManager already exists for the specified keystore path, - * this method has no affect. If no keystore has been identified and the Default Identity - * has been set, a self-signed certificate will be generated. If nothing has been set, the - * wrappedKeyManager will remain null and false will be returned. If an error occurs it - * will be logged and key managers will remain uninitialized. + * this method has no affect. If no keystore has been identified, keystore selection + * is based upon the current role: + *

    + *
  • Server mode: if the Default Identity has been set, a self-signed certificate + * will be generated, otherwise the wrappedKeyManager will remain null and false will + * be returned.
  • + *
  • Client mode: the OS-managed user keystore (Windows/macOS) will be used if supported, + * otherwise the default Java behavior will apply without a keystore.
  • + *
+ * If an error occurs it will be logged and key managers will remain uninitialized. * @param newKeystorePath specifies the keystore to be opened or null for no keystore - * @return true if key manager initialized successfully or was previously initialized, else - * false if new keystore path was not specified and default identity for self-signed certificate - * has not be established (see {@link DefaultKeyManagerFactory#setDefaultIdentity(X500Principal)}). + * @return KeyManagerRecord key manager initialized successfully or was previously initialized * @throws CancelledException user cancelled keystore password entry request + * @throws GeneralSecurityException if key manager initialization failed */ - private synchronized boolean init(String newKeystorePath) throws CancelledException { + private synchronized KeyManagerRecord init(String newKeystorePath) + throws CancelledException, GeneralSecurityException { - if (wrappedKeyManager != null) { - if (Objects.equals(keystorePath, newKeystorePath)) { - return true; + if (newKeystorePath != null) { + // Specified keystore is already being used + if (keyManagerRecord.wrappedKeyManager != null && + Objects.equals(keyManagerRecord.keystorePath, newKeystorePath)) { + return keyManagerRecord; } - invalidateKey(); } - - isSelfSigned = false; + else if (keyManagerRecord != UNINITIALIZED && keyManagerRecord.keystorePath == null) { + // Assume we have already initialized using OS or default + return keyManagerRecord; + } + + KeyManagerRecord newKeyManagerRecord = null; + boolean failed = false; try { + // Always use keystorePath is specified if (!StringUtils.isBlank(newKeystorePath)) { Msg.info(DefaultKeyManagerFactory.class, "Using certificate keystore: " + newKeystorePath); // Password optionally specified via property String keystorePwd = System.getProperty(KEYSTORE_PASSWORD_PROPERTY); - wrappedKeyManager = - ApplicationKeyManagerFactory.getKeyManager(newKeystorePath, keystorePwd); - keystorePath = newKeystorePath; // update current keystore path + newKeyManagerRecord = new KeyManagerRecord( + ApplicationKeyManagerFactory.getKeyManager(newKeystorePath, keystorePwd), + newKeystorePath, false, false); + return newKeyManagerRecord; } - else if (defaultIdentity != null) { - // use self-signed keystore as fallback (intended for server use only) + + if (serverMode) { + // Server mode without a specified key store file will use self-signed certificate. + // A server should generally specify a default identify + // Server may impose limitations. + if (defaultIdentity == null) { + defaultIdentity = getDefaultServerCertificateName(); + } Msg.info(this, "Using self-signed certificate: " + defaultIdentity.getName()); char[] pwd = DEFAULT_PASSWORD.toCharArray(); KeyStore selfSignedKeyStore = PKIUtils.createKeyStore("defaultSigKey", - defaultIdentity.getName(), SELF_SIGNED_DURATION_DAYS, null, null, "JKS", - defaultSubjectAlternativeNames, pwd); - wrappedKeyManager = ApplicationKeyManagerFactory - .getKeyManagerFromKeyStore(selfSignedKeyStore, pwd); - keystorePath = null; - isSelfSigned = true; + defaultIdentity.getName(), SELF_SIGNED_DURATION_DAYS, null, false, null, + "JKS", defaultSubjectAlternativeNames, pwd); + newKeyManagerRecord = new KeyManagerRecord(ApplicationKeyManagerFactory + .getKeyManagerFromKeyStore(selfSignedKeyStore, pwd), + null, true, false); + return newKeyManagerRecord; } - else { - Msg.error(this, - "Failed to generate certificate without Distinguished Name (DN)"); - return false; + + // Client without a keystore: attempt to load OS-managed keystore + X509KeyManager osKeyManager = ApplicationKeyManagerFactory.getOSKeyManager(); + if (osKeyManager != null) { + newKeyManagerRecord = new KeyManagerRecord(osKeyManager, null, false, true); + return newKeyManagerRecord; } - return true; + + // Rely on Java's default behavior - no need to use self-signed certificate for client. + // If PKI Client Authentication is used a real User Certification will be needed. + newKeyManagerRecord = new KeyManagerRecord(null, null, false, false); + return newKeyManagerRecord; } catch (CancelledException e) { + failed = true; throw e; } - catch (Exception e) { - Msg.showError(this, null, "PKI Keystore Failure", - "Failed to create PKI key manager: " + e.getMessage(), e); + catch (GeneralSecurityException e) { + throw e; } - return false; + catch (Throwable t) { + failed = true; + throw new KeyStoreException("Failed to create PKI key manager", t); + } + finally { + if (!failed) { + keyManagerRecord = newKeyManagerRecord; + } + } + } + + /** + * Get name to be used for the auto-generated self-signed server certificate + * (e.g., "Ghidra_Server"). + * @return server certificate name + */ + private X500Principal getDefaultServerCertificateName() { + return new X500Principal("CN=" + (Application.isInitialized() ? Application.getName() : "Ghidra_Server")); } } @@ -461,7 +729,6 @@ public class DefaultKeyManagerFactory { if (privateKey == null || certificateChain == null) { CertificateException e = new CertificateException("suitable PKI certificate not found"); - e.printStackTrace(); throw e; } diff --git a/Ghidra/Framework/Generic/src/main/java/ghidra/net/DefaultTrustManagerFactory.java b/Ghidra/Framework/Generic/src/main/java/ghidra/net/DefaultTrustManagerFactory.java index b9c19d9c52..070a2c2351 100644 --- a/Ghidra/Framework/Generic/src/main/java/ghidra/net/DefaultTrustManagerFactory.java +++ b/Ghidra/Framework/Generic/src/main/java/ghidra/net/DefaultTrustManagerFactory.java @@ -15,19 +15,21 @@ */ package ghidra.net; -import java.io.IOException; -import java.security.GeneralSecurityException; -import java.security.KeyStore; +import java.io.FileNotFoundException; +import java.net.InetAddress; +import java.net.Socket; +import java.security.*; import java.security.cert.CertificateException; import java.security.cert.X509Certificate; -import java.util.HashSet; -import java.util.Set; +import java.util.*; import javax.net.ssl.*; import javax.rmi.ssl.SslRMIClientSocketFactory; import javax.security.auth.x500.X500Principal; -import ghidra.framework.preferences.Preferences; +import org.apache.commons.lang3.StringUtils; + +import ghidra.framework.OperatingSystem; import ghidra.util.Msg; /** @@ -35,22 +37,29 @@ import ghidra.util.Msg; * acceptable certificate authorities to be used with the default SSLContext * as established by {@link DefaultSSLContextInitializer}. *

- * The default behavior is for no trust authority to be established, in which case - * SSL peers will not be authenticated. If CA certificates have been set, all SSL - * connections which leverage this factory will perform peer authentication. If an error - * occurs while reading the CA certs file, all peer authentication will fail based upon the - * inability to choose a suitable client/server certificate. + * All SSL connections which leverage this factory will perform peer authentication against + * the established certificate authorities. If an error occurs while reading a specified CA + * certs file, a "closed" trust policy is adopted and all peer authentication will fail. *

- * The application X.509 CA certificates file may be in the standard form (*.pem, *.crt, - * *.cer, *.der) or may be in a Java JKS form (*.jks). The path to this file may be - * established in one of two ways using the absolute file path: - *

    - *
  1. setting the system property ghidra.cacerts (takes precedence)
  2. - *
  3. setting the user preference ghidra.cacerts
  4. - *
+ * An application X.509 CA certificates file path may optionally be specified via the system + * property ghidra.cacerts. The file may be in a standard form (*.pem, *.crt, + * *.cer, *.der) or may be in a Java JKS form (*.jks). The application may choose to set this + * property automatically based upon the presence of a cacerts file at a predetermined + * location prior to trust manager initialization. *

- * The application may choose to set the file path automatically based upon the presence of - * a cacerts file at a predetermined location. + * When the ghidra.cacerts property has been specified that trust store is used + * exclusively and the OS and Java default trust stores are ignored. This applies to both + * client and server use, allowing a deployment to restrict trust to its own authorities. When + * the property has not been specified the OS trust store and the Java default trust store are + * used. + *

+ * A server reached over a loopback connection is authenticated in the same way as any other. + * Setting the client property ghidra.disable.loopback.server.authentication to + * {@code true} waives that authentication for loopback connections only, which permits such a + * server to present a self-signed certificate. It should be used only where every local account + * is trusted: a loopback connection is not inherently authentic, and any local process able to + * bind the port ahead of the intended server would be accepted in its place - along with any + * credential subsequently sent to it. *

* NOTE: Since {@link SslRMIClientSocketFactory} and {@link SSLServerSocketFactory} employ a * static cache of a default {@link SSLSocketFactory}, with its default {@link SSLContext}, we @@ -60,13 +69,24 @@ import ghidra.util.Msg; public class DefaultTrustManagerFactory { /** - * The X509 cacerts file to be used when authenticating remote - * certificates is identified by either a system property or user - * preference ghidra.cacerts. The system property takes precedence. + * The VM property name to be used when specifying the application trust store + * X509 'cacerts' file path. */ public static final String GHIDRA_CACERTS_PATH_PROPERTY = "ghidra.cacerts"; + + /** + * The VM property name which may be used to disable authentication of a server certificate + * presented over a loopback connection. Loopback server authentication is performed by + * default; specifying this property as {@code true} waives it, permitting a server reached + * over loopback to present a self-signed (or otherwise untrusted) certificate. + */ + public static final String GHIDRA_DISABLE_LOOPBACK_SERVER_AUTH_PROPERTY = + "ghidra.disable.loopback.server.authentication"; - private static final X509Certificate[] NO_CERTS = new X509Certificate[0]; + // Must default to false so that authentication is performed until the property has actually + // been read by init(); a true initial value would waive it for any connection established + // beforehand. The effective value is established by init(). + private static boolean disableLoopbackServerAuth = false; /** * Use a singleton wrappedTrustManager so we can alter the true trustManager @@ -84,54 +104,53 @@ public class DefaultTrustManagerFactory { } /** - * Initialize trustManagers if ghidra.cacerts property or preference was specified, - * otherwise an "open" trust manager will be established. If an error occurs processing - * a specified cacerts file, a "closed" trust policy will be adopted. + * Initialize trust managers. The OS and Java default trust stores are loaded unless a + * trust store has been specified with the ghidra.cacerts property, in which case + * that trust store is used exclusively. If an error occurs processing a specified + * cacerts file, a "closed" trust policy will be adopted. */ private static void init() { + + // Loopback server authentication is enabled by default, although it can be disabled + // via a property setting + disableLoopbackServerAuth = Boolean.parseBoolean( + System.getProperty(GHIDRA_DISABLE_LOOPBACK_SERVER_AUTH_PROPERTY, "false")); + if (disableLoopbackServerAuth) { + Msg.warn(DefaultTrustManagerFactory.class, "Loopback server authentication has been disabled."); + } String cacertsPath = System.getProperty(GHIDRA_CACERTS_PATH_PROPERTY); - if (cacertsPath == null || cacertsPath.length() == 0) { - // check user preferences if cacerts not set via system property - cacertsPath = Preferences.getProperty(GHIDRA_CACERTS_PATH_PROPERTY); - if (cacertsPath == null || cacertsPath.length() == 0) { + if (StringUtils.isBlank(cacertsPath)) { + cacertsPath = null; + } + + // A specified cacerts trust store is used exclusively, for both client and server use, + // so that a deployment is able to restrict trust to its own certificate authorities. + // The OS and Java default trust stores are only used in its absence. + boolean loadDefaultTrustStores = (cacertsPath == null); + wrappedTrustManager.initialize(loadDefaultTrustStores); + + if (cacertsPath != null) { + try { + KeyStore trustStore = FlexibleTrustStoreLoader.getTrustStore(cacertsPath); + X509TrustManager trustManager = getTrustManager(trustStore); + wrappedTrustManager.addTrustManager(trustManager); + Msg.info(DefaultTrustManagerFactory.class, - "Trust manager disabled, cacerts have not been set"); - wrappedTrustManager.setTrustManager(new OpenTrustManager()); - return; + "Loaded " + trustManager.getAcceptedIssuers().length + + " trusted CA certificates from: " + cacertsPath); + } + catch (FileNotFoundException | KeyStoreException | NoSuchAlgorithmException e) { + wrappedTrustManager.setTrustManagerError(e); + String msg = e.getMessage(); + if (msg == null) { + msg = e.toString(); + } + Msg.error(DefaultTrustManagerFactory.class, + "Failed to process cacerts (" + cacertsPath + "): " + msg, e); } } - try { - Msg.info(DefaultTrustManagerFactory.class, - "Trust manager initializing with cacerts: " + cacertsPath); - KeyStore keyStore = PKIUtils.loadCertificateStore(cacertsPath); - TrustManagerFactory tmf = - TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); - tmf.init(keyStore); - X509TrustManager x509TrustMgr = null; - TrustManager[] trustManagers = tmf.getTrustManagers(); - for (TrustManager trustMgr : trustManagers) { - if (trustMgr instanceof X509TrustManager) { - x509TrustMgr = (X509TrustManager) trustMgr; - wrappedTrustManager.setTrustManager(x509TrustMgr); - PKIUtils.logCerts(x509TrustMgr.getAcceptedIssuers()); - break; - } - } - if (x509TrustMgr == null) { - throw new CertificateException("Failed to load any X509 certificates"); - } - } - catch (GeneralSecurityException | IOException e) { - wrappedTrustManager.setTrustManagerError(e); - String msg = e.getMessage(); - if (msg == null) { - msg = e.toString(); - } - Msg.error(DefaultTrustManagerFactory.class, - "Failed to process cacerts (" + cacertsPath + "): " + msg, e); - } } /** @@ -140,8 +159,8 @@ public class DefaultTrustManagerFactory { * @return trust managers */ public static synchronized TrustManager[] getTrustManagers() { - if (wrappedTrustManager.trustManager == null) { - init(); + if (!wrappedTrustManager.isReady()) { + init(); // lazy initialization to allow password prompt when needed } return new TrustManager[] { wrappedTrustManager }; } @@ -179,27 +198,40 @@ public class DefaultTrustManagerFactory { wrappedTrustManager.checkClientTrusted(certChain, authType); } - private static class WrappedTrustManager implements X509TrustManager { + private static class WrappedTrustManager extends X509ExtendedTrustManager { - private X509TrustManager trustManager; + private List trustManagers; private Exception caError; + private X509Certificate[] trustedIssuers; - WrappedTrustManager() { - invalidate(); + synchronized boolean isReady() { + return trustManagers != null; } - void invalidate() { - this.trustManager = null; - this.caError = null; + synchronized void invalidate() { + trustManagers = null; + caError = null; + trustedIssuers = null; } - synchronized void setTrustManager(X509TrustManager trustManager) { - this.trustManager = trustManager; - this.caError = null; + synchronized void initialize(boolean loadDefaultTrustStores) { + trustManagers = new ArrayList<>(); + caError = null; + if (loadDefaultTrustStores) { + addOSTrustManager(trustManagers); + addDefaultTrustManager(trustManagers); + } + trustedIssuers = null; + } + + synchronized void addTrustManager(X509TrustManager trustManager) { + if (trustManager != null) { + trustManagers.add(trustManager); + trustedIssuers = null; + } } synchronized void setTrustManagerError(Exception caError) { - this.trustManager = null; this.caError = caError; } @@ -207,22 +239,222 @@ public class DefaultTrustManagerFactory { public synchronized void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException { checkTrustManager(); - trustManager.checkClientTrusted(chain, authType); + CertificateException exc = null; + for (X509TrustManager trustManager : trustManagers) { + try { + trustManager.checkClientTrusted(chain, authType); + return; + } + catch (CertificateException e) { + exc = keepPreferredException(exc, e); + } + } + if (exc != null) { + throw exc; + } + } + + @Override + public synchronized void checkClientTrusted(X509Certificate[] chain, String authType, + Socket socket) + throws CertificateException { + checkTrustManager(); + CertificateException exc = null; + for (X509TrustManager trustManager : trustManagers) { + try { + if (trustManager instanceof X509ExtendedTrustManager extTrustManager) { + extTrustManager.checkClientTrusted(chain, authType, socket); + } + else { + trustManager.checkClientTrusted(chain, authType); + } + return; + } + catch (CertificateException e) { + exc = keepPreferredException(exc, e); + } + } + if (exc != null) { + throw exc; + } + } + + @Override + public synchronized void checkClientTrusted(X509Certificate[] chain, String authType, + SSLEngine engine) + throws CertificateException { + checkTrustManager(); + CertificateException exc = null; + for (X509TrustManager trustManager : trustManagers) { + try { + if (trustManager instanceof X509ExtendedTrustManager extTrustManager) { + extTrustManager.checkClientTrusted(chain, authType, engine); + } + else { + trustManager.checkClientTrusted(chain, authType); + } + return; + } + catch (CertificateException e) { + exc = keepPreferredException(exc, e); + } + } + if (exc != null) { + throw exc; + } } @Override public synchronized void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException { checkTrustManager(); - trustManager.checkServerTrusted(chain, authType); + CertificateException exc = null; + for (X509TrustManager trustManager : trustManagers) { + try { + trustManager.checkServerTrusted(chain, authType); + return; + } + catch (CertificateException e) { + exc = keepPreferredException(exc, e); + } + } + if (exc != null) { + throw new ServerCertificateException(chain, exc); + } + } + + @Override + public synchronized void checkServerTrusted(X509Certificate[] chain, String authType, + Socket socket) + throws CertificateException { + if (disableLoopbackServerAuth && isLoopback(socket)) { + return; + } + checkTrustManager(); + CertificateException exc = null; + for (X509TrustManager trustManager : trustManagers) { + try { + if (trustManager instanceof X509ExtendedTrustManager extTrustManager) { + extTrustManager.checkServerTrusted(chain, authType, socket); + } + else { + trustManager.checkServerTrusted(chain, authType); + } + return; + } + catch (CertificateException e) { + exc = keepPreferredException(exc, e); + } + } + if (exc != null) { + throw new ServerCertificateException(chain, exc); + } + } + + @Override + public synchronized void checkServerTrusted(X509Certificate[] chain, String authType, + SSLEngine engine) + throws CertificateException { + if (disableLoopbackServerAuth && isLoopback(engine)) { + return; + } + checkTrustManager(); + CertificateException exc = null; + for (X509TrustManager trustManager : trustManagers) { + try { + if (trustManager instanceof X509ExtendedTrustManager extTrustManager) { + extTrustManager.checkServerTrusted(chain, authType, engine); + } + else { + trustManager.checkServerTrusted(chain, authType); + } + return; + } + catch (CertificateException e) { + exc = keepPreferredException(exc, e); + } + } + if (exc != null) { + throw new ServerCertificateException(chain, exc); + } + } + + private CertificateException keepPreferredException(CertificateException exc1, + CertificateException exc2) { + // Since we may be checking with multiple trust managers, where one may have the + // correct certification path, we would prefer to keep an exception that was produced + // in that case (i.e., general CertificateException) instead of a validator exception + // where a trust path was not found. + if (exc1 != null && exc1.getClass() == CertificateException.class) { + if (exc1.getCause() == null) { + return exc1; + } + if (exc2 != null && exc2.getClass() != CertificateException.class) { + return exc1; + } + } + return exc2; + } + + private boolean isLoopback(Socket socket) { + if (socket != null) { + InetAddress address = socket.getInetAddress(); + if (address != null && address.isLoopbackAddress()) { + return true; + } + } + return false; + } + + private boolean isLoopback(SSLEngine engine) { + if (engine != null) { + String peerHost = engine.getPeerHost(); + if (peerHost != null) { + return isLoopbackHost(peerHost); + } + } + return false; + } + + /** + * Determine if a peer host is one of the standard loopback names or addresses. + *

+ * Only these literal forms are recognized; no name resolution is performed. A resolver + * lookup here would occur while this trust manager's monitor is held, where a slow or + * unresponsive resolver would stall every TLS handshake within this JVM. Restricting the + * check to the standard forms also keeps it independent of name resolution, which a + * {@code hosts} entry or DNS record could otherwise influence. + * + * @param peerHost peer host name or literal address + * @return true if the peer host is a standard loopback name or address + */ + private boolean isLoopbackHost(String peerHost) { + String host = peerHost.trim(); + if (host.startsWith("[") && host.endsWith("]")) { + host = host.substring(1, host.length() - 1); // IPv6 literal in URI form + } + return NetworkUtils.isLoopbackAddress(host); } @Override public synchronized X509Certificate[] getAcceptedIssuers() { - if (trustManager == null) { - return NO_CERTS; + if (trustedIssuers != null) { + return trustedIssuers; } - return trustManager.getAcceptedIssuers(); + + Set seen = new HashSet<>(); + List deduped = new ArrayList<>(); + + for (X509TrustManager tm : trustManagers) { + for (X509Certificate cert : tm.getAcceptedIssuers()) { + CertIdentity id = new CertIdentity(cert); + if (seen.add(id)) { + deduped.add(cert); + } + } + } + trustedIssuers = deduped.toArray(new X509Certificate[deduped.size()]); + return trustedIssuers; } synchronized X500Principal[] getTrustedIssuers() throws CertificateException { @@ -244,49 +476,106 @@ public class DefaultTrustManagerFactory { } private void checkTrustManager() throws CertificateException { - if (trustManager != null) { - return; - } if (caError != null) { throw new CertificateException("Failed to load CA certs", caError); } + if (trustManagers != null && !trustManagers.isEmpty()) { + return; // OK to proceed with at least one trust manager installed + } throw new CertificateException("Trust manager not properly initialized"); } } + + private static final class CertIdentity { + private final String subject; + private final String serial; + private final byte[] publicKey; - /** - * OpenTrustManager provides a means of adopting an "open" trust policy - * where any peer certificate will be considered acceptable. - */ - private static class OpenTrustManager implements X509TrustManager { - - /* - * @see javax.net.ssl.X509TrustManager#checkClientTrusted(java.security.cert.X509Certificate[], java.lang.String) - */ - @Override - public void checkClientTrusted(X509Certificate[] chain, String authType) - throws CertificateException { - // trust all certs + public CertIdentity(X509Certificate cert) { + this.subject = cert.getSubjectX500Principal().getName(); + this.serial = cert.getSerialNumber().toString(); + this.publicKey = cert.getPublicKey().getEncoded(); } - /* - * @see javax.net.ssl.X509TrustManager#checkServerTrusted(java.security.cert.X509Certificate[], java.lang.String) - */ @Override - public void checkServerTrusted(X509Certificate[] chain, String authType) - throws CertificateException { - // trust all certs + public boolean equals(Object o) { + if (!(o instanceof CertIdentity)) + return false; + CertIdentity other = (CertIdentity) o; + return subject.equals(other.subject) && serial.equals(other.serial) && + java.util.Arrays.equals(publicKey, other.publicKey); } - /* - * @see javax.net.ssl.X509TrustManager#getAcceptedIssuers() - */ @Override - public X509Certificate[] getAcceptedIssuers() { - return NO_CERTS; // no CA's have been stipulated + public int hashCode() { + return subject.hashCode() ^ serial.hashCode() ^ java.util.Arrays.hashCode(publicKey); } + } + private static void addOSTrustManager(List trustManagers) { + try { + KeyStore ks; + if (OperatingSystem.CURRENT_OPERATING_SYSTEM == OperatingSystem.WINDOWS) { + ks = KeyStore.getInstance("Windows-ROOT"); + ks.load(null, null); // populate from OS trust store + } + else if (OperatingSystem.CURRENT_OPERATING_SYSTEM == OperatingSystem.MAC_OS_X) { + ks = KeyStore.getInstance("KeychainStore"); + ks.load(null, null); // populate from OS trust store + } + else { + ks = UnixSystemTrustKeyStoreUtil.loadSystemCaKeyStore(); + } + if (ks != null) { + X509TrustManager trustManager = getTrustManager(ks); + trustManagers.add(trustManager); + + Msg.info(DefaultTrustManagerFactory.class, + "Loaded " + trustManager.getAcceptedIssuers().length + + " trusted CA certificates from the OS trust store."); + } + } + catch (Exception e) { + wrappedTrustManager.caError = e; + Msg.error(DefaultTrustManagerFactory.class, + "OS trust store load failed: " + e.getMessage()); + } + } + + private static void addDefaultTrustManager(List trustManagers) { + String defaultCACertsPath = System.getProperty("java.home") + "/lib/security/cacerts"; + try { + KeyStore ks = FlexibleTrustStoreLoader.getTrustStore(defaultCACertsPath); + X509TrustManager trustManager = getTrustManager(ks); + trustManagers.add(trustManager); + + Msg.info(DefaultTrustManagerFactory.class, + "Loaded " + trustManager.getAcceptedIssuers().length + + " trusted CA certificates from the Java default trust store."); + } + catch (Exception e) { + if (wrappedTrustManager.caError == null) { // don't overwrite addOSTrustManager error + wrappedTrustManager.caError = e; + } + Msg.error(DefaultTrustManagerFactory.class, + "Default Java Truststore load failed: " + e.getMessage()); + } + } + + private static X509TrustManager getTrustManager(KeyStore trustStore) + throws NoSuchAlgorithmException, KeyStoreException { + TrustManagerFactory tmf = TrustManagerFactory.getInstance( + TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(trustStore); + for (TrustManager tm : tmf.getTrustManagers()) { + if (tm instanceof X509TrustManager x509Tm) { + if (x509Tm.getAcceptedIssuers().length != 0) { + return x509Tm; + } + } + } + throw new KeyStoreException("X509 CA certificates not found"); } } diff --git a/Ghidra/Framework/Generic/src/main/java/ghidra/net/FlexibleTrustStoreLoader.java b/Ghidra/Framework/Generic/src/main/java/ghidra/net/FlexibleTrustStoreLoader.java new file mode 100644 index 0000000000..26b997eb51 --- /dev/null +++ b/Ghidra/Framework/Generic/src/main/java/ghidra/net/FlexibleTrustStoreLoader.java @@ -0,0 +1,215 @@ +/* ### + * IP: GHIDRA + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ghidra.net; + +import java.io.*; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.List; + +import ghidra.util.Msg; + +/** + * Loads a trust {@link KeyStore} from a CA certificates file, which may be a Java key store + * (JKS or PKCS12) or a certificate file (one or more PEM encoded certificates, or a DER encoded + * certificate). + *

+ * The form of the file is determined from its content rather than by attempting each in turn, so + * that a failure to load can report why it failed instead of only that every attempt was + * unsuccessful. + */ +final class FlexibleTrustStoreLoader { + + private static final int DER_SEQUENCE_TAG = 0x30; + + private FlexibleTrustStoreLoader() { + // no construct - static utility + } + + /** + * Load a trust KeyStore from a Java key store (JKS, PKCS12) or a certificate file + * (PEM or DER). + * + * @param cacertsPath path to CA certificates file + * @return loaded certificates as keystore + * @throws KeyStoreException if unable to load cacerts file + * @throws FileNotFoundException cacerts file not found + */ + public static KeyStore getTrustStore(String cacertsPath) + throws FileNotFoundException, KeyStoreException { + + File cacertsFile = new File(cacertsPath); + if (!cacertsFile.isFile()) { + throw new FileNotFoundException("CA Certificates file not found: " + cacertsPath); + } + + String keyStoreType = detectKeyStoreType(cacertsFile); + if (keyStoreType != null) { + Exception failure; + try { + // No password is supplied: trusted certificate entries are held unencrypted + // within a JKS key store, and within a PKCS12 key store written for this purpose + // (the Java default 'cacerts' among them), so they are readable without one. A + // key store which does not hold its certificates that way yields no entries, + // which is reported as a failure to load rather than as an empty trust store. + KeyStore keyStore = + PKIUtils.getKeyStoreInstance(cacertsFile.getAbsolutePath(), null); + if (keyStore.size() != 0) { + return keyStore; + } + failure = new KeyStoreException( + "key store contains no certificate entries; it may be password protected"); + } + catch (IOException | GeneralSecurityException e) { + failure = e; + } + // A PKCS12 key store and a DER encoded certificate both begin with the same byte, so + // a file which is detected as PKCS12 but does not load as one may simply be a + // certificate; any other type which fails to load is a definite failure. + if (!PKIUtils.PKCS12_TYPE.equals(keyStoreType)) { + throw new KeyStoreException( + "Failed to load " + keyStoreType + " trust store: " + cacertsPath, failure); + } + } + + try { + List unusableBlocks = new ArrayList<>(); + List certs = loadCertificates(cacertsFile, unusableBlocks); + if (certs.isEmpty()) { + // Report why each block was rejected, since none of them produced a certificate + throw new CertificateException(unusableBlocks.isEmpty() + ? "file does not contain a certificate" + : "file contains no usable certificate: " + + String.join("; ", unusableBlocks)); + } + return createTrustStore(certs); + } + catch (IOException | GeneralSecurityException e) { + throw new KeyStoreException("Failed to load CA certificates file: " + cacertsPath, e); + } + } + + /** + * Determine the Java key store type of a file from its content. + * @param file CA certificates file + * @return the key store type, or null if the file is not a Java key store + * @throws KeyStoreException if the file cannot be read + */ + private static String detectKeyStoreType(File file) throws KeyStoreException { + try { + return PKIUtils.detectKeyStoreType(file.getAbsolutePath()); + } + catch (IOException e) { + throw new KeyStoreException("Failed to read CA certificates file: " + file, e); + } + } + + /** + * Read the certificates contained within a certificate file. + *

+ * A PEM block which cannot be used is reported and skipped rather than abandoning the file: + * this establishes the certificate authorities every SSL connection depends upon, so + * discarding all of them on account of one defective block would be the greater failure. + * + * @param file certificate file + * @param unusableBlocks collects a description of each PEM block which was skipped + * @return the certificates read, which may be empty + * @throws IOException if the file cannot be read + * @throws CertificateException if the file cannot be parsed + */ + private static List loadCertificates(File file, List unusableBlocks) + throws IOException, CertificateException { + + if (isDerEncoded(file)) { + return loadDerCertificates(file); + } + + List certs = PKIUtils.loadX509PemCertificates(file, unusableBlocks::add); + for (String unusable : unusableBlocks) { + Msg.warn(FlexibleTrustStoreLoader.class, + "Ignored unusable certificate within " + file.getAbsolutePath() + ": " + unusable); + } + return certs; + } + + /** + * @param file certificate file + * @return true if the file appears to be DER encoded + * @throws IOException if the file cannot be read + */ + private static boolean isDerEncoded(File file) throws IOException { + try (InputStream in = new BufferedInputStream(new FileInputStream(file))) { + int c; + while ((c = in.read()) != -1) { + if (!Character.isWhitespace(c)) { + return c == DER_SEQUENCE_TAG; + } + } + } + return false; // empty file - handled as an absence of certificates + } + + /** + * Read one or more DER encoded certificates from a file. + * @param file certificate file + * @return the X509 certificates read + * @throws IOException if the file cannot be read + * @throws CertificateException if the file cannot be parsed + */ + private static List loadDerCertificates(File file) + throws IOException, CertificateException { + + List certs = new ArrayList<>(); + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + try (InputStream in = new BufferedInputStream(new FileInputStream(file))) { + for (Certificate cert : cf.generateCertificates(in)) { + if (cert instanceof X509Certificate x509Cert) { + certs.add(x509Cert); + } + } + } + return certs; + } + + /** + * Establish an in-memory trust store containing the specified certificates. + * @param certs certificates to be trusted + * @return the trust store + * @throws KeyStoreException if the trust store cannot be created or populated + * @throws IOException if the empty trust store cannot be initialized + * @throws NoSuchAlgorithmException if the trust store integrity algorithm is unavailable + * @throws CertificateException if the empty trust store's certificate data cannot be processed + */ + private static KeyStore createTrustStore(List certs) + throws KeyStoreException, IOException, NoSuchAlgorithmException, CertificateException { + + KeyStore ks = KeyStore.getInstance(KeyStore.getDefaultType()); + ks.load(null, null); + int i = 0; + for (X509Certificate cert : certs) { + ks.setCertificateEntry("cert-" + (i++), cert); + } + return ks; + } + +} diff --git a/Ghidra/Framework/Generic/src/main/java/ghidra/net/NetworkUtils.java b/Ghidra/Framework/Generic/src/main/java/ghidra/net/NetworkUtils.java new file mode 100644 index 0000000000..686988861e --- /dev/null +++ b/Ghidra/Framework/Generic/src/main/java/ghidra/net/NetworkUtils.java @@ -0,0 +1,62 @@ +/* ### + * IP: GHIDRA + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ghidra.net; + +import java.net.InetAddress; +import java.net.UnknownHostException; + +import org.bouncycastle.util.IPAddress; + +/** + * {@link NetworkUtils} provides collection of network utilities + */ +public class NetworkUtils { + + /** + * {@return true if specified ipAddress corresponds to a loopback hostname of address} + * NOTE: The presence of any trailing CIDR netmask on an IPv4 or IPv6 address is ignored. + * @param ipAddress IP address or hostname (e.g., "127.0.0.1", "localhost") + */ + public static boolean isLoopbackAddress(String ipAddress) { + if (ipAddress == null || ipAddress.trim().isEmpty()) { + return false; + } + + // Fast-path check for literal "localhost" to avoid resolution overhead + if ("localhost".equalsIgnoreCase(ipAddress)) { + return true; + } + + // Strip CIDR suffix if present (e.g., "127.0.0.1/32" -> "127.0.0.1") + int slashIndex = ipAddress.indexOf('/'); + if (slashIndex != -1) { + if (!IPAddress.isValidWithNetMask(ipAddress)) { + return false; + } + ipAddress = ipAddress.substring(0, slashIndex); + } + + try { + InetAddress address = InetAddress.getByName(ipAddress); + + return address.isLoopbackAddress(); + } + catch (UnknownHostException | SecurityException e) { + return false; + } + } + +} diff --git a/Ghidra/Framework/Generic/src/main/java/ghidra/net/PKIUtils.java b/Ghidra/Framework/Generic/src/main/java/ghidra/net/PKIUtils.java index 551aa131fe..1e4ff0ce4b 100644 --- a/Ghidra/Framework/Generic/src/main/java/ghidra/net/PKIUtils.java +++ b/Ghidra/Framework/Generic/src/main/java/ghidra/net/PKIUtils.java @@ -22,7 +22,11 @@ import java.security.KeyStore.*; import java.security.cert.*; import java.security.cert.Certificate; import java.util.*; +import java.util.function.Consumer; +import javax.naming.InvalidNameException; +import javax.naming.ldap.LdapName; +import javax.naming.ldap.Rdn; import javax.net.ssl.*; import javax.security.auth.DestroyFailedException; import javax.security.auth.x500.X500Principal; @@ -38,11 +42,14 @@ import org.bouncycastle.operator.ContentSigner; import org.bouncycastle.operator.OperatorException; import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; import org.bouncycastle.util.IPAddress; +import org.bouncycastle.util.encoders.DecoderException; +import org.bouncycastle.util.io.pem.PemObject; +import org.bouncycastle.util.io.pem.PemReader; import generic.random.SecureRandomFactory; import ghidra.util.Msg; import ghidra.util.exception.AssertException; -import ghidra.util.exception.CancelledException; +import utility.function.ExceptionalConsumer; /** * {@link PKIUtils} provides supporting utilities for creating and accessing X509 certificate @@ -51,16 +58,31 @@ import ghidra.util.exception.CancelledException; public class PKIUtils { public static final String RSA_TYPE = "RSA"; - - private static final int KEY_SIZE = 4096; - - private static final String SIGNING_ALGORITHM = "SHA512withRSA"; + public static final String SIGNING_ALGORITHM = "SHA512withRSA"; + public static final int KEY_SIZE = 4096; private static final int MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000; public static final String BEGIN_CERT = "-----BEGIN CERTIFICATE-----"; public static final String END_CERT = "-----END CERTIFICATE-----"; + // PEM block type of an X.509 certificate (see loadX509PemCertificates) + private static final String CERTIFICATE_PEM_TYPE = "CERTIFICATE"; + + /** Java key store type of a JKS key store (see {@link #detectKeyStoreType(String)}) */ + public static final String JKS_TYPE = "JKS"; + + /** Java key store type of a PKCS#12 key store (see {@link #detectKeyStoreType(String)}) */ + public static final String PKCS12_TYPE = "PKCS12"; + + // Magic number which begins a JKS key store + private static final byte[] JKS_MAGIC = + { (byte) 0xFE, (byte) 0xED, (byte) 0xFE, (byte) 0xED }; + + // ASN.1 DER tags relied upon to identify a PKCS#12 key store (see detectKeyStoreType) + private static final int DER_SEQUENCE_TAG = 0x30; + private static final int DER_INTEGER_TAG = 0x02; + public static final String[] PKCS_FILE_EXTENSIONS = new String[] { "p12", "pks", "pfx" }; public static final FileNameExtensionFilter PKCS_FILENAME_FILTER = new FileNameExtensionFilter("PKCS Key File", PKCS_FILE_EXTENSIONS); @@ -78,18 +100,21 @@ public class PKIUtils { */ X500Name.setDefaultStyle(RFC4519Style.INSTANCE); } + + private PKIUtils() { + // no construct + } /** * Establish X509TrustManager for the specified CA certificate storage. * * @param caCertsFile CA certificates storage file * @return X509TrustManager - * @throws CancelledException if password entry was cancelled * @throws GeneralSecurityException if error occured during truststore initialization * @throws IOException if file read error occurs */ public static X509TrustManager getTrustManager(File caCertsFile) - throws CancelledException, GeneralSecurityException, IOException { + throws GeneralSecurityException, IOException { if (!caCertsFile.isFile()) { throw new FileNotFoundException( @@ -144,35 +169,122 @@ public class PKIUtils { public static void exportX509Certificates(Certificate[] certificates, File outFile) throws IOException, CertificateEncodingException { - try (FileOutputStream fout = new FileOutputStream(outFile); - PrintWriter writer = new PrintWriter(fout)) { + // MIME encoder with line length 44 and LF line separator + Base64.Encoder pemEncoder = Base64.getMimeEncoder(44, new byte[] { '\n' }); + + try (PrintWriter writer = new PrintWriter(new FileWriter(outFile))) { for (Certificate certificate : certificates) { - if (!(certificate instanceof X509Certificate)) { - continue; + if (certificate instanceof X509Certificate) { + String encodedCert = pemEncoder.encodeToString(certificate.getEncoded()); + + writer.println(BEGIN_CERT); + writer.println(encodedCert); // MimeEncoder already includes line breaks + writer.println(END_CERT); + writer.println(); } - writer.println(BEGIN_CERT); - String base64 = Base64.getEncoder().encodeToString(certificate.getEncoded()); - while (base64.length() != 0) { - int endIndex = Math.min(44, base64.length()); - String line = base64.substring(0, endIndex); - writer.println(line); - base64 = base64.substring(endIndex); - } - writer.println(END_CERT); - writer.println(); } } + + outFile.setWritable(false, false); + outFile.setExecutable(false, false); } /** - * Generate a new {@link X509Certificate} with RSA {@link KeyPair} and create/update a {@link KeyStore} - * optionally backed by a keyFile. + * {@return a random certificate serial number} + */ + private static final BigInteger generateCertificateSerialNumber() { + SecureRandom random = SecureRandomFactory.getSecureRandom(); + BigInteger serialNumber; + + do { + // 159 bits guarantees that when converted to a 160-bit (20 byte) + // signed BigInteger, the sign bit is always 0 (always positive). + serialNumber = new BigInteger(159, random); + } while (serialNumber.equals(BigInteger.ZERO)); // Retry if zero + + return serialNumber; + } + + + /** + * {@return the Common Name (CN) for an X509 certificate or null if DN does not contain a CN} + * @param cert X509 certificate + * @throws InvalidNameException if DN is invalid or contains invalid DN + */ + public static String getCommonName(X509Certificate cert) throws InvalidNameException { + + X500Principal subject = cert.getSubjectX500Principal(); + return getCommonName(subject.getName(X500Principal.RFC2253)); + } + + /** + * {@return the Common Name (CN) for an X509 distinguished name (DN) or null if DN does not contain a CN} + * @param distinguishedName distinguished name (DN) + * @throws InvalidNameException if DN is invalid or contains invalid CN + */ + public static String getCommonName(String distinguishedName) throws InvalidNameException { + + LdapName ldapDN = new LdapName(distinguishedName); + + for (Rdn rdn : ldapDN.getRdns()) { + if (rdn.getType().equalsIgnoreCase("CN")) { + String cn = rdn.getValue().toString().trim(); + if (cn.length() == 0) { + throw new InvalidNameException( + "Certificate DN specifies an empty Common Name (CN)"); + } + return cn; + } + } + return null; + } + + /** + * Save a keystore + * @param keyStore key store to be saved + * @param keyFile file to be written + * @param protectedPassphrase protection password + * @throws KeyStoreException if error occurred while generating keystore + * @throws IOException if failed to write keystore file + */ + public static void saveKeyStore(KeyStore keyStore, File keyFile, char[] protectedPassphrase) + throws KeyStoreException, IOException { + FileOutputStream out = new FileOutputStream(keyFile); + try { + keyStore.store(out, protectedPassphrase); + out.flush(); + out.getFD().sync(); + Msg.debug(PKIUtils.class, + out.getChannel().size() + " bytes written to keystore file: " + keyFile); + } + catch (SyncFailedException e) { + // ignore + } + catch (GeneralSecurityException e) { + throw new KeyStoreException("Failed to store keystore", e); + } + finally { + out.close(); + } + keyFile.setReadable(false, false); + keyFile.setReadable(true); + keyFile.setWritable(false, false); + keyFile.setExecutable(false, false); + } + + /** + * Generate a new {@link X509Certificate} with RSA {@link KeyPair} and create/update a + * {@link KeyStore} optionally backed by a keyFile. + *

+ * Standard usage will include {@link KeyUsage#digitalSignature} and {@link KeyUsage#keyEncipherment}. + * For non-CA extended usage will include serverAuth and clientAuth for maximum compatibility. * * @param alias entry alias with keystore * @param dn distinguished name (e.g., "CN=Ghidra Test, O=Ghidra, OU=Test, C=US" ) * @param durationDays number of days which generated certificate should remain valid - * @param caEntry optional CA private key entry. If null, a self-signed CA certificate will be - * generated. + * @param caEntry optional CA private key entry (issuer of new certificate). If null, new + * certificate will sign itself (self-signed). + * @param isCA if true the new certificate will tagged as a CA * @param keyFile optional file to load/store resulting {@link KeyStore} (may be null) * @param keystoreType support keystore type (e.g., "JKS", "PKCS12") * @param subjectAlternativeNames an optional list of subject alternative names to be included @@ -182,7 +294,7 @@ public class PKIUtils { * @throws KeyStoreException if error occurs while updating keystore */ public static final KeyStore createKeyStore(String alias, String dn, int durationDays, - PrivateKeyEntry caEntry, File keyFile, String keystoreType, + PrivateKeyEntry caEntry, boolean isCA, File keyFile, String keystoreType, Collection subjectAlternativeNames, char[] protectedPassphrase) throws KeyStoreException { @@ -202,8 +314,6 @@ public class PKIUtils { KeyStore keyStore = KeyStore.getInstance(keystoreType); keyStore.load(loadStoreParameter); - SecureRandom random = SecureRandomFactory.getSecureRandom(); - KeyPairGenerator rsa = KeyPairGenerator.getInstance(RSA_TYPE); rsa.initialize(KEY_SIZE); @@ -214,9 +324,10 @@ public class PKIUtils { SubjectPublicKeyInfo bcPk = SubjectPublicKeyInfo.getInstance(encodedPublicKey); X500Name x500Name = new X500Name(dn); - X500Name caX500Name = x500Name; // self-signed CA if caEntry is null - KeyUsage keyUsage = new KeyUsage( - KeyUsage.digitalSignature | KeyUsage.keyEncipherment | KeyUsage.keyCertSign); + + X500Name caX500Name; + KeyUsage keyUsage; + if (caEntry != null) { // derive CA X500Name from caEntry Certificate caCert = caEntry.getCertificate(); @@ -229,10 +340,17 @@ public class PKIUtils { keyUsage = new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyEncipherment); issuerKey = caEntry.getPrivateKey(); } + else { + // self-signed + keyUsage = new KeyUsage( + KeyUsage.digitalSignature | KeyUsage.keyEncipherment | KeyUsage.keyCertSign); + caX500Name = x500Name; + } + Date notBefore = new Date(); long durationMs = (long) durationDays * MILLISECONDS_PER_DAY; Date notAfter = new Date(notBefore.getTime() + durationMs); - BigInteger serialNumber = new BigInteger(128, random); + BigInteger serialNumber = generateCertificateSerialNumber(); X509v3CertificateBuilder certificateBuilder = new X509v3CertificateBuilder(caX500Name, serialNumber, notBefore, notAfter, x500Name, bcPk); @@ -248,9 +366,22 @@ public class PKIUtils { certificateBuilder.addExtension(Extension.subjectAlternativeName, false, new GeneralNames(altNames)); } - if (caEntry == null) { - certificateBuilder.addExtension(Extension.basicConstraints, true, - new BasicConstraints(1)); + + // Indicate if this certificate is a CA or not + certificateBuilder.addExtension(Extension.basicConstraints, true, + new BasicConstraints(isCA)); + + if (!isCA) { + // For non-CA add extended usage to include client and server authentication + KeyPurposeId[] usages = new KeyPurposeId[] { + KeyPurposeId.id_kp_serverAuth, // TLS Web Server Authentication + KeyPurposeId.id_kp_clientAuth // TLS Web Client Authentication + }; + ExtendedKeyUsage extendedKeyUsage = new ExtendedKeyUsage(usages); + certificateBuilder.addExtension( + Extension.extendedKeyUsage, + false, + extendedKeyUsage); } ContentSigner contentSigner = @@ -270,22 +401,7 @@ public class PKIUtils { keyStore.setKeyEntry(alias, keyPair.getPrivate(), protectedPassphrase, chain); if (keyFile != null) { - FileOutputStream out = new FileOutputStream(keyFile); - try { - keyStore.store(out, protectedPassphrase); - out.flush(); - out.getFD().sync(); - Msg.debug(PKIUtils.class, - out.getChannel().size() + " bytes written to key/cert file: " + keyFile); - } - catch (SyncFailedException e) { - // ignore - } - finally { - out.close(); - } - keyFile.setReadable(true, true); - keyFile.setWritable(false); + saveKeyStore(keyStore, keyFile, protectedPassphrase); } Msg.debug(PKIUtils.class, "Certificate Generated (" + alias + "): " + dn); @@ -305,46 +421,6 @@ public class PKIUtils { } } - /** - * Generate a new {@link X509Certificate} with RSA {@link KeyPair} and create/update a {@link KeyStore} - * optionally backed by a keyFile. - * - * @param alias entry alias with keystore - * @param dn distinguished name (e.g., "CN=Ghidra Test, O=Ghidra, OU=Test, C=US" ) - * @param durationDays number of days which generated certificate should remain valid - * @param caEntry optional CA private key entry. If null, a self-signed CA certificate will be generated. - * @param keyFile optional file to load/store resulting {@link KeyStore} (may be null) - * @param keystoreType support keystore type (e.g., "JKS", "PKCS12") - * @param subjectAlternativeNames an optional list of subject alternative names to be included - * in certificate (may be null) - * @param protectedPassphrase key and keystore protection password - * @return newly generated keystore entry with key pair - * @throws KeyStoreException if error occurs while updating keystore - */ - public static final PrivateKeyEntry createKeyEntry(String alias, String dn, int durationDays, - PrivateKeyEntry caEntry, File keyFile, String keystoreType, - Collection subjectAlternativeNames, char[] protectedPassphrase) - throws KeyStoreException { - - PasswordProtection pp = new PasswordProtection(protectedPassphrase); - try { - KeyStore keyStore = createKeyStore(alias, dn, durationDays, caEntry, keyFile, - keystoreType, subjectAlternativeNames, protectedPassphrase); - return (PrivateKeyEntry) keyStore.getEntry(alias, pp); - } - catch (NoSuchAlgorithmException | UnrecoverableEntryException e) { - throw new KeyStoreException("Failed to generate/store certificate (" + dn + ")", e); - } - finally { - try { - pp.destroy(); - } - catch (DestroyFailedException e) { - throw new AssertException(e); // unexpected for simple password clearing - } - } - } - /** * Load the all certificates from the specified certificate store in a standard * X.509 form (e.g., concatenation of Base64 encoded certificates: *.pem, *.crt, *.cer, *.der) @@ -361,28 +437,161 @@ public class PKIUtils { public static KeyStore loadCertificateStore(String certsPath) throws IOException, KeyStoreException, NoSuchAlgorithmException, CertificateException { - int certCount = 0; - KeyStore store = KeyStore.getInstance(KeyStore.getDefaultType()); store.load(null); + + int certCount = loadX509CertificateStore(certsPath, x509Cert -> { + String name = getCommonName(x509Cert.getSubjectX500Principal()); + // Ensure a unique alias is used: certificates may share a common name + // (e.g., a renewed CA) and must not displace each other within the store + String alias = name; + for (int i = 2; store.containsAlias(alias); i++) { + alias = name + "(" + i + ")"; + } + store.setCertificateEntry(alias, x509Cert); + }); - // Attempt to read certificates in Base64 encoded form - InputStream fis = new FileInputStream(certsPath); - BufferedInputStream bis = new BufferedInputStream(fis); + if (certCount == 0) { + // Processing JKS files above produce "Empty input", if no certs read + // try reading as keystore without password + return getKeyStoreInstance(certsPath, null); + } + return store; + } + + /** + * Load all X.509 certificates from an unencrypted PEM file (a concatenation of Base64 encoded + * certificates). Text surrounding the certificate blocks, such as the certificate details which + * some tools emit ahead of each one, is ignored. + *

+ * Unlike {@link #loadCertificateStore(String)} this requires the file to be PEM encoded and to + * contain nothing but complete certificates, as a certificate authority file must (e.g., the + * PostgreSQL {@code ssl_ca_file}) since every entry within it has to be usable: a DER encoded + * certificate or a keystore yields no certificates rather than being accepted, and a truncated or + * non-certificate PEM block is an error rather than being skipped. + * + * @param pemFile PEM certificate file + * @return the certificates in the order they occur within the file, which is empty if the file + * contains no PEM block at all + * @throws IOException if the file cannot be read or a certificate block is truncated + * @throws CertificateException if a PEM block is not a certificate or cannot be decoded + */ + public static List loadX509PemCertificates(File pemFile) + throws IOException, CertificateException { + return loadX509PemCertificates(pemFile, null); + } - try { + /** + * Read all X509 certificates contained within a PEM encoded file, optionally tolerating any + * block which cannot be used. + *

+ * Tolerating unusable blocks is appropriate when establishing a trust store, where discarding + * every valid certificate on account of one defective block would be the greater failure. It + * is not appropriate when validating a file which the user has supplied for that purpose, where + * a defect must be reported rather than passed over - such callers use + * {@link #loadX509PemCertificates(File)} instead. + * + * @param pemFile PEM encoded certificate file + * @param unusableBlockConsumer if non-null, a description of each unusable PEM block is passed + * to this consumer and the block is skipped, allowing the remaining certificates to be read. + * If null, the first unusable block is an error. + * @return list of X509 certificates read from the file + * @throws IOException if the file cannot be read + * @throws CertificateException if a block cannot be used and unusableBlockConsumer is null + */ + public static List loadX509PemCertificates(File pemFile, + Consumer unusableBlockConsumer) throws IOException, CertificateException { + + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + List certs = new ArrayList<>(); + try (PemReader reader = new PemReader(new FileReader(pemFile))) { + int blockNumber = 0; + for (;;) { + PemObject pemObject; + try { + pemObject = reader.readPemObject(); + } + catch (DecoderException e) { + // Base64 decoding failure is reported by BouncyCastle as an unchecked + // exception. The offending block has already been consumed through its end + // marker, so reading is able to continue with the block which follows it. + ++blockNumber; + String msg = "Invalid PEM certificate data within " + pemFile.getName() + ": " + + e.getMessage(); + if (unusableBlockConsumer == null) { + throw new CertificateException(msg); + } + unusableBlockConsumer.accept(msg + " (PEM block " + blockNumber + ")"); + continue; + } + if (pemObject == null) { + break; // end of file + } + ++blockNumber; + try { + certs.add(getX509Certificate(cf, pemObject, pemFile)); + } + catch (CertificateException e) { + if (unusableBlockConsumer == null) { + throw e; + } + unusableBlockConsumer + .accept(e.getMessage() + " (PEM block " + blockNumber + ")"); + } + } + } + return certs; + } + + /** + * Convert a single PEM block into the X509 certificate it contains. + * @param cf X509 certificate factory + * @param pemObject PEM block which was read + * @param pemFile file the block was read from, for error reporting + * @return the X509 certificate + * @throws CertificateException if the block is not a usable X509 certificate + */ + private static X509Certificate getX509Certificate(CertificateFactory cf, PemObject pemObject, + File pemFile) throws CertificateException { + if (!CERTIFICATE_PEM_TYPE.equals(pemObject.getType())) { + throw new CertificateException("Expected only certificates within " + + pemFile.getName() + ", read PEM block: " + pemObject.getType()); + } + Certificate cert = + cf.generateCertificate(new ByteArrayInputStream(pemObject.getContent())); + if (!(cert instanceof X509Certificate x509Cert)) { + throw new CertificateException("Unsupported certificate type: " + cert.getType()); + } + return x509Cert; + } + + /** + * Load the all certificates from the specified certificate store in a standard + * X.509 form (e.g., concatenation of Base64 encoded certificates: *.pem, *.crt, *.cer, *.der) + * @param certsPath certificate(s) storage file path + * @param certConsumer consumer callback to be supplied with each certificate + * @return number of X509 certificates read + * @throws IOException if failure occurred reading and processing keystore file. + * @throws CertificateException if any of the certificates in the keystore could not be loaded + * @throws KeyStoreException thrown by certConsumer + */ + public static int loadX509CertificateStore(String certsPath, + ExceptionalConsumer certConsumer) + throws IOException, CertificateException, KeyStoreException { + + // Attempt to read certificates in Base64 PEM encoded form + int certCount = 0; + try (InputStream fis = new FileInputStream(certsPath); + BufferedInputStream bis = new BufferedInputStream(fis)) { CertificateFactory cf = CertificateFactory.getInstance("X.509"); while (bis.available() > 0) { try { Certificate cert = cf.generateCertificate(bis); - if (cert instanceof X509Certificate) { - X509Certificate x509Cert = (X509Certificate) cert; - String name = getCommonName(x509Cert.getSubjectX500Principal()); - store.setCertificateEntry(name, cert); + if (cert instanceof X509Certificate x509Cert) { + certConsumer.accept(x509Cert); ++certCount; } - } - catch (CertificateException e) { + } catch (CertificateException e) { // Must handle blank lines at bottom of file Throwable cause = e.getCause(); if (cause != null && "Empty input".equals(cause.getMessage())) { @@ -392,16 +601,7 @@ public class PKIUtils { } } } - finally { - bis.close(); - } - - if (certCount == 0) { - // Processing JKS files above produce "Empty input", if no certs read - // try reading as keystore without password - return getKeyStoreInstance(certsPath, null); - } - return store; + return certCount; } /** @@ -448,27 +648,77 @@ public class PKIUtils { * @return "JKS", "PKCS12" or null * @throws IOException if file read error occurs */ + /** + * Determine the Java key store type of a file from its leading bytes. + *

+ * Only the Java key store forms are identified. A certificate file is not a key store and + * yields null, which includes a DER encoded certificate: a DER certificate and a PKCS#12 + * key store cannot be told apart by their leading byte, since both begin with an ASN.1 DER + * SEQUENCE (0x30). They are distinguished here by the first element within that + * SEQUENCE, which is an INTEGER version for a PKCS#12 PFX and a nested SEQUENCE + * (tbsCertificate) for a certificate: + *

+	 *   PFX         ::= SEQUENCE { version INTEGER {v3(3)}, authSafe ContentInfo, ... }
+	 *   Certificate ::= SEQUENCE { tbsCertificate TBSCertificate, ... }  -- itself a SEQUENCE
+	 * 
+ * Locating that element requires the SEQUENCE length to be decoded, since it may use the DER + * long form (the Java default cacerts does). + *

+ * This remains a determination based upon the leading bytes and not a full parse, so a caller + * must still be prepared for a key store of the identified type to fail to load. + * + * @param keystorePath path to the file to be examined + * @return {@value #JKS_TYPE} or {@value #PKCS12_TYPE} if the file is a Java key store of that + * type, otherwise null (a PEM or DER certificate file among the possibilities) + * @throws IOException if the file cannot be read + */ public static String detectKeyStoreType(String keystorePath) throws IOException { - try (FileInputStream fis = new FileInputStream(keystorePath)) { - byte[] header = new byte[4]; - int read = fis.read(header); - if (read < 4) { - return null; - } - // Check for JKS magic number: FEEDFEED - if ((header[0] & 0xFF) == 0xFE && (header[1] & 0xFF) == 0xED && - (header[2] & 0xFF) == 0xFE && (header[3] & 0xFF) == 0xED) { - return "JKS"; - } - - // Check for PKCS12: starts with 0x30 0x82 - if ((header[0] & 0xFF) == 0x30 && (header[1] & 0xFF) == 0x82) { - return "PKCS12"; - } - - return null; + byte[] header; + try (InputStream in = new BufferedInputStream(new FileInputStream(keystorePath))) { + // Sufficient for the JKS magic number, or for a SEQUENCE tag and length (long form of + // up to 4 length bytes) followed by the first byte of the SEQUENCE content + header = in.readNBytes(JKS_MAGIC.length + 7); } + + if (header.length >= JKS_MAGIC.length && + Arrays.equals(JKS_MAGIC, Arrays.copyOf(header, JKS_MAGIC.length))) { + return JKS_TYPE; + } + + if (header.length < 2 || (header[0] & 0xFF) != DER_SEQUENCE_TAG) { + return null; // not a key store (a PEM certificate file, or unrecognized) + } + + int contentOffset = derContentOffset(header); + if (contentOffset < 0 || contentOffset >= header.length) { + return null; // truncated, or a length encoding which is not supported + } + + // A PKCS#12 PFX begins with its INTEGER version, a certificate with its tbsCertificate + return (header[contentOffset] & 0xFF) == DER_INTEGER_TAG ? PKCS12_TYPE : null; + } + + /** + * Determine the offset of the content which follows the ASN.1 DER tag and length at the start + * of the specified data. + * + * @param der DER encoded data beginning with a tag byte + * @return offset of the first content byte, or -1 if the length encoding is unsupported + */ + private static int derContentOffset(byte[] der) { + if (der.length < 2) { + return -1; + } + int length = der[1] & 0xFF; + if (length < 0x80) { + return 2; // short form: the length is held within this byte + } + int lengthByteCount = length & 0x7F; + if (lengthByteCount == 0 || lengthByteCount > 4) { + return -1; // indefinite length (invalid in DER), or implausibly large + } + return 2 + lengthByteCount; // long form: length occupies the bytes which follow } /** diff --git a/Ghidra/Framework/Generic/src/main/java/ghidra/net/ServerCertificateException.java b/Ghidra/Framework/Generic/src/main/java/ghidra/net/ServerCertificateException.java new file mode 100644 index 0000000000..3bd0d1747e --- /dev/null +++ b/Ghidra/Framework/Generic/src/main/java/ghidra/net/ServerCertificateException.java @@ -0,0 +1,75 @@ +/* ### + * IP: GHIDRA + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ghidra.net; + +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; + +import javax.security.auth.x500.X500Principal; + +/** + * {@link ServerCertificateException} is thrown when TLS/SSL server + * certificate validation fails during an attempted server connection. + */ +public class ServerCertificateException extends CertificateException { + + private X509Certificate[] serverCertChain; + + /** + * Constructor + * @param serverCertChain server certificate chain + * @param cause underlying validation cause + */ + public ServerCertificateException(X509Certificate[] serverCertChain, CertificateException cause) { + super("Server authentication failed: " + getReason(cause) + getServerInfo(serverCertChain), + cause); + this.serverCertChain = serverCertChain; + } + + private static String getReason(CertificateException cause) { + Throwable originalCause = cause.getCause(); + if (originalCause != null) { + return originalCause.getMessage(); + } + return cause.getMessage(); + } + + private static String getServerInfo(X509Certificate[] serverCertChain) { + + X500Principal subject = serverCertChain[0].getSubjectX500Principal(); + X500Principal issuer = serverCertChain[0].getIssuerX500Principal(); + + StringBuilder buf = new StringBuilder(": "); + buf.append(subject.getName()); + buf.append(" ["); + if (issuer.equals(subject)) { + buf.append("self-signed"); + } + else { + buf.append("issued by: "); + buf.append(issuer.getName()); + } + buf.append("]"); + return buf.toString(); + } + + /** + * {@return server certificate chain} + */ + public X509Certificate[] getServerCertChain() { + return serverCertChain; + } +} diff --git a/Ghidra/Framework/Generic/src/main/java/ghidra/net/UnixSystemTrustKeyStoreUtil.java b/Ghidra/Framework/Generic/src/main/java/ghidra/net/UnixSystemTrustKeyStoreUtil.java new file mode 100644 index 0000000000..a9821e8fac --- /dev/null +++ b/Ghidra/Framework/Generic/src/main/java/ghidra/net/UnixSystemTrustKeyStoreUtil.java @@ -0,0 +1,292 @@ +/* ### + * IP: GHIDRA + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ghidra.net; + +import java.io.*; +import java.security.*; +import java.security.cert.*; +import java.security.cert.Certificate; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; + +import ghidra.util.Msg; +import ghidra.util.NumericUtilities; + +/** + * Builds a trust {@link KeyStore} backed by the host Linux (and BSD variants) operating system's + * known CA trust-store locations. + * + *

Rationale: Java ships no native "OS trust store" KeyStore provider on + * Linux/BSD (unlike "Windows-ROOT" on Windows or "KeychainStore" on macOS). + * Nearly every distro instead maintains one or more PEM bundle files (and/or + * a directory of individual PEM certs) kept in sync with the system's trust + * configuration by tooling such as update-ca-certificates, update-ca-trust, + * or p11-kit. This class scans the well-known locations for those bundles, + * parses every certificate it finds, de-duplicates by fingerprint, and loads + * the result into an in-memory KeyStore that is handed to a + * TrustManagerFactory. + * + *

Desktop environments (GNOME, KDE, XFCE, Cinnamon, etc.) don't keep a + * separate trust store for TLS -- they consume the same system bundle + * (often via p11-kit / NSS, which is itself synced from the paths below), so + * no desktop-manager-specific handling is required beyond this. + * + *

Only the trust configuration the OS has actually put into effect is used. The + * locations scanned are those which the OS tooling produces -- the extracted bundles of + * {@link #BUNDLE_FILES} and the enabled-certificate directories of {@link #BUNDLE_DIRS} -- and + * never the source/anchor directories which feed that tooling. Those source directories retain + * certificates which an administrator has since disabled: Debian and Ubuntu disable a CA by + * prefixing it with {@code !} in {@code /etc/ca-certificates.conf} (the file itself remaining + * under {@code /usr/share/ca-certificates}), and RHEL and Fedora distrust one through + * {@code /etc/pki/ca-trust/source/blocklist} while it may still sit in + * {@code source/anchors}. Reading a source directory would therefore re-trust exactly those + * authorities the administrator removed, so it is not done. + * + *

Known limitations: + *

    + *
  • Requires read access to the relevant system paths (usually + * world-readable, but not guaranteed in hardened/minimal images).
  • + *
  • Minimal container images (e.g. distroless, scratch) may not have any + * CA bundle installed at all -- no OS trust store is produced in that case + * and the caller falls back to the Java default trust store.
  • + *
  • Distros that route trust exclusively through a PKCS#11/NSS token + * rather than exporting a PEM bundle are not covered; nearly all + * mainstream distros do export a PEM bundle as of this writing.
  • + *
+ */ +public final class UnixSystemTrustKeyStoreUtil { + + private static final String UNIX_DEFAULT_CA_BUNDLE_PATH_PROPERTY = "ghidra.unix.default.cacerts"; + + /** Known single-file CA bundle locations, checked in this order. */ + private static final List BUNDLE_FILES = List.of( + // Debian / Ubuntu / Linux Mint / Alpine / Gentoo (app-misc/ca-certificates) + "/etc/ssl/certs/ca-certificates.crt", + // RHEL / CentOS / Fedora (post "update-ca-trust extract") + "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", + "/etc/pki/tls/certs/ca-bundle.crt", + // openSUSE / SLES + "/etc/ssl/ca-bundle.pem", + "/var/lib/ca-certificates/ca-bundle.pem", + // Arch Linux + "/etc/ca-certificates/extracted/tls-ca-bundle.pem", + // Generic OpenSSL default location (also used by several distros/BSDs) + "/etc/ssl/cert.pem", + // FreeBSD (security/ca_root_nss) + "/usr/local/share/certs/ca-root-nss.crt", + "/usr/local/etc/ssl/cert.pem" + // Note: OpenBSD's base-system bundle is also /etc/ssl/cert.pem, + // already covered above. + ); + + /** + * Known directories of individual PEM certs, scanned only when no bundle file was found. + *

+ * These hold the enabled certificates as published by the OS trust tooling. Source and + * anchor directories which feed that tooling ({@code /usr/share/ca-certificates} and + * {@code /etc/pki/ca-trust/source/anchors}) are deliberately absent: they also retain + * certificates the administrator has disabled or blocklisted, so reading them would re-trust + * authorities the OS no longer trusts. + */ + private static final List BUNDLE_DIRS = List.of( + "/etc/ssl/certs", // Debian/Ubuntu/Alpine/Gentoo/OpenBSD (hash-named symlinks) + "/usr/local/share/certs" // FreeBSD + ); + + private UnixSystemTrustKeyStoreUtil() { + } + + /** + * Scans the known OS trust-store locations and loads every distinct, valid CA certificate + * found into a new in-memory KeyStore. + *

+ * The locations are consulted in a defined order of precedence rather than being combined: + *

    + *
  1. the first of {@link #BUNDLE_FILES} which yields a CA certificate is used, and no + * further bundle is read. The bundles are not merged because a stale bundle left behind at + * another location would otherwise re-introduce certificate authorities which have since been + * removed from the one the OS currently maintains;
  2. + *
  3. {@link #BUNDLE_DIRS} is scanned only if no bundle file yielded anything, covering the + * distros which publish individual certificates rather than a bundle;
  4. + *
  5. a location given by the ghidra.unix.default.cacerts property is always applied in + * addition to the above, since it is an explicit administrative choice.
  6. + *
+ * + * @return a {@code PKCS12} KeyStore containing one {@code setCertificateEntry} + * per distinct CA certificate discovered (aliased {@code "system-ca-0"}, + * {@code "system-ca-1"}, ...); null returned if no CA certs were found. + * @throws KeyStoreException if the in-memory KeyStore cannot be created or + * populated (e.g. the {@code "PKCS12"} KeyStore type is unavailable) + * @throws IOException if an I/O error occurs while initializing the empty + * KeyStore + * @throws NoSuchAlgorithmException if the platform has no provider for the + * {@code "X.509"} CertificateFactory or the KeyStore's integrity + * algorithm + * @throws CertificateException if the KeyStore's own load step fails to + * process its (empty) certificate data + */ + static KeyStore loadSystemCaKeyStore() + throws KeyStoreException, IOException, NoSuchAlgorithmException, CertificateException { + + KeyStore keyStore = KeyStore.getInstance("PKCS12"); + keyStore.load(null, null); + + CertificateFactory certFactory = CertificateFactory.getInstance("X.509"); + Set seenFingerprints = new HashSet<>(); + AtomicInteger aliasCounter = new AtomicInteger(); + + // Use the first bundle which yields certificates; see precedence above + for (String path : BUNDLE_FILES) { + loadCaKeyStorePath(path, keyStore, certFactory, seenFingerprints, aliasCounter); + if (aliasCounter.get() != 0) { + break; + } + } + + if (aliasCounter.get() == 0) { + loadCaKeyStorePaths(BUNDLE_DIRS, keyStore, certFactory, seenFingerprints, aliasCounter); + } + + String optionalCaBundlePath = System.getProperty(UNIX_DEFAULT_CA_BUNDLE_PATH_PROPERTY); + if (optionalCaBundlePath != null && !optionalCaBundlePath.isBlank()) { + loadCaKeyStorePath(optionalCaBundlePath, keyStore, certFactory, seenFingerprints, + aliasCounter); + } + + if (aliasCounter.get() == 0) { + Msg.error(UnixSystemTrustKeyStoreUtil.class, """ + No readable OS CA certificate bundle found among known Linux/BSD locations. + The process may lack read permission, the ca-certificates package may not + be installed (common in minimal container images), or this OS distro's layout + isn't currently supported. + """); + return null; + } + return keyStore; + } + + private static void loadCaKeyStorePaths(List caStorePaths, KeyStore keyStore, CertificateFactory certFactory, Set seenFingerprints, AtomicInteger aliasCounter) { + for (String path : caStorePaths) { + loadCaKeyStorePath(path, keyStore, certFactory, seenFingerprints, aliasCounter); + } + } + + /** + * Load the CA certificates held by a single location, which may be either a bundle file or a + * directory of individual certificate files. + * + * @param path location to be loaded + * @param keyStore trust store being populated + * @param certFactory X.509 certificate factory + * @param seenFingerprints fingerprints of the certificates already added, for de-duplication + * @param aliasCounter running count of certificates added, used to alias each entry + * @return the number of certificates loaded from this location + */ + private static int loadCaKeyStorePath(String path, KeyStore keyStore, + CertificateFactory certFactory, Set seenFingerprints, + AtomicInteger aliasCounter) { + + File loc = new File(path); + int loadedCertCount = 0; + + if (!loc.isDirectory()) { + // Load individual CA store file + loadedCertCount = loadFile(loc, keyStore, certFactory, seenFingerprints, aliasCounter); + } + else { + // Load directory of CA store files + File[] files = loc.listFiles(); + if (files == null) { + return 0; + } + for (File f : files) { // NOTE: sub-directories will not be loaded + loadedCertCount += loadFile(f, keyStore, certFactory, seenFingerprints, aliasCounter); + } + } + + if (loadedCertCount != 0) { + Msg.info(UnixSystemTrustKeyStoreUtil.class, "Loaded " + loadedCertCount + " trusted CA certificate(s) from " + loc); + } + return loadedCertCount; + } + + private static int loadFile(File f, KeyStore keyStore, CertificateFactory certFactory, + Set seenFingerprints, AtomicInteger aliasCounter) { + if (!f.isFile() || !f.canRead() || f.length() == 0) { + return 0; + } + int loadedCertCount = 0; + try (InputStream in = new BufferedInputStream(new FileInputStream(f))) { + // CertificateFactory.generateCertificates handles a stream containing + // multiple concatenated PEM certificates (the normal "bundle" format). + for (Certificate cert : certFactory.generateCertificates(in)) { + if (!(cert instanceof X509Certificate)) { + continue; + } + X509Certificate x509 = (X509Certificate) cert; + if (!isCertificateAuthority(x509)) { + // Some scanned locations (notably /etc/ssl/certs) are general-purpose + // OpenSSL cert directories, not exclusively CA bundles -- skip anything + // that isn't actually marked as a CA so we never trust a stray leaf/server + // certificate as if it were a trust anchor. + continue; + } + if (addIfNew(keyStore, x509, seenFingerprints, aliasCounter.get())) { + aliasCounter.incrementAndGet(); + ++loadedCertCount; + } + } + } + catch (CertificateException | IOException | KeyStoreException + | NoSuchAlgorithmException e) { + // Not every file under e.g. /etc/ssl/certs or /usr/share/ca-certificates is a + // parseable certificate (broken symlinks, READMEs, etc.) -- skip and continue. + } + return loadedCertCount; + } + + /** + * Returns true if the certificate's BasicConstraints extension marks it as a + * CA (cA=true). Used to filter out non-CA (leaf/server/user) certificates that + * may be co-located in scanned directories -- not every location in + * BUNDLE_DIRS is exclusively reserved for CA certs by every distro or admin + * convention (e.g. /etc/ssl/certs is also OpenSSL's general-purpose CApath). + */ + private static boolean isCertificateAuthority(X509Certificate cert) { + // getBasicConstraints() returns the CA path-length constraint (>= 0, or + // Integer.MAX_VALUE if unlimited) when cA=true, and -1 otherwise. + return cert.getBasicConstraints() != -1; + } + + private static boolean addIfNew(KeyStore keyStore, X509Certificate cert, + Set seenFingerprints, int aliasCounter) + throws KeyStoreException, CertificateEncodingException, NoSuchAlgorithmException { + String fingerprint = sha256Fingerprint(cert); + if (!seenFingerprints.add(fingerprint)) { + return false; // duplicate cert, already present + } + keyStore.setCertificateEntry("system-ca-" + aliasCounter, cert); + return true; + } + + private static String sha256Fingerprint(X509Certificate cert) + throws CertificateEncodingException, NoSuchAlgorithmException { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(cert.getEncoded()); + return NumericUtilities.convertBytesToString(digest); + } + +} diff --git a/Ghidra/Framework/Generic/src/test/java/ghidra/net/ApplicationKeyManagerFactoryTest.java b/Ghidra/Framework/Generic/src/test/java/ghidra/net/ApplicationKeyManagerFactoryTest.java index 59d16c3345..f76bbbadbe 100644 --- a/Ghidra/Framework/Generic/src/test/java/ghidra/net/ApplicationKeyManagerFactoryTest.java +++ b/Ghidra/Framework/Generic/src/test/java/ghidra/net/ApplicationKeyManagerFactoryTest.java @@ -26,6 +26,7 @@ import javax.net.ssl.X509ExtendedKeyManager; import org.junit.*; import generic.test.AbstractGenericTest; +import ghidra.framework.OperatingSystem; import ghidra.security.KeyStorePasswordProvider; public class ApplicationKeyManagerFactoryTest extends AbstractGenericTest { @@ -52,7 +53,7 @@ public class ApplicationKeyManagerFactoryTest extends AbstractGenericTest { state = 0; return null; } - if (state == 0) { // enter wrong password once + if (state == 0) { // enter wrong password once state = 1; return "BAD".toCharArray(); } @@ -72,8 +73,8 @@ public class ApplicationKeyManagerFactoryTest extends AbstractGenericTest { keystoreFile = createTempFile("test-key", ".p12"); keystoreFile.delete(); - PKIUtils.createKeyStore(ALIAS, TEST_IDENTITY, 2, null, keystoreFile, "PKCS12", null, - TEST_PWD.toCharArray()); + PKIUtils.createKeyStore(ALIAS, TEST_IDENTITY, 2, null, false, keystoreFile, "PKCS12", null, + TEST_PWD.toCharArray()); ApplicationKeyManagerFactory.setKeyStorePasswordProvider(passwordProvider); } @@ -88,39 +89,63 @@ public class ApplicationKeyManagerFactoryTest extends AbstractGenericTest { @Test public void testCancelledPasswordOnSetCertificate() throws Exception { + assertFalse(DefaultKeyManagerFactory.isServerMode()); + assertNull(DefaultKeyManagerFactory.getPreferredKeyStore()); assertNull(DefaultKeyManagerFactory.getKeyStore()); X509ExtendedKeyManager keyManager = DefaultKeyManagerFactory.getKeyManager(); assertNotNull(keyManager); - // verify that no certs are installed - assertNull(keyManager.getCertificateChain(ALIAS)); - assertNull(keyManager.getClientAliases("RSA", null)); + // With no keystore specified, a client default identity is always established + // (OS-managed keystore or auto-generated self-signed client certificate). + // NOTE: must not assert specific aliases which depend on host OS keystore + // content. + assertTrue(DefaultKeyManagerFactory.initialize()); + if (OperatingSystem.CURRENT_OPERATING_SYSTEM == OperatingSystem.WINDOWS + || OperatingSystem.CURRENT_OPERATING_SYSTEM == OperatingSystem.MAC_OS_X) { + assertTrue(DefaultKeyManagerFactory.usingOSManagedKeyStore()); // OS key store will be used + } else { + assertFalse(DefaultKeyManagerFactory.usingOSManagedKeyStore()); + } + assertFalse(DefaultKeyManagerFactory.usingFileKeyStore()); + assertFalse(DefaultKeyManagerFactory.usingGeneratedSelfSignedCertificate()); + + assertNull(DefaultKeyManagerFactory.getKeyStore()); passwordProvider.cancelNextEntry(); + assertFalse(DefaultKeyManagerFactory.setDefaultKeyStore(keystoreFile.getAbsolutePath(), false)); - DefaultKeyManagerFactory.setDefaultKeyStore(keystoreFile.getAbsolutePath(), false); - - // verify that no certs are installed + // verify that keystore file was not activated assertEquals(null, DefaultKeyManagerFactory.getKeyStore()); assertNull(keyManager.getCertificateChain(ALIAS)); - assertNull(keyManager.getClientAliases("RSA", null)); + + // verify no impact to key manager state + if (OperatingSystem.CURRENT_OPERATING_SYSTEM == OperatingSystem.WINDOWS + || OperatingSystem.CURRENT_OPERATING_SYSTEM == OperatingSystem.MAC_OS_X) { + assertTrue(DefaultKeyManagerFactory.usingOSManagedKeyStore()); + } else { + assertFalse(DefaultKeyManagerFactory.usingOSManagedKeyStore()); + } + assertFalse(DefaultKeyManagerFactory.usingFileKeyStore()); + } @Test public void testSetClearCertificate() throws Exception { + assertFalse(DefaultKeyManagerFactory.isServerMode()); + assertNull(DefaultKeyManagerFactory.getPreferredKeyStore()); assertNull(DefaultKeyManagerFactory.getKeyStore()); X509ExtendedKeyManager keyManager = DefaultKeyManagerFactory.getKeyManager(); assertNotNull(keyManager); - // verify that no certs are installed - assertNull(keyManager.getCertificateChain(ALIAS)); - assertNull(keyManager.getClientAliases("RSA", null)); - DefaultKeyManagerFactory.setDefaultKeyStore(keystoreFile.getAbsolutePath(), false); // verify that generated cert is installed assertEquals(keystoreFile.getAbsolutePath(), DefaultKeyManagerFactory.getKeyStore()); + assertFalse(DefaultKeyManagerFactory.usingOSManagedKeyStore()); + assertFalse(DefaultKeyManagerFactory.usingGeneratedSelfSignedCertificate()); + assertTrue(DefaultKeyManagerFactory.usingFileKeyStore()); + X509Certificate[] chain = keyManager.getCertificateChain(ALIAS); assertNotNull(chain); String[] aliases = keyManager.getClientAliases("RSA", new Principal[0]); // any CA allowed @@ -151,13 +176,15 @@ public class ApplicationKeyManagerFactoryTest extends AbstractGenericTest { } // clear keystore - DefaultKeyManagerFactory.setDefaultKeyStore(null, false); - - // verify that no certs are installed - assertNull(DefaultKeyManagerFactory.getKeyStore()); - assertNull(keyManager.getCertificateChain(ALIAS)); - assertNull(keyManager.getClientAliases("RSA", null)); - + assertTrue(DefaultKeyManagerFactory.setDefaultKeyStore(null, false)); + if (OperatingSystem.CURRENT_OPERATING_SYSTEM == OperatingSystem.WINDOWS + || OperatingSystem.CURRENT_OPERATING_SYSTEM == OperatingSystem.MAC_OS_X) { + assertTrue(DefaultKeyManagerFactory.usingOSManagedKeyStore()); // OS key store will be used + } else { + assertFalse(DefaultKeyManagerFactory.usingOSManagedKeyStore()); + } + assertFalse(DefaultKeyManagerFactory.usingGeneratedSelfSignedCertificate()); + assertFalse(DefaultKeyManagerFactory.usingFileKeyStore()); } } diff --git a/Ghidra/Framework/Generic/src/test/java/ghidra/net/PKITestUtils.java b/Ghidra/Framework/Generic/src/test/java/ghidra/net/PKITestUtils.java new file mode 100644 index 0000000000..c945a37f13 --- /dev/null +++ b/Ghidra/Framework/Generic/src/test/java/ghidra/net/PKITestUtils.java @@ -0,0 +1,81 @@ +/* ### + * IP: GHIDRA + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ghidra.net; + +import java.io.File; +import java.security.*; +import java.security.KeyStore.PasswordProtection; +import java.security.KeyStore.PrivateKeyEntry; +import java.security.cert.X509Certificate; +import java.util.Collection; + +import javax.security.auth.DestroyFailedException; + +import org.bouncycastle.asn1.x509.KeyUsage; + +import ghidra.util.exception.AssertException; + +public class PKITestUtils { + + private PKITestUtils() { + // no construct + } + + /** + * Generate a new {@link X509Certificate} with RSA {@link KeyPair} and create/update a {@link KeyStore} + * optionally backed by a keyFile. + *

+ * Standard usage will include {@link KeyUsage#digitalSignature} and {@link KeyUsage#keyEncipherment}. + * For non-CA extended usage will include serverAuth and clientAuth for maximum compatibility. + * + * @param alias entry alias with keystore + * @param dn distinguished name (e.g., "CN=Ghidra Test, O=Ghidra, OU=Test, C=US" ) + * @param durationDays number of days which generated certificate should remain valid + * @param caEntry optional CA private key entry (issuer of new certificate). If null, new + * certificate will sign itself (self-signed). + * @param isCA if true the new certificate will tagged as a CA + * @param keyFile optional file to load/store resulting {@link KeyStore} (may be null) + * @param keystoreType support keystore type (e.g., "JKS", "PKCS12") + * @param subjectAlternativeNames an optional list of subject alternative names to be included + * in certificate (may be null) + * @param protectedPassphrase key and keystore protection password + * @return newly generated keystore entry with key pair + * @throws KeyStoreException if error occurs while updating keystore + */ + public static final PrivateKeyEntry createKeyEntry(String alias, String dn, int durationDays, + PrivateKeyEntry caEntry, boolean isCA, File keyFile, String keystoreType, + Collection subjectAlternativeNames, char[] protectedPassphrase) + throws KeyStoreException { + + PasswordProtection pp = new PasswordProtection(protectedPassphrase); + try { + KeyStore keyStore = PKIUtils.createKeyStore(alias, dn, durationDays, caEntry, isCA, keyFile, + keystoreType, subjectAlternativeNames, protectedPassphrase); + return (PrivateKeyEntry) keyStore.getEntry(alias, pp); + } + catch (NoSuchAlgorithmException | UnrecoverableEntryException e) { + throw new KeyStoreException("Failed to generate/store certificate (" + dn + ")", e); + } + finally { + try { + pp.destroy(); + } + catch (DestroyFailedException e) { + throw new AssertException(e); // unexpected for simple password clearing + } + } + } +} diff --git a/Ghidra/Framework/Generic/src/test/java/ghidra/net/PKIUtilsTest.java b/Ghidra/Framework/Generic/src/test/java/ghidra/net/PKIUtilsTest.java new file mode 100644 index 0000000000..085a21179f --- /dev/null +++ b/Ghidra/Framework/Generic/src/test/java/ghidra/net/PKIUtilsTest.java @@ -0,0 +1,663 @@ +/* ### + * IP: GHIDRA + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ghidra.net; + +import static org.junit.Assert.*; + +import java.io.*; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.security.KeyStore; +import java.security.KeyStore.PrivateKeyEntry; +import java.security.KeyStoreException; +import java.security.cert.*; +import java.util.*; + +import javax.net.ssl.X509TrustManager; + +import org.junit.*; + +import generic.test.AbstractGenericTest; +import utilities.util.FileUtilities; + +/** + * Tests for {@link PKIUtils} certificate and key store handling: generation of key stores in each + * supported format, detection of the format of a file from its content, and the loading of + * certificates from PEM and DER certificate files and from key stores. + *

+ * Generating an RSA key pair of {@link PKIUtils#KEY_SIZE} bits dominates the cost of these tests + * (hundreds of milliseconds each), so every certificate which is only ever read is generated once + * for the class rather than once per test, and the key store files which individual tests merely + * need to exist are copied from one generated for the class. + */ +public class PKIUtilsTest extends AbstractGenericTest { + + private static final char[] PWD = "!test-password!".toCharArray(); + + private static final String CA_DN = "CN=Ghidra Test CA, O=Ghidra, OU=Test, C=US"; + private static final String USER_DN = "CN=Ghidra Test User, O=Ghidra, OU=Test, C=US"; + + // Immutable certificate fixtures, generated once for the class + private static PrivateKeyEntry caEntry; + private static X509Certificate caCert; + private static X509Certificate userCert; + private static X509Certificate renewedCaCert; + private static X509Certificate otherCaCert; + private static X509Certificate noCommonNameCert; + + // Key store files generated once for the class and copied by keyStoreFile + private static File sharedDir; + private static File sharedPkcs12; + private static File sharedJks; + + private File tempDir; + + @BeforeClass + public static void setUpClass() throws Exception { + + // Self-signed CA, and an end-entity certificate which it issues + caEntry = PKITestUtils.createKeyEntry("ca", CA_DN, 2, null, true, null, + PKIUtils.PKCS12_TYPE, null, PWD); + caCert = (X509Certificate) caEntry.getCertificate(); + + userCert = (X509Certificate) PKITestUtils.createKeyEntry("user", USER_DN, 2, caEntry, false, + null, PKIUtils.PKCS12_TYPE, null, PWD).getCertificate(); + + // A second, distinct CA which shares the first one's common name + renewedCaCert = (X509Certificate) PKITestUtils.createKeyEntry("ca2", CA_DN, 2, null, true, + null, PKIUtils.PKCS12_TYPE, null, PWD).getCertificate(); + + // An unrelated CA, which issued none of the certificates above + otherCaCert = (X509Certificate) PKITestUtils.createKeyEntry("otherca", + "CN=Other Test CA, O=Ghidra, C=US", 2, null, true, null, PKIUtils.PKCS12_TYPE, null, + PWD).getCertificate(); + + // A certificate whose distinguished name specifies no common name + noCommonNameCert = (X509Certificate) PKITestUtils.createKeyEntry("nocn", "O=Ghidra, C=US", 2, + null, false, null, PKIUtils.PKCS12_TYPE, null, PWD).getCertificate(); + + // Key store files holding a private key entry, copied per test by keyStoreFile. These + // re-use the key pair generated above rather than generating further ones. + sharedDir = Files.createTempDirectory("pkiutils-shared").toFile(); + sharedPkcs12 = writeSharedKeyStore("shared.p12", PKIUtils.PKCS12_TYPE); + sharedJks = writeSharedKeyStore("shared.jks", PKIUtils.JKS_TYPE); + } + + /** + * @return a key store of the specified type holding the already-generated key pair under the + * alias "alias", protected by {@link #PWD} + * @throws Exception if an unexpected keystore or cryptographic error occurs + */ + private static File writeSharedKeyStore(String name, String keyStoreType) throws Exception { + File f = new File(sharedDir, name); + KeyStore ks = KeyStore.getInstance(keyStoreType); + ks.load(null, null); + ks.setKeyEntry("alias", caEntry.getPrivateKey(), PWD, caEntry.getCertificateChain()); + try (FileOutputStream out = new FileOutputStream(f)) { + ks.store(out, PWD); + } + return f; + } + + @AfterClass + public static void tearDownClass() { + restoreWritePermission(sharedDir); + FileUtilities.deleteDir(sharedDir); + } + + @Before + public void setUp() throws Exception { + tempDir = createTempDirectory("pkiutils"); + } + + /** + * Restore write permission to everything which was generated, so that the test framework is + * able to remove the temporary directory. + *

+ * {@link PKIUtils#saveKeyStore(KeyStore, File, char[])} and + * {@link PKIUtils#exportX509Certificates(java.security.cert.Certificate[], File)} both clear + * write permission on the file they produce. On Windows that sets the read-only attribute, + * which prevents the file from being deleted at all (unlike POSIX, where deletion is governed + * by the permissions of the containing directory), leaving the temporary directory behind. + * @throws Exception if an unexpected error occurs during cleanup + */ + @After + public void tearDown() throws Exception { + restoreWritePermission(tempDir); + } + + private static void restoreWritePermission(File file) { + if (file == null || !file.exists()) { + return; + } + file.setWritable(true, true); + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) { + restoreWritePermission(child); + } + } + } + + private File file(String name) { + return new File(tempDir, name); + } + + /** + * Generate a new key store file of the specified type containing a private key entry. This + * incurs a key pair generation and is used only where creating the file is itself under test; + * {@link #keyStoreFile(String, String)} is used otherwise. + * + * @return the generated key store file + */ + private File generateKeyStoreFile(String name, String keyStoreType) throws KeyStoreException { + File f = file(name); + PKIUtils.createKeyStore("alias", USER_DN, 2, null, false, f, keyStoreType, null, PWD); + return f; + } + + /** + * @return a copy of the key store generated for the class, of the specified type, holding a + * private key entry under the alias "alias". A copy is taken rather than generating a new key + * store, since a test which merely requires such a file to exist should not incur a key pair + * generation. + */ + private File keyStoreFile(String name, String keyStoreType) throws IOException { + File shared = PKIUtils.PKCS12_TYPE.equals(keyStoreType) ? sharedPkcs12 : sharedJks; + File f = file(name); + Files.copy(shared.toPath(), f.toPath(), StandardCopyOption.REPLACE_EXISTING); + f.setWritable(true, true); // the generated key store is read-only + return f; + } + + /** + * @return a key store file of the specified type holding only trusted certificate entries, + * written with a password (which for PKCS#12 encrypts those entries by default) + * @throws Exception if an unexpected keystore or cryptographic error occurs + */ + private File writeCertificateKeyStore(String name, String keyStoreType, X509Certificate... certs) + throws Exception { + File f = file(name); + KeyStore ks = KeyStore.getInstance(keyStoreType); + ks.load(null, null); + int i = 0; + for (X509Certificate cert : certs) { + ks.setCertificateEntry("ca" + (i++), cert); + } + try (FileOutputStream out = new FileOutputStream(f)) { + ks.store(out, PWD); + } + return f; + } + + private File writeDer(String name, X509Certificate... certs) throws Exception { + File f = file(name); + try (FileOutputStream out = new FileOutputStream(f)) { + for (X509Certificate cert : certs) { + out.write(cert.getEncoded()); + } + } + return f; + } + + private File writePem(String name, Certificate... certs) throws Exception { + File f = file(name); + PKIUtils.exportX509Certificates(certs, f); + f.setWritable(true, true); // exportX509Certificates clears write permission + return f; + } + + private File writeText(String name, String contents) throws IOException { + File f = file(name); + Files.writeString(f.toPath(), contents); + return f; + } + + //================================================================================== + // createKeyStore / getKeyStoreInstance + //================================================================================== + + @Test + public void testCreateAndLoadPkcs12KeyStore() throws Exception { + File f = generateKeyStoreFile("store.p12", PKIUtils.PKCS12_TYPE); + assertTrue("key store file was not created", f.isFile()); + + KeyStore ks = PKIUtils.getKeyStoreInstance(f.getAbsolutePath(), PWD); + assertEquals(1, ks.size()); + assertTrue("expected a private key entry", ks.isKeyEntry("alias")); + } + + @Test + public void testCreateAndLoadJksKeyStore() throws Exception { + File f = generateKeyStoreFile("store.jks", PKIUtils.JKS_TYPE); + assertTrue("key store file was not created", f.isFile()); + + KeyStore ks = PKIUtils.getKeyStoreInstance(f.getAbsolutePath(), PWD); + assertEquals(1, ks.size()); + assertTrue("expected a private key entry", ks.isKeyEntry("alias")); + } + + @Test + public void testGeneratedCaCertificate() throws Exception { + assertNotEquals("CA certificate must assert the CA basic constraint", -1, + caCert.getBasicConstraints()); + caCert.checkValidity(); + assertEquals(caCert.getSubjectX500Principal(), caCert.getIssuerX500Principal()); + caCert.verify(caCert.getPublicKey()); // self-signed + } + + @Test + public void testGeneratedUserCertificateIsIssuedByCa() throws Exception { + assertEquals("end-entity certificate must not assert the CA basic constraint", -1, + userCert.getBasicConstraints()); + userCert.checkValidity(); + assertEquals(caCert.getSubjectX500Principal(), userCert.getIssuerX500Principal()); + userCert.verify(caCert.getPublicKey()); // signed by the CA + + List eku = userCert.getExtendedKeyUsage(); + assertNotNull("end-entity certificate should specify extended key usage", eku); + assertTrue("expected clientAuth extended key usage", eku.contains("1.3.6.1.5.5.7.3.2")); + } + + @Test + public void testGetCommonName() throws Exception { + assertEquals("Ghidra Test CA", PKIUtils.getCommonName(caCert)); + assertEquals("Ghidra Test User", PKIUtils.getCommonName(userCert)); + } + + @Test + public void testGetCommonNameWithoutCommonName() throws Exception { + assertNull(PKIUtils.getCommonName(noCommonNameCert)); + } + + //================================================================================== + // Encrypted key stores + //================================================================================== + + /** + * Encryption within a key store applies to the entries it contains and never to the header + * which identifies its format, so {@link PKIUtils#detectKeyStoreType(String)} is unaffected by + * it. A PKCS#12 PFX in particular carries its INTEGER version in the clear ahead of any + * encrypted content, which is what identifies it. + * + * @throws Exception if an unexpected keystore or cryptographic error occurs + */ + @Test + public void testDetectEncryptedKeyStores() throws Exception { + // key stores holding an encrypted private key + File p12Key = keyStoreFile("enc-key.p12", PKIUtils.PKCS12_TYPE); + File jksKey = keyStoreFile("enc-key.jks", PKIUtils.JKS_TYPE); + assertEquals(PKIUtils.PKCS12_TYPE, PKIUtils.detectKeyStoreType(p12Key.getAbsolutePath())); + assertEquals(PKIUtils.JKS_TYPE, PKIUtils.detectKeyStoreType(jksKey.getAbsolutePath())); + + // a store whose certificate entries are themselves encrypted is still identified + File p12Certs = writeCertificateKeyStore("enc-certs.p12", PKIUtils.PKCS12_TYPE, caCert); + File jksCerts = writeCertificateKeyStore("enc-certs.jks", PKIUtils.JKS_TYPE, caCert); + assertEquals(PKIUtils.PKCS12_TYPE, PKIUtils.detectKeyStoreType(p12Certs.getAbsolutePath())); + assertEquals(PKIUtils.JKS_TYPE, PKIUtils.detectKeyStoreType(jksCerts.getAbsolutePath())); + } + + @Test + public void testEncryptedKeyStoreRequiresCorrectPassword() throws Exception { + for (String type : new String[] { PKIUtils.PKCS12_TYPE, PKIUtils.JKS_TYPE }) { + File f = keyStoreFile("pwd-" + type + ".ks", type); + + // the correct password recovers the encrypted private key + KeyStore ks = PKIUtils.getKeyStoreInstance(f.getAbsolutePath(), PWD); + assertNotNull(type + ": expected the private key to be recoverable", + ks.getKey("alias", PWD)); + + try { + PKIUtils.getKeyStoreInstance(f.getAbsolutePath(), "wrong-password".toCharArray()); + fail(type + ": expected an incorrect key store password to be rejected"); + } + catch (IOException e) { + // expected - the store's integrity check fails + } + } + } + + /** + * A private key remains encrypted even where the entry holding it can be enumerated without a + * password. + * + * @throws Exception if an unexpected keystore or cryptographic error occurs + */ + @Test + public void testEncryptedPrivateKeyNotRecoverableWithoutPassword() throws Exception { + File f = keyStoreFile("keyonly.p12", PKIUtils.PKCS12_TYPE); + + KeyStore ks = PKIUtils.getKeyStoreInstance(f.getAbsolutePath(), null); + assertEquals("the entry itself remains enumerable", 1, ks.size()); + try { + ks.getKey("alias", null); + fail("expected the private key to be unrecoverable without a password"); + } + catch (Exception e) { + // expected - the key material is encrypted + } + } + + /** + * A trust store is loaded without supplying a password, which is only able to yield its + * certificates where those entries are not themselves encrypted. A PKCS#12 store written with + * the JDK defaults encrypts them, and so appears empty rather than failing - which is why an + * empty result must be reported as a failure to load rather than as an empty trust store. + * + * @throws Exception if an unexpected keystore or cryptographic error occurs + */ + @Test + public void testEncryptedCertificateEntriesAreNotReadableWithoutPassword() throws Exception { + File f = writeCertificateKeyStore("certs-enc.p12", PKIUtils.PKCS12_TYPE, caCert); + + KeyStore withPassword = PKIUtils.getKeyStoreInstance(f.getAbsolutePath(), PWD); + assertEquals(1, withPassword.size()); + + KeyStore withoutPassword = PKIUtils.getKeyStoreInstance(f.getAbsolutePath(), null); + assertEquals("encrypted certificate entries must not be readable without the password", 0, + withoutPassword.size()); + } + + /** + * JKS holds trusted certificate entries unencrypted, so a JKS trust store is readable without a + * password even though the store as a whole was written with one. + * + * @throws Exception if an unexpected keystore or cryptographic error occurs + */ + @Test + public void testJksCertificateEntriesAreReadableWithoutPassword() throws Exception { + File f = writeCertificateKeyStore("certs.jks", PKIUtils.JKS_TYPE, caCert); + + KeyStore ks = PKIUtils.getKeyStoreInstance(f.getAbsolutePath(), null); + assertEquals(1, ks.size()); + assertEquals(caCert, ks.getCertificate("ca0")); + } + + /** + * The Java default cacerts is a PKCS#12 store whose certificate entries are deliberately + * left unencrypted so that it is readable without a password, which is what allows it to be + * used as a trust store. + * + * @throws Exception if an unexpected keystore or cryptographic error occurs + */ + @Test + public void testUnencryptedCertificateEntriesAreReadableWithoutPassword() throws Exception { + File cacerts = new File(System.getProperty("java.home"), "lib/security/cacerts"); + if (!cacerts.isFile()) { + return; // not present in every JRE layout + } + KeyStore ks = PKIUtils.getKeyStoreInstance(cacerts.getAbsolutePath(), null); + assertTrue("expected the default cacerts to be readable without a password", ks.size() > 0); + } + + //================================================================================== + // detectKeyStoreType + //================================================================================== + + @Test + public void testDetectJks() throws Exception { + File f = keyStoreFile("detect.jks", PKIUtils.JKS_TYPE); + assertEquals(PKIUtils.JKS_TYPE, PKIUtils.detectKeyStoreType(f.getAbsolutePath())); + } + + @Test + public void testDetectPkcs12() throws Exception { + File f = keyStoreFile("detect.p12", PKIUtils.PKCS12_TYPE); + assertEquals(PKIUtils.PKCS12_TYPE, PKIUtils.detectKeyStoreType(f.getAbsolutePath())); + } + + /** + * A DER encoded certificate begins with the same ASN.1 SEQUENCE tag as a PKCS#12 key store and + * must not be mistaken for one - it is not a key store. + * + * @throws Exception if an unexpected keystore or cryptographic error occurs + */ + @Test + public void testDetectDerCertificateIsNotAKeyStore() throws Exception { + File f = writeDer("cert.der", caCert); + assertNull("a DER certificate is not a key store", + PKIUtils.detectKeyStoreType(f.getAbsolutePath())); + } + + /** + * A PKCS#12 store large enough to encode its outer SEQUENCE length in DER long form is still + * identified, since its INTEGER version element is not then found at a fixed offset. The store + * holding a {@link PKIUtils#KEY_SIZE}-bit key is far larger than the short form's 127-byte limit, + * so it is generated for this test rather than depending on the ambient {@code cacerts} (which is + * PKCS#12 for a stock JDK, but a JKS file where the platform supplies the trust store). + * + * @throws Exception if an unexpected keystore or cryptographic error occurs + */ + @Test + public void testDetectPkcs12WithLongFormLength() throws Exception { + File f = keyStoreFile("longform.p12", PKIUtils.PKCS12_TYPE); + + // Confirm this fixture actually exercises the long-form path: a SEQUENCE tag (0x30) + // followed by a length byte whose high bit is set. + byte[] header = Files.readAllBytes(f.toPath()); + assertEquals("expected a DER SEQUENCE", 0x30, header[0] & 0xFF); + assertTrue("fixture should use a DER long-form length", (header[1] & 0x80) != 0); + + assertEquals(PKIUtils.PKCS12_TYPE, PKIUtils.detectKeyStoreType(f.getAbsolutePath())); + } + + @Test + public void testDetectPemCertificateIsNotAKeyStore() throws Exception { + File f = writePem("cert.pem", caCert); + assertNull(PKIUtils.detectKeyStoreType(f.getAbsolutePath())); + } + + @Test + public void testDetectNonKeyStoreContent() throws Exception { + assertNull("text file", PKIUtils.detectKeyStoreType( + writeText("garbage.txt", "this is not a key store\n").getAbsolutePath())); + assertNull("empty file", + PKIUtils.detectKeyStoreType(writeText("empty.bin", "").getAbsolutePath())); + assertNull("file shorter than any header", + PKIUtils.detectKeyStoreType(writeText("short.bin", "0").getAbsolutePath())); + // A SEQUENCE tag alone, with no length or content + File truncated = file("truncated.der"); + try (FileOutputStream out = new FileOutputStream(truncated)) { + out.write(new byte[] { 0x30 }); + } + assertNull("truncated DER", PKIUtils.detectKeyStoreType(truncated.getAbsolutePath())); + } + + @Test(expected = IOException.class) + public void testDetectMissingFile() throws Exception { + PKIUtils.detectKeyStoreType(file("does-not-exist").getAbsolutePath()); + } + + //================================================================================== + // exportX509Certificates / loadX509PemCertificates + //================================================================================== + + @Test + public void testPemExportImportRoundTrip() throws Exception { + File f = writePem("bundle.pem", userCert, caCert); + + List certs = PKIUtils.loadX509PemCertificates(f); + assertEquals(2, certs.size()); + assertEquals(userCert, certs.get(0)); + assertEquals(caCert, certs.get(1)); + } + + @Test + public void testLoadPemStrictRejectsMalformedBlock() throws Exception { + File f = writePem("bundle.pem", caCert); + String contents = Files.readString(f.toPath()) + + "-----BEGIN CERTIFICATE-----\nnot valid base64 !!!\n-----END CERTIFICATE-----\n"; + File mixed = writeText("mixed.pem", contents); + + try { + PKIUtils.loadX509PemCertificates(mixed); + fail("expected a malformed PEM block to be rejected"); + } + catch (CertificateException e) { + // expected - a file supplied for validation must report its defects + } + } + + @Test + public void testLoadPemStrictRejectsNonCertificateBlock() throws Exception { + File f = writeText("key.pem", + "-----BEGIN RSA PRIVATE KEY-----\nAAAA\n-----END RSA PRIVATE KEY-----\n"); + try { + PKIUtils.loadX509PemCertificates(f); + fail("expected a non-certificate PEM block to be rejected"); + } + catch (CertificateException e) { + // expected + } + } + + @Test + public void testLoadPemTolerantSkipsMalformedBlock() throws Exception { + File f = writePem("bundle.pem", userCert, caCert); + String contents = Files.readString(f.toPath()) + + "-----BEGIN CERTIFICATE-----\nnot valid base64 !!!\n-----END CERTIFICATE-----\n"; + File mixed = writeText("mixed.pem", contents); + + List problems = new ArrayList<>(); + List certs = PKIUtils.loadX509PemCertificates(mixed, problems::add); + + assertEquals("valid certificates must still be read", 2, certs.size()); + assertEquals("the unusable block must be reported", 1, problems.size()); + assertTrue("report should identify the block: " + problems.get(0), + problems.get(0).contains("PEM block")); + } + + @Test + public void testLoadPemTolerantSkipsNonCertificateBlock() throws Exception { + File f = writePem("bundle.pem", caCert); + String contents = Files.readString(f.toPath()) + + "-----BEGIN RSA PRIVATE KEY-----\nAAAA\n-----END RSA PRIVATE KEY-----\n"; + File mixed = writeText("mixed.pem", contents); + + List problems = new ArrayList<>(); + List certs = PKIUtils.loadX509PemCertificates(mixed, problems::add); + + assertEquals(1, certs.size()); + assertEquals(1, problems.size()); + } + + @Test + public void testLoadPemEmptyFile() throws Exception { + File f = writeText("empty.pem", ""); + assertTrue(PKIUtils.loadX509PemCertificates(f).isEmpty()); + } + + //================================================================================== + // loadCertificateStore / loadX509CertificateStore + //================================================================================== + + @Test + public void testLoadCertificateStoreFromPem() throws Exception { + File f = writePem("bundle.pem", userCert, caCert); + + KeyStore ks = PKIUtils.loadCertificateStore(f.getAbsolutePath()); + assertEquals(2, ks.size()); + assertTrue("certificates should be trusted entries", containsCertificate(ks, caCert)); + assertTrue(containsCertificate(ks, userCert)); + } + + @Test + public void testLoadCertificateStoreFromDer() throws Exception { + File f = writeDer("cert.der", caCert); + + KeyStore ks = PKIUtils.loadCertificateStore(f.getAbsolutePath()); + assertEquals(1, ks.size()); + assertTrue(containsCertificate(ks, caCert)); + } + + @Test + public void testLoadX509CertificateStoreCountsCertificates() throws Exception { + File f = writePem("bundle.pem", userCert, caCert); + + List consumed = new ArrayList<>(); + int count = PKIUtils.loadX509CertificateStore(f.getAbsolutePath(), consumed::add); + assertEquals(2, count); + assertEquals(2, consumed.size()); + } + + /** + * Certificates sharing a common name must not displace one another within the loaded store, + * which would silently reduce the set of trusted authorities. + * + * @throws Exception if an unexpected keystore or cryptographic error occurs + */ + @Test + public void testLoadCertificateStoreRetainsCertificatesWithDuplicateCommonName() + throws Exception { + assertNotEquals("expected two distinct certificates", caCert, renewedCaCert); + + File f = writePem("dupcn.pem", caCert, renewedCaCert); + KeyStore ks = PKIUtils.loadCertificateStore(f.getAbsolutePath()); + + assertEquals("both certificates must be retained", 2, ks.size()); + assertTrue(containsCertificate(ks, caCert)); + assertTrue(containsCertificate(ks, renewedCaCert)); + } + + //================================================================================== + // getTrustManager + //================================================================================== + + @Test + public void testTrustManagerAcceptsCertificateIssuedByLoadedAuthority() throws Exception { + File caFile = writePem("ca.pem", caCert); + + X509TrustManager trustManager = PKIUtils.getTrustManager(caFile); + X509Certificate[] issuers = trustManager.getAcceptedIssuers(); + assertEquals(1, issuers.length); + assertEquals(caCert, issuers[0]); + + // the certificate the CA issued must be accepted + trustManager.checkClientTrusted(new X509Certificate[] { userCert, caCert }, + PKIUtils.RSA_TYPE); + } + + @Test + public void testTrustManagerRejectsCertificateFromUnknownAuthority() throws Exception { + File caFile = writePem("otherca.pem", otherCaCert); + + X509TrustManager trustManager = PKIUtils.getTrustManager(caFile); + try { + trustManager.checkClientTrusted(new X509Certificate[] { userCert }, PKIUtils.RSA_TYPE); + fail("expected a certificate from an unknown authority to be rejected"); + } + catch (CertificateException e) { + // expected + } + } + + @Test(expected = IOException.class) + public void testTrustManagerMissingFile() throws Exception { + PKIUtils.getTrustManager(file("does-not-exist.pem")); + } + + //================================================================================== + + private static boolean containsCertificate(KeyStore ks, X509Certificate cert) + throws KeyStoreException { + for (String alias : Collections.list(ks.aliases())) { + if (cert.equals(ks.getCertificate(alias))) { + return true; + } + } + return false; + } +} diff --git a/Ghidra/Framework/Project/Module.manifest b/Ghidra/Framework/Project/Module.manifest index dcb42d3eba..b12866bc09 100644 --- a/Ghidra/Framework/Project/Module.manifest +++ b/Ghidra/Framework/Project/Module.manifest @@ -1 +1 @@ -MODULE FILE LICENSE: lib/xz-1.9.jar Public Domain +MODULE FILE LICENSE: lib/xz-1.9.jar Public Domain \ No newline at end of file diff --git a/Ghidra/Framework/Project/build.gradle b/Ghidra/Framework/Project/build.gradle index 065c2757c0..71d505fb6a 100644 --- a/Ghidra/Framework/Project/build.gradle +++ b/Ghidra/Framework/Project/build.gradle @@ -4,9 +4,9 @@ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -24,9 +24,11 @@ apply plugin: 'eclipse' eclipse.project.name = 'Framework Project' dependencies { + api project(':Generic') api project(':FileSystem') + + api "org.tukaani:xz:1.9" testImplementation project(path: ':Generic', configuration: 'testArtifacts') - api "org.tukaani:xz:1.9" } diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/DomainFileIndex.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/DomainFileIndex.java index 3965f0411f..7fd11e6efd 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/DomainFileIndex.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/data/DomainFileIndex.java @@ -4,9 +4,9 @@ * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at - * + * * http://www.apache.org/licenses/LICENSE-2.0 - * + * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -94,7 +94,6 @@ class DomainFileIndex implements DomainFolderChangeListener { } catch (IOException e) { Msg.error(this, "Error while resolving file IDs", e); - e.printStackTrace(); } } diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/EditActionManager.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/EditActionManager.java index 15a08c1e67..5101c16598 100644 --- a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/EditActionManager.java +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/EditActionManager.java @@ -17,13 +17,14 @@ package ghidra.framework.main; import java.io.File; -import docking.ActionContext; import docking.action.DockingAction; -import docking.action.MenuData; +import docking.action.builder.ActionBuilder; import docking.tool.ToolConstants; import docking.widgets.OptionDialog; import docking.widgets.filechooser.GhidraFileChooser; import docking.widgets.filechooser.GhidraFileChooserMode; +import ghidra.framework.OperatingSystem; +import ghidra.framework.main.certs.CertificateManagerLauncher; import ghidra.net.DefaultKeyManagerFactory; import ghidra.net.PKIUtils; import ghidra.util.HelpLocation; @@ -42,8 +43,7 @@ class EditActionManager { private FrontEndPlugin plugin; private FrontEndTool tool; - private DockingAction editPluginPathAction; - private DockingAction editCertPathAction; + private DockingAction clearCertPathAction; EditActionManager(FrontEndPlugin plugin) { @@ -55,51 +55,47 @@ class EditActionManager { /** * Create the menu items. */ + @SuppressWarnings("unused") private void createActions() { - // window.addSeparator(Ghidra.MENU_FILE); - - editPluginPathAction = new DockingAction("Edit Plugin Path", plugin.getName()) { - @Override - public void actionPerformed(ActionContext context) { - editPluginPath(); - } - }; -// ACTIONS - auto generated - editPluginPathAction.setEnabled(true); - - editPluginPathAction.setMenuBarData( - new MenuData(new String[] { ToolConstants.MENU_EDIT, "Plugin Path..." }, "GEdit")); - - editCertPathAction = new DockingAction("Set PKI Certificate", plugin.getName()) { - @Override - public void actionPerformed(ActionContext context) { - editCertPath(); - } - }; -// ACTIONS - auto generated - editCertPathAction.setEnabled(true); - - editCertPathAction.setMenuBarData(new MenuData( - new String[] { ToolConstants.MENU_EDIT, "Set PKI Certificate..." }, "PKI")); - - clearCertPathAction = new DockingAction("Clear PKI Certificate", plugin.getName()) { - @Override - public void actionPerformed(ActionContext context) { - clearCertPath(); - } - }; -// ACTIONS - auto generated - clearCertPathAction.setEnabled(DefaultKeyManagerFactory.getKeyStore() != null); - - clearCertPathAction.setMenuBarData(new MenuData( - new String[] { ToolConstants.MENU_EDIT, "Clear PKI Certificate..." }, "PKI")); - - clearCertPathAction - .setHelpLocation(new HelpLocation("FrontEndPlugin", "Set_PKI_Certificate")); - tool.addAction(editCertPathAction); - tool.addAction(clearCertPathAction); + DockingAction editPluginPathAction = + new ActionBuilder("Edit Plugin Path", plugin.getName()).menuGroup("GEdit") + .menuPath(ToolConstants.MENU_EDIT, "Plugin Path...") + .onAction(c -> editPluginPath()) + .enabled(true) + .build(); tool.addAction(editPluginPathAction); + + OperatingSystem currentOS = OperatingSystem.CURRENT_OPERATING_SYSTEM; + if (currentOS == OperatingSystem.WINDOWS || currentOS == OperatingSystem.MAC_OS_X) { + DockingAction manageCaCertsAction = + new ActionBuilder("Manage Certificates", plugin.getName()).menuGroup("PKI", "A") + .menuPath(ToolConstants.MENU_EDIT, "Manage Certificates...") + .helpLocation(new HelpLocation("FrontEndPlugin", "Manage_Certificates")) + .onAction(c -> CertificateManagerLauncher.launchOrFocus()) + .enabled(true) + .build(); + tool.addAction(manageCaCertsAction); + } + + DockingAction editCertPathAction = + new ActionBuilder("Set PKI Certificate", plugin.getName()).menuGroup("PKI", "B") + .menuPath(ToolConstants.MENU_EDIT, "Set PKI Certificate...") + .helpLocation(new HelpLocation("FrontEndPlugin", "Set_PKI_Certificate")) + .onAction(c -> editCertPath()) + .enabled(true) + .build(); + tool.addAction(editCertPathAction); + + clearCertPathAction = + new ActionBuilder("Clear PKI Certificate", plugin.getName()).menuGroup("PKI", "C") + .menuPath(ToolConstants.MENU_EDIT, "Clear PKI Certificate...") + .onAction(c -> clearCertPath()) + .enabledWhen(c -> DefaultKeyManagerFactory.getKeyStore() != null) + .enabled(true) + .build(); + tool.addAction(clearCertPathAction); + } /** @@ -119,8 +115,16 @@ class EditActionManager { return; } + OperatingSystem os = OperatingSystem.CURRENT_OPERATING_SYSTEM; + String revertMsg = + (os == OperatingSystem.WINDOWS || os == OperatingSystem.MAC_OS_X) + ? "The OS-managed certificate store, or an auto-generated certificate\n" + + "if no suitable OS certificate is found, will be used instead." + : "An auto-generated certificate will be used instead."; + if (OptionDialog.YES_OPTION != OptionDialog.showYesNoDialog(tool.getToolFrame(), - "Clear PKI Certificate", "Clear PKI certificate setting?\n(" + path + ")")) { + "Clear PKI Certificate", + "Clear PKI certificate setting?\n(" + path + ")\n\n" + revertMsg)) { return; } @@ -174,11 +178,19 @@ class EditActionManager { private GhidraFileChooser createCertFileChooser() { GhidraFileChooser fileChooser = new GhidraFileChooser(tool.getToolFrame()); - fileChooser.setTitle("Select Certificate (req'd for PKI authentication only)"); + String title = "Select Certificate (req'd for PKI authentication only)"; + if (DefaultKeyManagerFactory.usingOSManagedKeyStore()) { + title = "Select Certificate (currently using OS certificate store)"; + } + else { + title = "Select Certificate"; + } + fileChooser.setTitle(title); fileChooser.setApproveButtonText("Set Certificate"); fileChooser.setFileFilter(CERTIFICATE_FILE_FILTER); fileChooser.setFileSelectionMode(GhidraFileChooserMode.FILES_ONLY); fileChooser.setHelpLocation(new HelpLocation(plugin.getName(), "Set_PKI_Certificate")); return fileChooser; } + } diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/certs/CertificateManagerLauncher.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/certs/CertificateManagerLauncher.java new file mode 100644 index 0000000000..213c25ba93 --- /dev/null +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/certs/CertificateManagerLauncher.java @@ -0,0 +1,262 @@ +/* ### + * IP: GHIDRA + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ghidra.framework.main.certs; + +import java.io.File; +import java.io.IOException; +import java.lang.foreign.*; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.net.URISyntaxException; +import java.net.URL; +import java.security.CodeSource; +import java.util.LinkedHashSet; +import java.util.Set; + +import ghidra.framework.OperatingSystem; +import ghidra.util.Msg; + +/** + * Launches (or refocuses) the OS-native certificate manager used to view/manage the + * user's trusted CA certificates — the same tool Chrome/Edge expose via their own + * "Manage certificates" button. Supported on Windows and macOS only. + *

+ * Windows: displays the native {@code CryptUIDlgCertMgr} dialog via {@link WindowsCertMgrApp}, + * launched as an independent Java process (that call blocks until the dialog is closed, so + * it must not run inside Ghidra's own JVM). A subsequent call while that process is still + * alive brings its window to the foreground instead of starting a second one. + *

+ * macOS: launches Keychain Access via {@code open -a}, which itself focuses an + * already-running instance instead of starting a duplicate. + */ +public class CertificateManagerLauncher { + + private static Process lastWindowsProcess; + + /** + * Launch the platform certificate manager, or bring an already-launched instance to the + * foreground. Safe to call repeatedly/rapidly from the Swing thread; a single + * {@code synchronized} entry point is sufficient serialization since every operation + * performed here (starting a process, enumerating windows) returns quickly. + */ + public static synchronized void launchOrFocus() { + try { + switch (OperatingSystem.CURRENT_OPERATING_SYSTEM) { + case OperatingSystem.WINDOWS: + launchOrFocusWindows(); + break; + case OperatingSystem.MAC_OS_X: + new ProcessBuilder("open", "-a", "Keychain Access").start(); + break; + default: + Msg.warn(CertificateManagerLauncher.class, + "Manage CA Certificates is not supported on " + OperatingSystem.CURRENT_OPERATING_SYSTEM); + } + } + catch (IOException e) { + Msg.showError(CertificateManagerLauncher.class, null, "Manage CA Certificates", + "Unable to launch the system certificate manager", e); + } + } + + private static void launchOrFocusWindows() throws IOException { + + if (lastWindowsProcess != null && lastWindowsProcess.isAlive() && + focusWindow(lastWindowsProcess.pid())) { + return; + } + + String javaBin = + System.getProperty("java.home") + File.separator + "bin" + File.separator + "java"; + + // WindowsCertMgrApp uses the FFM API (Project Panama) to call cryptui.dll. Its restricted + // methods produce a warning on JDK 21+, and will become a hard error in a future release, + // unless native access is explicitly enabled. The application runs on the classpath (the + // unnamed module), so native access is granted to ALL-UNNAMED. This is the JDK-sanctioned + // opt-in, not a temporary workaround. The flag must be passed here because this child JVM + // does not inherit the parent's launch arguments. + ProcessBuilder builder = new ProcessBuilder(javaBin, "--enable-native-access=ALL-UNNAMED", + "-cp", getHelperClasspath(), WindowsCertMgrApp.class.getName()); + + builder.inheritIO(); + lastWindowsProcess = builder.start(); + } + + /** + * Establish the classpath required by the {@link WindowsCertMgrApp} helper process, which is + * the code source of that class together with the code source of each class it relies upon. + *

+ * The helper needs very little, so Ghidra's own classpath is not passed on: it names every + * module of the installation, which is both unnecessary here and long enough to approach the + * length limit which Windows imposes upon a command line. + * + * @return the classpath for the helper process + * @throws IOException if the location of a required class cannot be determined + */ + private static String getHelperClasspath() throws IOException { + + // The helper application and the classes it uses; each contributes its code source, which + // resolves to a module jar within an installation or to a class directory in development + Class[] required = { WindowsCertMgrApp.class, OperatingSystem.class }; + + Set classpath = new LinkedHashSet<>(); + for (Class c : required) { + CodeSource codeSource = c.getProtectionDomain().getCodeSource(); + URL location = codeSource != null ? codeSource.getLocation() : null; + if (location == null) { + throw new IOException("Unable to determine the location of " + c.getName()); + } + try { + classpath.add(new File(location.toURI()).getAbsolutePath()); + } + catch (URISyntaxException e) { + throw new IOException("Invalid location for " + c.getName() + ": " + location, e); + } + } + return String.join(File.pathSeparator, classpath); + } + + /** + * Win32 bindings used to locate and focus an existing certificate manager window. + *

+ * These are established within a nested class so that they are initialized when first used, + * which only occurs on Windows. Establishing them within {@link CertificateManagerLauncher} + * itself would fail on every other platform and would prevent the macOS branch of + * {@link #launchOrFocus()} from running. + */ + private static final class Win32 { + + /** {@code BOOL CALLBACK EnumWindowsProc(HWND hwnd, LPARAM lParam)} */ + private static final FunctionDescriptor ENUM_PROC_DESCRIPTOR = FunctionDescriptor + .of(ValueLayout.JAVA_INT, ValueLayout.ADDRESS, ValueLayout.JAVA_LONG); + + private static final MethodHandle ENUM_WINDOWS; + private static final MethodHandle GET_WINDOW_THREAD_PROCESS_ID; + private static final MethodHandle SET_FOREGROUND_WINDOW; + private static final MethodHandle ENUM_PROC; + + static { + Linker linker = Linker.nativeLinker(); + SymbolLookup user32 = SymbolLookup.libraryLookup("user32", Arena.global()); + + // BOOL EnumWindows(WNDENUMPROC lpEnumFunc, LPARAM lParam) + ENUM_WINDOWS = linker.downcallHandle(user32.find("EnumWindows").orElseThrow(), + FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.ADDRESS, + ValueLayout.JAVA_LONG)); + + // DWORD GetWindowThreadProcessId(HWND hWnd, LPDWORD lpdwProcessId) + GET_WINDOW_THREAD_PROCESS_ID = + linker.downcallHandle(user32.find("GetWindowThreadProcessId").orElseThrow(), + FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.ADDRESS, + ValueLayout.ADDRESS)); + + // BOOL SetForegroundWindow(HWND hWnd) + SET_FOREGROUND_WINDOW = + linker.downcallHandle(user32.find("SetForegroundWindow").orElseThrow(), + FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.ADDRESS)); + + try { + ENUM_PROC = MethodHandles.lookup().findStatic(CertificateManagerLauncher.class, + "enumWindowsProc", + MethodType.methodType(int.class, MemorySegment.class, long.class)); + } + catch (ReflectiveOperationException e) { + throw new ExceptionInInitializerError(e); + } + } + + private Win32() { + // no construct + } + } + + // State for the EnumWindows callback below. Its use is confined to focusWindow, which is only + // reached from the synchronized launchOrFocus entry point. + private static long searchProcessId; + private static MemorySegment searchProcessIdOut; + private static long foundWindowHandle; + + /** + * {@code EnumWindows} callback which records the first top-level window belonging to the + * process being searched for and then halts the enumeration. + * + * @param hwnd handle of the window supplied by the enumeration + * @param lParam application defined value, unused + * @return zero (FALSE) to halt the enumeration, non-zero (TRUE) to continue it + */ + private static int enumWindowsProc(MemorySegment hwnd, long lParam) { + try { + int threadId = + (int) Win32.GET_WINDOW_THREAD_PROCESS_ID.invokeExact(hwnd, searchProcessIdOut); + if (threadId != 0 && Integer.toUnsignedLong( + searchProcessIdOut.get(ValueLayout.JAVA_INT, 0)) == searchProcessId) { + foundWindowHandle = hwnd.address(); + return 0; // FALSE: the window was found, halt the enumeration + } + } + catch (Throwable t) { + // disregard this window and continue with the next + } + return 1; // TRUE: continue the enumeration + } + + /** + * Bring the top-level window owned by the given process to the foreground. + * + * @param pid the process ID whose window should be focused + * @return true if a window was found and focused + */ + private static boolean focusWindow(long pid) { + + try (Arena arena = Arena.ofConfined()) { + + searchProcessId = pid; + searchProcessIdOut = arena.allocate(ValueLayout.JAVA_INT); + foundWindowHandle = 0; + + MemorySegment enumProc = Linker.nativeLinker() + .upcallStub(Win32.ENUM_PROC, Win32.ENUM_PROC_DESCRIPTOR, arena); + + // EnumWindows reports FALSE when the callback halts the enumeration, which is the + // outcome sought here, so its result does not indicate success or failure + int enumerated = (int) Win32.ENUM_WINDOWS.invokeExact(enumProc, 0L); + Msg.trace(CertificateManagerLauncher.class, + "EnumWindows returned " + enumerated + " while searching for process " + pid); + + if (foundWindowHandle == 0) { + return false; + } + int focused = (int) Win32.SET_FOREGROUND_WINDOW + .invokeExact(MemorySegment.ofAddress(foundWindowHandle)); + return focused != 0; + } + catch (Throwable t) { + // Includes a failure to establish the Win32 bindings; the caller then launches a new + // certificate manager process rather than focusing the existing one + Msg.debug(CertificateManagerLauncher.class, + "Unable to focus the existing certificate manager window", t); + return false; + } + finally { + searchProcessIdOut = null; // the arena which allocated it has been closed + } + } + + private CertificateManagerLauncher() { + // static utility + } +} diff --git a/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/certs/WindowsCertMgrApp.java b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/certs/WindowsCertMgrApp.java new file mode 100644 index 0000000000..53b85b36fb --- /dev/null +++ b/Ghidra/Framework/Project/src/main/java/ghidra/framework/main/certs/WindowsCertMgrApp.java @@ -0,0 +1,153 @@ +/* ### + * IP: GHIDRA + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package ghidra.framework.main.certs; + +import java.awt.GraphicsEnvironment; +import java.lang.foreign.*; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.VarHandle; +import java.nio.charset.StandardCharsets; + +import ghidra.framework.OperatingSystem; + +/** + * Standalone helper application which displays the Windows-native certificate manager + * dialog via the {@code CryptUIDlgCertMgr} Win32 API using Project Panama (FFM API). + */ +public class WindowsCertMgrApp { + + /** + * Memory layout for {@code CRYPTUI_CERT_MGR_STRUCT}: + *

+	 * typedef struct _CRYPTUI_CERT_MGR_STRUCT {
+	 *   DWORD     dwSize;
+	 *   HWND      hwndParent;
+	 *   DWORD     dwFlags;
+	 *   LPCWSTR   pwszTitle;
+	 *   LPCSTR    pszInitUsageOID;
+	 * } CRYPTUI_CERT_MGR_STRUCT;
+	 * 
+ */ + private static final StructLayout CRYPTUI_CERT_MGR_STRUCT_LAYOUT = MemoryLayout.structLayout( + ValueLayout.JAVA_INT.withName("dwSize"), + MemoryLayout.paddingLayout(4), // Padding to align pointer/address field on 64-bit Windows + ValueLayout.ADDRESS.withName("hwndParent"), + ValueLayout.JAVA_INT.withName("dwFlags"), + MemoryLayout.paddingLayout(4), // Padding for alignment before the next address pointer + ValueLayout.ADDRESS.withName("pwszTitle"), + ValueLayout.ADDRESS.withName("pszInitUsageOID")); + + // VarHandles for accessing struct fields cleanly + private static final VarHandle VH_DW_SIZE = CRYPTUI_CERT_MGR_STRUCT_LAYOUT.varHandle( + MemoryLayout.PathElement.groupElement("dwSize")); + private static final VarHandle VH_HWND_PARENT = CRYPTUI_CERT_MGR_STRUCT_LAYOUT.varHandle( + MemoryLayout.PathElement.groupElement("hwndParent")); + private static final VarHandle VH_DW_FLAGS = CRYPTUI_CERT_MGR_STRUCT_LAYOUT.varHandle( + MemoryLayout.PathElement.groupElement("dwFlags")); + private static final VarHandle VH_PWSZ_TITLE = CRYPTUI_CERT_MGR_STRUCT_LAYOUT.varHandle( + MemoryLayout.PathElement.groupElement("pwszTitle")); + private static final VarHandle VH_PSZ_INIT_USAGE_OID = CRYPTUI_CERT_MGR_STRUCT_LAYOUT.varHandle( + MemoryLayout.PathElement.groupElement("pszInitUsageOID")); + + /** + * Layout of the buffer which receives the call state captured by the downcall below. The + * last-error value is held in thread-local storage, and the runtime may execute code which + * overwrites it between the downcall and any later attempt to read it, so it must be captured + * as part of the call itself rather than retrieved by a separate call to {@code GetLastError}. + */ + private static final StructLayout CAPTURED_STATE_LAYOUT = Linker.Option.captureStateLayout(); + + private static final VarHandle VH_LAST_ERROR = CAPTURED_STATE_LAYOUT + .varHandle(MemoryLayout.PathElement.groupElement("GetLastError")); + + private static final MethodHandle CRYPT_UI_DLG_CERT_MGR; + + static { + Linker linker = Linker.nativeLinker(); + try { + SymbolLookup cryptUiLib = SymbolLookup.libraryLookup("cryptui", Arena.global()); + + // cryptui.dll exports this symbol undecorated - there is no separate W/A pair, the + // wide/ANSI distinction is carried by the struct fields instead. BOOL is a 32-bit + // int, so JAVA_INT rather than JAVA_BOOLEAN (a single byte) describes the result. + // Capturing GetLastError adds a leading buffer parameter to the resulting handle. + CRYPT_UI_DLG_CERT_MGR = linker.downcallHandle( + cryptUiLib.find("CryptUIDlgCertMgr").orElseThrow(), + FunctionDescriptor.of(ValueLayout.JAVA_INT, ValueLayout.ADDRESS), + Linker.Option.captureCallState("GetLastError")); + } + catch (Throwable e) { + throw new ExceptionInInitializerError(e); + } + } + + public static void main(String[] args) { + + if (OperatingSystem.CURRENT_OPERATING_SYSTEM != OperatingSystem.WINDOWS) { + System.err.println( + "WinCertMgrApp may only be launched on Windows (detected: " + + OperatingSystem.CURRENT_OPERATING_SYSTEM + ")"); + System.exit(1); + } + + if (GraphicsEnvironment.isHeadless()) { + System.err.println("WinCertMgrApp requires a graphical environment"); + System.exit(1); + } + + // Use an Arena to manage native memory allocation safely and scoped + try (Arena arena = Arena.ofConfined()) { + // Allocate memory for the structure + MemorySegment structSegment = arena.allocate(CRYPTUI_CERT_MGR_STRUCT_LAYOUT); + + // Allocate null-terminated wide string (LPCWSTR) for the title. The charset must be + // given: allocateFrom(String) encodes as UTF-8 with a single-byte terminator, which a + // wide string reader would interpret as garbage and scan past looking for a 16-bit NUL. + MemorySegment titleSegment = + arena.allocateFrom("Certificate Manager", StandardCharsets.UTF_16LE); + + // Populate fields using the layout's VarHandles + VH_DW_SIZE.set(structSegment, 0L, (int) CRYPTUI_CERT_MGR_STRUCT_LAYOUT.byteSize()); + VH_HWND_PARENT.set(structSegment, 0L, MemorySegment.NULL); + VH_DW_FLAGS.set(structSegment, 0L, 0); + VH_PWSZ_TITLE.set(structSegment, 0L, titleSegment); + VH_PSZ_INIT_USAGE_OID.set(structSegment, 0L, MemorySegment.NULL); // null pointer filter + + // Receives the last-error value captured as part of the call below + MemorySegment capturedState = arena.allocate(CAPTURED_STATE_LAYOUT); + + try { + int result = + (int) CRYPT_UI_DLG_CERT_MGR.invokeExact(capturedState, structSegment); + if (result == 0) { // BOOL: zero indicates failure + int lastError = (int) VH_LAST_ERROR.get(capturedState, 0L); + System.err.println("CryptUIDlgCertMgr failed (GetLastError=" + lastError + ")"); + System.exit(1); + } + } + catch (Throwable t) { + System.err.println("Failed to execute native method invocation:"); + t.printStackTrace(); + System.exit(1); + } + + } + } + + private WindowsCertMgrApp() { + // entry point only + } +} diff --git a/Ghidra/Framework/Utility/src/main/java/utilities/util/FileUtilities.java b/Ghidra/Framework/Utility/src/main/java/utilities/util/FileUtilities.java index 9c55440d7b..b89e224c04 100644 --- a/Ghidra/Framework/Utility/src/main/java/utilities/util/FileUtilities.java +++ b/Ghidra/Framework/Utility/src/main/java/utilities/util/FileUtilities.java @@ -19,9 +19,12 @@ import java.awt.Desktop; import java.io.*; import java.net.URI; import java.net.URL; +import java.nio.channels.Channels; +import java.nio.channels.FileChannel; import java.nio.charset.StandardCharsets; import java.nio.file.*; import java.nio.file.FileSystem; +import java.nio.file.attribute.*; import java.text.DecimalFormat; import java.text.NumberFormat; import java.util.*; @@ -384,9 +387,38 @@ public final class FileUtilities { return dir; } + /** + * Create {@code file} with owner-only permissions and return a stream for writing. + * The stream is opened on the same file handle used to create the file + * (O_CREAT|O_EXCL on POSIX / CREATE_NEW on Windows), so there is no second path + * lookup between creation and write. + * @param file file to be created and written + * @return file output stream + * @throws IOException if operation fails + */ + public static OutputStream newOwnerPrivateFileOutputStream(File file) throws IOException { + Path path = file.toPath(); + + if (file.exists() && !file.canWrite()) { + file.setWritable(true, true); + } + Files.deleteIfExists(path); + + Set opts = Set.of(StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE); + try { + FileAttribute> perms = + PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------")); + return Channels.newOutputStream(FileChannel.open(path, opts, perms)); + } + catch (UnsupportedOperationException e) { + // Non-POSIX (Windows): rely on parent-directory ACL, as documented + return Channels.newOutputStream(FileChannel.open(path, opts)); + } + } + /** * Delete a file or directory and all of its contents - * + * * @param dir the directory to delete * @return true if delete was successful. If false is returned, a partial * delete may have occurred. diff --git a/Ghidra/RuntimeScripts/certification.manifest b/Ghidra/RuntimeScripts/certification.manifest index f604f6bb44..939cdbaefa 100644 --- a/Ghidra/RuntimeScripts/certification.manifest +++ b/Ghidra/RuntimeScripts/certification.manifest @@ -3,6 +3,7 @@ ##MODULE IP: Copyright Distribution Permitted ##MODULE IP: Public Domain ghidraRun||GHIDRA||||END| +server/certTool||GHIDRA||||END| server/ghidraSvr||GHIDRA||||END| server/jaas.conf||GHIDRA||||END| server/server.conf||GHIDRA||||END| diff --git a/Ghidra/RuntimeScripts/server/certTool b/Ghidra/RuntimeScripts/server/certTool new file mode 100755 index 0000000000..71e15c5a00 --- /dev/null +++ b/Ghidra/RuntimeScripts/server/certTool @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +# Maximum heap memory may be changed if default is inadequate. This will generally be up to 1/4 of +# the physical memory available to the OS. Uncomment MAXMEM setting if non-default value is needed. +MAXMEM=128M + +# Resolve symbolic link if present and get the directory this script lives in. +# NOTE: "readlink -f" is best but works on Linux only, "readlink" will only work if your PWD +# contains the link you are calling (which is the best we can do on macOS), and the "echo" is the +# fallback, which doesn't attempt to do anything with links. +SCRIPT_FILE="$(readlink -f "$0" 2>/dev/null || readlink "$0" 2>/dev/null || echo "$0")" +SCRIPT_DIR="$(dirname -- "$SCRIPT_FILE")" + +VMARGS="-DCertTool.invocation=$(basename "${SCRIPT_FILE}") -Djava.awt.headless=true" +"${SCRIPT_DIR}"/../support/launch.sh fg jre certTool "${MAXMEM}" "$VMARGS" ghidra.net.CertTool "$@" diff --git a/Ghidra/RuntimeScripts/server/certTool.bat b/Ghidra/RuntimeScripts/server/certTool.bat new file mode 100644 index 0000000000..5a4dcfae74 --- /dev/null +++ b/Ghidra/RuntimeScripts/server/certTool.bat @@ -0,0 +1,32 @@ +:: ### +:: IP: GHIDRA +:: +:: Licensed under the Apache License, Version 2.0 (the "License"); +:: you may not use this file except in compliance with the License. +:: You may obtain a copy of the License at +:: +:: http://www.apache.org/licenses/LICENSE-2.0 +:: +:: Unless required by applicable law or agreed to in writing, software +:: distributed under the License is distributed on an "AS IS" BASIS, +:: WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +:: See the License for the specific language governing permissions and +:: limitations under the License. +:: ## +@echo off + +setlocal + +:: maximum heap memory may be change if inadequate +set MAXMEM=128M + +:: Sets SCRIPT_DIR to the directory that contains this file +:: +:: '% ~' dereferences the value in param 0 +:: 'd' - drive +:: 'p' - path (without filename) +set "SCRIPT_DIR=%~dp0" + +set VMARGS=-DCertTool.invocation=%~n0 -Djava.awt.headless=true + +call "%~dp0\..\support\launch.bat" fg jre certTool "%MAXMEM%" "%VMARGS%" ghidra.net.CertTool %* diff --git a/Ghidra/RuntimeScripts/server/server.conf b/Ghidra/RuntimeScripts/server/server.conf index 89c8f71ec2..924f8a45c3 100644 --- a/Ghidra/RuntimeScripts/server/server.conf +++ b/Ghidra/RuntimeScripts/server/server.conf @@ -56,14 +56,30 @@ wrapper.java.additional.6=-Dghidra.tls.server.protocols=TLSv1.2;TLSv1.3 # RFC 9151 https://datatracker.ietf.org/doc/rfc9151/ wrapper.java.additional.7=-Djdk.tls.server.cipherSuites="TLS_DHE_RSA_WITH_AES_256_GCM_SHA384\,TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384\,TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384\,TLS_AES_256_GCM_SHA384" -# A suitable cacerts file must be installed when using PKI authentication +# A suitable cacerts file must be installed when using PKI authentication (-a2). +# +# NOTE: this trust store also governs connections the server itself makes as a client. JAAS +# authentication (-a4) using the LdapLoginModule connects to Active Directory over 'ldaps://' +# (see jaas.conf), and the LDAP server's certificate is authenticated using the trust +# established here. When this property is specified it is used EXCLUSIVELY and the OS and Java +# default trust stores are ignored, so the certificate authority which issued the LDAP server's +# certificate must also be present within this file or that authentication will fail. #wrapper.java.additional.8=-Dghidra.cacerts=./Ghidra/cacerts -# If Ghidra clients must authenticate the server, the server will need to install -# a server key/certificate in a secure location (e.g., /etc/pki/...) -# and specify the location and password via the properties below. -# Be sure to properly set permissions on the Ghidra installation and this file -# if using these settings. +# When the 'ghidra.cacerts' property above is NOT specified, the OS trust store and the Java +# default trust store are used. On Unix the OS trust store is built by scanning the well-known +# CA bundle locations (see UnixSystemTrustKeyStoreUtil.java). If this system keeps its CA +# certificates somewhere not covered by those locations, the property below may specify an +# additional file or directory to be included. It is ignored when 'ghidra.cacerts' is specified, +# since the OS trust store is not loaded in that case. Does not apply to Windows or macOS. +#wrapper.java.additional.21=-Dghidra.unix.default.cacerts= + +# IMPORTANT: A Ghidra server must be assigned a PKI keystore and corresponding password to access +# the key. If a keystore is not provided the server will bind to the loopback interface only +# and network connections will not be accepted. (see svrREADME.html for more information). +# The included CertTool command may be used to generate a certificate request or a self-signed +# certificate/keystore. Note that use of a self-signed certificate may complicate Ghidra client +# installation which will need to authenticate the server when connecting. #wrapper.java.additional.9=-Dghidra.keystore= #wrapper.java.additional.10=-Dghidra.password= @@ -135,28 +151,29 @@ wrapper.java.maxmemory=768 ghidra.repositories.dir=./repositories -# Ghidra server startup parameters. +# Ghidra server startup parameters. +# +# IMPORTANT: 'ghidra.keystore' property must generally be set (see setting near top of file) # # Command line parameters: (Add command line parameters as needed and renumber each starting from .1) -# [-ip ] [-ipAlt [;...]] [-i #.#.#.#] [-p#] [-n] +# [-ip ] [-i #.#.#.#] [-p#] [-n] # [-a#] [-d] [-e] [-jaas ] [-u] [-autoProvision] # [-anonymous] [-ssh] # # # -ip : identifies the remote access IPv4 address or hostname (FQDN) which should be -# used by remote clients to access the server. This option is frequently required -# when deploying a Ghidra Server within a docker container. When this option -# specifies a hostname, and the -Dghidra.keystore JVM property has not been specified, -# it is generally required that the -ipAlt option be included to specify the IP -# Address which corresponds to the hostname. +# used by remote clients to access the server. This name should be reflected +# in the server's specified certificate (see 'ghidra.keystore' property above). +# Additional host names or IP addresses used to access the server should also be +# included within the server's certificate (i.e., subject alternative names). # -# -ipAlt [;,...] : identifies additional addresses and hostnames (FQDN) that -# should be included as subject alternative names when generating a self-signed -# server certificate. Currently, a temporary self-signed server certificate is -# generated whenever the -Dghidra.keystore option JVM property has not been specified. -# NOTE: ';' must be used as separator for more than one altName. +# The -ip option will be ignored if the 'ghidra.keystore' property has been omitted. # -# -i #.#.#.# : server interface IPv4 address to listen on (default will listen on all interfaces). +# -i #.#.#.# : server interface IPv4 address to listen on. If the 'ghidra.keystore' property has +# been omitted, only a loopback interface may optionally be specified, otherwise +# the server will listen on the default 127.0.0.1 loopback interface. If the +# 'ghidra.keystore' property has been specified, the server will listen on all +# interfaces unless this option is specified. # # -p# : base TCP port to be used (default: 13100) [see Note 1] # diff --git a/Ghidra/RuntimeScripts/server/svrAdmin.bat b/Ghidra/RuntimeScripts/server/svrAdmin.bat index 9dfe2da1b3..3e382ee798 100644 --- a/Ghidra/RuntimeScripts/server/svrAdmin.bat +++ b/Ghidra/RuntimeScripts/server/svrAdmin.bat @@ -49,6 +49,6 @@ set MAXMEM=128M set "SCRIPT_DIR=%~dp0" set "CONFIG=%SCRIPT_DIR%.\server.conf" -set VMARGS=-DUserAdmin.invocation=%~n0 +set VMARGS=-DUserAdmin.invocation=%~n0 -Djava.awt.headless=true call "%~dp0\..\support\launch.bat" fg jre svrAdmin "%MAXMEM%" "%VMARGS%" ghidra.server.ServerAdmin "%CONFIG%" %* diff --git a/Ghidra/RuntimeScripts/server/svrREADME.md b/Ghidra/RuntimeScripts/server/svrREADME.md index 1eac65f397..e4d6a3918a 100644 --- a/Ghidra/RuntimeScripts/server/svrREADME.md +++ b/Ghidra/RuntimeScripts/server/svrREADME.md @@ -3,6 +3,7 @@ ## Table of Contents * [Introduction](#introduction) * [Java Runtime Environment](#java-runtime-environment) +* [Server Certificate and Client CA Certificates](#server-certificate-and-client-ca-certificates) * [Server Configuration](#server-configuration) * [Server Logs](#server-logs) * [Server Memory Considerations](#server-memory-considerations) @@ -53,6 +54,18 @@ __NOTE__: It is highly recommended that the installation files for Ghidra reside and that the intended Ghidra Server process owner is granted full access to the Ghidra installation directory (this is frequently not the case for NFS/SMB mounted home directories). +__NOTE__: All Ghidra Server deployments should have a server certificate generated by a recognized +Certificate Authority (CA) and established via the `ghidra.keystore` property. If a keystore is +not specified, the server will operate with a self-signed certificate and only listen for +connections on the server's loopback/localhost interface. An auto-generated self-signed certificate +will not pass client-side validation, so such a deployment requires each client to waive server +authentication for loopback connections by specifying the client VM property +`-Dghidra.disable.loopback.server.authentication=true` (see _support/launch.properties_). Waive it +only where every local account on the client machine is trusted, since a loopback connection is not +by itself proof of the server's identity. See +[Server Certificate and Client CA Certificates](#server-certificate-and-client-ca-certificates) +for more details. + You may also refer to the _GettingStarted.html_ file within the Ghidra installation root directory for general installation information. @@ -69,6 +82,244 @@ installed Java release may be preferable over one that is simply unpacked to an ([Back to Top][top]) +## Server Certificate and Client CA Certificates +Ghidra client/server communications rely on a cryptographically secure interface that relies +on proper configuration. The framework that facilitates this is frequently referred to as +Public Key Infrastructure (PKI). + +All server deployments should preferably have a server certificate generated by a recognized +Certificate Authority (CA) to allow client systems to properly validate server connections. +This also requires each client system to properly maintain a complete set of trusted Certificate +Authorities (CA) which facilitate server authentication. Use of a self-signed certificate +should generally be avoided since they would need to be added individually as a trusted certificate +by each user. How trusted certificates are managed by each client user/system varies by +Operating System (OS). + +### Certificate Authorities (CA) / Trust Stores + +The Ghidra Server relies on properly configured CA Certificate trust stores when PKI Authentication +is enabled (-a2) to facilitate client-authentication. Additionally, client applications always +rely on CA certificates when communicating with a secure server (SSL) to facilitate +server-authentication. There are a variety of trust store configuration mechanisms that Ghidra will +use for its default SSL Context: + +* __Application Trust Store__ - Ghidra allows an application trust store to be specified via the + `ghidra.cacerts` VM property. In the case of the Ghidra Server this property can be specified within + the `server.conf` file and is **required** when PKI Authentication mode (-a2) is used. This property + may also be specified for a Ghidra client applications which may be specified within the + `support/launch.properties` file. When this property is specified all other trust stores below are + ignored. + +* __OS Trust Store__ - Each operating system has adopted a unique approach for managing CA + certificates. MS Windows and macOS provide a convenient User Certificate Manager GUI, while the + support for this with Linux varies greatly based upon specific Linux release. This trust store + will be included when an __Application Trust Store__ has not been specified and will be merged + with the __Default Trust Store__ provided with Java. + + On Unix systems, which lack a User Certificate Manager, the Java property `ghidra.unix.default.cacerts` + may specify another directory which may contain individual trusted certificates. These will be + used in addition to those defined by the system. + +* __Default Trust Store__ - Each installation of Java includes a set of standard commercial CA +Certificates within a default trust store. This trust store will be included when an +__Application Trust Store__ has not been specified. + +### Server Certificate Generation + +When configuring any server it is highly recommended that a properly signed server certificate be +obtained from a suitable Certificate Authority (CA) so that a connecting client may properly +authenticate the server using a properly configured CA. For large organizational servers this is very +important. It is also possible to generate a self-signed certificate and keystore when installing for +a closed internal network where trust is more tightly controlled. In such cases, the self-signed +server certificate may be added by clients as a trusted certificate. + +The `certTool` utility included with Ghidra may be used to generate a certificate request and/or +a PKCS#12 key store (see [PKI Management Tools](#pki-management-tools) below). + +### Public Key Infrastructure (PKI) Essentials +At its core, PKI is the framework of roles, policies, hardware, software, and procedures needed to +create, manage, distribute, use, store, and revoke digital certificates and manage public-key encryption. + +Think of PKI as the digital identity and trust ecosystem for the Internet. Here is a quick breakdown +of its main purpose: + +* The Problem: On an open network (like the Internet), how do you know a web-site, server, or user + is actually who they claim to be? + +* The PKI Solution: PKI establishes trust by using a trusted third party (i.e., Certificate + Authority (CA) ) to vouch for identities. The CA signs a digital certificate (binding an identity + to a public key), allowing secure, encrypted communication (using the keys and certificates + detailed in the syllabus above). + +To make this trust work, PKI relies on three main elements: + +* __Confidentiality__ : Encrypting data so that only the intended recipient (the holder of the + matching private key) can read it. + +* __Authentication__ : Proving identity. For example: when you connect to `https://github.com`, + PKI verifies that you are actually talking to that server, not an imposter. + +* __Integrity__ : Ensuring that data cannot be quietly altered in transit. If a hacker tampers with + a signed message or certificate, the digital signature becomes invalid. + +#### PKI Glossary + +* __Private Key__ : A secret cryptographic key known only to its owner. It is used to generate + digital signatures and decrypt data encrypted with the corresponding public key. Must always + be kept secure. + +* __Public Key__ : A non-secret cryptographic key made available to everyone. It is mathematically + linked to the private key and used to encrypt data or verify digital signatures. + +* __Certificate__ : (End-Entity / Leaf): A digital document that binds a public key to an identity + (like a domain name or organization). It is digitally signed by a trusted authority to prove + authenticity. An individual certificate (with or without its full chain) may be stored with a + PEM or DER (*.pem, *.crt, *.cer) file. The PEM format is text based making it the easiest to + identify with its '-----BEGIN CERTIFICATE-----' line. Multiple PEM certificate entries may + exist in the same file when CA certificates and chains are included. + +* __CA Certificate__ : A certificate belonging to a Certificate Authority (CA). It is used to sign + other certificates. A Root CA signs itself, while an Intermediate CA is signed by a higher + authority. + +* __Certificate Chain (Chain of Trust)__ : A list of certificates starting from the end-entity + certificate, going through one or more intermediate CA certificates, and ending at a trusted + root CA certificate. It allows a client to verify that the end certificate is trustworthy. + Individually stored certificate chains may be stored as concatenation of PEM entries within a + file (*.pem, *.crt, *.cer) or as a PKCS#7 file (*.p7b, *.p7c). + +* __Key Usage__ : A certificate extension that defines the specific cryptographic operations the + keys can perform (e.g., Digital Signature, Key Encipherment, Client Authentication, Server + Authentication). + +* __Key Store__ : A storage mechanism (i.e., password protected) that can securely store certificate + chains, public keys and private keys. When used to store a private key it should only store that + key and its corresponding certificate chain. If only storing certificates (e.g., CA certificates), + multiple certificates may be stored. Common store file types include PKCS#12 (.p12, .pfx) + and Java Key Store (.jks). + +#### PKI File Types + +* __*.pem__ (PEM, Certificates): Text-based format starting with `-----BEGIN....` Can hold + certificates, private keys, or chains. + +* __*.crt,*.cer__ (PEM or DER, Certificates): Primarily used to store individual certificates or CA + certificates. + +* __*.key__ (PEM or DER, Private Keys): Typically holds a standalone private key. + +* __*.der__ (DER, Certificate/Keys): Raw binary encoding of a certificate or key. + +* __*.p12__ (PKCS#12, Certificate/Keys): PKCS#12 Key Store. A password-protected archive that + bundles a private key, its public key, and the entire certificate chain together. + +* __*.jks__ (JKS, Certificate/Keys): JKS Key Store. A proprietary Java-specific format used to + store certificates and keys for Java applications (though modern Java prefers PKCS#12). + +* __*.csr__ (PEM or DER, Certificate Signing Request): A file sent to a CA to apply for a + certificate. Contains your public key and identity info. + +* __*.p7b,*.p7c__ (PKCS#7, CA Certificate Chain): A non-encypted binary-encoded file containing + a complete CA certificate chain. + +#### PKI Management Tools + +* __CertTool__ : Convenience utility provided with Ghidra (`server/certTool`, `server\certTool.bat`) + which facilitates Certificate Request generation (include Private Key generation, `*.key`) and + subsequent PKCS#12 Key Store (`*.p12`) generation. Also provided is the ability to generate a + private key and self-signed certificate as a PKCS#12 Key Store (`*.p12`). During certificate + generation the user will be prompted for Distinguished Name properties (DN). The only required + entry is the Command Name (CN) and a protection password. + + *IMPORTANT!* When generating a certificate request or self-signed certificate the user will be + prompted to provide Distinguished Name (DN) attributes including a Common Name (CN) which is + required. Other DN attributes may be left blank. The user will also be prompted to specify + a comma-separated list of Subject Alternative Names (SANs) which is very important. All + server hostnames and address forms used to connect to the server must be specified. A client + connection to an IP Address or hostname not included in the SAN list may prevent a client from + properly validating a server connection. + + __Allowed Subject Alternative Name (SAN) Forms__ + + - Fully Qualified Domain Name (FQDN): myhost.mycompany.com + - Simple hostname: myhost + - IPv4 Address : 1.2.3.4 + + *NOTE:* SAN entries with a wildcard or subnet mask are not currently supported. + + CertTool Usage: `certTool [options]` + + __Generate Certificate Request__ + + Generate a new private key and a corresponding certificate request (CSR) using supplied + Distinguished Name (DN) data supplied via prompts as well as a list of subject alternative + names. The CSR in PEM format will be displayed to the console with the intention that it + be submitted to a CA for generation of a CA-signed certificate. Once the signed-certificate + has been obtained the command below may be used to generate the combined certficate/key key store. + + ```bash + certTool request -outkey + ``` + + __Generate Keystore from Signed Certificate and Private Key__ + + Generate a PKCS#12 (*.p12) key store from a private key and signed-certificate w/ CA chain. + + If the returned certificate does not include the full CA chain, a separate CA chain should be + obtained (e.g., *.p7b file). This chain may correspond to the CA chain only or the full issued + certificate chain. + + ```bash + certTool pkcs12 -inkey -cert [-cachain ] -out + ``` + + __Generate Self-Signed Certificate and Keystore__ + + Generate a self-signed certificate (*.crt) and keystore (*.p12) using supplied Distinguished Name (DN) + data supplied via prompts as well as a list of subject alternative names. + + NOTE: Use of a self-signed certificate has severe limitations with regard to it being + trusted by a client system. A client system/user will need to add the server's certificate + to their managed OS trusted certificates (Windows or macOS) or to Ghidra's `cacerts` file + for the client application (see `ghidra.cacerts` property within `support/launch.properties`). + + Be aware that specifying the `ghidra.cacerts` property causes that file to be used + *exclusively*: the OS and Java default trust stores are then ignored. Every certificate + authority the client relies upon must therefore be present within that file, not only the + server certificate being added. Adding the certificate to the OS trust store instead avoids + this, since the OS trust store remains in use when `ghidra.cacerts` is not specified. + + On Unix, where updating the system trust store requires root access, the + `ghidra.unix.default.cacerts` property offers a third option which may not: it names an + additional file or directory of trusted certificates which is *added to* the OS trust store as it is + built, rather than replacing it. It applies to Unix only (not Windows or macOS), and is ignored + when `ghidra.cacerts` has been specified, since the OS trust store is not loaded in that case. + Both properties are described in `support/launch.properties` for clients and in `server.conf` for + the server (which itself acts as a client when authenticating an LDAP server for JAAS + authentication). + + ```bash + certTool pkcs12 -self-signed -out + ``` + +* __OpenSSL__ : The definitive open-source command-line toolkit for working with X.509 certificates, + CSRs, and cryptographic keys. This tool has a very extensive set of commands for performing many cryptographic + functions. Searching online for examples of specific use is your best bet at using it. + + Example command use: Inspecting a PEM certificate + ```bash + openssl x509 -in certificate.crt -text -noout + ``` + +* __Keytool__ : A key and certificate management utility built into the Java Development Kit (JDK). + + Example command use: Listing contents of a Java keystore + ```bash + keytool -list -v -keystore keystore.jks + ``` + +([Back to Top][top]) + ## Server Configuration Before installing and running the Ghidra Server, the `server/server.conf` file must be modified to suit your particular needs. Within this file, locate the lines labeled: @@ -267,21 +518,21 @@ IPv4 address, if this fails the local loopback address is used. The server log remote access hostname at startup. This option may be required when a server has multiple IP interfaces, running within a docker container, or relies on a dynamic DNS or other network address translation for incoming connections. This option establishes the property value for -_java.rmi.server.hostname_. When this option specifies a hostname, and the _-Dghidra.keystore_ JVM -property has not been specified, it is generally required that the _-ipAlt_ option be included to -specify the IP Address which corresponds to the hostname. +_java.rmi.server.hostname_. This name should be reflected in the server certificate +('ghidra.keystore' property). Additional host names or IP address used to access the server should +also be included within the server's certificate (i.e., subject alternative names). -#### `-ipAlt [;,...]` -Identifies additional addresses and hostnames (FQDN) that should be included as subject alternative -names when generating a self-signed server certificate. Currently, a temporary self-signed server -certificate is generated whenever the _-Dghidra.keystore_ JVM property has not been specified. -NOTE: ';' must be used as separator for more than one altName. +The `-ip` option will be ignored if the 'ghidra.keystore' property has been omitted. #### `-i <#.#.#.#>` Forces the server to be bound to a specific IPv4 interface on the server. If specified and the `-ip` option is not, the address specified by `-i` will establish the remote access IP address as well as -restrict the listening interface. If this option is not specified connections will be accepted on -any interface. +restrict the listening interface. + +If the 'ghidra.keystore' property (server certificate keystore) has been omitted, only a loopback +interface may optionally be specified, otherwise the server will listen on the default 127.0.0.1 +loopback interface. If the 'ghidra.keystore' property has been specified, the server will listen +on all interfaces unless this option is specified. #### `-p#` Allows the base TCP port to be specified (default: 13100). The server utilizes three (3) TCP ports @@ -628,7 +879,7 @@ _View Checkouts_ action is available from the popup-menu of the Ghidra Project W right-clicking on a specific project file. Under special circumstances (e.g., classroom environment) it may be desirable to remove all -checkouts either for a specific repository or an entire Ghidra Server. Under Linux/Mac this is +checkouts either for a specific repository or an entire Ghidra Server. Under Linux/macOS this is most easily accomplished from the command shell while the Ghidra Server is stopped. The following command may be used: diff --git a/Ghidra/RuntimeScripts/support/GhidraGo/ghidraGoREADME.html b/Ghidra/RuntimeScripts/support/GhidraGo/ghidraGoREADME.html index 2c6fc3e372..b26d7c6af6 100644 --- a/Ghidra/RuntimeScripts/support/GhidraGo/ghidraGoREADME.html +++ b/Ghidra/RuntimeScripts/support/GhidraGo/ghidraGoREADME.html @@ -66,7 +66,7 @@ body {

GhidraGo passes information through a simple filesystem mechanism vice an open port for - security and simplicity. GhidraGo works on Windows, Linux, and MacOS. + security and simplicity. GhidraGo works on Windows, Linux, and macOS.

GhidraURL's have the format:

diff --git a/Ghidra/RuntimeScripts/support/bsim b/Ghidra/RuntimeScripts/support/bsim index b508550ed4..4a85d557e4 100755 --- a/Ghidra/RuntimeScripts/support/bsim +++ b/Ghidra/RuntimeScripts/support/bsim @@ -28,4 +28,4 @@ LAUNCH_MODE=fg # fallback, which doesn't attempt to do anything with links. SCRIPT_FILE="$(readlink -f "$0" 2>/dev/null || readlink "$0" 2>/dev/null || echo "$0")" SCRIPT_DIR="$(dirname -- "$SCRIPT_FILE")" -${SCRIPT_DIR}/launch.sh $LAUNCH_MODE jdk "BSim" "${GHIDRA_BSIM_MAXMEM}" "${VMARG_LIST}" ghidra.features.bsim.query.ingest.BSimLaunchable "$@" +"${SCRIPT_DIR}"/launch.sh $LAUNCH_MODE jdk "BSim" "${GHIDRA_BSIM_MAXMEM}" "${VMARG_LIST}" ghidra.features.bsim.query.ingest.BSimLaunchable "$@" diff --git a/Ghidra/RuntimeScripts/support/bsim_ctl b/Ghidra/RuntimeScripts/support/bsim_ctl index 3738689a19..f521000d56 100755 --- a/Ghidra/RuntimeScripts/support/bsim_ctl +++ b/Ghidra/RuntimeScripts/support/bsim_ctl @@ -8,7 +8,7 @@ MAXMEM=768M # launch mode (fg, bg, debug, debug-suspend) LAUNCH_MODE=fg -VMARG_LIST="-Djava.awt.headless=true " +#VMARG_LIST="-Djava.awt.headless=true " # Resolve symbolic link if present and get the directory this script lives in. # NOTE: "readlink -f" is best but works on Linux only, "readlink" will only work if your PWD @@ -16,4 +16,4 @@ VMARG_LIST="-Djava.awt.headless=true " # fallback, which doesn't attempt to do anything with links. SCRIPT_FILE="$(readlink -f "$0" 2>/dev/null || readlink "$0" 2>/dev/null || echo "$0")" SCRIPT_DIR="$(dirname -- "$SCRIPT_FILE")" -${SCRIPT_DIR}/launch.sh $LAUNCH_MODE jdk "BSimControl" $MAXMEM "${VMARG_LIST}" ghidra.features.bsim.query.BSimControlLaunchable "$@" +"${SCRIPT_DIR}"/launch.sh $LAUNCH_MODE jdk "BSimControl" "$MAXMEM" "${VMARG_LIST}" ghidra.features.bsim.query.BSimControlLaunchable "$@" diff --git a/Ghidra/RuntimeScripts/support/launch.properties b/Ghidra/RuntimeScripts/support/launch.properties index 4608a4bbad..6cae1371c2 100644 --- a/Ghidra/RuntimeScripts/support/launch.properties +++ b/Ghidra/RuntimeScripts/support/launch.properties @@ -35,10 +35,34 @@ VMARGS_WINDOWS=-Dsun.java2d.d3d=false # for details on configuring Java's cryptographic algorithms. VMARGS=-Djdk.tls.client.protocols=TLSv1.2,TLSv1.3 -# Force PKI server authentication of all HTTPS and Ghidra Server connections by -# specifying path to installed CA certificates file. +# Optionally disable server-authentication when accessed via a local loopback interface such +# as 127.0.0.1, ::1 or localhost. This permits such a server to present a self-signed certificate, +# as generated by a Ghidra Server or BSim PostgreSQL server which was configured without a server +# keystore. This should only be done under controlled situations since a rogue server on the local +# system could easily capture sensitive information passed by the client (such as a PostgreSQL +# admin password). Only loopback connections are affected; all other connections continue to be +# authenticated. +# +# NOTE: This will not work with the Ghidra Server if it has been initialized with a keystore +# unless the server has specifically been configured to use localhost interface. +# +# VMARGS=-Dghidra.disable.loopback.server.authentication=true + +# Optional CA certificates file may be specified to prevent use of OS and Java default CA certificates. +# If specified, only those CA certificates specified within this file will be considered when +# authenticating server connections which use the default SSLContext. If using the defaults is desirable +# adding CAs to the OS managed certificates should be done. In the case of Unix, whose system managed +# certificate store requires root access, the 'ghidra.unix.default.cacerts' property below can be used in +# the place of 'ghidra.cacerts'. # VMARGS=-Dghidra.cacerts= +# Optional Unix OS CA Trust Store path (does not apply to Windows or macOS). If the default set of +# Linux CA Trust Store paths, as defined by UnixSystemTrustKeyStoreUtil.java, does not include a suitable +# location where CA certificates are stored this property may specify a directory or file to be +# included when building the default OS trust store. This property is ignored if the ghidra.cacerts +# property has been set since OS trust store(s) will not be loaded. +# VMARGS=-Dghidra.unix.default.cacerts= + # Enable verbose logging for network SSL/TLS negotiations. # This can be very useful when troubleshooting SSLHandshakeException failures which can # manifest with very cryptic messages. All Ghidra Server communications rely on secure @@ -54,13 +78,6 @@ VMARGS=-Djdk.tls.client.protocols=TLSv1.2,TLSv1.3 # #VMARGS=-Djavax.net.debug=ssl -# When using Java 21.0.10 or later and connecting to an older Ghidra Server (pre-12.0.3) the following -# connection error may occur. -# ... SSLHandshakeException: (certificate_unknown) No matching found -# If unable to upgrade your Ghidra Server this property setting may be uncommented to disable the -# hostname check. -#VMARGS=-Djdk.rmi.ssl.client.enableEndpointIdentification=false - # The following property will limit the number of processor cores that Ghidra # will use for thread pools. If not specified, it will use the default number # of processors returned from Runtime.getRuntime().getAvailableProcessors(). diff --git a/Ghidra/Test/IntegrationTest/build.gradle b/Ghidra/Test/IntegrationTest/build.gradle index 5fa906b38f..2e6529b9a0 100644 --- a/Ghidra/Test/IntegrationTest/build.gradle +++ b/Ghidra/Test/IntegrationTest/build.gradle @@ -64,6 +64,8 @@ dependencies { testImplementation project(path: ':FunctionGraph', configuration: 'testArtifacts') testImplementation project(path: ':PDB', configuration: 'testArtifacts') testImplementation project(path: ':GnuDemangler', configuration: 'testArtifacts') + + testImplementation project(path: ':Generic', configuration: 'testArtifacts') testImplementation project(path: ':Framework-TraceModeling', configuration: 'testArtifacts') testImplementation project(path: ':Debugger', configuration: 'testArtifacts') diff --git a/Ghidra/Test/IntegrationTest/src/test.slow/java/ghidra/framework/main/NewProjectWizardTest.java b/Ghidra/Test/IntegrationTest/src/test.slow/java/ghidra/framework/main/NewProjectWizardTest.java index 0393287609..ed3131df3c 100644 --- a/Ghidra/Test/IntegrationTest/src/test.slow/java/ghidra/framework/main/NewProjectWizardTest.java +++ b/Ghidra/Test/IntegrationTest/src/test.slow/java/ghidra/framework/main/NewProjectWizardTest.java @@ -52,20 +52,9 @@ public class NewProjectWizardTest extends AbstractGhidraHeadedIntegrationTest { private File serverRoot; private RepositoryServerAdapter repositoryServer; - private static final String USER = ClientUtil.getUserName(); - private static final int SERVER_PORT = 14100; - private static String LOCALHOST = createLocalHostString(); - - private static String createLocalHostString() { - String localHostString = null; - try { - localHostString = InetAddress.getLocalHost().getHostName(); - } - catch (UnknownHostException e) { - localHostString = "127.0.0.1"; - } - return localHostString; - } + private static final String USER = SharedProjectUtil.USER; + private static final int SERVER_PORT = SharedProjectUtil.SERVER_PORT; + private static String LOCALHOST = SharedProjectUtil.LOCALHOST; public NewProjectWizardTest() { super(); diff --git a/Ghidra/Test/IntegrationTest/src/test.slow/java/ghidra/framework/main/SharedProjectUtil.java b/Ghidra/Test/IntegrationTest/src/test.slow/java/ghidra/framework/main/SharedProjectUtil.java index 8f85223a5a..1150cb9e75 100644 --- a/Ghidra/Test/IntegrationTest/src/test.slow/java/ghidra/framework/main/SharedProjectUtil.java +++ b/Ghidra/Test/IntegrationTest/src/test.slow/java/ghidra/framework/main/SharedProjectUtil.java @@ -21,7 +21,6 @@ import static org.junit.Assert.*; import java.io.File; import java.io.IOException; import java.net.InetAddress; -import java.net.UnknownHostException; import javax.swing.*; @@ -46,20 +45,12 @@ import utilities.util.FileUtilities; public class SharedProjectUtil { public static final int SERVER_PORT = ServerTestUtil.GHIDRA_TEST_SERVER_PORT; - public static String LOCALHOST = createLocalhostString(); - private static final String USER = ClientUtil.getUserName(); + public static String LOCALHOST = ServerTestUtil.LOCALHOST; + public static final String USER = ClientUtil.getUserName(); + private static File serverRoot; private static RepositoryServerAdapter repositoryServer; - private static String createLocalhostString() { - try { - return InetAddress.getLocalHost().getHostName(); - } - catch (UnknownHostException e) { - return "127.0.0.1"; - } - } - public static boolean createSharedProject(FrontEndTool frontEndTool, final String projectName) throws Exception { // create shared project against existing repository diff --git a/Ghidra/Test/IntegrationTest/src/test.slow/java/ghidra/server/remote/ServerTestUtil.java b/Ghidra/Test/IntegrationTest/src/test.slow/java/ghidra/server/remote/ServerTestUtil.java index 3e331d7e04..53eb70481b 100644 --- a/Ghidra/Test/IntegrationTest/src/test.slow/java/ghidra/server/remote/ServerTestUtil.java +++ b/Ghidra/Test/IntegrationTest/src/test.slow/java/ghidra/server/remote/ServerTestUtil.java @@ -15,14 +15,33 @@ */ package ghidra.server.remote; -import java.io.*; -import java.net.*; +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileReader; +import java.io.FileWriter; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.Inet4Address; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.NetworkInterface; +import java.net.Socket; +import java.net.SocketAddress; +import java.net.URL; +import java.net.UnknownHostException; import java.nio.file.Files; import java.nio.file.Path; import java.rmi.registry.LocateRegistry; import java.rmi.registry.Registry; import java.security.KeyStore.PrivateKeyEntry; -import java.util.*; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.HashSet; +import java.util.List; +import java.util.Set; import java.util.function.Consumer; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; @@ -34,32 +53,52 @@ import org.apache.commons.lang3.RandomStringUtils; import db.buffers.DataBuffer; import generic.hash.HashUtilities; -import generic.test.*; +import generic.test.AbstractGenericTest; +import generic.test.ConcurrentTestExceptionHandler; +import generic.test.TestUtils; import ghidra.framework.Application; -import ghidra.framework.client.*; +import ghidra.framework.client.ClientUtil; +import ghidra.framework.client.NotConnectedException; +import ghidra.framework.client.RepositoryServerAdapter; import ghidra.framework.data.ContentHandler; import ghidra.framework.data.DomainObjectAdapter; import ghidra.framework.protocol.ghidra.GhidraURL; +import ghidra.framework.protocol.ghidra.Handler; import ghidra.framework.remote.GhidraServerHandle; import ghidra.framework.remote.RMIServerPortFactory; import ghidra.framework.store.FileSystem; import ghidra.framework.store.local.LocalFileSystem; import ghidra.framework.store.local.LocalFolderItem; -import ghidra.net.*; +import ghidra.net.DefaultKeyManagerFactory; +import ghidra.net.DefaultSSLContextInitializer; +import ghidra.net.DefaultTrustManagerFactory; +import ghidra.net.PKITestUtils; +import ghidra.net.PKIUtils; import ghidra.program.model.listing.Program; import ghidra.server.ServerAdmin; import ghidra.server.UserManager; import ghidra.test.ToyProgramBuilder; -import ghidra.util.*; -import ghidra.util.exception.*; +import ghidra.util.InvalidNameException; +import ghidra.util.Msg; +import ghidra.util.NamingUtilities; +import ghidra.util.SystemUtilities; +import ghidra.util.exception.AssertException; +import ghidra.util.exception.CancelledException; +import ghidra.util.exception.DuplicateFileException; import ghidra.util.task.TaskMonitor; import ghidra.util.timer.GTimer; import utilities.util.FileUtilities; public class ServerTestUtil { + + static { + Handler.registerHandler(); + } public static final int GHIDRA_TEST_SERVER_PORT = 14100; - public static final String LOCALHOST = "127.0.0.1"; + public static final String LOCALHOST = InetAddress.getLoopbackAddress().getHostAddress(); + + private static final int TEST_PKI_CERT_DURATIONS_DAYS = 2; public static final String TEST_PKI_USER_PASSPHRASE = "xyzzy"; public static final String TEST_PKI_SERVER_PASSPHRASE = "plugh"; @@ -479,7 +518,10 @@ public class ServerTestUtil { argList.add("-anonymous"); } - argList.add("-ip" + LOCALHOST); // bind to loopback interface + // Force use of localhost interface only + argList.add("-ip" + LOCALHOST); + argList.add("-i" + LOCALHOST); + argList.add("-p" + port); argList.add(dirPath); @@ -618,7 +660,7 @@ public class ServerTestUtil { public static synchronized void disposeServer() { - System.setProperty(DefaultTrustManagerFactory.GHIDRA_CACERTS_PATH_PROPERTY, ""); + System.clearProperty(DefaultTrustManagerFactory.GHIDRA_CACERTS_PATH_PROPERTY); if (serverProcess != null) { @@ -822,39 +864,6 @@ public class ServerTestUtil { item.terminateCheckout(checkoutId, false); } - /** - * Add a new user to an existing local Ghidra Test Server using the ServerAdmin class. - * @param serverRoot server's repositories root directory - * @param name user name - * @param dn DN or null (applies to PKI authentication only) - * @throws Exception - */ - public static void addUser(File serverRoot, String name, String dn) throws Exception { - ServerAdmin serverAdmin = new ServerAdmin(); - if (dn != null) { - serverAdmin.execute(new String[] { serverRoot.getAbsolutePath(), "-dn", name, dn }); - } - else { - serverAdmin.execute(new String[] { serverRoot.getAbsolutePath(), "-add", name }); - } - } - - /** - * Grant an existing user access to a repository for an existing local Ghidra Test Server - * using the ServerAdmin class. - * @param serverRoot server's repositories root directory - * @param name user name - * @param repoName existing repository name - * @param access an access string: "+r", "+w", "+a" - * @throws Exception - */ - public static void setUserAccess(File serverRoot, String name, String repoName, String access) - throws Exception { - ServerAdmin serverAdmin = new ServerAdmin(); - serverAdmin.execute( - new String[] { serverRoot.getAbsolutePath(), "-grant", name, access, repoName }); - } - /** * Create and populate server test repositories "Test" and "Test1". The ADMIN_USER "test" * is added by default to both repositories. @@ -974,40 +983,93 @@ public class ServerTestUtil { // Generate CA certificate and keystore Msg.info(ServerTestUtil.class, "Generating self-signed CA cert: " + caPath); - PrivateKeyEntry caEntry = PKIUtils.createKeyEntry("test-CA", TEST_PKI_CA_DN, 2, null, null, - "PKCS12", null, DefaultKeyManagerFactory.DEFAULT_PASSWORD.toCharArray()); + PrivateKeyEntry caEntry = PKITestUtils.createKeyEntry("test-CA", TEST_PKI_CA_DN, + TEST_PKI_CERT_DURATIONS_DAYS, null, true, null, "PKCS12", null, + DefaultKeyManagerFactory.DEFAULT_PASSWORD.toCharArray()); PKIUtils.exportX509Certificates(caEntry.getCertificateChain(), caFile); // Generate User/Client certificate and keystore Msg.info(ServerTestUtil.class, "Generating test user key/cert (signed by test-CA, pwd: " + TEST_PKI_USER_PASSPHRASE + "): " + userKeystorePath); - PKIUtils.createKeyEntry("test-sig", TEST_PKI_USER_DN, 2, caEntry, userKeystoreFile, - "PKCS12", null, TEST_PKI_USER_PASSPHRASE.toCharArray()); + PKITestUtils.createKeyEntry("test-sig", TEST_PKI_USER_DN, TEST_PKI_CERT_DURATIONS_DAYS, + caEntry, false, userKeystoreFile, "PKCS12", null, + TEST_PKI_USER_PASSPHRASE.toCharArray()); - // Generate Server certificate and keystore + // Generate Server certificate and keystore - intended for localhost testing only Msg.info(ServerTestUtil.class, "Generating test server key/cert (signed by test-CA, pwd: " + TEST_PKI_SERVER_PASSPHRASE + "): " + serverKeystorePath); - - PKIUtils.createKeyEntry("test-sig", TEST_PKI_SERVER_DN, 2, caEntry, serverKeystoreFile, - "PKCS12", getLocalHostnames(), TEST_PKI_SERVER_PASSPHRASE.toCharArray()); + PKITestUtils.createKeyEntry("test-sig", TEST_PKI_SERVER_DN, TEST_PKI_CERT_DURATIONS_DAYS, + caEntry, false, serverKeystoreFile, "PKCS12", getLocalHostAlternateNames(), + TEST_PKI_SERVER_PASSPHRASE.toCharArray()); } - - private static Collection getLocalHostnames() throws SocketException { - - // Collect alternate hostnames for inclusion in certificate - Set altNames = new TreeSet<>(); + + private static Set getLocalHostAlternateNames() throws IOException { + // Collect alternate IPv4 hostnames and addresses for inclusion in certificate + Set altNames = new HashSet<>(); Enumeration nets = NetworkInterface.getNetworkInterfaces(); while (nets.hasMoreElements()) { NetworkInterface netint = nets.nextElement(); Enumeration addrs = netint.getInetAddresses(); while (addrs.hasMoreElements()) { InetAddress addr = addrs.nextElement(); - altNames.add(addr.getHostAddress()); - altNames.add(addr.getHostName()); - altNames.add(addr.getCanonicalHostName()); + if (addr instanceof Inet4Address) { + altNames.add(addr.getHostAddress()); + altNames.add(addr.getHostName()); + altNames.add(addr.getCanonicalHostName()); + } } } return altNames; } + /** + * Add a new user to an existing local Ghidra Test Server using the ServerAdmin class. + * @param serverRoot server's repositories root directory + * @param name user name + * @param dn DN or null (applies to PKI authentication only) + * @throws Exception + */ + public static void addUser(File serverRoot, String name, String dn) throws Exception { + ServerAdmin serverAdmin = new ServerAdmin(); + if (dn != null) { + serverAdmin.execute(new String[] { serverRoot.getAbsolutePath(), "-dn", name, dn }); + } + else { + serverAdmin.execute(new String[] { serverRoot.getAbsolutePath(), "-add", name }); + } + } + + /** + * Grant an existing user access to a repository for an existing local Ghidra Test Server + * using the ServerAdmin class. + * @param serverRoot server's repositories root directory + * @param name user name + * @param repoName existing repository name + * @param access an access string: "+r", "+w", "+a" + * @throws Exception + */ + public static void setUserAccess(File serverRoot, String name, String repoName, String access) + throws Exception { + ServerAdmin serverAdmin = new ServerAdmin(); + serverAdmin.execute( + new String[] { serverRoot.getAbsolutePath(), "-grant", name, access, repoName }); + } + + /** + * Add PKI user to server + * @param serverRoot + * @param userName + * @param dn + * @throws Exception + */ + public static void addPKIUser(File serverRoot, String userName, String dn) throws Exception { + ServerAdmin serverAdmin = new ServerAdmin(); + if (dn != null) { + serverAdmin.execute(new String[] { serverRoot.getAbsolutePath(), "-dn", userName, dn }); + } + else { + serverAdmin.execute(new String[] { serverRoot.getAbsolutePath(), "-add", userName }); + } + } + }