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 // 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 // WebAuthn PRF output to an AES-GCM wrap key, and wrap/unwrap of canonical
// linking key under it. No WebAuthn, no storage - a PRF output is just 32 // wallet material under it. No WebAuthn or storage is involved, so malformed
// bytes, so everything here is unit-testable in plain Node. // 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 // 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 // even though the ceremony-side PRF salt is fixed (see passkeys.ts); the
@@ -9,10 +9,21 @@
// from the same PRF output. // from the same PRF output.
import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js' import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js'
import {sha256} from '@noble/hashes/sha2.js'
import type {PasskeyWrap} from './storage/passkeySlots' 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 ( export const derivePasskeyWrapKey = async (
prfOutput: Uint8Array, prfOutput: Uint8Array,
@@ -37,34 +48,43 @@ export const derivePasskeyWrapKey = async (
) )
} }
export const wrapLinkingKeyWithPrf = async ( export const wrapWalletMaterialWithPrf = async (
prfOutput: Uint8Array, prfOutput: Uint8Array,
linkingKey: Uint8Array, material: WalletMaterialV2,
): Promise<PasskeyWrap> => { ): Promise<PasskeyWrap> => {
const hkdfSalt = crypto.getRandomValues(new Uint8Array(16)) const hkdfSalt = crypto.getRandomValues(new Uint8Array(16))
const iv = crypto.getRandomValues(new Uint8Array(12)) const iv = crypto.getRandomValues(new Uint8Array(12))
const wrapKey = await derivePasskeyWrapKey(prfOutput, hkdfSalt) const wrapKey = await derivePasskeyWrapKey(prfOutput, hkdfSalt)
const serialized = serializeWalletMaterial(material)
if (parseWalletMaterial(serialized) === null) throw new InvalidPasskeyMaterialError()
const ciphertext = new Uint8Array( 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 { return {
hkdfSalt: bytesToHex(hkdfSalt), hkdfSalt: bytesToHex(hkdfSalt),
iv: bytesToHex(iv), 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 - // 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 // i.e. a different passkey than the one that created the slot
export const unwrapLinkingKeyWithPrf = async ( export const unwrapWalletMaterialWithPrf = async (
prfOutput: Uint8Array, prfOutput: Uint8Array,
wrap: PasskeyWrap, wrap: PasskeyWrap,
): Promise<Uint8Array> => { ): Promise<WalletMaterialV2> => {
const wrapKey = await derivePasskeyWrapKey(prfOutput, hexToBytes(wrap.hkdfSalt)) const wrapKey = await derivePasskeyWrapKey(prfOutput, hexToBytes(wrap.hkdfSalt))
const plaintext = await crypto.subtle.decrypt( const plaintext = await crypto.subtle.decrypt(
{name: 'AES-GCM', iv: new Uint8Array(hexToBytes(wrap.iv))}, {name: 'AES-GCM', iv: new Uint8Array(hexToBytes(wrap.iv))},
wrapKey, 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, removePasskey,
rewrapAllSlots, rewrapAllSlots,
unlockWithPasskey, unlockWithPasskey,
unwrapLinkingKeyWithPrf, unwrapWalletMaterialWithPrf,
wrapLinkingKeyWithPrf, wrapWalletMaterialWithPrf,
} from './passkeys' } from './passkeys'
import { import {
decryptRecord, decryptRecord,
decryptSavedLinkingKey, decryptSavedLinkingKey,
deriveWalletMaterial,
deriveBearerAesKey, deriveBearerAesKey,
ensureSavedKeyOwner, ensureSavedKeyOwner,
encryptRecord, encryptRecord,
linkingPubKeyHex, linkingPubKeyHex,
savedKeyOwnerId, savedKeyOwnerId,
saveLinkingKey, saveLinkingKey,
saveWalletMaterial,
} from './keys' } from './keys'
import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils' 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 OTHER_LINKING_KEY = new Uint8Array(32).fill(9)
const PRF_OUTPUT = new Uint8Array(32).fill(3) const PRF_OUTPUT = new Uint8Array(32).fill(3)
const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4) 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 => const toBytes = (source: BufferSource): Uint8Array =>
source instanceof ArrayBuffer source instanceof ArrayBuffer
@@ -134,32 +142,33 @@ const removeSavedOwnerMarker = (): void => {
beforeEach(async () => { beforeEach(async () => {
stubLocalStorage() stubLocalStorage()
await saveLinkingKey(LINKING_KEY) await saveLinkingKey(LINKING_KEY)
await saveWalletMaterial(MATERIAL)
}) })
describe('pure wrap crypto', () => { describe('pure wrap crypto', () => {
it('round-trips a linking key through a PRF-derived wrap', async () => { it('round-trips a linking key through a PRF-derived wrap', async () => {
const wrap = await wrapLinkingKeyWithPrf(PRF_OUTPUT, LINKING_KEY) const wrap = await wrapWalletMaterialWithPrf(PRF_OUTPUT, MATERIAL)
const unwrapped = await unwrapLinkingKeyWithPrf(PRF_OUTPUT, wrap) const unwrapped = await unwrapWalletMaterialWithPrf(PRF_OUTPUT, wrap)
expect(bytesToHex(unwrapped)).toBe(bytesToHex(LINKING_KEY)) expect(unwrapped).toEqual(MATERIAL)
}) })
it('rejects unwrap with a different PRF output', async () => { it('rejects unwrap with a different PRF output', async () => {
const wrap = await wrapLinkingKeyWithPrf(PRF_OUTPUT, LINKING_KEY) const wrap = await wrapWalletMaterialWithPrf(PRF_OUTPUT, MATERIAL)
await expect(unwrapLinkingKeyWithPrf(OTHER_PRF_OUTPUT, wrap)).rejects.toThrow() await expect(unwrapWalletMaterialWithPrf(OTHER_PRF_OUTPUT, wrap)).rejects.toThrow()
}) })
it('rejects unwrap with a tampered HKDF salt', async () => { 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( await expect(
unwrapLinkingKeyWithPrf(PRF_OUTPUT, {...wrap, hkdfSalt: 'ab'.repeat(16)}), unwrapWalletMaterialWithPrf(PRF_OUTPUT, {...wrap, hkdfSalt: 'ab'.repeat(16)}),
).rejects.toThrow() ).rejects.toThrow()
}) })
it('rejects unwrap with a tampered ciphertext', async () => { it('rejects unwrap with a tampered ciphertext', async () => {
const wrap = await wrapLinkingKeyWithPrf(PRF_OUTPUT, LINKING_KEY) const wrap = await wrapWalletMaterialWithPrf(PRF_OUTPUT, MATERIAL)
const flipped = `${wrap.wrappedKey.slice(0, -2)}${wrap.wrappedKey.endsWith('00') ? '01' : '00'}` const flipped = `${wrap.wrappedMaterial.slice(0, -2)}${wrap.wrappedMaterial.endsWith('00') ? '01' : '00'}`
await expect( await expect(
unwrapLinkingKeyWithPrf(PRF_OUTPUT, {...wrap, wrappedKey: flipped}), unwrapWalletMaterialWithPrf(PRF_OUTPUT, {...wrap, wrappedMaterial: flipped}),
).rejects.toThrow() ).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, removePasskey,
rewrapAllSlots, rewrapAllSlots,
unlockWithPasskey, unlockWithPasskey,
unwrapLinkingKeyWithPrf, unwrapWalletMaterialWithPrf,
wrapLinkingKeyWithPrf, wrapWalletMaterialWithPrf,
} from './passkeys' } from './passkeys'
import { import {
decryptRecord, decryptRecord,
decryptSavedLinkingKey, decryptSavedLinkingKey,
deriveBearerAesKey, deriveBearerAesKey,
deriveWalletMaterial,
ensureSavedKeyOwner, ensureSavedKeyOwner,
encryptRecord, encryptRecord,
linkingPubKeyHex, linkingPubKeyHex,
savedKeyOwnerId, savedKeyOwnerId,
saveLinkingKey, saveLinkingKey,
saveWalletMaterial,
} from './keys' } from './keys'
import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils' 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 OTHER_LINKING_KEY = new Uint8Array(32).fill(9)
const PRF_OUTPUT = new Uint8Array(32).fill(3) const PRF_OUTPUT = new Uint8Array(32).fill(3)
const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4) 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 => const toBytes = (source: BufferSource): Uint8Array =>
source instanceof ArrayBuffer source instanceof ArrayBuffer
@@ -134,6 +142,7 @@ const removeSavedOwnerMarker = (): void => {
beforeEach(async () => { beforeEach(async () => {
stubLocalStorage() stubLocalStorage()
await saveLinkingKey(LINKING_KEY) await saveLinkingKey(LINKING_KEY)
await saveWalletMaterial(MATERIAL)
}) })
describe('multiple passkeys', () => { describe('multiple passkeys', () => {
@@ -151,7 +160,7 @@ describe('multiple passkeys', () => {
expect(readPasskeySlots()).toHaveLength(2) expect(readPasskeySlots()).toHaveLength(2)
// independent wrap keys: same plaintext, different salts and ciphertexts // independent wrap keys: same plaintext, different salts and ciphertexts
expect(laptopSlot.hkdfSalt).not.toBe(phoneSlot.hkdfSalt) 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: laptop}))).toBe(bytesToHex(LINKING_KEY))
expect(bytesToHex(await unlockWithPasskey({credentials: phone}))).toBe(bytesToHex(LINKING_KEY)) expect(bytesToHex(await unlockWithPasskey({credentials: phone}))).toBe(bytesToHex(LINKING_KEY))
+14 -3
View File
@@ -21,18 +21,20 @@ import {
removePasskey, removePasskey,
rewrapAllSlots, rewrapAllSlots,
unlockWithPasskey, unlockWithPasskey,
unwrapLinkingKeyWithPrf, unwrapWalletMaterialWithPrf,
wrapLinkingKeyWithPrf, wrapWalletMaterialWithPrf,
} from './passkeys' } from './passkeys'
import { import {
decryptRecord, decryptRecord,
decryptSavedLinkingKey, decryptSavedLinkingKey,
deriveBearerAesKey, deriveBearerAesKey,
deriveWalletMaterial,
ensureSavedKeyOwner, ensureSavedKeyOwner,
encryptRecord, encryptRecord,
linkingPubKeyHex, linkingPubKeyHex,
savedKeyOwnerId, savedKeyOwnerId,
saveLinkingKey, saveLinkingKey,
saveWalletMaterial,
} from './keys' } from './keys'
import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils' 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 OTHER_LINKING_KEY = new Uint8Array(32).fill(9)
const PRF_OUTPUT = new Uint8Array(32).fill(3) const PRF_OUTPUT = new Uint8Array(32).fill(3)
const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4) 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 => const toBytes = (source: BufferSource): Uint8Array =>
source instanceof ArrayBuffer source instanceof ArrayBuffer
@@ -134,6 +142,7 @@ const removeSavedOwnerMarker = (): void => {
beforeEach(async () => { beforeEach(async () => {
stubLocalStorage() stubLocalStorage()
await saveLinkingKey(LINKING_KEY) await saveLinkingKey(LINKING_KEY)
await saveWalletMaterial(MATERIAL)
}) })
describe('slot ownership', () => { describe('slot ownership', () => {
@@ -167,7 +176,8 @@ describe('slot ownership', () => {
credentialId: '33'.repeat(16), credentialId: '33'.repeat(16),
hkdfSalt: current.hkdfSalt, hkdfSalt: current.hkdfSalt,
iv: current.iv, iv: current.iv,
wrappedKey: current.wrappedKey, materialHash: current.materialHash,
wrappedMaterial: current.wrappedMaterial,
createdAt: current.createdAt, createdAt: current.createdAt,
} }
writeRawSlots([foreign, malformed, unowned]) writeRawSlots([foreign, malformed, unowned])
@@ -210,6 +220,7 @@ describe('slot ownership', () => {
// When a different wallet is installed with its canonical owner // When a different wallet is installed with its canonical owner
await saveLinkingKey(OTHER_LINKING_KEY) await saveLinkingKey(OTHER_LINKING_KEY)
await saveWalletMaterial({...MATERIAL, linkingKeyHex: bytesToHex(OTHER_LINKING_KEY)})
// Then the residue stays unowned and unavailable to the new wallet // Then the residue stays unowned and unavailable to the new wallet
expect(readPasskeySlots()).toEqual([]) expect(readPasskeySlots()).toEqual([])
+15 -4
View File
@@ -21,18 +21,20 @@ import {
removePasskey, removePasskey,
rewrapAllSlots, rewrapAllSlots,
unlockWithPasskey, unlockWithPasskey,
unwrapLinkingKeyWithPrf, unwrapWalletMaterialWithPrf,
wrapLinkingKeyWithPrf, wrapWalletMaterialWithPrf,
} from './passkeys' } from './passkeys'
import { import {
decryptRecord, decryptRecord,
decryptSavedLinkingKey, decryptSavedLinkingKey,
deriveBearerAesKey, deriveBearerAesKey,
deriveWalletMaterial,
ensureSavedKeyOwner, ensureSavedKeyOwner,
encryptRecord, encryptRecord,
linkingPubKeyHex, linkingPubKeyHex,
savedKeyOwnerId, savedKeyOwnerId,
saveLinkingKey, saveLinkingKey,
saveWalletMaterial,
} from './keys' } from './keys'
import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils' 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 OTHER_LINKING_KEY = new Uint8Array(32).fill(9)
const PRF_OUTPUT = new Uint8Array(32).fill(3) const PRF_OUTPUT = new Uint8Array(32).fill(3)
const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4) 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 => const toBytes = (source: BufferSource): Uint8Array =>
source instanceof ArrayBuffer source instanceof ArrayBuffer
@@ -134,6 +143,7 @@ const removeSavedOwnerMarker = (): void => {
beforeEach(async () => { beforeEach(async () => {
stubLocalStorage() stubLocalStorage()
await saveLinkingKey(LINKING_KEY) await saveLinkingKey(LINKING_KEY)
await saveWalletMaterial(MATERIAL)
}) })
describe('slot ownership (continued)', () => { describe('slot ownership (continued)', () => {
@@ -145,7 +155,7 @@ describe('slot ownership (continued)', () => {
const prfOutput = await getPasskeyPrfOutput(slot.credentialId, { const prfOutput = await getPasskeyPrfOutput(slot.credentialId, {
credentials: auth, credentials: auth,
}) })
const foreignWrap = await wrapLinkingKeyWithPrf(prfOutput, OTHER_LINKING_KEY) const foreignWrap = await wrapWalletMaterialWithPrf(prfOutput, OTHER_MATERIAL)
writeRawSlots([{...slot, ...foreignWrap}]) writeRawSlots([{...slot, ...foreignWrap}])
// When the authenticator successfully unwraps that foreign key // When the authenticator successfully unwraps that foreign key
@@ -164,6 +174,7 @@ describe('slot ownership (continued)', () => {
get: async (options) => { get: async (options) => {
const credential = await auth.get(options) const credential = await auth.get(options)
await saveLinkingKey(OTHER_LINKING_KEY) await saveLinkingKey(OTHER_LINKING_KEY)
await saveWalletMaterial(OTHER_MATERIAL)
return credential return credential
}, },
} }
@@ -220,7 +231,7 @@ describe('slot ownership (continued)', () => {
}) })
// When the current wallet rewraps its slots // 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 // Then the foreign slot did not require output and remains byte-identical
expect(readRawSlots()).toContainEqual(foreign) expect(readRawSlots()).toContainEqual(foreign)
+19 -9
View File
@@ -21,18 +21,21 @@ import {
removePasskey, removePasskey,
rewrapAllSlots, rewrapAllSlots,
unlockWithPasskey, unlockWithPasskey,
unwrapLinkingKeyWithPrf, unwrapWalletMaterialWithPrf,
wrapLinkingKeyWithPrf, wrapWalletMaterialWithPrf,
} from './passkeys' } from './passkeys'
import { import {
decryptRecord, decryptRecord,
decryptSavedLinkingKey, decryptSavedWalletMaterial,
deriveBearerAesKey, deriveBearerAesKey,
deriveWalletMaterial,
ensureSavedKeyOwner, ensureSavedKeyOwner,
encryptRecord, encryptRecord,
linkingPubKeyHex, linkingPubKeyHex,
savedKeyOwnerId, savedKeyOwnerId,
saveLinkingKey, saveLinkingKey,
saveWalletMaterial,
walletMaterialLinkingKey,
} from './keys' } from './keys'
import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils' 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 OTHER_LINKING_KEY = new Uint8Array(32).fill(9)
const PRF_OUTPUT = new Uint8Array(32).fill(3) const PRF_OUTPUT = new Uint8Array(32).fill(3)
const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4) 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 => const toBytes = (source: BufferSource): Uint8Array =>
source instanceof ArrayBuffer source instanceof ArrayBuffer
@@ -134,6 +143,7 @@ const removeSavedOwnerMarker = (): void => {
beforeEach(async () => { beforeEach(async () => {
stubLocalStorage() stubLocalStorage()
await saveLinkingKey(LINKING_KEY) await saveLinkingKey(LINKING_KEY)
await saveWalletMaterial(MATERIAL)
}) })
describe('registration and unlock', () => { describe('registration and unlock', () => {
@@ -160,18 +170,18 @@ describe('registration and unlock', () => {
}) })
it('yields the same key material unlock(password) yields', async () => { it('yields the same key material unlock(password) yields', async () => {
const linkingKey = crypto.getRandomValues(new Uint8Array(32)) await saveWalletMaterial(MATERIAL, 'correct horse')
await saveLinkingKey(linkingKey, 'correct horse')
const auth = new FakeAuthenticator() 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}) 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 // and the practical consequence: a bearer record encrypted after a
// password unlock decrypts after a passkey unlock // 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 record = await encryptRecord(passwordAes, {note: 'still readable'})
const passkeyAes = await deriveBearerAesKey(viaPasskey) const passkeyAes = await deriveBearerAesKey(viaPasskey)
await expect(decryptRecord(passkeyAes, record)).resolves.toEqual({ await expect(decryptRecord(passkeyAes, record)).resolves.toEqual({
+14 -5
View File
@@ -21,18 +21,20 @@ import {
removePasskey, removePasskey,
rewrapAllSlots, rewrapAllSlots,
unlockWithPasskey, unlockWithPasskey,
unwrapLinkingKeyWithPrf, unwrapWalletMaterialWithPrf,
wrapLinkingKeyWithPrf, wrapWalletMaterialWithPrf,
} from './passkeys' } from './passkeys'
import { import {
decryptRecord, decryptRecord,
decryptSavedLinkingKey, decryptSavedLinkingKey,
deriveBearerAesKey, deriveBearerAesKey,
deriveWalletMaterial,
ensureSavedKeyOwner, ensureSavedKeyOwner,
encryptRecord, encryptRecord,
linkingPubKeyHex, linkingPubKeyHex,
savedKeyOwnerId, savedKeyOwnerId,
saveLinkingKey, saveLinkingKey,
saveWalletMaterial,
} from './keys' } from './keys'
import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils' 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 OTHER_LINKING_KEY = new Uint8Array(32).fill(9)
const PRF_OUTPUT = new Uint8Array(32).fill(3) const PRF_OUTPUT = new Uint8Array(32).fill(3)
const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4) 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 => const toBytes = (source: BufferSource): Uint8Array =>
source instanceof ArrayBuffer source instanceof ArrayBuffer
@@ -134,6 +142,7 @@ const removeSavedOwnerMarker = (): void => {
beforeEach(async () => { beforeEach(async () => {
stubLocalStorage() stubLocalStorage()
await saveLinkingKey(LINKING_KEY) await saveLinkingKey(LINKING_KEY)
await saveWalletMaterial(MATERIAL)
}) })
describe('rewrap for the current owner', () => { 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)) expect(bytesToHex(await unlockWithPasskey({credentials: laptop}))).toBe(bytesToHex(LINKING_KEY))
// full coverage refreshes both wraps around the same proven owner 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: laptop}))).toBe(bytesToHex(LINKING_KEY))
expect(bytesToHex(await unlockWithPasskey({credentials: phone}))).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 // credential ids and labels survive the re-wrap
expect( expect(
readPasskeySlots() readPasskeySlots()
+11 -2
View File
@@ -21,18 +21,20 @@ import {
removePasskey, removePasskey,
rewrapAllSlots, rewrapAllSlots,
unlockWithPasskey, unlockWithPasskey,
unwrapLinkingKeyWithPrf, unwrapWalletMaterialWithPrf,
wrapLinkingKeyWithPrf, wrapWalletMaterialWithPrf,
} from './passkeys' } from './passkeys'
import { import {
decryptRecord, decryptRecord,
decryptSavedLinkingKey, decryptSavedLinkingKey,
deriveBearerAesKey, deriveBearerAesKey,
deriveWalletMaterial,
ensureSavedKeyOwner, ensureSavedKeyOwner,
encryptRecord, encryptRecord,
linkingPubKeyHex, linkingPubKeyHex,
savedKeyOwnerId, savedKeyOwnerId,
saveLinkingKey, saveLinkingKey,
saveWalletMaterial,
} from './keys' } from './keys'
import {parseJsonArray, parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils' 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 OTHER_LINKING_KEY = new Uint8Array(32).fill(9)
const PRF_OUTPUT = new Uint8Array(32).fill(3) const PRF_OUTPUT = new Uint8Array(32).fill(3)
const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4) 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 => const toBytes = (source: BufferSource): Uint8Array =>
source instanceof ArrayBuffer source instanceof ArrayBuffer
@@ -134,6 +142,7 @@ const removeSavedOwnerMarker = (): void => {
beforeEach(async () => { beforeEach(async () => {
stubLocalStorage() stubLocalStorage()
await saveLinkingKey(LINKING_KEY) await saveLinkingKey(LINKING_KEY)
await saveWalletMaterial(MATERIAL)
}) })
describe('slot storage hygiene', () => { describe('slot storage hygiene', () => {
+11 -2
View File
@@ -21,18 +21,20 @@ import {
removePasskey, removePasskey,
rewrapAllSlots, rewrapAllSlots,
unlockWithPasskey, unlockWithPasskey,
unwrapLinkingKeyWithPrf, unwrapWalletMaterialWithPrf,
wrapLinkingKeyWithPrf, wrapWalletMaterialWithPrf,
} from './passkeys' } from './passkeys'
import { import {
decryptRecord, decryptRecord,
decryptSavedLinkingKey, decryptSavedLinkingKey,
deriveBearerAesKey, deriveBearerAesKey,
deriveWalletMaterial,
ensureSavedKeyOwner, ensureSavedKeyOwner,
encryptRecord, encryptRecord,
linkingPubKeyHex, linkingPubKeyHex,
savedKeyOwnerId, savedKeyOwnerId,
saveLinkingKey, saveLinkingKey,
saveWalletMaterial,
} from './keys' } from './keys'
import {parseJsonObject, parseJsonObjectArray, stubLocalStorage} from './test-utils' 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 OTHER_LINKING_KEY = new Uint8Array(32).fill(9)
const PRF_OUTPUT = new Uint8Array(32).fill(3) const PRF_OUTPUT = new Uint8Array(32).fill(3)
const OTHER_PRF_OUTPUT = new Uint8Array(32).fill(4) 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 => const toBytes = (source: BufferSource): Uint8Array =>
source instanceof ArrayBuffer source instanceof ArrayBuffer
@@ -134,6 +142,7 @@ const removeSavedOwnerMarker = (): void => {
beforeEach(async () => { beforeEach(async () => {
stubLocalStorage() stubLocalStorage()
await saveLinkingKey(LINKING_KEY) await saveLinkingKey(LINKING_KEY)
await saveWalletMaterial(MATERIAL)
}) })
describe('passkeySupported', () => { describe('passkeySupported', () => {
+1
View File
@@ -1,4 +1,5 @@
import './passkeys.crypto.cases' import './passkeys.crypto.cases'
import './passkeys.material.cases'
import './passkeys.registration.cases' import './passkeys.registration.cases'
import './passkeys.ownership-a.cases' import './passkeys.ownership-a.cases'
import './passkeys.ownership-b.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 // Passkey (WebAuthn PRF) unlock is an alternative wrap of the complete
// key the password path protects (keys.ts) - never a second key, so notes // canonical wallet material protected by the password path. Slot ownership
// encrypted under a password unlock stay readable after a passkey unlock // remains derived only from the linking key inside that material.
// and vice versa (both yield the identical linking key, from which the
// bearer AES key derives).
// //
// No master-key indirection is introduced: unlike Bitwarden, this wallet // No master-key indirection is introduced: unlike Bitwarden, this wallet
// persists exactly one secret - the seed-derived linking key - and the // persists one authenticated material envelope. The password wrap and each
// bearer-encryption key is derived from it (not wrapped by it), so a random // passkey slot are independent wraps of those same canonical bytes.
// 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.
// //
// The module is split into a pure-crypto core (passkeyWrap.ts: HKDF from a // 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) // 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 {sha256} from '@noble/hashes/sha2.js'
import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.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 {withStorageLock} from './storageLock'
import type {PasskeySlot} from './storage/passkeySlots' import type {PasskeySlot} from './storage/passkeySlots'
import { import {
PASSKEY_SLOTS_STORAGE_KEY, PASSKEY_SLOTS_STORAGE_KEY,
PASSKEY_SLOT_VERSION, PASSKEY_SLOT_VERSION,
passkeySlotsEqual,
readPasskeySlots, readPasskeySlots,
requireCurrentPasskeyMaterialOwner,
writePasskeySlots, writePasskeySlots,
} from './storage/passkeySlots' } 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 type {PasskeySlot, PasskeyWrap} from './storage/passkeySlots'
export {readPasskeySlots, hasPasskeySlots} from './storage/passkeySlots' export {readPasskeySlots, hasPasskeySlots} from './storage/passkeySlots'
export {migrateLegacyPasskeySlots} from './passkeyOwnership' 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 // 32 bytes, fixed - the authenticator requires exactly 32
const PASSKEY_PRF_SALT = sha256(utf8ToBytes('sattle-passkey-prf-v1')) 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 // one get() ceremony against a single known credential, returning its fresh
// PRF output - the building block for re-wrap ceremonies during linking-key // PRF output - the building block for complete-material re-wrap ceremonies
// rotation
export const getPasskeyPrfOutput = async ( export const getPasskeyPrfOutput = async (
credentialId: string, credentialId: string,
options: {credentials?: PasskeyCredentials} = {}, options: {credentials?: PasskeyCredentials} = {},
@@ -149,20 +152,28 @@ export type RegisterPasskeyOptions = {
authenticatorAttachment?: AuthenticatorAttachment authenticatorAttachment?: AuthenticatorAttachment
} }
// Registers a new passkey and persists a slot wrapping the given linking // Registers a new passkey and persists a slot wrapping the complete material.
// key. The caller supplies the linking key from the currently unlocked // The Uint8Array input exists only until Task 5 updates the UI caller; it can
// wallet; the ceremony is navigator.credentials.create with the PRF // 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 // extension evaluated on creation. Some authenticators only report
// prf.enabled during create and evaluate the secret on the first get - // prf.enabled during create and evaluate the secret on the first get -
// those get a follow-up get() against the fresh credential. // those get a follow-up get() against the fresh credential.
export const registerPasskey = async ( export const registerPasskey = async (
linkingKey: Uint8Array, material: WalletMaterialV2 | Uint8Array,
options: RegisterPasskeyOptions = {}, options: RegisterPasskeyOptions = {},
): Promise<PasskeySlot> => { ): Promise<PasskeySlot> => {
const ownerId = savedKeyOwnerId() let walletMaterial: WalletMaterialV2
if (ownerId === null || linkingPubKeyHex(linkingKey) !== ownerId) { if (material instanceof Uint8Array) {
throw new Error('Passkey registration requires the proven saved wallet owner.') 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 credentials = options.credentials ?? defaultCredentials()
const credential = await credentials.create({ const credential = await credentials.create({
publicKey: { publicKey: {
@@ -197,7 +208,7 @@ export const registerPasskey = async (
} }
prfOutput = await getPasskeyPrfOutput(credentialId, {credentials}) prfOutput = await getPasskeyPrfOutput(credentialId, {credentials})
} }
const wrap = await wrapLinkingKeyWithPrf(prfOutput, linkingKey) const wrap = await wrapWalletMaterialWithPrf(prfOutput, walletMaterial)
const slot: PasskeySlot = { const slot: PasskeySlot = {
credentialId, credentialId,
...wrap, ...wrap,
@@ -214,13 +225,13 @@ export const registerPasskey = async (
return slot return slot
} }
// Unlocks via any registered passkey: one get() ceremony offering every // Unlocks complete material via one get() ceremony offering every current
// slot's credential, then unwrap. Yields the exact same linking key // slot. The selected slot is re-read after the ceremony before any plaintext
// unlock(password) yields - the caller activates the wallet with it. // reaches the caller, rejecting owner or storage changes during user presence.
export const unlockWithPasskey = async ( export const unlockWalletMaterialWithPasskey = async (
options: {credentials?: PasskeyCredentials} = {}, options: {credentials?: PasskeyCredentials} = {},
): Promise<Uint8Array> => { ): Promise<WalletMaterialV2> => {
const ownerId = savedKeyOwnerId() const ownerId = savedWalletMaterialOwnerId()
const slots = readPasskeySlots() const slots = readPasskeySlots()
if (ownerId === null || slots.length === 0) { if (ownerId === null || slots.length === 0) {
throw new Error('No passkeys registered on this device.') throw new Error('No passkeys registered on this device.')
@@ -247,18 +258,32 @@ export const unlockWithPasskey = async (
if (!prfOutput) { if (!prfOutput) {
throw new Error('This passkey did not return a PRF secret - it cannot unlock this wallet.') throw new Error('This passkey did not return a PRF secret - it cannot unlock this wallet.')
} }
const linkingKey = await unwrapLinkingKeyWithPrf(prfOutput, slot) if (savedWalletMaterialOwnerId() !== ownerId) {
if (savedKeyOwnerId() !== ownerId || linkingPubKeyHex(linkingKey) !== ownerId) {
throw new Error('This passkey belongs to a different wallet.') 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 // Removes the slot only: WebAuthn has no API to delete the credential from
// the authenticator - an orphaned passkey simply finds nothing to unwrap. // the authenticator - an orphaned passkey simply finds nothing to unwrap.
// Returns whether a slot was actually removed. // Returns whether a slot was actually removed.
export const removePasskey = async (credentialId: string): Promise<boolean> => { export const removePasskey = async (credentialId: string): Promise<boolean> => {
const ownerId = savedKeyOwnerId() const ownerId = savedWalletMaterialOwnerId()
if (ownerId === null) return false if (ownerId === null) return false
let removed = false let removed = false
await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => { await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => {
@@ -270,23 +295,19 @@ export const removePasskey = async (credentialId: string): Promise<boolean> => {
return removed 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 // Each slot's wrap secret lives only inside its authenticator, so the caller
// must supply a fresh PRF output per credential (one getPasskeyPrfOutput // must supply a fresh PRF output per credential (one getPasskeyPrfOutput
// ceremony each). All-or-nothing: a slot without a PRF output aborts the // ceremony each). All-or-nothing: a slot without a PRF output aborts the
// whole refresh before anything is written. // whole refresh before anything is written.
// //
// A password change does NOT need this: the password wrap (keys.ts) and the // A password change does not need this because password and passkey slots are
// passkey slots wrap the same linking key independently, so re-encrypting // independent wraps of the same canonical material.
// the stored key under a new password leaves every slot valid.
export const rewrapAllSlots = async ( export const rewrapAllSlots = async (
linkingKey: Uint8Array, material: WalletMaterialV2,
prfOutputs: ReadonlyMap<string, Uint8Array>, prfOutputs: ReadonlyMap<string, Uint8Array>,
): Promise<void> => { ): Promise<void> => {
const ownerId = savedKeyOwnerId() const ownerId = requireCurrentPasskeyMaterialOwner(material)
if (ownerId === null || linkingPubKeyHex(linkingKey) !== ownerId) {
throw new Error('Passkey re-wrap requires the proven saved wallet owner.')
}
await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, async () => { await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, async () => {
const slots = readPasskeySlots() const slots = readPasskeySlots()
const rewrapped: PasskeySlot[] = [] const rewrapped: PasskeySlot[] = []
@@ -297,7 +318,7 @@ export const rewrapAllSlots = async (
} }
rewrapped.push({ rewrapped.push({
...slot, ...slot,
...(await wrapLinkingKeyWithPrf(prfOutput, linkingKey)), ...(await wrapWalletMaterialWithPrf(prfOutput, material)),
}) })
} }
writePasskeySlots(ownerId, rewrapped) writePasskeySlots(ownerId, rewrapped)
+24 -9
View File
@@ -1,7 +1,14 @@
import {beforeEach, describe, expect, it} from 'vitest' import {beforeEach, describe, expect, it} from 'vitest'
import {bytesToHex} from '@noble/hashes/utils.js' 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 {migrateLegacyPasskeySlots, readPasskeySlots} from './passkeys'
import type {PasskeySlot} from './passkeys' import type {PasskeySlot} from './passkeys'
import {parseJsonObjectArray, stubLocalStorage} from './test-utils' 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 OTHER_LINKING_KEY = new Uint8Array(32).fill(9)
const KEY_STORAGE = 'sattle_linking_key' const KEY_STORAGE = 'sattle_linking_key'
const SLOT_STORAGE = 'sattle_passkey_slots' 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 = { const SLOT_BASE = {
credentialId: '11'.repeat(16), credentialId: '11'.repeat(16),
hkdfSalt: '22'.repeat(16), hkdfSalt: '22'.repeat(16),
iv: '33'.repeat(12), iv: '33'.repeat(12),
wrappedKey: '44'.repeat(48), materialHash: '44'.repeat(32),
wrappedMaterial: '55'.repeat(128),
createdAt: 1, createdAt: 1,
} as const } as const
@@ -24,17 +38,18 @@ const writeRawSlots = (slots: readonly Record<string, unknown>[]): void => {
} }
describe('passkey-slot schema version', () => { describe('passkey-slot schema version', () => {
beforeEach(() => { beforeEach(async () => {
stubLocalStorage() 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 // Given a current saved-key marker and passkey record
const ownerId = linkingPubKeyHex(LINKING_KEY) const ownerId = linkingPubKeyHex(LINKING_KEY)
localStorage.setItem( localStorage.setItem(
KEY_STORAGE, KEY_STORAGE,
JSON.stringify({enc: false, value: bytesToHex(LINKING_KEY), ownerId, version: 1}), 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]) writeRawSlots([slot])
// When current-owner slots are read // When current-owner slots are read
@@ -61,12 +76,12 @@ describe('passkey-slot schema version', () => {
// Then both compatible legacy forms become current records // Then both compatible legacy forms become current records
expect(savedKeyOwnerId()).toBe(ownerId) expect(savedKeyOwnerId()).toBe(ownerId)
expect(parseJsonObjectArray(localStorage.getItem(SLOT_STORAGE) ?? '[]')).toEqual([ expect(parseJsonObjectArray(localStorage.getItem(SLOT_STORAGE) ?? '[]')).toEqual([
{...SLOT_BASE, ownerId, version: 1}, {...SLOT_BASE, ownerId, version: 2},
{...SLOT_BASE, credentialId: '55'.repeat(16), ownerId, version: 1}, {...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 // Given an otherwise valid slot carrying unrecognized metadata
const ownerId = linkingPubKeyHex(LINKING_KEY) const ownerId = linkingPubKeyHex(LINKING_KEY)
localStorage.setItem( localStorage.setItem(
@@ -87,7 +102,7 @@ describe('passkey-slot schema version', () => {
KEY_STORAGE, KEY_STORAGE,
JSON.stringify({enc: false, value: bytesToHex(LINKING_KEY), ownerId, version: 1}), 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 // When the current wallet reads passkeys
// Then the foreign credential is unavailable // 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 // Passkey-slot persistence: every v2 slot is bound to the canonical owner
// of the saved linking key. localStorage remains hostile input, so owner // of the saved wallet material. localStorage remains hostile input, so owner
// markers are parsed strictly and reads expose only slots belonging to the // markers are parsed strictly and reads expose only slots belonging to the
// currently proven saved owner. Mutations preserve every other record. // currently proven saved owner. Mutations preserve every other record.
import {savedKeyOwnerId} from '../keys'
import {isJsonObject} from '../jsonParsing' import {isJsonObject} from '../jsonParsing'
import type {WalletMaterialV2} from './storedSecret'
import {serializeWalletMaterial, walletMaterialOwnerId} from './storedSecret'
import {getPlainWalletMaterial, savedWalletMaterialOwnerId} from '../walletMaterialStorage'
import {isWalletOwnerId} from './walletOwner' import {isWalletOwnerId} from './walletOwner'
export type PasskeyWrap = { export type PasskeyWrap = {
readonly hkdfSalt: string readonly hkdfSalt: string
readonly iv: 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 & { export type PasskeySlot = PasskeyWrap & {
readonly credentialId: string readonly credentialId: string
@@ -23,6 +26,30 @@ export type PasskeySlot = PasskeyWrap & {
readonly version: typeof PASSKEY_SLOT_VERSION 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 & { type StoredPasskeySlot = PasskeyWrap & {
readonly credentialId: string readonly credentialId: string
readonly createdAt: number readonly createdAt: number
@@ -43,7 +70,8 @@ const SLOT_KEYS: readonly string[] = [
'credentialId', 'credentialId',
'hkdfSalt', 'hkdfSalt',
'iv', 'iv',
'wrappedKey', 'materialHash',
'wrappedMaterial',
'createdAt', 'createdAt',
'name', 'name',
'ownerId', 'ownerId',
@@ -61,10 +89,12 @@ const parseStoredPasskeySlot = (slot: unknown): ParsedPasskeySlot | null => {
/^[0-9a-f]{32}$/i.test(slot.hkdfSalt) && /^[0-9a-f]{32}$/i.test(slot.hkdfSalt) &&
typeof slot.iv === 'string' && typeof slot.iv === 'string' &&
/^[0-9a-f]{24}$/i.test(slot.iv) && /^[0-9a-f]{24}$/i.test(slot.iv) &&
typeof slot.wrappedKey === 'string' && typeof slot.materialHash === 'string' &&
slot.wrappedKey.length > 0 && /^[0-9a-f]{64}$/.test(slot.materialHash) &&
slot.wrappedKey.length % 2 === 0 && typeof slot.wrappedMaterial === 'string' &&
/^[0-9a-f]+$/i.test(slot.wrappedKey) && slot.wrappedMaterial.length > 0 &&
slot.wrappedMaterial.length % 2 === 0 &&
/^[0-9a-f]+$/.test(slot.wrappedMaterial) &&
typeof slot.createdAt === 'number' && typeof slot.createdAt === 'number' &&
(slot.name === undefined || typeof slot.name === 'string') && (slot.name === undefined || typeof slot.name === 'string') &&
Object.keys(slot).every((key) => SLOT_KEYS.includes(key)) Object.keys(slot).every((key) => SLOT_KEYS.includes(key))
@@ -73,7 +103,8 @@ const parseStoredPasskeySlot = (slot: unknown): ParsedPasskeySlot | null => {
credentialId: slot.credentialId, credentialId: slot.credentialId,
hkdfSalt: slot.hkdfSalt, hkdfSalt: slot.hkdfSalt,
iv: slot.iv, iv: slot.iv,
wrappedKey: slot.wrappedKey, materialHash: slot.materialHash,
wrappedMaterial: slot.wrappedMaterial,
createdAt: slot.createdAt, createdAt: slot.createdAt,
...(slot.name !== undefined ? {name: slot.name} : {}), ...(slot.name !== undefined ? {name: slot.name} : {}),
} }
@@ -124,7 +155,8 @@ const asOwnedSlot = (stored: ParsedPasskeySlot, ownerId: string): PasskeySlot |
credentialId: record.credentialId, credentialId: record.credentialId,
hkdfSalt: record.hkdfSalt, hkdfSalt: record.hkdfSalt,
iv: record.iv, iv: record.iv,
wrappedKey: record.wrappedKey, materialHash: record.materialHash,
wrappedMaterial: record.wrappedMaterial,
createdAt: record.createdAt, createdAt: record.createdAt,
...(record.name !== undefined ? {name: record.name} : {}), ...(record.name !== undefined ? {name: record.name} : {}),
ownerId, ownerId,
@@ -133,7 +165,7 @@ const asOwnedSlot = (stored: ParsedPasskeySlot, ownerId: string): PasskeySlot |
} }
export const readPasskeySlots = (): PasskeySlot[] => { export const readPasskeySlots = (): PasskeySlot[] => {
const ownerId = savedKeyOwnerId() const ownerId = savedWalletMaterialOwnerId()
if (ownerId === null) return [] if (ownerId === null) return []
return readStoredPasskeySlots() return readStoredPasskeySlots()
.map((slot) => asOwnedSlot(slot, ownerId)) .map((slot) => asOwnedSlot(slot, ownerId))
@@ -143,7 +175,7 @@ export const readPasskeySlots = (): PasskeySlot[] => {
export const hasPasskeySlots = (): boolean => readPasskeySlots().length > 0 export const hasPasskeySlots = (): boolean => readPasskeySlots().length > 0
export const writePasskeySlots = (ownerId: string, slots: PasskeySlot[]): void => { 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.') throw new Error('Passkey slots require the proven saved wallet owner.')
} }
if (slots.some((slot) => slot.ownerId !== ownerId || slot.version !== PASSKEY_SLOT_VERSION)) { 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 => { 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.') throw new Error('Legacy passkey migration requires a proven owner.')
} }
const stored = readStoredPasskeySlots() const stored = readStoredPasskeySlots()
@@ -183,7 +215,7 @@ const persistStoredPasskeySlots = (slots: StoredPasskeySlot[]): void => {
} }
export const clearPasskeySlotsForOwner = (ownerId: string): 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.') throw new Error('Passkey teardown requires the proven saved wallet owner.')
} }
persistStoredPasskeySlots( persistStoredPasskeySlots(