Skip to content

Commit 44d2cd3

Browse files
committed
Teach the oracle to speak the symmetric binding
WSS4J now signs with an HMAC keyed by one xenc:EncryptedKey and encrypts the Body under that same key, with the xenc:ReferenceList a sibling of the key rather than a child of it and a ds:KeyInfo on every xenc:EncryptedData. The RequireDerivedKeys variant hangs one wsc:DerivedKeyToken per block off that single key instead. The PHP symmetric binding is being written against this arrangement and nothing here had ever round-tripped it. The new test settles it: WSS4J accepts both shapes it emits, so the sibling reference list the design assumed is real rather than reported. Resolving an #EncryptedKeySHA1 reference goes through a callback and nowhere else, because WSS4J never looks at the xenc:EncryptedKey standing beside it in the header. The verifier therefore matches the identifier against the digest of each wrapped key it has already processed.
1 parent 457564f commit 44d2cd3

8 files changed

Lines changed: 631 additions & 9 deletions

File tree

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,13 @@ AES-256-GCM + RSA-OAEP). Override per request via query string, e.g.
6868
`POST /sign?keyref=SubjectKeyIdentifier&sigalg=RSA_SHA512&c14n=INCLUSIVE`,
6969
`POST /encrypt?encdata=AES256_CBC&oaep=SHA256&enckeyref=IssuerSerial`.
7070

71+
`symmetric=true` is the one option that changes what `/sign` produces rather than only how: it emits a
72+
WS-SecurityPolicy SymmetricBinding, so the response carries an HMAC signature and an encrypted Body keyed by
73+
one shared `xenc:EncryptedKey`, with the `xenc:ReferenceList` a sibling of that key. `sigalg` has to name an
74+
HMAC there, and `derivedkeys=true` puts a `wsc:DerivedKeyToken` per block in between.
75+
7176
Recognised query params:
72-
- `/sign`: `keyref`, `sigalg` (`RSA_SHA256|RSA_SHA512|ECDSA_SHA256`), `sigalias` (`java-server`|`ec-client`), `c14n`, `disableBsp`, `ttl`.
77+
- `/sign`: `keyref`, `sigalg` (`RSA_SHA256|RSA_SHA512|ECDSA_SHA256|HMAC_SHA1|HMAC_SHA256|HMAC_SHA512`), `sigalias` (`java-server`|`ec-client`), `c14n`, `disableBsp`, `ttl`; `symmetric` + `derivedkeys` + `recipient` for the SymmetricBinding.
7378
- `/verify`: `sigalg`, `disableBsp`, `ttl`; `sig`/`ts`/`ut` (require-flags) + `user`/`pass`/`utdigest` for UsernameToken validation.
7479
- `/encrypt`: `encdata` (`AES256_GCM|AES256_CBC`), `oaep` (`SHA1|SHA256`), `enckeyref` (`SubjectKeyIdentifier|IssuerSerial`), `recipient`.
7580
- `/attach`: `op` (`emit|receive`), `type` (`swa|mtom`), `protocol` (`soap11|soap12`), `cid`.

oracle/src/main/java/org/phpsoap/interop/OracleServer.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,12 @@ private static ScenarioConfig configFrom(URI uri) {
385385
if (q.containsKey("strTransform")) {
386386
config.signTokenThroughStrTransform = Boolean.parseBoolean(q.get("strTransform"));
387387
}
388+
if (q.containsKey("symmetric")) {
389+
config.symmetricBinding = Boolean.parseBoolean(q.get("symmetric"));
390+
}
391+
if (q.containsKey("derivedkeys")) {
392+
config.requireDerivedKeys = Boolean.parseBoolean(q.get("derivedkeys"));
393+
}
388394
if (q.containsKey("sigalias")) {
389395
config.signatureKeyAlias = q.get("sigalias");
390396
}

oracle/src/main/java/org/phpsoap/interop/ScenarioConfig.java

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,32 @@ public final class ScenarioConfig {
8383
*/
8484
public String signatureKeyReference = "BinarySecurityToken";
8585

86-
/** XML-DSig signature algorithm: RSA_SHA256 (default), RSA_SHA512 or ECDSA_SHA256. */
86+
/**
87+
* XML-DSig signature algorithm: RSA_SHA256 (default), RSA_SHA512, ECDSA_SHA256, or one of the
88+
* HMAC_SHA1 / HMAC_SHA256 / HMAC_SHA512 keyed-MAC algorithms the symmetric binding uses. Mirrors the PHP
89+
* {@code Algorithm\SignatureMethod} enum, which carries the same two families in one type so a consumer
90+
* has to decide which kind of key it is holding rather than defaulting to the certificate route.
91+
*/
8792
public String signatureAlgorithm = "RSA_SHA256";
8893

94+
/**
95+
* Emit a WS-SecurityPolicy SymmetricBinding: one session key, wrapped once in an xenc:EncryptedKey, keying
96+
* an HMAC signature and the Body encryption both, with the xenc:ReferenceList a sibling of that key.
97+
*
98+
* <p>This one flag decides the whole shape of what the signer emits rather than modifying it, so it
99+
* overrides {@link #requireSignature} and {@link #requireEncryption} instead of combining with them: the
100+
* two blocks are two uses of one key here, and a binding carrying only one of them is not this binding.
101+
* Those two flags keep their meaning on the verifying side, where they say what a message must carry.
102+
*/
103+
public boolean symmetricBinding = false;
104+
105+
/**
106+
* Derive a separate key per block from the one xenc:EncryptedKey, each announced by its own
107+
* wsc:DerivedKeyToken, rather than using the session key directly. What {@code sp:RequireDerivedKeys}
108+
* asks for. Only read when {@link #symmetricBinding} is on.
109+
*/
110+
public boolean requireDerivedKeys = false;
111+
89112
/**
90113
* When true the signer puts the whole certification path in the wsse:BinarySecurityToken as an
91114
* ASN.1 SEQUENCE OF Certificate (#X509PKIPathv1) instead of the leaf certificate alone
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package org.phpsoap.interop;
2+
3+
import org.apache.wss4j.common.ext.WSPasswordCallback;
4+
import org.apache.wss4j.common.util.KeyUtils;
5+
import org.apache.wss4j.dom.WSConstants;
6+
import org.apache.wss4j.dom.WSDocInfo;
7+
import org.apache.wss4j.dom.engine.WSSecurityEngineResult;
8+
import org.apache.wss4j.dom.handler.RequestData;
9+
10+
import javax.security.auth.callback.Callback;
11+
import javax.security.auth.callback.CallbackHandler;
12+
import javax.security.auth.callback.UnsupportedCallbackException;
13+
import java.io.IOException;
14+
import java.util.Base64;
15+
16+
/**
17+
* Hands WSS4J the session key behind an {@code #EncryptedKeySHA1} reference.
18+
*
19+
* <p>WSS4J resolves that reference through a callback and nothing else: it does not look at the
20+
* {@code xenc:EncryptedKey} it just decrypted in the same header, because in the deployment this was written
21+
* for the key came from an earlier exchange and lived in a cache. A message that carries its own key, as the
22+
* SymmetricBinding does, therefore needs somebody to make the connection, which is all this does: the
23+
* identifier is by definition the SHA-1 of a wrapped key's cipher bytes, so hashing the cipher bytes of each
24+
* {@code xenc:EncryptedKey} already processed finds the one it names.
25+
*
26+
* <p>Only works when the {@code xenc:EncryptedKey} precedes whatever references it in the header, which is
27+
* what a reader walking document order requires anyway.
28+
*/
29+
final class SessionKeyCallbackHandler implements CallbackHandler {
30+
31+
private final CallbackHandler passwords;
32+
private final RequestData data;
33+
34+
SessionKeyCallbackHandler(CallbackHandler passwords, RequestData data) {
35+
this.passwords = passwords;
36+
this.data = data;
37+
}
38+
39+
@Override
40+
public void handle(Callback[] callbacks) throws IOException, UnsupportedCallbackException {
41+
for (Callback callback : callbacks) {
42+
byte[] sessionKey = null;
43+
if (callback instanceof WSPasswordCallback pc && pc.getUsage() == WSPasswordCallback.SECRET_KEY) {
44+
sessionKey = sessionKeyFor(pc.getIdentifier());
45+
if (sessionKey != null) {
46+
pc.setKey(sessionKey);
47+
}
48+
}
49+
if (sessionKey == null) {
50+
passwords.handle(new Callback[] {callback});
51+
}
52+
}
53+
}
54+
55+
/** @return the decrypted session key whose wrapped form digests to {@code identifier}, or null. */
56+
private byte[] sessionKeyFor(String identifier) throws IOException {
57+
WSDocInfo processed = data.getWsDocInfo();
58+
if (identifier == null || processed == null) {
59+
return null;
60+
}
61+
62+
try {
63+
for (WSSecurityEngineResult result : processed.getResultsByTag(WSConstants.ENCR)) {
64+
byte[] wrapped =
65+
(byte[]) result.get(WSSecurityEngineResult.TAG_ENCRYPTED_EPHEMERAL_KEY);
66+
if (wrapped == null) {
67+
continue;
68+
}
69+
String digest = Base64.getEncoder().encodeToString(KeyUtils.generateDigest(wrapped));
70+
if (digest.equals(identifier)) {
71+
return (byte[]) result.get(WSSecurityEngineResult.TAG_SECRET);
72+
}
73+
}
74+
} catch (Exception e) {
75+
throw new IOException("could not digest a processed xenc:EncryptedKey", e);
76+
}
77+
78+
return null;
79+
}
80+
}

oracle/src/main/java/org/phpsoap/interop/Signer.java

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
import org.apache.wss4j.dom.message.WSSecUsernameToken;
1010
import org.w3c.dom.Document;
1111

12+
import java.util.ArrayList;
13+
import java.util.List;
14+
1215
/**
1316
* Produces a WSS4J-signed SOAP message whose wire shape matches what the PHP
1417
* {@code Outbound\Signature} + {@code Outbound\Timestamp} + {@code Outbound\BinarySecurityToken}
@@ -66,7 +69,11 @@ String sign(String xml) throws Exception {
6669
timestamp.build();
6770
}
6871

69-
if (config.requireSignature) {
72+
if (config.symmetricBinding) {
73+
// One key, two blocks: signing and encryption are not independent here, so one collaborator emits
74+
// both rather than this method growing an encryption branch of its own.
75+
new SymmetricBinding(crypto, config).apply(document, header, signedParts(document));
76+
} else if (config.requireSignature) {
7077
WSSecSignature signature = new WSSecSignature(header);
7178
signature.setUserInfo(keyAlias, keyPassword);
7279
signature.setKeyIdentifierType(keyIdentifierType(config.signatureKeyReference));
@@ -77,11 +84,7 @@ String sign(String xml) throws Exception {
7784
// will not complete the chain itself gets the intermediates handed to it.
7885
signature.setUseSingleCertificate(!config.signatureCertificatePath);
7986

80-
signature.getParts().add(
81-
new WSEncryptionPart(WSConstants.ELEM_BODY, soapNamespace(document), "Content"));
82-
if (config.requireTimestamp) {
83-
signature.getParts().add(new WSEncryptionPart("Timestamp", WSConstants.WSU_NS, "Element"));
84-
}
87+
signature.getParts().addAll(signedParts(document));
8588
if (config.signTokenThroughStrTransform) {
8689
// "STRTransform" is reserved: WSSecSignature rewrites this part's id to strUri, the
8790
// wsse:SecurityTokenReference it puts in ds:KeyInfo, and WSSecSignatureBase gives the
@@ -96,6 +99,17 @@ String sign(String xml) throws Exception {
9699
return Xml.serialize(document);
97100
}
98101

102+
/** What every signature here covers: the SOAP Body and, when there is one, the wsu:Timestamp. */
103+
private List<WSEncryptionPart> signedParts(Document document) {
104+
List<WSEncryptionPart> parts = new ArrayList<>();
105+
parts.add(new WSEncryptionPart(WSConstants.ELEM_BODY, soapNamespace(document), "Content"));
106+
if (config.requireTimestamp) {
107+
parts.add(new WSEncryptionPart("Timestamp", WSConstants.WSU_NS, "Element"));
108+
}
109+
110+
return parts;
111+
}
112+
99113
/** Maps the PHP KeyRef enum names to the WSS4J key-identifier constants. */
100114
static int keyIdentifierType(String keyReference) {
101115
switch (keyReference) {
@@ -139,6 +153,12 @@ static String signatureAlgorithm(String name) {
139153
return WSConstants.RSA_SHA512;
140154
case "ECDSA_SHA256":
141155
return ECDSA_SHA256;
156+
case "HMAC_SHA1":
157+
return WSConstants.HMAC_SHA1;
158+
case "HMAC_SHA256":
159+
return WSConstants.HMAC_SHA256;
160+
case "HMAC_SHA512":
161+
return WSConstants.HMAC_SHA512;
142162
default:
143163
throw new IllegalArgumentException("Unknown signature.algorithm: " + name);
144164
}
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
package org.phpsoap.interop;
2+
3+
import org.apache.wss4j.common.WSEncryptionPart;
4+
import org.apache.wss4j.common.crypto.Crypto;
5+
import org.apache.wss4j.dom.WSConstants;
6+
import org.apache.wss4j.dom.message.WSSecDKEncrypt;
7+
import org.apache.wss4j.dom.message.WSSecDKSign;
8+
import org.apache.wss4j.dom.message.WSSecEncrypt;
9+
import org.apache.wss4j.dom.message.WSSecEncryptedKey;
10+
import org.apache.wss4j.dom.message.WSSecHeader;
11+
import org.apache.wss4j.dom.message.WSSecSignature;
12+
import org.w3c.dom.Document;
13+
14+
import javax.crypto.KeyGenerator;
15+
import javax.crypto.SecretKey;
16+
import java.util.List;
17+
18+
/**
19+
* The WS-SecurityPolicy SymmetricBinding: one session key, wrapped once in an {@code xenc:EncryptedKey},
20+
* keying the signature (as an HMAC) and the encryption both. Unlike the asymmetric flow, where signing and
21+
* encryption are independent blocks that happen to sit in the same header, here they are two uses of one key,
22+
* so one class emits both.
23+
*
24+
* <p>Two things about the wire shape are the reason this exists. First, the {@code xenc:ReferenceList} is a
25+
* SIBLING of the {@code xenc:EncryptedKey} rather than a child of it, which is what lets a signature cover the
26+
* key element for token protection without the encryption block writing into that same element afterwards.
27+
* The price is a {@code ds:KeyInfo} on every {@code xenc:EncryptedData}, since a detached reference list no
28+
* longer says which key opens what. Second, the key is named by {@code #EncryptedKeySHA1}, the digest of the
29+
* key's CIPHER bytes, which is the one identifier both peers can compute: the session key itself is known
30+
* only to the sender until the recipient unwraps it.
31+
*
32+
* <p>Header layout falls out of WSS4J prepending each block it builds, and the resulting document order is
33+
* load-bearing rather than cosmetic. A reader walking the header meets the {@code xenc:EncryptedKey} first, so
34+
* it holds the session key by the time the {@code xenc:ReferenceList} asks it to decrypt, and the Body is
35+
* plaintext again by the time the {@code ds:Signature} asks it to verify.
36+
*
37+
* <p>With {@code derivedkeys=true} each block gets its own {@code wsc:DerivedKeyToken} off that single
38+
* {@code xenc:EncryptedKey} instead, P_SHA1 over a per-token nonce, which is what {@code sp:RequireDerivedKeys}
39+
* asks for, and what a live peer has been seen sending.
40+
*/
41+
final class SymmetricBinding {
42+
43+
private final Crypto crypto;
44+
private final ScenarioConfig config;
45+
46+
SymmetricBinding(Crypto crypto, ScenarioConfig config) {
47+
this.crypto = crypto;
48+
this.config = config;
49+
}
50+
51+
/**
52+
* @param signedParts what the signature covers, decided by the caller so the asymmetric and symmetric
53+
* flows cannot drift on it
54+
*/
55+
void apply(Document document, WSSecHeader header, List<WSEncryptionPart> signedParts) throws Exception {
56+
// Santuario must be initialised before the encryption classes are used directly; the engine-based
57+
// paths trigger it lazily, this one does not.
58+
org.apache.xml.security.Init.init();
59+
60+
SecretKey sessionKey = sessionKey();
61+
WSSecEncryptedKey encryptedKey = encryptedKey(header);
62+
encryptedKey.prepare(crypto, sessionKey);
63+
64+
if (config.requireDerivedKeys) {
65+
deriveAndApply(document, header, signedParts, encryptedKey, sessionKey);
66+
} else {
67+
applyDirectly(document, header, signedParts, encryptedKey, sessionKey);
68+
}
69+
70+
// Last, so it lands in front of everything that needs it.
71+
encryptedKey.prependToHeader();
72+
}
73+
74+
/** Sign and encrypt with the session key itself, naming it by the digest of its cipher bytes. */
75+
private void applyDirectly(
76+
Document document,
77+
WSSecHeader header,
78+
List<WSEncryptionPart> signedParts,
79+
WSSecEncryptedKey encryptedKey,
80+
SecretKey sessionKey) throws Exception {
81+
82+
WSSecSignature signature = new WSSecSignature(header);
83+
signature.setKeyIdentifierType(WSConstants.ENCRYPTED_KEY_SHA1_IDENTIFIER);
84+
signature.setEncrKeySha1value(encryptedKey.getEncryptedKeySHA1());
85+
signature.setSecretKey(sessionKey.getEncoded());
86+
signature.setSignatureAlgorithm(macAlgorithm());
87+
signature.setDigestAlgo(WSConstants.SHA256);
88+
signature.setSigCanonicalization(Signer.canonicalizationUri(config.canonicalization));
89+
signature.getParts().addAll(signedParts);
90+
signature.build(crypto);
91+
92+
WSSecEncrypt encrypt = new WSSecEncrypt(header);
93+
// The key is already on the wire in the block above, so this one references it rather than wrapping a
94+
// second copy. That is also what makes WSS4J emit the reference list detached instead of nested.
95+
encrypt.setEncryptSymmKey(false);
96+
encrypt.setKeyIdentifierType(WSConstants.ENCRYPTED_KEY_SHA1_IDENTIFIER);
97+
// Without this WSS4J would name the session key by its own digest rather than by the digest of the
98+
// cipher bytes, which is an identifier no recipient can reproduce.
99+
encrypt.setCustomReferenceValue(encryptedKey.getEncryptedKeySHA1());
100+
encrypt.setSymmetricEncAlgorithm(Encryptor.dataAlgorithm(config.dataEncryptionAlgorithm));
101+
encrypt.getParts().add(bodyContent(document));
102+
encrypt.build(crypto, sessionKey);
103+
}
104+
105+
/** Derive one key per block from the session key, each announced by its own wsc:DerivedKeyToken. */
106+
private void deriveAndApply(
107+
Document document,
108+
WSSecHeader header,
109+
List<WSEncryptionPart> signedParts,
110+
WSSecEncryptedKey encryptedKey,
111+
SecretKey sessionKey) throws Exception {
112+
113+
WSSecDKSign signature = new WSSecDKSign(header);
114+
signature.setTokenIdentifier(encryptedKey.getId());
115+
signature.setCustomValueType(WSConstants.WSS_ENC_KEY_VALUE_TYPE);
116+
signature.setSignatureAlgorithm(macAlgorithm());
117+
signature.setDigestAlgorithm(WSConstants.SHA256);
118+
signature.setSigCanonicalization(Signer.canonicalizationUri(config.canonicalization));
119+
signature.getParts().addAll(signedParts);
120+
signature.build(sessionKey.getEncoded());
121+
122+
WSSecDKEncrypt encrypt = new WSSecDKEncrypt(header);
123+
encrypt.setTokenIdentifier(encryptedKey.getId());
124+
encrypt.setCustomValueType(WSConstants.WSS_ENC_KEY_VALUE_TYPE);
125+
encrypt.setSymmetricEncAlgorithm(Encryptor.dataAlgorithm(config.dataEncryptionAlgorithm));
126+
encrypt.getParts().add(bodyContent(document));
127+
encrypt.build(sessionKey.getEncoded());
128+
}
129+
130+
/**
131+
* The signature here is keyed by the session key, so its algorithm has to be a keyed MAC. Refused rather
132+
* than quietly substituted: an RSA name reaching this path means the caller believes a certificate is
133+
* signing, and a message that says HMAC while the sender thought otherwise is the confusion this whole
134+
* feature has to avoid.
135+
*/
136+
private String macAlgorithm() {
137+
if (!config.signatureAlgorithm.startsWith("HMAC_")) {
138+
throw new IllegalArgumentException(
139+
"a symmetric binding needs an HMAC signature.algorithm, got: " + config.signatureAlgorithm);
140+
}
141+
142+
return Signer.signatureAlgorithm(config.signatureAlgorithm);
143+
}
144+
145+
private WSSecEncryptedKey encryptedKey(WSSecHeader header) {
146+
WSSecEncryptedKey encryptedKey = new WSSecEncryptedKey(header);
147+
encryptedKey.setUserInfo(config.encryptionRecipientAlias);
148+
encryptedKey.setKeyIdentifierType(Encryptor.keyIdentifierType(config.encryptionKeyReference));
149+
encryptedKey.setKeyEncAlgo(Encryptor.keyAlgorithm(config.keyEncryptionAlgorithm));
150+
encryptedKey.setDigestAlgorithm(Encryptor.oaepDigestAlgorithm(config.oaepDigest));
151+
encryptedKey.setMGFAlgorithm(Encryptor.oaepMgfAlgorithm(config.oaepDigest));
152+
153+
return encryptedKey;
154+
}
155+
156+
/**
157+
* Both AES-256-GCM and AES-256-CBC take a 256-bit AES key, and a keyed MAC accepts a key of any length, so
158+
* one generator serves every algorithm combination the scenario offers.
159+
*/
160+
private static SecretKey sessionKey() throws Exception {
161+
KeyGenerator generator = KeyGenerator.getInstance("AES");
162+
generator.init(256);
163+
164+
return generator.generateKey();
165+
}
166+
167+
private static WSEncryptionPart bodyContent(Document document) {
168+
String namespace = document.getDocumentElement().getNamespaceURI();
169+
170+
return new WSEncryptionPart(
171+
WSConstants.ELEM_BODY,
172+
namespace != null ? namespace : WSConstants.URI_SOAP12_ENV,
173+
"Content");
174+
}
175+
}

0 commit comments

Comments
 (0)