-
- {@html keychipCode}
+ {#if selectedKeychip}
+ {#if !!window.showOpenFilePicker}
+
+ {t('setup.type.automatic')}
+ {@html t('setup.automatic')}
+ {#if automaticSetupStatus != "none"}
+
+ {t(`setup.automatic.${automaticSetupStatus}`)}
+
+ {/if}
+
+
+
+
+ {/if}
+
+
+ {t('setup.type.manual')}
+ {@html t('setup.manual')}
+
+
+ {@html keychipCode}
+
+ {#if !exposeKeychip}
+
+ {/if}
- {#if !exposeKeychip}
-
- {/if}
-
-
-
+
+
+ {/if}
2.
{@html t('setup.steps.two')}
@@ -100,8 +208,6 @@ id=${keychip}`.trim(), {
{@html t('setup.support-info')}
- {:else}
-
{t('loading')}
{/if}
@@ -111,6 +217,18 @@ id=${keychip}`.trim(), {
.code
overflow-x: auto
+ .divider
+ width: 90%
+ height: 1px
+
+ background: #fff3
+
+ margin: 1em 0
+
+ position: relative
+ left: 50%
+ transform: translate(-50%, 0)
+
:global(pre.shiki)
background-color: transparent !important
@@ -167,5 +285,48 @@ id=${keychip}`.trim(), {
top: 50%
left: 50%
transform: translate(-50%, -50%)
-
+
+ .keychip-list
+ display: flex
+ flex-direction: column
+ gap: 0.5em
+ margin: 1em 0
+
+ .keychip-item
+ display: flex
+ align-items: center
+ gap: 0.5em
+ padding: 0.25em 0.5em
+ border-radius: 4px
+ &.selected
+ background: vars.$c-shadow
+
+ .keychip-select
+ font-family: monospace
+ flex: 1
+ text-align: left
+
+ .add-keychip
+ align-self: flex-start
+ margin-top: 0.25em
+
+ .add-keychip-form
+ display: flex
+ flex-wrap: wrap
+ gap: 0.5em
+ align-items: center
+ margin-top: 0.25em
+
+ input
+ flex: 1 1 16rem
+ max-width: 16rem
+
+ button
+ flex: 1 1 10rem
+ max-width: 8rem
+
+ .danger
+ color: vars.$c-error
+
+
diff --git a/config/application.properties b/config/application.properties
index 4b23d72e..2a55c13a 100644
--- a/config/application.properties
+++ b/config/application.properties
@@ -97,6 +97,7 @@ spring.datasource.hikari.maximum-pool-size=10
## Link card limit
aqua-net.link-card-limit=10
+aqua-net.keychip-limit=20
## CloudFlare Turnstile Captcha
## This enables captcha for user registration.
diff --git a/docker-compose.yml b/docker-compose.yml
index 6ffda56f..36efbc07 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,5 +1,6 @@
services:
app:
+ build: .
image: hykilpikonna/aquadx:latest
ports:
- "80:80"
diff --git a/src/main/java/icu/samnyan/aqua/net/Bot.kt b/src/main/java/icu/samnyan/aqua/net/Bot.kt
index b6d76c06..1d86051b 100644
--- a/src/main/java/icu/samnyan/aqua/net/Bot.kt
+++ b/src/main/java/icu/samnyan/aqua/net/Bot.kt
@@ -116,7 +116,7 @@ class BotController(
val gamesDict = listOfNotNull(chu3, mai2).map {
// Find the keychip owner
val keychip = it.lastClientId
- val keychipOwner = keychip?.let { us.userRepo.findByKeychip(it) }
+ val keychipOwner = keychip?.let { k -> us.userKeychipRepo.findByKeychipId(k)?.user }
mapOf(
"userData" to it,
diff --git a/src/main/java/icu/samnyan/aqua/net/UserRegistrar.kt b/src/main/java/icu/samnyan/aqua/net/UserRegistrar.kt
index 99915057..57ecf165 100644
--- a/src/main/java/icu/samnyan/aqua/net/UserRegistrar.kt
+++ b/src/main/java/icu/samnyan/aqua/net/UserRegistrar.kt
@@ -2,12 +2,16 @@ package icu.samnyan.aqua.net
import ext.*
import icu.samnyan.aqua.net.components.*
+import icu.samnyan.aqua.net.db.AquaNetUser
import icu.samnyan.aqua.net.db.AquaNetUserRepo
import icu.samnyan.aqua.net.db.AquaUserServices
import icu.samnyan.aqua.net.db.EmailConfirmationRepo
import icu.samnyan.aqua.net.db.ResetPasswordRepo
+import icu.samnyan.aqua.net.utils.AquaNetProps
import icu.samnyan.aqua.net.utils.PathProps
import icu.samnyan.aqua.net.utils.SUCCESS
+import icu.samnyan.aqua.sega.allnet.UserKeychip
+import icu.samnyan.aqua.sega.allnet.UserKeychipRepo
import icu.samnyan.aqua.sega.general.dao.CardRepository
import icu.samnyan.aqua.sega.general.model.CardStatus
import jakarta.servlet.http.HttpServletRequest
@@ -33,6 +37,8 @@ class UserRegistrar(
val cardRepo: CardRepository,
val validator: AquaUserServices,
val emailProps: EmailProperties,
+ val userKeychipRepo: UserKeychipRepo,
+ val aquaNetProps: AquaNetProps,
final val paths: PathProps
) {
val portraitPath = paths.aquaNetPortrait.path()
@@ -235,20 +241,57 @@ class UserRegistrar(
SUCCESS
}
+ val keychipPattern = Regex("^A\\d{14}$")
+ val dashedKeychipPattern = Regex("^A\\d{3}-\\d{11}$")
val keychipRange = 1e9.toULong()..1e10.toULong() - 1UL
+
+ private fun ensureCanModifyKeychips(u: AquaNetUser) {
+ if (!u.canModifyKeychips) 403 - "You don't have permission to modify keychips"
+ }
+
+ private fun validateCustomKeychip(keychipId: Str): Str {
+ val raw = keychipId.trim().uppercase()
+ val normalized = raw.replace("-", "")
+
+ val validRawFormat = raw == normalized || dashedKeychipPattern.matches(raw)
+ if (!validRawFormat || !keychipPattern.matches(normalized))
+ 400 - "Invalid keychip format. Expected A followed by 14 digits (with optional dash)"
+
+ return normalized
+ }
+
@API("/keychip")
- @Doc("Get a Keychip ID so that the user can connect to the server.", "Success message")
- suspend fun setupConnection(@RP token: Str) = jwt.auth(token) { u ->
- u.keychip?.let { return mapOf("keychip" to it) }
- log.info("Net: /user/keychip setup: ${u.auId} for ${u.username}")
+ @Doc("List all keychip IDs associated with the current user's account.", "List of keychip IDs")
+ suspend fun listKeychips(@RP token: Str) = jwt.auth(token) { u ->
+ val keychips = async { userKeychipRepo.findAllByUserAuId(u.auId) }
+ mapOf("keychips" to keychips.map { it.keychipId })
+ }
- // Generate a keychip id with 10 digits (e.g. A1234567890)
- var new = "A" + keychipRange.random()
- while (async { userRepo.findByKeychip(new) != null }) new = "A" + keychipRange.random()
- async { userRepo.save(u.apply { keychip = new }) }
+ @API("/keychip/add")
+ @Doc("Add a custom keychip ID for the user.", "The newly added keychip ID")
+ suspend fun addKeychip(@RP token: Str, @RP keychipId: Str) = jwt.auth(token) { u ->
+ ensureCanModifyKeychips(u)
+ log.info("Net: /user/keychip/add: ${u.auId} for ${u.username}")
+ val validated = validateCustomKeychip(keychipId)
- mapOf("keychip" to new)
+ if (async { userKeychipRepo.existsByKeychipId(validated) })
+ 400 - "Keychip already exists"
+
+ if (userKeychipRepo.findAllByUserAuId(u.auId).size >= aquaNetProps.keychipLimit)
+ 400 - "Exceeds maximum keychip count"
+
+ async { userKeychipRepo.save(UserKeychip(user = u, keychipId = validated)) }
+ mapOf("keychipId" to validated)
+ }
+
+ @API("/keychip/delete")
+ @Doc("Remove a keychip ID from the user's account.", "Success message")
+ suspend fun deleteKeychip(@RP token: Str, @RP keychipId: Str) = jwt.auth(token) { u ->
+ ensureCanModifyKeychips(u)
+ val deleted = async { userKeychipRepo.deleteByKeychipIdAndUserAuId(keychipId, u.auId) }
+ if (deleted == 0L) 404 - "Keychip not found"
+ SUCCESS
}
@API("/upload-pfp", consumes = ["multipart/form-data"])
diff --git a/src/main/java/icu/samnyan/aqua/net/db/AquaNetUser.kt b/src/main/java/icu/samnyan/aqua/net/db/AquaNetUser.kt
index 34b2221b..811a9883 100644
--- a/src/main/java/icu/samnyan/aqua/net/db/AquaNetUser.kt
+++ b/src/main/java/icu/samnyan/aqua/net/db/AquaNetUser.kt
@@ -6,8 +6,9 @@ import icu.samnyan.aqua.net.UserRegistrar.Companion.cardExtIdEnd
import icu.samnyan.aqua.net.UserRegistrar.Companion.cardExtIdStart
import icu.samnyan.aqua.net.components.JWT
import icu.samnyan.aqua.sega.allnet.AllNetProps
-import icu.samnyan.aqua.sega.allnet.KeyChipRepo
import icu.samnyan.aqua.sega.allnet.KeychipSession
+import icu.samnyan.aqua.sega.allnet.UserKeychip
+import icu.samnyan.aqua.sega.allnet.UserKeychipRepo
import icu.samnyan.aqua.sega.general.GameMusicPopularity
import icu.samnyan.aqua.sega.general.dao.CardRepository
import icu.samnyan.aqua.sega.general.model.Card
@@ -74,16 +75,19 @@ class AquaNetUser(
@OneToMany(mappedBy = "aquaUser", cascade = [CascadeType.ALL])
var cards: MutableList
= mutableListOf(),
- // Each user can have one keychip (if the user owns a cabinet)
+ // Each user can have multiple keychips (if the user owns cabinets)
@JsonIgnore
- @Column(nullable = true, length = 32, unique = true)
- var keychip: Str? = null,
+ @OneToMany(mappedBy = "user", cascade = [CascadeType.ALL])
+ var keychips: MutableList = mutableListOf(),
// Each user's keychip can have multiple sessions
@JsonIgnore
@OneToMany(mappedBy = "user", cascade = [CascadeType.ALL])
var keychipSessions: MutableList = mutableListOf(),
+ @Column(nullable = false)
+ var canModifyKeychips: Boolean = false,
+
@OneToOne(cascade = [CascadeType.ALL])
@JoinColumn(name = "gameOptions", unique = true, nullable = true)
var gameOptions: AquaGameOptions? = null,
@@ -105,7 +109,6 @@ interface AquaNetUserRepo : JpaRepository {
fun findByAuId(auId: Long): AquaNetUser?
fun findByEmailIgnoreCase(email: String): AquaNetUser?
fun findByUsernameIgnoreCase(username: String): AquaNetUser?
- fun findByKeychip(keychip: String): AquaNetUser?
fun findByGhostCardExtId(extId: Long): AquaNetUser?
}
@@ -124,7 +127,7 @@ class AquaUserServices(
val userRepo: AquaNetUserRepo,
val cardRepo: CardRepository,
val hasher: PasswordEncoder,
- val keyChipRepo: KeyChipRepo,
+ val userKeychipRepo: UserKeychipRepo,
val allNetProps: AllNetProps,
val jwt: JWT,
val em: EntityManager,
@@ -142,9 +145,18 @@ class AquaUserServices(
}
}
+ val keychipRange = 1e9.toULong()..1e10.toULong() - 1UL
+ private fun generateKeychipId(): String {
+ // 1337 is retained for backwards compatibility, it's no longer required for cabinet keychips
+ var keychip = "A" + keychipRange.random() + "1337"
+ while ( userKeychipRepo.existsByKeychipId(keychip) )
+ keychip = "A" + keychipRange.random() + "1337"
+ return keychip
+ }
+
fun create(username: Str, email: Str, password: Str, country: Str, emailConfirmed: Boolean = false): AquaNetUser {
// Create user
- val u = AquaNetUser(
+ val user = AquaNetUser(
username = checkUsername(username),
email = validateEmail(email),
pwHash = checkPwHash(password),
@@ -158,16 +170,20 @@ class AquaUserServices(
luid = extId.toString()
registerTime = LocalDateTime.now()
accessTime = registerTime
- aquaUser = u
+ aquaUser = user
isGhost = true
}
- u.ghostCard = card
+ user.ghostCard = card
+
+ // Create an automatic keychip
+ val keychip = UserKeychip(0, user, generateKeychipId())
// Save the user
- userRepo.save(u)
+ userRepo.save(user)
cardRepo.save(card)
+ userKeychipRepo.save(keychip)
- return u
+ return user
}
fun update(user: AquaNetUser, key: Str, value: Str) {
@@ -192,7 +208,7 @@ class AquaUserServices(
fun validKeychip(keychipId: Str): Bool {
if (!allNetProps.checkKeychip) return true
if (keychipId.isBlank()) return false
- if (userRepo.findByKeychip(keychipId) != null || keyChipRepo.existsByKeychipId(keychipId)) return true
+ if (userKeychipRepo.existsByKeychipId(keychipId)) return true
return false
}
diff --git a/src/main/java/icu/samnyan/aqua/net/games/GameApiController.kt b/src/main/java/icu/samnyan/aqua/net/games/GameApiController.kt
index 100e7175..0b5d0f56 100644
--- a/src/main/java/icu/samnyan/aqua/net/games/GameApiController.kt
+++ b/src/main/java/icu/samnyan/aqua/net/games/GameApiController.kt
@@ -4,6 +4,7 @@ import ext.*
import icu.samnyan.aqua.net.BotProps
import icu.samnyan.aqua.net.db.AquaUserServices
import icu.samnyan.aqua.net.utils.SUCCESS
+import icu.samnyan.aqua.sega.allnet.UserKeychipRepo
import icu.samnyan.aqua.sega.general.model.Card
import icu.samnyan.aqua.sega.general.service.CardService
import jakarta.annotation.PostConstruct
@@ -32,6 +33,7 @@ abstract class GameApiController(val name: String, userDataClass:
open val gettableFields: Set = setOf()
@Autowired lateinit var cardService: CardService
+ @Autowired lateinit var userKeychipRepo: UserKeychipRepo
@API("trend")
abstract suspend fun trend(@RP username: String): List
@@ -205,7 +207,7 @@ abstract class GameApiController(val name: String, userDataClass:
lastVersion = user.lastRomVersion,
ratingComposition = ratingComp,
recent = plays.sortedBy { it.userPlayDate.toString() }.takeLast(100).reversed(),
- lastPlayedHost = user.lastClientId?.let { us.userRepo.findByKeychip(it)?.username },
+ lastPlayedHost = user.lastClientId?.let { userKeychipRepo.findByKeychipId(it)?.user?.username },
rival = rival,
favorites = favorites
)
diff --git a/src/main/java/icu/samnyan/aqua/net/utils/AquaNetProps.kt b/src/main/java/icu/samnyan/aqua/net/utils/AquaNetProps.kt
index 6843da21..3fe18bb4 100644
--- a/src/main/java/icu/samnyan/aqua/net/utils/AquaNetProps.kt
+++ b/src/main/java/icu/samnyan/aqua/net/utils/AquaNetProps.kt
@@ -10,6 +10,7 @@ import kotlin.io.path.createDirectories
@ConfigurationProperties(prefix = "aqua-net")
class AquaNetProps {
var linkCardLimit: Int = 10
+ var keychipLimit: Int = 20
var importBackupPath = "data/import-backups"
@PostConstruct
diff --git a/src/main/java/icu/samnyan/aqua/sega/allnet/AllNet.kt b/src/main/java/icu/samnyan/aqua/sega/allnet/AllNet.kt
index 2240772f..ef977362 100644
--- a/src/main/java/icu/samnyan/aqua/sega/allnet/AllNet.kt
+++ b/src/main/java/icu/samnyan/aqua/sega/allnet/AllNet.kt
@@ -1,7 +1,6 @@
package icu.samnyan.aqua.sega.allnet
import ext.*
-import icu.samnyan.aqua.net.db.AquaNetUserRepo
import icu.samnyan.aqua.sega.allnet.AllNetBillingDecoder.decodeAllNet
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
@@ -59,9 +58,8 @@ class AllNetProps {
@Suppress("HttpUrlsUsage")
@RestController
class AllNet(
- val userRepo: AquaNetUserRepo,
+ val userKeychipRepo: UserKeychipRepo,
val keychipSessionService: KeychipSessionService,
- val keychipRepo: KeyChipRepo,
val props: AllNetProps
) {
@API("/")
@@ -109,7 +107,7 @@ class AllNet(
// Proper keychip authentication
if (props.checkKeychip) {
// If it's a user keychip, it should be in user database
- val u = userRepo.findByKeychip(serial)
+ val u = findUserByIncomingSerial(serial)
if (u != null) {
// Create a new session for the user
logger.info("> Keychip authenticated: ${u.auId} ${u.computedName}")
@@ -120,11 +118,6 @@ class AllNet(
session = keychipSessionService.new(u, reqMap["game_id"] ?: "").token
}
- // Check if it's a whitelisted keychip
- else if (!serial.isEmpty() && keychipRepo.existsByKeychipId(serial)) {
- session = keychipSessionService.new(null, reqMap["game_id"] ?: "").token
- }
-
else if (props.keychipPermissiveForTesting) {
logger.warn("> Accepted invalid keychip $serial in permissive mode")
session = keychipSessionService.new(null, reqMap["game_id"] ?: "").token
@@ -175,6 +168,17 @@ class AllNet(
return resp.toUrl() + "\n"
}
+ private fun findUserByIncomingSerial(serial: String) = when (serial.length) {
+ FULL_KEYCHIP_LENGTH -> userKeychipRepo.findByKeychipId(serial)?.user
+ SHORT_KEYCHIP_LENGTH -> {
+ // segatools only sends the first 11 characters of the keychip
+ // First, try to find it by suffixing AquaDX's generated suffix, then fall back to matching without the suffixed 4 digits
+ userKeychipRepo.findByKeychipId(serial + KEYCHIP_SUFFIX)?.user
+ ?: userKeychipRepo.findByKeychipIdStartingWith(serial)?.user
+ }
+ else -> userKeychipRepo.findByKeychipId(serial)?.user
+ }
+
private fun switchUri(hereAddr: Str, localPort: Str, gameId: Str, ver: Str, session: Str?): Str {
val addr = hereAddr + (if (props.hidePort) "" else ":${props.port ?: localPort}")
@@ -198,6 +202,9 @@ class AllNet(
}
companion object {
+ const val SHORT_KEYCHIP_LENGTH = 11
+ const val FULL_KEYCHIP_LENGTH = 15
+ const val KEYCHIP_SUFFIX = "1337"
val logger = logger()
}
}
diff --git a/src/main/java/icu/samnyan/aqua/sega/allnet/AllNetSecure.kt b/src/main/java/icu/samnyan/aqua/sega/allnet/AllNetSecure.kt
index 21f420f1..f7d69848 100644
--- a/src/main/java/icu/samnyan/aqua/sega/allnet/AllNetSecure.kt
+++ b/src/main/java/icu/samnyan/aqua/sega/allnet/AllNetSecure.kt
@@ -25,7 +25,6 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
@Component
@ConditionalOnBean(AllNetSecureInit::class)
class TokenChecker(
- val keyChipRepo: KeyChipRepo,
val keychipSessionService: KeychipSessionService,
val frontierProps: FrontierProps,
val geoip: GeoIP
@@ -56,7 +55,7 @@ class TokenChecker(
// The token can either be a keychip id (old method) or a session id (new method)
// Or the frontier token
val session = keychipSessionService.find(token)
- if (token.isNotBlank() && (keyChipRepo.existsByKeychipId(token) || session != null
+ if (token.isNotBlank() && (session != null
|| (frontierProps.enabled && frontierProps.ftk == token)))
{
currentSession.set(session)
diff --git a/src/main/java/icu/samnyan/aqua/sega/allnet/Keychip.kt b/src/main/java/icu/samnyan/aqua/sega/allnet/Keychip.kt
deleted file mode 100644
index f88ff798..00000000
--- a/src/main/java/icu/samnyan/aqua/sega/allnet/Keychip.kt
+++ /dev/null
@@ -1,32 +0,0 @@
-package icu.samnyan.aqua.sega.allnet
-
-import jakarta.persistence.Column
-import jakarta.persistence.Entity
-import jakarta.persistence.Id
-import jakarta.persistence.Table
-import org.springframework.data.jpa.repository.JpaRepository
-import org.springframework.stereotype.Repository
-import java.io.Serializable
-
-/**
- * This is the old method of securing requests - a keychip whitelist,
- * it's kept here only for backwards compatibility.
- */
-@Entity
-@Table(name = "allnet_keychips")
-class Keychip(
- @Id
- val id: Long = 0,
-
- @Column(unique = true, nullable = false)
- val keychipId: String = "",
-) : Serializable {
- companion object {
- const val serialVersionUID = 1L
- }
-}
-
-@Repository("KeyChipRepository")
-interface KeyChipRepo : JpaRepository {
- fun existsByKeychipId(keychipId: String?): Boolean
-}
\ No newline at end of file
diff --git a/src/main/java/icu/samnyan/aqua/sega/allnet/UserKeychip.kt b/src/main/java/icu/samnyan/aqua/sega/allnet/UserKeychip.kt
new file mode 100644
index 00000000..49e70c80
--- /dev/null
+++ b/src/main/java/icu/samnyan/aqua/sega/allnet/UserKeychip.kt
@@ -0,0 +1,32 @@
+package icu.samnyan.aqua.sega.allnet
+
+import icu.samnyan.aqua.net.db.AquaNetUser
+import jakarta.persistence.*
+import jakarta.transaction.Transactional
+import org.springframework.data.jpa.repository.JpaRepository
+import org.springframework.stereotype.Repository
+
+@Entity
+@Table(name = "user_keychip")
+class UserKeychip(
+ @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
+ val id: Long = 0,
+
+ @ManyToOne
+ @JoinColumn(name = "au_id", nullable = false)
+ var user: AquaNetUser,
+
+ @Column(unique = true, nullable = false, length = 32)
+ val keychipId: String,
+)
+
+@Repository
+interface UserKeychipRepo : JpaRepository {
+ fun findByKeychipId(keychipId: String): UserKeychip?
+ fun findByKeychipIdStartingWith(keychipIdPrefix: String): UserKeychip?
+ fun existsByKeychipId(keychipId: String): Boolean
+ fun findAllByUserAuId(auId: Long): List
+
+ @Transactional
+ fun deleteByKeychipIdAndUserAuId(keychipId: String, auId: Long): Long
+}
diff --git a/src/main/resources/db/80/V1000_72__user_keychip.sql b/src/main/resources/db/80/V1000_72__user_keychip.sql
new file mode 100644
index 00000000..0819f874
--- /dev/null
+++ b/src/main/resources/db/80/V1000_72__user_keychip.sql
@@ -0,0 +1,27 @@
+-- Create the user_keychip join table
+CREATE TABLE user_keychip
+(
+ id BIGINT AUTO_INCREMENT NOT NULL,
+ au_id BIGINT NOT NULL,
+ keychip_id VARCHAR(32) NOT NULL,
+ CONSTRAINT pk_user_keychip PRIMARY KEY (id)
+);
+
+ALTER TABLE user_keychip
+ ADD CONSTRAINT uc_user_keychip_keychip_id UNIQUE (keychip_id);
+
+ALTER TABLE user_keychip
+ ADD CONSTRAINT fk_user_keychip_on_au FOREIGN KEY (au_id) REFERENCES aqua_net_user (au_id) ON DELETE CASCADE;
+
+-- Migrate existing keychip values from aqua_net_user into the new table
+INSERT INTO user_keychip (au_id, keychip_id)
+SELECT au_id, keychip
+FROM aqua_net_user
+WHERE keychip IS NOT NULL;
+
+-- Drop the old keychip column
+ALTER TABLE aqua_net_user
+ DROP CONSTRAINT uc_aqua_net_user_keychip;
+
+ALTER TABLE aqua_net_user
+ DROP COLUMN keychip;
diff --git a/src/main/resources/db/80/V1000_73__keychip_ids_to_15_digits.sql b/src/main/resources/db/80/V1000_73__keychip_ids_to_15_digits.sql
new file mode 100644
index 00000000..d8c2f97c
--- /dev/null
+++ b/src/main/resources/db/80/V1000_73__keychip_ids_to_15_digits.sql
@@ -0,0 +1,5 @@
+-- Normalize stored keychip IDs to 15 digits by appending our suffix.
+-- Existing IDs are historically 11 characters (A + 10 digits).
+UPDATE user_keychip
+SET keychip_id = CONCAT(keychip_id, '1337')
+WHERE CHAR_LENGTH(keychip_id) = 11;
diff --git a/src/main/resources/db/80/V1000_74__user_can_modify_keychips.sql b/src/main/resources/db/80/V1000_74__user_can_modify_keychips.sql
new file mode 100644
index 00000000..e9750f05
--- /dev/null
+++ b/src/main/resources/db/80/V1000_74__user_can_modify_keychips.sql
@@ -0,0 +1,2 @@
+ALTER TABLE aqua_net_user
+ ADD COLUMN can_modify_keychips BOOLEAN NOT NULL DEFAULT FALSE;
diff --git a/src/main/resources/db/80/V1000_75__drop_keychip_whitelist.sql b/src/main/resources/db/80/V1000_75__drop_keychip_whitelist.sql
new file mode 100644
index 00000000..36203b2e
--- /dev/null
+++ b/src/main/resources/db/80/V1000_75__drop_keychip_whitelist.sql
@@ -0,0 +1 @@
+DROP TABLE allnet_keychips;
\ No newline at end of file