From eefe3b53311fd7ea48ae6d97d251c0af3f9ee161 Mon Sep 17 00:00:00 2001 From: "smx.pusha" <139338836+smxpusha@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:11:49 +0200 Subject: [PATCH] ADD - add device passcode protection --- frontend/src/App.vue | 140 +++++++++++- frontend/src/components/PhonePasscode.vue | 215 ++++++++++++++++++ frontend/src/stores/phone-security.test.ts | 80 +++++++ frontend/src/stores/phone.ts | 106 ++++++++- frontend/src/types/device.ts | 7 + frontend/src/views/apps/SettingsApp.vue | 245 +++++++++++++++++++++ frontend/testserver/index.cjs | 47 ++++ sky_phone/config/config.lua | 7 + sky_phone/config/locales/en.lua | 16 +- sky_phone/source/client/main.lua | 4 + sky_phone/source/server/db_migrate.lua | 33 +++ sky_phone/source/server/phone.lua | 226 ++++++++++++++++++- sky_phone/sql/install.sql | 12 + 13 files changed, 1128 insertions(+), 10 deletions(-) create mode 100644 frontend/src/components/PhonePasscode.vue create mode 100644 frontend/src/stores/phone-security.test.ts diff --git a/frontend/src/App.vue b/frontend/src/App.vue index bbb2634..3af545f 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -14,6 +14,7 @@ import PhoneHomeIndicator from '@/components/PhoneHomeIndicator.vue' import PhoneControlCenter from '@/components/PhoneControlCenter.vue' import PhoneMediaCapture from '@/components/PhoneMediaCapture.vue' import PhoneLockScreen from '@/components/PhoneLockScreen.vue' +import PhonePasscode from '@/components/PhonePasscode.vue' import PhoneNotifications from '@/components/PhoneNotifications.vue' import NotificationPhonePreview from '@/components/NotificationPhonePreview.vue' import PhoneStatusBar from '@/components/PhoneStatusBar.vue' @@ -143,6 +144,13 @@ const appTransitionName = computed(() => ) const isLocked = ref(false) const isUnlocking = ref(false) +const passcodeBusy = ref(false) +const passcodeError = ref('') +const passcodeResetKey = ref(0) +const passcodeRetrySeconds = ref(0) +const passcodeVisible = ref(false) +const pendingUnlockRoute = ref(null) +const unlockedServicesLoaded = ref(false) const controlCenterOpened = ref(false) const simPicker = ref(null) const systemColorScheme = window.matchMedia('(prefers-color-scheme: dark)') @@ -168,6 +176,7 @@ const phoneFrameImage = computed( ) let clockTicker: ReturnType | undefined let unlockTimer: number | undefined +let passcodeLockTimer: number | undefined function getViewportScale(): number { const heightScale = window.innerHeight / REFERENCE_VIEWPORT_HEIGHT @@ -185,12 +194,17 @@ function hydratePhone(payload: PhoneOpenPayload): void { media.hydrate(payload.device?.data.media?.payload) appStore.hydrate(payload.device?.data.apps?.payload) widgets.hydrate(payload.device?.data.widgets?.payload) - void mail.bootstrap(payload.account?.email ?? '') - if (payload.account?.email) void marketplace.loadCounts() +} + +function loadUnlockedPhoneData(): void { + if (unlockedServicesLoaded.value) return + unlockedServicesLoaded.value = true + void mail.bootstrap(account.email) + if (account.email) void marketplace.loadCounts() else marketplace.setCounts({ active: 0, unread: 0 }) void calls.bootstrap() void messages.loadConversations() - if (payload.account?.email) void darkchat.bootstrap() + if (account.email) void darkchat.bootstrap() } async function hydrateDevelopmentPhone(): Promise { @@ -392,8 +406,11 @@ function onMessage(event: MessageEvent): void { ) { calls.applyCallState(event.data.data as PhoneCall) controlCenterOpened.value = false - isLocked.value = false - isUnlocking.value = false + if (!phone.security.enabled) { + isLocked.value = false + isUnlocking.value = false + loadUnlockedPhoneData() + } window.setTimeout(() => void router.push('/apps/phone'), 0) } else if (event.data?.type === 'sim:picker' && event.data.data) { simPicker.value = event.data.data as unknown as SimPickerPayload @@ -420,14 +437,78 @@ function updateViewportScale(): void { viewportScale.value = getViewportScale() } -function unlockPhone(): void { +function finishUnlock(): void { if (!isLocked.value) return isUnlocking.value = true isLocked.value = false + passcodeVisible.value = false + passcodeError.value = '' unlockTimer = window.setTimeout(() => { isUnlocking.value = false }, 720) + + if (pendingUnlockRoute.value) { + const routePath = pendingUnlockRoute.value + pendingUnlockRoute.value = null + window.setTimeout(() => void router.push(routePath), 0) + } + loadUnlockedPhoneData() +} + +function unlockPhone(): void { + if (!isLocked.value) return + if (phone.security.enabled) { + passcodeError.value = '' + passcodeVisible.value = true + return + } + finishUnlock() +} + +function cancelPasscode(): void { + if (passcodeBusy.value) return + passcodeVisible.value = false + passcodeError.value = '' + pendingUnlockRoute.value = null +} + +function startPasscodeLock(seconds: number): void { + if (passcodeLockTimer !== undefined) window.clearInterval(passcodeLockTimer) + passcodeRetrySeconds.value = Math.max(1, Math.ceil(seconds)) + passcodeLockTimer = window.setInterval(() => { + passcodeRetrySeconds.value = Math.max(0, passcodeRetrySeconds.value - 1) + if (passcodeRetrySeconds.value === 0 && passcodeLockTimer !== undefined) { + window.clearInterval(passcodeLockTimer) + passcodeLockTimer = undefined + passcodeError.value = '' + } + }, 1000) +} + +async function submitUnlockPasscode(passcode: string): Promise { + if (passcodeBusy.value || passcodeRetrySeconds.value > 0) return + passcodeBusy.value = true + const response = await phone.unlockWithPasscode(passcode) + passcodeBusy.value = false + if (response.success) { + finishUnlock() + return + } + + passcodeResetKey.value += 1 + if (response.error === 'passcode_locked') { + startPasscodeLock(response.data?.retryAfter ?? 30) + passcodeError.value = phone.t('LockScreen.passcode.locked', { + seconds: String(response.data?.retryAfter ?? 30), + }) + return + } + if (response.error === 'rate_limited') { + passcodeError.value = phone.t('LockScreen.passcode.rateLimited') + return + } + passcodeError.value = phone.t('LockScreen.passcode.incorrect') } function toggleControlCenter(): void { @@ -436,6 +517,11 @@ function toggleControlCenter(): void { } function unlockCamera(): void { + if (phone.security.enabled) { + pendingUnlockRoute.value = '/apps/camera' + unlockPhone() + return + } unlockPhone() window.setTimeout(() => void router.push('/apps/camera'), 0) } @@ -524,12 +610,33 @@ watch( controlCenterOpened.value = false isLocked.value = false isUnlocking.value = false + passcodeVisible.value = false + passcodeBusy.value = false + passcodeError.value = '' + pendingUnlockRoute.value = null + unlockedServicesLoaded.value = false + if (passcodeLockTimer !== undefined) { + window.clearInterval(passcodeLockTimer) + passcodeLockTimer = undefined + } return } isLocked.value = true + unlockedServicesLoaded.value = false controlCenterOpened.value = false weather.start() isUnlocking.value = false + passcodeVisible.value = false + passcodeBusy.value = false + passcodeError.value = '' + passcodeResetKey.value += 1 + passcodeRetrySeconds.value = Math.max( + 0, + (phone.security.lockedUntil ?? 0) - Math.floor(Date.now() / 1000), + ) + if (passcodeRetrySeconds.value > 0) { + startPasscodeLock(passcodeRetrySeconds.value) + } phone.setLaunchOrigin(null) void router.replace('/') }, @@ -546,6 +653,7 @@ onBeforeUnmount(() => { weather.stop() if (clockTicker) clearInterval(clockTicker) if (unlockTimer !== undefined) window.clearTimeout(unlockTimer) + if (passcodeLockTimer !== undefined) window.clearInterval(passcodeLockTimer) window.removeEventListener('message', onMessage) window.removeEventListener('keydown', onKeydown) window.removeEventListener('resize', updateViewportScale) @@ -637,6 +745,26 @@ onBeforeUnmount(() => { @unlock="unlockPhone" /> + + + +import { Delete } from 'lucide-vue-next' +import { computed, ref, watch } from 'vue' + +import { usePhoneStore } from '@/stores/phone' + +const props = withDefaults( + defineProps<{ + busy?: boolean + cancelable?: boolean + disabled?: boolean + error?: string + length: 4 | 6 + resetKey?: number + subtitle?: string + title: string + }>(), + { + busy: false, + cancelable: true, + disabled: false, + error: '', + resetKey: 0, + subtitle: '', + }, +) + +const emit = defineEmits<{ + cancel: [] + complete: [passcode: string] +}>() + +const phone = usePhoneStore() +const digits = ref('') +const keypad = [1, 2, 3, 4, 5, 6, 7, 8, 9] +const inputDisabled = computed(() => props.busy || props.disabled) + +function enterDigit(digit: number): void { + if (inputDisabled.value || digits.value.length >= props.length) return + digits.value += String(digit) + if (digits.value.length === props.length) emit('complete', digits.value) +} + +function removeDigit(): void { + if (inputDisabled.value) return + digits.value = digits.value.slice(0, -1) +} + +watch( + () => props.resetKey, + () => { + digits.value = '' + }, +) + + + + + diff --git a/frontend/src/stores/phone-security.test.ts b/frontend/src/stores/phone-security.test.ts new file mode 100644 index 0000000..740dca5 --- /dev/null +++ b/frontend/src/stores/phone-security.test.ts @@ -0,0 +1,80 @@ +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { usePhoneStore } from '@/stores/phone' +import { nuiCall } from '@/utils/nui' + +vi.mock('@/utils/nui', () => ({ + nuiCall: vi.fn(), +})) + +const mockNuiCall = vi.mocked(nuiCall) + +describe('phone passcode store', () => { + beforeEach(() => { + vi.stubGlobal('window', { + matchMedia: vi.fn(() => ({ matches: false })), + }) + setActivePinia(createPinia()) + mockNuiCall.mockReset() + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('stores the server security state after setting a six digit passcode', async () => { + mockNuiCall.mockResolvedValueOnce({ + data: { + security: { enabled: true, length: 6, lockedUntil: 0 }, + }, + success: true, + }) + + const phone = usePhoneStore() + const response = await phone.setPasscode('123456') + + expect(response.success).toBe(true) + expect(phone.security).toEqual({ + enabled: true, + length: 6, + lockedUntil: 0, + }) + expect(mockNuiCall).toHaveBeenCalledWith('security:set-passcode', { + passcode: '123456', + }) + }) + + it('keeps the configured state after a rejected unlock attempt', async () => { + mockNuiCall.mockResolvedValueOnce({ + error: 'invalid_passcode', + success: false, + }) + + const phone = usePhoneStore() + phone.security = { enabled: true, length: 4, lockedUntil: 0 } + await phone.unlockWithPasscode('9999') + + expect(phone.security).toEqual({ + enabled: true, + length: 4, + lockedUntil: 0, + }) + }) + + it('clears the security state after disabling the passcode', async () => { + mockNuiCall.mockResolvedValueOnce({ + data: { + security: { enabled: false, length: null, lockedUntil: 0 }, + }, + success: true, + }) + + const phone = usePhoneStore() + phone.security = { enabled: true, length: 4, lockedUntil: 0 } + await phone.disablePasscode('1234') + + expect(phone.security.enabled).toBe(false) + expect(phone.security.length).toBeNull() + }) +}) diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index 0238baa..65d609e 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -1,10 +1,15 @@ import { defineStore } from 'pinia' import type { AppLaunchOrigin, LaunchablePhoneAppId } from '@/types/apps' -import type { DeviceBootstrap, PhoneDevice } from '@/types/device' +import type { + DeviceBootstrap, + DeviceSecurity, + PhoneDevice, +} from '@/types/device' import { clampPage } from '@/utils/pages' import { cloneJsonData } from '@/utils/clone' import { nuiCall } from '@/utils/nui' +import type { NuiResponse } from '@/utils/nui' import { DEFAULT_PHONE_PREFERENCES, parsePhonePreferences, @@ -15,12 +20,19 @@ import { type LocaleTree = Record +export type PasscodeResponseData = { + attemptsRemaining?: number + retryAfter?: number + security?: DeviceSecurity +} + export type PhoneOpenPayload = { account?: DeviceBootstrap['account'] device?: PhoneDevice lang?: string locales?: LocaleTree notes?: DeviceBootstrap['notes'] + security?: DeviceSecurity token?: string } @@ -1322,6 +1334,7 @@ const defaultLocales: LocaleTree = { notifications: 'Notifications', sounds: 'Sounds & Haptics', general: 'General Settings', + security: 'Passcode & Security', appearance: 'Appearance', allowNotifications: 'Allow Notifications', notificationSounds: 'Sounds', @@ -1366,6 +1379,28 @@ const defaultLocales: LocaleTree = { 'This removes the account and all local data from this phone. Cloud data and the IMEI remain.', factoryResetProgress: 'Erasing iFruit Phone', factoryResetWarning: 'Do not turn off this phone. This takes 60 seconds.', + passcode: { + description: + 'A passcode protects the contents of this phone. It stays with the device when the SIM or iFruit account changes.', + status: 'Passcode', + codeLength: 'Code Length', + sixDigit: '6-Digit Code', + fourDigit: '4-Digit Code', + turnOn: 'Turn Passcode On', + turnOff: 'Turn Passcode Off', + change: 'Change Passcode', + enterNew: 'Enter New Passcode', + confirmNew: 'Verify New Passcode', + enterCurrent: 'Enter Current Passcode', + screenSubtitle: 'Use 4 or 6 numbers.', + incorrect: 'Incorrect passcode.', + mismatch: 'The passcodes did not match.', + locked: 'Too many incorrect attempts. Try again later.', + rateLimited: 'Too many attempts. Please wait.', + failed: 'The passcode could not be updated.', + saved: 'Passcode saved.', + disabled: 'Passcode turned off.', + }, accountErrors: { invalid_email: 'Choose a valid 3–32 character iFruit address.', invalid_password: 'Password must be 6–64 characters.', @@ -1460,6 +1495,16 @@ const defaultLocales: LocaleTree = { flashlight: 'Flashlight', camera: 'Camera', swipeUp: 'Swipe up to open', + passcode: { + enter: 'Enter Passcode', + unlockSubtitle: 'Enter the passcode for this phone.', + cancel: 'Cancel', + delete: 'Delete digit', + incorrect: 'Incorrect passcode', + locked: 'Too many attempts. Try again in {seconds} seconds.', + tryAgain: 'Try again in {seconds} seconds', + rateLimited: 'Too many attempts. Please wait.', + }, }, Home: { appLibrary: 'App Library', @@ -1575,6 +1620,11 @@ export const usePhoneStore = defineStore('phone', { launchOrigin: null as AppLaunchOrigin | null, locales: defaultLocales, preferences: cloneJsonData(DEFAULT_PHONE_PREFERENCES), + security: { + enabled: false, + length: null, + lockedUntil: 0, + } as DeviceSecurity, systemDarkMode: window.matchMedia('(prefers-color-scheme: dark)').matches, }), getters: { @@ -1593,6 +1643,11 @@ export const usePhoneStore = defineStore('phone', { this.lang = payload.lang ?? 'en' this.locales = payload.locales ?? defaultLocales if (payload.device) this.hydrateDevice(payload.device) + this.security = payload.security ?? { + enabled: false, + length: null, + lockedUntil: 0, + } this.isOpen = true }, hydrateDevice(device: PhoneDevice): void { @@ -1662,6 +1717,55 @@ export const usePhoneStore = defineStore('phone', { this.preferences.settings.wallpaper = wallpaper this.saveDeviceNamespace('settings', this.preferences) }, + async unlockWithPasscode( + passcode: string, + ): Promise> { + const response = await nuiCall( + 'security:unlock', + { passcode }, + ) + if (response.success && response.data?.security) { + this.security = response.data.security + } + return response + }, + async setPasscode( + passcode: string, + ): Promise> { + const response = await nuiCall( + 'security:set-passcode', + { passcode }, + ) + if (response.success && response.data?.security) { + this.security = response.data.security + } + return response + }, + async changePasscode( + currentPasscode: string, + newPasscode: string, + ): Promise> { + const response = await nuiCall( + 'security:change-passcode', + { currentPasscode, newPasscode }, + ) + if (response.success && response.data?.security) { + this.security = response.data.security + } + return response + }, + async disablePasscode( + passcode: string, + ): Promise> { + const response = await nuiCall( + 'security:disable-passcode', + { passcode }, + ) + if (response.success && response.data?.security) { + this.security = response.data.security + } + return response + }, t(path: string, replacements: Record = {}): string { const translated = getByPath(this.locales, path) const fallback = getByPath(defaultLocales, path) diff --git a/frontend/src/types/device.ts b/frontend/src/types/device.ts index 7f76d7c..06f7bd0 100644 --- a/frontend/src/types/device.ts +++ b/frontend/src/types/device.ts @@ -19,6 +19,12 @@ export type PhoneNotificationDevicePayload = { settings?: string | null } +export type DeviceSecurity = { + enabled: boolean + length: 4 | 6 | null + lockedUntil: number +} + export type AccountDevice = { created_at: string current: boolean @@ -37,5 +43,6 @@ export type DeviceBootstrap = { account: IfruitAccount | null device: PhoneDevice notes: Note[] + security: DeviceSecurity token: string } diff --git a/frontend/src/views/apps/SettingsApp.vue b/frontend/src/views/apps/SettingsApp.vue index 0f8ff3b..fd62c03 100644 --- a/frontend/src/views/apps/SettingsApp.vue +++ b/frontend/src/views/apps/SettingsApp.vue @@ -17,6 +17,8 @@ import { kPreloader, kRange, kSearchbar, + kSegmented, + kSegmentedButton, kToast, kToggle, } from 'konsta/vue' @@ -45,6 +47,7 @@ import { import { PHONE_FRAME_COLORS } from '@/config/appearance' import { isLaunchablePhoneApp, PHONE_APPS } from '@/config/apps' import { usePhoneStore } from '@/stores/phone' +import PhonePasscode from '@/components/PhonePasscode.vue' import { useAccountStore } from '@/stores/account' import type { LaunchablePhoneAppDefinition, @@ -74,6 +77,7 @@ import { type SettingsView = | 'root' | 'account' + | 'security' | 'notifications' | 'notification-detail' | 'sounds' @@ -82,6 +86,14 @@ type SettingsView = | 'wallpaper' type RootToggleKey = 'airplaneMode' | 'streamerMode' type SubmenuView = Exclude +type PasscodeFlow = + | 'set-new' + | 'set-confirm' + | 'change-current' + | 'change-new' + | 'change-confirm' + | 'disable' + | null const FACTORY_RESET_DURATION_MS = 60_000 const FACTORY_RESET_CIRCUMFERENCE = 2 * Math.PI * 48 @@ -110,6 +122,13 @@ const accountPassword = ref('') const accountConfirm = ref('') const accountSubmitting = ref(false) const accountToast = ref('') +const passcodeBusy = ref(false) +const passcodeCurrent = ref('') +const passcodeError = ref('') +const passcodeFirst = ref('') +const passcodeFlow = ref(null) +const passcodeLength = ref<4 | 6>(6) +const passcodeResetKey = ref(0) const removeDeviceImei = ref('') const removeDevicePassword = ref('') const removeDeviceOpened = ref(false) @@ -152,6 +171,12 @@ const serviceRows = [ }, ] const preferenceRows = [ + { + key: 'security', + view: 'security' as const, + icon: KeyRound, + iconColor: '#34c759', + }, { key: 'general', view: 'general' as const, @@ -204,6 +229,18 @@ const activeTitle = computed(() => { } return phone.t(`Apps.settings.${activeView.value}`) }) +const passcodeTitle = computed(() => { + if (passcodeFlow.value === 'set-confirm') { + return phone.t('Apps.settings.passcode.confirmNew') + } + if (passcodeFlow.value === 'change-current' || passcodeFlow.value === 'disable') { + return phone.t('Apps.settings.passcode.enterCurrent') + } + if (passcodeFlow.value === 'change-confirm') { + return phone.t('Apps.settings.passcode.confirmNew') + } + return phone.t('Apps.settings.passcode.enterNew') +}) function matchesSearch(key: string): boolean { return ( @@ -220,6 +257,9 @@ function updateSearch(event: Event): void { } function openView(view: SubmenuView): void { + if (view === 'security') { + passcodeLength.value = phone.security.length ?? 6 + } activeView.value = view scrollPageToTop() } @@ -248,6 +288,115 @@ function toggleRootSetting(key: RootToggleKey): void { phone.setPreference(key, !phone.preferences.settings[key]) } +function resetPasscodeInput(): void { + passcodeError.value = '' + passcodeResetKey.value += 1 +} + +function beginSetPasscode(): void { + passcodeLength.value = phone.security.length ?? passcodeLength.value + passcodeFirst.value = '' + passcodeCurrent.value = '' + passcodeFlow.value = 'set-new' + resetPasscodeInput() +} + +function beginChangePasscode(): void { + passcodeFirst.value = '' + passcodeCurrent.value = '' + passcodeFlow.value = 'change-current' + resetPasscodeInput() +} + +function beginDisablePasscode(): void { + passcodeLength.value = phone.security.length ?? 6 + passcodeCurrent.value = '' + passcodeFlow.value = 'disable' + resetPasscodeInput() +} + +function cancelPasscodeFlow(): void { + if (passcodeBusy.value) return + passcodeFlow.value = null + passcodeFirst.value = '' + passcodeCurrent.value = '' + resetPasscodeInput() +} + +function passcodeRequestError(error?: string): string { + if (error === 'invalid_passcode') { + return phone.t('Apps.settings.passcode.incorrect') + } + if (error === 'passcode_locked') { + return phone.t('Apps.settings.passcode.locked') + } + if (error === 'rate_limited') { + return phone.t('Apps.settings.passcode.rateLimited') + } + return phone.t('Apps.settings.passcode.failed') +} + +async function submitSettingsPasscode(passcode: string): Promise { + if (passcodeBusy.value || !passcodeFlow.value) return + + if (passcodeFlow.value === 'set-new') { + passcodeFirst.value = passcode + passcodeFlow.value = 'set-confirm' + resetPasscodeInput() + return + } + if (passcodeFlow.value === 'change-current') { + passcodeCurrent.value = passcode + passcodeFlow.value = 'change-new' + resetPasscodeInput() + return + } + if (passcodeFlow.value === 'change-new') { + passcodeFirst.value = passcode + passcodeFlow.value = 'change-confirm' + resetPasscodeInput() + return + } + if ( + (passcodeFlow.value === 'set-confirm' || + passcodeFlow.value === 'change-confirm') && + passcode !== passcodeFirst.value + ) { + passcodeError.value = phone.t('Apps.settings.passcode.mismatch') + passcodeResetKey.value += 1 + return + } + + passcodeBusy.value = true + const response = + passcodeFlow.value === 'set-confirm' + ? await phone.setPasscode(passcode) + : passcodeFlow.value === 'change-confirm' + ? await phone.changePasscode(passcodeCurrent.value, passcode) + : await phone.disablePasscode(passcode) + passcodeBusy.value = false + if (!response.success) { + passcodeError.value = passcodeRequestError(response.error) + if ( + passcodeFlow.value === 'change-confirm' && + response.error === 'invalid_passcode' + ) { + passcodeFlow.value = 'change-current' + passcodeCurrent.value = '' + passcodeFirst.value = '' + } + passcodeResetKey.value += 1 + return + } + + accountToast.value = phone.t( + passcodeFlow.value === 'disable' + ? 'Apps.settings.passcode.disabled' + : 'Apps.settings.passcode.saved', + ) + cancelPasscodeFlow() +} + function updateNumberPreference( key: | 'notificationDurationSeconds' @@ -551,6 +700,15 @@ onBeforeUnmount(() => { + @@ -715,6 +873,81 @@ onBeforeUnmount(() => { + + + +
{ }, }, notes: mockNotes, + security: mockSecurity, token: 'development', }, }) @@ -1667,12 +1670,56 @@ app.post('/api/:endpoint', (request, response) => { response.json({ success: true, data: { revision } }) return } + if (endpoint === 'security:unlock') { + response.json( + !mockSecurity.enabled || request.body.passcode === mockPasscode + ? { success: true, data: { security: mockSecurity } } + : { success: false, error: 'invalid_passcode' }, + ) + return + } + if (endpoint === 'security:set-passcode') { + mockPasscode = String(request.body.passcode) + mockSecurity = { + enabled: true, + length: mockPasscode.length, + lockedUntil: 0, + } + response.json({ success: true, data: { security: mockSecurity } }) + return + } + if (endpoint === 'security:change-passcode') { + if (request.body.currentPasscode !== mockPasscode) { + response.json({ success: false, error: 'invalid_passcode' }) + return + } + mockPasscode = String(request.body.newPasscode) + mockSecurity = { + enabled: true, + length: mockPasscode.length, + lockedUntil: 0, + } + response.json({ success: true, data: { security: mockSecurity } }) + return + } + if (endpoint === 'security:disable-passcode') { + if (request.body.passcode !== mockPasscode) { + response.json({ success: false, error: 'invalid_passcode' }) + return + } + mockPasscode = '' + mockSecurity = { enabled: false, length: null, lockedUntil: 0 } + response.json({ success: true, data: { security: mockSecurity } }) + return + } if (endpoint === 'device:factory-reset') { authenticated = false linkedAccount = null mockNotes = [] mockMedia = [] calendarEvents = [] + mockPasscode = '' + mockSecurity = { enabled: false, length: null, lockedUntil: 0 } for (const key of Object.keys(deviceData)) delete deviceData[key] response.json({ success: true }) return diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua index afc4287..fbfec09 100644 --- a/sky_phone/config/config.lua +++ b/sky_phone/config/config.lua @@ -19,6 +19,13 @@ Config.Phone = { DeviceName = "iFruit Phone", } +Config.Security = { + PasscodePepperConvar = "sky_phone_passcode_pepper", + MaximumAttempts = 5, + LockSeconds = 30, + AttemptsPerMinute = 12, +} + Config.Sim = { RegisteredItem = "sky_phone_sim_registered", AnonymousItem = "sky_phone_sim_anonymous", diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index 29b140e..4956924 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -29,6 +29,11 @@ Locales["en"] = { Notifications = { now = "now" }, LockScreen = { label = "Lock Screen", flashlight = "Flashlight", camera = "Camera", swipeUp = "Swipe up to open", + passcode = { + enter = "Enter Passcode", unlockSubtitle = "Enter the passcode for this phone.", cancel = "Cancel", delete = "Delete digit", + incorrect = "Incorrect passcode", locked = "Too many attempts. Try again in {seconds} seconds.", + tryAgain = "Try again in {seconds} seconds", rateLimited = "Too many attempts. Please wait.", + }, }, Home = { appLibrary = "App Library", appLibrarySearch = "Search apps", allApps = "All Apps", apps = "Apps", @@ -545,7 +550,7 @@ Locales["en"] = { accountInformation = "Account Information", accountStatus = "Account Status", accountStatusValue = "Active", accountStorage = "Cloud Storage", accountStorageValue = "On Device", accountPurchases = "Media & Purchases", accountPurchasesValue = "Available", notifications = "Notifications", sounds = "Sounds & Haptics", - general = "General Settings", appearance = "Appearance", allowNotifications = "Allow Notifications", + general = "General Settings", security = "Passcode & Security", appearance = "Appearance", allowNotifications = "Allow Notifications", notificationSounds = "Sounds", notificationDuration = "Notification Duration", seconds = "{seconds} seconds", ringtoneVolume = "Ringtone Volume", notificationVolume = "Notification Volume", ringtone = "Ringtone", notificationSound = "Notification Sound", appearanceMode = "Appearance Mode", @@ -559,6 +564,15 @@ Locales["en"] = { removeDeviceBody = "Enter your iFruit password to remove this device from the account.", signOut = "Sign Out", factoryReset = "Erase All Content and Settings", factoryResetBody = "This removes the account and all local data from this phone. Cloud data and the IMEI remain.", factoryResetProgress = "Erasing iFruit Phone", factoryResetWarning = "Do not turn off this phone. This takes 60 seconds.", + passcode = { + description = "A passcode protects the contents of this phone. It stays with the device when the SIM or iFruit account changes.", + status = "Passcode", codeLength = "Code Length", sixDigit = "6-Digit Code", fourDigit = "4-Digit Code", + turnOn = "Turn Passcode On", turnOff = "Turn Passcode Off", change = "Change Passcode", + enterNew = "Enter New Passcode", confirmNew = "Verify New Passcode", enterCurrent = "Enter Current Passcode", + screenSubtitle = "Use 4 or 6 numbers.", incorrect = "Incorrect passcode.", mismatch = "The passcodes did not match.", + locked = "Too many incorrect attempts. Try again later.", rateLimited = "Too many attempts. Please wait.", + failed = "The passcode could not be updated.", saved = "Passcode saved.", disabled = "Passcode turned off.", + }, accountErrors = { invalid_email = "Choose a valid 3–32 character iFruit address.", invalid_password = "Password must be 6–64 characters.", invalid_credentials = "Email or password is incorrect.", email_taken = "That iFruit address is already registered.", diff --git a/sky_phone/source/client/main.lua b/sky_phone/source/client/main.lua index 09ebfa3..b90dc12 100644 --- a/sky_phone/source/client/main.lua +++ b/sky_phone/source/client/main.lua @@ -8,6 +8,10 @@ local call_channel = 0 Bridge.Debug("debug", "[sky_phone] Client script initialized.", { always = true }) local server_callbacks = { + "security:unlock", + "security:set-passcode", + "security:change-passcode", + "security:disable-passcode", "device:save", "device:factory-reset", "account:login", diff --git a/sky_phone/source/server/db_migrate.lua b/sky_phone/source/server/db_migrate.lua index 4275e4d..59ecfc4 100644 --- a/sky_phone/source/server/db_migrate.lua +++ b/sky_phone/source/server/db_migrate.lua @@ -208,6 +208,39 @@ local schema = { }, tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", }, + { + name = "sky_phone_device_security", + columns = { + { + name = "device_imei", + type = "CHAR(15) NOT NULL", + characterSet = "ascii", + collation = "ascii_bin", + }, + { name = "passcode_hash", type = "BINARY(32) NOT NULL" }, + { + name = "passcode_salt", + type = "CHAR(32) NOT NULL", + characterSet = "ascii", + collation = "ascii_bin", + }, + { name = "passcode_length", type = "TINYINT UNSIGNED NOT NULL" }, + { name = "failed_attempts", type = "TINYINT UNSIGNED NOT NULL DEFAULT 0" }, + { name = "locked_until", type = "BIGINT UNSIGNED NOT NULL DEFAULT 0" }, + { + name = "updated_at", + type = "DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP", + }, + }, + primaryKey = "device_imei", + foreignKeys = { + { + column = "device_imei", + references = "`sky_phone_devices` (`imei`) ON DELETE CASCADE", + }, + }, + tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci", + }, { name = "sky_phone_notes", columns = { diff --git a/sky_phone/source/server/phone.lua b/sky_phone/source/server/phone.lua index 9306467..5339fbd 100644 --- a/sky_phone/source/server/phone.lua +++ b/sky_phone/source/server/phone.lua @@ -7,6 +7,15 @@ local sessions = {} local auth_attempts = {} local operation_attempts = {} local max_device_data_bytes = 100000 +local passcode_pepper = GetConvar(Config.Security.PasscodePepperConvar, "") +if passcode_pepper == "" then + Bridge.Debug( + "warn", + "[sky_phone] Passcode pepper convar '%s' is empty; configure it before production use.", + Config.Security.PasscodePepperConvar, + { always = true } + ) +end local allowed_device_namespaces = { settings = true, notifications = true, @@ -234,6 +243,98 @@ local function load_device_data(imei) return data 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 security_status(imei) + local security = load_device_security(imei) + return { + enabled = security ~= nil, + length = security and tonumber(security.passcode_length) or nil, + lockedUntil = security and tonumber(security.locked_until) or 0, + } +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 + local function account_devices(account_id, current_imei) local rows = Bridge.Database.Query([[ SELECT `imei`, `device_name`, `created_at`, `updated_at` @@ -248,7 +349,7 @@ local function account_devices(account_id, current_imei) end local function bootstrap(source) - local session, error_response = SkyPhone.RequireSession(source) + local session, error_response = SkyPhone.RequireDeviceSession(source) if not session then return nil, error_response end @@ -260,6 +361,7 @@ local function bootstrap(source) return { token = session.token, + security = security_status(device.imei), device = { imei = device.imei, name = device.device_name, @@ -417,7 +519,7 @@ local function authenticate(source, data, registering) return link_account(source, accounts[1]) end -function SkyPhone.RequireSession(source) +function SkyPhone.RequireDeviceSession(source) local session = sessions[source] if not session then return nil, { success = false, error = "device_not_open" } @@ -433,6 +535,17 @@ function SkyPhone.RequireSession(source) return session end +function SkyPhone.RequireSession(source) + local session, error_response = SkyPhone.RequireDeviceSession(source) + if not session then + return nil, error_response + end + if not session.unlocked then + return nil, { success = false, error = "device_locked" } + end + return session +end + function SkyPhone.AllowOperation(source, operation, maximum, window_seconds) local now = os.time() operation_attempts[source] = operation_attempts[source] or {} @@ -565,10 +678,12 @@ local function open_phone(source, used_item) return false end + local security = load_device_security(imei) sessions[source] = { imei = imei, slot = slot.slot, token = ("%s:%s:%s"):format(imei, tostring(source), tostring(GetGameTimer())), + unlocked = security == nil, } local payload = bootstrap(source) Bridge.Debug( @@ -590,10 +705,12 @@ function SkyPhone.OpenDeviceForCall(source, imei) Bridge.Debug("warn", "[sky_phone] Could not open ringing device %s for source %s.", tostring(imei), tostring(source)) return false end + local security = load_device_security(imei) sessions[source] = { imei = imei, slot = matches[1].slot, token = ("%s:%s:%s"):format(imei, tostring(source), tostring(GetGameTimer())), + unlocked = security == nil, } TriggerClientEvent("sky_phone:device:open", source, bootstrap(source)) return true @@ -619,6 +736,106 @@ Bridge.Callbacks.Register("sky_phone:device:close", function(source) return { success = true } 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 = security_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 = security_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 = security_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 = security_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 = security_status(session.imei) } } +end) + Bridge.Callbacks.Register("sky_phone:device:development-open", function(source) if not Config.Phone.DevelopmentCommand then return { success = false, error = "disabled" } @@ -764,6 +981,10 @@ Bridge.Callbacks.Register("sky_phone:device:factory-reset", function(source) end local media_remote_ids = SkyPhoneMedia.GetDeviceRemoteIds(session.imei) if not Bridge.Database.Transaction({ + { + query = "DELETE FROM `sky_phone_device_security` WHERE `device_imei` = ?", + params = { session.imei }, + }, { query = "DELETE FROM `sky_phone_device_data` WHERE `device_imei` = ?", params = { session.imei }, @@ -792,6 +1013,7 @@ Bridge.Callbacks.Register("sky_phone:device:factory-reset", function(source) return { success = false, error = "request_failed" } end SkyPhoneMedia.CleanupRemoteFiles(media_remote_ids) + session.unlocked = true refresh_source(source) return { success = true } end) diff --git a/sky_phone/sql/install.sql b/sky_phone/sql/install.sql index fdb12b0..859a10d 100644 --- a/sky_phone/sql/install.sql +++ b/sky_phone/sql/install.sql @@ -92,6 +92,18 @@ CREATE TABLE IF NOT EXISTS `sky_phone_device_data` ( FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS `sky_phone_device_security` ( + `device_imei` CHAR(15) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `passcode_hash` BINARY(32) NOT NULL, + `passcode_salt` CHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, + `passcode_length` TINYINT UNSIGNED NOT NULL, + `failed_attempts` TINYINT UNSIGNED NOT NULL DEFAULT 0, + `locked_until` BIGINT UNSIGNED NOT NULL DEFAULT 0, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`device_imei`), + FOREIGN KEY (`device_imei`) REFERENCES `sky_phone_devices` (`imei`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS `sky_phone_notes` ( `id` VARCHAR(64) NOT NULL, `account_id` BIGINT UNSIGNED NULL,