feat(passkeys): wrap complete wallet material

This commit is contained in:
2026-09-04 20:19:48 +02:00
parent d48deeb6e0
commit 6099bb4b6b
14 changed files with 559 additions and 121 deletions
+32 -12
View File
@@ -1,7 +1,7 @@
// The pure crypto core of passkey unlock (passkeys.ts): HKDF-SHA256 from a
// WebAuthn PRF output to an AES-GCM wrap key, and wrap/unwrap of the
// linking key under it. No WebAuthn, no storage - a PRF output is just 32
// bytes, so everything here is unit-testable in plain Node.
// WebAuthn PRF output to an AES-GCM wrap key, and wrap/unwrap of canonical
// wallet material under it. No WebAuthn or storage is involved, so malformed
// decrypted material is rejected at this boundary before it can reach runtime.
//
// A per-slot random HKDF salt separates the wrap keys of different passkeys
// even though the ceremony-side PRF salt is fixed (see passkeys.ts); the
@@ -9,10 +9,21 @@
// from the same PRF output.
import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js'
import {sha256} from '@noble/hashes/sha2.js'
import type {PasskeyWrap} from './storage/passkeySlots'
import {parseWalletMaterial, serializeWalletMaterial} from './storage/storedSecret'
import type {WalletMaterialV2} from './storage/storedSecret'
const WRAP_KEY_HKDF_INFO = 'sattle-passkey-wrap-v1'
const WRAP_KEY_HKDF_INFO = 'sattle-passkey-wrap-v2'
export class InvalidPasskeyMaterialError extends Error {
override readonly name = 'InvalidPasskeyMaterialError'
constructor() {
super('Passkey payload is not canonical v2 wallet material.')
}
}
export const derivePasskeyWrapKey = async (
prfOutput: Uint8Array,
@@ -37,34 +48,43 @@ export const derivePasskeyWrapKey = async (
)
}
export const wrapLinkingKeyWithPrf = async (
export const wrapWalletMaterialWithPrf = async (
prfOutput: Uint8Array,
linkingKey: Uint8Array,
material: WalletMaterialV2,
): Promise<PasskeyWrap> => {
const hkdfSalt = crypto.getRandomValues(new Uint8Array(16))
const iv = crypto.getRandomValues(new Uint8Array(12))
const wrapKey = await derivePasskeyWrapKey(prfOutput, hkdfSalt)
const serialized = serializeWalletMaterial(material)
if (parseWalletMaterial(serialized) === null) throw new InvalidPasskeyMaterialError()
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt({name: 'AES-GCM', iv}, wrapKey, new Uint8Array(linkingKey)),
await crypto.subtle.encrypt({name: 'AES-GCM', iv}, wrapKey, utf8ToBytes(serialized)),
)
return {
hkdfSalt: bytesToHex(hkdfSalt),
iv: bytesToHex(iv),
wrappedKey: bytesToHex(ciphertext),
materialHash: bytesToHex(sha256(utf8ToBytes(serialized))),
wrappedMaterial: bytesToHex(ciphertext),
}
}
// rejects (WebCrypto's own auth-tag check) if the PRF output is wrong -
// i.e. a different passkey than the one that created the slot
export const unwrapLinkingKeyWithPrf = async (
export const unwrapWalletMaterialWithPrf = async (
prfOutput: Uint8Array,
wrap: PasskeyWrap,
): Promise<Uint8Array> => {
): Promise<WalletMaterialV2> => {
const wrapKey = await derivePasskeyWrapKey(prfOutput, hexToBytes(wrap.hkdfSalt))
const plaintext = await crypto.subtle.decrypt(
{name: 'AES-GCM', iv: new Uint8Array(hexToBytes(wrap.iv))},
wrapKey,
new Uint8Array(hexToBytes(wrap.wrappedKey)),
new Uint8Array(hexToBytes(wrap.wrappedMaterial)),
)
return new Uint8Array(plaintext)
const serialized = new TextDecoder().decode(plaintext)
if (bytesToHex(sha256(utf8ToBytes(serialized))) !== wrap.materialHash) {
throw new InvalidPasskeyMaterialError()
}
const material = parseWalletMaterial(serialized)
if (material === null) throw new InvalidPasskeyMaterialError()
return material
}
+21 -12
View File
@@ -21,18 +21,20 @@ import {
removePasskey,
rewrapAllSlots,
unlockWithPasskey,
unwrapLinkingKeyWithPrf,
wrapLinkingKeyWithPrf,
unwrapWalletMaterialWithPrf,
wrapWalletMaterialWithPrf,
} from './passkeys'
import {
decryptRecord,
decryptSavedLinkingKey,
deriveWalletMaterial,
deriveBearerAesKey,
ensureSavedKeyOwner,
encryptRecord,
linkingPubKeyHex,
savedKeyOwnerId,
saveLinkingKey,
saveWalletMaterial,
} from './keys'
import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils'
@@ -40,6 +42,12 @@ const LINKING_KEY = new Uint8Array(32).fill(7)
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9)
const PRF_OUTPUT = new Uint8Array(32).fill(3)
const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4)
const MATERIAL = {
...deriveWalletMaterial(
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
),
linkingKeyHex: bytesToHex(LINKING_KEY),
}
const toBytes = (source: BufferSource): Uint8Array =>
source instanceof ArrayBuffer
@@ -134,32 +142,33 @@ const removeSavedOwnerMarker = (): void => {
beforeEach(async () => {
stubLocalStorage()
await saveLinkingKey(LINKING_KEY)
await saveWalletMaterial(MATERIAL)
})
describe('pure wrap crypto', () => {
it('round-trips a linking key through a PRF-derived wrap', async () => {
const wrap = await wrapLinkingKeyWithPrf(PRF_OUTPUT, LINKING_KEY)
const unwrapped = await unwrapLinkingKeyWithPrf(PRF_OUTPUT, wrap)
expect(bytesToHex(unwrapped)).toBe(bytesToHex(LINKING_KEY))
const wrap = await wrapWalletMaterialWithPrf(PRF_OUTPUT, MATERIAL)
const unwrapped = await unwrapWalletMaterialWithPrf(PRF_OUTPUT, wrap)
expect(unwrapped).toEqual(MATERIAL)
})
it('rejects unwrap with a different PRF output', async () => {
const wrap = await wrapLinkingKeyWithPrf(PRF_OUTPUT, LINKING_KEY)
await expect(unwrapLinkingKeyWithPrf(OTHER_PRF_OUTPUT, wrap)).rejects.toThrow()
const wrap = await wrapWalletMaterialWithPrf(PRF_OUTPUT, MATERIAL)
await expect(unwrapWalletMaterialWithPrf(OTHER_PRF_OUTPUT, wrap)).rejects.toThrow()
})
it('rejects unwrap with a tampered HKDF salt', async () => {
const wrap = await wrapLinkingKeyWithPrf(PRF_OUTPUT, LINKING_KEY)
const wrap = await wrapWalletMaterialWithPrf(PRF_OUTPUT, MATERIAL)
await expect(
unwrapLinkingKeyWithPrf(PRF_OUTPUT, {...wrap, hkdfSalt: 'ab'.repeat(16)}),
unwrapWalletMaterialWithPrf(PRF_OUTPUT, {...wrap, hkdfSalt: 'ab'.repeat(16)}),
).rejects.toThrow()
})
it('rejects unwrap with a tampered ciphertext', async () => {
const wrap = await wrapLinkingKeyWithPrf(PRF_OUTPUT, LINKING_KEY)
const flipped = `${wrap.wrappedKey.slice(0, -2)}${wrap.wrappedKey.endsWith('00') ? '01' : '00'}`
const wrap = await wrapWalletMaterialWithPrf(PRF_OUTPUT, MATERIAL)
const flipped = `${wrap.wrappedMaterial.slice(0, -2)}${wrap.wrappedMaterial.endsWith('00') ? '01' : '00'}`
await expect(
unwrapLinkingKeyWithPrf(PRF_OUTPUT, {...wrap, wrappedKey: flipped}),
unwrapWalletMaterialWithPrf(PRF_OUTPUT, {...wrap, wrappedMaterial: flipped}),
).rejects.toThrow()
})
+272
View File
@@ -0,0 +1,272 @@
// V2 passkey cases exercise the material boundary without exposing private
// material in assertions or diagnostics. The fake authenticator models only
// WebAuthn credential selection and deterministic PRF evaluation.
import {hmac} from '@noble/hashes/hmac.js'
import {sha256} from '@noble/hashes/sha2.js'
import {bytesToHex, utf8ToBytes} from '@noble/hashes/utils.js'
import {beforeEach, describe, expect, it} from 'vitest'
import {
deriveWalletMaterial,
saveWalletMaterial,
serializeWalletMaterial,
} from './keys'
import {
derivePasskeyWrapKey,
getPasskeyPrfOutput,
readPasskeySlots,
registerPasskey,
rewrapAllSlots,
unlockWalletMaterialWithPasskey,
unwrapWalletMaterialWithPrf,
wrapWalletMaterialWithPrf,
} from './passkeys'
import type {CeremonyCredential, PasskeyCredentials, PasskeyWrap} from './passkeys'
import {stubLocalStorage} from './test-utils'
const MNEMONIC = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
const OTHER_MNEMONIC = 'legal winner thank year wave sausage worth useful legal winner thank yellow'
const MATERIAL = {...deriveWalletMaterial(MNEMONIC), linkingKeyHex: '07'.repeat(32)}
const OTHER_MATERIAL = deriveWalletMaterial(OTHER_MNEMONIC)
const PRF_OUTPUT = new Uint8Array(32).fill(3)
const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4)
const SLOT_STORAGE_KEY = 'sattle_passkey_slots'
const toBytes = (source: BufferSource): Uint8Array =>
source instanceof ArrayBuffer
? new Uint8Array(source)
: new Uint8Array(source.buffer, source.byteOffset, source.byteLength)
class FakeAuthenticator implements PasskeyCredentials {
private readonly credentialId = new Uint8Array(16).fill(5)
private secret = new Uint8Array(32).fill(6)
onGet?: () => Promise<void>
cancelCreate = false
rotateSecret = (): void => {
this.secret = new Uint8Array(32).fill(7)
}
create = async (options?: CredentialCreationOptions): Promise<CeremonyCredential | null> => {
if (this.cancelCreate) return null
return this.credential(options?.publicKey?.extensions?.prf?.eval?.first)
}
get = async (options?: CredentialRequestOptions): Promise<CeremonyCredential | null> => {
await this.onGet?.()
const allowed = options?.publicKey?.allowCredentials ?? []
const selected = allowed.some(
(descriptor) => bytesToHex(toBytes(descriptor.id)) === bytesToHex(this.credentialId),
)
return selected
? this.credential(options?.publicKey?.extensions?.prf?.eval?.first)
: null
}
private credential = (salt: BufferSource | undefined): CeremonyCredential => ({
type: 'public-key',
rawId: this.credentialId,
getClientExtensionResults: () => ({
prf: salt
? {enabled: true, results: {first: new Uint8Array(hmac(sha256, this.secret, toBytes(salt)))}}
: {},
}),
})
}
const rawSlots = (): string => localStorage.getItem(SLOT_STORAGE_KEY) ?? ''
const alterLastByte = (hex: string): string =>
`${hex.slice(0, -2)}${hex.endsWith('00') ? '01' : '00'}`
const encryptPayload = async (
prfOutput: Uint8Array,
payload: string,
materialHash: string,
): Promise<PasskeyWrap> => {
const hkdfSalt = new Uint8Array(16).fill(8)
const iv = new Uint8Array(12).fill(9)
const wrapKey = await derivePasskeyWrapKey(prfOutput, hkdfSalt)
const ciphertext = await crypto.subtle.encrypt(
{name: 'AES-GCM', iv},
wrapKey,
utf8ToBytes(payload),
)
return {
hkdfSalt: bytesToHex(hkdfSalt),
iv: bytesToHex(iv),
materialHash,
wrappedMaterial: bytesToHex(new Uint8Array(ciphertext)),
}
}
beforeEach(async () => {
stubLocalStorage()
await saveWalletMaterial(MATERIAL)
})
describe('v2 passkey material', () => {
it('round-trips canonical material bytes through the PRF wrap', async () => {
const wrap = await wrapWalletMaterialWithPrf(PRF_OUTPUT, MATERIAL)
await expect(unwrapWalletMaterialWithPrf(PRF_OUTPUT, wrap)).resolves.toEqual(MATERIAL)
})
it('rejects wrong PRF and altered ciphertext without changing the wrap', async () => {
const wrap = await wrapWalletMaterialWithPrf(PRF_OUTPUT, MATERIAL)
const before = JSON.stringify(wrap)
const altered = {
...wrap,
wrappedMaterial: alterLastByte(wrap.wrappedMaterial),
}
await expect(unwrapWalletMaterialWithPrf(OTHER_PRF_OUTPUT, wrap)).rejects.toThrow()
await expect(unwrapWalletMaterialWithPrf(PRF_OUTPUT, altered)).rejects.toThrow()
expect(JSON.stringify(wrap)).toBe(before)
})
it.each([
['legacy key-only payload', MATERIAL.linkingKeyHex],
[
'malformed root',
JSON.stringify({...MATERIAL, cashRootHex: `${MATERIAL.cashRootHex.slice(0, -2)}00`}),
],
])('rejects authenticated %s without changing slots or activation', async (_name, payload) => {
const authenticator = new FakeAuthenticator()
const slot = await registerPasskey(MATERIAL, {credentials: authenticator})
const prfOutput = await getPasskeyPrfOutput(slot.credentialId, {credentials: authenticator})
const materialHash = bytesToHex(sha256(utf8ToBytes(serializeWalletMaterial(MATERIAL))))
const wrap = await encryptPayload(prfOutput, payload, materialHash)
localStorage.setItem(SLOT_STORAGE_KEY, JSON.stringify([{...slot, ...wrap}]))
const before = rawSlots()
let activated = false
await expect(
unlockWalletMaterialWithPasskey({credentials: authenticator}).then(() => {
activated = true
}),
).rejects.toThrow()
expect(activated).toBe(false)
expect(rawSlots()).toBe(before)
})
it('rejects a wrong runtime PRF without changing slots or activation', async () => {
const authenticator = new FakeAuthenticator()
await registerPasskey(MATERIAL, {credentials: authenticator})
const before = rawSlots()
let activated = false
authenticator.rotateSecret()
await expect(
unlockWalletMaterialWithPasskey({credentials: authenticator}).then(() => {
activated = true
}),
).rejects.toThrow()
expect(activated).toBe(false)
expect(rawSlots()).toBe(before)
})
it('rejects altered stored ciphertext without rewriting it or activating', async () => {
const authenticator = new FakeAuthenticator()
const slot = await registerPasskey(MATERIAL, {credentials: authenticator})
const altered = {
...slot,
wrappedMaterial: alterLastByte(slot.wrappedMaterial),
}
localStorage.setItem(SLOT_STORAGE_KEY, JSON.stringify([altered]))
const before = rawSlots()
let activated = false
await expect(
unlockWalletMaterialWithPasskey({credentials: authenticator}).then(() => {
activated = true
}),
).rejects.toThrow()
expect(activated).toBe(false)
expect(rawSlots()).toBe(before)
})
it('rejects same-owner altered root at registration without a ceremony or slot write', async () => {
const authenticator = new FakeAuthenticator()
const alteredRoot = {...MATERIAL, cashRootHex: OTHER_MATERIAL.cashRootHex}
await expect(registerPasskey(alteredRoot, {credentials: authenticator})).rejects.toThrow(
'saved wallet material',
)
expect(rawSlots()).toBe('')
})
it('registers and unlocks complete material without plaintext slot leakage', async () => {
const authenticator = new FakeAuthenticator()
const slot = await registerPasskey(MATERIAL, {credentials: authenticator})
const unlocked = await unlockWalletMaterialWithPasskey({credentials: authenticator})
expect(unlocked).toEqual(MATERIAL)
expect(slot.ownerId).not.toContain(MATERIAL.linkingKeyHex)
expect(rawSlots()).not.toContain(MATERIAL.linkingKeyHex)
expect(rawSlots()).not.toContain(MATERIAL.cashRootHex)
})
it('rejects authenticated foreign-owner material without changing slots or activation', async () => {
const authenticator = new FakeAuthenticator()
const slot = await registerPasskey(MATERIAL, {credentials: authenticator})
const prfOutput = await getPasskeyPrfOutput(slot.credentialId, {credentials: authenticator})
const foreignWrap = await wrapWalletMaterialWithPrf(prfOutput, OTHER_MATERIAL)
localStorage.setItem(SLOT_STORAGE_KEY, JSON.stringify([{...slot, ...foreignWrap}]))
const before = rawSlots()
let activated = false
await expect(
unlockWalletMaterialWithPasskey({credentials: authenticator}).then(() => {
activated = true
}),
).rejects.toThrow('different wallet')
expect(activated).toBe(false)
expect(rawSlots()).toBe(before)
})
it('rejects a slot changed during its ceremony without changing storage or activation', async () => {
const authenticator = new FakeAuthenticator()
await registerPasskey(MATERIAL, {credentials: authenticator})
let changed = ''
authenticator.onGet = async () => {
const slot = readPasskeySlots()[0]
if (slot === undefined) throw new Error('expected registered slot')
localStorage.setItem(SLOT_STORAGE_KEY, JSON.stringify([{...slot, createdAt: slot.createdAt + 1}]))
changed = rawSlots()
}
let activated = false
await expect(
unlockWalletMaterialWithPasskey({credentials: authenticator}).then(() => {
activated = true
}),
).rejects.toThrow('changed')
expect(activated).toBe(false)
expect(rawSlots()).toBe(changed)
})
it('rewraps every slot around byte-identical material or writes nothing', async () => {
const authenticator = new FakeAuthenticator()
const slot = await registerPasskey(MATERIAL, {credentials: authenticator})
const before = rawSlots()
await expect(rewrapAllSlots(MATERIAL, new Map())).rejects.toThrow('partial re-wrap')
expect(rawSlots()).toBe(before)
const prfOutput = await getPasskeyPrfOutput(slot.credentialId, {credentials: authenticator})
await rewrapAllSlots(MATERIAL, new Map([[slot.credentialId, prfOutput]]))
await expect(unlockWalletMaterialWithPasskey({credentials: authenticator})).resolves.toEqual(
MATERIAL,
)
})
it('does not create a slot when registration is cancelled', async () => {
const authenticator = new FakeAuthenticator()
authenticator.cancelCreate = true
await expect(registerPasskey(MATERIAL, {credentials: authenticator})).rejects.toThrow('cancelled')
expect(rawSlots()).toBe('')
})
})
+12 -3
View File
@@ -21,18 +21,20 @@ import {
removePasskey,
rewrapAllSlots,
unlockWithPasskey,
unwrapLinkingKeyWithPrf,
wrapLinkingKeyWithPrf,
unwrapWalletMaterialWithPrf,
wrapWalletMaterialWithPrf,
} from './passkeys'
import {
decryptRecord,
decryptSavedLinkingKey,
deriveBearerAesKey,
deriveWalletMaterial,
ensureSavedKeyOwner,
encryptRecord,
linkingPubKeyHex,
savedKeyOwnerId,
saveLinkingKey,
saveWalletMaterial,
} from './keys'
import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils'
@@ -40,6 +42,12 @@ const LINKING_KEY = new Uint8Array(32).fill(7)
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9)
const PRF_OUTPUT = new Uint8Array(32).fill(3)
const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4)
const MATERIAL = {
...deriveWalletMaterial(
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
),
linkingKeyHex: bytesToHex(LINKING_KEY),
}
const toBytes = (source: BufferSource): Uint8Array =>
source instanceof ArrayBuffer
@@ -134,6 +142,7 @@ const removeSavedOwnerMarker = (): void => {
beforeEach(async () => {
stubLocalStorage()
await saveLinkingKey(LINKING_KEY)
await saveWalletMaterial(MATERIAL)
})
describe('multiple passkeys', () => {
@@ -151,7 +160,7 @@ describe('multiple passkeys', () => {
expect(readPasskeySlots()).toHaveLength(2)
// independent wrap keys: same plaintext, different salts and ciphertexts
expect(laptopSlot.hkdfSalt).not.toBe(phoneSlot.hkdfSalt)
expect(laptopSlot.wrappedKey).not.toBe(phoneSlot.wrappedKey)
expect(laptopSlot.wrappedMaterial).not.toBe(phoneSlot.wrappedMaterial)
expect(bytesToHex(await unlockWithPasskey({credentials: laptop}))).toBe(bytesToHex(LINKING_KEY))
expect(bytesToHex(await unlockWithPasskey({credentials: phone}))).toBe(bytesToHex(LINKING_KEY))
+14 -3
View File
@@ -21,18 +21,20 @@ import {
removePasskey,
rewrapAllSlots,
unlockWithPasskey,
unwrapLinkingKeyWithPrf,
wrapLinkingKeyWithPrf,
unwrapWalletMaterialWithPrf,
wrapWalletMaterialWithPrf,
} from './passkeys'
import {
decryptRecord,
decryptSavedLinkingKey,
deriveBearerAesKey,
deriveWalletMaterial,
ensureSavedKeyOwner,
encryptRecord,
linkingPubKeyHex,
savedKeyOwnerId,
saveLinkingKey,
saveWalletMaterial,
} from './keys'
import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils'
@@ -40,6 +42,12 @@ const LINKING_KEY = new Uint8Array(32).fill(7)
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9)
const PRF_OUTPUT = new Uint8Array(32).fill(3)
const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4)
const MATERIAL = {
...deriveWalletMaterial(
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
),
linkingKeyHex: bytesToHex(LINKING_KEY),
}
const toBytes = (source: BufferSource): Uint8Array =>
source instanceof ArrayBuffer
@@ -134,6 +142,7 @@ const removeSavedOwnerMarker = (): void => {
beforeEach(async () => {
stubLocalStorage()
await saveLinkingKey(LINKING_KEY)
await saveWalletMaterial(MATERIAL)
})
describe('slot ownership', () => {
@@ -167,7 +176,8 @@ describe('slot ownership', () => {
credentialId: '33'.repeat(16),
hkdfSalt: current.hkdfSalt,
iv: current.iv,
wrappedKey: current.wrappedKey,
materialHash: current.materialHash,
wrappedMaterial: current.wrappedMaterial,
createdAt: current.createdAt,
}
writeRawSlots([foreign, malformed, unowned])
@@ -210,6 +220,7 @@ describe('slot ownership', () => {
// When a different wallet is installed with its canonical owner
await saveLinkingKey(OTHER_LINKING_KEY)
await saveWalletMaterial({...MATERIAL, linkingKeyHex: bytesToHex(OTHER_LINKING_KEY)})
// Then the residue stays unowned and unavailable to the new wallet
expect(readPasskeySlots()).toEqual([])
+15 -4
View File
@@ -21,18 +21,20 @@ import {
removePasskey,
rewrapAllSlots,
unlockWithPasskey,
unwrapLinkingKeyWithPrf,
wrapLinkingKeyWithPrf,
unwrapWalletMaterialWithPrf,
wrapWalletMaterialWithPrf,
} from './passkeys'
import {
decryptRecord,
decryptSavedLinkingKey,
deriveBearerAesKey,
deriveWalletMaterial,
ensureSavedKeyOwner,
encryptRecord,
linkingPubKeyHex,
savedKeyOwnerId,
saveLinkingKey,
saveWalletMaterial,
} from './keys'
import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils'
@@ -40,6 +42,13 @@ const LINKING_KEY = new Uint8Array(32).fill(7)
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9)
const PRF_OUTPUT = new Uint8Array(32).fill(3)
const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4)
const MATERIAL = {
...deriveWalletMaterial(
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
),
linkingKeyHex: bytesToHex(LINKING_KEY),
}
const OTHER_MATERIAL = {...MATERIAL, linkingKeyHex: bytesToHex(OTHER_LINKING_KEY)}
const toBytes = (source: BufferSource): Uint8Array =>
source instanceof ArrayBuffer
@@ -134,6 +143,7 @@ const removeSavedOwnerMarker = (): void => {
beforeEach(async () => {
stubLocalStorage()
await saveLinkingKey(LINKING_KEY)
await saveWalletMaterial(MATERIAL)
})
describe('slot ownership (continued)', () => {
@@ -145,7 +155,7 @@ describe('slot ownership (continued)', () => {
const prfOutput = await getPasskeyPrfOutput(slot.credentialId, {
credentials: auth,
})
const foreignWrap = await wrapLinkingKeyWithPrf(prfOutput, OTHER_LINKING_KEY)
const foreignWrap = await wrapWalletMaterialWithPrf(prfOutput, OTHER_MATERIAL)
writeRawSlots([{...slot, ...foreignWrap}])
// When the authenticator successfully unwraps that foreign key
@@ -164,6 +174,7 @@ describe('slot ownership (continued)', () => {
get: async (options) => {
const credential = await auth.get(options)
await saveLinkingKey(OTHER_LINKING_KEY)
await saveWalletMaterial(OTHER_MATERIAL)
return credential
},
}
@@ -220,7 +231,7 @@ describe('slot ownership (continued)', () => {
})
// When the current wallet rewraps its slots
await rewrapAllSlots(LINKING_KEY, new Map([[slot.credentialId, prfOutput]]))
await rewrapAllSlots(MATERIAL, new Map([[slot.credentialId, prfOutput]]))
// Then the foreign slot did not require output and remains byte-identical
expect(readRawSlots()).toContainEqual(foreign)
+19 -9
View File
@@ -21,18 +21,21 @@ import {
removePasskey,
rewrapAllSlots,
unlockWithPasskey,
unwrapLinkingKeyWithPrf,
wrapLinkingKeyWithPrf,
unwrapWalletMaterialWithPrf,
wrapWalletMaterialWithPrf,
} from './passkeys'
import {
decryptRecord,
decryptSavedLinkingKey,
decryptSavedWalletMaterial,
deriveBearerAesKey,
deriveWalletMaterial,
ensureSavedKeyOwner,
encryptRecord,
linkingPubKeyHex,
savedKeyOwnerId,
saveLinkingKey,
saveWalletMaterial,
walletMaterialLinkingKey,
} from './keys'
import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils'
@@ -40,6 +43,12 @@ const LINKING_KEY = new Uint8Array(32).fill(7)
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9)
const PRF_OUTPUT = new Uint8Array(32).fill(3)
const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4)
const MATERIAL = {
...deriveWalletMaterial(
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
),
linkingKeyHex: bytesToHex(LINKING_KEY),
}
const toBytes = (source: BufferSource): Uint8Array =>
source instanceof ArrayBuffer
@@ -134,6 +143,7 @@ const removeSavedOwnerMarker = (): void => {
beforeEach(async () => {
stubLocalStorage()
await saveLinkingKey(LINKING_KEY)
await saveWalletMaterial(MATERIAL)
})
describe('registration and unlock', () => {
@@ -160,18 +170,18 @@ describe('registration and unlock', () => {
})
it('yields the same key material unlock(password) yields', async () => {
const linkingKey = crypto.getRandomValues(new Uint8Array(32))
await saveLinkingKey(linkingKey, 'correct horse')
await saveWalletMaterial(MATERIAL, 'correct horse')
const auth = new FakeAuthenticator()
await registerPasskey(linkingKey, {credentials: auth})
await registerPasskey(MATERIAL, {credentials: auth})
const viaPassword = await decryptSavedLinkingKey('correct horse')
const viaPassword = await decryptSavedWalletMaterial('correct horse')
const viaPasskey = await unlockWithPasskey({credentials: auth})
expect(bytesToHex(viaPasskey)).toBe(bytesToHex(viaPassword))
const passwordLinkingKey = walletMaterialLinkingKey(viaPassword)
expect(bytesToHex(viaPasskey)).toBe(bytesToHex(passwordLinkingKey))
// and the practical consequence: a bearer record encrypted after a
// password unlock decrypts after a passkey unlock
const passwordAes = await deriveBearerAesKey(viaPassword)
const passwordAes = await deriveBearerAesKey(passwordLinkingKey)
const record = await encryptRecord(passwordAes, {note: 'still readable'})
const passkeyAes = await deriveBearerAesKey(viaPasskey)
await expect(decryptRecord(passkeyAes, record)).resolves.toEqual({
+14 -5
View File
@@ -21,18 +21,20 @@ import {
removePasskey,
rewrapAllSlots,
unlockWithPasskey,
unwrapLinkingKeyWithPrf,
wrapLinkingKeyWithPrf,
unwrapWalletMaterialWithPrf,
wrapWalletMaterialWithPrf,
} from './passkeys'
import {
decryptRecord,
decryptSavedLinkingKey,
deriveBearerAesKey,
deriveWalletMaterial,
ensureSavedKeyOwner,
encryptRecord,
linkingPubKeyHex,
savedKeyOwnerId,
saveLinkingKey,
saveWalletMaterial,
} from './keys'
import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils'
@@ -40,6 +42,12 @@ const LINKING_KEY = new Uint8Array(32).fill(7)
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9)
const PRF_OUTPUT = new Uint8Array(32).fill(3)
const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4)
const MATERIAL = {
...deriveWalletMaterial(
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
),
linkingKeyHex: bytesToHex(LINKING_KEY),
}
const toBytes = (source: BufferSource): Uint8Array =>
source instanceof ArrayBuffer
@@ -134,6 +142,7 @@ const removeSavedOwnerMarker = (): void => {
beforeEach(async () => {
stubLocalStorage()
await saveLinkingKey(LINKING_KEY)
await saveWalletMaterial(MATERIAL)
})
describe('rewrap for the current owner', () => {
@@ -156,7 +165,7 @@ describe('rewrap for the current owner', () => {
}),
],
])
await expect(rewrapAllSlots(LINKING_KEY, partial)).rejects.toThrow('partial re-wrap')
await expect(rewrapAllSlots(MATERIAL, partial)).rejects.toThrow('partial re-wrap')
expect(bytesToHex(await unlockWithPasskey({credentials: laptop}))).toBe(bytesToHex(LINKING_KEY))
// full coverage refreshes both wraps around the same proven owner key
@@ -174,10 +183,10 @@ describe('rewrap for the current owner', () => {
}),
],
])
await rewrapAllSlots(LINKING_KEY, fresh)
await rewrapAllSlots(MATERIAL, fresh)
expect(bytesToHex(await unlockWithPasskey({credentials: laptop}))).toBe(bytesToHex(LINKING_KEY))
expect(bytesToHex(await unlockWithPasskey({credentials: phone}))).toBe(bytesToHex(LINKING_KEY))
expect(readPasskeySlots()[0]?.wrappedKey).not.toBe(laptopSlot.wrappedKey)
expect(readPasskeySlots()[0]?.wrappedMaterial).not.toBe(laptopSlot.wrappedMaterial)
// credential ids and labels survive the re-wrap
expect(
readPasskeySlots()
+11 -2
View File
@@ -21,18 +21,20 @@ import {
removePasskey,
rewrapAllSlots,
unlockWithPasskey,
unwrapLinkingKeyWithPrf,
wrapLinkingKeyWithPrf,
unwrapWalletMaterialWithPrf,
wrapWalletMaterialWithPrf,
} from './passkeys'
import {
decryptRecord,
decryptSavedLinkingKey,
deriveBearerAesKey,
deriveWalletMaterial,
ensureSavedKeyOwner,
encryptRecord,
linkingPubKeyHex,
savedKeyOwnerId,
saveLinkingKey,
saveWalletMaterial,
} from './keys'
import {parseJsonArray, parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils'
@@ -40,6 +42,12 @@ const LINKING_KEY = new Uint8Array(32).fill(7)
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9)
const PRF_OUTPUT = new Uint8Array(32).fill(3)
const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4)
const MATERIAL = {
...deriveWalletMaterial(
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
),
linkingKeyHex: bytesToHex(LINKING_KEY),
}
const toBytes = (source: BufferSource): Uint8Array =>
source instanceof ArrayBuffer
@@ -134,6 +142,7 @@ const removeSavedOwnerMarker = (): void => {
beforeEach(async () => {
stubLocalStorage()
await saveLinkingKey(LINKING_KEY)
await saveWalletMaterial(MATERIAL)
})
describe('slot storage hygiene', () => {
+11 -2
View File
@@ -21,18 +21,20 @@ import {
removePasskey,
rewrapAllSlots,
unlockWithPasskey,
unwrapLinkingKeyWithPrf,
wrapLinkingKeyWithPrf,
unwrapWalletMaterialWithPrf,
wrapWalletMaterialWithPrf,
} from './passkeys'
import {
decryptRecord,
decryptSavedLinkingKey,
deriveBearerAesKey,
deriveWalletMaterial,
ensureSavedKeyOwner,
encryptRecord,
linkingPubKeyHex,
savedKeyOwnerId,
saveLinkingKey,
saveWalletMaterial,
} from './keys'
import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils'
@@ -40,6 +42,12 @@ const LINKING_KEY = new Uint8Array(32).fill(7)
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9)
const PRF_OUTPUT = new Uint8Array(32).fill(3)
const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4)
const MATERIAL = {
...deriveWalletMaterial(
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
),
linkingKeyHex: bytesToHex(LINKING_KEY),
}
const toBytes = (source: BufferSource): Uint8Array =>
source instanceof ArrayBuffer
@@ -134,6 +142,7 @@ const removeSavedOwnerMarker = (): void => {
beforeEach(async () => {
stubLocalStorage()
await saveLinkingKey(LINKING_KEY)
await saveWalletMaterial(MATERIAL)
})
describe('passkeySupported', () => {
+1
View File
@@ -1,4 +1,5 @@
import './passkeys.crypto.cases'
import './passkeys.material.cases'
import './passkeys.registration.cases'
import './passkeys.ownership-a.cases'
import './passkeys.ownership-b.cases'
+65 -44
View File
@@ -1,16 +1,10 @@
// Passkey (WebAuthn PRF) unlock: an ALTERNATIVE wrap of the same linking
// key the password path protects (keys.ts) - never a second key, so notes
// encrypted under a password unlock stay readable after a passkey unlock
// and vice versa (both yield the identical linking key, from which the
// bearer AES key derives).
// Passkey (WebAuthn PRF) unlock is an alternative wrap of the complete
// canonical wallet material protected by the password path. Slot ownership
// remains derived only from the linking key inside that material.
//
// No master-key indirection is introduced: unlike Bitwarden, this wallet
// persists exactly one secret - the seed-derived linking key - and the
// bearer-encryption key is derived from it (not wrapped by it), so a random
// master key would only ever encrypt that one 32-byte value while forcing a
// migration of every existing store. The linking key IS the "master key"
// here: the password wrap (keys.ts) and each passkey slot below are
// independent wraps of the same key material.
// persists one authenticated material envelope. The password wrap and each
// passkey slot are independent wraps of those same canonical bytes.
//
// The module is split into a pure-crypto core (passkeyWrap.ts: HKDF from a
// PRF output to an AES-GCM wrap key, slot wrap/unwrap - fully unit-tested)
@@ -30,21 +24,31 @@
import {sha256} from '@noble/hashes/sha2.js'
import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js'
import {linkingPubKeyHex, savedKeyOwnerId} from './keys'
import {getPlainWalletMaterial, walletMaterialLinkingKey} from './keys'
import {withStorageLock} from './storageLock'
import type {PasskeySlot} from './storage/passkeySlots'
import {
PASSKEY_SLOTS_STORAGE_KEY,
PASSKEY_SLOT_VERSION,
passkeySlotsEqual,
readPasskeySlots,
requireCurrentPasskeyMaterialOwner,
writePasskeySlots,
} from './storage/passkeySlots'
import {unwrapLinkingKeyWithPrf, wrapLinkingKeyWithPrf} from './passkeyWrap'
import type {WalletMaterialV2} from './storage/storedSecret'
import {walletMaterialOwnerId} from './storage/storedSecret'
import {savedWalletMaterialOwnerId} from './walletMaterialStorage'
import {unwrapWalletMaterialWithPrf, wrapWalletMaterialWithPrf} from './passkeyWrap'
export type {PasskeySlot, PasskeyWrap} from './storage/passkeySlots'
export {readPasskeySlots, hasPasskeySlots} from './storage/passkeySlots'
export {migrateLegacyPasskeySlots} from './passkeyOwnership'
export {derivePasskeyWrapKey, wrapLinkingKeyWithPrf, unwrapLinkingKeyWithPrf} from './passkeyWrap'
export {
derivePasskeyWrapKey,
InvalidPasskeyMaterialError,
wrapWalletMaterialWithPrf,
unwrapWalletMaterialWithPrf,
} from './passkeyWrap'
// 32 bytes, fixed - the authenticator requires exactly 32
const PASSKEY_PRF_SALT = sha256(utf8ToBytes('sattle-passkey-prf-v1'))
@@ -120,8 +124,7 @@ const prfOutputOf = (credential: CeremonyCredential): Uint8Array | null => {
}
// one get() ceremony against a single known credential, returning its fresh
// PRF output - the building block for re-wrap ceremonies during linking-key
// rotation
// PRF output - the building block for complete-material re-wrap ceremonies
export const getPasskeyPrfOutput = async (
credentialId: string,
options: {credentials?: PasskeyCredentials} = {},
@@ -149,20 +152,28 @@ export type RegisterPasskeyOptions = {
authenticatorAttachment?: AuthenticatorAttachment
}
// Registers a new passkey and persists a slot wrapping the given linking
// key. The caller supplies the linking key from the currently unlocked
// wallet; the ceremony is navigator.credentials.create with the PRF
// Registers a new passkey and persists a slot wrapping the complete material.
// The Uint8Array input exists only until Task 5 updates the UI caller; it can
// select already-saved plaintext v2 material but can never create a key-only
// slot. The ceremony is navigator.credentials.create with the PRF
// extension evaluated on creation. Some authenticators only report
// prf.enabled during create and evaluate the secret on the first get -
// those get a follow-up get() against the fresh credential.
export const registerPasskey = async (
linkingKey: Uint8Array,
material: WalletMaterialV2 | Uint8Array,
options: RegisterPasskeyOptions = {},
): Promise<PasskeySlot> => {
const ownerId = savedKeyOwnerId()
if (ownerId === null || linkingPubKeyHex(linkingKey) !== ownerId) {
throw new Error('Passkey registration requires the proven saved wallet owner.')
let walletMaterial: WalletMaterialV2
if (material instanceof Uint8Array) {
const savedMaterial = getPlainWalletMaterial()
if (savedMaterial === null || savedMaterial.linkingKeyHex !== bytesToHex(material)) {
throw new Error('Passkey registration requires complete v2 wallet material.')
}
walletMaterial = savedMaterial
} else {
walletMaterial = material
}
const ownerId = requireCurrentPasskeyMaterialOwner(walletMaterial)
const credentials = options.credentials ?? defaultCredentials()
const credential = await credentials.create({
publicKey: {
@@ -197,7 +208,7 @@ export const registerPasskey = async (
}
prfOutput = await getPasskeyPrfOutput(credentialId, {credentials})
}
const wrap = await wrapLinkingKeyWithPrf(prfOutput, linkingKey)
const wrap = await wrapWalletMaterialWithPrf(prfOutput, walletMaterial)
const slot: PasskeySlot = {
credentialId,
...wrap,
@@ -214,13 +225,13 @@ export const registerPasskey = async (
return slot
}
// Unlocks via any registered passkey: one get() ceremony offering every
// slot's credential, then unwrap. Yields the exact same linking key
// unlock(password) yields - the caller activates the wallet with it.
export const unlockWithPasskey = async (
// Unlocks complete material via one get() ceremony offering every current
// slot. The selected slot is re-read after the ceremony before any plaintext
// reaches the caller, rejecting owner or storage changes during user presence.
export const unlockWalletMaterialWithPasskey = async (
options: {credentials?: PasskeyCredentials} = {},
): Promise<Uint8Array> => {
const ownerId = savedKeyOwnerId()
): Promise<WalletMaterialV2> => {
const ownerId = savedWalletMaterialOwnerId()
const slots = readPasskeySlots()
if (ownerId === null || slots.length === 0) {
throw new Error('No passkeys registered on this device.')
@@ -247,18 +258,32 @@ export const unlockWithPasskey = async (
if (!prfOutput) {
throw new Error('This passkey did not return a PRF secret - it cannot unlock this wallet.')
}
const linkingKey = await unwrapLinkingKeyWithPrf(prfOutput, slot)
if (savedKeyOwnerId() !== ownerId || linkingPubKeyHex(linkingKey) !== ownerId) {
if (savedWalletMaterialOwnerId() !== ownerId) {
throw new Error('This passkey belongs to a different wallet.')
}
return linkingKey
const currentSlot = readPasskeySlots().find((candidate) => candidate.credentialId === credentialId)
if (currentSlot === undefined || !passkeySlotsEqual(slot, currentSlot)) {
throw new Error('The passkey slot changed during the unlock ceremony.')
}
const material = await unwrapWalletMaterialWithPrf(prfOutput, currentSlot)
if (savedWalletMaterialOwnerId() !== ownerId || walletMaterialOwnerId(material) !== ownerId) {
throw new Error('This passkey belongs to a different wallet.')
}
return material
}
// Task 5 replaces this temporary projection when lifecycle activation accepts
// complete wallet material. Slots already contain only canonical v2 material.
export const unlockWithPasskey = async (
options: {credentials?: PasskeyCredentials} = {},
): Promise<Uint8Array> =>
walletMaterialLinkingKey(await unlockWalletMaterialWithPasskey(options))
// Removes the slot only: WebAuthn has no API to delete the credential from
// the authenticator - an orphaned passkey simply finds nothing to unwrap.
// Returns whether a slot was actually removed.
export const removePasskey = async (credentialId: string): Promise<boolean> => {
const ownerId = savedKeyOwnerId()
const ownerId = savedWalletMaterialOwnerId()
if (ownerId === null) return false
let removed = false
await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => {
@@ -270,23 +295,19 @@ export const removePasskey = async (credentialId: string): Promise<boolean> => {
return removed
}
// Refreshes every current-owner slot around the same proven key material.
// Refreshes every current-owner slot around the same proven wallet material.
// Each slot's wrap secret lives only inside its authenticator, so the caller
// must supply a fresh PRF output per credential (one getPasskeyPrfOutput
// ceremony each). All-or-nothing: a slot without a PRF output aborts the
// whole refresh before anything is written.
//
// A password change does NOT need this: the password wrap (keys.ts) and the
// passkey slots wrap the same linking key independently, so re-encrypting
// the stored key under a new password leaves every slot valid.
// A password change does not need this because password and passkey slots are
// independent wraps of the same canonical material.
export const rewrapAllSlots = async (
linkingKey: Uint8Array,
material: WalletMaterialV2,
prfOutputs: ReadonlyMap<string, Uint8Array>,
): Promise<void> => {
const ownerId = savedKeyOwnerId()
if (ownerId === null || linkingPubKeyHex(linkingKey) !== ownerId) {
throw new Error('Passkey re-wrap requires the proven saved wallet owner.')
}
const ownerId = requireCurrentPasskeyMaterialOwner(material)
await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, async () => {
const slots = readPasskeySlots()
const rewrapped: PasskeySlot[] = []
@@ -297,7 +318,7 @@ export const rewrapAllSlots = async (
}
rewrapped.push({
...slot,
...(await wrapLinkingKeyWithPrf(prfOutput, linkingKey)),
...(await wrapWalletMaterialWithPrf(prfOutput, material)),
})
}
writePasskeySlots(ownerId, rewrapped)
+24 -9
View File
@@ -1,7 +1,14 @@
import {beforeEach, describe, expect, it} from 'vitest'
import {bytesToHex} from '@noble/hashes/utils.js'
import {ensureSavedKeyOwner, getPlainLinkingKey, linkingPubKeyHex, savedKeyOwnerId} from './keys'
import {
deriveWalletMaterial,
ensureSavedKeyOwner,
getPlainLinkingKey,
linkingPubKeyHex,
savedKeyOwnerId,
saveWalletMaterial,
} from './keys'
import {migrateLegacyPasskeySlots, readPasskeySlots} from './passkeys'
import type {PasskeySlot} from './passkeys'
import {parseJsonObjectArray, stubLocalStorage} from './test-utils'
@@ -10,12 +17,19 @@ const LINKING_KEY = new Uint8Array(32).fill(7)
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9)
const KEY_STORAGE = 'sattle_linking_key'
const SLOT_STORAGE = 'sattle_passkey_slots'
const MATERIAL = {
...deriveWalletMaterial(
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about',
),
linkingKeyHex: bytesToHex(LINKING_KEY),
}
const SLOT_BASE = {
credentialId: '11'.repeat(16),
hkdfSalt: '22'.repeat(16),
iv: '33'.repeat(12),
wrappedKey: '44'.repeat(48),
materialHash: '44'.repeat(32),
wrappedMaterial: '55'.repeat(128),
createdAt: 1,
} as const
@@ -24,17 +38,18 @@ const writeRawSlots = (slots: readonly Record<string, unknown>[]): void => {
}
describe('passkey-slot schema version', () => {
beforeEach(() => {
beforeEach(async () => {
stubLocalStorage()
await saveWalletMaterial(MATERIAL)
})
it('reads a current version 1 slot for the exact saved owner', () => {
it('reads a current version 2 slot for the exact saved owner', () => {
// Given a current saved-key marker and passkey record
const ownerId = linkingPubKeyHex(LINKING_KEY)
localStorage.setItem(
KEY_STORAGE,
JSON.stringify({enc: false, value: bytesToHex(LINKING_KEY), ownerId, version: 1}),
)
const slot: PasskeySlot = {...SLOT_BASE, ownerId, version: 1}
const slot: PasskeySlot = {...SLOT_BASE, ownerId, version: 2}
writeRawSlots([slot])
// When current-owner slots are read
@@ -61,12 +76,12 @@ describe('passkey-slot schema version', () => {
// Then both compatible legacy forms become current records
expect(savedKeyOwnerId()).toBe(ownerId)
expect(parseJsonObjectArray(localStorage.getItem(SLOT_STORAGE) ?? '[]')).toEqual([
{...SLOT_BASE, ownerId, version: 1},
{...SLOT_BASE, credentialId: '55'.repeat(16), ownerId, version: 1},
{...SLOT_BASE, ownerId, version: 2},
{...SLOT_BASE, credentialId: '55'.repeat(16), ownerId, version: 2},
])
})
it.each([2, '1', null])('hides unsupported or malformed version %j', (version) => {
it.each([1, 3, '2', null])('hides unsupported or malformed version %j', (version) => {
// Given an otherwise valid slot carrying unrecognized metadata
const ownerId = linkingPubKeyHex(LINKING_KEY)
localStorage.setItem(
@@ -87,7 +102,7 @@ describe('passkey-slot schema version', () => {
KEY_STORAGE,
JSON.stringify({enc: false, value: bytesToHex(LINKING_KEY), ownerId, version: 1}),
)
writeRawSlots([{...SLOT_BASE, ownerId: linkingPubKeyHex(OTHER_LINKING_KEY), version: 1}])
writeRawSlots([{...SLOT_BASE, ownerId: linkingPubKeyHex(OTHER_LINKING_KEY), version: 2}])
// When the current wallet reads passkeys
// Then the foreign credential is unavailable
+48 -16
View File
@@ -1,19 +1,22 @@
// Passkey-slot persistence: every new slot is bound to the canonical owner
// of the saved linking key. localStorage remains hostile input, so owner
// Passkey-slot persistence: every v2 slot is bound to the canonical owner
// of the saved wallet material. localStorage remains hostile input, so owner
// markers are parsed strictly and reads expose only slots belonging to the
// currently proven saved owner. Mutations preserve every other record.
import {savedKeyOwnerId} from '../keys'
import {isJsonObject} from '../jsonParsing'
import type {WalletMaterialV2} from './storedSecret'
import {serializeWalletMaterial, walletMaterialOwnerId} from './storedSecret'
import {getPlainWalletMaterial, savedWalletMaterialOwnerId} from '../walletMaterialStorage'
import {isWalletOwnerId} from './walletOwner'
export type PasskeyWrap = {
readonly hkdfSalt: string
readonly iv: string
readonly wrappedKey: string
readonly materialHash: string
readonly wrappedMaterial: string
}
export const PASSKEY_SLOT_VERSION = 1 as const
export const PASSKEY_SLOT_VERSION = 2 as const
export type PasskeySlot = PasskeyWrap & {
readonly credentialId: string
@@ -23,6 +26,30 @@ export type PasskeySlot = PasskeyWrap & {
readonly version: typeof PASSKEY_SLOT_VERSION
}
export const passkeySlotsEqual = (left: PasskeySlot, right: PasskeySlot): boolean =>
left.credentialId === right.credentialId &&
left.hkdfSalt === right.hkdfSalt &&
left.iv === right.iv &&
left.materialHash === right.materialHash &&
left.wrappedMaterial === right.wrappedMaterial &&
left.createdAt === right.createdAt &&
left.name === right.name &&
left.ownerId === right.ownerId &&
left.version === right.version
export const requireCurrentPasskeyMaterialOwner = (material: WalletMaterialV2): string => {
const ownerId = savedWalletMaterialOwnerId()
const plaintext = getPlainWalletMaterial()
if (
ownerId === null ||
walletMaterialOwnerId(material) !== ownerId ||
(plaintext !== null && serializeWalletMaterial(plaintext) !== serializeWalletMaterial(material))
) {
throw new Error('Passkey operation requires the proven saved wallet material.')
}
return ownerId
}
type StoredPasskeySlot = PasskeyWrap & {
readonly credentialId: string
readonly createdAt: number
@@ -43,7 +70,8 @@ const SLOT_KEYS: readonly string[] = [
'credentialId',
'hkdfSalt',
'iv',
'wrappedKey',
'materialHash',
'wrappedMaterial',
'createdAt',
'name',
'ownerId',
@@ -61,10 +89,12 @@ const parseStoredPasskeySlot = (slot: unknown): ParsedPasskeySlot | null => {
/^[0-9a-f]{32}$/i.test(slot.hkdfSalt) &&
typeof slot.iv === 'string' &&
/^[0-9a-f]{24}$/i.test(slot.iv) &&
typeof slot.wrappedKey === 'string' &&
slot.wrappedKey.length > 0 &&
slot.wrappedKey.length % 2 === 0 &&
/^[0-9a-f]+$/i.test(slot.wrappedKey) &&
typeof slot.materialHash === 'string' &&
/^[0-9a-f]{64}$/.test(slot.materialHash) &&
typeof slot.wrappedMaterial === 'string' &&
slot.wrappedMaterial.length > 0 &&
slot.wrappedMaterial.length % 2 === 0 &&
/^[0-9a-f]+$/.test(slot.wrappedMaterial) &&
typeof slot.createdAt === 'number' &&
(slot.name === undefined || typeof slot.name === 'string') &&
Object.keys(slot).every((key) => SLOT_KEYS.includes(key))
@@ -73,7 +103,8 @@ const parseStoredPasskeySlot = (slot: unknown): ParsedPasskeySlot | null => {
credentialId: slot.credentialId,
hkdfSalt: slot.hkdfSalt,
iv: slot.iv,
wrappedKey: slot.wrappedKey,
materialHash: slot.materialHash,
wrappedMaterial: slot.wrappedMaterial,
createdAt: slot.createdAt,
...(slot.name !== undefined ? {name: slot.name} : {}),
}
@@ -124,7 +155,8 @@ const asOwnedSlot = (stored: ParsedPasskeySlot, ownerId: string): PasskeySlot |
credentialId: record.credentialId,
hkdfSalt: record.hkdfSalt,
iv: record.iv,
wrappedKey: record.wrappedKey,
materialHash: record.materialHash,
wrappedMaterial: record.wrappedMaterial,
createdAt: record.createdAt,
...(record.name !== undefined ? {name: record.name} : {}),
ownerId,
@@ -133,7 +165,7 @@ const asOwnedSlot = (stored: ParsedPasskeySlot, ownerId: string): PasskeySlot |
}
export const readPasskeySlots = (): PasskeySlot[] => {
const ownerId = savedKeyOwnerId()
const ownerId = savedWalletMaterialOwnerId()
if (ownerId === null) return []
return readStoredPasskeySlots()
.map((slot) => asOwnedSlot(slot, ownerId))
@@ -143,7 +175,7 @@ export const readPasskeySlots = (): PasskeySlot[] => {
export const hasPasskeySlots = (): boolean => readPasskeySlots().length > 0
export const writePasskeySlots = (ownerId: string, slots: PasskeySlot[]): void => {
if (!isWalletOwnerId(ownerId) || savedKeyOwnerId() !== ownerId) {
if (!isWalletOwnerId(ownerId) || savedWalletMaterialOwnerId() !== ownerId) {
throw new Error('Passkey slots require the proven saved wallet owner.')
}
if (slots.some((slot) => slot.ownerId !== ownerId || slot.version !== PASSKEY_SLOT_VERSION)) {
@@ -156,7 +188,7 @@ export const writePasskeySlots = (ownerId: string, slots: PasskeySlot[]): void =
}
export const adoptLegacyPasskeySlots = (ownerId: string): number => {
if (!isWalletOwnerId(ownerId) || savedKeyOwnerId() !== ownerId) {
if (!isWalletOwnerId(ownerId) || savedWalletMaterialOwnerId() !== ownerId) {
throw new Error('Legacy passkey migration requires a proven owner.')
}
const stored = readStoredPasskeySlots()
@@ -183,7 +215,7 @@ const persistStoredPasskeySlots = (slots: StoredPasskeySlot[]): void => {
}
export const clearPasskeySlotsForOwner = (ownerId: string): void => {
if (!isWalletOwnerId(ownerId) || savedKeyOwnerId() !== ownerId) {
if (!isWalletOwnerId(ownerId) || savedWalletMaterialOwnerId() !== ownerId) {
throw new Error('Passkey teardown requires the proven saved wallet owner.')
}
persistStoredPasskeySlots(