@oxy.so/core 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +16 -0
- package/README.md +221 -0
- package/dist/cjs/.tsbuildinfo +1 -0
- package/dist/cjs/HttpService.js +1270 -0
- package/dist/cjs/OxyServices.base.js +390 -0
- package/dist/cjs/OxyServices.errors.js +95 -0
- package/dist/cjs/OxyServices.js +72 -0
- package/dist/cjs/boot/sessionColdBoot.js +395 -0
- package/dist/cjs/constants/version.js +16 -0
- package/dist/cjs/crypto/aead.js +79 -0
- package/dist/cjs/crypto/ecdh.js +35 -0
- package/dist/cjs/crypto/identityMarker.js +255 -0
- package/dist/cjs/crypto/kdf.js +39 -0
- package/dist/cjs/crypto/keyManager.js +2234 -0
- package/dist/cjs/crypto/polyfill.js +131 -0
- package/dist/cjs/crypto/recoveryPhrase.js +242 -0
- package/dist/cjs/crypto/signatureService.js +273 -0
- package/dist/cjs/i18n/accountCategoryLabels.js +44 -0
- package/dist/cjs/i18n/accountRoleLabels.js +27 -0
- package/dist/cjs/i18n/index.js +90 -0
- package/dist/cjs/i18n/locales/ar-SA.json +282 -0
- package/dist/cjs/i18n/locales/ca-ES.json +282 -0
- package/dist/cjs/i18n/locales/de-DE.json +282 -0
- package/dist/cjs/i18n/locales/en-US.json +2161 -0
- package/dist/cjs/i18n/locales/es-ES.json +2161 -0
- package/dist/cjs/i18n/locales/fr-FR.json +282 -0
- package/dist/cjs/i18n/locales/it-IT.json +282 -0
- package/dist/cjs/i18n/locales/ja-JP.json +282 -0
- package/dist/cjs/i18n/locales/ko-KR.json +282 -0
- package/dist/cjs/i18n/locales/locales/ar-SA.json +282 -0
- package/dist/cjs/i18n/locales/locales/ca-ES.json +282 -0
- package/dist/cjs/i18n/locales/locales/de-DE.json +282 -0
- package/dist/cjs/i18n/locales/locales/en-US.json +2161 -0
- package/dist/cjs/i18n/locales/locales/es-ES.json +2161 -0
- package/dist/cjs/i18n/locales/locales/fr-FR.json +282 -0
- package/dist/cjs/i18n/locales/locales/it-IT.json +282 -0
- package/dist/cjs/i18n/locales/locales/ja-JP.json +282 -0
- package/dist/cjs/i18n/locales/locales/ko-KR.json +282 -0
- package/dist/cjs/i18n/locales/locales/pt-PT.json +282 -0
- package/dist/cjs/i18n/locales/locales/zh-CN.json +282 -0
- package/dist/cjs/i18n/locales/pt-PT.json +282 -0
- package/dist/cjs/i18n/locales/zh-CN.json +282 -0
- package/dist/cjs/i18n/reputationCategoryLabels.js +20 -0
- package/dist/cjs/i18n/trustTierLabels.js +19 -0
- package/dist/cjs/index.js +498 -0
- package/dist/cjs/inference/OxyInferenceClient.js +544 -0
- package/dist/cjs/logger/index.js +149 -0
- package/dist/cjs/mixins/OxyServices.accounts.js +555 -0
- package/dist/cjs/mixins/OxyServices.analytics.js +49 -0
- package/dist/cjs/mixins/OxyServices.appData.js +108 -0
- package/dist/cjs/mixins/OxyServices.assets.js +764 -0
- package/dist/cjs/mixins/OxyServices.auth.js +1254 -0
- package/dist/cjs/mixins/OxyServices.chains.js +73 -0
- package/dist/cjs/mixins/OxyServices.civic.js +611 -0
- package/dist/cjs/mixins/OxyServices.connectedApps.js +78 -0
- package/dist/cjs/mixins/OxyServices.contacts.js +50 -0
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +191 -0
- package/dist/cjs/mixins/OxyServices.deviceTransfer.js +314 -0
- package/dist/cjs/mixins/OxyServices.devices.js +116 -0
- package/dist/cjs/mixins/OxyServices.features.js +335 -0
- package/dist/cjs/mixins/OxyServices.followGraph.js +221 -0
- package/dist/cjs/mixins/OxyServices.identity.js +461 -0
- package/dist/cjs/mixins/OxyServices.identityBackup.js +172 -0
- package/dist/cjs/mixins/OxyServices.inference.js +59 -0
- package/dist/cjs/mixins/OxyServices.language.js +125 -0
- package/dist/cjs/mixins/OxyServices.links.js +72 -0
- package/dist/cjs/mixins/OxyServices.location.js +43 -0
- package/dist/cjs/mixins/OxyServices.nodes.js +175 -0
- package/dist/cjs/mixins/OxyServices.notifications.js +63 -0
- package/dist/cjs/mixins/OxyServices.payment.js +158 -0
- package/dist/cjs/mixins/OxyServices.privacy.js +199 -0
- package/dist/cjs/mixins/OxyServices.reputation.js +289 -0
- package/dist/cjs/mixins/OxyServices.security.js +89 -0
- package/dist/cjs/mixins/OxyServices.store.js +266 -0
- package/dist/cjs/mixins/OxyServices.topics.js +137 -0
- package/dist/cjs/mixins/OxyServices.user.js +902 -0
- package/dist/cjs/mixins/OxyServices.utility.js +1098 -0
- package/dist/cjs/mixins/index.js +135 -0
- package/dist/cjs/mixins/mixinHelpers.js +53 -0
- package/dist/cjs/models/Topic.js +16 -0
- package/dist/cjs/models/interfaces.js +20 -0
- package/dist/cjs/models/session.js +2 -0
- package/dist/cjs/server/auth.js +167 -0
- package/dist/cjs/server/capabilityTicket.js +192 -0
- package/dist/cjs/server/cors.js +225 -0
- package/dist/cjs/server/index.js +97 -0
- package/dist/cjs/server/rateLimit.js +207 -0
- package/dist/cjs/server/safeFetch.js +458 -0
- package/dist/cjs/server/securityHeaders.js +377 -0
- package/dist/cjs/server/userInvalidation.js +156 -0
- package/dist/cjs/server/verifySecret.js +50 -0
- package/dist/cjs/session/SessionClient.js +865 -0
- package/dist/cjs/session/accountDelegationAccess.js +48 -0
- package/dist/cjs/session/accountDialogController.js +1131 -0
- package/dist/cjs/session/accountSwitchTargets.js +81 -0
- package/dist/cjs/session/authStateStore.js +404 -0
- package/dist/cjs/session/createSessionClient.js +32 -0
- package/dist/cjs/session/deviceDirectory.js +143 -0
- package/dist/cjs/session/deviceSwitcherRows.js +76 -0
- package/dist/cjs/session/identityPin.js +256 -0
- package/dist/cjs/session/identitySession.js +135 -0
- package/dist/cjs/session/projectSessionState.js +121 -0
- package/dist/cjs/session/refresh.js +398 -0
- package/dist/cjs/session/sessionClientHost.js +34 -0
- package/dist/cjs/session/sharedDeviceCredential.js +247 -0
- package/dist/cjs/session/socketLoader.js +55 -0
- package/dist/cjs/shared/utils/colorUtils.js +153 -0
- package/dist/cjs/shared/utils/errorUtils.js +183 -0
- package/dist/cjs/shared/utils/index.js +49 -0
- package/dist/cjs/shared/utils/networkUtils.js +183 -0
- package/dist/cjs/shared/utils/themeUtils.js +106 -0
- package/dist/cjs/utils/accountCacheSweep.js +80 -0
- package/dist/cjs/utils/accountUtils.js +140 -0
- package/dist/cjs/utils/activityId.js +6 -0
- package/dist/cjs/utils/apiUtils.js +91 -0
- package/dist/cjs/utils/asyncUtils.js +222 -0
- package/dist/cjs/utils/authHelpers.js +120 -0
- package/dist/cjs/utils/authWebUrl.js +19 -0
- package/dist/cjs/utils/avatarUtils.js +29 -0
- package/dist/cjs/utils/cache.js +235 -0
- package/dist/cjs/utils/cacheKey.js +85 -0
- package/dist/cjs/utils/coldBoot.js +122 -0
- package/dist/cjs/utils/commonsApproval.js +31 -0
- package/dist/cjs/utils/commonsDelivery.js +59 -0
- package/dist/cjs/utils/deviceManager.js +174 -0
- package/dist/cjs/utils/displayNamePolicyRanges.generated.js +60 -0
- package/dist/cjs/utils/edgeRegion.js +6 -0
- package/dist/cjs/utils/errorUtils.js +243 -0
- package/dist/cjs/utils/identityCacheSweep.js +97 -0
- package/dist/cjs/utils/languageUtils.js +224 -0
- package/dist/cjs/utils/oauthPkce.js +311 -0
- package/dist/cjs/utils/officialOrigins.js +64 -0
- package/dist/cjs/utils/oxyServiceEnvironment.js +19 -0
- package/dist/cjs/utils/platform.js +119 -0
- package/dist/cjs/utils/profileLinks.js +63 -0
- package/dist/cjs/utils/redactUrl.js +29 -0
- package/dist/cjs/utils/registrableApex.js +45 -0
- package/dist/cjs/utils/requestUtils.js +209 -0
- package/dist/cjs/utils/sessionUtils.js +181 -0
- package/dist/cjs/utils/textNormalization.js +158 -0
- package/dist/cjs/utils/userHandle.js +37 -0
- package/dist/cjs/utils/userIdentity.js +50 -0
- package/dist/cjs/utils/validationUtils.js +370 -0
- package/dist/cjs/utils/webauthnOrigin.js +51 -0
- package/dist/esm/.tsbuildinfo +1 -0
- package/dist/esm/HttpService.js +1266 -0
- package/dist/esm/OxyServices.base.js +386 -0
- package/dist/esm/OxyServices.errors.js +88 -0
- package/dist/esm/OxyServices.js +66 -0
- package/dist/esm/boot/sessionColdBoot.js +392 -0
- package/dist/esm/constants/version.js +13 -0
- package/dist/esm/crypto/aead.js +74 -0
- package/dist/esm/crypto/ecdh.js +32 -0
- package/dist/esm/crypto/identityMarker.js +248 -0
- package/dist/esm/crypto/kdf.js +36 -0
- package/dist/esm/crypto/keyManager.js +2227 -0
- package/dist/esm/crypto/polyfill.js +129 -0
- package/dist/esm/crypto/recoveryPhrase.js +238 -0
- package/dist/esm/crypto/signatureService.js +269 -0
- package/dist/esm/i18n/accountCategoryLabels.js +37 -0
- package/dist/esm/i18n/accountRoleLabels.js +20 -0
- package/dist/esm/i18n/index.js +83 -0
- package/dist/esm/i18n/locales/ar-SA.json +282 -0
- package/dist/esm/i18n/locales/ca-ES.json +282 -0
- package/dist/esm/i18n/locales/de-DE.json +282 -0
- package/dist/esm/i18n/locales/en-US.json +2161 -0
- package/dist/esm/i18n/locales/es-ES.json +2161 -0
- package/dist/esm/i18n/locales/fr-FR.json +282 -0
- package/dist/esm/i18n/locales/it-IT.json +282 -0
- package/dist/esm/i18n/locales/ja-JP.json +282 -0
- package/dist/esm/i18n/locales/ko-KR.json +282 -0
- package/dist/esm/i18n/locales/locales/ar-SA.json +282 -0
- package/dist/esm/i18n/locales/locales/ca-ES.json +282 -0
- package/dist/esm/i18n/locales/locales/de-DE.json +282 -0
- package/dist/esm/i18n/locales/locales/en-US.json +2161 -0
- package/dist/esm/i18n/locales/locales/es-ES.json +2161 -0
- package/dist/esm/i18n/locales/locales/fr-FR.json +282 -0
- package/dist/esm/i18n/locales/locales/it-IT.json +282 -0
- package/dist/esm/i18n/locales/locales/ja-JP.json +282 -0
- package/dist/esm/i18n/locales/locales/ko-KR.json +282 -0
- package/dist/esm/i18n/locales/locales/pt-PT.json +282 -0
- package/dist/esm/i18n/locales/locales/zh-CN.json +282 -0
- package/dist/esm/i18n/locales/pt-PT.json +282 -0
- package/dist/esm/i18n/locales/zh-CN.json +282 -0
- package/dist/esm/i18n/reputationCategoryLabels.js +13 -0
- package/dist/esm/i18n/trustTierLabels.js +12 -0
- package/dist/esm/index.js +254 -0
- package/dist/esm/inference/OxyInferenceClient.js +538 -0
- package/dist/esm/logger/index.js +140 -0
- package/dist/esm/mixins/OxyServices.accounts.js +543 -0
- package/dist/esm/mixins/OxyServices.analytics.js +46 -0
- package/dist/esm/mixins/OxyServices.appData.js +103 -0
- package/dist/esm/mixins/OxyServices.assets.js +761 -0
- package/dist/esm/mixins/OxyServices.auth.js +1247 -0
- package/dist/esm/mixins/OxyServices.chains.js +70 -0
- package/dist/esm/mixins/OxyServices.civic.js +605 -0
- package/dist/esm/mixins/OxyServices.connectedApps.js +75 -0
- package/dist/esm/mixins/OxyServices.contacts.js +47 -0
- package/dist/esm/mixins/OxyServices.deviceBoot.js +186 -0
- package/dist/esm/mixins/OxyServices.deviceTransfer.js +311 -0
- package/dist/esm/mixins/OxyServices.devices.js +113 -0
- package/dist/esm/mixins/OxyServices.features.js +332 -0
- package/dist/esm/mixins/OxyServices.followGraph.js +218 -0
- package/dist/esm/mixins/OxyServices.identity.js +457 -0
- package/dist/esm/mixins/OxyServices.identityBackup.js +169 -0
- package/dist/esm/mixins/OxyServices.inference.js +56 -0
- package/dist/esm/mixins/OxyServices.language.js +122 -0
- package/dist/esm/mixins/OxyServices.links.js +69 -0
- package/dist/esm/mixins/OxyServices.location.js +40 -0
- package/dist/esm/mixins/OxyServices.nodes.js +172 -0
- package/dist/esm/mixins/OxyServices.notifications.js +60 -0
- package/dist/esm/mixins/OxyServices.payment.js +155 -0
- package/dist/esm/mixins/OxyServices.privacy.js +196 -0
- package/dist/esm/mixins/OxyServices.reputation.js +286 -0
- package/dist/esm/mixins/OxyServices.security.js +86 -0
- package/dist/esm/mixins/OxyServices.store.js +263 -0
- package/dist/esm/mixins/OxyServices.topics.js +134 -0
- package/dist/esm/mixins/OxyServices.user.js +899 -0
- package/dist/esm/mixins/OxyServices.utility.js +1095 -0
- package/dist/esm/mixins/index.js +132 -0
- package/dist/esm/mixins/mixinHelpers.js +48 -0
- package/dist/esm/models/Topic.js +13 -0
- package/dist/esm/models/interfaces.js +17 -0
- package/dist/esm/models/session.js +1 -0
- package/dist/esm/server/auth.js +154 -0
- package/dist/esm/server/capabilityTicket.js +183 -0
- package/dist/esm/server/cors.js +220 -0
- package/dist/esm/server/index.js +47 -0
- package/dist/esm/server/rateLimit.js +201 -0
- package/dist/esm/server/safeFetch.js +447 -0
- package/dist/esm/server/securityHeaders.js +364 -0
- package/dist/esm/server/userInvalidation.js +152 -0
- package/dist/esm/server/verifySecret.js +47 -0
- package/dist/esm/session/SessionClient.js +861 -0
- package/dist/esm/session/accountDelegationAccess.js +45 -0
- package/dist/esm/session/accountDialogController.js +1126 -0
- package/dist/esm/session/accountSwitchTargets.js +77 -0
- package/dist/esm/session/authStateStore.js +398 -0
- package/dist/esm/session/createSessionClient.js +29 -0
- package/dist/esm/session/deviceDirectory.js +135 -0
- package/dist/esm/session/deviceSwitcherRows.js +72 -0
- package/dist/esm/session/identityPin.js +249 -0
- package/dist/esm/session/identitySession.js +131 -0
- package/dist/esm/session/projectSessionState.js +114 -0
- package/dist/esm/session/refresh.js +390 -0
- package/dist/esm/session/sessionClientHost.js +31 -0
- package/dist/esm/session/sharedDeviceCredential.js +239 -0
- package/dist/esm/session/socketLoader.js +19 -0
- package/dist/esm/shared/utils/colorUtils.js +143 -0
- package/dist/esm/shared/utils/errorUtils.js +170 -0
- package/dist/esm/shared/utils/index.js +15 -0
- package/dist/esm/shared/utils/networkUtils.js +173 -0
- package/dist/esm/shared/utils/themeUtils.js +98 -0
- package/dist/esm/utils/accountCacheSweep.js +75 -0
- package/dist/esm/utils/accountUtils.js +131 -0
- package/dist/esm/utils/activityId.js +2 -0
- package/dist/esm/utils/apiUtils.js +84 -0
- package/dist/esm/utils/asyncUtils.js +207 -0
- package/dist/esm/utils/authHelpers.js +111 -0
- package/dist/esm/utils/authWebUrl.js +16 -0
- package/dist/esm/utils/avatarUtils.js +26 -0
- package/dist/esm/utils/cache.js +227 -0
- package/dist/esm/utils/cacheKey.js +80 -0
- package/dist/esm/utils/coldBoot.js +119 -0
- package/dist/esm/utils/commonsApproval.js +27 -0
- package/dist/esm/utils/commonsDelivery.js +54 -0
- package/dist/esm/utils/deviceManager.js +170 -0
- package/dist/esm/utils/displayNamePolicyRanges.generated.js +57 -0
- package/dist/esm/utils/edgeRegion.js +2 -0
- package/dist/esm/utils/errorUtils.js +232 -0
- package/dist/esm/utils/identityCacheSweep.js +92 -0
- package/dist/esm/utils/languageUtils.js +212 -0
- package/dist/esm/utils/oauthPkce.js +297 -0
- package/dist/esm/utils/officialOrigins.js +59 -0
- package/dist/esm/utils/oxyServiceEnvironment.js +16 -0
- package/dist/esm/utils/platform.js +110 -0
- package/dist/esm/utils/profileLinks.js +60 -0
- package/dist/esm/utils/redactUrl.js +26 -0
- package/dist/esm/utils/registrableApex.js +42 -0
- package/dist/esm/utils/requestUtils.js +203 -0
- package/dist/esm/utils/sessionUtils.js +171 -0
- package/dist/esm/utils/textNormalization.js +154 -0
- package/dist/esm/utils/userHandle.js +33 -0
- package/dist/esm/utils/userIdentity.js +45 -0
- package/dist/esm/utils/validationUtils.js +350 -0
- package/dist/esm/utils/webauthnOrigin.js +48 -0
- package/dist/types/.tsbuildinfo +1 -0
- package/dist/types/HttpService.d.ts +413 -0
- package/dist/types/OxyServices.base.d.ts +187 -0
- package/dist/types/OxyServices.d.ts +139 -0
- package/dist/types/OxyServices.errors.d.ts +84 -0
- package/dist/types/boot/sessionColdBoot.d.ts +140 -0
- package/dist/types/constants/version.d.ts +13 -0
- package/dist/types/crypto/aead.d.ts +56 -0
- package/dist/types/crypto/ecdh.d.ts +29 -0
- package/dist/types/crypto/identityMarker.d.ts +94 -0
- package/dist/types/crypto/kdf.d.ts +25 -0
- package/dist/types/crypto/keyManager.d.ts +582 -0
- package/dist/types/crypto/polyfill.d.ts +25 -0
- package/dist/types/crypto/recoveryPhrase.d.ts +173 -0
- package/dist/types/crypto/signatureService.d.ts +136 -0
- package/dist/types/i18n/accountCategoryLabels.d.ts +34 -0
- package/dist/types/i18n/accountRoleLabels.d.ts +10 -0
- package/dist/types/i18n/index.d.ts +3 -0
- package/dist/types/i18n/reputationCategoryLabels.d.ts +10 -0
- package/dist/types/i18n/trustTierLabels.d.ts +9 -0
- package/dist/types/index.d.ts +145 -0
- package/dist/types/inference/OxyInferenceClient.d.ts +342 -0
- package/dist/types/logger/index.d.ts +104 -0
- package/dist/types/mixins/OxyServices.accounts.d.ts +868 -0
- package/dist/types/mixins/OxyServices.analytics.d.ts +72 -0
- package/dist/types/mixins/OxyServices.appData.d.ts +108 -0
- package/dist/types/mixins/OxyServices.assets.d.ts +329 -0
- package/dist/types/mixins/OxyServices.auth.d.ts +908 -0
- package/dist/types/mixins/OxyServices.chains.d.ts +156 -0
- package/dist/types/mixins/OxyServices.civic.d.ts +510 -0
- package/dist/types/mixins/OxyServices.connectedApps.d.ts +169 -0
- package/dist/types/mixins/OxyServices.contacts.d.ts +100 -0
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +185 -0
- package/dist/types/mixins/OxyServices.deviceTransfer.d.ts +149 -0
- package/dist/types/mixins/OxyServices.devices.d.ts +103 -0
- package/dist/types/mixins/OxyServices.features.d.ts +250 -0
- package/dist/types/mixins/OxyServices.followGraph.d.ts +223 -0
- package/dist/types/mixins/OxyServices.identity.d.ts +351 -0
- package/dist/types/mixins/OxyServices.identityBackup.d.ts +129 -0
- package/dist/types/mixins/OxyServices.inference.d.ts +95 -0
- package/dist/types/mixins/OxyServices.language.d.ts +87 -0
- package/dist/types/mixins/OxyServices.links.d.ts +104 -0
- package/dist/types/mixins/OxyServices.location.d.ts +70 -0
- package/dist/types/mixins/OxyServices.nodes.d.ts +198 -0
- package/dist/types/mixins/OxyServices.notifications.d.ts +143 -0
- package/dist/types/mixins/OxyServices.payment.d.ts +117 -0
- package/dist/types/mixins/OxyServices.privacy.d.ts +146 -0
- package/dist/types/mixins/OxyServices.reputation.d.ts +202 -0
- package/dist/types/mixins/OxyServices.security.d.ts +84 -0
- package/dist/types/mixins/OxyServices.store.d.ts +334 -0
- package/dist/types/mixins/OxyServices.topics.d.ts +110 -0
- package/dist/types/mixins/OxyServices.user.d.ts +558 -0
- package/dist/types/mixins/OxyServices.utility.d.ts +393 -0
- package/dist/types/mixins/index.d.ts +87 -0
- package/dist/types/mixins/mixinHelpers.d.ts +31 -0
- package/dist/types/models/Topic.d.ts +42 -0
- package/dist/types/models/interfaces.d.ts +675 -0
- package/dist/types/models/session.d.ts +51 -0
- package/dist/types/server/auth.d.ts +130 -0
- package/dist/types/server/capabilityTicket.d.ts +41 -0
- package/dist/types/server/cors.d.ts +98 -0
- package/dist/types/server/index.d.ts +35 -0
- package/dist/types/server/rateLimit.d.ts +40 -0
- package/dist/types/server/safeFetch.d.ts +137 -0
- package/dist/types/server/securityHeaders.d.ts +221 -0
- package/dist/types/server/userInvalidation.d.ts +114 -0
- package/dist/types/server/verifySecret.d.ts +29 -0
- package/dist/types/session/SessionClient.d.ts +418 -0
- package/dist/types/session/accountDelegationAccess.d.ts +40 -0
- package/dist/types/session/accountDialogController.d.ts +648 -0
- package/dist/types/session/accountSwitchTargets.d.ts +70 -0
- package/dist/types/session/authStateStore.d.ts +153 -0
- package/dist/types/session/createSessionClient.d.ts +30 -0
- package/dist/types/session/deviceDirectory.d.ts +182 -0
- package/dist/types/session/deviceSwitcherRows.d.ts +92 -0
- package/dist/types/session/identityPin.d.ts +87 -0
- package/dist/types/session/identitySession.d.ts +105 -0
- package/dist/types/session/projectSessionState.d.ts +74 -0
- package/dist/types/session/refresh.d.ts +179 -0
- package/dist/types/session/sessionClientHost.d.ts +18 -0
- package/dist/types/session/sharedDeviceCredential.d.ts +202 -0
- package/dist/types/session/socketLoader.d.ts +11 -0
- package/dist/types/shared/utils/colorUtils.d.ts +104 -0
- package/dist/types/shared/utils/errorUtils.d.ts +97 -0
- package/dist/types/shared/utils/index.d.ts +13 -0
- package/dist/types/shared/utils/networkUtils.d.ts +139 -0
- package/dist/types/shared/utils/themeUtils.d.ts +90 -0
- package/dist/types/utils/accountCacheSweep.d.ts +75 -0
- package/dist/types/utils/accountUtils.d.ts +100 -0
- package/dist/types/utils/activityId.d.ts +2 -0
- package/dist/types/utils/apiUtils.d.ts +95 -0
- package/dist/types/utils/asyncUtils.d.ts +62 -0
- package/dist/types/utils/authHelpers.d.ts +58 -0
- package/dist/types/utils/authWebUrl.d.ts +16 -0
- package/dist/types/utils/avatarUtils.d.ts +16 -0
- package/dist/types/utils/cache.d.ts +130 -0
- package/dist/types/utils/cacheKey.d.ts +66 -0
- package/dist/types/utils/coldBoot.d.ts +131 -0
- package/dist/types/utils/commonsApproval.d.ts +13 -0
- package/dist/types/utils/commonsDelivery.d.ts +105 -0
- package/dist/types/utils/deviceManager.d.ts +65 -0
- package/dist/types/utils/displayNamePolicyRanges.generated.d.ts +57 -0
- package/dist/types/utils/edgeRegion.d.ts +2 -0
- package/dist/types/utils/errorUtils.d.ts +125 -0
- package/dist/types/utils/identityCacheSweep.d.ts +80 -0
- package/dist/types/utils/languageUtils.d.ts +100 -0
- package/dist/types/utils/oauthPkce.d.ts +157 -0
- package/dist/types/utils/officialOrigins.d.ts +13 -0
- package/dist/types/utils/oxyServiceEnvironment.d.ts +17 -0
- package/dist/types/utils/platform.d.ts +49 -0
- package/dist/types/utils/profileLinks.d.ts +44 -0
- package/dist/types/utils/redactUrl.d.ts +17 -0
- package/dist/types/utils/registrableApex.d.ts +27 -0
- package/dist/types/utils/requestUtils.d.ts +123 -0
- package/dist/types/utils/sessionUtils.d.ts +54 -0
- package/dist/types/utils/textNormalization.d.ts +103 -0
- package/dist/types/utils/userHandle.d.ts +26 -0
- package/dist/types/utils/userIdentity.d.ts +21 -0
- package/dist/types/utils/validationUtils.d.ts +268 -0
- package/dist/types/utils/webauthnOrigin.d.ts +32 -0
- package/package.json +168 -0
- package/src/HttpService.ts +1553 -0
- package/src/OxyServices.base.ts +478 -0
- package/src/OxyServices.errors.ts +122 -0
- package/src/OxyServices.ts +172 -0
- package/src/__tests__/authSocket.test.ts +96 -0
- package/src/__tests__/httpServiceActivityId.test.ts +49 -0
- package/src/__tests__/httpServiceAuthSelfAwait.test.ts +108 -0
- package/src/__tests__/httpServiceCache.test.ts +318 -0
- package/src/__tests__/httpServiceCsrf.test.ts +235 -0
- package/src/__tests__/httpServiceFormEncoded.test.ts +142 -0
- package/src/__tests__/httpServiceQueueDeadlock.test.ts +128 -0
- package/src/__tests__/httpServiceUnwrapEnvelope.test.ts +115 -0
- package/src/__tests__/inSessionRefresh.test.ts +260 -0
- package/src/__tests__/linkedClient.test.ts +370 -0
- package/src/__tests__/parseHttpErrorBody.test.ts +116 -0
- package/src/__tests__/serverValueImportsDeclared.test.ts +120 -0
- package/src/__tests__/userIdentity.test.ts +143 -0
- package/src/boot/__tests__/sessionColdBoot.identity.test.ts +393 -0
- package/src/boot/__tests__/sessionColdBoot.sharedDevice.test.ts +325 -0
- package/src/boot/__tests__/sessionColdBoot.test.ts +570 -0
- package/src/boot/sessionColdBoot.ts +529 -0
- package/src/constants/version.ts +15 -0
- package/src/crypto/__tests__/backupMaterial.test.ts +86 -0
- package/src/crypto/__tests__/cryptoPrimitives.test.ts +226 -0
- package/src/crypto/__tests__/identityMocks.ts +125 -0
- package/src/crypto/__tests__/keyManager.atomicity.test.ts +272 -0
- package/src/crypto/__tests__/keyManager.cacheSafety.test.ts +175 -0
- package/src/crypto/__tests__/keyManager.identityStatus.test.ts +217 -0
- package/src/crypto/__tests__/keyManager.recoveryLadder.test.ts +179 -0
- package/src/crypto/__tests__/keyManager.recoveryMnemonic.test.ts +138 -0
- package/src/crypto/__tests__/keyManager.storageMigration.test.ts +227 -0
- package/src/crypto/__tests__/keyManager.test.ts +329 -0
- package/src/crypto/__tests__/polyfill.test.ts +109 -0
- package/src/crypto/__tests__/recoveryPhrase.test.ts +61 -0
- package/src/crypto/__tests__/scopedSeed.test.ts +126 -0
- package/src/crypto/__tests__/signChallengeShared.test.ts +63 -0
- package/src/crypto/__tests__/signedRecord.test.ts +230 -0
- package/src/crypto/aead.ts +97 -0
- package/src/crypto/ecdh.ts +37 -0
- package/src/crypto/identityMarker.ts +291 -0
- package/src/crypto/kdf.ts +43 -0
- package/src/crypto/keyManager.ts +2607 -0
- package/src/crypto/polyfill.ts +143 -0
- package/src/crypto/recoveryPhrase.ts +337 -0
- package/src/crypto/signatureService.ts +350 -0
- package/src/i18n/__tests__/accountCategoryLabels.test.ts +62 -0
- package/src/i18n/__tests__/accountRoleLabels.test.ts +54 -0
- package/src/i18n/__tests__/reputationCategoryLabels.test.ts +56 -0
- package/src/i18n/__tests__/trustTierLabels.test.ts +47 -0
- package/src/i18n/accountCategoryLabels.ts +44 -0
- package/src/i18n/accountRoleLabels.ts +26 -0
- package/src/i18n/index.ts +88 -0
- package/src/i18n/locales/ar-SA.json +282 -0
- package/src/i18n/locales/ca-ES.json +282 -0
- package/src/i18n/locales/de-DE.json +282 -0
- package/src/i18n/locales/en-US.json +2161 -0
- package/src/i18n/locales/es-ES.json +2161 -0
- package/src/i18n/locales/fr-FR.json +282 -0
- package/src/i18n/locales/it-IT.json +282 -0
- package/src/i18n/locales/ja-JP.json +282 -0
- package/src/i18n/locales/ko-KR.json +282 -0
- package/src/i18n/locales/pt-PT.json +282 -0
- package/src/i18n/locales/zh-CN.json +282 -0
- package/src/i18n/reputationCategoryLabels.ts +20 -0
- package/src/i18n/trustTierLabels.ts +18 -0
- package/src/index.ts +881 -0
- package/src/inference/OxyInferenceClient.ts +888 -0
- package/src/inference/__tests__/OxyInferenceClient.test.ts +834 -0
- package/src/logger/__tests__/logger.test.ts +207 -0
- package/src/logger/index.ts +217 -0
- package/src/mixins/OxyServices.accounts.ts +1330 -0
- package/src/mixins/OxyServices.analytics.ts +53 -0
- package/src/mixins/OxyServices.appData.ts +158 -0
- package/src/mixins/OxyServices.assets.ts +861 -0
- package/src/mixins/OxyServices.auth.ts +1858 -0
- package/src/mixins/OxyServices.chains.ts +134 -0
- package/src/mixins/OxyServices.civic.ts +945 -0
- package/src/mixins/OxyServices.connectedApps.ts +174 -0
- package/src/mixins/OxyServices.contacts.ts +73 -0
- package/src/mixins/OxyServices.deviceBoot.ts +210 -0
- package/src/mixins/OxyServices.deviceTransfer.ts +393 -0
- package/src/mixins/OxyServices.devices.ts +120 -0
- package/src/mixins/OxyServices.features.ts +454 -0
- package/src/mixins/OxyServices.followGraph.ts +293 -0
- package/src/mixins/OxyServices.identity.ts +705 -0
- package/src/mixins/OxyServices.identityBackup.ts +250 -0
- package/src/mixins/OxyServices.inference.ts +57 -0
- package/src/mixins/OxyServices.language.ts +144 -0
- package/src/mixins/OxyServices.links.ts +107 -0
- package/src/mixins/OxyServices.location.ts +46 -0
- package/src/mixins/OxyServices.nodes.ts +337 -0
- package/src/mixins/OxyServices.notifications.ts +142 -0
- package/src/mixins/OxyServices.payment.ts +163 -0
- package/src/mixins/OxyServices.privacy.ts +219 -0
- package/src/mixins/OxyServices.reputation.ts +436 -0
- package/src/mixins/OxyServices.security.ts +110 -0
- package/src/mixins/OxyServices.store.ts +585 -0
- package/src/mixins/OxyServices.topics.ts +169 -0
- package/src/mixins/OxyServices.user.ts +1137 -0
- package/src/mixins/OxyServices.utility.ts +1370 -0
- package/src/mixins/__tests__/OxyServices.civic.test.ts +1095 -0
- package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +124 -0
- package/src/mixins/__tests__/OxyServices.deviceTransfer.test.ts +271 -0
- package/src/mixins/__tests__/OxyServices.identity.test.ts +376 -0
- package/src/mixins/__tests__/OxyServices.links.test.ts +154 -0
- package/src/mixins/__tests__/OxyServices.nodes.test.ts +341 -0
- package/src/mixins/__tests__/OxyServices.rotateKey.test.ts +276 -0
- package/src/mixins/__tests__/OxyServices.serviceAssetMetadata.test.ts +192 -0
- package/src/mixins/__tests__/OxyServices.serviceAssetMetadataBySha.test.ts +135 -0
- package/src/mixins/__tests__/accounts.test.ts +727 -0
- package/src/mixins/__tests__/appData.test.ts +203 -0
- package/src/mixins/__tests__/assetCredentials.test.ts +47 -0
- package/src/mixins/__tests__/assetUpload.test.ts +191 -0
- package/src/mixins/__tests__/backgroundCredential.httpIntegration.test.ts +135 -0
- package/src/mixins/__tests__/chains.test.ts +113 -0
- package/src/mixins/__tests__/commonsSignIn.test.ts +1144 -0
- package/src/mixins/__tests__/connectedApps.test.ts +123 -0
- package/src/mixins/__tests__/deviceTokenMintPinned.test.ts +113 -0
- package/src/mixins/__tests__/discoveryErrorHandling.test.ts +266 -0
- package/src/mixins/__tests__/followCacheInvalidation.test.ts +173 -0
- package/src/mixins/__tests__/followGraph.test.ts +147 -0
- package/src/mixins/__tests__/followGraphPagination.test.ts +271 -0
- package/src/mixins/__tests__/getFileDownloadUrl.test.ts +346 -0
- package/src/mixins/__tests__/getFollowStatuses.test.ts +95 -0
- package/src/mixins/__tests__/getUsersByIds.test.ts +149 -0
- package/src/mixins/__tests__/identityBackup.test.ts +315 -0
- package/src/mixins/__tests__/identityWriteCacheInvalidation.test.ts +407 -0
- package/src/mixins/__tests__/inferenceFactory.test.ts +58 -0
- package/src/mixins/__tests__/onTokensChanged.test.ts +129 -0
- package/src/mixins/__tests__/preSessionSkipAuth.test.ts +284 -0
- package/src/mixins/__tests__/privacyCacheInvalidation.test.ts +155 -0
- package/src/mixins/__tests__/reputation.test.ts +522 -0
- package/src/mixins/__tests__/serviceAuth.test.ts +832 -0
- package/src/mixins/__tests__/serviceTokenJwks.test.ts +208 -0
- package/src/mixins/__tests__/store.test.ts +304 -0
- package/src/mixins/__tests__/topics.test.ts +156 -0
- package/src/mixins/__tests__/userReadCacheBypass.test.ts +129 -0
- package/src/mixins/__tests__/userTokenAuth.test.ts +746 -0
- package/src/mixins/__tests__/verifyChallenge.test.ts +130 -0
- package/src/mixins/__tests__/verifyServiceActingAs.test.ts +167 -0
- package/src/mixins/__tests__/webauthnAuth.test.ts +205 -0
- package/src/mixins/index.ts +201 -0
- package/src/mixins/mixinHelpers.ts +69 -0
- package/src/models/Topic.ts +46 -0
- package/src/models/interfaces.ts +786 -0
- package/src/models/session.ts +55 -0
- package/src/server/__tests__/auth.test.ts +78 -0
- package/src/server/__tests__/capabilityTicket.test.ts +111 -0
- package/src/server/__tests__/cors.socket.test.ts +225 -0
- package/src/server/__tests__/cors.test.ts +158 -0
- package/src/server/__tests__/rateLimit.test.ts +257 -0
- package/src/server/__tests__/safeFetch.test.ts +232 -0
- package/src/server/__tests__/securityHeaders.test.ts +348 -0
- package/src/server/__tests__/serviceTokenAttribution.test.ts +396 -0
- package/src/server/__tests__/userInvalidation.test.ts +203 -0
- package/src/server/__tests__/verifySecret.test.ts +40 -0
- package/src/server/auth.ts +271 -0
- package/src/server/capabilityTicket.ts +258 -0
- package/src/server/cors.ts +277 -0
- package/src/server/index.ts +136 -0
- package/src/server/rateLimit.ts +313 -0
- package/src/server/safeFetch.ts +586 -0
- package/src/server/securityHeaders.ts +467 -0
- package/src/server/userInvalidation.ts +199 -0
- package/src/server/verifySecret.ts +52 -0
- package/src/session/SessionClient.ts +1001 -0
- package/src/session/__tests__/SessionClient.additive.test.ts +153 -0
- package/src/session/__tests__/SessionClient.broadcastChannel.test.ts +114 -0
- package/src/session/__tests__/SessionClient.diagnostics.test.ts +91 -0
- package/src/session/__tests__/SessionClient.directory.test.ts +688 -0
- package/src/session/__tests__/SessionClient.httpIntegration.test.ts +65 -0
- package/src/session/__tests__/SessionClient.identityPin.test.ts +175 -0
- package/src/session/__tests__/SessionClient.rest.test.ts +119 -0
- package/src/session/__tests__/SessionClient.serverEvents.test.ts +72 -0
- package/src/session/__tests__/SessionClient.socket.test.ts +202 -0
- package/src/session/__tests__/SessionClient.socketFactory.test.ts +81 -0
- package/src/session/__tests__/SessionClient.state.test.ts +78 -0
- package/src/session/__tests__/SessionClient.switchTokenOrder.test.ts +170 -0
- package/src/session/__tests__/accountDelegationAccess.test.ts +104 -0
- package/src/session/__tests__/accountDialogController.test.ts +1969 -0
- package/src/session/__tests__/accountDialogShape.test.ts +118 -0
- package/src/session/__tests__/accountSwitchTargets.test.ts +137 -0
- package/src/session/__tests__/authStateStore.test.ts +451 -0
- package/src/session/__tests__/deviceDirectory.test.ts +422 -0
- package/src/session/__tests__/deviceSwitcherRows.test.ts +223 -0
- package/src/session/__tests__/identityPin.test.ts +131 -0
- package/src/session/__tests__/identitySession.test.ts +194 -0
- package/src/session/__tests__/projectSessionState.test.ts +105 -0
- package/src/session/__tests__/refresh.identityPin.test.ts +307 -0
- package/src/session/__tests__/refresh.test.ts +443 -0
- package/src/session/__tests__/sessionIntegration.test.ts +209 -0
- package/src/session/__tests__/sharedDeviceCredential.test.ts +300 -0
- package/src/session/accountDelegationAccess.ts +72 -0
- package/src/session/accountDialogController.ts +1494 -0
- package/src/session/accountSwitchTargets.ts +93 -0
- package/src/session/authStateStore.ts +502 -0
- package/src/session/createSessionClient.ts +39 -0
- package/src/session/deviceDirectory.ts +269 -0
- package/src/session/deviceSwitcherRows.ts +145 -0
- package/src/session/identityPin.ts +310 -0
- package/src/session/identitySession.ts +206 -0
- package/src/session/projectSessionState.ts +137 -0
- package/src/session/refresh.ts +517 -0
- package/src/session/sessionClientHost.ts +39 -0
- package/src/session/sharedDeviceCredential.ts +349 -0
- package/src/session/socketLoader.ts +30 -0
- package/src/shared/utils/colorUtils.ts +155 -0
- package/src/shared/utils/errorUtils.ts +181 -0
- package/src/shared/utils/index.ts +59 -0
- package/src/shared/utils/networkUtils.ts +248 -0
- package/src/shared/utils/themeUtils.ts +115 -0
- package/src/types/buffer.d.ts +97 -0
- package/src/types/expo-crypto.d.ts +16 -0
- package/src/types/expo-secure-store.d.ts +17 -0
- package/src/utils/__tests__/accountUtils.test.ts +151 -0
- package/src/utils/__tests__/activityId.test.ts +45 -0
- package/src/utils/__tests__/asyncUtils.test.ts +187 -0
- package/src/utils/__tests__/authWebUrl.test.ts +15 -0
- package/src/utils/__tests__/cacheKey.test.ts +0 -0
- package/src/utils/__tests__/coldBoot.test.ts +496 -0
- package/src/utils/__tests__/commonsApproval.test.ts +60 -0
- package/src/utils/__tests__/identityCacheSweep.test.ts +151 -0
- package/src/utils/__tests__/languageUtils.test.ts +219 -0
- package/src/utils/__tests__/oauthPkce.test.ts +272 -0
- package/src/utils/__tests__/officialOrigins.test.ts +31 -0
- package/src/utils/__tests__/oxyServiceEnvironment.test.ts +7 -0
- package/src/utils/__tests__/profileLinks.test.ts +180 -0
- package/src/utils/__tests__/redactUrl.test.ts +33 -0
- package/src/utils/__tests__/registrableApex.test.ts +62 -0
- package/src/utils/__tests__/textNormalization.test.ts +205 -0
- package/src/utils/__tests__/validationUtils.test.ts +703 -0
- package/src/utils/__tests__/webauthnOrigin.test.ts +83 -0
- package/src/utils/accountCacheSweep.ts +93 -0
- package/src/utils/accountUtils.ts +194 -0
- package/src/utils/activityId.ts +2 -0
- package/src/utils/apiUtils.ts +137 -0
- package/src/utils/asyncUtils.ts +280 -0
- package/src/utils/authHelpers.ts +148 -0
- package/src/utils/authWebUrl.ts +17 -0
- package/src/utils/avatarUtils.ts +39 -0
- package/src/utils/cache.ts +273 -0
- package/src/utils/cacheKey.ts +93 -0
- package/src/utils/coldBoot.ts +218 -0
- package/src/utils/commonsApproval.ts +39 -0
- package/src/utils/commonsDelivery.ts +132 -0
- package/src/utils/deviceManager.ts +203 -0
- package/src/utils/displayNamePolicyRanges.generated.ts +69 -0
- package/src/utils/edgeRegion.ts +2 -0
- package/src/utils/errorUtils.ts +352 -0
- package/src/utils/identityCacheSweep.ts +104 -0
- package/src/utils/languageUtils.ts +256 -0
- package/src/utils/oauthPkce.ts +392 -0
- package/src/utils/officialOrigins.ts +62 -0
- package/src/utils/oxyServiceEnvironment.ts +17 -0
- package/src/utils/platform.ts +139 -0
- package/src/utils/profileLinks.ts +91 -0
- package/src/utils/redactUrl.ts +28 -0
- package/src/utils/registrableApex.ts +41 -0
- package/src/utils/requestUtils.ts +237 -0
- package/src/utils/sessionUtils.ts +206 -0
- package/src/utils/textNormalization.ts +162 -0
- package/src/utils/userHandle.ts +49 -0
- package/src/utils/userIdentity.ts +60 -0
- package/src/utils/validationUtils.ts +390 -0
- package/src/utils/webauthnOrigin.ts +52 -0
|
@@ -0,0 +1,2607 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Key Manager - ECDSA secp256k1 Key Generation and Storage
|
|
3
|
+
*
|
|
4
|
+
* Handles secure generation, storage, and retrieval of cryptographic keys.
|
|
5
|
+
* Private keys are stored securely using expo-secure-store and never leave the device.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { isWeb, isIOS, isAndroid } from '../utils/platform';
|
|
9
|
+
import { type ExpoCryptoLike, type ExpoSecureStoreLike, isReactNative, isNodeJS, loadAsyncStorage, loadExpoCrypto, loadNodeCrypto, loadSecureStore, loadSharedIdentityBridge } from '@oxy.so/protocol';
|
|
10
|
+
import {
|
|
11
|
+
deriveSecp256k1PublicKey,
|
|
12
|
+
generateSecp256k1KeyPair,
|
|
13
|
+
isValidSecp256k1PrivateKey,
|
|
14
|
+
isValidSecp256k1PublicKey,
|
|
15
|
+
normalizeSecp256k1PrivateKey,
|
|
16
|
+
normalizeSecp256k1PublicKey,
|
|
17
|
+
signSecp256k1Digest,
|
|
18
|
+
verifySecp256k1Digest,
|
|
19
|
+
} from '@oxy.so/protocol/secp256k1';
|
|
20
|
+
import { isDev, logger } from '../logger';
|
|
21
|
+
import { hkdfSha256 } from './kdf';
|
|
22
|
+
import {
|
|
23
|
+
type IdentityMarker,
|
|
24
|
+
clearIdentityMarker,
|
|
25
|
+
readIdentityMarker,
|
|
26
|
+
updateIdentityMarker,
|
|
27
|
+
writeIdentityMarker,
|
|
28
|
+
} from './identityMarker';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Options for expo-secure-store calls made by KeyManager.
|
|
32
|
+
*
|
|
33
|
+
* Defined as a standalone interface (not extending expo-secure-store's
|
|
34
|
+
* `SecureStoreOptions`) so that server / Node.js consumers of @oxy.so/core
|
|
35
|
+
* do not transitively pull in expo-modules-core's type declarations under
|
|
36
|
+
* NodeNext module resolution (which would cause NodeJS.Timeout / number
|
|
37
|
+
* pollution across all timer APIs). The fields mirror SecureStoreOptions
|
|
38
|
+
* exactly — any object satisfying this interface is safe to pass to the
|
|
39
|
+
* real expo-secure-store methods (which accept `options?: object`).
|
|
40
|
+
*/
|
|
41
|
+
interface OxySecureStoreOptions {
|
|
42
|
+
/** Keychain service name (Android keystore / iOS keychain tag). */
|
|
43
|
+
keychainService?: string;
|
|
44
|
+
/**
|
|
45
|
+
* Keychain / Keystore item accessibility. Use the constants from the
|
|
46
|
+
* `ExpoSecureStoreLike` handle returned by `initSecureStore()`, e.g.
|
|
47
|
+
* `store.WHEN_UNLOCKED_THIS_DEVICE_ONLY`.
|
|
48
|
+
*/
|
|
49
|
+
keychainAccessible?: number;
|
|
50
|
+
/** Prompt shown to the user for biometric/auth-protected items. */
|
|
51
|
+
authenticationPrompt?: string;
|
|
52
|
+
/** Whether Face ID / Touch ID / biometric auth is required to read the item. */
|
|
53
|
+
requireAuthentication?: boolean;
|
|
54
|
+
/**
|
|
55
|
+
* iOS Keychain access group. Required for sharing identity material across
|
|
56
|
+
* apps in the Oxy ecosystem via Keychain Sharing entitlements.
|
|
57
|
+
*/
|
|
58
|
+
keychainAccessGroup?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Thrown when an identity-mutating operation (createIdentity / importKeyPair)
|
|
63
|
+
* is invoked while a valid identity already exists on the device.
|
|
64
|
+
*
|
|
65
|
+
* The local private key IS the user's identity — overwriting it without
|
|
66
|
+
* explicit consent permanently loses access to their account (unless
|
|
67
|
+
* they previously saved their recovery phrase). This error forces callers
|
|
68
|
+
* to make an explicit, audited decision instead of silently clobbering.
|
|
69
|
+
*/
|
|
70
|
+
export class IdentityAlreadyExistsError extends Error {
|
|
71
|
+
override readonly name = 'IdentityAlreadyExistsError';
|
|
72
|
+
readonly existingPublicKey: string;
|
|
73
|
+
constructor(existingPublicKey: string) {
|
|
74
|
+
super(
|
|
75
|
+
'An identity already exists on this device. Refusing to overwrite without explicit consent. ' +
|
|
76
|
+
'If you really want to replace it, ensure the user has saved their recovery phrase, then call ' +
|
|
77
|
+
'the operation with { overwrite: true }.'
|
|
78
|
+
);
|
|
79
|
+
this.existingPublicKey = existingPublicKey;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Thrown when a freshly written identity cannot be read back, parsed, or
|
|
85
|
+
* round-tripped through sign/verify. Indicates a storage failure or
|
|
86
|
+
* corruption that would otherwise silently leave the user with an
|
|
87
|
+
* unusable account.
|
|
88
|
+
*/
|
|
89
|
+
export class IdentityPersistError extends Error {
|
|
90
|
+
override readonly name = 'IdentityPersistError';
|
|
91
|
+
constructor(message: string, readonly cause?: unknown) {
|
|
92
|
+
super(message);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Thrown when identity storage cannot be read/written right now — the keychain
|
|
98
|
+
* is locked, the module failed to load, or a read threw — as opposed to the
|
|
99
|
+
* identity being genuinely absent.
|
|
100
|
+
*
|
|
101
|
+
* This is the crux of the corruption-vs-fresh-install fix: a storage THROW must
|
|
102
|
+
* NEVER be flattened into "no identity" (the old behavior, which let onboarding
|
|
103
|
+
* treat a momentarily-locked keystore as a blank device). Callers that used to
|
|
104
|
+
* tolerate a `false`/`null` from `hasIdentity()`/`getPublicKey()` on error must
|
|
105
|
+
* now treat this typed error as "cannot determine" — retry, surface a locked
|
|
106
|
+
* state, or abort a destructive path — never as "safe to create/overwrite".
|
|
107
|
+
*/
|
|
108
|
+
export class IdentityUnavailableError extends Error {
|
|
109
|
+
override readonly name = 'IdentityUnavailableError';
|
|
110
|
+
constructor(message: string, readonly cause?: unknown) {
|
|
111
|
+
super(message);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Authoritative tri-state (plus `unavailable`) verdict on the on-device
|
|
117
|
+
* identity, from {@link KeyManager.getIdentityStatus}.
|
|
118
|
+
*
|
|
119
|
+
* - `present` — a healthy, round-tripping key pair exists.
|
|
120
|
+
* - `absent` — storage read succeeded and returned nothing, AND no marker
|
|
121
|
+
* records a prior identity → a genuine fresh device. The ONLY
|
|
122
|
+
* state that may route to create/onboarding.
|
|
123
|
+
* - `lost` — storage read succeeded but the keys are empty/unreadable while
|
|
124
|
+
* the independent {@link IdentityMarker} records that an identity
|
|
125
|
+
* DID exist here → corruption/keystore death. Route to recovery,
|
|
126
|
+
* NEVER to create.
|
|
127
|
+
* - `unavailable` — a storage read THREW (keychain locked, module load failure).
|
|
128
|
+
* Transient by assumption; NEVER cached; callers retry.
|
|
129
|
+
*/
|
|
130
|
+
export type IdentityStatus =
|
|
131
|
+
| { state: 'present'; publicKey: string }
|
|
132
|
+
| { state: 'absent' }
|
|
133
|
+
| { state: 'lost'; marker: IdentityMarker }
|
|
134
|
+
| { state: 'unavailable'; cause: unknown };
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Result of {@link KeyManager.attemptIdentityRecovery}. On success it reports
|
|
138
|
+
* which independent, `key_v1`-surviving source restored the identity. On failure
|
|
139
|
+
* `reason` distinguishes "wasn't lost", "no surviving source", "a source held a
|
|
140
|
+
* DIFFERENT account" (never silently switched), and "storage unavailable".
|
|
141
|
+
*/
|
|
142
|
+
export type IdentityRecoveryResult =
|
|
143
|
+
| { recovered: true; source: 'backup' | 'shared'; publicKey: string }
|
|
144
|
+
| { recovered: false; reason: 'not-lost' | 'no-sources' | 'mismatch' | 'unavailable' };
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* HKDF salt that domain-separates every identity-scoped seed produced by
|
|
148
|
+
* {@link KeyManager.deriveScopedSeed}. Versioned so a future scheme change is a
|
|
149
|
+
* new, non-colliding tag. The per-app domain (e.g. Oxy Pay's FairCoin wallet)
|
|
150
|
+
* is carried by the caller's `info` string, not this salt.
|
|
151
|
+
*/
|
|
152
|
+
const SCOPED_SEED_KDF_SALT = 'oxy-identity-scoped-seed-v1';
|
|
153
|
+
|
|
154
|
+
/** UTF-8 encode an ASCII label to bytes (HKDF salt/info). */
|
|
155
|
+
function utf8ToBytes(label: string): Uint8Array {
|
|
156
|
+
return new TextEncoder().encode(label);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Decode a hex string to bytes. Inverse of {@link uint8ArrayToHex}. */
|
|
160
|
+
function hexToBytes(hex: string): Uint8Array {
|
|
161
|
+
const out = new Uint8Array(hex.length / 2);
|
|
162
|
+
for (let i = 0; i < out.length; i++) {
|
|
163
|
+
out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const STORAGE_KEYS = {
|
|
169
|
+
PRIVATE_KEY: 'oxy_identity_private_key',
|
|
170
|
+
PUBLIC_KEY: 'oxy_identity_public_key',
|
|
171
|
+
BACKUP_PRIVATE_KEY: 'oxy_identity_backup_private_key',
|
|
172
|
+
BACKUP_PUBLIC_KEY: 'oxy_identity_backup_public_key',
|
|
173
|
+
BACKUP_TIMESTAMP: 'oxy_identity_backup_timestamp',
|
|
174
|
+
// Shared keys accessible across all Oxy apps (iOS Keychain Group / Android Account Manager)
|
|
175
|
+
SHARED_PRIVATE_KEY: 'oxy_shared_identity_private_key',
|
|
176
|
+
SHARED_PUBLIC_KEY: 'oxy_shared_identity_public_key',
|
|
177
|
+
SHARED_SESSION_TOKEN: 'oxy_shared_session_token',
|
|
178
|
+
SHARED_SESSION_ID: 'oxy_shared_session_id',
|
|
179
|
+
} as const;
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* v2 identity slot layout — blast-radius isolation.
|
|
183
|
+
*
|
|
184
|
+
* The legacy keys above were written WITHOUT a `keychainService`, so on Android
|
|
185
|
+
* they all shared expo-secure-store's single default `key_v1` AndroidKeyStore
|
|
186
|
+
* key — meaning ONE keystore invalidation deleted the primary AND the backup
|
|
187
|
+
* together (the exact loss this hardening closes). The v2 layout gives the
|
|
188
|
+
* primary and the backup DISTINCT keychain services (→ independent AndroidKeyStore
|
|
189
|
+
* keys, independent iOS keychain items), so they can no longer die together, and
|
|
190
|
+
* DISTINCT key names (`_v2`) so the post-copy migration verify can only observe
|
|
191
|
+
* what it actually wrote (old/new locations are non-aliasable).
|
|
192
|
+
*
|
|
193
|
+
* Migration from the legacy layout is lazy + verify-before-delete — see
|
|
194
|
+
* {@link KeyManager._runSlotMigration}.
|
|
195
|
+
*/
|
|
196
|
+
const V2_PRIMARY_KEYCHAIN_SERVICE = 'oxy_identity';
|
|
197
|
+
const V2_BACKUP_KEYCHAIN_SERVICE = 'oxy_identity_backup';
|
|
198
|
+
|
|
199
|
+
const V2_STORAGE_KEYS = {
|
|
200
|
+
PRIVATE_KEY: 'oxy_identity_private_key_v2',
|
|
201
|
+
PUBLIC_KEY: 'oxy_identity_public_key_v2',
|
|
202
|
+
BACKUP_PRIVATE_KEY: 'oxy_identity_backup_private_key_v2',
|
|
203
|
+
BACKUP_PUBLIC_KEY: 'oxy_identity_backup_public_key_v2',
|
|
204
|
+
BACKUP_TIMESTAMP: 'oxy_identity_backup_timestamp_v2',
|
|
205
|
+
} as const;
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Dedicated keychain slot for the recovery mnemonic (the 12-word phrase).
|
|
209
|
+
*
|
|
210
|
+
* Stored under its OWN keychain service — distinct from the v2 primary, backup,
|
|
211
|
+
* and shared slots — so it shares an AndroidKeyStore key with none of them
|
|
212
|
+
* (blast-radius isolation, same rationale as the v2 primary/backup split).
|
|
213
|
+
* Written `WHEN_UNLOCKED_THIS_DEVICE_ONLY` and NEVER exported off-device: it
|
|
214
|
+
* exists solely so the user can RE-READ their phrase from Settings on the SAME
|
|
215
|
+
* device that generated/imported it.
|
|
216
|
+
*
|
|
217
|
+
* This is convenience persistence, NOT a recovery mechanism — a keystore death
|
|
218
|
+
* wipes it alongside the keys, exactly like the private key itself. The user's
|
|
219
|
+
* written-down phrase remains the sole out-of-band recovery path. The mnemonic
|
|
220
|
+
* lives ONLY in this slot: it is never mirrored into the identity marker,
|
|
221
|
+
* {@link KeyManager.getIdentityStatus}, logs, or any exported bundle.
|
|
222
|
+
*/
|
|
223
|
+
const RECOVERY_MNEMONIC_KEYCHAIN_SERVICE = 'oxy_identity_mnemonic';
|
|
224
|
+
const RECOVERY_MNEMONIC_STORAGE_KEY = 'oxy_identity_mnemonic_v1';
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Advisory AsyncStorage fast-path flag: set once the v2 slots own the identity.
|
|
228
|
+
* Re-derivable (its loss just re-runs the cheap slot check), so it lives in
|
|
229
|
+
* plain AsyncStorage rather than the keychain. It only SKIPS re-reading the
|
|
230
|
+
* legacy slots on an already-migrated device; it is never trusted over an actual
|
|
231
|
+
* v2 read (a set flag with an unhealthy v2 pair falls through to full migration).
|
|
232
|
+
*/
|
|
233
|
+
const SLOTS_MIGRATED_FLAG_KEY = 'oxy_identity_slots_migrated_v2';
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* The resolved set of storage key names + keychain services a session reads and
|
|
237
|
+
* writes. Normally {@link V2_SLOT_LAYOUT}; degrades to {@link LEGACY_SLOT_LAYOUT}
|
|
238
|
+
* for the current session only when a v2 migration write could not be verified
|
|
239
|
+
* (so the user is never locked out of a still-readable legacy identity).
|
|
240
|
+
*/
|
|
241
|
+
interface ResolvedSlotLayout {
|
|
242
|
+
primaryService?: string;
|
|
243
|
+
primaryPrivateKeyName: string;
|
|
244
|
+
primaryPublicKeyName: string;
|
|
245
|
+
backupService?: string;
|
|
246
|
+
backupPrivateKeyName: string;
|
|
247
|
+
backupPublicKeyName: string;
|
|
248
|
+
backupTimestampName: string;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const V2_SLOT_LAYOUT: ResolvedSlotLayout = {
|
|
252
|
+
primaryService: V2_PRIMARY_KEYCHAIN_SERVICE,
|
|
253
|
+
primaryPrivateKeyName: V2_STORAGE_KEYS.PRIVATE_KEY,
|
|
254
|
+
primaryPublicKeyName: V2_STORAGE_KEYS.PUBLIC_KEY,
|
|
255
|
+
backupService: V2_BACKUP_KEYCHAIN_SERVICE,
|
|
256
|
+
backupPrivateKeyName: V2_STORAGE_KEYS.BACKUP_PRIVATE_KEY,
|
|
257
|
+
backupPublicKeyName: V2_STORAGE_KEYS.BACKUP_PUBLIC_KEY,
|
|
258
|
+
backupTimestampName: V2_STORAGE_KEYS.BACKUP_TIMESTAMP,
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
const LEGACY_SLOT_LAYOUT: ResolvedSlotLayout = {
|
|
262
|
+
primaryService: undefined,
|
|
263
|
+
primaryPrivateKeyName: STORAGE_KEYS.PRIVATE_KEY,
|
|
264
|
+
primaryPublicKeyName: STORAGE_KEYS.PUBLIC_KEY,
|
|
265
|
+
backupService: undefined,
|
|
266
|
+
backupPrivateKeyName: STORAGE_KEYS.BACKUP_PRIVATE_KEY,
|
|
267
|
+
backupPublicKeyName: STORAGE_KEYS.BACKUP_PUBLIC_KEY,
|
|
268
|
+
backupTimestampName: STORAGE_KEYS.BACKUP_TIMESTAMP,
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Outcome of the one-time-per-process slot migration. `deferred` means a read
|
|
273
|
+
* threw (keychain locked) — nothing was written or deleted, and every accessor
|
|
274
|
+
* treats it as `unavailable` (surfaced, never cached) so a later call retries.
|
|
275
|
+
*/
|
|
276
|
+
type SlotMigrationResult =
|
|
277
|
+
| { mode: 'v2'; layout: ResolvedSlotLayout }
|
|
278
|
+
| { mode: 'legacy'; layout: ResolvedSlotLayout }
|
|
279
|
+
| { mode: 'deferred'; cause: unknown };
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* iOS Keychain Access Group for sharing identities across Oxy apps
|
|
283
|
+
* All Oxy apps must have this access group enabled in their entitlements
|
|
284
|
+
* Format: [Team ID].so.oxy.shared or group.so.oxy.shared
|
|
285
|
+
*/
|
|
286
|
+
const IOS_KEYCHAIN_GROUP = 'group.so.oxy.shared';
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Android Account Manager type for shared authentication
|
|
290
|
+
* Used with sharedUserId to share sessions across apps
|
|
291
|
+
*/
|
|
292
|
+
const ANDROID_ACCOUNT_TYPE = 'com.oxy.account';
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Initialize React Native specific modules
|
|
296
|
+
*
|
|
297
|
+
* Delegates to `@oxy.so/protocol`'s `platform/crypto`, a per-platform module
|
|
298
|
+
* (`crypto.ts` vs `crypto.native.ts`) selected by the
|
|
299
|
+
* consumer's bundler. On RN it returns a statically-imported handle to
|
|
300
|
+
* `expo-secure-store`; off RN it throws (and is never called because every
|
|
301
|
+
* caller is gated by `isWebPlatform()` / native-only paths).
|
|
302
|
+
*/
|
|
303
|
+
async function initSecureStore(): Promise<ExpoSecureStoreLike> {
|
|
304
|
+
try {
|
|
305
|
+
return await loadSecureStore();
|
|
306
|
+
} catch (error) {
|
|
307
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
308
|
+
throw new Error(
|
|
309
|
+
`Failed to load expo-secure-store: ${errorMessage}. ` +
|
|
310
|
+
'Make sure expo-secure-store is installed and properly configured.',
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Check if we're on web platform
|
|
317
|
+
* Identity storage is only available on native platforms (iOS/Android)
|
|
318
|
+
*/
|
|
319
|
+
function isWebPlatform(): boolean {
|
|
320
|
+
return isWeb();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function initExpoCrypto(): Promise<ExpoCryptoLike> {
|
|
324
|
+
// Same per-platform delegation as initSecureStore — see comment there.
|
|
325
|
+
return loadExpoCrypto();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Convert Uint8Array to hexadecimal string
|
|
330
|
+
* Works in both Node.js and React Native
|
|
331
|
+
*/
|
|
332
|
+
function uint8ArrayToHex(bytes: Uint8Array): string {
|
|
333
|
+
return Array.from(bytes)
|
|
334
|
+
.map(b => b.toString(16).padStart(2, '0'))
|
|
335
|
+
.join('');
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Generate cryptographically secure random bytes
|
|
340
|
+
*/
|
|
341
|
+
async function getSecureRandomBytes(length: number): Promise<Uint8Array> {
|
|
342
|
+
// In React Native, always use expo-crypto
|
|
343
|
+
if (isReactNative() || !isNodeJS()) {
|
|
344
|
+
const Crypto = await initExpoCrypto();
|
|
345
|
+
return Crypto.getRandomBytes(length);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// In Node.js, use Node's crypto module.
|
|
349
|
+
//
|
|
350
|
+
// `loadNodeCrypto` is per-platform: the default variant performs
|
|
351
|
+
// `await import('crypto')`, the RN variant throws (and we'd never reach
|
|
352
|
+
// here on RN because the early-return above caught it).
|
|
353
|
+
try {
|
|
354
|
+
const nodeCrypto = await loadNodeCrypto();
|
|
355
|
+
return new Uint8Array(nodeCrypto.randomBytes(length));
|
|
356
|
+
} catch (error) {
|
|
357
|
+
// Fallback to expo-crypto if Node crypto fails (defensive — should not
|
|
358
|
+
// happen on real Node, but the platform-detection edge cases are
|
|
359
|
+
// surprisingly varied).
|
|
360
|
+
const Crypto = await initExpoCrypto();
|
|
361
|
+
return Crypto.getRandomBytes(length);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export interface KeyPair {
|
|
366
|
+
publicKey: string;
|
|
367
|
+
privateKey: string;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
export class KeyManager {
|
|
371
|
+
// In-memory cache for identity state (invalidated on identity changes)
|
|
372
|
+
private static cachedPublicKey: string | null = null;
|
|
373
|
+
private static cachedHasIdentity: boolean | null = null;
|
|
374
|
+
private static cachedSharedPublicKey: string | null = null;
|
|
375
|
+
private static cachedHasSharedIdentity: boolean | null = null;
|
|
376
|
+
/**
|
|
377
|
+
* Distinguishes "public key genuinely absent (a successful empty read, safe to
|
|
378
|
+
* cache)" from "never resolved / storage threw (must NOT be cached)". A `null`
|
|
379
|
+
* {@link cachedPublicKey} alone is ambiguous — this flag makes the genuine
|
|
380
|
+
* absence cacheable WITHOUT ever caching a null produced by a thrown read.
|
|
381
|
+
*/
|
|
382
|
+
private static cachedPublicKeyResolved = false;
|
|
383
|
+
|
|
384
|
+
/** Listeners notified synchronously whenever the identity verdict may have changed. */
|
|
385
|
+
private static readonly identityChangeListeners = new Set<() => void>();
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Memoized one-run-per-process slot migration. `slotMigrationResult` caches a
|
|
389
|
+
* STABLE outcome (`v2`/`legacy`); a `deferred` outcome is intentionally not
|
|
390
|
+
* cached (the in-flight promise is cleared) so a later call retries once the
|
|
391
|
+
* keychain unlocks.
|
|
392
|
+
*/
|
|
393
|
+
private static slotMigrationPromise: Promise<SlotMigrationResult> | null = null;
|
|
394
|
+
private static slotMigrationResult: SlotMigrationResult | null = null;
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Invalidate cached identity state
|
|
398
|
+
* Called internally when identity is created/deleted/imported
|
|
399
|
+
*/
|
|
400
|
+
private static invalidateCache(): void {
|
|
401
|
+
KeyManager.cachedPublicKey = null;
|
|
402
|
+
KeyManager.cachedHasIdentity = null;
|
|
403
|
+
KeyManager.cachedPublicKeyResolved = false;
|
|
404
|
+
KeyManager.notifyIdentityChanged();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Subscribe to identity-verdict changes (create / import / delete / restore /
|
|
409
|
+
* cache invalidation). Fires synchronously; the returned function unsubscribes.
|
|
410
|
+
* Consumed via `useOxyEvent`-style hooks in commons to invalidate the routing
|
|
411
|
+
* queries the instant the identity state moves, without polling.
|
|
412
|
+
*/
|
|
413
|
+
static subscribeIdentityChanged(listener: () => void): () => void {
|
|
414
|
+
KeyManager.identityChangeListeners.add(listener);
|
|
415
|
+
return () => {
|
|
416
|
+
KeyManager.identityChangeListeners.delete(listener);
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Synchronous fan-out with per-listener isolation (one throwing listener never blocks the rest). */
|
|
421
|
+
private static notifyIdentityChanged(): void {
|
|
422
|
+
// Snapshot first — a listener may unsubscribe (mutate the Set) during fan-out.
|
|
423
|
+
for (const listener of Array.from(KeyManager.identityChangeListeners)) {
|
|
424
|
+
try {
|
|
425
|
+
listener();
|
|
426
|
+
} catch (error) {
|
|
427
|
+
logger.warn('Identity-change listener threw', { component: 'KeyManager' }, error);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Build `getItemAsync`/`deleteItemAsync` options for a given keychain service (read/delete). */
|
|
433
|
+
private static _slotOpts(service?: string): OxySecureStoreOptions {
|
|
434
|
+
return service ? { keychainService: service } : {};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Build private-key write options (device-only accessibility) for a given keychain service. */
|
|
438
|
+
private static _privateWriteOpts(
|
|
439
|
+
store: Awaited<ReturnType<typeof initSecureStore>>,
|
|
440
|
+
service?: string,
|
|
441
|
+
): OxySecureStoreOptions {
|
|
442
|
+
const opts: OxySecureStoreOptions = { keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY };
|
|
443
|
+
if (service) {
|
|
444
|
+
opts.keychainService = service;
|
|
445
|
+
}
|
|
446
|
+
return opts;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** True only when both keys are present, well-formed, AND the public derives from the private. */
|
|
450
|
+
private static _isHealthyPair(privateKey: string | null, publicKey: string | null): boolean {
|
|
451
|
+
if (!privateKey || !publicKey) {
|
|
452
|
+
return false;
|
|
453
|
+
}
|
|
454
|
+
if (!KeyManager.isValidPrivateKey(privateKey) || !KeyManager.isValidPublicKey(publicKey)) {
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
try {
|
|
458
|
+
return KeyManager.derivePublicKey(privateKey).toLowerCase() === publicKey.toLowerCase();
|
|
459
|
+
} catch {
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Resolve the AsyncStorage-backed KV store for the advisory migration flag, or
|
|
466
|
+
* `null` off-RN / when unavailable. Independent of the keychain, so the flag
|
|
467
|
+
* cannot be taken down by the keystore event this whole subsystem defends
|
|
468
|
+
* against.
|
|
469
|
+
*/
|
|
470
|
+
private static async _advisoryStorage(): Promise<{
|
|
471
|
+
getItem(key: string): Promise<string | null>;
|
|
472
|
+
setItem(key: string, value: string): Promise<void>;
|
|
473
|
+
} | null> {
|
|
474
|
+
if (!isReactNative()) {
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
try {
|
|
478
|
+
const mod = await loadAsyncStorage();
|
|
479
|
+
return mod.default;
|
|
480
|
+
} catch {
|
|
481
|
+
// Advisory only — absence just means the slot check runs in full.
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
private static async _readSlotsMigratedFlag(): Promise<boolean> {
|
|
487
|
+
const storage = await KeyManager._advisoryStorage();
|
|
488
|
+
if (!storage) {
|
|
489
|
+
return false;
|
|
490
|
+
}
|
|
491
|
+
try {
|
|
492
|
+
return (await storage.getItem(SLOTS_MIGRATED_FLAG_KEY)) === 'true';
|
|
493
|
+
} catch {
|
|
494
|
+
// Advisory only — treat an unreadable flag as "not yet migrated".
|
|
495
|
+
return false;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
private static async _setSlotsMigratedFlag(): Promise<void> {
|
|
500
|
+
const storage = await KeyManager._advisoryStorage();
|
|
501
|
+
if (!storage) {
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
try {
|
|
505
|
+
await storage.setItem(SLOTS_MIGRATED_FLAG_KEY, 'true');
|
|
506
|
+
} catch (error) {
|
|
507
|
+
// Advisory only — a failed write just re-runs the cheap slot check next launch.
|
|
508
|
+
if (isDev()) {
|
|
509
|
+
logger.debug('Failed to set slots-migrated flag (advisory)', { component: 'KeyManager' }, error);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Ensure the identity has been migrated onto the isolated v2 slots (or that we
|
|
516
|
+
* know we must read legacy this session). Memoized so concurrent callers share
|
|
517
|
+
* ONE run; a `deferred` (read-threw) outcome is not cached so a later call
|
|
518
|
+
* retries after the keychain unlocks. Every identity-slot accessor awaits this
|
|
519
|
+
* before touching storage.
|
|
520
|
+
*/
|
|
521
|
+
private static async _ensureIdentitySlotsMigrated(): Promise<SlotMigrationResult> {
|
|
522
|
+
if (KeyManager.slotMigrationResult && KeyManager.slotMigrationResult.mode !== 'deferred') {
|
|
523
|
+
return KeyManager.slotMigrationResult;
|
|
524
|
+
}
|
|
525
|
+
if (!KeyManager.slotMigrationPromise) {
|
|
526
|
+
const run = (async () => {
|
|
527
|
+
const result = await KeyManager._runSlotMigration();
|
|
528
|
+
KeyManager.slotMigrationResult = result;
|
|
529
|
+
return result;
|
|
530
|
+
})();
|
|
531
|
+
KeyManager.slotMigrationPromise = run;
|
|
532
|
+
// Clear the in-flight handle once settled so a deferred outcome retries.
|
|
533
|
+
run
|
|
534
|
+
.then((result) => {
|
|
535
|
+
if (result.mode === 'deferred') {
|
|
536
|
+
KeyManager.slotMigrationPromise = null;
|
|
537
|
+
}
|
|
538
|
+
})
|
|
539
|
+
.catch(() => {
|
|
540
|
+
KeyManager.slotMigrationPromise = null;
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
return KeyManager.slotMigrationPromise;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* One-shot slot migration state machine. All reads are DIRECT and a thrown
|
|
548
|
+
* read defers everything (zero writes/deletes) so a locked keychain is never
|
|
549
|
+
* mistaken for an empty one. INVARIANT: at every instant ≥1 readable copy of a
|
|
550
|
+
* previously-existing identity remains — legacy is deleted ONLY after the v2
|
|
551
|
+
* copy is verified re-readable in its new (non-aliasable) location.
|
|
552
|
+
*/
|
|
553
|
+
private static async _runSlotMigration(): Promise<SlotMigrationResult> {
|
|
554
|
+
let store: Awaited<ReturnType<typeof initSecureStore>>;
|
|
555
|
+
try {
|
|
556
|
+
store = await initSecureStore();
|
|
557
|
+
} catch (error) {
|
|
558
|
+
return { mode: 'deferred', cause: error };
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const migratedFlag = await KeyManager._readSlotsMigratedFlag();
|
|
562
|
+
|
|
563
|
+
// Read the v2 primary (dedicated keychain service).
|
|
564
|
+
let v2Private: string | null;
|
|
565
|
+
let v2Public: string | null;
|
|
566
|
+
try {
|
|
567
|
+
v2Private = await store.getItemAsync(
|
|
568
|
+
V2_STORAGE_KEYS.PRIVATE_KEY,
|
|
569
|
+
KeyManager._slotOpts(V2_PRIMARY_KEYCHAIN_SERVICE),
|
|
570
|
+
);
|
|
571
|
+
v2Public = await store.getItemAsync(
|
|
572
|
+
V2_STORAGE_KEYS.PUBLIC_KEY,
|
|
573
|
+
KeyManager._slotOpts(V2_PRIMARY_KEYCHAIN_SERVICE),
|
|
574
|
+
);
|
|
575
|
+
} catch (error) {
|
|
576
|
+
return { mode: 'deferred', cause: error };
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (KeyManager._isHealthyPair(v2Private, v2Public)) {
|
|
580
|
+
// v2 already owns the identity. On the first observation, clean up any
|
|
581
|
+
// stale legacy copy and record the fast-path flag.
|
|
582
|
+
if (!migratedFlag) {
|
|
583
|
+
await KeyManager._bestEffortDeleteLegacyPrimaryAndBackup(store);
|
|
584
|
+
await KeyManager._setSlotsMigratedFlag();
|
|
585
|
+
}
|
|
586
|
+
return { mode: 'v2', layout: V2_SLOT_LAYOUT };
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// v2 primary absent/partial but the flag says migration finished → v2 is
|
|
590
|
+
// simply empty (identity deleted / never created). No legacy to rescue.
|
|
591
|
+
if (migratedFlag) {
|
|
592
|
+
return { mode: 'v2', layout: V2_SLOT_LAYOUT };
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Read the legacy primary (default keychain service = the old `key_v1`).
|
|
596
|
+
let legacyPrivate: string | null;
|
|
597
|
+
let legacyPublic: string | null;
|
|
598
|
+
try {
|
|
599
|
+
legacyPrivate = await store.getItemAsync(STORAGE_KEYS.PRIVATE_KEY);
|
|
600
|
+
legacyPublic = await store.getItemAsync(STORAGE_KEYS.PUBLIC_KEY);
|
|
601
|
+
} catch (error) {
|
|
602
|
+
return { mode: 'deferred', cause: error };
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (!KeyManager._isHealthyPair(legacyPrivate, legacyPublic)) {
|
|
606
|
+
// Nothing readable in either generation → v2 is the canonical (empty) home.
|
|
607
|
+
// The marker (not this migration) decides fresh-vs-lost.
|
|
608
|
+
return { mode: 'v2', layout: V2_SLOT_LAYOUT };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// legacy healthy, v2 absent → migrate: copy → read-back verify → only then delete legacy.
|
|
612
|
+
const canonicalPrivate = KeyManager.canonicalPrivateKey(legacyPrivate as string);
|
|
613
|
+
const canonicalPublic = (legacyPublic as string).toLowerCase();
|
|
614
|
+
try {
|
|
615
|
+
await store.setItemAsync(
|
|
616
|
+
V2_STORAGE_KEYS.PUBLIC_KEY,
|
|
617
|
+
canonicalPublic,
|
|
618
|
+
KeyManager._slotOpts(V2_PRIMARY_KEYCHAIN_SERVICE),
|
|
619
|
+
);
|
|
620
|
+
await store.setItemAsync(
|
|
621
|
+
V2_STORAGE_KEYS.PRIVATE_KEY,
|
|
622
|
+
canonicalPrivate,
|
|
623
|
+
KeyManager._privateWriteOpts(store, V2_PRIMARY_KEYCHAIN_SERVICE),
|
|
624
|
+
);
|
|
625
|
+
const readBackPrivate = await store.getItemAsync(
|
|
626
|
+
V2_STORAGE_KEYS.PRIVATE_KEY,
|
|
627
|
+
KeyManager._slotOpts(V2_PRIMARY_KEYCHAIN_SERVICE),
|
|
628
|
+
);
|
|
629
|
+
const readBackPublic = await store.getItemAsync(
|
|
630
|
+
V2_STORAGE_KEYS.PUBLIC_KEY,
|
|
631
|
+
KeyManager._slotOpts(V2_PRIMARY_KEYCHAIN_SERVICE),
|
|
632
|
+
);
|
|
633
|
+
const verified =
|
|
634
|
+
readBackPrivate?.toLowerCase() === canonicalPrivate &&
|
|
635
|
+
readBackPublic?.toLowerCase() === canonicalPublic &&
|
|
636
|
+
KeyManager._isHealthyPair(readBackPrivate, readBackPublic);
|
|
637
|
+
if (!verified) {
|
|
638
|
+
// v2 write did not durably land — remove the partial v2 and serve reads
|
|
639
|
+
// from legacy this session (legacy is UNTOUCHED). Retry next launch.
|
|
640
|
+
await KeyManager._bestEffortDeleteV2Primary(store);
|
|
641
|
+
logger.warn(
|
|
642
|
+
'Identity slot migration verify failed; serving identity from legacy slots this session',
|
|
643
|
+
{ component: 'KeyManager' },
|
|
644
|
+
);
|
|
645
|
+
return { mode: 'legacy', layout: LEGACY_SLOT_LAYOUT };
|
|
646
|
+
}
|
|
647
|
+
} catch (error) {
|
|
648
|
+
await KeyManager._bestEffortDeleteV2Primary(store);
|
|
649
|
+
logger.warn(
|
|
650
|
+
'Identity slot migration write threw; serving identity from legacy slots this session',
|
|
651
|
+
{ component: 'KeyManager' },
|
|
652
|
+
error,
|
|
653
|
+
);
|
|
654
|
+
return { mode: 'legacy', layout: LEGACY_SLOT_LAYOUT };
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// v2 primary is verified re-readable. Migrate the backup slot (best-effort),
|
|
658
|
+
// then it is finally safe to delete the legacy generation.
|
|
659
|
+
await KeyManager._migrateBackupSlotToV2(store, canonicalPrivate, canonicalPublic);
|
|
660
|
+
await KeyManager._bestEffortDeleteLegacyPrimaryAndBackup(store);
|
|
661
|
+
await KeyManager._setSlotsMigratedFlag();
|
|
662
|
+
return { mode: 'v2', layout: V2_SLOT_LAYOUT };
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Seed the v2 backup slot during migration. Prefers a healthy legacy backup;
|
|
667
|
+
* otherwise mirrors the (already-verified) v2 primary material so a v2 backup
|
|
668
|
+
* always exists on an independent keychain key. Best-effort — a failure just
|
|
669
|
+
* defers backup population to the next {@link _persistIdentityAtomic}.
|
|
670
|
+
*/
|
|
671
|
+
private static async _migrateBackupSlotToV2(
|
|
672
|
+
store: Awaited<ReturnType<typeof initSecureStore>>,
|
|
673
|
+
primaryPrivate: string,
|
|
674
|
+
primaryPublic: string,
|
|
675
|
+
): Promise<void> {
|
|
676
|
+
try {
|
|
677
|
+
let backupPrivate: string | null = null;
|
|
678
|
+
let backupPublic: string | null = null;
|
|
679
|
+
try {
|
|
680
|
+
backupPrivate = await store.getItemAsync(STORAGE_KEYS.BACKUP_PRIVATE_KEY);
|
|
681
|
+
backupPublic = await store.getItemAsync(STORAGE_KEYS.BACKUP_PUBLIC_KEY);
|
|
682
|
+
} catch (error) {
|
|
683
|
+
if (isDev()) {
|
|
684
|
+
logger.debug('Legacy backup unreadable during migration (non-fatal)', { component: 'KeyManager' }, error);
|
|
685
|
+
}
|
|
686
|
+
backupPrivate = null;
|
|
687
|
+
backupPublic = null;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
let seedPrivate: string;
|
|
691
|
+
let seedPublic: string;
|
|
692
|
+
if (KeyManager._isHealthyPair(backupPrivate, backupPublic)) {
|
|
693
|
+
seedPrivate = KeyManager.canonicalPrivateKey(backupPrivate as string);
|
|
694
|
+
seedPublic = (backupPublic as string).toLowerCase();
|
|
695
|
+
} else {
|
|
696
|
+
seedPrivate = primaryPrivate;
|
|
697
|
+
seedPublic = primaryPublic;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
await store.setItemAsync(
|
|
701
|
+
V2_STORAGE_KEYS.BACKUP_PUBLIC_KEY,
|
|
702
|
+
seedPublic,
|
|
703
|
+
KeyManager._slotOpts(V2_BACKUP_KEYCHAIN_SERVICE),
|
|
704
|
+
);
|
|
705
|
+
await store.setItemAsync(
|
|
706
|
+
V2_STORAGE_KEYS.BACKUP_PRIVATE_KEY,
|
|
707
|
+
seedPrivate,
|
|
708
|
+
KeyManager._privateWriteOpts(store, V2_BACKUP_KEYCHAIN_SERVICE),
|
|
709
|
+
);
|
|
710
|
+
await store.setItemAsync(
|
|
711
|
+
V2_STORAGE_KEYS.BACKUP_TIMESTAMP,
|
|
712
|
+
Date.now().toString(),
|
|
713
|
+
KeyManager._slotOpts(V2_BACKUP_KEYCHAIN_SERVICE),
|
|
714
|
+
);
|
|
715
|
+
} catch (error) {
|
|
716
|
+
logger.warn('Failed to migrate identity backup slot to v2 (non-fatal)', { component: 'KeyManager' }, error);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** Best-effort single delete under an optional keychain service. Cleanup only — never surfaces. */
|
|
721
|
+
private static async _bestEffortDelete(
|
|
722
|
+
store: Awaited<ReturnType<typeof initSecureStore>>,
|
|
723
|
+
key: string,
|
|
724
|
+
service?: string,
|
|
725
|
+
): Promise<void> {
|
|
726
|
+
try {
|
|
727
|
+
await store.deleteItemAsync(key, KeyManager._slotOpts(service));
|
|
728
|
+
} catch (error) {
|
|
729
|
+
if (isDev()) {
|
|
730
|
+
logger.debug('Best-effort identity delete failed', { component: 'KeyManager' }, error);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
private static async _bestEffortDeleteV2Primary(
|
|
736
|
+
store: Awaited<ReturnType<typeof initSecureStore>>,
|
|
737
|
+
): Promise<void> {
|
|
738
|
+
await KeyManager._bestEffortDelete(store, V2_STORAGE_KEYS.PRIVATE_KEY, V2_PRIMARY_KEYCHAIN_SERVICE);
|
|
739
|
+
await KeyManager._bestEffortDelete(store, V2_STORAGE_KEYS.PUBLIC_KEY, V2_PRIMARY_KEYCHAIN_SERVICE);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
private static async _bestEffortDeleteLegacyPrimaryAndBackup(
|
|
743
|
+
store: Awaited<ReturnType<typeof initSecureStore>>,
|
|
744
|
+
): Promise<void> {
|
|
745
|
+
await KeyManager._bestEffortDelete(store, STORAGE_KEYS.PRIVATE_KEY);
|
|
746
|
+
await KeyManager._bestEffortDelete(store, STORAGE_KEYS.PUBLIC_KEY);
|
|
747
|
+
await KeyManager._bestEffortDelete(store, STORAGE_KEYS.BACKUP_PRIVATE_KEY);
|
|
748
|
+
await KeyManager._bestEffortDelete(store, STORAGE_KEYS.BACKUP_PUBLIC_KEY);
|
|
749
|
+
await KeyManager._bestEffortDelete(store, STORAGE_KEYS.BACKUP_TIMESTAMP);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
private static async _bestEffortDeleteBackupsAllGenerations(
|
|
753
|
+
store: Awaited<ReturnType<typeof initSecureStore>>,
|
|
754
|
+
): Promise<void> {
|
|
755
|
+
await KeyManager._bestEffortDelete(store, V2_STORAGE_KEYS.BACKUP_PRIVATE_KEY, V2_BACKUP_KEYCHAIN_SERVICE);
|
|
756
|
+
await KeyManager._bestEffortDelete(store, V2_STORAGE_KEYS.BACKUP_PUBLIC_KEY, V2_BACKUP_KEYCHAIN_SERVICE);
|
|
757
|
+
await KeyManager._bestEffortDelete(store, V2_STORAGE_KEYS.BACKUP_TIMESTAMP, V2_BACKUP_KEYCHAIN_SERVICE);
|
|
758
|
+
await KeyManager._bestEffortDelete(store, STORAGE_KEYS.BACKUP_PRIVATE_KEY);
|
|
759
|
+
await KeyManager._bestEffortDelete(store, STORAGE_KEYS.BACKUP_PUBLIC_KEY);
|
|
760
|
+
await KeyManager._bestEffortDelete(store, STORAGE_KEYS.BACKUP_TIMESTAMP);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* Clear the cross-app shared identity slot (force-delete only) so a deleted
|
|
765
|
+
* identity cannot be resurrected via the recovery ladder's shared rung.
|
|
766
|
+
* Best-effort — the shared slot is a redundant convenience copy.
|
|
767
|
+
*/
|
|
768
|
+
private static async _clearSharedSlot(
|
|
769
|
+
store: Awaited<ReturnType<typeof initSecureStore>>,
|
|
770
|
+
): Promise<void> {
|
|
771
|
+
try {
|
|
772
|
+
if (isIOS()) {
|
|
773
|
+
const opts: OxySecureStoreOptions = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
|
|
774
|
+
await store.deleteItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, opts);
|
|
775
|
+
await store.deleteItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, opts);
|
|
776
|
+
} else if (isAndroid()) {
|
|
777
|
+
const bridge = await loadSharedIdentityBridge();
|
|
778
|
+
if (bridge) {
|
|
779
|
+
await bridge.clearShared();
|
|
780
|
+
} else {
|
|
781
|
+
await store.deleteItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY);
|
|
782
|
+
await store.deleteItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
KeyManager.invalidateSharedCache();
|
|
786
|
+
} catch (error) {
|
|
787
|
+
logger.warn('Failed to clear shared identity slot during force delete', { component: 'KeyManager' }, error);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* Invalidate cached shared identity state
|
|
793
|
+
* Called internally when shared identity is created/deleted/imported
|
|
794
|
+
*/
|
|
795
|
+
private static invalidateSharedCache(): void {
|
|
796
|
+
KeyManager.cachedSharedPublicKey = null;
|
|
797
|
+
KeyManager.cachedHasSharedIdentity = null;
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* Lowercase and pad to canonical 64-hex-char form.
|
|
802
|
+
*
|
|
803
|
+
* Tolerates legacy leading-zero-stripped keys and externally imported
|
|
804
|
+
* uppercase hex. Every derivation path canonicalizes first so storage
|
|
805
|
+
* representation cannot change the resulting identity.
|
|
806
|
+
*
|
|
807
|
+
* Private (used only inside KeyManager) — public consumers should not need
|
|
808
|
+
* to think about hex representation.
|
|
809
|
+
*/
|
|
810
|
+
private static canonicalPrivateKey(key: string): string {
|
|
811
|
+
return normalizeSecp256k1PrivateKey(key);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Generate a new ECDSA secp256k1 key pair
|
|
816
|
+
* Returns the keys in hexadecimal format
|
|
817
|
+
*/
|
|
818
|
+
static generateKeyPairSync(): KeyPair {
|
|
819
|
+
return generateSecp256k1KeyPair();
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Generate a new key pair using secure random bytes
|
|
824
|
+
*/
|
|
825
|
+
static async generateKeyPair(): Promise<KeyPair> {
|
|
826
|
+
for (let attempt = 0; attempt < 128; attempt += 1) {
|
|
827
|
+
const privateKey = uint8ArrayToHex(await getSecureRandomBytes(32));
|
|
828
|
+
if (isValidSecp256k1PrivateKey(privateKey)) {
|
|
829
|
+
return { privateKey, publicKey: deriveSecp256k1PublicKey(privateKey) };
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
throw new Error('Unable to generate a valid secp256k1 private key');
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// ==================== SHARED IDENTITY METHODS ====================
|
|
836
|
+
// These methods enable cross-app session sharing (like Google)
|
|
837
|
+
// iOS: Uses Keychain Access Groups
|
|
838
|
+
// Android: Uses Account Manager with shared user ID
|
|
839
|
+
// =================================================================
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Create a shared identity accessible across all Oxy apps
|
|
843
|
+
*
|
|
844
|
+
* iOS: Stores in shared keychain group (requires entitlement configuration)
|
|
845
|
+
* Android: Stores in Account Manager (requires sharedUserId in manifest)
|
|
846
|
+
*
|
|
847
|
+
* This enables true cross-app SSO - when user signs in to one Oxy app,
|
|
848
|
+
* they're automatically signed in to all other Oxy apps.
|
|
849
|
+
*
|
|
850
|
+
* @returns Public key of the shared identity
|
|
851
|
+
* @throws Error if not on native platform or if sharing is not configured
|
|
852
|
+
*/
|
|
853
|
+
static async createSharedIdentity(): Promise<string> {
|
|
854
|
+
if (isWebPlatform()) {
|
|
855
|
+
throw new Error('Shared identity is only available on native platforms (iOS/Android).');
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
const store = await initSecureStore();
|
|
859
|
+
const { privateKey, publicKey } = await KeyManager.generateKeyPair();
|
|
860
|
+
|
|
861
|
+
if (isIOS()) {
|
|
862
|
+
// iOS: Store in shared keychain group
|
|
863
|
+
// Note: keychainAccessGroup requires Keychain Sharing capability in Xcode
|
|
864
|
+
try {
|
|
865
|
+
const privateOpts: OxySecureStoreOptions = {
|
|
866
|
+
keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
|
867
|
+
keychainAccessGroup: IOS_KEYCHAIN_GROUP, // Enables sharing across apps
|
|
868
|
+
};
|
|
869
|
+
await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, privateKey, privateOpts);
|
|
870
|
+
|
|
871
|
+
const publicOpts: OxySecureStoreOptions = {
|
|
872
|
+
keychainAccessGroup: IOS_KEYCHAIN_GROUP,
|
|
873
|
+
};
|
|
874
|
+
await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey, publicOpts);
|
|
875
|
+
} catch (error) {
|
|
876
|
+
throw new Error(
|
|
877
|
+
`Failed to create shared identity on iOS. Ensure your app has the Keychain Sharing capability enabled with access group "${IOS_KEYCHAIN_GROUP}". Error: ${error}`
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
} else if (isAndroid()) {
|
|
881
|
+
// Android: write through the cross-app bridge (`@oxy.so/expo-oxy-identity`)
|
|
882
|
+
// when present — it persists into Commons's hardware-backed
|
|
883
|
+
// EncryptedSharedPreferences behind a signature-protected ContentProvider,
|
|
884
|
+
// so same-key Oxy apps can read it. When the bridge is not linked, fall
|
|
885
|
+
// back to the package-private secure store (no cross-app sharing).
|
|
886
|
+
const bridge = await loadSharedIdentityBridge();
|
|
887
|
+
if (bridge) {
|
|
888
|
+
await bridge.putShared(privateKey, publicKey);
|
|
889
|
+
} else {
|
|
890
|
+
await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, privateKey, {
|
|
891
|
+
keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
|
892
|
+
});
|
|
893
|
+
await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// Update cache
|
|
898
|
+
KeyManager.cachedSharedPublicKey = publicKey;
|
|
899
|
+
KeyManager.cachedHasSharedIdentity = true;
|
|
900
|
+
|
|
901
|
+
if (isDev()) {
|
|
902
|
+
logger.debug('Shared identity created successfully', { component: 'KeyManager' });
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
return publicKey;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* Get the shared public key (accessible across all Oxy apps)
|
|
910
|
+
*
|
|
911
|
+
* @returns Shared public key or null if no shared identity exists
|
|
912
|
+
*/
|
|
913
|
+
static async getSharedPublicKey(): Promise<string | null> {
|
|
914
|
+
if (isWebPlatform()) {
|
|
915
|
+
return null;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// Return cached value if available
|
|
919
|
+
if (KeyManager.cachedSharedPublicKey !== null) {
|
|
920
|
+
return KeyManager.cachedSharedPublicKey;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
try {
|
|
924
|
+
const store = await initSecureStore();
|
|
925
|
+
let publicKey: string | null = null;
|
|
926
|
+
|
|
927
|
+
if (isIOS()) {
|
|
928
|
+
const opts: OxySecureStoreOptions = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
|
|
929
|
+
publicKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, opts);
|
|
930
|
+
} else if (isAndroid()) {
|
|
931
|
+
// Android reads through the cross-app bridge; when it is not linked, fall
|
|
932
|
+
// back to the package-private store the fallback write path used.
|
|
933
|
+
const bridge = await loadSharedIdentityBridge();
|
|
934
|
+
if (bridge) {
|
|
935
|
+
publicKey = (await bridge.getShared())?.publicKey ?? null;
|
|
936
|
+
} else {
|
|
937
|
+
publicKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// Cache result
|
|
942
|
+
KeyManager.cachedSharedPublicKey = publicKey;
|
|
943
|
+
|
|
944
|
+
return publicKey;
|
|
945
|
+
} catch (error) {
|
|
946
|
+
if (isDev()) {
|
|
947
|
+
logger.warn('Failed to get shared public key', { component: 'KeyManager' }, error);
|
|
948
|
+
}
|
|
949
|
+
KeyManager.cachedSharedPublicKey = null;
|
|
950
|
+
return null;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* Get the shared private key (for signing operations)
|
|
956
|
+
*
|
|
957
|
+
* WARNING: Only use this for signing operations within the app.
|
|
958
|
+
* The private key should NEVER be transmitted or exposed.
|
|
959
|
+
*
|
|
960
|
+
* @returns Shared private key or null if no shared identity exists
|
|
961
|
+
*/
|
|
962
|
+
static async getSharedPrivateKey(): Promise<string | null> {
|
|
963
|
+
if (isWebPlatform()) {
|
|
964
|
+
return null;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
try {
|
|
968
|
+
const store = await initSecureStore();
|
|
969
|
+
let privateKey: string | null = null;
|
|
970
|
+
|
|
971
|
+
if (isIOS()) {
|
|
972
|
+
const opts: OxySecureStoreOptions = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
|
|
973
|
+
privateKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, opts);
|
|
974
|
+
} else if (isAndroid()) {
|
|
975
|
+
// Android reads through the cross-app bridge; when it is not linked, fall
|
|
976
|
+
// back to the package-private store the fallback write path used.
|
|
977
|
+
const bridge = await loadSharedIdentityBridge();
|
|
978
|
+
if (bridge) {
|
|
979
|
+
privateKey = (await bridge.getShared())?.privateKey ?? null;
|
|
980
|
+
} else {
|
|
981
|
+
privateKey = await store.getItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
return privateKey;
|
|
986
|
+
} catch (error) {
|
|
987
|
+
if (isDev()) {
|
|
988
|
+
logger.warn('Failed to get shared private key', { component: 'KeyManager' }, error);
|
|
989
|
+
}
|
|
990
|
+
return null;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* Check if a shared identity exists (accessible across all Oxy apps)
|
|
996
|
+
*
|
|
997
|
+
* @returns True if shared identity exists, false otherwise
|
|
998
|
+
*/
|
|
999
|
+
static async hasSharedIdentity(): Promise<boolean> {
|
|
1000
|
+
if (isWebPlatform()) {
|
|
1001
|
+
return false;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// Return cached value if available
|
|
1005
|
+
if (KeyManager.cachedHasSharedIdentity !== null) {
|
|
1006
|
+
return KeyManager.cachedHasSharedIdentity;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
try {
|
|
1010
|
+
const privateKey = await KeyManager.getSharedPrivateKey();
|
|
1011
|
+
const hasShared = privateKey !== null;
|
|
1012
|
+
|
|
1013
|
+
// Cache result
|
|
1014
|
+
KeyManager.cachedHasSharedIdentity = hasShared;
|
|
1015
|
+
|
|
1016
|
+
return hasShared;
|
|
1017
|
+
} catch (error) {
|
|
1018
|
+
if (isDev()) {
|
|
1019
|
+
logger.warn('Failed to check shared identity', { component: 'KeyManager' }, error);
|
|
1020
|
+
}
|
|
1021
|
+
KeyManager.cachedHasSharedIdentity = false;
|
|
1022
|
+
return false;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* Import an existing key pair as shared identity
|
|
1028
|
+
*
|
|
1029
|
+
* This is used when:
|
|
1030
|
+
* 1. User signs in to a new Oxy app for the first time
|
|
1031
|
+
* 2. User has existing identity on another Oxy app
|
|
1032
|
+
* 3. We want to sync the identity across apps
|
|
1033
|
+
*
|
|
1034
|
+
* @param privateKey - Private key in hex format
|
|
1035
|
+
* @returns Public key
|
|
1036
|
+
*/
|
|
1037
|
+
static async importSharedIdentity(privateKey: string): Promise<string> {
|
|
1038
|
+
if (isWebPlatform()) {
|
|
1039
|
+
throw new Error('Shared identity import is only available on native platforms.');
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
const store = await initSecureStore();
|
|
1043
|
+
// Canonicalize incoming key BEFORE storage so the stored value is always
|
|
1044
|
+
// in canonical 64-hex-char lowercase form going forward. Without this,
|
|
1045
|
+
// legacy short keys would derive a different public key on the read path.
|
|
1046
|
+
const canonicalPrivate = KeyManager.canonicalPrivateKey(privateKey);
|
|
1047
|
+
const publicKey = deriveSecp256k1PublicKey(canonicalPrivate);
|
|
1048
|
+
|
|
1049
|
+
if (isIOS()) {
|
|
1050
|
+
const privateOpts: OxySecureStoreOptions = {
|
|
1051
|
+
keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
|
1052
|
+
keychainAccessGroup: IOS_KEYCHAIN_GROUP,
|
|
1053
|
+
};
|
|
1054
|
+
await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, canonicalPrivate, privateOpts);
|
|
1055
|
+
|
|
1056
|
+
const publicOpts: OxySecureStoreOptions = {
|
|
1057
|
+
keychainAccessGroup: IOS_KEYCHAIN_GROUP,
|
|
1058
|
+
};
|
|
1059
|
+
await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey, publicOpts);
|
|
1060
|
+
} else if (isAndroid()) {
|
|
1061
|
+
// Android: write through the cross-app bridge when present; otherwise the
|
|
1062
|
+
// package-private store (kept consistent with the read fallback).
|
|
1063
|
+
const bridge = await loadSharedIdentityBridge();
|
|
1064
|
+
if (bridge) {
|
|
1065
|
+
await bridge.putShared(canonicalPrivate, publicKey);
|
|
1066
|
+
} else {
|
|
1067
|
+
await store.setItemAsync(STORAGE_KEYS.SHARED_PRIVATE_KEY, canonicalPrivate, {
|
|
1068
|
+
keychainAccessible: store.WHEN_UNLOCKED_THIS_DEVICE_ONLY,
|
|
1069
|
+
});
|
|
1070
|
+
await store.setItemAsync(STORAGE_KEYS.SHARED_PUBLIC_KEY, publicKey);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
// Update cache
|
|
1075
|
+
KeyManager.cachedSharedPublicKey = publicKey;
|
|
1076
|
+
KeyManager.cachedHasSharedIdentity = true;
|
|
1077
|
+
|
|
1078
|
+
if (isDev()) {
|
|
1079
|
+
logger.debug('Shared identity imported successfully', { component: 'KeyManager' });
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
return publicKey;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* Store session information in shared storage
|
|
1087
|
+
*
|
|
1088
|
+
* This allows all Oxy apps to access the same session without
|
|
1089
|
+
* re-authenticating. When user signs in to one app, all apps
|
|
1090
|
+
* get the session automatically.
|
|
1091
|
+
*
|
|
1092
|
+
* @param sessionId - Session ID from authentication
|
|
1093
|
+
* @param accessToken - Access token for API calls
|
|
1094
|
+
*/
|
|
1095
|
+
static async storeSharedSession(sessionId: string, accessToken: string): Promise<void> {
|
|
1096
|
+
if (isWebPlatform()) {
|
|
1097
|
+
return; // Not supported on web
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
try {
|
|
1101
|
+
const store = await initSecureStore();
|
|
1102
|
+
|
|
1103
|
+
if (isIOS()) {
|
|
1104
|
+
const sessionIdOpts: OxySecureStoreOptions = {
|
|
1105
|
+
keychainAccessGroup: IOS_KEYCHAIN_GROUP,
|
|
1106
|
+
};
|
|
1107
|
+
await store.setItemAsync(STORAGE_KEYS.SHARED_SESSION_ID, sessionId, sessionIdOpts);
|
|
1108
|
+
|
|
1109
|
+
const tokenOpts: OxySecureStoreOptions = {
|
|
1110
|
+
keychainAccessible: store.WHEN_UNLOCKED,
|
|
1111
|
+
keychainAccessGroup: IOS_KEYCHAIN_GROUP,
|
|
1112
|
+
};
|
|
1113
|
+
await store.setItemAsync(STORAGE_KEYS.SHARED_SESSION_TOKEN, accessToken, tokenOpts);
|
|
1114
|
+
} else if (isAndroid()) {
|
|
1115
|
+
await store.setItemAsync(STORAGE_KEYS.SHARED_SESSION_ID, sessionId);
|
|
1116
|
+
await store.setItemAsync(STORAGE_KEYS.SHARED_SESSION_TOKEN, accessToken);
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
if (isDev()) {
|
|
1120
|
+
logger.debug('Shared session stored successfully', { component: 'KeyManager' });
|
|
1121
|
+
}
|
|
1122
|
+
} catch (error) {
|
|
1123
|
+
if (isDev()) {
|
|
1124
|
+
logger.error('Failed to store shared session', error, { component: 'KeyManager' });
|
|
1125
|
+
}
|
|
1126
|
+
throw error;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/**
|
|
1131
|
+
* Get shared session information
|
|
1132
|
+
*
|
|
1133
|
+
* This allows any Oxy app to check if user is already signed in
|
|
1134
|
+
* via another Oxy app. Enables instant cross-app SSO.
|
|
1135
|
+
*
|
|
1136
|
+
* @returns Session data or null if no shared session exists
|
|
1137
|
+
*/
|
|
1138
|
+
static async getSharedSession(): Promise<{ sessionId: string; accessToken: string } | null> {
|
|
1139
|
+
if (isWebPlatform()) {
|
|
1140
|
+
return null;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
try {
|
|
1144
|
+
const store = await initSecureStore();
|
|
1145
|
+
let sessionId: string | null = null;
|
|
1146
|
+
let accessToken: string | null = null;
|
|
1147
|
+
|
|
1148
|
+
if (isIOS()) {
|
|
1149
|
+
const opts: OxySecureStoreOptions = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
|
|
1150
|
+
sessionId = await store.getItemAsync(STORAGE_KEYS.SHARED_SESSION_ID, opts);
|
|
1151
|
+
accessToken = await store.getItemAsync(STORAGE_KEYS.SHARED_SESSION_TOKEN, opts);
|
|
1152
|
+
} else if (isAndroid()) {
|
|
1153
|
+
sessionId = await store.getItemAsync(STORAGE_KEYS.SHARED_SESSION_ID);
|
|
1154
|
+
accessToken = await store.getItemAsync(STORAGE_KEYS.SHARED_SESSION_TOKEN);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
if (!sessionId || !accessToken) {
|
|
1158
|
+
return null;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
return { sessionId, accessToken };
|
|
1162
|
+
} catch (error) {
|
|
1163
|
+
if (isDev()) {
|
|
1164
|
+
logger.warn('Failed to get shared session', { component: 'KeyManager' }, error);
|
|
1165
|
+
}
|
|
1166
|
+
return null;
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/**
|
|
1171
|
+
* Clear shared session (on logout)
|
|
1172
|
+
*
|
|
1173
|
+
* This signs out the user from ALL Oxy apps simultaneously.
|
|
1174
|
+
* Call this when user explicitly logs out.
|
|
1175
|
+
*/
|
|
1176
|
+
static async clearSharedSession(): Promise<void> {
|
|
1177
|
+
if (isWebPlatform()) {
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
try {
|
|
1182
|
+
const store = await initSecureStore();
|
|
1183
|
+
|
|
1184
|
+
if (isIOS()) {
|
|
1185
|
+
const opts: OxySecureStoreOptions = { keychainAccessGroup: IOS_KEYCHAIN_GROUP };
|
|
1186
|
+
await store.deleteItemAsync(STORAGE_KEYS.SHARED_SESSION_ID, opts);
|
|
1187
|
+
await store.deleteItemAsync(STORAGE_KEYS.SHARED_SESSION_TOKEN, opts);
|
|
1188
|
+
} else if (isAndroid()) {
|
|
1189
|
+
await store.deleteItemAsync(STORAGE_KEYS.SHARED_SESSION_ID);
|
|
1190
|
+
await store.deleteItemAsync(STORAGE_KEYS.SHARED_SESSION_TOKEN);
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
if (isDev()) {
|
|
1194
|
+
logger.debug('Shared session cleared successfully', { component: 'KeyManager' });
|
|
1195
|
+
}
|
|
1196
|
+
} catch (error) {
|
|
1197
|
+
if (isDev()) {
|
|
1198
|
+
logger.error('Failed to clear shared session', error, { component: 'KeyManager' });
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
/**
|
|
1204
|
+
* Migrate local identity to shared identity
|
|
1205
|
+
*
|
|
1206
|
+
* Call this when upgrading existing apps to use shared identities.
|
|
1207
|
+
* Copies the device-specific identity to shared storage so it can
|
|
1208
|
+
* be accessed by other Oxy apps.
|
|
1209
|
+
*
|
|
1210
|
+
* @returns True if migration was successful, false if no local identity exists
|
|
1211
|
+
*/
|
|
1212
|
+
static async migrateToSharedIdentity(): Promise<boolean> {
|
|
1213
|
+
if (isWebPlatform()) {
|
|
1214
|
+
return false;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
try {
|
|
1218
|
+
// Check if we already have a shared identity
|
|
1219
|
+
const hasShared = await KeyManager.hasSharedIdentity();
|
|
1220
|
+
if (hasShared) {
|
|
1221
|
+
if (isDev()) {
|
|
1222
|
+
logger.debug('Shared identity already exists, skipping migration', { component: 'KeyManager' });
|
|
1223
|
+
}
|
|
1224
|
+
return true;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// Get local identity
|
|
1228
|
+
const privateKey = await KeyManager.getPrivateKey();
|
|
1229
|
+
if (!privateKey) {
|
|
1230
|
+
if (isDev()) {
|
|
1231
|
+
logger.debug('No local identity to migrate', { component: 'KeyManager' });
|
|
1232
|
+
}
|
|
1233
|
+
return false;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
// Import to shared storage
|
|
1237
|
+
await KeyManager.importSharedIdentity(privateKey);
|
|
1238
|
+
|
|
1239
|
+
if (isDev()) {
|
|
1240
|
+
logger.debug('Successfully migrated local identity to shared identity', { component: 'KeyManager' });
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
return true;
|
|
1244
|
+
} catch (error) {
|
|
1245
|
+
if (isDev()) {
|
|
1246
|
+
logger.error('Failed to migrate to shared identity', error, { component: 'KeyManager' });
|
|
1247
|
+
}
|
|
1248
|
+
return false;
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
// ==================== END SHARED IDENTITY METHODS ====================
|
|
1253
|
+
|
|
1254
|
+
/**
|
|
1255
|
+
* Atomically persist a key pair to secure storage with verification + backup.
|
|
1256
|
+
*
|
|
1257
|
+
* INVARIANT (the reason this method exists): at no instant during the write
|
|
1258
|
+
* may the device be left holding ZERO recoverable copies of a healthy
|
|
1259
|
+
* identity. This matters most on the OVERWRITE / account-switch path: if we
|
|
1260
|
+
* are replacing identity A with B and the write fails halfway, we MUST end
|
|
1261
|
+
* up back on A — never on a half-written B, and never on nothing.
|
|
1262
|
+
*
|
|
1263
|
+
* Algorithm (recoverability-preserving):
|
|
1264
|
+
* 0. Snapshot the existing primary (privA, pubA) so we can roll back to
|
|
1265
|
+
* EXACTLY what was there.
|
|
1266
|
+
* 1. Write the new primary: public first, then private.
|
|
1267
|
+
* 2. Read back + sign/verify the new primary.
|
|
1268
|
+
* 3. ONLY after the new primary is proven durable, refresh the backup to
|
|
1269
|
+
* the new key. The backup is NEVER touched before this point, so any
|
|
1270
|
+
* prior identity's backup remains intact and `restoreIdentityFromBackup`
|
|
1271
|
+
* can always recover it.
|
|
1272
|
+
* 4. On ANY failure in steps 1–2, restore the snapshotted primary verbatim
|
|
1273
|
+
* (or delete it if there was none), then surface the error.
|
|
1274
|
+
*
|
|
1275
|
+
* Earlier versions wrote the *incoming* key to the backup FIRST, which
|
|
1276
|
+
* destroyed the previous identity's backup, and rolled back by blindly
|
|
1277
|
+
* deleting the primary — so a failed overwrite silently switched the user
|
|
1278
|
+
* to (or lost them into) the half-written new identity. That is fixed here.
|
|
1279
|
+
*
|
|
1280
|
+
* @internal
|
|
1281
|
+
*/
|
|
1282
|
+
private static async _persistIdentityAtomic(
|
|
1283
|
+
privateKey: string,
|
|
1284
|
+
publicKey: string,
|
|
1285
|
+
origin: IdentityMarker['origin'],
|
|
1286
|
+
): Promise<void> {
|
|
1287
|
+
const store = await initSecureStore();
|
|
1288
|
+
|
|
1289
|
+
// Resolve the active slot layout (normally v2; legacy only in the rare
|
|
1290
|
+
// migration-fallback session). Reading and writing the SAME layout keeps the
|
|
1291
|
+
// snapshot/rollback machinery below internally consistent. A deferred
|
|
1292
|
+
// migration (keychain locked) must never write blind.
|
|
1293
|
+
const migration = await KeyManager._ensureIdentitySlotsMigrated();
|
|
1294
|
+
if (migration.mode === 'deferred') {
|
|
1295
|
+
throw new IdentityUnavailableError(
|
|
1296
|
+
'Identity storage is temporarily unavailable; refusing to persist an identity.',
|
|
1297
|
+
migration.cause,
|
|
1298
|
+
);
|
|
1299
|
+
}
|
|
1300
|
+
const layout = migration.layout;
|
|
1301
|
+
const primaryReadOpts = KeyManager._slotOpts(layout.primaryService);
|
|
1302
|
+
const primaryPrivWriteOpts = KeyManager._privateWriteOpts(store, layout.primaryService);
|
|
1303
|
+
const primaryPubWriteOpts = KeyManager._slotOpts(layout.primaryService);
|
|
1304
|
+
const backupReadOpts = KeyManager._slotOpts(layout.backupService);
|
|
1305
|
+
const backupPrivWriteOpts = KeyManager._privateWriteOpts(store, layout.backupService);
|
|
1306
|
+
const backupPubWriteOpts = KeyManager._slotOpts(layout.backupService);
|
|
1307
|
+
|
|
1308
|
+
// Canonicalize BEFORE persistence so the stored value is always in
|
|
1309
|
+
// canonical 64-hex-char lowercase form going forward. This is the single
|
|
1310
|
+
// place all primary writes flow through, so once a value lands here all
|
|
1311
|
+
// subsequent reads see a stable representation.
|
|
1312
|
+
const canonicalPrivate = KeyManager.canonicalPrivateKey(privateKey);
|
|
1313
|
+
const canonicalPublic = publicKey.toLowerCase();
|
|
1314
|
+
|
|
1315
|
+
// Step 0: Snapshot the existing primary so a failed write can be rolled
|
|
1316
|
+
// back to EXACTLY the prior state. If the read itself fails we treat the
|
|
1317
|
+
// prior primary as unknown and refuse to proceed — overwriting blind
|
|
1318
|
+
// would risk clobbering an identity we just couldn't see (e.g. a
|
|
1319
|
+
// transient keychain lock).
|
|
1320
|
+
let priorPrivate: string | null;
|
|
1321
|
+
let priorPublic: string | null;
|
|
1322
|
+
try {
|
|
1323
|
+
priorPrivate = await store.getItemAsync(layout.primaryPrivateKeyName, primaryReadOpts);
|
|
1324
|
+
priorPublic = await store.getItemAsync(layout.primaryPublicKeyName, primaryReadOpts);
|
|
1325
|
+
} catch (error) {
|
|
1326
|
+
logger.error('Failed to read existing primary before persist', error, { component: 'KeyManager' });
|
|
1327
|
+
throw new IdentityPersistError(
|
|
1328
|
+
'Could not read existing identity before writing a new one; refusing to overwrite blind.',
|
|
1329
|
+
error,
|
|
1330
|
+
);
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// If we are replacing a DIFFERENT, currently-healthy identity, make sure
|
|
1334
|
+
// it is recoverable from the backup slot BEFORE we overwrite the primary.
|
|
1335
|
+
// We only do this when the existing backup does not already hold that
|
|
1336
|
+
// identity — otherwise we would needlessly churn the keychain. This keeps
|
|
1337
|
+
// the "always at least one recoverable copy" invariant intact across the
|
|
1338
|
+
// window where the primary briefly holds the new key but the new backup
|
|
1339
|
+
// has not been written yet.
|
|
1340
|
+
const priorIsHealthyDifferent =
|
|
1341
|
+
!!priorPrivate &&
|
|
1342
|
+
!!priorPublic &&
|
|
1343
|
+
priorPublic.toLowerCase() !== canonicalPublic &&
|
|
1344
|
+
KeyManager.isValidPrivateKey(priorPrivate) &&
|
|
1345
|
+
KeyManager.isValidPublicKey(priorPublic) &&
|
|
1346
|
+
KeyManager.derivePublicKey(priorPrivate).toLowerCase() === priorPublic.toLowerCase();
|
|
1347
|
+
|
|
1348
|
+
if (priorIsHealthyDifferent && priorPrivate && priorPublic) {
|
|
1349
|
+
let existingBackupPublic: string | null = null;
|
|
1350
|
+
try {
|
|
1351
|
+
existingBackupPublic = await store.getItemAsync(layout.backupPublicKeyName, backupReadOpts);
|
|
1352
|
+
} catch {
|
|
1353
|
+
existingBackupPublic = null;
|
|
1354
|
+
}
|
|
1355
|
+
if (existingBackupPublic?.toLowerCase() !== priorPublic.toLowerCase()) {
|
|
1356
|
+
try {
|
|
1357
|
+
await store.setItemAsync(
|
|
1358
|
+
layout.backupPrivateKeyName,
|
|
1359
|
+
KeyManager.canonicalPrivateKey(priorPrivate),
|
|
1360
|
+
backupPrivWriteOpts,
|
|
1361
|
+
);
|
|
1362
|
+
await store.setItemAsync(layout.backupPublicKeyName, priorPublic.toLowerCase(), backupPubWriteOpts);
|
|
1363
|
+
await store.setItemAsync(layout.backupTimestampName, Date.now().toString(), backupPubWriteOpts);
|
|
1364
|
+
} catch (error) {
|
|
1365
|
+
logger.error('Failed to back up existing identity before overwrite', error, { component: 'KeyManager' });
|
|
1366
|
+
throw new IdentityPersistError('Failed to back up existing identity before overwrite', error);
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
// Step 1: Write the new primary. Public first so that if the private write
|
|
1372
|
+
// fails we are missing the most critical bit. The backup is intentionally
|
|
1373
|
+
// NOT touched here — it still holds the previous good identity until the
|
|
1374
|
+
// new primary is proven durable.
|
|
1375
|
+
try {
|
|
1376
|
+
await store.setItemAsync(layout.primaryPublicKeyName, canonicalPublic, primaryPubWriteOpts);
|
|
1377
|
+
await store.setItemAsync(layout.primaryPrivateKeyName, canonicalPrivate, primaryPrivWriteOpts);
|
|
1378
|
+
} catch (error) {
|
|
1379
|
+
logger.error('Failed to write primary identity to secure store', error, { component: 'KeyManager' });
|
|
1380
|
+
await KeyManager._rollbackPrimary(store, layout, priorPrivate, priorPublic);
|
|
1381
|
+
throw new IdentityPersistError('Failed to write identity to secure store', error);
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
// Step 2: Verify round-trip. If the store silently drops our writes
|
|
1385
|
+
// (e.g., a misconfigured keychain access group), we MUST surface it
|
|
1386
|
+
// before declaring success — otherwise the caller will think the
|
|
1387
|
+
// identity was saved and discard the in-memory copy.
|
|
1388
|
+
let readBackPrivate: string | null;
|
|
1389
|
+
let readBackPublic: string | null;
|
|
1390
|
+
try {
|
|
1391
|
+
readBackPrivate = await store.getItemAsync(layout.primaryPrivateKeyName, primaryReadOpts);
|
|
1392
|
+
readBackPublic = await store.getItemAsync(layout.primaryPublicKeyName, primaryReadOpts);
|
|
1393
|
+
} catch (error) {
|
|
1394
|
+
logger.error('Failed to read identity back after write', error, { component: 'KeyManager' });
|
|
1395
|
+
await KeyManager._rollbackPrimary(store, layout, priorPrivate, priorPublic);
|
|
1396
|
+
throw new IdentityPersistError('Failed to verify identity after write', error);
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// Hex comparisons are case-insensitive — normalize on both sides so a
|
|
1400
|
+
// store that uppercases on round-trip (some keychain backends) doesn't
|
|
1401
|
+
// trigger a spurious mismatch.
|
|
1402
|
+
if (
|
|
1403
|
+
readBackPrivate?.toLowerCase() !== canonicalPrivate ||
|
|
1404
|
+
readBackPublic?.toLowerCase() !== canonicalPublic
|
|
1405
|
+
) {
|
|
1406
|
+
logger.error('Identity round-trip mismatch after write', undefined, { component: 'KeyManager' });
|
|
1407
|
+
await KeyManager._rollbackPrimary(store, layout, priorPrivate, priorPublic);
|
|
1408
|
+
throw new IdentityPersistError('Identity write was not persisted correctly (round-trip mismatch).');
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
// Final sanity: derive public from the stored private and confirm the
|
|
1412
|
+
// pair signs/verifies cleanly. Catches crypto-state corruption immediately
|
|
1413
|
+
// rather than the next time the user tries to sign in.
|
|
1414
|
+
try {
|
|
1415
|
+
const privateKey = KeyManager.canonicalPrivateKey(readBackPrivate);
|
|
1416
|
+
const derived = deriveSecp256k1PublicKey(privateKey);
|
|
1417
|
+
if (derived.toLowerCase() !== readBackPublic.toLowerCase()) {
|
|
1418
|
+
throw new IdentityPersistError('Stored public key does not match derived public key.');
|
|
1419
|
+
}
|
|
1420
|
+
// Sign/verify roundtrip using a known test vector
|
|
1421
|
+
const probeHash = '0'.repeat(64);
|
|
1422
|
+
const signature = signSecp256k1Digest(privateKey, probeHash);
|
|
1423
|
+
if (!verifySecp256k1Digest(derived, probeHash, signature)) {
|
|
1424
|
+
throw new IdentityPersistError('Sign/verify roundtrip failed for newly stored identity.');
|
|
1425
|
+
}
|
|
1426
|
+
} catch (error) {
|
|
1427
|
+
await KeyManager._rollbackPrimary(store, layout, priorPrivate, priorPublic);
|
|
1428
|
+
if (error instanceof IdentityPersistError) throw error;
|
|
1429
|
+
logger.error('Identity sign/verify probe failed', error, { component: 'KeyManager' });
|
|
1430
|
+
throw new IdentityPersistError('Stored identity failed crypto self-test', error);
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
// Step 3: The new primary is durable and functional. NOW it is safe to
|
|
1434
|
+
// refresh the backup to the new key. This is part of the successful write
|
|
1435
|
+
// contract: returning success while the backup still belongs to the
|
|
1436
|
+
// previous identity would allow a later restore with an absent primary to
|
|
1437
|
+
// silently switch the device back to the previous account. Snapshot the
|
|
1438
|
+
// backup first so a partial backup refresh can be rolled back along with
|
|
1439
|
+
// the primary before surfacing the failure.
|
|
1440
|
+
let priorBackupPrivate: string | null;
|
|
1441
|
+
let priorBackupPublic: string | null;
|
|
1442
|
+
let priorBackupTimestamp: string | null;
|
|
1443
|
+
try {
|
|
1444
|
+
priorBackupPrivate = await store.getItemAsync(layout.backupPrivateKeyName, backupReadOpts);
|
|
1445
|
+
priorBackupPublic = await store.getItemAsync(layout.backupPublicKeyName, backupReadOpts);
|
|
1446
|
+
priorBackupTimestamp = await store.getItemAsync(layout.backupTimestampName, backupReadOpts);
|
|
1447
|
+
} catch (error) {
|
|
1448
|
+
logger.error('Failed to snapshot identity backup before refresh', error, { component: 'KeyManager' });
|
|
1449
|
+
await KeyManager._rollbackPrimary(store, layout, priorPrivate, priorPublic);
|
|
1450
|
+
throw new IdentityPersistError('Failed to snapshot identity backup before refresh', error);
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
try {
|
|
1454
|
+
await store.setItemAsync(layout.backupPrivateKeyName, canonicalPrivate, backupPrivWriteOpts);
|
|
1455
|
+
await store.setItemAsync(layout.backupPublicKeyName, canonicalPublic, backupPubWriteOpts);
|
|
1456
|
+
await store.setItemAsync(layout.backupTimestampName, Date.now().toString(), backupPubWriteOpts);
|
|
1457
|
+
} catch (error) {
|
|
1458
|
+
logger.error('Failed to refresh identity backup after primary write', error, { component: 'KeyManager' });
|
|
1459
|
+
await KeyManager._rollbackBackup(store, layout, priorBackupPrivate, priorBackupPublic, priorBackupTimestamp);
|
|
1460
|
+
await KeyManager._rollbackPrimary(store, layout, priorPrivate, priorPublic);
|
|
1461
|
+
throw new IdentityPersistError('Failed to refresh identity backup after primary write', error);
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
// Update cache only after we are certain the identity is durable, then fan
|
|
1465
|
+
// out to identity-change subscribers.
|
|
1466
|
+
KeyManager.cachedPublicKey = canonicalPublic;
|
|
1467
|
+
KeyManager.cachedHasIdentity = true;
|
|
1468
|
+
KeyManager.cachedPublicKeyResolved = false;
|
|
1469
|
+
KeyManager.notifyIdentityChanged();
|
|
1470
|
+
|
|
1471
|
+
// LAST step: mirror the identity into the AndroidKeyStore-independent marker
|
|
1472
|
+
// so a later keystore death can be told apart from a fresh install. This is
|
|
1473
|
+
// best-effort — a marker write failure must NEVER fail an otherwise-durable
|
|
1474
|
+
// persist (a subsequent healthy read re-backfills it). Rollback paths above
|
|
1475
|
+
// return before reaching here, so they never touch the marker.
|
|
1476
|
+
await KeyManager._syncMarkerAfterPersist(canonicalPublic, origin);
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
/**
|
|
1480
|
+
* Write/refresh the identity marker after a successful persist. A same-identity
|
|
1481
|
+
* re-persist (e.g. backup refresh, idempotent re-import) preserves `createdAt`
|
|
1482
|
+
* and the `onboardingComplete` milestone by only updating `origin`; a NEW or
|
|
1483
|
+
* switched identity writes a fresh marker. Best-effort — never throws.
|
|
1484
|
+
*
|
|
1485
|
+
* @internal
|
|
1486
|
+
*/
|
|
1487
|
+
private static async _syncMarkerAfterPersist(
|
|
1488
|
+
publicKey: string,
|
|
1489
|
+
origin: IdentityMarker['origin'],
|
|
1490
|
+
): Promise<void> {
|
|
1491
|
+
try {
|
|
1492
|
+
const existing = await readIdentityMarker();
|
|
1493
|
+
if (existing && existing.publicKey.toLowerCase() === publicKey.toLowerCase()) {
|
|
1494
|
+
await updateIdentityMarker({ origin });
|
|
1495
|
+
} else {
|
|
1496
|
+
await writeIdentityMarker({ publicKey, origin });
|
|
1497
|
+
}
|
|
1498
|
+
} catch (error) {
|
|
1499
|
+
logger.warn('Failed to sync identity marker after persist (non-fatal)', { component: 'KeyManager' }, error);
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
/**
|
|
1504
|
+
* Restore the backup slot to a previously-snapshotted state. Best-effort so
|
|
1505
|
+
* the original persistence error remains the one surfaced to the caller.
|
|
1506
|
+
*
|
|
1507
|
+
* @internal
|
|
1508
|
+
*/
|
|
1509
|
+
private static async _rollbackBackup(
|
|
1510
|
+
store: Awaited<ReturnType<typeof initSecureStore>>,
|
|
1511
|
+
layout: ResolvedSlotLayout,
|
|
1512
|
+
priorBackupPrivate: string | null,
|
|
1513
|
+
priorBackupPublic: string | null,
|
|
1514
|
+
priorBackupTimestamp: string | null,
|
|
1515
|
+
): Promise<void> {
|
|
1516
|
+
const backupPrivWriteOpts = KeyManager._privateWriteOpts(store, layout.backupService);
|
|
1517
|
+
const backupPubWriteOpts = KeyManager._slotOpts(layout.backupService);
|
|
1518
|
+
const backupReadOpts = KeyManager._slotOpts(layout.backupService);
|
|
1519
|
+
try {
|
|
1520
|
+
if (priorBackupPrivate) {
|
|
1521
|
+
await store.setItemAsync(layout.backupPrivateKeyName, priorBackupPrivate, backupPrivWriteOpts);
|
|
1522
|
+
} else {
|
|
1523
|
+
try { await store.deleteItemAsync(layout.backupPrivateKeyName, backupReadOpts); } catch { /* best effort */ }
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
if (priorBackupPublic) {
|
|
1527
|
+
await store.setItemAsync(layout.backupPublicKeyName, priorBackupPublic, backupPubWriteOpts);
|
|
1528
|
+
} else {
|
|
1529
|
+
try { await store.deleteItemAsync(layout.backupPublicKeyName, backupReadOpts); } catch { /* best effort */ }
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
if (priorBackupTimestamp) {
|
|
1533
|
+
await store.setItemAsync(layout.backupTimestampName, priorBackupTimestamp, backupPubWriteOpts);
|
|
1534
|
+
} else {
|
|
1535
|
+
try { await store.deleteItemAsync(layout.backupTimestampName, backupReadOpts); } catch { /* best effort */ }
|
|
1536
|
+
}
|
|
1537
|
+
} catch (rollbackError) {
|
|
1538
|
+
logger.error('Failed to roll back identity backup after a failed refresh', rollbackError, { component: 'KeyManager' });
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
/**
|
|
1543
|
+
* Restore the primary slot to a previously-snapshotted (privA, pubA) pair,
|
|
1544
|
+
* or delete it entirely if there was no prior identity. Best-effort: every
|
|
1545
|
+
* step is wrapped so a rollback failure never masks the original error the
|
|
1546
|
+
* caller is about to throw. Invalidates the in-memory cache so the next read
|
|
1547
|
+
* reflects whatever actually landed on disk.
|
|
1548
|
+
*
|
|
1549
|
+
* @internal
|
|
1550
|
+
*/
|
|
1551
|
+
private static async _rollbackPrimary(
|
|
1552
|
+
store: Awaited<ReturnType<typeof initSecureStore>>,
|
|
1553
|
+
layout: ResolvedSlotLayout,
|
|
1554
|
+
priorPrivate: string | null,
|
|
1555
|
+
priorPublic: string | null,
|
|
1556
|
+
): Promise<void> {
|
|
1557
|
+
const primaryPrivWriteOpts = KeyManager._privateWriteOpts(store, layout.primaryService);
|
|
1558
|
+
const primaryPubWriteOpts = KeyManager._slotOpts(layout.primaryService);
|
|
1559
|
+
const primaryReadOpts = KeyManager._slotOpts(layout.primaryService);
|
|
1560
|
+
try {
|
|
1561
|
+
if (priorPrivate && priorPublic) {
|
|
1562
|
+
// Restore exactly what was there before the failed write.
|
|
1563
|
+
await store.setItemAsync(layout.primaryPublicKeyName, priorPublic, primaryPubWriteOpts);
|
|
1564
|
+
await store.setItemAsync(layout.primaryPrivateKeyName, priorPrivate, primaryPrivWriteOpts);
|
|
1565
|
+
} else {
|
|
1566
|
+
// There was no prior identity — leave the device empty rather than
|
|
1567
|
+
// half-written so hasIdentity() does not lie.
|
|
1568
|
+
try { await store.deleteItemAsync(layout.primaryPublicKeyName, primaryReadOpts); } catch { /* best effort */ }
|
|
1569
|
+
try { await store.deleteItemAsync(layout.primaryPrivateKeyName, primaryReadOpts); } catch { /* best effort */ }
|
|
1570
|
+
}
|
|
1571
|
+
} catch (rollbackError) {
|
|
1572
|
+
logger.error('Failed to roll back primary identity after a failed write', rollbackError, { component: 'KeyManager' });
|
|
1573
|
+
} finally {
|
|
1574
|
+
// Whatever happened, the cached verdict is no longer trustworthy.
|
|
1575
|
+
KeyManager.invalidateCache();
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
/**
|
|
1580
|
+
* Generate and securely store a new key pair on the device.
|
|
1581
|
+
*
|
|
1582
|
+
* Refuses to overwrite an existing identity unless `options.overwrite === true`.
|
|
1583
|
+
* Returns the public key. The private key never leaves secure storage.
|
|
1584
|
+
*
|
|
1585
|
+
* @throws IdentityAlreadyExistsError if an identity already exists and overwrite is not set
|
|
1586
|
+
* @throws IdentityPersistError if the key cannot be durably written
|
|
1587
|
+
*/
|
|
1588
|
+
static async createIdentity(options?: { overwrite?: boolean }): Promise<string> {
|
|
1589
|
+
if (isWebPlatform()) {
|
|
1590
|
+
throw new Error('Identity creation is only available on native platforms (iOS/Android). Please use the native app to create your identity.');
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
// CRITICAL SAFEGUARD: never silently overwrite an existing identity.
|
|
1594
|
+
// The local key IS the account — clobbering it without consent is
|
|
1595
|
+
// catastrophic. Callers must opt in explicitly when they have already
|
|
1596
|
+
// confirmed (via UI) that the user has saved their recovery phrase.
|
|
1597
|
+
//
|
|
1598
|
+
// The guard reads storage DIRECTLY (cache-bypassing) AND consults the
|
|
1599
|
+
// AndroidKeyStore-independent marker: either a stored key OR a marker means
|
|
1600
|
+
// an identity exists here → refuse. A storage THROW surfaces as
|
|
1601
|
+
// IdentityUnavailableError (never a blind write over a locked keystore).
|
|
1602
|
+
if (!options?.overwrite) {
|
|
1603
|
+
const marker = await readIdentityMarker();
|
|
1604
|
+
const direct = await KeyManager._readPrimaryDirect();
|
|
1605
|
+
if (direct.publicKey) {
|
|
1606
|
+
throw new IdentityAlreadyExistsError(direct.publicKey);
|
|
1607
|
+
}
|
|
1608
|
+
if (marker) {
|
|
1609
|
+
throw new IdentityAlreadyExistsError(marker.publicKey);
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
const { privateKey, publicKey } = await KeyManager.generateKeyPair();
|
|
1614
|
+
await KeyManager._persistIdentityAtomic(privateKey, publicKey, 'create');
|
|
1615
|
+
return publicKey;
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
/**
|
|
1619
|
+
* Read the primary key pair DIRECTLY from storage, bypassing the in-memory
|
|
1620
|
+
* cache (which a prior transient failure could have poisoned). Awaits slot
|
|
1621
|
+
* migration first. Throws {@link IdentityUnavailableError} if storage is
|
|
1622
|
+
* deferred/locked or a read throws — so overwrite guards never write blind.
|
|
1623
|
+
*
|
|
1624
|
+
* @internal
|
|
1625
|
+
*/
|
|
1626
|
+
private static async _readPrimaryDirect(): Promise<{ privateKey: string | null; publicKey: string | null }> {
|
|
1627
|
+
const migration = await KeyManager._ensureIdentitySlotsMigrated();
|
|
1628
|
+
if (migration.mode === 'deferred') {
|
|
1629
|
+
throw new IdentityUnavailableError(
|
|
1630
|
+
'Identity storage is temporarily unavailable; refusing to write blind.',
|
|
1631
|
+
migration.cause,
|
|
1632
|
+
);
|
|
1633
|
+
}
|
|
1634
|
+
const layout = migration.layout;
|
|
1635
|
+
const readOpts = KeyManager._slotOpts(layout.primaryService);
|
|
1636
|
+
try {
|
|
1637
|
+
const store = await initSecureStore();
|
|
1638
|
+
const privateKey = await store.getItemAsync(layout.primaryPrivateKeyName, readOpts);
|
|
1639
|
+
const publicKey = await store.getItemAsync(layout.primaryPublicKeyName, readOpts);
|
|
1640
|
+
return { privateKey, publicKey };
|
|
1641
|
+
} catch (error) {
|
|
1642
|
+
throw new IdentityUnavailableError('Could not read existing identity; refusing to write blind.', error);
|
|
1643
|
+
}
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
/**
|
|
1647
|
+
* Import an existing key pair (e.g., from recovery phrase).
|
|
1648
|
+
*
|
|
1649
|
+
* Refuses to overwrite an existing identity unless `options.overwrite === true`.
|
|
1650
|
+
*
|
|
1651
|
+
* @throws IdentityAlreadyExistsError if an identity already exists and overwrite is not set
|
|
1652
|
+
* @throws IdentityPersistError if the key cannot be durably written
|
|
1653
|
+
*/
|
|
1654
|
+
static async importKeyPair(
|
|
1655
|
+
privateKey: string,
|
|
1656
|
+
options?: { overwrite?: boolean },
|
|
1657
|
+
): Promise<string> {
|
|
1658
|
+
if (isWebPlatform()) {
|
|
1659
|
+
throw new Error('Identity import is only available on native platforms (iOS/Android). Please use the native app to import your identity.');
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
if (!KeyManager.isValidPrivateKey(privateKey)) {
|
|
1663
|
+
throw new Error('Invalid private key supplied to importKeyPair.');
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
// Canonicalize the incoming private key so the stored value (and the
|
|
1667
|
+
// derived public key) are always in canonical form. Without this, an
|
|
1668
|
+
// externally-imported short or uppercase key would derive one public
|
|
1669
|
+
// key here and a different one when later read back unpadded.
|
|
1670
|
+
const canonicalPrivate = KeyManager.canonicalPrivateKey(privateKey);
|
|
1671
|
+
const publicKey = deriveSecp256k1PublicKey(canonicalPrivate);
|
|
1672
|
+
|
|
1673
|
+
// Refuse silent overwrite — see createIdentity() for rationale. The guard
|
|
1674
|
+
// reads storage DIRECTLY (cache-bypassing) AND the marker, and treats
|
|
1675
|
+
// storage as authoritative:
|
|
1676
|
+
// - stored key === this import → safe idempotent refresh (fall through)
|
|
1677
|
+
// - stored key differs → a DIFFERENT identity is present → refuse
|
|
1678
|
+
// - storage empty + marker for a DIFFERENT identity (lost state) → refuse
|
|
1679
|
+
// - storage empty + marker matches this import (recovery) / no marker → allow
|
|
1680
|
+
// A storage throw surfaces as IdentityUnavailableError (never a blind write).
|
|
1681
|
+
if (!options?.overwrite) {
|
|
1682
|
+
const marker = await readIdentityMarker();
|
|
1683
|
+
const direct = await KeyManager._readPrimaryDirect();
|
|
1684
|
+
const importedPub = publicKey.toLowerCase();
|
|
1685
|
+
const existingPub = direct.publicKey?.toLowerCase() ?? null;
|
|
1686
|
+
const markerPub = marker?.publicKey.toLowerCase() ?? null;
|
|
1687
|
+
|
|
1688
|
+
if (existingPub && existingPub !== importedPub) {
|
|
1689
|
+
throw new IdentityAlreadyExistsError(direct.publicKey as string);
|
|
1690
|
+
}
|
|
1691
|
+
if (!existingPub && markerPub && markerPub !== importedPub) {
|
|
1692
|
+
throw new IdentityAlreadyExistsError(marker?.publicKey as string);
|
|
1693
|
+
}
|
|
1694
|
+
// Otherwise: existing === import (idempotent refresh), or storage empty
|
|
1695
|
+
// with a matching/absent marker (fresh import or lost-identity recovery)
|
|
1696
|
+
// → fall through and (re-)persist to refresh the backup + marker.
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
await KeyManager._persistIdentityAtomic(canonicalPrivate, publicKey, 'import');
|
|
1700
|
+
return publicKey;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
/**
|
|
1704
|
+
* Get the stored private key
|
|
1705
|
+
* WARNING: Only use this for signing operations within the app
|
|
1706
|
+
*
|
|
1707
|
+
* Preserves the "return null on any storage failure" contract signing paths
|
|
1708
|
+
* rely on (a locked keychain simply means "cannot sign now"); unlike
|
|
1709
|
+
* {@link getPublicKey}, it does NOT throw {@link IdentityUnavailableError}.
|
|
1710
|
+
*/
|
|
1711
|
+
static async getPrivateKey(): Promise<string | null> {
|
|
1712
|
+
if (isWebPlatform()) {
|
|
1713
|
+
return null; // Identity storage is only available on native platforms
|
|
1714
|
+
}
|
|
1715
|
+
try {
|
|
1716
|
+
const migration = await KeyManager._ensureIdentitySlotsMigrated();
|
|
1717
|
+
if (migration.mode === 'deferred') {
|
|
1718
|
+
// Storage unreadable right now — preserve the null contract.
|
|
1719
|
+
return null;
|
|
1720
|
+
}
|
|
1721
|
+
const store = await initSecureStore();
|
|
1722
|
+
return await store.getItemAsync(
|
|
1723
|
+
migration.layout.primaryPrivateKeyName,
|
|
1724
|
+
KeyManager._slotOpts(migration.layout.primaryService),
|
|
1725
|
+
);
|
|
1726
|
+
} catch (error) {
|
|
1727
|
+
// If secure store is not available, return null (no identity)
|
|
1728
|
+
// This allows the app to continue functioning even if secure store fails to load
|
|
1729
|
+
if (isDev()) {
|
|
1730
|
+
logger.warn('Failed to access secure store', { component: 'KeyManager' }, error);
|
|
1731
|
+
}
|
|
1732
|
+
return null;
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
/**
|
|
1737
|
+
* Get the stored public key (cached for performance).
|
|
1738
|
+
*
|
|
1739
|
+
* Returns the public key, or `null` when a read SUCCEEDS and finds none.
|
|
1740
|
+
* THROWS {@link IdentityUnavailableError} when storage is unreadable (keychain
|
|
1741
|
+
* locked / module load failure) — a thrown read is NEVER flattened to `null`
|
|
1742
|
+
* and NEVER cached, so a poisoned "no identity" verdict can no longer stick.
|
|
1743
|
+
*/
|
|
1744
|
+
static async getPublicKey(): Promise<string | null> {
|
|
1745
|
+
if (isWebPlatform()) {
|
|
1746
|
+
return null; // Identity storage is only available on native platforms
|
|
1747
|
+
}
|
|
1748
|
+
if (KeyManager.cachedPublicKey !== null) {
|
|
1749
|
+
return KeyManager.cachedPublicKey;
|
|
1750
|
+
}
|
|
1751
|
+
// A genuine-absent result (read succeeded, empty) is cacheable distinctly
|
|
1752
|
+
// from a thrown read — only the former sets this flag.
|
|
1753
|
+
if (KeyManager.cachedPublicKeyResolved) {
|
|
1754
|
+
return null;
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
const migration = await KeyManager._ensureIdentitySlotsMigrated();
|
|
1758
|
+
if (migration.mode === 'deferred') {
|
|
1759
|
+
throw new IdentityUnavailableError(
|
|
1760
|
+
'Identity storage is temporarily unavailable (keychain locked or unreadable).',
|
|
1761
|
+
migration.cause,
|
|
1762
|
+
);
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
try {
|
|
1766
|
+
const store = await initSecureStore();
|
|
1767
|
+
const publicKey = await store.getItemAsync(
|
|
1768
|
+
migration.layout.primaryPublicKeyName,
|
|
1769
|
+
KeyManager._slotOpts(migration.layout.primaryService),
|
|
1770
|
+
);
|
|
1771
|
+
if (publicKey !== null) {
|
|
1772
|
+
KeyManager.cachedPublicKey = publicKey;
|
|
1773
|
+
} else {
|
|
1774
|
+
// Genuine-absent (successful empty read) IS safe to cache.
|
|
1775
|
+
KeyManager.cachedPublicKeyResolved = true;
|
|
1776
|
+
}
|
|
1777
|
+
return publicKey;
|
|
1778
|
+
} catch (error) {
|
|
1779
|
+
// Storage threw AFTER migration resolved — transient/unavailable. Do NOT
|
|
1780
|
+
// cache; surface a typed error so callers never misread it as "no identity".
|
|
1781
|
+
if (isDev()) {
|
|
1782
|
+
logger.warn('Failed to access secure store', { component: 'KeyManager' }, error);
|
|
1783
|
+
}
|
|
1784
|
+
throw new IdentityUnavailableError('Failed to read identity from secure storage.', error);
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
/**
|
|
1789
|
+
* Persist the recovery mnemonic (the 12-word phrase) into its dedicated,
|
|
1790
|
+
* device-only keychain slot so the user can re-reveal it from Settings after
|
|
1791
|
+
* onboarding.
|
|
1792
|
+
*
|
|
1793
|
+
* Called best-effort at identity creation/import, where the phrase is already
|
|
1794
|
+
* in memory: a failure to persist it must NEVER fail the identity itself, so
|
|
1795
|
+
* callers deliberately swallow the thrown error (logging it). Storage errors
|
|
1796
|
+
* throw {@link IdentityUnavailableError} — same "cannot determine" semantics as
|
|
1797
|
+
* the other getters — so a caller MAY observe/log the failure.
|
|
1798
|
+
*
|
|
1799
|
+
* The mnemonic is stored ONLY here — never in the marker, `getIdentityStatus`,
|
|
1800
|
+
* logs, or any exported bundle.
|
|
1801
|
+
*/
|
|
1802
|
+
static async storeRecoveryMnemonic(mnemonic: string): Promise<void> {
|
|
1803
|
+
if (isWebPlatform()) {
|
|
1804
|
+
return; // Identity storage is only available on native platforms
|
|
1805
|
+
}
|
|
1806
|
+
try {
|
|
1807
|
+
const store = await initSecureStore();
|
|
1808
|
+
await store.setItemAsync(
|
|
1809
|
+
RECOVERY_MNEMONIC_STORAGE_KEY,
|
|
1810
|
+
mnemonic,
|
|
1811
|
+
KeyManager._privateWriteOpts(store, RECOVERY_MNEMONIC_KEYCHAIN_SERVICE),
|
|
1812
|
+
);
|
|
1813
|
+
} catch (error) {
|
|
1814
|
+
if (isDev()) {
|
|
1815
|
+
logger.warn('Failed to persist recovery mnemonic', { component: 'KeyManager' }, error);
|
|
1816
|
+
}
|
|
1817
|
+
throw new IdentityUnavailableError('Failed to persist recovery mnemonic.', error);
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
/**
|
|
1822
|
+
* Read the stored recovery mnemonic for re-reveal in Settings.
|
|
1823
|
+
*
|
|
1824
|
+
* Returns the phrase, or `null` when a read SUCCEEDS and finds none — the
|
|
1825
|
+
* expected result for any identity created/imported before this feature
|
|
1826
|
+
* existed, since the phrase was never captured for those. THROWS
|
|
1827
|
+
* {@link IdentityUnavailableError} when storage is unreadable (keychain locked
|
|
1828
|
+
* / module load failure), matching {@link getPublicKey}'s contract — a thrown
|
|
1829
|
+
* read is never flattened to `null`, so a caller distinguishes "phrase was
|
|
1830
|
+
* never stored" from "keychain temporarily locked, retry".
|
|
1831
|
+
*/
|
|
1832
|
+
static async getRecoveryMnemonic(): Promise<string | null> {
|
|
1833
|
+
if (isWebPlatform()) {
|
|
1834
|
+
return null; // Identity storage is only available on native platforms
|
|
1835
|
+
}
|
|
1836
|
+
try {
|
|
1837
|
+
const store = await initSecureStore();
|
|
1838
|
+
return await store.getItemAsync(
|
|
1839
|
+
RECOVERY_MNEMONIC_STORAGE_KEY,
|
|
1840
|
+
KeyManager._slotOpts(RECOVERY_MNEMONIC_KEYCHAIN_SERVICE),
|
|
1841
|
+
);
|
|
1842
|
+
} catch (error) {
|
|
1843
|
+
if (isDev()) {
|
|
1844
|
+
logger.warn('Failed to read recovery mnemonic', { component: 'KeyManager' }, error);
|
|
1845
|
+
}
|
|
1846
|
+
throw new IdentityUnavailableError('Failed to read recovery mnemonic from secure storage.', error);
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
/**
|
|
1851
|
+
* Delete the stored recovery mnemonic. Best-effort: a delete failure is logged
|
|
1852
|
+
* and swallowed, never thrown — it runs inside the identity-deletion path where
|
|
1853
|
+
* an unreadable keychain must not abort the wider teardown.
|
|
1854
|
+
*/
|
|
1855
|
+
static async deleteRecoveryMnemonic(): Promise<void> {
|
|
1856
|
+
if (isWebPlatform()) {
|
|
1857
|
+
return; // Identity storage is only available on native platforms
|
|
1858
|
+
}
|
|
1859
|
+
const store = await initSecureStore();
|
|
1860
|
+
await KeyManager._bestEffortDelete(store, RECOVERY_MNEMONIC_STORAGE_KEY, RECOVERY_MNEMONIC_KEYCHAIN_SERVICE);
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
/**
|
|
1864
|
+
* Check if a complete, parseable identity exists on this device.
|
|
1865
|
+
*
|
|
1866
|
+
* Returns `true` only when BOTH the private and public keys are present,
|
|
1867
|
+
* both are well-formed, AND the public key derives from the private key.
|
|
1868
|
+
* A partially-written or corrupted identity (read succeeded, bytes empty/bad)
|
|
1869
|
+
* returns `false` so that downstream code can resume the create / restore flow.
|
|
1870
|
+
* THROWS {@link IdentityUnavailableError} when storage is unreadable — a locked
|
|
1871
|
+
* keychain must never be mistaken for "no identity" (the old behavior that let
|
|
1872
|
+
* onboarding treat a transient lock as a blank device).
|
|
1873
|
+
*
|
|
1874
|
+
* Note: this does NOT perform the full sign/verify roundtrip — call
|
|
1875
|
+
* `verifyIdentityIntegrity()` for that.
|
|
1876
|
+
*/
|
|
1877
|
+
static async hasIdentity(): Promise<boolean> {
|
|
1878
|
+
if (isWebPlatform()) {
|
|
1879
|
+
return false; // Identity storage is only available on native platforms
|
|
1880
|
+
}
|
|
1881
|
+
if (KeyManager.cachedHasIdentity !== null) {
|
|
1882
|
+
return KeyManager.cachedHasIdentity;
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
const migration = await KeyManager._ensureIdentitySlotsMigrated();
|
|
1886
|
+
if (migration.mode === 'deferred') {
|
|
1887
|
+
throw new IdentityUnavailableError('Identity storage is temporarily unavailable.', migration.cause);
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
let privateKey: string | null;
|
|
1891
|
+
let publicKey: string | null;
|
|
1892
|
+
try {
|
|
1893
|
+
const store = await initSecureStore();
|
|
1894
|
+
[privateKey, publicKey] = await Promise.all([
|
|
1895
|
+
store.getItemAsync(migration.layout.primaryPrivateKeyName, KeyManager._slotOpts(migration.layout.primaryService)),
|
|
1896
|
+
store.getItemAsync(migration.layout.primaryPublicKeyName, KeyManager._slotOpts(migration.layout.primaryService)),
|
|
1897
|
+
]);
|
|
1898
|
+
} catch (error) {
|
|
1899
|
+
// Storage threw — could be a transient keychain lock (e.g., background
|
|
1900
|
+
// fetch before the device is unlocked). Do NOT cache; throw a TYPED error
|
|
1901
|
+
// so callers distinguish "temporarily unavailable" from "genuinely absent"
|
|
1902
|
+
// instead of silently treating a locked keystore as a blank device.
|
|
1903
|
+
logger.error('Failed to read identity from secure storage', error, { component: 'KeyManager' });
|
|
1904
|
+
throw new IdentityUnavailableError('Failed to read identity from secure storage.', error);
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
// Storage succeeded. Now classify the result. From here onward, any
|
|
1908
|
+
// outcome is stable and safe to cache (the bytes won't change between
|
|
1909
|
+
// calls).
|
|
1910
|
+
let hasIdentity = false;
|
|
1911
|
+
if (privateKey && publicKey) {
|
|
1912
|
+
// Require BOTH bytes-present AND parseable AND matching. Any weaker
|
|
1913
|
+
// check would let a half-written identity (private without public)
|
|
1914
|
+
// pretend to be a real one, which then fails opaquely later in the
|
|
1915
|
+
// sign-in flow when SignatureService.sign() can't find the keypair.
|
|
1916
|
+
if (KeyManager.isValidPrivateKey(privateKey) && KeyManager.isValidPublicKey(publicKey)) {
|
|
1917
|
+
try {
|
|
1918
|
+
const derived = deriveSecp256k1PublicKey(
|
|
1919
|
+
KeyManager.canonicalPrivateKey(privateKey),
|
|
1920
|
+
);
|
|
1921
|
+
// Hex equality is case-insensitive; normalize on both sides to
|
|
1922
|
+
// tolerate legacy uppercase-stored public keys.
|
|
1923
|
+
hasIdentity = derived.toLowerCase() === publicKey.toLowerCase();
|
|
1924
|
+
if (!hasIdentity) {
|
|
1925
|
+
logger.warn(
|
|
1926
|
+
'KeyManager.hasIdentity: stored public key does not match derived public key',
|
|
1927
|
+
{ component: 'KeyManager' },
|
|
1928
|
+
);
|
|
1929
|
+
}
|
|
1930
|
+
} catch (error) {
|
|
1931
|
+
logger.warn(
|
|
1932
|
+
'KeyManager.hasIdentity: failed to derive public key from stored private key',
|
|
1933
|
+
{ component: 'KeyManager' },
|
|
1934
|
+
error,
|
|
1935
|
+
);
|
|
1936
|
+
}
|
|
1937
|
+
} else {
|
|
1938
|
+
logger.warn(
|
|
1939
|
+
'KeyManager.hasIdentity: stored key material is malformed',
|
|
1940
|
+
{ component: 'KeyManager' },
|
|
1941
|
+
);
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
// Cache result. Storage succeeded, so this verdict is stable:
|
|
1946
|
+
// - true → identity exists and round-trips cleanly
|
|
1947
|
+
// - false → storage is empty / partial / malformed (a stable result;
|
|
1948
|
+
// callers should run integrity-recovery / restore from
|
|
1949
|
+
// backup explicitly)
|
|
1950
|
+
KeyManager.cachedHasIdentity = hasIdentity;
|
|
1951
|
+
if (hasIdentity && publicKey) {
|
|
1952
|
+
KeyManager.cachedPublicKey = publicKey;
|
|
1953
|
+
}
|
|
1954
|
+
// Diagnostic breadcrumb (dev only). Logs lengths + validity flags so we
|
|
1955
|
+
// can tell from `adb logcat` exactly WHY hasIdentity returned what it
|
|
1956
|
+
// did. Never log the key material itself.
|
|
1957
|
+
if (isDev()) {
|
|
1958
|
+
logger.debug(
|
|
1959
|
+
'KeyManager.hasIdentity result',
|
|
1960
|
+
{ component: 'KeyManager' },
|
|
1961
|
+
{
|
|
1962
|
+
privateLen: privateKey?.length ?? 0,
|
|
1963
|
+
publicLen: publicKey?.length ?? 0,
|
|
1964
|
+
privateValid: privateKey ? KeyManager.isValidPrivateKey(privateKey) : null,
|
|
1965
|
+
publicValid: publicKey ? KeyManager.isValidPublicKey(publicKey) : null,
|
|
1966
|
+
derived: hasIdentity,
|
|
1967
|
+
},
|
|
1968
|
+
);
|
|
1969
|
+
}
|
|
1970
|
+
return hasIdentity;
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
/**
|
|
1974
|
+
* Authoritative identity verdict — the corruption-vs-fresh-install
|
|
1975
|
+
* disambiguator that routing (commons) keys off of.
|
|
1976
|
+
*
|
|
1977
|
+
* - Healthy pair → `present` (and the marker is backfilled if missing or
|
|
1978
|
+
* pointing at a different key, `origin: 'backfill'`).
|
|
1979
|
+
* - Read succeeded but no healthy pair, WITH a marker → `lost` (keystore death
|
|
1980
|
+
* / corruption; route to recovery, NEVER to create).
|
|
1981
|
+
* - Read succeeded, no pair, NO marker → `absent` (a genuine fresh device; the
|
|
1982
|
+
* only state that may route to onboarding/create).
|
|
1983
|
+
* - A read THREW → `unavailable` (keychain locked); this verdict is NEVER
|
|
1984
|
+
* cached, so a later call re-reads.
|
|
1985
|
+
*
|
|
1986
|
+
* @param opts.bypassCache When true, never reads OR writes the in-memory cache
|
|
1987
|
+
* — a pure, fresh storage verdict for the auto-create interlock preflight.
|
|
1988
|
+
*/
|
|
1989
|
+
static async getIdentityStatus(opts?: { bypassCache?: boolean }): Promise<IdentityStatus> {
|
|
1990
|
+
if (isWebPlatform()) {
|
|
1991
|
+
return { state: 'absent' }; // Identity storage is only available on native platforms
|
|
1992
|
+
}
|
|
1993
|
+
const bypassCache = opts?.bypassCache === true;
|
|
1994
|
+
|
|
1995
|
+
// Read the marker FIRST (fail-open null) — it is the AndroidKeyStore-independent
|
|
1996
|
+
// signal that survives a keystore death.
|
|
1997
|
+
const marker = await readIdentityMarker();
|
|
1998
|
+
|
|
1999
|
+
const migration = await KeyManager._ensureIdentitySlotsMigrated();
|
|
2000
|
+
if (migration.mode === 'deferred') {
|
|
2001
|
+
return { state: 'unavailable', cause: migration.cause };
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
let privateKey: string | null;
|
|
2005
|
+
let publicKey: string | null;
|
|
2006
|
+
try {
|
|
2007
|
+
const store = await initSecureStore();
|
|
2008
|
+
const readOpts = KeyManager._slotOpts(migration.layout.primaryService);
|
|
2009
|
+
privateKey = await store.getItemAsync(migration.layout.primaryPrivateKeyName, readOpts);
|
|
2010
|
+
publicKey = await store.getItemAsync(migration.layout.primaryPublicKeyName, readOpts);
|
|
2011
|
+
} catch (error) {
|
|
2012
|
+
// Storage threw — NEVER cache this verdict; callers retry.
|
|
2013
|
+
return { state: 'unavailable', cause: error };
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
if (KeyManager._isHealthyPair(privateKey, publicKey) && publicKey) {
|
|
2017
|
+
const canonicalPublic = publicKey.toLowerCase();
|
|
2018
|
+
// Backfill the marker when missing or pointing at a DIFFERENT identity —
|
|
2019
|
+
// e.g. a loss that predates markers, healed on first healthy read.
|
|
2020
|
+
if (!marker || marker.publicKey.toLowerCase() !== canonicalPublic) {
|
|
2021
|
+
try {
|
|
2022
|
+
await writeIdentityMarker({ publicKey: canonicalPublic, origin: 'backfill' });
|
|
2023
|
+
} catch (error) {
|
|
2024
|
+
logger.warn('Failed to backfill identity marker', { component: 'KeyManager' }, error);
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
if (!bypassCache) {
|
|
2028
|
+
KeyManager.cachedPublicKey = canonicalPublic;
|
|
2029
|
+
KeyManager.cachedHasIdentity = true;
|
|
2030
|
+
KeyManager.cachedPublicKeyResolved = false;
|
|
2031
|
+
}
|
|
2032
|
+
return { state: 'present', publicKey: canonicalPublic };
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
// Read succeeded but no healthy pair present.
|
|
2036
|
+
if (!bypassCache) {
|
|
2037
|
+
KeyManager.cachedHasIdentity = false;
|
|
2038
|
+
KeyManager.cachedPublicKeyResolved = true;
|
|
2039
|
+
}
|
|
2040
|
+
if (marker) {
|
|
2041
|
+
return { state: 'lost', marker };
|
|
2042
|
+
}
|
|
2043
|
+
return { state: 'absent' };
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
/**
|
|
2047
|
+
* Delete the stored identity (both keys)
|
|
2048
|
+
* Use with EXTREME caution - this is irreversible without a recovery phrase
|
|
2049
|
+
* This should ONLY be called when explicitly requested by the user
|
|
2050
|
+
* @param skipBackup - If true, skip backup before deletion (default: false)
|
|
2051
|
+
* @param force - If true, skip confirmation checks (default: false)
|
|
2052
|
+
* @param userConfirmed - If true, user has explicitly confirmed deletion (default: false)
|
|
2053
|
+
*/
|
|
2054
|
+
static async deleteIdentity(
|
|
2055
|
+
skipBackup = false,
|
|
2056
|
+
force = false,
|
|
2057
|
+
userConfirmed = false
|
|
2058
|
+
): Promise<void> {
|
|
2059
|
+
if (isWebPlatform()) {
|
|
2060
|
+
return; // Identity storage is only available on native platforms, nothing to delete
|
|
2061
|
+
}
|
|
2062
|
+
// CRITICAL SAFEGUARD: Require explicit user confirmation unless force is true
|
|
2063
|
+
if (!force && !userConfirmed) {
|
|
2064
|
+
throw new Error('Identity deletion requires explicit user confirmation. This is a safety measure to prevent accidental data loss.');
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
if (!force) {
|
|
2068
|
+
// May throw IdentityUnavailableError if storage is locked — correct: a
|
|
2069
|
+
// non-force delete must abort rather than run against an unreadable store.
|
|
2070
|
+
const hasIdentity = await KeyManager.hasIdentity();
|
|
2071
|
+
if (!hasIdentity) {
|
|
2072
|
+
return; // Nothing to delete
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
const store = await initSecureStore();
|
|
2077
|
+
|
|
2078
|
+
// ALWAYS create backup before deletion unless explicitly skipped
|
|
2079
|
+
if (!skipBackup) {
|
|
2080
|
+
try {
|
|
2081
|
+
const backupSuccess = await KeyManager.backupIdentity();
|
|
2082
|
+
if (!backupSuccess && isDev()) {
|
|
2083
|
+
logger.warn('Failed to backup identity before deletion - proceeding anyway', { component: 'KeyManager' });
|
|
2084
|
+
}
|
|
2085
|
+
} catch (backupError) {
|
|
2086
|
+
if (isDev()) {
|
|
2087
|
+
logger.warn('Failed to backup identity before deletion', { component: 'KeyManager' }, backupError);
|
|
2088
|
+
}
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
2091
|
+
|
|
2092
|
+
// Delete the primary from the active layout (authoritative), then best-effort
|
|
2093
|
+
// delete BOTH generations so a stale legacy copy can never resurrect the
|
|
2094
|
+
// identity after deletion.
|
|
2095
|
+
const migration = await KeyManager._ensureIdentitySlotsMigrated();
|
|
2096
|
+
if (migration.mode !== 'deferred') {
|
|
2097
|
+
const layout = migration.layout;
|
|
2098
|
+
const readOpts = KeyManager._slotOpts(layout.primaryService);
|
|
2099
|
+
await store.deleteItemAsync(layout.primaryPrivateKeyName, readOpts);
|
|
2100
|
+
await store.deleteItemAsync(layout.primaryPublicKeyName, readOpts);
|
|
2101
|
+
}
|
|
2102
|
+
await KeyManager._bestEffortDeleteV2Primary(store);
|
|
2103
|
+
await KeyManager._bestEffortDelete(store, STORAGE_KEYS.PRIVATE_KEY);
|
|
2104
|
+
await KeyManager._bestEffortDelete(store, STORAGE_KEYS.PUBLIC_KEY);
|
|
2105
|
+
|
|
2106
|
+
// Always drop the stored recovery mnemonic — it is scoped to the identity
|
|
2107
|
+
// being deleted, so a leftover would let Settings reveal a stale phrase for
|
|
2108
|
+
// an identity that no longer exists (or a DIFFERENT one after re-onboarding).
|
|
2109
|
+
await KeyManager.deleteRecoveryMnemonic();
|
|
2110
|
+
|
|
2111
|
+
// Also clear backups + the shared slot on force deletion, so a deleted
|
|
2112
|
+
// identity cannot be resurrected from any recovery source.
|
|
2113
|
+
if (force) {
|
|
2114
|
+
await KeyManager._bestEffortDeleteBackupsAllGenerations(store);
|
|
2115
|
+
await KeyManager._clearSharedSlot(store);
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
// Clear the marker AFTER key deletion succeeds — a marker must never outlive
|
|
2119
|
+
// its identity (a leftover marker would route a truly-absent device to
|
|
2120
|
+
// `recovery` instead of `welcome`).
|
|
2121
|
+
try {
|
|
2122
|
+
await clearIdentityMarker();
|
|
2123
|
+
} catch (error) {
|
|
2124
|
+
logger.warn('Failed to clear identity marker during delete', { component: 'KeyManager' }, error);
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
// Invalidate cache LAST — its subscriber fan-out fires only after both the
|
|
2128
|
+
// keys AND the marker are gone, so a routing subscriber that re-reads on the
|
|
2129
|
+
// notification observes `absent`, never a transient `lost`.
|
|
2130
|
+
KeyManager.invalidateCache();
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
/**
|
|
2134
|
+
* Backup identity to SecureStore (separate backup storage)
|
|
2135
|
+
* This provides a recovery mechanism if primary storage fails
|
|
2136
|
+
*/
|
|
2137
|
+
static async backupIdentity(): Promise<boolean> {
|
|
2138
|
+
if (isWebPlatform()) {
|
|
2139
|
+
return false; // Identity storage is only available on native platforms
|
|
2140
|
+
}
|
|
2141
|
+
try {
|
|
2142
|
+
const store = await initSecureStore();
|
|
2143
|
+
const migration = await KeyManager._ensureIdentitySlotsMigrated();
|
|
2144
|
+
if (migration.mode === 'deferred') {
|
|
2145
|
+
return false; // Cannot read the primary safely → nothing to back up
|
|
2146
|
+
}
|
|
2147
|
+
const layout = migration.layout;
|
|
2148
|
+
// Read the primary DIRECTLY (raw) rather than via getPublicKey (which now
|
|
2149
|
+
// throws) — a locked keychain here should simply mean "nothing to back up".
|
|
2150
|
+
const primaryReadOpts = KeyManager._slotOpts(layout.primaryService);
|
|
2151
|
+
const privateKey = await store.getItemAsync(layout.primaryPrivateKeyName, primaryReadOpts);
|
|
2152
|
+
const publicKey = await store.getItemAsync(layout.primaryPublicKeyName, primaryReadOpts);
|
|
2153
|
+
|
|
2154
|
+
if (!privateKey || !publicKey) {
|
|
2155
|
+
return false; // Nothing to backup
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
// Store backup in SecureStore (still secure, but separate from primary storage)
|
|
2159
|
+
await store.setItemAsync(
|
|
2160
|
+
layout.backupPrivateKeyName,
|
|
2161
|
+
privateKey,
|
|
2162
|
+
KeyManager._privateWriteOpts(store, layout.backupService),
|
|
2163
|
+
);
|
|
2164
|
+
await store.setItemAsync(layout.backupPublicKeyName, publicKey, KeyManager._slotOpts(layout.backupService));
|
|
2165
|
+
await store.setItemAsync(layout.backupTimestampName, Date.now().toString(), KeyManager._slotOpts(layout.backupService));
|
|
2166
|
+
|
|
2167
|
+
return true;
|
|
2168
|
+
} catch (error) {
|
|
2169
|
+
if (isDev()) {
|
|
2170
|
+
logger.error('Failed to backup identity', error, { component: 'KeyManager' });
|
|
2171
|
+
}
|
|
2172
|
+
return false;
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
/**
|
|
2177
|
+
* Verify identity integrity — checks keys are valid, accessible, derive
|
|
2178
|
+
* consistently, AND can sign + verify a probe message.
|
|
2179
|
+
*
|
|
2180
|
+
* Returns true only when the full sign/verify roundtrip succeeds. Use
|
|
2181
|
+
* this on app start to detect silent corruption before the user finds
|
|
2182
|
+
* out by failing to sign in.
|
|
2183
|
+
*/
|
|
2184
|
+
static async verifyIdentityIntegrity(): Promise<boolean> {
|
|
2185
|
+
if (isWebPlatform()) {
|
|
2186
|
+
return false; // Identity storage is only available on native platforms
|
|
2187
|
+
}
|
|
2188
|
+
try {
|
|
2189
|
+
const privateKey = await KeyManager.getPrivateKey();
|
|
2190
|
+
const publicKey = await KeyManager.getPublicKey();
|
|
2191
|
+
|
|
2192
|
+
if (!privateKey || !publicKey) {
|
|
2193
|
+
return false;
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
// Validate formats
|
|
2197
|
+
if (!KeyManager.isValidPrivateKey(privateKey)) {
|
|
2198
|
+
return false;
|
|
2199
|
+
}
|
|
2200
|
+
if (!KeyManager.isValidPublicKey(publicKey)) {
|
|
2201
|
+
return false;
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
// Verify public key derives from private key (case-insensitive
|
|
2205
|
+
// because hex is case-insensitive — legacy uppercase stored values
|
|
2206
|
+
// must still validate).
|
|
2207
|
+
const derivedPublicKey = KeyManager.derivePublicKey(privateKey);
|
|
2208
|
+
if (derivedPublicKey.toLowerCase() !== publicKey.toLowerCase()) {
|
|
2209
|
+
return false; // Keys don't match
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
// Full sign/verify probe — proves the keypair is functional, not just
|
|
2213
|
+
// bytewise parseable. A previous version of this method would return true
|
|
2214
|
+
// without proving the signing primitive could operate on the key.
|
|
2215
|
+
const canonicalPrivateKey = KeyManager.canonicalPrivateKey(privateKey);
|
|
2216
|
+
const probeHash = '0'.repeat(64);
|
|
2217
|
+
const signature = signSecp256k1Digest(canonicalPrivateKey, probeHash);
|
|
2218
|
+
if (!verifySecp256k1Digest(publicKey, probeHash, signature)) {
|
|
2219
|
+
logger.error('Identity sign/verify probe failed during integrity check', undefined, { component: 'KeyManager' });
|
|
2220
|
+
return false;
|
|
2221
|
+
}
|
|
2222
|
+
|
|
2223
|
+
return true;
|
|
2224
|
+
} catch (error) {
|
|
2225
|
+
logger.error('Identity integrity check failed', error, { component: 'KeyManager' });
|
|
2226
|
+
return false;
|
|
2227
|
+
}
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
/**
|
|
2231
|
+
* Restore identity from backup if primary storage is genuinely missing or
|
|
2232
|
+
* corrupt.
|
|
2233
|
+
*
|
|
2234
|
+
* SAFETY (three independent guards against silently switching accounts):
|
|
2235
|
+
* 1. If the primary passes a full sign/verify probe, do nothing.
|
|
2236
|
+
* 2. If the primary keys CANNOT BE READ (storage threw — e.g. a transient
|
|
2237
|
+
* keychain lock during a background launch), do nothing. We must NOT
|
|
2238
|
+
* treat "couldn't read" as "corrupted" and restore a possibly-stale
|
|
2239
|
+
* backup over an identity that is actually fine but momentarily
|
|
2240
|
+
* inaccessible.
|
|
2241
|
+
* 3. If a primary private/public key IS present but does not match the
|
|
2242
|
+
* backup, the backup may belong to a different identity — refuse, so we
|
|
2243
|
+
* never silently switch the user to another account.
|
|
2244
|
+
*
|
|
2245
|
+
* Only when the primary is provably absent (read succeeded, returned
|
|
2246
|
+
* null/empty) or provably corrupt (read succeeded, bytes malformed AND no
|
|
2247
|
+
* conflicting key material is present) do we rebuild it from the backup.
|
|
2248
|
+
*/
|
|
2249
|
+
static async restoreIdentityFromBackup(): Promise<boolean> {
|
|
2250
|
+
if (isWebPlatform()) {
|
|
2251
|
+
return false; // Identity storage is only available on native platforms
|
|
2252
|
+
}
|
|
2253
|
+
try {
|
|
2254
|
+
const store = await initSecureStore();
|
|
2255
|
+
const migration = await KeyManager._ensureIdentitySlotsMigrated();
|
|
2256
|
+
if (migration.mode === 'deferred') {
|
|
2257
|
+
// Storage locked — refuse to restore (guard 2). Retry a later call.
|
|
2258
|
+
logger.warn(
|
|
2259
|
+
'restoreIdentityFromBackup: identity storage unavailable. Refusing to restore.',
|
|
2260
|
+
{ component: 'KeyManager' },
|
|
2261
|
+
);
|
|
2262
|
+
return false;
|
|
2263
|
+
}
|
|
2264
|
+
const layout = migration.layout;
|
|
2265
|
+
const primaryReadOpts = KeyManager._slotOpts(layout.primaryService);
|
|
2266
|
+
const backupReadOpts = KeyManager._slotOpts(layout.backupService);
|
|
2267
|
+
|
|
2268
|
+
// Read the primary DIRECTLY (not via the error-swallowing getters) so
|
|
2269
|
+
// we can distinguish a transient read failure from a genuinely absent
|
|
2270
|
+
// key. A thrown read here means the keychain is locked/unavailable —
|
|
2271
|
+
// bail out and let a later call retry rather than risk restoring over a
|
|
2272
|
+
// healthy-but-locked identity.
|
|
2273
|
+
let primaryPrivate: string | null;
|
|
2274
|
+
let primaryPublic: string | null;
|
|
2275
|
+
try {
|
|
2276
|
+
primaryPrivate = await store.getItemAsync(layout.primaryPrivateKeyName, primaryReadOpts);
|
|
2277
|
+
primaryPublic = await store.getItemAsync(layout.primaryPublicKeyName, primaryReadOpts);
|
|
2278
|
+
} catch (error) {
|
|
2279
|
+
logger.warn(
|
|
2280
|
+
'restoreIdentityFromBackup: could not read primary (transient?). Refusing to restore.',
|
|
2281
|
+
{ component: 'KeyManager' },
|
|
2282
|
+
error,
|
|
2283
|
+
);
|
|
2284
|
+
return false;
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
// If the primary reads back as a complete, self-consistent identity, it
|
|
2288
|
+
// is healthy — nothing to restore. (Guard 1.)
|
|
2289
|
+
if (primaryPrivate && primaryPublic) {
|
|
2290
|
+
if (
|
|
2291
|
+
KeyManager.isValidPrivateKey(primaryPrivate) &&
|
|
2292
|
+
KeyManager.isValidPublicKey(primaryPublic) &&
|
|
2293
|
+
KeyManager.derivePublicKey(primaryPrivate).toLowerCase() === primaryPublic.toLowerCase()
|
|
2294
|
+
) {
|
|
2295
|
+
return false;
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
// Load + validate the backup.
|
|
2300
|
+
const backupPrivateKey = await store.getItemAsync(layout.backupPrivateKeyName, backupReadOpts);
|
|
2301
|
+
const backupPublicKey = await store.getItemAsync(layout.backupPublicKeyName, backupReadOpts);
|
|
2302
|
+
|
|
2303
|
+
if (!backupPrivateKey || !backupPublicKey) {
|
|
2304
|
+
return false; // No backup available
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
if (!KeyManager.isValidPrivateKey(backupPrivateKey) || !KeyManager.isValidPublicKey(backupPublicKey)) {
|
|
2308
|
+
logger.warn('Backup identity is malformed; refusing to restore', { component: 'KeyManager' });
|
|
2309
|
+
return false;
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
// Verify backup keys derive consistently. Hex is case-insensitive so
|
|
2313
|
+
// normalize both sides — a legacy uppercase-stored backup must still
|
|
2314
|
+
// be considered valid.
|
|
2315
|
+
const derivedPublicKey = KeyManager.derivePublicKey(backupPrivateKey);
|
|
2316
|
+
if (derivedPublicKey.toLowerCase() !== backupPublicKey.toLowerCase()) {
|
|
2317
|
+
logger.warn('Backup public key does not match derived; refusing to restore', { component: 'KeyManager' });
|
|
2318
|
+
return false;
|
|
2319
|
+
}
|
|
2320
|
+
|
|
2321
|
+
// Guard 3: if ANY primary key material is still present and identifies a
|
|
2322
|
+
// DIFFERENT identity than the backup, refuse — the backup may be from a
|
|
2323
|
+
// completely different account and restoring it would silently switch
|
|
2324
|
+
// the user. We check the private key too (not just the public): a
|
|
2325
|
+
// present private key that derives to a non-backup public means a real,
|
|
2326
|
+
// different identity is sitting in the primary slot.
|
|
2327
|
+
if (
|
|
2328
|
+
primaryPublic &&
|
|
2329
|
+
primaryPublic.toLowerCase() !== backupPublicKey.toLowerCase()
|
|
2330
|
+
) {
|
|
2331
|
+
logger.error(
|
|
2332
|
+
'Primary public key is present, corrupt-or-mismatched, AND differs from the backup. Refusing to restore to avoid switching accounts.',
|
|
2333
|
+
undefined,
|
|
2334
|
+
{ component: 'KeyManager' },
|
|
2335
|
+
);
|
|
2336
|
+
return false;
|
|
2337
|
+
}
|
|
2338
|
+
if (
|
|
2339
|
+
primaryPrivate &&
|
|
2340
|
+
KeyManager.isValidPrivateKey(primaryPrivate) &&
|
|
2341
|
+
KeyManager.derivePublicKey(primaryPrivate).toLowerCase() !== backupPublicKey.toLowerCase()
|
|
2342
|
+
) {
|
|
2343
|
+
logger.error(
|
|
2344
|
+
'Primary private key identifies a DIFFERENT identity than the backup. Refusing to restore to avoid switching accounts.',
|
|
2345
|
+
undefined,
|
|
2346
|
+
{ component: 'KeyManager' },
|
|
2347
|
+
);
|
|
2348
|
+
return false;
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
// Safe to restore: rebuild the primary using the same atomic write
|
|
2352
|
+
// path createIdentity uses, including verification.
|
|
2353
|
+
try {
|
|
2354
|
+
await KeyManager._persistIdentityAtomic(backupPrivateKey, backupPublicKey, 'restore');
|
|
2355
|
+
} catch (error) {
|
|
2356
|
+
logger.error('Failed to persist identity restored from backup', error, { component: 'KeyManager' });
|
|
2357
|
+
return false;
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
await store.setItemAsync(layout.backupTimestampName, Date.now().toString(), backupReadOpts);
|
|
2361
|
+
return true;
|
|
2362
|
+
} catch (error) {
|
|
2363
|
+
logger.error('Failed to restore identity from backup', error, { component: 'KeyManager' });
|
|
2364
|
+
return false;
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
/**
|
|
2369
|
+
* Recovery ladder — restore a `lost` identity from an independent,
|
|
2370
|
+
* `key_v1`-surviving source WITHOUT the user re-entering their recovery phrase.
|
|
2371
|
+
*
|
|
2372
|
+
* Gated on {@link getIdentityStatus} being `lost` (marker present, keys empty):
|
|
2373
|
+
* - `present` / `absent` → `not-lost` (nothing to recover / nothing lost)
|
|
2374
|
+
* - `unavailable` → `unavailable` (keychain locked; retry later)
|
|
2375
|
+
*
|
|
2376
|
+
* Rungs, tried in order, each fully validated (well-formed + derive-match +
|
|
2377
|
+
* `publicKey === marker.publicKey`, so a source holding a DIFFERENT account is
|
|
2378
|
+
* SKIPPED, never restored):
|
|
2379
|
+
* 1. the v2 backup slot (independent keychain key from the primary), then
|
|
2380
|
+
* 2. the cross-app shared slot (Android bridge `getShared` / iOS keychain
|
|
2381
|
+
* group) — the copy that survives a primary+backup `key_v1` death.
|
|
2382
|
+
*
|
|
2383
|
+
* On success it re-persists via {@link _persistIdentityAtomic} (origin
|
|
2384
|
+
* `'restore'`) and invalidates the cache so routing re-reads `present`. When no
|
|
2385
|
+
* rung matches, the UI proceeds to recovery-phrase entry.
|
|
2386
|
+
*/
|
|
2387
|
+
static async attemptIdentityRecovery(): Promise<IdentityRecoveryResult> {
|
|
2388
|
+
if (isWebPlatform()) {
|
|
2389
|
+
return { recovered: false, reason: 'not-lost' };
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
const status = await KeyManager.getIdentityStatus({ bypassCache: true });
|
|
2393
|
+
if (status.state === 'present' || status.state === 'absent') {
|
|
2394
|
+
return { recovered: false, reason: 'not-lost' };
|
|
2395
|
+
}
|
|
2396
|
+
if (status.state === 'unavailable') {
|
|
2397
|
+
return { recovered: false, reason: 'unavailable' };
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
// status.state === 'lost'
|
|
2401
|
+
const expectedPublic = status.marker.publicKey.toLowerCase();
|
|
2402
|
+
let sawMismatch = false;
|
|
2403
|
+
|
|
2404
|
+
// Rung 1: backup slot.
|
|
2405
|
+
const backupCandidate = await KeyManager._readBackupCandidate();
|
|
2406
|
+
if (backupCandidate) {
|
|
2407
|
+
if (backupCandidate.publicKey.toLowerCase() === expectedPublic) {
|
|
2408
|
+
if (await KeyManager._commitRecovery(backupCandidate.privateKey, backupCandidate.publicKey)) {
|
|
2409
|
+
return { recovered: true, source: 'backup', publicKey: backupCandidate.publicKey };
|
|
2410
|
+
}
|
|
2411
|
+
} else {
|
|
2412
|
+
sawMismatch = true;
|
|
2413
|
+
}
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
// Rung 2: cross-app shared slot.
|
|
2417
|
+
const sharedCandidate = await KeyManager._readSharedCandidate();
|
|
2418
|
+
if (sharedCandidate) {
|
|
2419
|
+
if (sharedCandidate.publicKey.toLowerCase() === expectedPublic) {
|
|
2420
|
+
if (await KeyManager._commitRecovery(sharedCandidate.privateKey, sharedCandidate.publicKey)) {
|
|
2421
|
+
return { recovered: true, source: 'shared', publicKey: sharedCandidate.publicKey };
|
|
2422
|
+
}
|
|
2423
|
+
} else {
|
|
2424
|
+
sawMismatch = true;
|
|
2425
|
+
}
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
// A source existed but identified a DIFFERENT account — never silently
|
|
2429
|
+
// switched. Report `mismatch` so the UI can require explicit confirmation.
|
|
2430
|
+
return { recovered: false, reason: sawMismatch ? 'mismatch' : 'no-sources' };
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
/** Read the active-layout backup slot as a healthy candidate, or null. @internal */
|
|
2434
|
+
private static async _readBackupCandidate(): Promise<{ privateKey: string; publicKey: string } | null> {
|
|
2435
|
+
try {
|
|
2436
|
+
const migration = await KeyManager._ensureIdentitySlotsMigrated();
|
|
2437
|
+
if (migration.mode === 'deferred') {
|
|
2438
|
+
return null;
|
|
2439
|
+
}
|
|
2440
|
+
const layout = migration.layout;
|
|
2441
|
+
const backupReadOpts = KeyManager._slotOpts(layout.backupService);
|
|
2442
|
+
const store = await initSecureStore();
|
|
2443
|
+
const privateKey = await store.getItemAsync(layout.backupPrivateKeyName, backupReadOpts);
|
|
2444
|
+
const publicKey = await store.getItemAsync(layout.backupPublicKeyName, backupReadOpts);
|
|
2445
|
+
if (KeyManager._isHealthyPair(privateKey, publicKey) && privateKey && publicKey) {
|
|
2446
|
+
return { privateKey, publicKey };
|
|
2447
|
+
}
|
|
2448
|
+
return null;
|
|
2449
|
+
} catch (error) {
|
|
2450
|
+
logger.warn('Recovery: failed to read backup slot', { component: 'KeyManager' }, error);
|
|
2451
|
+
return null;
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
/** Read the cross-app shared slot as a healthy candidate, or null. @internal */
|
|
2456
|
+
private static async _readSharedCandidate(): Promise<{ privateKey: string; publicKey: string } | null> {
|
|
2457
|
+
try {
|
|
2458
|
+
const privateKey = await KeyManager.getSharedPrivateKey();
|
|
2459
|
+
const publicKey = await KeyManager.getSharedPublicKey();
|
|
2460
|
+
if (KeyManager._isHealthyPair(privateKey, publicKey) && privateKey && publicKey) {
|
|
2461
|
+
return { privateKey, publicKey };
|
|
2462
|
+
}
|
|
2463
|
+
return null;
|
|
2464
|
+
} catch (error) {
|
|
2465
|
+
logger.warn('Recovery: failed to read shared slot', { component: 'KeyManager' }, error);
|
|
2466
|
+
return null;
|
|
2467
|
+
}
|
|
2468
|
+
}
|
|
2469
|
+
|
|
2470
|
+
/** Persist a validated recovery candidate + refresh caches/subscribers. @internal */
|
|
2471
|
+
private static async _commitRecovery(privateKey: string, publicKey: string): Promise<boolean> {
|
|
2472
|
+
try {
|
|
2473
|
+
await KeyManager._persistIdentityAtomic(privateKey, publicKey, 'restore');
|
|
2474
|
+
KeyManager.invalidateCache();
|
|
2475
|
+
return true;
|
|
2476
|
+
} catch (error) {
|
|
2477
|
+
logger.error('Recovery: failed to persist recovered identity', error, { component: 'KeyManager' });
|
|
2478
|
+
return false;
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
/**
|
|
2483
|
+
* Derive public key from a private key (without storing)
|
|
2484
|
+
*/
|
|
2485
|
+
static derivePublicKey(privateKey: string): string {
|
|
2486
|
+
return deriveSecp256k1PublicKey(KeyManager.canonicalPrivateKey(privateKey));
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
/**
|
|
2490
|
+
* Normalize a public key to uncompressed, lowercased hex. Used when building
|
|
2491
|
+
* signed rotation payloads so legacy compressed/cased encodings still verify.
|
|
2492
|
+
*/
|
|
2493
|
+
static canonicalPublicKey(publicKey: string): string {
|
|
2494
|
+
return normalizeSecp256k1PublicKey(publicKey);
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
/**
|
|
2498
|
+
* Validate that a string is a valid public key
|
|
2499
|
+
*
|
|
2500
|
+
* Returns false on parse errors (invalid input is the expected fail mode here).
|
|
2501
|
+
* Errors are logged at debug level so they're available when troubleshooting
|
|
2502
|
+
* but don't pollute production logs.
|
|
2503
|
+
*/
|
|
2504
|
+
static isValidPublicKey(publicKey: string): boolean {
|
|
2505
|
+
if (typeof publicKey !== 'string' || publicKey.length === 0) {
|
|
2506
|
+
return false;
|
|
2507
|
+
}
|
|
2508
|
+
// secp256k1 public keys are either uncompressed (130 hex chars, starts with 04)
|
|
2509
|
+
// or compressed (66 hex chars, starts with 02 or 03). Anything else is
|
|
2510
|
+
// clearly bogus; reject up front so we never silently widen the trust
|
|
2511
|
+
// boundary by accepting whatever BN(...) parses out of junk input.
|
|
2512
|
+
if (!/^[0-9a-fA-F]+$/.test(publicKey)) {
|
|
2513
|
+
return false;
|
|
2514
|
+
}
|
|
2515
|
+
if (publicKey.length !== 130 && publicKey.length !== 66) {
|
|
2516
|
+
return false;
|
|
2517
|
+
}
|
|
2518
|
+
if (isValidSecp256k1PublicKey(publicKey)) {
|
|
2519
|
+
return true;
|
|
2520
|
+
}
|
|
2521
|
+
if (isDev()) {
|
|
2522
|
+
logger.debug('[oxy.crypto] isValidPublicKey rejected input', { component: 'KeyManager' });
|
|
2523
|
+
}
|
|
2524
|
+
return false;
|
|
2525
|
+
}
|
|
2526
|
+
|
|
2527
|
+
/**
|
|
2528
|
+
* Validate that a string is a valid private key.
|
|
2529
|
+
*
|
|
2530
|
+
* secp256k1 private keys are 256-bit, so 64 hex chars. We require strict
|
|
2531
|
+
* hex-only input so malformed text can never be coerced into a scalar.
|
|
2532
|
+
*/
|
|
2533
|
+
static isValidPrivateKey(privateKey: string): boolean {
|
|
2534
|
+
if (typeof privateKey !== 'string' || privateKey.length === 0) {
|
|
2535
|
+
return false;
|
|
2536
|
+
}
|
|
2537
|
+
if (!/^[0-9a-fA-F]+$/.test(privateKey)) {
|
|
2538
|
+
return false;
|
|
2539
|
+
}
|
|
2540
|
+
// Legacy storage may have stripped leading zero bytes. Accept 1..64 chars
|
|
2541
|
+
// here and normalize before curve validation.
|
|
2542
|
+
if (privateKey.length > 64) {
|
|
2543
|
+
return false;
|
|
2544
|
+
}
|
|
2545
|
+
const padded = privateKey.padStart(64, '0').toLowerCase();
|
|
2546
|
+
// After padding, require minimum entropy: reject obvious low-scalar
|
|
2547
|
+
// keys. A scalar that fits in 8 hex chars (~32 bits of entropy) is a
|
|
2548
|
+
// degenerate / accidental key, not a real one. The existing isZero()
|
|
2549
|
+
// check below covers literal 0; this also rejects trivially small
|
|
2550
|
+
// scalars like '1', '2', etc. that would otherwise pad to a valid but
|
|
2551
|
+
// weak key whose public point is trivially derivable.
|
|
2552
|
+
if (/^0{56}/.test(padded)) {
|
|
2553
|
+
return false;
|
|
2554
|
+
}
|
|
2555
|
+
if (isValidSecp256k1PrivateKey(padded)) {
|
|
2556
|
+
return true;
|
|
2557
|
+
}
|
|
2558
|
+
if (isDev()) {
|
|
2559
|
+
logger.debug('[oxy.crypto] isValidPrivateKey rejected input', { component: 'KeyManager' });
|
|
2560
|
+
}
|
|
2561
|
+
return false;
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
/**
|
|
2565
|
+
* Derive a 32-byte, domain-separated seed from the on-device Oxy identity
|
|
2566
|
+
* private key via HKDF-SHA256, WITHOUT ever exposing the raw private key.
|
|
2567
|
+
*
|
|
2568
|
+
* The domain separation is carried by `info` (e.g. `"oxypay/faircoin/v1"`),
|
|
2569
|
+
* so distinct apps/purposes get independent seeds from the same identity.
|
|
2570
|
+
* The output is HKDF keying material, never the private key itself — a
|
|
2571
|
+
* consumer (e.g. Oxy Pay's FairCoin HD wallet) can feed it straight into
|
|
2572
|
+
* `HDKey.fromMasterSeed` and never touches the identity key.
|
|
2573
|
+
*
|
|
2574
|
+
* Key source (native only): prefers the shared ecosystem identity written to
|
|
2575
|
+
* `group.so.oxy.shared` (what a Relying Party like Oxy Pay reads), then falls
|
|
2576
|
+
* back to this device's primary identity (Commons/Accounts). Both reproduce
|
|
2577
|
+
* from the user's Oxy recovery phrase, so the derived seed is recoverable.
|
|
2578
|
+
*
|
|
2579
|
+
* @param info Context/domain-binding label (distinct labels → independent seeds).
|
|
2580
|
+
* @returns 32 bytes of derived keying material, or `null` on web / when no
|
|
2581
|
+
* identity key is available on this device.
|
|
2582
|
+
*/
|
|
2583
|
+
static async deriveScopedSeed(info: string): Promise<Uint8Array | null> {
|
|
2584
|
+
if (isWebPlatform()) {
|
|
2585
|
+
return null;
|
|
2586
|
+
}
|
|
2587
|
+
const privateKey =
|
|
2588
|
+
(await KeyManager.getSharedPrivateKey()) ?? (await KeyManager.getPrivateKey());
|
|
2589
|
+
if (!privateKey) {
|
|
2590
|
+
return null;
|
|
2591
|
+
}
|
|
2592
|
+
const ikm = hexToBytes(KeyManager.canonicalPrivateKey(privateKey));
|
|
2593
|
+
return hkdfSha256(ikm, utf8ToBytes(SCOPED_SEED_KDF_SALT), utf8ToBytes(info), 32);
|
|
2594
|
+
}
|
|
2595
|
+
|
|
2596
|
+
/**
|
|
2597
|
+
* Get a shortened version of the public key for display
|
|
2598
|
+
* Format: first 8 chars...last 8 chars
|
|
2599
|
+
*/
|
|
2600
|
+
static shortenPublicKey(publicKey: string): string {
|
|
2601
|
+
if (publicKey.length <= 20) return publicKey;
|
|
2602
|
+
return `${publicKey.slice(0, 8)}...${publicKey.slice(-8)}`;
|
|
2603
|
+
}
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2606
|
+
export default KeyManager;
|
|
2607
|
+
|