wallet: support offset in key image export and import
What changed, and why it matters
This commit updates the Java Monero wallet library so that exporting and importing signed key images now also carries an 'offset' value. The offset tells the wallet where the exported key images start among all the wallet's outputs. This is a feature/API alignment change rather than a clear security fix. There is no evidence in the commit message or diff that the author describes it as fixing a vulnerability, and no independent security references are provided.
Treat this as a routine API/feature update. If using the new importKeyImages(List, long) overload, ensure the offset is validated and matches the wallet's actual output list to avoid incorrect spent-status reporting. Review the underlying monero-cpp/native implementation for how it handles out-of-range offsets, since this wrapper does not add validation.
Security signals we found
API surface change for key image import/export
New offset parameter passed to native wallet import_key_images
No explicit security claim in commit message or diff
No input validation added for offset or key image fields
No bounds/overflow checks visible in the diff
Evidence from the diff
The change adds a MoneroKeyImageExportResult model containing a Long offset and a List
Changed components
monero.wallet.MoneroWallet interfacemonero.wallet.MoneroWalletDefaultmonero.wallet.MoneroWalletFullmonero.wallet.MoneroWalletRpcmonero.wallet.model.MoneroKeyImageExportResultsrc/main/cpp/monero_jni_bridge.cppTestMoneroWalletCommonTestMoneroWalletFullInspect captured patch +99 / −59
diff --git a/docs/monero-spec.drawio b/docs/monero-spec.drawio
index c6bbbf1..5d06c5c 100644
--- a/docs/monero-spec.drawio
+++ b/docs/monero-spec.drawio
@@ -945,10 +945,10 @@
<mxCell id="QYUe6zCbC_x82nWfNGpH-329" parent="QYUe6zCbC_x82nWfNGpH-248" style="text;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontFamily=Verdana;" value="importOutputs(string outputsHex): uint" vertex="1">
<mxGeometry height="26" width="814" y="318" as="geometry" />
</mxCell>
- <mxCell id="QYUe6zCbC_x82nWfNGpH-330" parent="QYUe6zCbC_x82nWfNGpH-248" style="text;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontFamily=Verdana;" value="exportKeyImages(bool all=false): MoneroKeyImage[]" vertex="1">
+ <mxCell id="QYUe6zCbC_x82nWfNGpH-330" parent="QYUe6zCbC_x82nWfNGpH-248" style="text;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontFamily=Verdana;" value="exportKeyImages(bool all=false): MoneroKeyImageExportResult" vertex="1">
<mxGeometry height="26" width="814" y="344" as="geometry" />
</mxCell>
- <mxCell id="QYUe6zCbC_x82nWfNGpH-331" parent="QYUe6zCbC_x82nWfNGpH-248" style="text;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontFamily=Verdana;" value="importKeyImages(MoneroKeyImage[] keyImages): MoneroKeyImageImportResult" vertex="1">
+ <mxCell id="QYUe6zCbC_x82nWfNGpH-331" parent="QYUe6zCbC_x82nWfNGpH-248" style="text;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontFamily=Verdana;" value="importKeyImages(MoneroKeyImage[] keyImages, uint offset=0): MoneroKeyImageImportResult" vertex="1">
<mxGeometry height="26" width="814" y="370" as="geometry" />
</mxCell>
<mxCell id="9wusJIeZhnCERMAPth61-1" parent="QYUe6zCbC_x82nWfNGpH-248" style="text;strokeColor=none;fillColor=none;align=left;verticalAlign=top;spacingLeft=4;spacingRight=4;overflow=hidden;rotatable=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;fontFamily=Verdana;" value="freezeOutput(String keyImage): void" vertex="1">
diff --git a/docs/monero-spec.pdf b/docs/monero-spec.pdf
index a5f2e8c..405587a 100644
Binary files a/docs/monero-spec.pdf and b/docs/monero-spec.pdf differ
diff --git a/src/main/cpp/monero_jni_bridge.cpp b/src/main/cpp/monero_jni_bridge.cpp
index 90e8346..d7d2262 100644
--- a/src/main/cpp/monero_jni_bridge.cpp
+++ b/src/main/cpp/monero_jni_bridge.cpp
@@ -1236,17 +1236,7 @@ JNIEXPORT jstring JNICALL Java_monero_wallet_MoneroWalletFull_exportKeyImagesJni
MTRACE("Java_monero_wallet_MoneroWalletFull_exportKeyImagesJni");
monero_wallet* wallet = get_handle<monero_wallet>(env, instance, JNI_WALLET_HANDLE);
try {
-
- // fetch key images
- vector<shared_ptr<monero_key_image>> key_images = wallet->export_key_images(all);
- MTRACE("Fetched " << key_images.size() << " key images");
-
- // wrap and serialize key images
- rapidjson::Document doc;
- doc.SetObject();
- doc.AddMember("keyImages", monero_utils::to_rapidjson_val(doc.GetAllocator(), key_images), doc.GetAllocator());
- string key_images_json = gen_utils::serialize(doc);
- return env->NewStringUTF(key_images_json.c_str());
+ return env->NewStringUTF(wallet->export_key_images(all)->serialize().c_str());
} catch (...) {
rethrow_cpp_exception_as_java_exception(env);
return 0;
@@ -1260,14 +1250,10 @@ JNIEXPORT jstring JNICALL Java_monero_wallet_MoneroWalletFull_importKeyImagesJni
string key_images_json = string(_key_images_json ? _key_images_json : "");
env->ReleaseStringUTFChars(jkey_images_json, _key_images_json);
- // deserialize key images to import
- vector<shared_ptr<monero_key_image>> key_images = monero_key_image::deserialize_key_images(key_images_json);
- //MTRACE("Deserialized " << key_images.size() << " key images from java json");
-
- // import key images
- shared_ptr<monero_key_image_import_result> result;
+ // import key images at their offset
try {
- result = wallet->import_key_images(key_images);
+ shared_ptr<monero_key_image_export_result> exported = monero_key_image_export_result::deserialize(key_images_json);
+ shared_ptr<monero_key_image_import_result> result = wallet->import_key_images(exported->m_key_images, exported->m_offset == boost::none ? 0 : exported->m_offset.get());
return env->NewStringUTF(result->serialize().c_str());
} catch (...) {
rethrow_cpp_exception_as_java_exception(env);
diff --git a/src/main/java/monero/wallet/MoneroWallet.java b/src/main/java/monero/wallet/MoneroWallet.java
index e7a46c8..70da958 100644
--- a/src/main/java/monero/wallet/MoneroWallet.java
+++ b/src/main/java/monero/wallet/MoneroWallet.java
@@ -38,6 +38,7 @@ import monero.wallet.model.MoneroCheckReserve;
import monero.wallet.model.MoneroCheckTx;
import monero.wallet.model.MoneroIncomingTransfer;
import monero.wallet.model.MoneroIntegratedAddress;
+import monero.wallet.model.MoneroKeyImageExportResult;
import monero.wallet.model.MoneroKeyImageImportResult;
import monero.wallet.model.MoneroMessageSignatureResult;
import monero.wallet.model.MoneroMessageSignatureType;
@@ -778,17 +779,17 @@ public interface MoneroWallet {
/**
* Export key images since the last export.
*
- * @return signed key images since the last export
+ * @return signed key images since the last export and their offset among the wallet's outputs
*/
- List<MoneroKeyImage> exportKeyImages();
+ MoneroKeyImageExportResult exportKeyImages();
/**
* Export signed key images.
*
* @param all exports all key images if true, else exports the key images since the last export
- * @return signed key images
+ * @return signed key images and their offset among the wallet's outputs
*/
- public List<MoneroKeyImage> exportKeyImages(boolean all);
+ public MoneroKeyImageExportResult exportKeyImages(boolean all);
/**
* Import signed key images and verify their spent status.
@@ -798,6 +799,15 @@ public interface MoneroWallet {
*/
public MoneroKeyImageImportResult importKeyImages(List<MoneroKeyImage> keyImages);
+ /**
+ * Import signed key images and verify their spent status.
+ *
+ * @param keyImages are key images to import and verify (requires hex and signature)
+ * @param offset offset of the first key image among the wallet's outputs
+ * @return results of the import
+ */
+ public MoneroKeyImageImportResult importKeyImages(List<MoneroKeyImage> keyImages, long offset);
+
/**
* Get new key images from the last imported outputs.
*
diff --git a/src/main/java/monero/wallet/MoneroWalletDefault.java b/src/main/java/monero/wallet/MoneroWalletDefault.java
index 4ec0496..6a30c6e 100644
--- a/src/main/java/monero/wallet/MoneroWalletDefault.java
+++ b/src/main/java/monero/wallet/MoneroWalletDefault.java
@@ -41,6 +41,8 @@ import monero.wallet.model.MoneroAccount;
import monero.wallet.model.MoneroAddressBookEntry;
import monero.wallet.model.MoneroIncomingTransfer;
import monero.wallet.model.MoneroIntegratedAddress;
+import monero.wallet.model.MoneroKeyImageExportResult;
+import monero.wallet.model.MoneroKeyImageImportResult;
import monero.wallet.model.MoneroMessageSignatureType;
import monero.wallet.model.MoneroOutgoingTransfer;
import monero.wallet.model.MoneroOutputWallet;
@@ -365,10 +367,15 @@ abstract class MoneroWalletDefault implements MoneroWallet {
}
@Override
- public List<MoneroKeyImage> exportKeyImages() {
+ public MoneroKeyImageExportResult exportKeyImages() {
return exportKeyImages(false);
}
+ @Override
+ public MoneroKeyImageImportResult importKeyImages(List<MoneroKeyImage> keyImages) {
+ return importKeyImages(keyImages, 0);
+ }
+
@Override
public MoneroTxWallet createTx(MoneroTxConfig config) {
if (config == null) throw new MoneroError("Send request cannot be null");
diff --git a/src/main/java/monero/wallet/MoneroWalletFull.java b/src/main/java/monero/wallet/MoneroWalletFull.java
index 3c28de5..fdc50a4 100644
--- a/src/main/java/monero/wallet/MoneroWalletFull.java
+++ b/src/main/java/monero/wallet/MoneroWalletFull.java
@@ -50,6 +50,7 @@ import monero.wallet.model.MoneroCheckReserve;
import monero.wallet.model.MoneroCheckTx;
import monero.wallet.model.MoneroIncomingTransfer;
import monero.wallet.model.MoneroIntegratedAddress;
+import monero.wallet.model.MoneroKeyImageExportResult;
import monero.wallet.model.MoneroKeyImageImportResult;
import monero.wallet.model.MoneroMessageSignatureResult;
import monero.wallet.model.MoneroMessageSignatureType;
@@ -876,19 +877,22 @@ public class MoneroWalletFull extends MoneroWalletDefault {
}
@Override
- public List<MoneroKeyImage> exportKeyImages(boolean all) {
+ public MoneroKeyImageExportResult exportKeyImages(boolean all) {
assertNotClosed();
- String keyImagesJson = exportKeyImagesJni(all);
- List<MoneroKeyImage> keyImages = JsonUtils.deserialize(MoneroRpcConnection.MAPPER, keyImagesJson, KeyImagesContainer.class).keyImages;
- return keyImages;
+ String resultJson = exportKeyImagesJni(all);
+ MoneroKeyImageExportResult result = JsonUtils.deserialize(MoneroRpcConnection.MAPPER, resultJson, MoneroKeyImageExportResult.class);
+ if (result.getKeyImages() == null) result.setKeyImages(new ArrayList<MoneroKeyImage>());
+ return result;
}
@Override
- public MoneroKeyImageImportResult importKeyImages(List<MoneroKeyImage> keyImages) {
+ public MoneroKeyImageImportResult importKeyImages(List<MoneroKeyImage> keyImages, long offset) {
assertNotClosed();
- // wrap and serialize key images in container for jni
- KeyImagesContainer keyImageContainer = new KeyImagesContainer(keyImages);
+ // wrap and serialize offset and key images for jni
+ MoneroKeyImageExportResult keyImageContainer = new MoneroKeyImageExportResult();
+ keyImageContainer.setOffset(offset);
+ keyImageContainer.setKeyImages(keyImages);
String importResultJson = importKeyImagesJni(JsonUtils.serialize(keyImageContainer));
// deserialize response
@@ -1794,12 +1798,6 @@ public class MoneroWalletFull extends MoneroWalletDefault {
public List<MoneroTxSet> txSets;
}
- private static class KeyImagesContainer {
- public List<MoneroKeyImage> keyImages;
- @SuppressWarnings("unused") public KeyImagesContainer() { } // necessary for serialization
- public KeyImagesContainer(List<MoneroKeyImage> keyImages) { this.keyImages = keyImages; };
- }
-
private static DeserializedBlocksContainer deserializeBlocks(String blocksJson) {
DeserializedBlocksContainer deserializedBlocksContainer = new DeserializedBlocksContainer();
deserializedBlocksContainer.blocks = new ArrayList<MoneroBlock>();
diff --git a/src/main/java/monero/wallet/MoneroWalletRpc.java b/src/main/java/monero/wallet/MoneroWalletRpc.java
index 607b5c9..f767c08 100644
--- a/src/main/java/monero/wallet/MoneroWalletRpc.java
+++ b/src/main/java/monero/wallet/MoneroWalletRpc.java
@@ -64,6 +64,7 @@ import monero.wallet.model.MoneroCheckTx;
import monero.wallet.model.MoneroDestination;
import monero.wallet.model.MoneroIncomingTransfer;
import monero.wallet.model.MoneroIntegratedAddress;
+import monero.wallet.model.MoneroKeyImageExportResult;
import monero.wallet.model.MoneroKeyImageImportResult;
import monero.wallet.model.MoneroMessageSignatureResult;
import monero.wallet.model.MoneroMessageSignatureType;
@@ -1109,14 +1110,14 @@ public class MoneroWalletRpc extends MoneroWalletDefault {
}
@Override
- public List<MoneroKeyImage> exportKeyImages(boolean all) {
+ public MoneroKeyImageExportResult exportKeyImages(boolean all) {
return rpcExportKeyImages(all);
}
@SuppressWarnings("unchecked")
@Override
- public MoneroKeyImageImportResult importKeyImages(List<MoneroKeyImage> keyImages) {
-
+ public MoneroKeyImageImportResult importKeyImages(List<MoneroKeyImage> keyImages, long offset) {
+
// convert key images to rpc parameter
List<Map<String, Object>> rpcKeyImages = new ArrayList<Map<String, Object>>();
for (MoneroKeyImage keyImage : keyImages) {
@@ -1125,10 +1126,11 @@ public class MoneroWalletRpc extends MoneroWalletDefault {
rpcKeyImage.put("signature", keyImage.getSignature());
rpcKeyImages.add(rpcKeyImage);
}
-
+
// send rpc request
Map<String, Object> params = new HashMap<String, Object>();
params.put("signed_key_images", rpcKeyImages);
+ params.put("offset", offset);
Map<String, Object> resp = rpc.sendJsonRequest("import_key_images", params);
Map<String, Object> result = (Map<String, Object>) resp.get("result");
@@ -1142,7 +1144,7 @@ public class MoneroWalletRpc extends MoneroWalletDefault {
@Override
public List<MoneroKeyImage> getNewKeyImagesFromLastImport() {
- return rpcExportKeyImages(false);
+ return rpcExportKeyImages(false).getKeyImages();
}
@Override
@@ -1989,20 +1991,23 @@ public class MoneroWalletRpc extends MoneroWalletDefault {
* Common method to get key images.
*
* @param all specifies to get all xor only new images from last import
- * @return {MoneroKeyImage[]} are the key images
+ * @return {MoneroKeyImageExportResult} the key images and their offset among the wallet's outputs
*/
@SuppressWarnings("unchecked")
- private List<MoneroKeyImage> rpcExportKeyImages(boolean all) {
+ private MoneroKeyImageExportResult rpcExportKeyImages(boolean all) {
Map<String, Object> params = new HashMap<String, Object>();
params.put("all", all);
Map<String, Object> resp = rpc.sendJsonRequest("export_key_images", params);
Map<String, Object> result = (Map<String, Object>) resp.get("result");
+ MoneroKeyImageExportResult exportResult = new MoneroKeyImageExportResult();
+ if (result.containsKey("offset")) exportResult.setOffset(((BigInteger) result.get("offset")).longValue());
List<MoneroKeyImage> images = new ArrayList<MoneroKeyImage>();
- if (!result.containsKey("signed_key_images")) return images;
+ exportResult.setKeyImages(images);
+ if (!result.containsKey("signed_key_images")) return exportResult;
for (Map<String, Object> rpcImage : (List<Map<String, Object>>) result.get("signed_key_images")) {
images.add(new MoneroKeyImage((String) rpcImage.get("key_image"), (String) rpcImage.get("signature")));
}
- return images;
+ return exportResult;
}
@SuppressWarnings("unchecked")
diff --git a/src/main/java/monero/wallet/model/MoneroKeyImageExportResult.java b/src/main/java/monero/wallet/model/MoneroKeyImageExportResult.java
new file mode 100644
index 0000000..6a5f9b6
--- /dev/null
+++ b/src/main/java/monero/wallet/model/MoneroKeyImageExportResult.java
@@ -0,0 +1,30 @@
+package monero.wallet.model;
+
+import java.util.List;
+
+import monero.daemon.model.MoneroKeyImage;
+
+/**
+ * Models results from exporting signed key images.
+ */
+public class MoneroKeyImageExportResult {
+
+ private Long offset;
+ private List<MoneroKeyImage> keyImages;
+
+ public Long getOffset() {
+ return offset;
+ }
+
+ public void setOffset(Long offset) {
+ this.offset = offset;
+ }
+
+ public List<MoneroKeyImage> getKeyImages() {
+ return keyImages;
+ }
+
+ public void setKeyImages(List<MoneroKeyImage> keyImages) {
+ this.keyImages = keyImages;
+ }
+}
diff --git a/src/test/java/TestMoneroWalletCommon.java b/src/test/java/TestMoneroWalletCommon.java
index 78fd1dd..4adc8e5 100644
--- a/src/test/java/TestMoneroWalletCommon.java
+++ b/src/test/java/TestMoneroWalletCommon.java
@@ -48,6 +48,7 @@ import monero.wallet.model.MoneroCheckTx;
import monero.wallet.model.MoneroDestination;
import monero.wallet.model.MoneroIncomingTransfer;
import monero.wallet.model.MoneroIntegratedAddress;
+import monero.wallet.model.MoneroKeyImageExportResult;
import monero.wallet.model.MoneroKeyImageImportResult;
import monero.wallet.model.MoneroMessageSignatureResult;
import monero.wallet.model.MoneroMessageSignatureType;
@@ -2408,17 +2409,19 @@ public abstract class TestMoneroWalletCommon {
@Test
public void testExportKeyImages() {
assumeTrue(TEST_NON_RELAYS);
- List<MoneroKeyImage> images = wallet.exportKeyImages(true);
+ MoneroKeyImageExportResult result = wallet.exportKeyImages(true);
+ assertEquals(0, (long) result.getOffset());
+ List<MoneroKeyImage> images = result.getKeyImages();
assertTrue(images.size() > 0, "No signed key images in wallet");
for (MoneroKeyImage image : images) {
assertTrue(image instanceof MoneroKeyImage);
assertTrue(image.getHex().length() > 0);
assertTrue(image.getSignature().length() > 0);
}
-
+
// wallet exports key images since last export by default
- images = wallet.exportKeyImages();
- List<MoneroKeyImage> imagesAll = wallet.exportKeyImages(true);
+ images = wallet.exportKeyImages().getKeyImages();
+ List<MoneroKeyImage> imagesAll = wallet.exportKeyImages(true).getKeyImages();
assert(imagesAll.size() > images.size());
}
@@ -2451,9 +2454,10 @@ public abstract class TestMoneroWalletCommon {
@Test
public void testImportKeyImages() {
assumeTrue(TEST_NON_RELAYS);
- List<MoneroKeyImage> images = wallet.exportKeyImages();
+ MoneroKeyImageExportResult exportResult = wallet.exportKeyImages();
+ List<MoneroKeyImage> images = exportResult.getKeyImages();
assertTrue(images.size() > 0, "Wallet does not have any key images; run send tests");
- MoneroKeyImageImportResult result = wallet.importKeyImages(images);
+ MoneroKeyImageImportResult result = wallet.importKeyImages(images, exportResult.getOffset());
assertTrue(result.getHeight() > 0);
// determine if non-zero spent and unspent amounts are expected
@@ -2544,11 +2548,11 @@ public abstract class TestMoneroWalletCommon {
assertTrue(numOutputsImported > 0, "No outputs imported");
// export key images from offline wallet
- List<MoneroKeyImage> keyImages = offlineWallet.exportKeyImages();
-
+ MoneroKeyImageExportResult keyImageResult = offlineWallet.exportKeyImages();
+
// import key images to view-only wallet
assertTrue(viewOnlyWallet.isConnectedToDaemon());
- viewOnlyWallet.importKeyImages(keyImages);
+ viewOnlyWallet.importKeyImages(keyImageResult.getKeyImages(), keyImageResult.getOffset());
assertEquals(wallet.getBalance(), viewOnlyWallet.getBalance());
// create unsigned tx using view-only wallet
diff --git a/src/test/java/TestMoneroWalletFull.java b/src/test/java/TestMoneroWalletFull.java
index 0a65cec..817f8a5 100644
--- a/src/test/java/TestMoneroWalletFull.java
+++ b/src/test/java/TestMoneroWalletFull.java
@@ -19,11 +19,11 @@ import java.util.concurrent.TimeUnit;
import monero.common.MoneroError;
import monero.common.MoneroRpcConnection;
import monero.common.MoneroUtils;
-import monero.daemon.model.MoneroKeyImage;
import monero.daemon.model.MoneroNetworkType;
import monero.wallet.MoneroWallet;
import monero.wallet.MoneroWalletFull;
import monero.wallet.MoneroWalletRpc;
+import monero.wallet.model.MoneroKeyImageExportResult;
import monero.wallet.model.MoneroMultisigInfo;
import monero.wallet.model.MoneroMultisigInitResult;
import monero.wallet.model.MoneroOutputQuery;
@@ -153,8 +153,8 @@ public class TestMoneroWalletFull extends TestMoneroWalletCommon {
List<MoneroTransfer> inTransfers1 = wallet.getTransfers(new MoneroTransferQuery().setIsIncoming(true));
// export and re-import key images
- List<MoneroKeyImage> keyImages = wallet.exportKeyImages();
- wallet.importKeyImages(keyImages);
+ MoneroKeyImageExportResult keyImageResult = wallet.exportKeyImages();
+ wallet.importKeyImages(keyImageResult.getKeyImages(), keyImageResult.getOffset());
// get incoming transfers after importing
List<MoneroTransfer> inTransfers2 = wallet.getTransfers(new MoneroTransferQuery().setIsIncoming(true));
Why this scored 20/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.