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
|
// 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,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()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
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))
|
||||||
|
|||||||
@@ -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([])
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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,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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user