Skip to content

Commit 8f154f4

Browse files
Merge pull request #1861 from walt-id/migrationcli
Migration CLI
2 parents 52b8a22 + 0aeaaf3 commit 8f154f4

5 files changed

Lines changed: 910 additions & 22 deletions

File tree

waltid-services/waltid-wallet-migration/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,12 @@ The JDBC driver is automatically inferred from the URL. Both source and target c
115115
- Target database will be initialized automatically (tables created if missing)
116116
- For PostgreSQL, ensure the database exists before running
117117

118+
## Limitations
119+
120+
The tool currently only works with W3C Verifiable Credentials. Support for IETF SD-JWTs and ISO mDocs is untested.
121+
122+
Feel free to test and contribute the missing functionality, or open an issue to request support.
123+
118124
## Related Libraries
119125

120126
- **[waltid-openid4vc-wallet-persistence](../../waltid-libraries/protocols/waltid-openid4vc-wallet-persistence)** — Target schema and store implementations

waltid-services/waltid-wallet-migration/build.gradle.kts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,15 @@ dependencies {
2626
// JDBC drivers — source and target databases
2727
implementation("org.xerial:sqlite-jdbc:3.47.0.0")
2828
runtimeOnly("org.postgresql:postgresql:42.7.3")
29+
30+
// Tests
31+
testImplementation(kotlin("test"))
32+
testImplementation(identityLibs.junit.jupiter.api)
33+
testRuntimeOnly(identityLibs.junit.jupiter.engine)
34+
testRuntimeOnly(identityLibs.junit.platform.launcher)
35+
testImplementation(identityLibs.kotlinx.coroutines.test)
36+
}
37+
38+
tasks.test {
39+
useJUnitPlatform()
2940
}

waltid-services/waltid-wallet-migration/src/main/kotlin/id/walt/wallet/migration/WalletMigration.kt

Lines changed: 100 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -77,14 +77,32 @@ fun main(args: Array<String>) {
7777
var errorCount = 0
7878

7979
// ── Wallets ────────────────────────────────────────────────────────────────
80-
81-
val walletIds = sourceConn.query("SELECT id FROM wallets") { rs ->
82-
rs.getString("id")
80+
//
81+
// IMPORTANT: waltid-wallet-api stores wallet and account UUIDs as 16-byte binary
82+
// blobs (not text strings). rs.getString() on a blob returns an unreliable binary
83+
// string that does NOT match when used as a setString() parameter in child queries.
84+
// We must read blobs with getBytes() and convert to proper UUID strings.
85+
//
86+
// Child queries (keys / credentials / DIDs) use a queryWithBytes() helper that
87+
// passes the raw 16-byte blob as setBytes() — SQLite matches blobs by bytes, not
88+
// by text representation.
89+
90+
val walletEntries = sourceConn.query("SELECT id FROM wallets") { rs ->
91+
val bytes = rs.getBytes("id")
92+
if (bytes != null && bytes.size == 16) {
93+
// waltid-wallet-api stores UUIDs as 16-byte binary blobs (big-endian)
94+
val uuid = bytesToUuid(bytes)
95+
uuid to WalletIdParam.Blob(bytes)
96+
} else {
97+
// PostgreSQL / text-based SQLite — ID is already a string
98+
val text = rs.getString("id") ?: ""
99+
text to WalletIdParam.Text(text)
100+
}
83101
}
84102

85-
log.info { "Found ${walletIds.size} wallets to migrate" }
103+
log.info { "Found ${walletEntries.size} wallets to migrate" }
86104

87-
for (walletUuid in walletIds) {
105+
for ((walletUuid, walletParam) in walletEntries) {
88106
val keyStoreId = "wallet-$walletUuid-keys"
89107
val credStoreId = "wallet-$walletUuid-creds"
90108
val didStoreId = "wallet-$walletUuid-dids"
@@ -115,8 +133,8 @@ fun main(args: Array<String>) {
115133

116134
// ── Keys ──────────────────────────────────────────────────────────────
117135

118-
val keys = sourceConn.queryWith(
119-
"SELECT kid, document FROM wallet_keys WHERE wallet = ?", walletUuid
136+
val keys = sourceConn.queryWithWalletParam(
137+
"SELECT kid, document FROM wallet_keys WHERE wallet = ?", walletParam
120138
) { rs -> rs.getString("kid") to rs.getString("document") }
121139

122140
for ((keyId, serializedKey) in keys) {
@@ -142,8 +160,8 @@ fun main(args: Array<String>) {
142160
// ── Credentials ───────────────────────────────────────────────────────
143161

144162
data class OldCred(val id: String, val document: String, val disclosures: String?)
145-
val credentials = sourceConn.queryWith(
146-
"SELECT id, document, disclosures FROM credentials WHERE wallet = ?", walletUuid
163+
val credentials = sourceConn.queryWithWalletParam(
164+
"SELECT id, document, disclosures FROM credentials WHERE wallet = ?", walletParam
147165
) { rs -> OldCred(rs.getString("id"), rs.getString("document"), rs.getString("disclosures")) }
148166

149167
for (cred in credentials) {
@@ -171,19 +189,32 @@ fun main(args: Array<String>) {
171189

172190
// ── DIDs ──────────────────────────────────────────────────────────────
173191

174-
val dids = sourceConn.queryWith(
175-
"SELECT did, document FROM wallet_dids WHERE wallet = ?", walletUuid
192+
val dids = sourceConn.queryWithWalletParam(
193+
"SELECT did, document FROM wallet_dids WHERE wallet = ?", walletParam
176194
) { rs -> rs.getString("did") to rs.getString("document") }
177195

178196
for ((did, documentStr) in dids) {
179197
runCatching {
180-
Json.parseToJsonElement(documentStr).jsonObject // validate JSON
198+
// The document column normally holds the full DID document JSON.
199+
// However some deployments (e.g. NDID) store only the DID URI string
200+
// for DID types where only the identifier matters, not the document.
201+
// In that case we synthesise a minimal {"id":"<did>"} document so the
202+
// DID is preserved in v2 rather than silently dropped.
203+
val documentJson = runCatching {
204+
Json.parseToJsonElement(documentStr).jsonObject
205+
}.getOrElse {
206+
log.warn {
207+
"DID $did has non-JSON document ('$documentStr') — " +
208+
"storing minimal {\"id\":\"$did\"} placeholder document."
209+
}
210+
Json.parseToJsonElement("""{"id":"$did"}""").jsonObject
211+
}
181212
if (!dryRun) {
182213
suspendTransaction(targetDb) {
183214
Wallet2Tables.Dids.upsert {
184215
it[Wallet2Tables.Dids.storeId] = didStoreId
185216
it[Wallet2Tables.Dids.did] = did
186-
it[Wallet2Tables.Dids.document] = documentStr
217+
it[Wallet2Tables.Dids.document] = documentJson.toString()
187218
}
188219
}
189220
}
@@ -199,9 +230,10 @@ fun main(args: Array<String>) {
199230

200231
// Old: account_wallet_mapping(tenant, id UUID, wallet UUID)
201232
// New: wallet2_account_wallets(account_id TEXT, wallet_id TEXT)
233+
// IDs may be 16-byte binary blobs (waltid-wallet-api SQLite) or plain text (PostgreSQL).
202234
val accountMappings = sourceConn.query(
203-
"SELECT CAST(id AS TEXT) AS account_id, CAST(wallet AS TEXT) AS wallet_id FROM account_wallet_mapping"
204-
) { rs -> rs.getString("account_id") to rs.getString("wallet_id") }
235+
"SELECT id, wallet FROM account_wallet_mapping"
236+
) { rs -> readWalletId(rs, "id") to readWalletId(rs, "wallet") }
205237

206238
for ((accountId, walletId) in accountMappings) {
207239
runCatching {
@@ -235,7 +267,27 @@ fun main(args: Array<String>) {
235267
}
236268
}
237269

238-
// ── JDBC helpers ───────────────────────────────────────────────────────────────
270+
/**
271+
* Represents a wallet/account UUID parameter for use in JDBC child queries.
272+
*
273+
* waltid-wallet-api (SQLite) stores UUIDs as 16-byte binary blobs → [Blob].
274+
* PostgreSQL and non-blob SQLite store them as text strings → [Text].
275+
*/
276+
private sealed class WalletIdParam {
277+
data class Blob(val bytes: ByteArray) : WalletIdParam()
278+
data class Text(val value: String) : WalletIdParam()
279+
}
280+
281+
/**
282+
* Read a UUID column from [rs] by name, detecting whether it is stored as a 16-byte
283+
* binary blob (waltid-wallet-api SQLite) or as a plain text/UUID string (PostgreSQL).
284+
* Returns the UUID as a hyphenated string in both cases.
285+
*/
286+
private fun readWalletId(rs: ResultSet, columnName: String): String {
287+
val bytes = rs.getBytes(columnName)
288+
return if (bytes != null && bytes.size == 16) bytesToUuid(bytes)
289+
else rs.getString(columnName) ?: ""
290+
}
239291

240292
/** Execute a SELECT with no parameters and map each row. */
241293
private fun <T> java.sql.Connection.query(sql: String, mapper: (ResultSet) -> T): List<T> =
@@ -247,28 +299,54 @@ private fun <T> java.sql.Connection.query(sql: String, mapper: (ResultSet) -> T)
247299
}
248300
}
249301

250-
/** Execute a SELECT with one String parameter (wallet UUID as TEXT) and map each row. */
251-
private fun <T> java.sql.Connection.queryWith(sql: String, param: String, mapper: (ResultSet) -> T): List<T> =
302+
/**
303+
* Execute a SELECT with one wallet-UUID parameter, handling both blob (waltid-wallet-api
304+
* SQLite) and text (PostgreSQL / plain SQLite) storage formats automatically.
305+
*/
306+
private fun <T> java.sql.Connection.queryWithWalletParam(
307+
sql: String,
308+
param: WalletIdParam,
309+
mapper: (ResultSet) -> T,
310+
): List<T> =
252311
prepareStatement(sql).use { stmt ->
253-
stmt.setString(1, param)
312+
when (param) {
313+
is WalletIdParam.Blob -> stmt.setBytes(1, param.bytes)
314+
is WalletIdParam.Text -> stmt.setString(1, param.value)
315+
}
254316
stmt.executeQuery().use { rs ->
255317
val out = mutableListOf<T>()
256318
while (rs.next()) out += mapper(rs)
257319
out
258320
}
259321
}
260322

323+
/**
324+
* Convert a 16-byte binary UUID blob to a standard hyphenated UUID string.
325+
* The old wallet stores UUIDs as 16-byte binary (most-significant bytes first).
326+
*/
327+
private fun bytesToUuid(bytes: ByteArray?): String {
328+
if (bytes == null || bytes.size != 16) return bytes?.toString(Charsets.ISO_8859_1) ?: ""
329+
val bb = java.nio.ByteBuffer.wrap(bytes)
330+
return java.util.UUID(bb.long, bb.long).toString()
331+
}
332+
261333
/** Infer the wallet2 key type string from a KeySerialization JSON document. */
262-
private fun inferKeyType(serializedKey: String): String = runCatching {
334+
internal fun inferKeyType(serializedKey: String): String = runCatching {
263335
val json = Json.parseToJsonElement(serializedKey).jsonObject
264-
val crv = json["jwk"]?.jsonObject?.get("crv")?.toString()?.trim('"')
336+
val jwk = json["jwk"]?.jsonObject
337+
val crv = jwk?.get("crv")?.toString()?.trim('"')
338+
val kty = jwk?.get("kty")?.toString()?.trim('"')
265339
when (crv) {
266340
"P-256" -> "secp256r1"
267341
"P-384" -> "secp384r1"
268342
"P-521" -> "secp521r1"
269343
"Ed25519" -> "Ed25519"
270344
"secp256k1" -> "secp256k1"
271-
else -> json["type"]?.toString()?.trim('"') ?: "unknown"
345+
else -> when (kty) {
346+
"RSA" -> "RSA"
347+
"OKP" -> "Ed25519" // Ed25519/X25519 without explicit crv — treat as Ed25519
348+
else -> kty ?: "unknown"
349+
}
272350
}
273351
}.getOrDefault("unknown")
274352

0 commit comments

Comments
 (0)