Skip to content

Commit

Permalink
SAK-45491 LTI Advantage Key Rotation (sakaiproject#9586)
Browse files Browse the repository at this point in the history
  • Loading branch information
csev authored Aug 25, 2021
1 parent 6780138 commit e599638
Show file tree
Hide file tree
Showing 9 changed files with 291 additions and 45 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -156,10 +156,14 @@ public interface LTIService extends LTISubstitutionsFilter {
"lti13_oidc_endpoint:text:label=bl_lti13_oidc_endpoint:maxlength=1024:role=admin",
"lti13_oidc_redirect:text:label=bl_lti13_oidc_redirect:maxlength=1024:role=admin",

// SAK-45491 - Key rotation interval
"lti13_platform_public_next:textarea:hidden=true:label=bl_lti13_platform_public:maxlength=1M:role=admin",
"lti13_platform_public_next_at:date",
"lti13_platform_private_next:textarea:hidden=true:label=bl_lti13_platform_public:maxlength=1M:role=admin",
"lti13_platform_public:textarea:hidden=true:label=bl_lti13_platform_public:maxlength=1M:role=admin",
"lti13_platform_private:textarea:hidden=true:label=bl_lti13_platform_private:maxlength=1M:role=admin",
"lti13_platform_public_old:textarea:hidden=true:label=bl_lti13_platform_public:maxlength=1M:role=admin",
"lti13_platform_public_old_at:date",
"lti13_platform_private:textarea:hidden=true:label=bl_lti13_platform_private:maxlength=1M:role=admin",
"lti13_settings:textarea:hidden=true:maxlength=1M:role=admin",

"lti11_launch_type:radio:label=bl_lti11_launch_type:role=admin:choices=inherit,legacy,lti112",
Expand Down Expand Up @@ -279,10 +283,13 @@ public interface LTIService extends LTISubstitutionsFilter {
String LTI13 = "lti13";
String LTI13_CLIENT_ID = "lti13_client_id";
String LTI13_TOOL_KEYSET = "lti13_tool_keyset";
String LTI13_PLATFORM_PUBLIC_NEXT = "lti13_platform_public_next";
String LTI13_PLATFORM_PUBLIC_NEXT_AT = "lti13_platform_public_next_at";
String LTI13_PLATFORM_PRIVATE_NEXT = "lti13_platform_private_next";
String LTI13_PLATFORM_PUBLIC = "lti13_platform_public";
String LTI13_PLATFORM_PUBLIC_OLD = "lti13_platform_public_old";
String LTI13_PLATFORM_PUBLIC_OLD_at = "lti13_platform_public_old_at";
String LTI13_PLATFORM_PRIVATE = "lti13_platform_private";
String LTI13_PLATFORM_PUBLIC_OLD = "lti13_platform_public_old";
String LTI13_PLATFORM_PUBLIC_OLD_AT = "lti13_platform_public_old_at";
String LTI13_OIDC_ENDPOINT = "lti13_oidc_endpoint";
String LTI13_OIDC_REDIRECT = "lti13_oidc_redirect";

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -635,7 +635,6 @@ protected void handleWellKnown(HttpServletRequest request, HttpServletResponse r

pc.lti_platform_configuration = lpc;


response.setContentType(APPLICATION_JSON);
try {
PrintWriter out = response.getWriter();
Expand Down Expand Up @@ -664,35 +663,41 @@ protected void handleKeySet(String tool_id, HttpServletRequest request, HttpServ
return;
}

String publicSerialized = BasicLTIUtil.toNull((String) tool.get(LTIService.LTI13_PLATFORM_PUBLIC));
if (publicSerialized == null) {
String publicSerializedCurrent = BasicLTIUtil.toNull((String) tool.get(LTIService.LTI13_PLATFORM_PUBLIC));
if (publicSerializedCurrent == null) {
response.setHeader(ERROR_DETAIL, "Client has no public key");
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
log.error("Client_id={} has no public key", tool_id);
return;
}

Key publicKey = LTI13Util.string2PublicKey(publicSerialized);
if (publicKey == null) {
Map<String, RSAPublicKey> keys = new TreeMap<>();

if (LTI13KeySetUtil.addPublicKey(keys, publicSerializedCurrent) != true ) {
response.setHeader(ERROR_DETAIL, "Client public key deserialization error");
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
log.error("Client_id={} deserialization error", tool_id);
return;
}

// Cast should work :)
RSAPublicKey rsaPublic = (RSAPublicKey) publicKey;
// Pull in Next and Old if they exist
String publicSerializedNext = BasicLTIUtil.toNull((String) tool.get(LTIService.LTI13_PLATFORM_PUBLIC_NEXT));
LTI13KeySetUtil.addPublicKey(keys, publicSerializedNext);
String publicSerializedOld = BasicLTIUtil.toNull((String) tool.get(LTIService.LTI13_PLATFORM_PUBLIC_OLD));
LTI13KeySetUtil.addPublicKey(keys, publicSerializedOld);


String keySetJSON = null;
try {
keySetJSON = LTI13KeySetUtil.getKeySetJSON(rsaPublic);
keySetJSON = LTI13KeySetUtil.getKeySetJSON(keys);
} catch (NoSuchAlgorithmException ex) {
response.setHeader(ERROR_DETAIL, "NoSuchAlgorithmException");
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
log.error("Client_id={} NoSuchAlgorithmException", tool_id);
return;
}

//
// Send Response
response.setContentType(APPLICATION_JSON);
try {
out = response.getWriter();
Expand All @@ -707,7 +712,17 @@ protected void handleKeySet(String tool_id, HttpServletRequest request, HttpServ
} catch (Exception e) {
log.error(e.getMessage(), e);
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
return;
}

// See if this key needs to be rotated
try {
SakaiBLTIUtil.rotateToolKeys(toolKey, tool);
} catch (Exception e) {
// We still return the JSON - just log and go
log.error(e.toString(), e);
}

}

protected void handleTokenPost(String tool_id, HttpServletRequest request, HttpServletResponse response) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import java.net.URL;
import java.net.URLEncoder;
import java.security.Key;
import java.security.KeyPair;
import java.util.Arrays;
import java.util.ArrayList;
import java.util.Enumeration;
Expand All @@ -34,6 +35,8 @@
import java.util.Properties;
import java.util.TimeZone;
import java.util.TreeMap;
import java.time.Instant;
import java.time.Duration;

import javax.servlet.http.HttpServletRequest;

Expand Down Expand Up @@ -81,6 +84,7 @@
import org.sakaiproject.util.api.FormattedText;
import org.sakaiproject.util.ResourceLoader;
import org.sakaiproject.util.Web;
import org.sakaiproject.util.foorm.Foorm;
import org.sakaiproject.lti13.util.SakaiLineItem;
import org.sakaiproject.lti13.util.SakaiDeepLink;
import org.sakaiproject.lti13.util.SakaiLaunchJWT;
Expand Down Expand Up @@ -139,6 +143,9 @@ public class SakaiBLTIUtil {
public static final String LTI13_DEPLOYMENT_ID = "lti13.deployment_id";
public static final String LTI13_DEPLOYMENT_ID_DEFAULT = "1"; // To match Moodle
public static final String LTI_CUSTOM_SUBSTITION_PREFIX = "lti.custom.substitution.";
// SAK-45491 - Key rotation interval
public static final String LTI_ADVANTAGE_KEY_ROTATION_DAYS = "lti.advantage.key.rotation.days";
public static final String LTI_ADVANTAGE_KEY_ROTATION_DAYS_DEFAULT = "30";

// These are the field names in old school portlet placements
public static final String BASICLTI_PORTLET_KEY = "key";
Expand Down Expand Up @@ -402,7 +409,7 @@ public static String encryptSecret(String orig, String encryptionKey) {
}

// Never double encrypt
String check = decryptSecret(orig, encryptionKey);
String check = decryptSecret(orig, encryptionKey, true);
if ( ! orig.equals(check) ) {
return orig;
}
Expand All @@ -414,10 +421,10 @@ public static String encryptSecret(String orig, String encryptionKey) {

public static String decryptSecret(String orig) {
String encryptionKey = ServerConfigurationService.getString(BASICLTI_ENCRYPTION_KEY, null);
return decryptSecret(orig, encryptionKey);
return decryptSecret(orig, encryptionKey, false);
}

public static String decryptSecret(String orig, String encryptionKey) {
public static String decryptSecret(String orig, String encryptionKey, boolean checkonly) {
if (StringUtils.isEmpty(orig) || StringUtils.isEmpty(encryptionKey) ) {
return orig;
}
Expand All @@ -426,7 +433,7 @@ public static String decryptSecret(String orig, String encryptionKey) {
String newsecret = SimpleEncryption.decrypt(encryptionKey, orig);
return newsecret;
} catch (RuntimeException re) {
log.debug("Exception when decrypting secret - this is normal if the secret is unencrypted");
if ( ! checkonly ) log.debug("Exception when decrypting secret - this is normal if the secret is unencrypted");
return orig;
}
}
Expand Down Expand Up @@ -3057,6 +3064,87 @@ public static Object copyLTIContent(Map<String, Object> ltiContent, String siteI
return result;
}

/**
* rotateToolKeys - If necessary - rotate tool keys
*
* This is controlled by a sakai.property
*
* lti.advantage.key.rotation.days=30
*
* For positive numbers this is the number of days before rotation happens
* For negative numbers it is the number of minutes before rotation happens (for testing)
* If it is zero, no rotation happens
*
*/
// SAK-45491 - Support LTI 1.3 Key Rotation
public static void rotateToolKeys(Long toolKey, Map<String, Object> tool)
{
// Get services
LTIService ltiService = (LTIService) ComponentManager.get("org.sakaiproject.lti.api.LTIService");
org.sakaiproject.component.api.ServerConfigurationService serverConfigurationService =
(org.sakaiproject.component.api.ServerConfigurationService) ComponentManager.get("org.sakaiproject.component.api.ServerConfigurationService");

String daysStr = serverConfigurationService.getString(LTI_ADVANTAGE_KEY_ROTATION_DAYS, LTI_ADVANTAGE_KEY_ROTATION_DAYS_DEFAULT);
int days = LTI13Util.getInt(daysStr);
if ( days == 0 ) return;

Instant now = Instant.now();
Instant nextInstant = Foorm.getInstantUTC(tool.get(LTIService.LTI13_PLATFORM_PUBLIC_NEXT_AT));

Map<String, Object> updates = new TreeMap<String, Object>();

// Generate next Keypair in case we update
KeyPair kp = LTI13Util.generateKeyPair();
String pub = LTI13Util.getPublicEncoded(kp);
String priv = LTI13Util.getPrivateEncoded(kp);
priv = SakaiBLTIUtil.encryptSecret(priv);
updates.put(LTIService.LTI13_PLATFORM_PUBLIC_NEXT, pub);
updates.put(LTIService.LTI13_PLATFORM_PRIVATE_NEXT, priv);

updates.put(LTIService.LTI13_PLATFORM_PUBLIC_NEXT_AT, Foorm.now());
String siteId = null; // bypass

if ( nextInstant == null ) {
Object retval = ltiService.updateToolDao(toolKey, updates, siteId);
if ( retval instanceof String) {
log.error("Could not update tool={} retval={}", toolKey, retval);
} else if ( nextInstant == null ) {
log.info("Created future keys for tool={}", toolKey);
}
} else {
long deltaDays = Duration.between(now, nextInstant).abs().toDays();
long deltaMinutes = Duration.between(now, nextInstant).abs().toMinutes();

// Should we rotate?
if ( ( days > 0 && deltaDays >= days ) || ( days < -1 && deltaMinutes >= (-1*days) ) ) {

// Only rotate next->current if next already contains valid values
String publicSerializedNext = BasicLTIUtil.toNull((String) tool.get(LTIService.LTI13_PLATFORM_PUBLIC_NEXT));
String privateSerializedNext = BasicLTIUtil.toNull((String) tool.get(LTIService.LTI13_PLATFORM_PRIVATE_NEXT));
Key publicKeyNext = LTI13Util.string2PublicKey(publicSerializedNext);
// Key privateKeyNext = (privateSerializedNext == null) ? null : LTI13Util.string2PrivateKey(SakaiBLTIUtil.decryptSecret(privateSerializedNext));
Key privateKeyNext = LTI13Util.string2PrivateKey(SakaiBLTIUtil.decryptSecret(privateSerializedNext));

if ( publicKeyNext != null && privateKeyNext != null ) {
String publicSerializedCurrent = BasicLTIUtil.toNull((String) tool.get(LTIService.LTI13_PLATFORM_PUBLIC));

updates.put(LTIService.LTI13_PLATFORM_PUBLIC, publicSerializedNext);
updates.put(LTIService.LTI13_PLATFORM_PRIVATE, privateSerializedNext);
updates.put(LTIService.LTI13_PLATFORM_PUBLIC_OLD, publicSerializedCurrent);
updates.put(LTIService.LTI13_PLATFORM_PUBLIC_OLD_AT, Foorm.now());
}

// If the next key is somehow broken, at least we update the next values
Object retval = ltiService.updateToolDao(toolKey, updates, siteId);
if ( retval instanceof String) {
log.error("Could not update tool={} retval={}", toolKey, retval);
} else {
log.info("Rotated keys for tool={} days={} delta={}", toolKey, days, deltaDays);
}
}
}
}

public static Long getLong(Object key) {
return LTI13Util.getLong(key);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
package org.sakaiproject.util.foorm;

import java.sql.ResultSetMetaData;
import java.sql.Timestamp;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
Expand All @@ -33,6 +34,11 @@
import java.util.SortedMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAccessor;

import org.apache.commons.lang3.StringUtils;
import org.sakaiproject.lti.api.LTISearchData;
Expand Down Expand Up @@ -1062,7 +1068,7 @@ public String formExtract(Object parms, String[] formDefinition, Object loader,

// Check the automatically populate empty date fields
if ("autodate".equals(type) && dataMap != null && (!isFieldSet(parms, field)) ) {
java.sql.Timestamp sqlTimestamp = new java.sql.Timestamp(
Timestamp sqlTimestamp = new Timestamp(
new java.util.Date().getTime());
if ("updated_at".equals(field) || (forInsert && "created_at".equals(field))) {
dataMap.put(field, sqlTimestamp);
Expand Down Expand Up @@ -1173,6 +1179,16 @@ public String formExtract(Object parms, String[] formDefinition, Object loader,
dataMap.put(field, sdf);
}
}

if ("date".equals(type) ) {
if (sdf == null) {
if (dataMap != null)
dataMap.put(field, null);
} else {
if (dataMap != null)
dataMap.put(field, getInstantUTC(sdf));
}
}
}
if (sb.length() < 1)
return null;
Expand Down Expand Up @@ -2126,6 +2142,51 @@ public String getPagedSelect(String sqlIn, int startRec, int endRec, String vend
}
}

/**
* Deal with the vagaries of date object types returned from this library - all UTC
*/
// https://www.baeldung.com/java-date-to-localdate-and-localdatetime
public static Instant getInstantUTC(Object input)
{
if ( input == null ) return null;

String dateString = null;
if ( input instanceof LocalDateTime ) {
return ((LocalDateTime) input).toInstant(ZoneOffset.UTC);
} else if ( input instanceof Timestamp ) {
return ((Timestamp) input).toInstant();
} else if ( input instanceof Date ) {
Date dateToConvert = (Date) input;
return dateToConvert.toInstant();
} else if ( input instanceof String ) {
dateString = (String) input;
if ( dateString.trim().length() < 1 ) return null;
} else {
dateString = input.toString();
}

// https://stackoverflow.com/questions/4024544/how-to-parse-dates-in-multiple-formats-using-simpledateformat
String pattern = "[yyyy-MM-dd[['T'][ ]HH:mm:ss[.SSSSSSSz][.SSS[XXX][X]]]]";
try {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern(pattern).withZone(ZoneOffset.UTC);
TemporalAccessor accessor = formatter.parse(dateString);
return Instant.from(accessor);
} catch(Exception e) {
return null;
}

}

/**
* Return now() in the right format to add to a Map to all Foorm routines
*/
public static String now()
{
Instant instant = Foorm.getInstantUTC(new Date());
String nowStr = instant.toString();
return nowStr;
}

/**
* Deal with suffixes like "M" and "K"
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,8 @@ public void testEncryptDecrypt() {
String encrypt2 = SakaiBLTIUtil.encryptSecret(encrypt1, key);
assertTrue(goodEncrypt(encrypt2));
assertEquals(encrypt1, encrypt2);
String decrypt = SakaiBLTIUtil.decryptSecret(encrypt2, key);
boolean checkonly = false;
String decrypt = SakaiBLTIUtil.decryptSecret(encrypt2, key, checkonly);
assertEquals(plain, decrypt);
}

Expand Down
Loading

0 comments on commit e599638

Please sign in to comment.