Skip to content

Commit aa23bd7

Browse files
authored
Merge pull request #1837 from microsoftgraph/feat/change-notification
feat: adds ChangeNotification interfaces and static methods
2 parents 620d4e4 + 8a4f9b0 commit aa23bd7

16 files changed

+1503
-0
lines changed

gradle/dependencies.gradle

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ dependencies {
1111

1212
implementation 'jakarta.annotation:jakarta.annotation-api:2.1.1'
1313

14+
implementation 'io.jsonwebtoken:jjwt-api:0.12.6'
15+
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6'
16+
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6'
17+
implementation 'com.auth0:jwks-rsa:0.22.1'
18+
1419
api 'com.squareup.okhttp3:okhttp:4.12.0'
1520
api 'com.azure:azure-core:1.54.1'
1621

spotBugsExcludeFilter.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,4 +112,8 @@ xsi:schemaLocation="https://github.com/spotbugs/filter/3.0.0 https://raw.githubu
112112
<Bug pattern="DCN_NULLPOINTER_EXCEPTION" />
113113
<Class name="com.microsoft.graph.core.content.BatchResponseContentTest" />
114114
</Match>
115+
<Match>
116+
<Bug pattern="CT_CONSTRUCTOR_THROW" />
117+
<Class name="com.microsoft.graph.core.models.DiscoverUrlAdapter" />
118+
</Match>
115119
</FindBugsFilter>
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
package com.microsoft.graph.core.models;
2+
3+
import java.io.ByteArrayInputStream;
4+
import java.nio.charset.StandardCharsets;
5+
import java.security.Key;
6+
import java.util.Arrays;
7+
import java.util.Base64;
8+
import java.util.Objects;
9+
10+
import javax.crypto.Cipher;
11+
import javax.crypto.Mac;
12+
13+
import javax.crypto.spec.IvParameterSpec;
14+
import javax.crypto.spec.SecretKeySpec;
15+
16+
import com.microsoft.kiota.serialization.Parsable;
17+
import com.microsoft.kiota.serialization.ParsableFactory;
18+
import com.microsoft.kiota.serialization.ParseNode;
19+
import com.microsoft.kiota.serialization.ParseNodeFactoryRegistry;
20+
21+
import jakarta.annotation.Nonnull;
22+
import jakarta.annotation.Nullable;
23+
24+
/**
25+
* DecryptableContent interface
26+
*/
27+
public interface DecryptableContent {
28+
29+
/**
30+
* Sets the data
31+
* @param data resource data
32+
*/
33+
public void setData(@Nullable final String data);
34+
/**
35+
* Gets the data
36+
* @return the data
37+
*/
38+
public @Nullable String getData();
39+
/**
40+
* Sets the data key
41+
* @param dataKey asymmetric key used to sign data
42+
*/
43+
public void setDataKey(@Nullable final String dataKey);
44+
/**
45+
* Gets the data key
46+
* @return the data key
47+
*/
48+
public @Nullable String getDataKey();
49+
50+
/**
51+
* Sets the data signature
52+
* @param signature signature of the data
53+
*/
54+
public void setDataSignature(@Nullable final String signature);
55+
/**
56+
* Gets the data signature
57+
* @return data signature
58+
*/
59+
public @Nullable String getDataSignature();
60+
/**
61+
* Sets the encryption certificate id
62+
* @param encryptionCertificateId certificate Id used when subscribing
63+
*/
64+
public void setEncryptionCertificateId(@Nullable final String encryptionCertificateId);
65+
/**
66+
* Gets the encryption certificate id
67+
* @return the encryption certificate id
68+
*/
69+
public @Nullable String getEncryptionCertificateId();
70+
/**
71+
* Sets the encryption certificate thumbprint
72+
* @param encryptionCertificateThumbprint certificate thumbprint
73+
*/
74+
public void setEncryptionCertificateThumbprint(@Nullable final String encryptionCertificateThumbprint);
75+
/**
76+
* Gets the encryption certificate thumbprint
77+
* @return the encryption certificate thumbprint
78+
*/
79+
public @Nullable String getEncryptionCertificateThumbprint();
80+
81+
/**
82+
* Validates the signature of the resource data, decrypts resource data and deserializes the data to a Parsable
83+
* https://learn.microsoft.com/en-us/graph/change-notifications-with-resource-data?tabs=csharp#decrypting-resource-data-from-change-notifications
84+
*
85+
* @param <T> Parsable type to return
86+
* @param decryptableContent instance of DecryptableContent
87+
* @param certificateKeyProvider provides an RSA Private Key for the certificate provided when subscribing
88+
* @param factory ParsableFactory for the return type
89+
* @return decrypted resource data
90+
* @throws Exception if an error occurs while decrypting the data
91+
*/
92+
public static @Nonnull <T extends Parsable> T decrypt(@Nonnull final DecryptableContent decryptableContent, @Nonnull final CertificateKeyProvider certificateKeyProvider, @Nonnull final ParsableFactory<T> factory) throws Exception {
93+
Objects.requireNonNull(certificateKeyProvider);
94+
final String decryptedContent = decryptAsString(decryptableContent, certificateKeyProvider);
95+
final ParseNode rootParseNode = ParseNodeFactoryRegistry.defaultInstance.getParseNode(
96+
"application/json", new ByteArrayInputStream(decryptedContent.getBytes(StandardCharsets.UTF_8)));
97+
return rootParseNode.getObjectValue(factory);
98+
}
99+
100+
/**
101+
* Validates the signature and decrypts resource data attached to the notification.
102+
* https://learn.microsoft.com/en-us/graph/change-notifications-with-resource-data?tabs=csharp#decrypting-resource-data-from-change-notifications
103+
*
104+
* @param content instance of DecryptableContent
105+
* @param certificateKeyProvider provides an RSA Private Key for the certificate provided when subscribing
106+
* @return decrypted resource data
107+
* @throws Exception if an error occurs while decrypting the data
108+
*/
109+
public static @Nonnull String decryptAsString(@Nonnull final DecryptableContent content, @Nonnull final CertificateKeyProvider certificateKeyProvider) throws Exception {
110+
Objects.requireNonNull(content);
111+
Objects.requireNonNull(certificateKeyProvider);
112+
final Key privateKey = certificateKeyProvider.getCertificateKey(content.getEncryptionCertificateId(), content.getEncryptionCertificateThumbprint());
113+
final Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPWITHSHA1ANDMGF1PADDING");
114+
cipher.init(Cipher.DECRYPT_MODE, privateKey);
115+
final byte[] decryptedSymmetricKey = cipher.doFinal(Base64.getDecoder().decode(content.getDataKey()));
116+
117+
final Mac sha256Mac = Mac.getInstance("HmacSHA256");
118+
sha256Mac.init(new SecretKeySpec(decryptedSymmetricKey, "HmacSHA256"));
119+
final byte[] hashedData = sha256Mac.doFinal(Base64.getDecoder().decode(content.getData()));
120+
121+
final String expectedSignature = Base64.getEncoder().encodeToString(hashedData);
122+
if (!expectedSignature.equals(content.getDataSignature())) {
123+
throw new Exception("Signature does not match");
124+
}
125+
return new String(aesDecrypt(Base64.getDecoder().decode(content.getData()), decryptedSymmetricKey), StandardCharsets.UTF_8);
126+
}
127+
128+
/**
129+
* Decrypts the resource data using the decrypted symmetric key
130+
* @param data Base-64 decoded resource data
131+
* @param key Decrypted symmetric key from DecryptableContent.getDataKey()
132+
* @return decrypted resource data
133+
* @throws Exception if an error occurs while decrypting the data
134+
*/
135+
public static @Nonnull byte[] aesDecrypt(@Nonnull final byte[] data, @Nonnull final byte[] key) throws Exception {
136+
Objects.requireNonNull(data);
137+
Objects.requireNonNull(key);
138+
try {
139+
@SuppressWarnings("java:S3329")
140+
// Sonar warns that a random IV should be used for encryption
141+
// but we are decrypting here.
142+
final IvParameterSpec ivSpec = new IvParameterSpec(Arrays.copyOf(key, 16));
143+
@SuppressWarnings("java:S5542")
144+
// Sonar warns that cncryption algorithms should be used with secure mode and padding scheme
145+
// but ChangeNotifications implementation uses this algorithm for decryption.
146+
// https://learn.microsoft.com/en-us/graph/change-notifications-with-resource-data?tabs=java#decrypting-resource-data
147+
final Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
148+
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key, "AES"), ivSpec);
149+
return cipher.doFinal(data);
150+
} catch (Exception ex) {
151+
throw new RuntimeException("Unexpected error occurred while trying to decrypt the data", ex);
152+
}
153+
}
154+
155+
/**
156+
* Provides a private key for the certificate with the ID provided when creating the
157+
* subscription and the thumbprint.
158+
*/
159+
@FunctionalInterface
160+
public interface CertificateKeyProvider {
161+
/**
162+
* Returns the private key for an X.509 certificate with the given id and thumbprint
163+
* @param certificateId certificate Id provided when subscribing
164+
* @param certificateThumbprint certificate thumbprint
165+
* @return Private key used to sign the certificate
166+
*/
167+
public @Nonnull Key getCertificateKey(@Nullable final String certificateId, @Nullable final String certificateThumbprint);
168+
}
169+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package com.microsoft.graph.core.models;
2+
3+
import java.net.MalformedURLException;
4+
import java.net.URI;
5+
import java.net.URISyntaxException;
6+
import java.security.Key;
7+
import java.util.Objects;
8+
9+
import org.slf4j.LoggerFactory;
10+
11+
import com.auth0.jwk.Jwk;
12+
import com.auth0.jwk.JwkProvider;
13+
import com.auth0.jwk.UrlJwkProvider;
14+
15+
import io.jsonwebtoken.JweHeader;
16+
import io.jsonwebtoken.JwsHeader;
17+
import io.jsonwebtoken.LocatorAdapter;
18+
import jakarta.annotation.Nonnull;
19+
import jakarta.annotation.Nullable;
20+
21+
/**
22+
* DiscoverUrlAdapter class
23+
*/
24+
public class DiscoverUrlAdapter extends LocatorAdapter<Key> {
25+
26+
/**
27+
* Key store
28+
*/
29+
private final JwkProvider keyStore;
30+
31+
/**
32+
* Constructor
33+
* @param keyDiscoveryUrl the JWKS endpoint to use to retrieve signing keys
34+
* @throws URISyntaxException if uri is invalid
35+
* @throws MalformedURLException if url is invalid
36+
*/
37+
public DiscoverUrlAdapter(@Nonnull final String keyDiscoveryUrl)
38+
throws URISyntaxException, MalformedURLException {
39+
this.keyStore =
40+
new UrlJwkProvider(new URI(Objects.requireNonNull(keyDiscoveryUrl)).toURL());
41+
}
42+
43+
@Override
44+
protected @Nullable Key locate(@Nonnull JwsHeader header) {
45+
Objects.requireNonNull(header);
46+
try {
47+
String keyId = header.getKeyId();
48+
Jwk publicKey = keyStore.get(keyId);
49+
return publicKey.getPublicKey();
50+
} catch (final Exception e) {
51+
throw new IllegalArgumentException("Could not locate key", e);
52+
}
53+
}
54+
55+
@Override
56+
protected @Nullable Key locate(@Nonnull JweHeader header) {
57+
return null;
58+
}
59+
60+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package com.microsoft.graph.core.models;
2+
3+
import java.security.cert.CertificateEncodingException;
4+
import java.security.cert.X509Certificate;
5+
import java.util.Base64;
6+
import java.util.Objects;
7+
8+
import jakarta.annotation.Nonnull;
9+
import jakarta.annotation.Nullable;
10+
11+
/**
12+
* EncryptableSubscription interface
13+
*/
14+
public interface EncryptableSubscription {
15+
16+
/**
17+
* Sets the encryption certificate
18+
* @param certificate Base-64 encoded certificate to be used by Microsoft Graph to encrypt resource data
19+
*/
20+
public void setEncryptionCertificate(@Nullable final String certificate);
21+
22+
/**
23+
* Returns the encryption certificate
24+
* @return encryption certificate
25+
*/
26+
public @Nullable String getEncryptionCertificate();
27+
28+
/**
29+
* Converts an X.509 Certificate object to Base-64 string and adds to the encryptableSubscription provided
30+
* @param subscription encryptable subscription
31+
* @param certificate X.509 Certificate
32+
* @throws CertificateEncodingException if the certificate cannot be encoded
33+
*/
34+
public static void addPublicEncryptionCertificate(@Nonnull final EncryptableSubscription subscription, @Nonnull final X509Certificate certificate) throws CertificateEncodingException {
35+
Objects.requireNonNull(subscription);
36+
Objects.requireNonNull(certificate);
37+
subscription.setEncryptionCertificate(
38+
Base64.getEncoder().encodeToString(certificate.getEncoded())
39+
);
40+
}
41+
42+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.microsoft.graph.core.models;
2+
3+
/**
4+
* Contains Decryptable content
5+
* @param <T> The type of the decryptable content
6+
*/
7+
public interface EncryptedContentBearer<T extends DecryptableContent> {
8+
9+
/**
10+
* Sets encrypted content
11+
* @param encryptedContent encrypted content
12+
*/
13+
public void setEncryptedContent(T encryptedContent);
14+
15+
/**
16+
* Return encrypted content
17+
* @return encrypted content
18+
*/
19+
public T getEncryptedContent();
20+
21+
}

0 commit comments

Comments
 (0)