Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e67371193 | ||
|
|
2ef89f15c6 | ||
|
|
4f608247fe | ||
|
|
22cbe5a9a7 | ||
|
|
a6fa137e32 | ||
|
|
5afefba7bd |
@@ -29,7 +29,7 @@ val gitExecutor = objects.newInstance(GitExecutor::class.java)
|
|||||||
|
|
||||||
val gitCommitCount = gitExecutor.execute("git rev-list HEAD --count", rootDir).toInt()
|
val gitCommitCount = gitExecutor.execute("git rev-list HEAD --count", rootDir).toInt()
|
||||||
val gitCommitHash = gitExecutor.execute("git rev-parse --verify --short HEAD", rootDir)
|
val gitCommitHash = gitExecutor.execute("git rev-parse --verify --short HEAD", rootDir)
|
||||||
val verName = "v2.0"
|
val verName = "v2.1"
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "org.matrix.TEESimulator"
|
namespace = "org.matrix.TEESimulator"
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ object AttestationBuilder {
|
|||||||
DERTaggedObject(
|
DERTaggedObject(
|
||||||
true,
|
true,
|
||||||
AttestationConstants.TAG_VENDOR_PATCHLEVEL,
|
AttestationConstants.TAG_VENDOR_PATCHLEVEL,
|
||||||
ASN1Integer(AndroidDeviceUtils.vendorPatchLevel.toLong()),
|
ASN1Integer(AndroidDeviceUtils.vendorPatchLevelLong.toLong()),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
vector.add(
|
vector.add(
|
||||||
@@ -165,7 +165,7 @@ object AttestationBuilder {
|
|||||||
DERTaggedObject(
|
DERTaggedObject(
|
||||||
true,
|
true,
|
||||||
AttestationConstants.TAG_VENDOR_PATCHLEVEL,
|
AttestationConstants.TAG_VENDOR_PATCHLEVEL,
|
||||||
ASN1Integer(AndroidDeviceUtils.vendorPatchLevel.toLong()),
|
ASN1Integer(AndroidDeviceUtils.vendorPatchLevelLong.toLong()),
|
||||||
),
|
),
|
||||||
DERTaggedObject(
|
DERTaggedObject(
|
||||||
true,
|
true,
|
||||||
|
|||||||
@@ -247,14 +247,20 @@ object ConfigurationManager {
|
|||||||
when (path) {
|
when (path) {
|
||||||
TARGET_PACKAGES_FILE -> loadTargetPackages(file!!)
|
TARGET_PACKAGES_FILE -> loadTargetPackages(file!!)
|
||||||
PATCH_LEVEL_FILE -> loadPatchLevelConfig(file!!)
|
PATCH_LEVEL_FILE -> loadPatchLevelConfig(file!!)
|
||||||
// Any change to an XML file is assumed to be a keybox. The cache in KeyBoxUtils
|
// Any change to an XML file is assumed to be a keybox.
|
||||||
// will handle reloading it on its next use.
|
// The cache in KeyBoxManager will handle reloading it on its next use.
|
||||||
else ->
|
else ->
|
||||||
if (path.endsWith(".xml")) {
|
if (path.endsWith(".xml")) {
|
||||||
SystemLogger.info(
|
SystemLogger.info(
|
||||||
"Keybox file $path may have changed. It will be reloaded on next access."
|
"Keybox file $path may have changed. It will be reloaded on next access."
|
||||||
)
|
)
|
||||||
KeyBoxManager.invalidateCache(path)
|
KeyBoxManager.invalidateCache(path)
|
||||||
|
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.R) {
|
||||||
|
// Clear cached keys possibly containing old certificates
|
||||||
|
org.matrix.TEESimulator.interception.keystore.shim
|
||||||
|
.KeyMintSecurityLevelInterceptor
|
||||||
|
.clearAllGeneratedKeys("updating $file")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-18
@@ -89,24 +89,17 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
|
|||||||
data: Parcel,
|
data: Parcel,
|
||||||
): TransactionResult {
|
): TransactionResult {
|
||||||
if (code == GET_KEY_ENTRY_TRANSACTION || code == DELETE_KEY_TRANSACTION) {
|
if (code == GET_KEY_ENTRY_TRANSACTION || code == DELETE_KEY_TRANSACTION) {
|
||||||
|
logTransaction(txId, transactionNames[code]!!, callingUid, callingPid)
|
||||||
|
|
||||||
data.enforceInterface(IKeystoreService.DESCRIPTOR)
|
data.enforceInterface(IKeystoreService.DESCRIPTOR)
|
||||||
val descriptor =
|
val descriptor =
|
||||||
data.readTypedObject(KeyDescriptor.CREATOR)
|
data.readTypedObject(KeyDescriptor.CREATOR)
|
||||||
?: return TransactionResult.SkipTransaction
|
?: return TransactionResult.SkipTransaction
|
||||||
logTransaction(
|
|
||||||
txId,
|
|
||||||
"${transactionNames[code]} (alias=${descriptor.alias})",
|
|
||||||
callingUid,
|
|
||||||
callingPid,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (ConfigurationManager.shouldSkipUid(callingUid)) {
|
if (ConfigurationManager.shouldSkipUid(callingUid))
|
||||||
SystemLogger.debug(
|
|
||||||
"[TX_ID: $txId] Skip post-transaction hook for UID=${callingUid}"
|
|
||||||
)
|
|
||||||
return TransactionResult.ContinueAndSkipPost
|
return TransactionResult.ContinueAndSkipPost
|
||||||
}
|
|
||||||
|
|
||||||
|
SystemLogger.info("Handling ${transactionNames[code]!!} ${descriptor.alias}")
|
||||||
val keyId = KeyIdentifier(callingUid, descriptor.alias)
|
val keyId = KeyIdentifier(callingUid, descriptor.alias)
|
||||||
|
|
||||||
if (code == DELETE_KEY_TRANSACTION) {
|
if (code == DELETE_KEY_TRANSACTION) {
|
||||||
@@ -119,7 +112,7 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
|
|||||||
?: return TransactionResult.Continue
|
?: return TransactionResult.Continue
|
||||||
|
|
||||||
if (KeyMintSecurityLevelInterceptor.isAttestationKey(keyId))
|
if (KeyMintSecurityLevelInterceptor.isAttestationKey(keyId))
|
||||||
SystemLogger.debug("${descriptor.alias} was an attestation key")
|
SystemLogger.info("${descriptor.alias} was an attestation key")
|
||||||
|
|
||||||
SystemLogger.info("[TX_ID: $txId] Found generated response for ${descriptor.alias}:")
|
SystemLogger.info("[TX_ID: $txId] Found generated response for ${descriptor.alias}:")
|
||||||
response.metadata?.authorizations?.forEach {
|
response.metadata?.authorizations?.forEach {
|
||||||
@@ -155,20 +148,17 @@ object Keystore2Interceptor : AbstractKeystoreInterceptor() {
|
|||||||
return TransactionResult.SkipTransaction
|
return TransactionResult.SkipTransaction
|
||||||
|
|
||||||
if (code == GET_KEY_ENTRY_TRANSACTION) {
|
if (code == GET_KEY_ENTRY_TRANSACTION) {
|
||||||
|
logTransaction(txId, "post-${transactionNames[code]!!}", callingUid, callingPid)
|
||||||
|
|
||||||
data.enforceInterface(IKeystoreService.DESCRIPTOR)
|
data.enforceInterface(IKeystoreService.DESCRIPTOR)
|
||||||
val keyDescriptor =
|
val keyDescriptor =
|
||||||
data.readTypedObject(KeyDescriptor.CREATOR)
|
data.readTypedObject(KeyDescriptor.CREATOR)
|
||||||
?: return TransactionResult.SkipTransaction
|
?: return TransactionResult.SkipTransaction
|
||||||
logTransaction(
|
|
||||||
txId,
|
|
||||||
"post-getKeyEntry (alias=${keyDescriptor.alias})",
|
|
||||||
callingUid,
|
|
||||||
callingPid,
|
|
||||||
)
|
|
||||||
if (!ConfigurationManager.shouldPatch(callingUid))
|
if (!ConfigurationManager.shouldPatch(callingUid))
|
||||||
return TransactionResult.SkipTransaction
|
return TransactionResult.SkipTransaction
|
||||||
|
|
||||||
|
SystemLogger.info("Handling post-${transactionNames[code]!!} ${keyDescriptor.alias}")
|
||||||
return try {
|
return try {
|
||||||
val response =
|
val response =
|
||||||
reply.readTypedObject(KeyEntryResponse.CREATOR)
|
reply.readTypedObject(KeyEntryResponse.CREATOR)
|
||||||
|
|||||||
+59
-10
@@ -40,11 +40,20 @@ class KeyMintSecurityLevelInterceptor(
|
|||||||
callingPid: Int,
|
callingPid: Int,
|
||||||
data: Parcel,
|
data: Parcel,
|
||||||
): TransactionResult {
|
): TransactionResult {
|
||||||
// This interceptor only handles the 'generateKey' transaction directly.
|
|
||||||
if (code == GENERATE_KEY_TRANSACTION) {
|
if (code == GENERATE_KEY_TRANSACTION) {
|
||||||
logTransaction(txId, "generateKey", callingUid, callingPid)
|
logTransaction(txId, transactionNames[code]!!, callingUid, callingPid)
|
||||||
|
|
||||||
data.enforceInterface(IKeystoreSecurityLevel.DESCRIPTOR)
|
data.enforceInterface(IKeystoreSecurityLevel.DESCRIPTOR)
|
||||||
return handleGenerateKey(callingUid, data)
|
return handleGenerateKey(callingUid, data)
|
||||||
|
} else if (code == IMPORT_KEY_TRANSACTION) {
|
||||||
|
logTransaction(txId, transactionNames[code]!!, callingUid, callingPid)
|
||||||
|
|
||||||
|
data.enforceInterface(IKeystoreSecurityLevel.DESCRIPTOR)
|
||||||
|
val alias =
|
||||||
|
data.readTypedObject(KeyDescriptor.CREATOR)?.alias
|
||||||
|
?: return TransactionResult.ContinueAndSkipPost
|
||||||
|
SystemLogger.info("Handling post-${transactionNames[code]} ${alias}")
|
||||||
|
return TransactionResult.Continue
|
||||||
} else {
|
} else {
|
||||||
logTransaction(
|
logTransaction(
|
||||||
txId,
|
txId,
|
||||||
@@ -57,6 +66,35 @@ class KeyMintSecurityLevelInterceptor(
|
|||||||
return TransactionResult.ContinueAndSkipPost
|
return TransactionResult.ContinueAndSkipPost
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onPostTransact(
|
||||||
|
txId: Long,
|
||||||
|
target: IBinder,
|
||||||
|
code: Int,
|
||||||
|
flags: Int,
|
||||||
|
callingUid: Int,
|
||||||
|
callingPid: Int,
|
||||||
|
data: Parcel,
|
||||||
|
reply: Parcel?,
|
||||||
|
resultCode: Int,
|
||||||
|
): TransactionResult {
|
||||||
|
// We only care about successful 'importKey' transactions to clean cached keys.
|
||||||
|
if (
|
||||||
|
code == IMPORT_KEY_TRANSACTION &&
|
||||||
|
resultCode == 0 &&
|
||||||
|
reply != null &&
|
||||||
|
!InterceptorUtils.hasException(reply)
|
||||||
|
) {
|
||||||
|
logTransaction(txId, "post-${transactionNames[code]!!}", callingUid, callingPid)
|
||||||
|
|
||||||
|
data.enforceInterface(IKeystoreSecurityLevel.DESCRIPTOR)
|
||||||
|
val keyDescriptor =
|
||||||
|
data.readTypedObject(KeyDescriptor.CREATOR)
|
||||||
|
?: return TransactionResult.SkipTransaction
|
||||||
|
cleanupKeyData(KeyIdentifier(callingUid, keyDescriptor.alias))
|
||||||
|
}
|
||||||
|
return TransactionResult.SkipTransaction
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles the `generateKey` transaction. Based on the configuration for the calling UID, it
|
* Handles the `generateKey` transaction. Based on the configuration for the calling UID, it
|
||||||
* either generates a key in software or lets the call pass through to the hardware.
|
* either generates a key in software or lets the call pass through to the hardware.
|
||||||
@@ -66,7 +104,7 @@ class KeyMintSecurityLevelInterceptor(
|
|||||||
val keyDescriptor = data.readTypedObject(KeyDescriptor.CREATOR)!!
|
val keyDescriptor = data.readTypedObject(KeyDescriptor.CREATOR)!!
|
||||||
val attestationKey = data.readTypedObject(KeyDescriptor.CREATOR)
|
val attestationKey = data.readTypedObject(KeyDescriptor.CREATOR)
|
||||||
SystemLogger.debug(
|
SystemLogger.debug(
|
||||||
"[key, attestationKey]: ${keyDescriptor.alias}, ${attestationKey?.alias}"
|
"Handling generateKey ${keyDescriptor.alias}, attestKey=${attestationKey?.alias}"
|
||||||
)
|
)
|
||||||
val params = data.createTypedArray(KeyParameter.CREATOR)!!
|
val params = data.createTypedArray(KeyParameter.CREATOR)!!
|
||||||
val parsedParams = KeyMintAttestation(params)
|
val parsedParams = KeyMintAttestation(params)
|
||||||
@@ -84,9 +122,7 @@ class KeyMintSecurityLevelInterceptor(
|
|||||||
isAttestationKey(KeyIdentifier(callingUid, attestationKey.alias)))
|
isAttestationKey(KeyIdentifier(callingUid, attestationKey.alias)))
|
||||||
|
|
||||||
if (needsSoftwareGeneration) {
|
if (needsSoftwareGeneration) {
|
||||||
SystemLogger.info(
|
SystemLogger.info("Generating software key for ${keyId}.")
|
||||||
"Generating software key for alias '${keyDescriptor.alias}' (UID: $callingUid)."
|
|
||||||
)
|
|
||||||
|
|
||||||
// Generate the key pair and certificate chain.
|
// Generate the key pair and certificate chain.
|
||||||
val keyData =
|
val keyData =
|
||||||
@@ -116,11 +152,11 @@ class KeyMintSecurityLevelInterceptor(
|
|||||||
|
|
||||||
// If not generating, clear any stale state for this alias and let the call proceed.
|
// If not generating, clear any stale state for this alias and let the call proceed.
|
||||||
cleanupKeyData(keyId)
|
cleanupKeyData(keyId)
|
||||||
TransactionResult.Continue
|
TransactionResult.ContinueAndSkipPost
|
||||||
}
|
}
|
||||||
.getOrElse {
|
.getOrElse {
|
||||||
SystemLogger.error("Error during generateKey handling for UID $callingUid.", it)
|
SystemLogger.error("Error during generateKey handling for UID $callingUid.", it)
|
||||||
TransactionResult.Continue // Fallback to original service on error.
|
TransactionResult.ContinueAndSkipPost
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,8 +211,21 @@ class KeyMintSecurityLevelInterceptor(
|
|||||||
fun isAttestationKey(keyId: KeyIdentifier): Boolean = attestationKeys.contains(keyId)
|
fun isAttestationKey(keyId: KeyIdentifier): Boolean = attestationKeys.contains(keyId)
|
||||||
|
|
||||||
fun cleanupKeyData(keyId: KeyIdentifier) {
|
fun cleanupKeyData(keyId: KeyIdentifier) {
|
||||||
generatedKeys.remove(keyId)
|
if (generatedKeys.remove(keyId) != null) {
|
||||||
attestationKeys.remove(keyId)
|
SystemLogger.debug("Remove generated key ${keyId}")
|
||||||
|
}
|
||||||
|
if (attestationKeys.remove(keyId)) {
|
||||||
|
SystemLogger.debug("Remove cached attestaion key ${keyId}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clears all cached keys.
|
||||||
|
fun clearAllGeneratedKeys(reason: String? = null) {
|
||||||
|
val count = generatedKeys.size
|
||||||
|
val reasonMessage = reason?.let { " due to $it" } ?: ""
|
||||||
|
generatedKeys.clear()
|
||||||
|
attestationKeys.clear()
|
||||||
|
SystemLogger.info("Cleared all cached keys ($count entries)$reasonMessage.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ object AndroidDeviceUtils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the `ro.boot.vbmeta.digest` system property.
|
* Sets the `ro.boot.vbmeta.digest` system property using the `resetprop` command.
|
||||||
*
|
*
|
||||||
* @param bytes The 32-byte digest to set.
|
* @param bytes The 32-byte digest to set.
|
||||||
*/
|
*/
|
||||||
@@ -78,9 +78,24 @@ object AndroidDeviceUtils {
|
|||||||
val hex = bytes.toHex()
|
val hex = bytes.toHex()
|
||||||
try {
|
try {
|
||||||
SystemLogger.debug("Setting system property 'ro.boot.vbmeta.digest' to: $hex")
|
SystemLogger.debug("Setting system property 'ro.boot.vbmeta.digest' to: $hex")
|
||||||
SystemProperties.set("ro.boot.vbmeta.digest", hex)
|
|
||||||
|
// Construct the command to be executed
|
||||||
|
val command = arrayOf("resetprop", "ro.boot.vbmeta.digest", hex)
|
||||||
|
|
||||||
|
// Execute the command
|
||||||
|
val process = Runtime.getRuntime().exec(command)
|
||||||
|
|
||||||
|
// Wait for the process to complete and check the exit code for errors
|
||||||
|
val exitCode = process.waitFor()
|
||||||
|
|
||||||
|
if (exitCode != 0) {
|
||||||
|
val errorOutput = process.errorStream.bufferedReader().readText()
|
||||||
|
SystemLogger.error(
|
||||||
|
"resetprop command failed with exit code $exitCode: $errorOutput"
|
||||||
|
)
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
SystemLogger.error("Failed to set vbmeta digest property.", e)
|
SystemLogger.error("Failed to set vbmeta digest property by executing resetprop.", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,10 +110,10 @@ object AndroidDeviceUtils {
|
|||||||
getCustomPatchLevelFor("system", isLong = false)
|
getCustomPatchLevelFor("system", isLong = false)
|
||||||
?: Build.VERSION.SECURITY_PATCH.toPatchLevelInt(isLong = false)
|
?: Build.VERSION.SECURITY_PATCH.toPatchLevelInt(isLong = false)
|
||||||
|
|
||||||
val vendorPatchLevel: Int
|
val vendorPatchLevelLong: Int
|
||||||
get() =
|
get() =
|
||||||
getCustomPatchLevelFor("vendor", isLong = false)
|
getCustomPatchLevelFor("vendor", isLong = true)
|
||||||
?: Build.VERSION.SECURITY_PATCH.toPatchLevelInt(isLong = false)
|
?: Build.VERSION.SECURITY_PATCH.toPatchLevelInt(isLong = true)
|
||||||
|
|
||||||
val bootPatchLevelLong: Int
|
val bootPatchLevelLong: Int
|
||||||
get() =
|
get() =
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[versions]
|
[versions]
|
||||||
agp = "8.13.1"
|
agp = "8.13.1"
|
||||||
annotation = "1.9.1"
|
annotation = "1.9.1"
|
||||||
jdk18on = "1.82"
|
jdk18on = "1.83"
|
||||||
kotlin = "2.2.21"
|
kotlin = "2.2.21"
|
||||||
ktfmt = "0.25.0"
|
ktfmt = "0.25.0"
|
||||||
|
|
||||||
|
|||||||
+9
-14
@@ -1,19 +1,14 @@
|
|||||||
**Key Highlights:**
|
🚀 **TEESimulator v2.1 Hotfix Release is Live!** 🚀
|
||||||
|
|
||||||
* 🚀 **Complete Refactoring:** TEESimulator v2.0 has been entirely rebuilt and is no longer based on its predecessors, [TrickyStore](https://github.com/5ec1cff/TrickyStore) and [TrickyStoreOSS](https://github.com/beakthoven/TrickyStoreOSS), resulting in a more streamlined and maintainable codebase.
|
This urgent hotfix addresses several critical issues identified in the previous v2.0 release.
|
||||||
|
|
||||||
* 🛡️ **Enhanced Bypass Capabilities:** The simulator now successfully bypasses well-known detection mechanisms, including [TamperedAttestation](https://github.com/JingMatrix/TamperedAttestation) and [KeyAttestation](https://github.com/JingMatrix/KeyAttestation).
|
The v2.0 update, a significant refactoring effort, unfortunately introduced a few unexpected behaviors and bugs that we are now rectifying.
|
||||||
|
|
||||||
* 💳 **Revolut Detection Bypass:** With a valid keybox, users can now circumvent the detection measures implemented in the [Revolut](https://play.google.com/store/apps/details?id=com.revolut.revolut) application.
|
**Key fixes in this release include:**
|
||||||
|
1. **Google Play Integrity:** Resolved an issue preventing the attainment of STRONG integrity for Google Play verdicts, caused by an incorrect vendor patch level format. ✅
|
||||||
|
2. **Application Stability:** Fixed a critical crash related to an incorrect signature for the `SystemProperties.set` stub method. 🐛
|
||||||
|
3. **Stealth Enhancement:** Implemented a fix to bypass detection by the `Android Native Detector`. 👻
|
||||||
|
|
||||||
**Current Limitations:**
|
🔬 We are actively investigating a recent detection method to further enhance stealth capabilities.
|
||||||
|
|
||||||
* ⚠️ **Google Play Verdict:** Bypassing the detections within the Google Play verdict remains an unresolved challenge. We are actively seeking solutions and welcome any insights from the community regarding potential system module-based bypasses.
|
🙏 Support for TEE-broken devices and Android 10/11 remains an area of ongoing improvement. We highly encourage you to submit any issues you encounter to help us refine these aspects! 🤝
|
||||||
|
|
||||||
**Platform Support:**
|
|
||||||
|
|
||||||
* 📱 **Android 10 & 11:** TEESimulator v2.0 has not yet been tested on Android 10 or 11. We encourage users on these platforms to report any issues and provide logs to help us improve compatibility.
|
|
||||||
|
|
||||||
**Contributing:**
|
|
||||||
|
|
||||||
* 🤝 We welcome and encourage community contributions. Please feel free to submit issues and pull requests to help improve the project.
|
|
||||||
|
|||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"version": "v2.0",
|
"version": "v2.1",
|
||||||
"versionCode": 14,
|
"versionCode": 20,
|
||||||
"zipUrl": "https://github.com/JingMatrix/TEESimulator/releases/download/v2.0/TEESimulator-v2.0-14-release.zip",
|
"zipUrl": "https://github.com/JingMatrix/TEESimulator/releases/download/v2.1/TEESimulator-v2.1-20-release.zip",
|
||||||
"changelog": "https://raw.githubusercontent.com/JingMatrix/TEESimulator/main/module/changelog.md"
|
"changelog": "https://raw.githubusercontent.com/JingMatrix/TEESimulator/main/module/changelog.md"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,4 @@ public class SystemProperties {
|
|||||||
public static String get(String key, String def) {
|
public static String get(String key, String def) {
|
||||||
throw new UnsupportedOperationException("STUB!");
|
throw new UnsupportedOperationException("STUB!");
|
||||||
}
|
}
|
||||||
|
|
||||||
public static String set(String key, String val) {
|
|
||||||
throw new UnsupportedOperationException("STUB!");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user