Bridge.Database.AfterMigration("sky_phone", function() SkyPhoneSecurity = {} local passcode_pepper = tostring(Config.Server.PasscodePepper or "") AddEventHandler("sky_phone:configurator:serverUpdated", function() passcode_pepper = tostring(Config.Server.PasscodePepper or "") end) if passcode_pepper == "" then Bridge.Debug( "warn", "[sky_phone] Config.Server.PasscodePepper is empty. Device passcodes still work, but their hashes lack the required server-side secret. Set a stable random value in config/config.lua before production; changing it later invalidates existing device passcodes.", { always = true } ) end local function affected_rows(result) if type(result) == "number" then return result end return type(result) == "table" and tonumber(result.affectedRows) or 0 end local function load_device_security(imei) local rows = Bridge.Database.Query([[ SELECT `passcode_length`, `failed_attempts`, `locked_until` FROM `sky_phone_device_security` WHERE `device_imei` = ? LIMIT 1 ]], { imei }) return rows[1] end local function valid_passcode(value) return type(value) == "string" and (#value == 4 or #value == 6) and value:match("^%d+$") ~= nil end local function passcode_matches(imei, passcode) local rows = Bridge.Database.Query([[ SELECT 1 AS `matches` FROM `sky_phone_device_security` WHERE `device_imei` = ? AND `passcode_hash` = UNHEX(SHA2(CONCAT(?, `passcode_salt`, ?), 256)) LIMIT 1 ]], { imei, passcode_pepper, passcode }) return rows[1] ~= nil end local function verify_passcode(session, passcode) if not valid_passcode(passcode) then return false, { success = false, error = "invalid_passcode" } end local security = load_device_security(session.imei) if not security then return false, { success = false, error = "passcode_not_set" } end local now = os.time() local locked_until = tonumber(security.locked_until) or 0 if locked_until > now then return false, { success = false, error = "passcode_locked", data = { retryAfter = locked_until - now }, } end if passcode_matches(session.imei, passcode) then Bridge.Database.Query([[ UPDATE `sky_phone_device_security` SET `failed_attempts` = 0, `locked_until` = 0 WHERE `device_imei` = ? ]], { session.imei }) return true end local failed_attempts = (tonumber(security.failed_attempts) or 0) + 1 if failed_attempts >= Config.Security.MaximumAttempts then local next_unlock = now + Config.Security.LockSeconds Bridge.Database.Query([[ UPDATE `sky_phone_device_security` SET `failed_attempts` = 0, `locked_until` = ? WHERE `device_imei` = ? ]], { next_unlock, session.imei }) return false, { success = false, error = "passcode_locked", data = { retryAfter = Config.Security.LockSeconds }, } end Bridge.Database.Query([[ UPDATE `sky_phone_device_security` SET `failed_attempts` = ? WHERE `device_imei` = ? ]], { failed_attempts, session.imei }) return false, { success = false, error = "invalid_passcode", data = { attemptsRemaining = Config.Security.MaximumAttempts - failed_attempts }, } end function SkyPhoneSecurity.Load(imei) return load_device_security(imei) end function SkyPhoneSecurity.Status(imei, security, security_loaded) if not security_loaded then security = load_device_security(imei) end return { enabled = security ~= nil, length = security and tonumber(security.passcode_length) or nil, lockedUntil = security and tonumber(security.locked_until) or 0, } end Bridge.Callbacks.Register("sky_phone:security:unlock", function(source, data) if not SkyPhone.AllowOperation(source, "security_unlock", Config.Security.AttemptsPerMinute, 60) then return { success = false, error = "rate_limited" } end local session, error_response = SkyPhone.RequireDeviceSession(source) if not session then return error_response end if session.unlocked then return { success = true, data = { security = SkyPhoneSecurity.Status(session.imei) } } end local verified, verification_error = verify_passcode(session, data and data.passcode) if not verified then return verification_error end session.unlocked = true return { success = true, data = { security = SkyPhoneSecurity.Status(session.imei) } } end) Bridge.Callbacks.Register("sky_phone:security:set-passcode", function(source, data) local session, error_response = SkyPhone.RequireSession(source) if not session then return error_response end local passcode = data and data.passcode if not valid_passcode(passcode) then return { success = false, error = "invalid_passcode" } end if load_device_security(session.imei) then return { success = false, error = "passcode_already_set" } end local salts = Bridge.Database.Query("SELECT REPLACE(UUID(), '-', '') AS `salt`", {}) local salt = salts[1] and salts[1].salt if type(salt) ~= "string" or #salt ~= 32 then error("[sky_phone] Database did not generate a valid passcode salt.") end local result = Bridge.Database.Query([[ INSERT INTO `sky_phone_device_security` (`device_imei`, `passcode_hash`, `passcode_salt`, `passcode_length`) VALUES (?, UNHEX(SHA2(CONCAT(?, ?, ?), 256)), ?, ?) ]], { session.imei, passcode_pepper, salt, passcode, salt, #passcode }) if affected_rows(result) ~= 1 then return { success = false, error = "request_failed" } end return { success = true, data = { security = SkyPhoneSecurity.Status(session.imei) } } end) Bridge.Callbacks.Register("sky_phone:security:change-passcode", function(source, data) local session, error_response = SkyPhone.RequireSession(source) if not session then return error_response end local new_passcode = data and data.newPasscode if not valid_passcode(new_passcode) then return { success = false, error = "invalid_passcode" } end local verified, verification_error = verify_passcode(session, data and data.currentPasscode) if not verified then return verification_error end local salts = Bridge.Database.Query("SELECT REPLACE(UUID(), '-', '') AS `salt`", {}) local salt = salts[1] and salts[1].salt if type(salt) ~= "string" or #salt ~= 32 then error("[sky_phone] Database did not generate a valid passcode salt.") end local result = Bridge.Database.Query([[ UPDATE `sky_phone_device_security` SET `passcode_hash` = UNHEX(SHA2(CONCAT(?, ?, ?), 256)), `passcode_salt` = ?, `passcode_length` = ?, `failed_attempts` = 0, `locked_until` = 0 WHERE `device_imei` = ? ]], { passcode_pepper, salt, new_passcode, salt, #new_passcode, session.imei }) if affected_rows(result) ~= 1 then return { success = false, error = "request_failed" } end return { success = true, data = { security = SkyPhoneSecurity.Status(session.imei) } } end) Bridge.Callbacks.Register("sky_phone:security:disable-passcode", function(source, data) local session, error_response = SkyPhone.RequireSession(source) if not session then return error_response end local verified, verification_error = verify_passcode(session, data and data.passcode) if not verified then return verification_error end local result = Bridge.Database.Query( "DELETE FROM `sky_phone_device_security` WHERE `device_imei` = ?", { session.imei } ) if affected_rows(result) ~= 1 then return { success = false, error = "request_failed" } end session.unlocked = true return { success = true, data = { security = SkyPhoneSecurity.Status(session.imei) } } end) end)