feat: implement persistent master key storage (Approach 1) and local PIN fallback (Approach 2)
This commit is contained in:
@@ -1,6 +1,14 @@
|
|||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { registerUser, loginUser, completeLoginWithRecovery } from '../services/auth.js'
|
import {
|
||||||
|
registerUser,
|
||||||
|
loginUser,
|
||||||
|
completeLoginWithRecovery,
|
||||||
|
setLocalPin,
|
||||||
|
hasLocalPin,
|
||||||
|
decryptWithLocalPin,
|
||||||
|
getActiveMasterKey
|
||||||
|
} from '../services/auth.js'
|
||||||
import { KeyRound, ShieldAlert, Languages, HelpCircle } from 'lucide-react'
|
import { KeyRound, ShieldAlert, Languages, HelpCircle } from 'lucide-react'
|
||||||
|
|
||||||
interface AuthOnboardingProps {
|
interface AuthOnboardingProps {
|
||||||
@@ -22,6 +30,15 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
|
|||||||
const [recoveryInput, setRecoveryInput] = useState('')
|
const [recoveryInput, setRecoveryInput] = useState('')
|
||||||
const [encryptedPayloads, setEncryptedPayloads] = useState<any>(null)
|
const [encryptedPayloads, setEncryptedPayloads] = useState<any>(null)
|
||||||
|
|
||||||
|
// PIN setup flow state
|
||||||
|
const [showPinSetup, setShowPinSetup] = useState(false)
|
||||||
|
const [pinInput, setPinInput] = useState('')
|
||||||
|
const [pinSetupUsername, setPinSetupUsername] = useState('')
|
||||||
|
|
||||||
|
// PIN login fallback flow state
|
||||||
|
const [showPinLogin, setShowPinLogin] = useState(false)
|
||||||
|
const [pinLoginInput, setPinLoginInput] = useState('')
|
||||||
|
|
||||||
const handleRegister = async (e: React.FormEvent) => {
|
const handleRegister = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!username.trim()) return
|
if (!username.trim()) return
|
||||||
@@ -52,12 +69,17 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
|
|||||||
// Biometric E2E decryption succeeded
|
// Biometric E2E decryption succeeded
|
||||||
onAuthenticated()
|
onAuthenticated()
|
||||||
} else {
|
} else {
|
||||||
// Biometrics succeeded but PRF key wasn't supported/available, fall back to recovery phrase
|
// Biometrics succeeded but PRF key wasn't supported/available, fall back to PIN or recovery phrase
|
||||||
setEncryptedPayloads(result.encryptedPayloads)
|
setEncryptedPayloads(result.encryptedPayloads)
|
||||||
if (result.username) {
|
const resolvedUser = result.username || result.encryptedPayloads?.username || ''
|
||||||
setUsername(result.username)
|
if (resolvedUser) {
|
||||||
|
setUsername(resolvedUser)
|
||||||
|
}
|
||||||
|
if (resolvedUser && hasLocalPin(resolvedUser)) {
|
||||||
|
setShowPinLogin(true)
|
||||||
|
} else {
|
||||||
|
setShowRecoveryFallback(true)
|
||||||
}
|
}
|
||||||
setShowRecoveryFallback(true)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -77,7 +99,10 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
|
|||||||
const resolvedUser = username.trim() || encryptedPayloads.username
|
const resolvedUser = username.trim() || encryptedPayloads.username
|
||||||
const success = await completeLoginWithRecovery(resolvedUser, recoveryInput.trim(), encryptedPayloads)
|
const success = await completeLoginWithRecovery(resolvedUser, recoveryInput.trim(), encryptedPayloads)
|
||||||
if (success) {
|
if (success) {
|
||||||
onAuthenticated()
|
// Offer PIN setup to prevent future recovery phrase entries on this device
|
||||||
|
setPinSetupUsername(resolvedUser)
|
||||||
|
setShowRecoveryFallback(false)
|
||||||
|
setShowPinSetup(true)
|
||||||
} else {
|
} else {
|
||||||
setError(t('auth.error_incorrect_recovery'))
|
setError(t('auth.error_incorrect_recovery'))
|
||||||
}
|
}
|
||||||
@@ -88,6 +113,55 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleConfirmRecovery = () => {
|
||||||
|
setPinSetupUsername(username.trim() || encryptedPayloads?.username || '')
|
||||||
|
setShowPinSetup(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePinSetupSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!pinInput.trim() || pinInput.length < 4) {
|
||||||
|
setError(t('auth.pin_length_error', 'PIN must be at least 4 digits'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const activeKey = getActiveMasterKey()
|
||||||
|
if (activeKey) {
|
||||||
|
await setLocalPin(pinInput.trim(), pinSetupUsername, activeKey)
|
||||||
|
onAuthenticated()
|
||||||
|
} else {
|
||||||
|
setError('No active master key found')
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || 'Failed to save PIN')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePinLoginSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!pinLoginInput.trim()) return
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const resolvedUser = username.trim() || encryptedPayloads?.username
|
||||||
|
const key = await decryptWithLocalPin(pinLoginInput.trim(), resolvedUser)
|
||||||
|
if (key) {
|
||||||
|
onAuthenticated()
|
||||||
|
} else {
|
||||||
|
setError(t('auth.error_incorrect_pin'))
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(t('auth.error_incorrect_pin'))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const toggleLanguage = () => {
|
const toggleLanguage = () => {
|
||||||
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
|
const nextLang = i18n.language.startsWith('de') ? 'en' : 'de'
|
||||||
i18n.changeLanguage(nextLang)
|
i18n.changeLanguage(nextLang)
|
||||||
@@ -123,7 +197,7 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
|
|||||||
<button className="btn secondary" onClick={copyToClipboard}>
|
<button className="btn secondary" onClick={copyToClipboard}>
|
||||||
{copied ? t('auth.copied') : t('auth.copy_phrase')}
|
{copied ? t('auth.copied') : t('auth.copy_phrase')}
|
||||||
</button>
|
</button>
|
||||||
<button className="btn primary" onClick={onAuthenticated}>
|
<button className="btn primary" onClick={handleConfirmRecovery}>
|
||||||
{t('auth.confirm_recovery')}
|
{t('auth.confirm_recovery')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -131,6 +205,113 @@ export default function AuthOnboarding({ onAuthenticated }: AuthOnboardingProps)
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render 4: PIN setup screen
|
||||||
|
if (showPinSetup) {
|
||||||
|
return (
|
||||||
|
<div className="auth-card glass">
|
||||||
|
<div className="auth-header">
|
||||||
|
<KeyRound className="auth-icon accent" size={48} />
|
||||||
|
<h2>{t('auth.setup_pin_title')}</h2>
|
||||||
|
</div>
|
||||||
|
<p className="recovery-warning">
|
||||||
|
{t('auth.setup_pin_warning')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handlePinSetupSubmit} className="auth-form">
|
||||||
|
<div className="input-group">
|
||||||
|
<label className="input-label" style={{ display: 'block', marginBottom: '8px', color: '#94a3b8' }}>
|
||||||
|
{t('auth.pin_label')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
maxLength={8}
|
||||||
|
className="input-text"
|
||||||
|
placeholder={t('auth.pin_placeholder')}
|
||||||
|
value={pinInput}
|
||||||
|
onChange={(e) => setPinInput(e.target.value.replace(/\D/g, ''))}
|
||||||
|
disabled={loading}
|
||||||
|
required
|
||||||
|
style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="auth-error" style={{ color: '#ef4444', fontSize: '14px', marginTop: '8px' }}>{error}</div>}
|
||||||
|
|
||||||
|
<div className="auth-actions" style={{ marginTop: '20px' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={onAuthenticated}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{t('auth.skip_pin')}
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn primary" disabled={loading || pinInput.length < 4}>
|
||||||
|
{loading ? t('auth.processing') : t('auth.save_pin')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render 5: PIN login screen
|
||||||
|
if (showPinLogin) {
|
||||||
|
return (
|
||||||
|
<div className="auth-card glass">
|
||||||
|
<div className="auth-header">
|
||||||
|
<KeyRound className="auth-icon accent" size={48} />
|
||||||
|
<h2>{t('auth.enter_pin_title')}</h2>
|
||||||
|
</div>
|
||||||
|
<p className="recovery-warning">
|
||||||
|
{t('auth.enter_pin_warning')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handlePinLoginSubmit} className="auth-form">
|
||||||
|
<div className="input-group">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
inputMode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
maxLength={8}
|
||||||
|
className="input-text"
|
||||||
|
placeholder={t('auth.enter_pin_placeholder')}
|
||||||
|
value={pinLoginInput}
|
||||||
|
onChange={(e) => setPinLoginInput(e.target.value.replace(/\D/g, ''))}
|
||||||
|
disabled={loading}
|
||||||
|
required
|
||||||
|
style={{ width: '100%', padding: '12px', boxSizing: 'border-box' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="auth-error" style={{ color: '#ef4444', fontSize: '14px', marginTop: '8px' }}>{error}</div>}
|
||||||
|
|
||||||
|
<div className="auth-actions" style={{ flexDirection: 'column', gap: '10px', marginTop: '20px' }}>
|
||||||
|
<button type="submit" className="btn primary" disabled={loading} style={{ width: '100%' }}>
|
||||||
|
{loading ? t('auth.decrypting') : t('auth.decrypt_with_pin')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setShowPinLogin(false)
|
||||||
|
setShowRecoveryFallback(true)
|
||||||
|
setError(null)
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
>
|
||||||
|
{t('auth.use_recovery_instead')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Render 2: Ask for recovery phrase fallback if biometric PRF fails
|
// Render 2: Ask for recovery phrase fallback if biometric PRF fails
|
||||||
if (showRecoveryFallback) {
|
if (showRecoveryFallback) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -35,7 +35,19 @@
|
|||||||
"or_register": "oder Registrieren",
|
"or_register": "oder Registrieren",
|
||||||
"username_placeholder": "Benutzername / Skippername",
|
"username_placeholder": "Benutzername / Skippername",
|
||||||
"processing": "Verarbeitung...",
|
"processing": "Verarbeitung...",
|
||||||
"help": "Hilfe"
|
"help": "Hilfe",
|
||||||
|
"setup_pin_title": "Lokale PIN einrichten (Optional)",
|
||||||
|
"setup_pin_warning": "Da Ihr Gerät keine direkte Passkey-Schlüsselableitung unterstützt, müssten Sie andernfalls bei jedem Login auf diesem Gerät Ihren 12-Wörter-Schlüssel eingeben. Richten Sie eine lokale PIN ein, um das zu vermeiden.",
|
||||||
|
"pin_placeholder": "Z.B. 123456",
|
||||||
|
"pin_label": "Lokaler PIN-Code (4-8 Ziffern)",
|
||||||
|
"save_pin": "PIN speichern & Fortfahren",
|
||||||
|
"skip_pin": "Überspringen & recovery verwenden",
|
||||||
|
"enter_pin_title": "Mit PIN entschlüsseln",
|
||||||
|
"enter_pin_warning": "Geben Sie Ihre lokale PIN ein, um den Entschlüsselungsschlüssel auf diesem Gerät freizuschalten.",
|
||||||
|
"enter_pin_placeholder": "Geben Sie Ihre PIN ein...",
|
||||||
|
"decrypt_with_pin": "Entschlüsseln",
|
||||||
|
"use_recovery_instead": "Stattdessen Wiederherstellungsschlüssel verwenden",
|
||||||
|
"error_incorrect_pin": "Falsche PIN. Entschlüsselung fehlgeschlagen."
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
"status_synced": "Synchronisiert",
|
"status_synced": "Synchronisiert",
|
||||||
|
|||||||
@@ -35,7 +35,19 @@
|
|||||||
"or_register": "or register",
|
"or_register": "or register",
|
||||||
"username_placeholder": "Username / Skipper Name",
|
"username_placeholder": "Username / Skipper Name",
|
||||||
"processing": "Processing...",
|
"processing": "Processing...",
|
||||||
"help": "Help"
|
"help": "Help",
|
||||||
|
"setup_pin_title": "Setup Local PIN (Optional)",
|
||||||
|
"setup_pin_warning": "Since your device does not support hardware passkey key derivation, you would otherwise need to enter your 12-word recovery phrase on every login on this device. Setup a local PIN to avoid this.",
|
||||||
|
"pin_placeholder": "E.g. 123456",
|
||||||
|
"pin_label": "Local PIN Code (4-8 digits)",
|
||||||
|
"save_pin": "Save PIN & Continue",
|
||||||
|
"skip_pin": "Skip & use recovery phrase",
|
||||||
|
"enter_pin_title": "Decrypt with PIN",
|
||||||
|
"enter_pin_warning": "Enter your local PIN to unlock the decryption key on this device.",
|
||||||
|
"enter_pin_placeholder": "Enter your PIN...",
|
||||||
|
"decrypt_with_pin": "Decrypt",
|
||||||
|
"use_recovery_instead": "Use recovery phrase instead",
|
||||||
|
"error_incorrect_pin": "Incorrect PIN. Decryption failed."
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
"status_synced": "Synced",
|
"status_synced": "Synced",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
generateMasterKey,
|
generateMasterKey,
|
||||||
deriveKeyFromPhrase,
|
deriveKeyFromPhrase,
|
||||||
deriveKeyFromPrf,
|
deriveKeyFromPrf,
|
||||||
|
deriveKeyFromPin,
|
||||||
encryptBuffer,
|
encryptBuffer,
|
||||||
decryptBuffer,
|
decryptBuffer,
|
||||||
generateRecoveryPhrase,
|
generateRecoveryPhrase,
|
||||||
@@ -17,9 +18,9 @@ const API_BASE = '/api/auth'
|
|||||||
// Shared in-memory container for the active user's session master key
|
// Shared in-memory container for the active user's session master key
|
||||||
let activeMasterKey: ArrayBuffer | null = null
|
let activeMasterKey: ArrayBuffer | null = null
|
||||||
|
|
||||||
// Restore key from sessionStorage on load if present (survives reload)
|
// Restore key from localStorage on load if present (survives reload/restart)
|
||||||
try {
|
try {
|
||||||
const savedKey = sessionStorage.getItem('active_master_key')
|
const savedKey = localStorage.getItem('active_master_key')
|
||||||
if (savedKey) {
|
if (savedKey) {
|
||||||
activeMasterKey = base64ToBuffer(savedKey)
|
activeMasterKey = base64ToBuffer(savedKey)
|
||||||
}
|
}
|
||||||
@@ -35,15 +36,43 @@ export function setActiveMasterKey(key: ArrayBuffer | null) {
|
|||||||
activeMasterKey = key
|
activeMasterKey = key
|
||||||
if (key) {
|
if (key) {
|
||||||
try {
|
try {
|
||||||
sessionStorage.setItem('active_master_key', bufferToBase64(key))
|
localStorage.setItem('active_master_key', bufferToBase64(key))
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to save master key to sessionStorage:', e)
|
console.error('Failed to save master key to localStorage:', e)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
sessionStorage.removeItem('active_master_key')
|
localStorage.removeItem('active_master_key')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PIN fallback mechanism functions
|
||||||
|
export async function setLocalPin(pin: string, username: string, masterKey: ArrayBuffer): Promise<void> {
|
||||||
|
const pinKey = await deriveKeyFromPin(pin, username)
|
||||||
|
const encrypted = await encryptBuffer(masterKey, pinKey)
|
||||||
|
localStorage.setItem(`pin_encrypted_master_key_${username.toLowerCase()}`, JSON.stringify(encrypted))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasLocalPin(username: string): boolean {
|
||||||
|
return !!localStorage.getItem(`pin_encrypted_master_key_${username.toLowerCase()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeLocalPin(username: string): void {
|
||||||
|
localStorage.removeItem(`pin_encrypted_master_key_${username.toLowerCase()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decryptWithLocalPin(pin: string, username: string): Promise<ArrayBuffer | null> {
|
||||||
|
const stored = localStorage.getItem(`pin_encrypted_master_key_${username.toLowerCase()}`)
|
||||||
|
if (!stored) return null
|
||||||
|
|
||||||
|
const { ciphertext, iv, tag } = JSON.parse(stored)
|
||||||
|
const pinKey = await deriveKeyFromPin(pin, username)
|
||||||
|
const decrypted = await decryptBuffer(ciphertext, iv, tag, pinKey)
|
||||||
|
|
||||||
|
setActiveMasterKey(decrypted)
|
||||||
|
localStorage.setItem('active_username', username)
|
||||||
|
return decrypted
|
||||||
|
}
|
||||||
|
|
||||||
// Convert string salt to 32-byte Uint8Array
|
// Convert string salt to 32-byte Uint8Array
|
||||||
const PRF_SALT = new TextEncoder().encode("KapteinsDaagboxPRFSaltForE2EKey_")
|
const PRF_SALT = new TextEncoder().encode("KapteinsDaagboxPRFSaltForE2EKey_")
|
||||||
|
|
||||||
@@ -384,6 +413,7 @@ export function logoutUser() {
|
|||||||
|
|
||||||
export async function deleteAccount(): Promise<boolean> {
|
export async function deleteAccount(): Promise<boolean> {
|
||||||
const userId = localStorage.getItem('active_userid')
|
const userId = localStorage.getItem('active_userid')
|
||||||
|
const username = localStorage.getItem('active_username')
|
||||||
if (!userId) return false
|
if (!userId) return false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -395,6 +425,9 @@ export async function deleteAccount(): Promise<boolean> {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
|
if (username) {
|
||||||
|
removeLocalPin(username)
|
||||||
|
}
|
||||||
// Clear IndexedDB completely to prevent leaking residual encrypted E2E data on client
|
// Clear IndexedDB completely to prevent leaking residual encrypted E2E data on client
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
db.logbooks.clear(),
|
db.logbooks.clear(),
|
||||||
|
|||||||
@@ -86,6 +86,34 @@ export async function deriveKeyFromPhrase(phrase: string): Promise<CryptoKey> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Derive a 256-bit CryptoKey from a PIN (using PBKDF2)
|
||||||
|
export async function deriveKeyFromPin(pin: string, username: string): Promise<CryptoKey> {
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const pinBytes = encoder.encode(pin.trim())
|
||||||
|
const saltBytes = encoder.encode(`KapteinsDaagboxLocalPinSalt_${username.toLowerCase()}`)
|
||||||
|
|
||||||
|
const baseKey = await window.crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
pinBytes,
|
||||||
|
{ name: 'PBKDF2' },
|
||||||
|
false,
|
||||||
|
['deriveKey']
|
||||||
|
)
|
||||||
|
|
||||||
|
return window.crypto.subtle.deriveKey(
|
||||||
|
{
|
||||||
|
name: 'PBKDF2',
|
||||||
|
salt: saltBytes,
|
||||||
|
iterations: 50000,
|
||||||
|
hash: 'SHA-256'
|
||||||
|
},
|
||||||
|
baseKey,
|
||||||
|
{ name: 'AES-GCM', length: 256 },
|
||||||
|
false,
|
||||||
|
['encrypt', 'decrypt']
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Derive a 256-bit CryptoKey from WebAuthn PRF results
|
// Derive a 256-bit CryptoKey from WebAuthn PRF results
|
||||||
export async function deriveKeyFromPrf(prfResult: ArrayBuffer): Promise<CryptoKey> {
|
export async function deriveKeyFromPrf(prfResult: ArrayBuffer): Promise<CryptoKey> {
|
||||||
const infoBytes = new TextEncoder().encode('KapteinsDaagboxPRFKeyDerivation')
|
const infoBytes = new TextEncoder().encode('KapteinsDaagboxPRFKeyDerivation')
|
||||||
|
|||||||
Reference in New Issue
Block a user