Skip to content

Commit 358a8bc

Browse files
feat(stored-key): add atomic file storage method using temporary files (#4756)
* feat(stored-key): add atomic file storage method using temporary files * test(stored-key): add tests for storing keys with temporary files * feat(stored-key): add storageFailed error case and handle in save method
1 parent 00b52e0 commit 358a8bc

10 files changed

Lines changed: 223 additions & 45 deletions

File tree

android/app/src/androidTest/java/com/trustwallet/core/app/utils/TestKeyStore.kt

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import wallet.core.jni.StoredKey
77
import wallet.core.jni.CoinType
88
import wallet.core.jni.StoredKeyEncryption
99
import java.io.File
10+
import java.util.UUID
1011

1112
class TestKeyStore {
1213

@@ -121,6 +122,30 @@ class TestKeyStore {
121122
}
122123

123124
@Test
125+
fun testStoreWithTemporaryFile() {
126+
val password = "password".toByteArray()
127+
val keyStore = StoredKey("Test Wallet", password)
128+
129+
// Use UUID-generated names in the system temp directory — no file is
130+
// created upfront, avoiding the TOCTOU race of create-then-delete.
131+
// Both paths share the same directory so rename(2) is atomic.
132+
val tmpDir = File(System.getProperty("java.io.tmpdir")!!)
133+
val destFile = File(tmpDir, UUID.randomUUID().toString() + ".json")
134+
val tempFile = File(tmpDir, UUID.randomUUID().toString() + ".json.tmp")
135+
136+
try {
137+
assertTrue(keyStore.storeWithTemporaryFile(destFile.absolutePath, tempFile.absolutePath))
138+
assertTrue(destFile.exists())
139+
assertFalse(tempFile.exists()) // rename consumed the temp file
140+
141+
val loaded = StoredKey.load(destFile.absolutePath)
142+
assertNotNull(loaded)
143+
} finally {
144+
destFile.delete()
145+
tempFile.delete()
146+
}
147+
}
148+
124149
fun testFixScryptWithEmptySalt() {
125150
val gMnemonic = "team engine square letter hero song dizzy scrub tornado fabric divert saddle"
126151
val password = "password".toByteArray()

include/TrustWalletCore/TWStoredKey.h

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,12 +285,34 @@ void TWStoredKeyRemoveAccountForCoinDerivationPath(struct TWStoredKey* _Nonnull
285285

286286
/// Saves the key to a file.
287287
///
288+
/// \note Prefer `TWStoredKeyStoreWithTemporaryFile` over this function. It writes to a
289+
/// temporary file first and then renames it atomically, which prevents data loss if the
290+
/// process is interrupted mid-write. This function writes directly to `path` and will
291+
/// truncate the existing file before writing, so an interrupted write leaves a corrupt file.
292+
///
288293
/// \param key Non-null pointer to a stored key
289294
/// \param path Non-null string filepath where the key will be saved
290295
/// \return true if the key was successfully stored in the given filepath file, false otherwise
291296
TW_EXPORT_METHOD
292297
bool TWStoredKeyStore(struct TWStoredKey* _Nonnull key, TWString* _Nonnull path);
293298

299+
/// Saves the key to a file atomically using a temporary file and rename.
300+
///
301+
/// Writes the key JSON to `temporaryPath` first, then renames it to `path` in a single
302+
/// atomic operation. The original file at `path` is never truncated until the new content
303+
/// is fully written and flushed, so a crash or I/O error mid-write leaves the original
304+
/// file intact. Prefer this over `TWStoredKeyStore` whenever the caller can supply a
305+
/// suitable temporary path (typically the same directory as `path` with a unique suffix
306+
/// to guarantee the rename stays on the same filesystem volume).
307+
///
308+
/// \param key Non-null pointer to a stored key
309+
/// \param path Non-null string filepath where the key will be saved
310+
/// \param temporaryPath Non-null string filepath used for the intermediate write; must be
311+
/// on the same filesystem volume as `path`
312+
/// \return true if the key was successfully stored in the given filepath file, false otherwise
313+
TW_EXPORT_METHOD
314+
bool TWStoredKeyStoreWithTemporaryFile(struct TWStoredKey* _Nonnull key, TWString* _Nonnull path, TWString* _Nonnull temporaryPath);
315+
294316
/// Decrypts the private key.
295317
///
296318
/// \param key Non-null pointer to a stored key

src/Keystore/StoredKey.cpp

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -506,9 +506,32 @@ nlohmann::json StoredKey::json() const {
506506

507507
// File operations
508508

509-
void StoredKey::store(const std::string& path) {
509+
void StoredKey::store(const std::string& path) const {
510+
const std::string jsonData = json().dump();
511+
510512
auto stream = std::ofstream(path);
511-
stream << json();
513+
if (!stream) {
514+
throw std::invalid_argument("Can't open file for writing: " + path);
515+
}
516+
stream << jsonData;
517+
stream.flush();
518+
if (!stream) {
519+
throw std::runtime_error("Failed to write key file: " + path);
520+
}
521+
}
522+
523+
void StoredKey::storeWithTemporaryFile(const std::string& path, const std::string& tempFilePath) const {
524+
try {
525+
store(tempFilePath);
526+
} catch (...) {
527+
std::remove(tempFilePath.c_str());
528+
throw;
529+
}
530+
531+
if (std::rename(tempFilePath.c_str(), path.c_str()) != 0) {
532+
std::remove(tempFilePath.c_str());
533+
throw std::runtime_error("Failed to rename key file: temp=" + tempFilePath + ", target=" + path);
534+
}
512535
}
513536

514537
StoredKey StoredKey::load(const std::string& path) {

src/Keystore/StoredKey.h

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,13 @@ class StoredKey {
151151
/// Stores the key into an encrypted file.
152152
///
153153
/// \param path file path to store in.
154-
void store(const std::string& path);
154+
void store(const std::string& path) const;
155+
156+
/// Stores the key into an encrypted file, using a temporary file to ensure atomicity of the operation.
157+
///
158+
/// \param path file path to store in.
159+
/// \param tempFilePath file path to use for temporary file during the store operation.
160+
void storeWithTemporaryFile(const std::string& path, const std::string& tempFilePath) const;
155161

156162
/// Initializes `StoredKey` with a JSON object.
157163
void loadJson(const nlohmann::json& json);

src/interface/TWStoredKey.cpp

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,17 @@ bool TWStoredKeyStore(struct TWStoredKey* _Nonnull key, TWString* _Nonnull path)
221221
}
222222
}
223223

224+
bool TWStoredKeyStoreWithTemporaryFile(struct TWStoredKey* _Nonnull key, TWString* _Nonnull path, TWString* _Nonnull temporaryPath) {
225+
try {
226+
const auto& pathString = *reinterpret_cast<const std::string*>(path);
227+
const auto& temporaryPathString = *reinterpret_cast<const std::string*>(temporaryPath);
228+
key->impl.storeWithTemporaryFile(pathString, temporaryPathString);
229+
return true;
230+
} catch (...) {
231+
return false;
232+
}
233+
}
234+
224235
TWData* _Nullable TWStoredKeyDecryptPrivateKey(struct TWStoredKey* _Nonnull key, TWData* _Nonnull password) {
225236
try {
226237
const auto passwordData = TW::data(TWDataBytes(password), TWDataSize(password));

swift/Sources/KeyStore.Error.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ extension KeyStore {
1313
case invalidJSON
1414
case invalidKey
1515
case invalidPassword
16+
case storageFailed
1617

1718
public var errorDescription: String? {
1819
switch self {
@@ -26,6 +27,8 @@ extension KeyStore {
2627
return NSLocalizedString("Invalid private key", comment: "Error message when trying to import an invalid private key")
2728
case .invalidPassword:
2829
return NSLocalizedString("Invalid password", comment: "Error message when trying to export a private key")
30+
case .storageFailed:
31+
return NSLocalizedString("Failed to save keystore file", comment: "Error message when the keystore file cannot be written to disk. Please check available disk space and directory permissions")
2932
}
3033
}
3134
}

swift/Sources/KeyStore.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -395,8 +395,16 @@ public final class KeyStore {
395395
return keyDirectory.appendingPathComponent(generateFileName(identifier: UUID().uuidString))
396396
}
397397

398+
private func generateTempFileURL(accountURL: URL) -> URL {
399+
return accountURL.deletingLastPathComponent()
400+
.appendingPathComponent(accountURL.lastPathComponent + "." + UUID().uuidString + ".tmp")
401+
}
402+
398403
private func save(wallet: Wallet) throws {
399-
_ = wallet.key.store(path: wallet.keyURL.path)
404+
let tempFilePath = generateTempFileURL(accountURL: wallet.keyURL).path
405+
guard wallet.key.storeWithTemporaryFile(path: wallet.keyURL.path, temporaryPath: tempFilePath) else {
406+
throw Error.storageFailed
407+
}
400408
}
401409

402410
/// Generates a unique file name for an address.

swift/Tests/Keystore/KeyStoreTests.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -574,6 +574,27 @@ class KeyStoreTests: XCTestCase {
574574
XCTAssertEqual(decryptedMnemonic, mnemonic)
575575
}
576576

577+
func testCreateWalletThrowsStorageFailedOnUnwritableDirectory() throws {
578+
let dir = try createTempDirURL()
579+
let keyStore = try KeyStore(keyDirectory: dir)
580+
581+
// Make the directory read-only so storeWithTemporaryFile cannot create any file in it.
582+
try fileManager.setAttributes([.posixPermissions: 0o444], ofItemAtPath: dir.path)
583+
defer {
584+
// Restore permissions so teardown can clean up the directory.
585+
try? fileManager.setAttributes([.posixPermissions: 0o755], ofItemAtPath: dir.path)
586+
}
587+
588+
do {
589+
_ = try keyStore.createWallet(name: "test", password: "password", coins: [.ethereum])
590+
XCTFail("Expected storageFailed error but no error was thrown")
591+
} catch KeyStore.Error.storageFailed {
592+
// expected
593+
} catch {
594+
XCTFail("Expected storageFailed but got: \(error)")
595+
}
596+
}
597+
577598
func createTempDirURL() throws -> URL {
578599
let dir = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("keystore")
579600
try? fileManager.removeItem(at: dir)

swift/Tests/Keystore/KeystoreKeyTests.swift

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,29 @@ class KeystoreKeyTests: XCTestCase {
130130
let kdfparams: KdfParams
131131
}
132132

133+
func testStoreWithTemporaryFile() {
134+
let key = StoredKey.importPrivateKey(
135+
privateKey: Data(hexString: "3a1076bf45ab87712ad64ccb3b10217737f7faacbf2872e88fdd9a537d8fe266")!,
136+
name: "name",
137+
password: Data("password".utf8),
138+
coin: .ethereum
139+
)!
140+
141+
let dir = URL(fileURLWithPath: NSTemporaryDirectory())
142+
let destURL = dir.appendingPathComponent(UUID().uuidString + ".json")
143+
let tempURL = dir.appendingPathComponent(UUID().uuidString + ".json.tmp")
144+
defer {
145+
try? FileManager.default.removeItem(at: destURL)
146+
try? FileManager.default.removeItem(at: tempURL)
147+
}
148+
149+
XCTAssertTrue(key.storeWithTemporaryFile(path: destURL.path, temporaryPath: tempURL.path))
150+
XCTAssertTrue(FileManager.default.fileExists(atPath: destURL.path))
151+
XCTAssertFalse(FileManager.default.fileExists(atPath: tempURL.path)) // consumed by rename
152+
153+
XCTAssertNotNil(StoredKey.load(path: destURL.path))
154+
}
155+
133156
func testEncryptionParameters() {
134157
let url = Bundle(for: type(of: self)).url(forResource: "key", withExtension: "json")!
135158
let key = StoredKey.load(path: url.path)!

tests/interface/TWStoredKeyTests.cpp

Lines changed: 77 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,25 @@ struct std::shared_ptr<TWStoredKey> createAStoredKey(TWCoinType coin, TWData* pa
3434
struct std::shared_ptr<TWStoredKey> createDefaultStoredKey(TWStoredKeyEncryption encryption = TWStoredKeyEncryptionAes128Ctr) {
3535
const auto passwordString = WRAPS(TWStringCreateWithUTF8Bytes("password"));
3636
const auto password = WRAPD(TWDataCreateWithBytes(reinterpret_cast<const uint8_t *>(TWStringUTF8Bytes(passwordString.get())), TWStringSize(passwordString.get())));
37-
37+
3838
return createAStoredKey(TWCoinTypeBitcoin, password.get(), encryption);
3939
}
4040

41+
/// Reads the entire contents of the file at `path` into a Data buffer.
42+
/// Throws std::invalid_argument if the file cannot be opened.
43+
/// Note: uses a byte-by-byte loop instead of ifs.read() to avoid static-analysis warnings.
44+
static Data readFileData(const string& path) {
45+
ifstream ifs(path);
46+
if (!ifs.is_open()) throw std::invalid_argument("Cannot open file: " + path);
47+
ifs.seekg(0, ifs.end);
48+
const auto length = ifs.tellg();
49+
ifs.seekg(0, ifs.beg);
50+
Data data(length);
51+
size_t idx = 0;
52+
while (!ifs.eof() && idx < static_cast<size_t>(length)) { char c = ifs.get(); data[idx++] = static_cast<uint8_t>(c); }
53+
return data;
54+
}
55+
4156
TEST(TWStoredKey, loadPBKDF2Key) {
4257
const auto filename = WRAPS(TWStringCreateWithUTF8Bytes((TESTS_ROOT + "/common/Keystore/Data/pbkdf2.json").c_str()));
4358
const auto key = WRAP(TWStoredKey, TWStoredKeyLoad(filename.get()));
@@ -439,19 +454,8 @@ TEST(TWStoredKey, storeAndImportJSONAES192Ctr) {
439454
const auto outFileNameStr = WRAPS(TWStringCreateWithUTF8Bytes(outFileName.c_str()));
440455
EXPECT_TRUE(TWStoredKeyStore(key.get(), outFileNameStr.get()));
441456

442-
// read contents of file
443-
ifstream ifs(outFileName);
444-
// get length of file:
445-
ifs.seekg (0, ifs.end);
446-
auto length = ifs.tellg();
447-
ifs.seekg (0, ifs.beg);
448-
EXPECT_TRUE(length > 20);
449-
450-
Data json(length);
451-
size_t idx = 0;
452-
// read the slow way, ifs.read gave some false warnings with codacy
453-
while (!ifs.eof() && idx < static_cast<std::size_t>(length)) { char c = ifs.get(); json[idx++] = (uint8_t)c; }
454-
457+
auto json = readFileData(outFileName);
458+
EXPECT_GT(json.size(), 20ul);
455459
const auto key2 = WRAP(TWStoredKey, TWStoredKeyImportJSON(WRAPD(TWDataCreateWithData(&json)).get()));
456460
const auto name2 = WRAPS(TWStoredKeyName(key2.get()));
457461
EXPECT_EQ(string(TWStringUTF8Bytes(name2.get())), "name");
@@ -463,19 +467,8 @@ TEST(TWStoredKey, storeAndImportJSONAES256Ctr) {
463467
const auto outFileNameStr = WRAPS(TWStringCreateWithUTF8Bytes(outFileName.c_str()));
464468
EXPECT_TRUE(TWStoredKeyStore(key.get(), outFileNameStr.get()));
465469

466-
// read contents of file
467-
ifstream ifs(outFileName);
468-
// get length of file:
469-
ifs.seekg (0, ifs.end);
470-
auto length = ifs.tellg();
471-
ifs.seekg (0, ifs.beg);
472-
EXPECT_TRUE(length > 20);
473-
474-
Data json(length);
475-
size_t idx = 0;
476-
// read the slow way, ifs.read gave some false warnings with codacy
477-
while (!ifs.eof() && idx < static_cast<std::size_t>(length)) { char c = ifs.get(); json[idx++] = (uint8_t)c; }
478-
470+
auto json = readFileData(outFileName);
471+
EXPECT_GT(json.size(), 20ul);
479472
const auto key2 = WRAP(TWStoredKey, TWStoredKeyImportJSON(WRAPD(TWDataCreateWithData(&json)).get()));
480473
const auto name2 = WRAPS(TWStoredKeyName(key2.get()));
481474
EXPECT_EQ(string(TWStringUTF8Bytes(name2.get())), "name");
@@ -486,21 +479,9 @@ TEST(TWStoredKey, storeAndImportJSON) {
486479
const auto outFileName = string(getTestTempDir() + "/TWStoredKey_store.json");
487480
const auto outFileNameStr = WRAPS(TWStringCreateWithUTF8Bytes(outFileName.c_str()));
488481
EXPECT_TRUE(TWStoredKeyStore(key.get(), outFileNameStr.get()));
489-
//EXPECT_TRUE(filesystem::exists(outFileName)); // some linker issues with filesystem
490-
491-
// read contents of file
492-
ifstream ifs(outFileName);
493-
// get length of file:
494-
ifs.seekg (0, ifs.end);
495-
auto length = ifs.tellg();
496-
ifs.seekg (0, ifs.beg);
497-
EXPECT_TRUE(length > 20);
498-
499-
Data json(length);
500-
size_t idx = 0;
501-
// read the slow way, ifs.read gave some false warnings with codacy
502-
while (!ifs.eof() && idx < static_cast<std::size_t>(length)) { char c = ifs.get(); json[idx++] = (uint8_t)c; }
503482

483+
auto json = readFileData(outFileName);
484+
EXPECT_GT(json.size(), 20ul);
504485
const auto key2 = WRAP(TWStoredKey, TWStoredKeyImportJSON(WRAPD(TWDataCreateWithData(&json)).get()));
505486
const auto name2 = WRAPS(TWStoredKeyName(key2.get()));
506487
EXPECT_EQ(string(TWStringUTF8Bytes(name2.get())), "name");
@@ -678,6 +659,61 @@ TEST(TWStoredKey, encryptionParameters) {
678659
)");
679660
}
680661

662+
TEST(TWStoredKey, storeInvalidPath) {
663+
const auto key = createDefaultStoredKey();
664+
const auto invalidPath = WRAPS(TWStringCreateWithUTF8Bytes("/non-existing/file/path.json"));
665+
EXPECT_FALSE(TWStoredKeyStore(key.get(), invalidPath.get()));
666+
}
667+
668+
TEST(TWStoredKey, storeWithTemporaryFileSuccess) {
669+
const auto key = createDefaultStoredKey();
670+
const auto outFileName = string(getTestTempDir() + "/TWStoredKey_storeWithTmp.json");
671+
const auto tmpFileName = string(getTestTempDir() + "/TWStoredKey_storeWithTmp.json.tmp");
672+
const auto outFileNameStr = WRAPS(TWStringCreateWithUTF8Bytes(outFileName.c_str()));
673+
const auto tmpFileNameStr = WRAPS(TWStringCreateWithUTF8Bytes(tmpFileName.c_str()));
674+
675+
EXPECT_TRUE(TWStoredKeyStoreWithTemporaryFile(key.get(), outFileNameStr.get(), tmpFileNameStr.get()));
676+
677+
// The temp file must have been renamed away — only the final file should exist.
678+
EXPECT_FALSE(ifstream(tmpFileName).is_open());
679+
680+
// The final file must be readable and parse as a valid StoredKey.
681+
auto json = readFileData(outFileName);
682+
EXPECT_GT(json.size(), 20ul);
683+
const auto reloaded = WRAP(TWStoredKey, TWStoredKeyImportJSON(WRAPD(TWDataCreateWithData(&json)).get()));
684+
ASSERT_NE(reloaded.get(), nullptr);
685+
const auto name = WRAPS(TWStoredKeyName(reloaded.get()));
686+
EXPECT_EQ(string(TWStringUTF8Bytes(name.get())), "name");
687+
}
688+
689+
TEST(TWStoredKey, storeWithTemporaryFileInvalidPath) {
690+
const auto key = createDefaultStoredKey();
691+
const auto invalidPath = WRAPS(TWStringCreateWithUTF8Bytes("/non-existing/file/path.json"));
692+
const auto tmpFileName = string(getTestTempDir() + "/TWStoredKey_storeWithTmpInvalidPath.json.tmp");
693+
const auto tmpFileNameStr = WRAPS(TWStringCreateWithUTF8Bytes(tmpFileName.c_str()));
694+
695+
EXPECT_FALSE(TWStoredKeyStoreWithTemporaryFile(key.get(), invalidPath.get(), tmpFileNameStr.get()));
696+
}
697+
698+
TEST(TWStoredKey, storeWithTemporaryFileInvalidTempPath) {
699+
const auto key = createDefaultStoredKey();
700+
const auto outFileName = string(getTestTempDir() + "/TWStoredKey_storeWithTmpInvalidTmp.json");
701+
const auto outFileNameStr = WRAPS(TWStringCreateWithUTF8Bytes(outFileName.c_str()));
702+
const auto invalidTmpPath = WRAPS(TWStringCreateWithUTF8Bytes("/non-existing/file/path.tmp"));
703+
704+
// Write known sentinel content to the destination file so we can verify it is untouched.
705+
const string sentinel = "{\"sentinel\":true}";
706+
{ ofstream f(outFileName); f << sentinel; }
707+
708+
EXPECT_FALSE(TWStoredKeyStoreWithTemporaryFile(key.get(), outFileNameStr.get(), invalidTmpPath.get()));
709+
710+
// The destination file must be unchanged.
711+
ifstream ifs(outFileName);
712+
ASSERT_TRUE(ifs.is_open());
713+
const string actual((istreambuf_iterator<char>(ifs)), istreambuf_iterator<char>());
714+
EXPECT_EQ(actual, sentinel);
715+
}
716+
681717
TEST(TWStoredKey, fixScryptWithEmptySaltAes256Ctr) {
682718
const auto filename = WRAPS(TWStringCreateWithUTF8Bytes((TESTS_ROOT + "/common/Keystore/Data/scrypt-empty-salt-aes-256-ctr.json").c_str()));
683719
const auto key = WRAP(TWStoredKey, TWStoredKeyLoad(filename.get()));

0 commit comments

Comments
 (0)