Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions RELEASE-NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,23 @@ This is a minor enhancement release.
unresolved so placeholders such as $properties.storageBaseDir$ keep working. Pass --dry-run to validate the file and
see what would be written without touching the database, or --replace to overwrite partnerships already stored there.
The whole migration runs in one transaction, so a file that cannot be represented in the schema leaves it untouched.
2. Add partial update support so a partner, partnership or certificate no longer has to be deleted and recreated to change
it. Previously "add" refused to overwrite an existing entry, so the only route was delete followed by recreate, which
loses the definition outright if the recreate fails. New "update" commands for partner and partnership merge only what
is supplied and leave everything else alone, working against both the XML and the database partnership store, and a new
PATCH API endpoint exposes them: PATCH /api/partner/<name>, PATCH /api/partnership/<name> and PATCH /api/cert/<alias>
with the attributes to change as form fields. Use pollerConfig.<attr> to change a partnership poller attribute and
sender.name or receiver.name to point a partnership at a different partner. For a certificate, send the base64 encoded
certificate in the "data" field to replace a partner certificate, or a base64 encoded PKCS12 in "data" plus the password
that opens it in "password" to replace a certificate and its private key together, which is what rotating an identity of
your own needs.
3. Fix the PUT and DELETE API endpoints, which dropped the first character of the item name, and PUT and HEAD, which bound
a path parameter that was not in their path template and so never received the resource name.
4. Fix replacing the key pair held under a keystore alias. Importing a PKCS12 over an alias that already held a private key
failed because the certificate was staged with setCertificateEntry first, which a keystore refuses on such an alias. The
key entry is now written in one operation, so "cert import <alias> <file.p12> <password>" and the certificate PATCH can
both rotate an existing identity. The previous key pair is left in place unless the new one is written successfully, and
the entry is stored under the keystore password so it can be read back.

Version 4.11.0 - 2026-09-02
===========================
Expand Down
2 changes: 2 additions & 0 deletions Server/src/config/commands.xml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
description="Partner commands">
<command classname="org.openas2.app.partner.ListPartnersCommand"/>
<command classname="org.openas2.app.partner.AddPartnerCommand"/>
<command classname="org.openas2.app.partner.UpdatePartnerCommand"/>
<command classname="org.openas2.app.partner.DeletePartnerCommand"/>
<command classname="org.openas2.app.partner.ViewPartnerCommand"/>
<command classname="org.openas2.app.partner.FilterCertPartnerCommand"/>
Expand All @@ -21,6 +22,7 @@
<command classname="org.openas2.app.partner.RefreshPartnershipsCommand"/>
<command classname="org.openas2.app.partner.ListPartnershipsCommand"/>
<command classname="org.openas2.app.partner.AddPartnershipCommand"/>
<command classname="org.openas2.app.partner.UpdatePartnershipCommand"/>
<command classname="org.openas2.app.partner.DeletePartnershipCommand"/>
<command classname="org.openas2.app.partner.StorePartnershipsCommand"/>
<command classname="org.openas2.app.partner.ViewPartnershipCommand"/>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package org.openas2.app.partner;

import org.openas2.OpenAS2Exception;
import org.openas2.cmd.CommandResult;
import org.openas2.partner.DbPartnershipFactory;
import org.openas2.partner.Partnership;
import org.openas2.partner.PartnershipFactory;
import org.openas2.partner.XMLPartnershipFactory;

import java.util.LinkedHashMap;
import java.util.Map;

/**
* Merges attributes into an existing partner without deleting and recreating it.
* <p>
* Only the attributes supplied are changed, so this is a partial update. It exists because "add"
* refuses to overwrite an existing entry, which previously left callers no way to change a partner
* except to delete it first and lose the definition if the recreate then failed.
*/
public class UpdatePartnerCommand extends AliasedPartnershipsCommand {
public String getDefaultDescription() {
return "Update attributes of an existing partner, leaving the ones not supplied unchanged.";
}

public String getDefaultName() {
return "update";
}

public String getDefaultUsage() {
return "update <name> <attribute-1=value-1> [attribute-2=value-2] ... [attribute-n=value-n]";
}

public CommandResult execute(PartnershipFactory partFx, Object[] params) throws OpenAS2Exception {
if (params.length < 2) {
return new CommandResult(CommandResult.TYPE_INVALID_PARAM_COUNT, getUsage());
}

synchronized (partFx) {
String name = params[0].toString();
Map<String, String> attributes = new LinkedHashMap<String, String>();
for (int i = 1; i < params.length; i++) {
String param = params[i].toString();
int equalsPos = param.indexOf('=');
if (equalsPos == 0) {
return new CommandResult(CommandResult.TYPE_ERROR, "incoming parameter missing name");
} else if (equalsPos < 0) {
return new CommandResult(CommandResult.TYPE_ERROR, "incoming parameter missing value");
}
String attrName = param.substring(0, equalsPos);
if (Partnership.PID_NAME.equals(attrName)) {
// The name identifies the partner and the partnerships reference it, so changing
// it here would silently orphan them
return new CommandResult(CommandResult.TYPE_ERROR,
"A partner cannot be renamed by an update. Add the new partner, repoint the partnerships, then delete the old one.");
}
attributes.put(attrName, param.substring(equalsPos + 1));
}

if (!(partFx instanceof DbPartnershipFactory) && !(partFx instanceof XMLPartnershipFactory)) {
return new CommandResult(CommandResult.TYPE_COMMAND_NOT_SUPPORTED, "Not supported by current partnership store");
}
try {
if (partFx instanceof DbPartnershipFactory) {
((DbPartnershipFactory) partFx).updatePartner(name, attributes);
} else {
((XMLPartnershipFactory) partFx).updatePartner(name, attributes);
}
} catch (OpenAS2Exception e) {
// Report an unknown partner as a command error so an API caller gets the reason back
// rather than a server error, matching how the delete command behaves
return new CommandResult(CommandResult.TYPE_ERROR, e.getMessage());
}
return new CommandResult(CommandResult.TYPE_OK, "Updated partner: " + name);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package org.openas2.app.partner;

import org.openas2.OpenAS2Exception;
import org.openas2.cmd.CommandResult;
import org.openas2.partner.DbPartnershipFactory;
import org.openas2.partner.Partnership;
import org.openas2.partner.PartnershipFactory;
import org.openas2.partner.XMLPartnershipFactory;

import java.util.LinkedHashMap;
import java.util.Map;

/**
* Merges attributes, poller configuration and partner references into an existing partnership
* without deleting and recreating it.
* <p>
* Only what is supplied is changed, so this is a partial update. It exists because "add" refuses to
* overwrite an existing entry, which previously left callers no way to change a partnership except
* to delete it first and lose the definition if the recreate then failed.
*/
public class UpdatePartnershipCommand extends AliasedPartnershipsCommand {

private static final String POLLER_CONFIG_PREFIX = Partnership.PCFG_POLLER + ".";
private static final String SENDER_NAME_PARAM = Partnership.PTYPE_SENDER + "." + Partnership.PID_NAME;
private static final String RECEIVER_NAME_PARAM = Partnership.PTYPE_RECEIVER + "." + Partnership.PID_NAME;

public String getDefaultDescription() {
return "Update an existing partnership, leaving anything not supplied unchanged.";
}

public String getDefaultName() {
return "update";
}

public String getDefaultUsage() {
return "update <name> [attribute-1=value-1] ... [attribute-n=value-n] [pollerConfig.attr=value ...]"
+ " [" + SENDER_NAME_PARAM + "=<partner name>] [" + RECEIVER_NAME_PARAM + "=<partner name>]\n"
+ "\t- subject=\"New subject\" replaces the value of the existing subject attribute or adds it\n"
+ "\t- pollerConfig.interval=30 replaces that attribute of the pollerConfig element or adds it\n"
+ "\t- " + SENDER_NAME_PARAM + "=PartnerB points the partnership at a different partner";
}

public CommandResult execute(PartnershipFactory partFx, Object[] params) throws OpenAS2Exception {
if (params.length < 2) {
return new CommandResult(CommandResult.TYPE_INVALID_PARAM_COUNT, getUsage());
}

synchronized (partFx) {
String name = params[0].toString();
Map<String, String> attributes = new LinkedHashMap<String, String>();
Map<String, String> pollerConfig = new LinkedHashMap<String, String>();
String senderName = null;
String receiverName = null;

for (int i = 1; i < params.length; i++) {
String param = params[i].toString();
int equalsPos = param.indexOf('=');
if (equalsPos == 0) {
return new CommandResult(CommandResult.TYPE_ERROR, "incoming parameter missing name");
} else if (equalsPos < 0) {
return new CommandResult(CommandResult.TYPE_ERROR, "incoming parameter missing value");
}
String key = param.substring(0, equalsPos);
String value = param.substring(equalsPos + 1);
if (SENDER_NAME_PARAM.equals(key)) {
senderName = value;
} else if (RECEIVER_NAME_PARAM.equals(key)) {
receiverName = value;
} else if (key.startsWith(POLLER_CONFIG_PREFIX)) {
pollerConfig.put(key.substring(POLLER_CONFIG_PREFIX.length()), value);
} else if (Partnership.PID_NAME.equals(key)) {
// The name identifies the partnership, so changing it here would leave callers
// unable to tell which definition they are now looking at
return new CommandResult(CommandResult.TYPE_ERROR,
"A partnership cannot be renamed by an update. Add the new partnership then delete the old one.");
} else {
attributes.put(key, value);
}
}

if (attributes.isEmpty() && pollerConfig.isEmpty() && senderName == null && receiverName == null) {
return new CommandResult(CommandResult.TYPE_ERROR, "Nothing to update.\n" + getUsage());
}

if (!(partFx instanceof DbPartnershipFactory) && !(partFx instanceof XMLPartnershipFactory)) {
return new CommandResult(CommandResult.TYPE_COMMAND_NOT_SUPPORTED, "Not supported by current partnership store");
}
try {
if (partFx instanceof DbPartnershipFactory) {
((DbPartnershipFactory) partFx).updatePartnership(name, attributes, pollerConfig, senderName, receiverName);
} else {
((XMLPartnershipFactory) partFx).updatePartnership(name, attributes, pollerConfig, senderName, receiverName);
}
} catch (OpenAS2Exception e) {
// Report an unknown partnership or partner as a command error so an API caller gets
// the reason back rather than a server error, matching how the delete command behaves
return new CommandResult(CommandResult.TYPE_ERROR, e.getMessage());
}
return new CommandResult(CommandResult.TYPE_OK, "Updated partnership: " + name);
}
}
}
81 changes: 79 additions & 2 deletions Server/src/main/java/org/openas2/cert/X509CertificateFactory.java
Original file line number Diff line number Diff line change
Expand Up @@ -228,15 +228,92 @@ public boolean importPrivateKey(String alias, KeyStore ks, String password) thro
String certAlias = aliases.nextElement();
Certificate cert = ks.getCertificate(certAlias);
if (cert instanceof X509Certificate) {
addCertificate(alias, (X509Certificate) cert, true);
Key certKey = ks.getKey(certAlias, password.toCharArray());
addPrivateKey(alias, certKey, password);
if (certKey == null) {
// A certificate with no key in the source: not what this method is importing
continue;
}
Certificate[] chain = ks.getCertificateChain(certAlias);
if (chain == null || chain.length == 0) {
chain = selfSignedChain((X509Certificate) cert, alias);
}
setPrivateKeyEntry(alias, certKey, chain);
return true;
}
}
return false;
}

/**
* Replaces the certificate and private key held under an alias in one operation.
* <p>
* This is what makes it possible to rotate an identity: the alias for a key pair of our own
* already holds a private key, and {@link java.security.KeyStore#setCertificateEntry} refuses to
* touch such an alias because replacing only the certificate would orphan the key. Setting the
* key entry replaces whatever the alias held, so the previous entry stays in place until the new
* one is written and there is no window where the alias has a certificate but no key.
* <p>
* The entry is protected with the keystore password rather than the password of the file the key
* came from, because that is what {@link #getPrivateKey(String)} reads it back with.
*
* @param alias - the alias to write the entry to, whether or not it already exists
* @param key - the private key to store
* @param chain - the certificate chain for the key, leaf first
* @throws OpenAS2Exception if the entry could not be written
*/
private void setPrivateKeyEntry(String alias, Key key, Certificate[] chain) throws OpenAS2Exception {
KeyStore ks = getKeyStore();
Key previousKey = null;
Certificate[] previousChain = null;
try {
if (ks.containsAlias(alias)) {
previousChain = ks.getCertificateChain(alias);
try {
previousKey = ks.getKey(alias, getPassword());
} catch (GeneralSecurityException e) {
// Nothing recoverable to keep, so there is nothing to put back on failure
previousKey = null;
}
}
ks.setKeyEntry(alias, key, getPassword(), chain);
save(getFilename(), getPassword());
} catch (Exception e) {
/*
* Put the previous key pair back so a failed rotation leaves the running server able to
* keep signing with the identity it already had.
*/
if (previousKey != null && previousChain != null) {
try {
ks.setKeyEntry(alias, previousKey, getPassword(), previousChain);
} catch (Exception restoreFailure) {
logger.error("Failed to restore the previous key entry for alias " + alias
+ " after the replacement failed. The keystore may need to be restored from a backup.", restoreFailure);
}
}
if (e instanceof OpenAS2Exception) {
throw (OpenAS2Exception) e;
}
throw new WrappedException(e);
}
}

/**
* A key entry needs a chain, so a self-signed certificate that arrives without one stands as its
* own chain. The certificate is repeated to keep the stored chain the same shape as the one the
* two step import produced before this method existed.
*/
private Certificate[] selfSignedChain(X509Certificate cert, String alias) throws OpenAS2Exception {
if (!cert.getSubjectX500Principal().equals(cert.getIssuerX500Principal())) {
throw new OpenAS2Exception("No certificate chain was supplied for alias " + alias
+ " and the certificate is not self-signed, so the chain cannot be established."
+ " Import a keystore that contains the full chain for the key.");
}
if (logger.isInfoEnabled()) {
logger.info("Detected self-signed certificate and allowed import. Alias: " + alias);
}
return new X509Certificate[]{cert, cert};
}

public void clearCertificates() throws OpenAS2Exception {
KeyStore ks = getKeyStore();

Expand Down
Loading
Loading