mirror of
https://github.com/tompro/sattle.git
synced 2026-10-26 01:49:58 +00:00
feat(passkeys): wrap complete wallet material
This commit is contained in:
@@ -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,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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
|
||||
@@ -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([])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user