mirror of
https://github.com/tompro/sattle.git
synced 2026-10-26 01:49:58 +00:00
fix(passkeys): bind slots to wallet material
This commit is contained in:
@@ -0,0 +1,202 @@
|
|||||||
|
import {sha256} from '@noble/hashes/sha2.js'
|
||||||
|
import {bytesToHex, utf8ToBytes} from '@noble/hashes/utils.js'
|
||||||
|
import {beforeEach, describe, expect, it} from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
decryptSavedWalletMaterial,
|
||||||
|
deriveWalletMaterial,
|
||||||
|
encryptSecretParts,
|
||||||
|
ensureSavedWalletMaterialOwner,
|
||||||
|
getPlainWalletMaterial,
|
||||||
|
isValidStoredWalletMaterial,
|
||||||
|
linkingPubKeyHex,
|
||||||
|
restoreWalletMaterialStored,
|
||||||
|
saveWalletMaterial,
|
||||||
|
serializeWalletMaterial,
|
||||||
|
savedWalletMaterialOwnerId,
|
||||||
|
walletMaterialLinkingKey,
|
||||||
|
WalletMaterialProofMismatchError,
|
||||||
|
} from './keys'
|
||||||
|
import {walletMaterialOwnerId} from './storage/storedSecret'
|
||||||
|
import {parseJsonObject, 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_STORAGE_KEY = 'sattle_wallet_material_v2'
|
||||||
|
const PASSWORD = 'correct horse battery staple'
|
||||||
|
|
||||||
|
const materialHash = (serialized: string): string =>
|
||||||
|
bytesToHex(sha256(utf8ToBytes(serialized)))
|
||||||
|
|
||||||
|
const readRawMaterialRecord = (): Record<string, unknown> => {
|
||||||
|
const raw = localStorage.getItem(MATERIAL_STORAGE_KEY)
|
||||||
|
if (raw === null) throw new Error('expected a saved wallet-material record')
|
||||||
|
return parseJsonObject(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
stubLocalStorage()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('baseline: wallet material identity', () => {
|
||||||
|
it('derives identity only from the linking key when the cash root changes', () => {
|
||||||
|
const material = deriveWalletMaterial(MNEMONIC)
|
||||||
|
const other = deriveWalletMaterial(OTHER_MNEMONIC)
|
||||||
|
const alteredRoot = {...material, cashRootHex: other.cashRootHex}
|
||||||
|
|
||||||
|
expect(walletMaterialOwnerId(alteredRoot)).toBe(walletMaterialOwnerId(material))
|
||||||
|
expect(serializeWalletMaterial(alteredRoot)).not.toBe(serializeWalletMaterial(material))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('saved wallet material commitment', () => {
|
||||||
|
it('stores the canonical material commitment on plaintext and encrypted saves', async () => {
|
||||||
|
const material = deriveWalletMaterial(MNEMONIC)
|
||||||
|
const expectedHash = materialHash(serializeWalletMaterial(material))
|
||||||
|
|
||||||
|
await saveWalletMaterial(material)
|
||||||
|
const plaintext = readRawMaterialRecord()
|
||||||
|
await saveWalletMaterial(material, PASSWORD)
|
||||||
|
const encrypted = readRawMaterialRecord()
|
||||||
|
|
||||||
|
expect(plaintext.materialHash).toBe(expectedHash)
|
||||||
|
expect(encrypted.materialHash).toBe(expectedHash)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects authenticated encrypted material when its commitment differs', async () => {
|
||||||
|
const material = deriveWalletMaterial(MNEMONIC)
|
||||||
|
await saveWalletMaterial(material, PASSWORD)
|
||||||
|
localStorage.setItem(
|
||||||
|
MATERIAL_STORAGE_KEY,
|
||||||
|
JSON.stringify({...readRawMaterialRecord(), materialHash: '00'.repeat(32)}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(decryptSavedWalletMaterial(PASSWORD)).rejects.toBeInstanceOf(
|
||||||
|
WalletMaterialProofMismatchError,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects plaintext material when its commitment differs', async () => {
|
||||||
|
const material = deriveWalletMaterial(MNEMONIC)
|
||||||
|
await saveWalletMaterial(material)
|
||||||
|
const before = JSON.stringify({...readRawMaterialRecord(), materialHash: '00'.repeat(32)})
|
||||||
|
localStorage.setItem(MATERIAL_STORAGE_KEY, before)
|
||||||
|
|
||||||
|
expect(getPlainWalletMaterial()).toBeNull()
|
||||||
|
expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).toBe(before)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses to stamp same-owner material with an altered cash root', async () => {
|
||||||
|
const material = deriveWalletMaterial(MNEMONIC)
|
||||||
|
const other = deriveWalletMaterial(OTHER_MNEMONIC)
|
||||||
|
await saveWalletMaterial(material, PASSWORD)
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
ensureSavedWalletMaterialOwner({...material, cashRootHex: other.cashRootHex}),
|
||||||
|
).toThrow(WalletMaterialProofMismatchError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('requires exactly one lowercase 64-hex commitment at the storage boundary', () => {
|
||||||
|
const material = deriveWalletMaterial(MNEMONIC)
|
||||||
|
const serialized = serializeWalletMaterial(material)
|
||||||
|
const record = {
|
||||||
|
enc: false,
|
||||||
|
value: serialized,
|
||||||
|
materialHash: materialHash(serialized),
|
||||||
|
ownerId: walletMaterialOwnerId(material),
|
||||||
|
version: 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(isValidStoredWalletMaterial(record)).toBe(true)
|
||||||
|
expect(isValidStoredWalletMaterial({...record, materialHash: record.materialHash.toUpperCase()})).toBe(
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
isValidStoredWalletMaterial({
|
||||||
|
enc: record.enc,
|
||||||
|
value: record.value,
|
||||||
|
ownerId: record.ownerId,
|
||||||
|
version: record.version,
|
||||||
|
}),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects malformed authenticated material after its correct commitment matches', async () => {
|
||||||
|
const material = deriveWalletMaterial(MNEMONIC)
|
||||||
|
const malformed = JSON.stringify({...material, cashRootHex: '00'.repeat(64)})
|
||||||
|
const parts = await encryptSecretParts(malformed, PASSWORD)
|
||||||
|
localStorage.setItem(
|
||||||
|
MATERIAL_STORAGE_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
enc: true,
|
||||||
|
...parts,
|
||||||
|
materialHash: materialHash(malformed),
|
||||||
|
ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)),
|
||||||
|
version: 2,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(decryptSavedWalletMaterial(PASSWORD)).rejects.toThrow(
|
||||||
|
'valid v2 wallet material',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves a verified restored commitment while discarding its owner claim', () => {
|
||||||
|
const material = deriveWalletMaterial(MNEMONIC)
|
||||||
|
const serialized = serializeWalletMaterial(material)
|
||||||
|
|
||||||
|
restoreWalletMaterialStored({
|
||||||
|
enc: false,
|
||||||
|
value: serialized,
|
||||||
|
materialHash: materialHash(serialized),
|
||||||
|
ownerId: linkingPubKeyHex(new Uint8Array(32).fill(9)),
|
||||||
|
version: 2,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(readRawMaterialRecord()).toEqual({
|
||||||
|
enc: false,
|
||||||
|
value: serialized,
|
||||||
|
materialHash: materialHash(serialized),
|
||||||
|
})
|
||||||
|
expect(getPlainWalletMaterial()).toEqual(material)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('proves an encrypted restored commitment before owner stamping', async () => {
|
||||||
|
const material = deriveWalletMaterial(MNEMONIC)
|
||||||
|
const serialized = serializeWalletMaterial(material)
|
||||||
|
const parts = await encryptSecretParts(serialized, PASSWORD)
|
||||||
|
restoreWalletMaterialStored({
|
||||||
|
enc: true,
|
||||||
|
...parts,
|
||||||
|
materialHash: materialHash(serialized),
|
||||||
|
ownerId: linkingPubKeyHex(new Uint8Array(32).fill(9)),
|
||||||
|
version: 2,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(savedWalletMaterialOwnerId()).toBeNull()
|
||||||
|
const proven = await decryptSavedWalletMaterial(PASSWORD)
|
||||||
|
ensureSavedWalletMaterialOwner(proven)
|
||||||
|
|
||||||
|
expect(savedWalletMaterialOwnerId()).toBe(walletMaterialOwnerId(material))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects an unproven restored hash that contradicts encrypted material', async () => {
|
||||||
|
const material = deriveWalletMaterial(MNEMONIC)
|
||||||
|
const serialized = serializeWalletMaterial(material)
|
||||||
|
const parts = await encryptSecretParts(serialized, PASSWORD)
|
||||||
|
restoreWalletMaterialStored({
|
||||||
|
enc: true,
|
||||||
|
...parts,
|
||||||
|
materialHash: '00'.repeat(32),
|
||||||
|
ownerId: walletMaterialOwnerId(material),
|
||||||
|
version: 2,
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(decryptSavedWalletMaterial(PASSWORD)).rejects.toBeInstanceOf(
|
||||||
|
WalletMaterialProofMismatchError,
|
||||||
|
)
|
||||||
|
expect(savedWalletMaterialOwnerId()).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import {sha256} from '@noble/hashes/sha2.js'
|
||||||
|
import {bytesToHex, utf8ToBytes} from '@noble/hashes/utils.js'
|
||||||
import {describe, expect, it} from 'vitest'
|
import {describe, expect, it} from 'vitest'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -27,6 +29,9 @@ const LINKING_KEY_HEX = 'da2a752d3d28668288ff50bed644e4a95f726d8711fd8754060f0a9
|
|||||||
const CASH_ROOT_HEX =
|
const CASH_ROOT_HEX =
|
||||||
'c7a2496e9b453a67c5d2a1f04936ec1259440d45454c795a99a66269e4cd3005111e1cc966fca2fe32f054f14caceab90449e536d94cf6935ea12a087e414f60'
|
'c7a2496e9b453a67c5d2a1f04936ec1259440d45454c795a99a66269e4cd3005111e1cc966fca2fe32f054f14caceab90449e536d94cf6935ea12a087e414f60'
|
||||||
|
|
||||||
|
const materialHash = (serialized: string): string =>
|
||||||
|
bytesToHex(sha256(utf8ToBytes(serialized)))
|
||||||
|
|
||||||
const readRawMaterialRecord = (): Record<string, unknown> => {
|
const readRawMaterialRecord = (): Record<string, unknown> => {
|
||||||
const raw = localStorage.getItem(MATERIAL_STORAGE_KEY)
|
const raw = localStorage.getItem(MATERIAL_STORAGE_KEY)
|
||||||
if (raw === null) throw new Error('expected a saved wallet-material record')
|
if (raw === null) throw new Error('expected a saved wallet-material record')
|
||||||
@@ -50,6 +55,7 @@ describe('WalletMaterialV2 persistence', () => {
|
|||||||
expect(readRawMaterialRecord()).toEqual({
|
expect(readRawMaterialRecord()).toEqual({
|
||||||
enc: false,
|
enc: false,
|
||||||
value: serializeWalletMaterial(material),
|
value: serializeWalletMaterial(material),
|
||||||
|
materialHash: materialHash(serializeWalletMaterial(material)),
|
||||||
ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)),
|
ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)),
|
||||||
version: 2,
|
version: 2,
|
||||||
})
|
})
|
||||||
@@ -87,6 +93,7 @@ describe('WalletMaterialV2 persistence', () => {
|
|||||||
restoreWalletMaterialStored({
|
restoreWalletMaterialStored({
|
||||||
enc: false,
|
enc: false,
|
||||||
value: serializeWalletMaterial(material),
|
value: serializeWalletMaterial(material),
|
||||||
|
materialHash: materialHash(serializeWalletMaterial(material)),
|
||||||
ownerId: foreignOwner,
|
ownerId: foreignOwner,
|
||||||
version: 2,
|
version: 2,
|
||||||
})
|
})
|
||||||
@@ -96,7 +103,11 @@ describe('WalletMaterialV2 persistence', () => {
|
|||||||
ensureSavedWalletMaterialOwner(unlocked)
|
ensureSavedWalletMaterialOwner(unlocked)
|
||||||
|
|
||||||
// Then the claim was absent before proof and only the derived owner was stamped
|
// Then the claim was absent before proof and only the derived owner was stamped
|
||||||
expect(beforeProof).toEqual({enc: false, value: serializeWalletMaterial(material)})
|
expect(beforeProof).toEqual({
|
||||||
|
enc: false,
|
||||||
|
value: serializeWalletMaterial(material),
|
||||||
|
materialHash: materialHash(serializeWalletMaterial(material)),
|
||||||
|
})
|
||||||
expect(savedWalletMaterialOwnerId()).toBe(linkingPubKeyHex(walletMaterialLinkingKey(material)))
|
expect(savedWalletMaterialOwnerId()).toBe(linkingPubKeyHex(walletMaterialLinkingKey(material)))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -109,6 +120,7 @@ describe('WalletMaterialV2 persistence', () => {
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
enc: false,
|
enc: false,
|
||||||
value: serializeWalletMaterial(material),
|
value: serializeWalletMaterial(material),
|
||||||
|
materialHash: materialHash(serializeWalletMaterial(material)),
|
||||||
ownerId: linkingPubKeyHex(new Uint8Array(32).fill(9)),
|
ownerId: linkingPubKeyHex(new Uint8Array(32).fill(9)),
|
||||||
version: 2,
|
version: 2,
|
||||||
}),
|
}),
|
||||||
@@ -160,6 +172,7 @@ describe('WalletMaterialV2 persistence', () => {
|
|||||||
const record = {
|
const record = {
|
||||||
enc: false,
|
enc: false,
|
||||||
value,
|
value,
|
||||||
|
materialHash: materialHash(value),
|
||||||
ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)),
|
ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)),
|
||||||
version,
|
version,
|
||||||
}
|
}
|
||||||
@@ -182,6 +195,7 @@ describe('WalletMaterialV2 persistence', () => {
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
enc: true,
|
enc: true,
|
||||||
...parts,
|
...parts,
|
||||||
|
materialHash: materialHash(LINKING_KEY_HEX),
|
||||||
ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)),
|
ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)),
|
||||||
version: 2,
|
version: 2,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export {
|
|||||||
isValidStoredWalletMaterial,
|
isValidStoredWalletMaterial,
|
||||||
parseWalletMaterial,
|
parseWalletMaterial,
|
||||||
serializeWalletMaterial,
|
serializeWalletMaterial,
|
||||||
|
walletMaterialHash,
|
||||||
} from './storage/storedSecret'
|
} from './storage/storedSecret'
|
||||||
export type {StoredSecret, StoredWalletMaterial, WalletMaterialV2} from './storage/storedSecret'
|
export type {StoredSecret, StoredWalletMaterial, WalletMaterialV2} from './storage/storedSecret'
|
||||||
export {decryptSecretParts, encryptSecretParts} from './passwordWrap'
|
export {decryptSecretParts, encryptSecretParts} from './passwordWrap'
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import {parseJsonObject, stubLocalStorage} from './test-utils'
|
import {parseJsonObject, stubLocalStorage} from './test-utils'
|
||||||
|
|
||||||
import './keys.material.cases'
|
import './keys.material.cases'
|
||||||
|
import './keys.material-commitment.cases'
|
||||||
import './keys.material-storage.cases'
|
import './keys.material-storage.cases'
|
||||||
|
|
||||||
const LINKING_KEY = new Uint8Array(32).fill(7)
|
const LINKING_KEY = new Uint8Array(32).fill(7)
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Rewrap proves every existing slot before replacing any bytes. A supplied PRF
|
||||||
|
// is therefore both authority for the old wrap and input for its fresh wrap;
|
||||||
|
// one wrong credential aborts the complete in-memory batch before persistence.
|
||||||
|
|
||||||
|
import type {WalletMaterialV2} from './storage/storedSecret'
|
||||||
|
import {serializeWalletMaterial, walletMaterialHash} from './storage/storedSecret'
|
||||||
|
import type {PasskeySlot} from './storage/passkeySlots'
|
||||||
|
import {
|
||||||
|
PASSKEY_SLOTS_STORAGE_KEY,
|
||||||
|
passkeySlotsEqual,
|
||||||
|
readPasskeySlots,
|
||||||
|
requireCurrentPasskeyMaterialOwner,
|
||||||
|
writePasskeySlots,
|
||||||
|
} from './storage/passkeySlots'
|
||||||
|
import {withStorageLock} from './storageLock'
|
||||||
|
import {unwrapWalletMaterialWithPrf, wrapWalletMaterialWithPrf} from './passkeyWrap'
|
||||||
|
|
||||||
|
type ProvenSlot = {
|
||||||
|
readonly slot: PasskeySlot
|
||||||
|
readonly prfOutput: Uint8Array
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rewrapAllSlots = async (
|
||||||
|
material: WalletMaterialV2,
|
||||||
|
prfOutputs: ReadonlyMap<string, Uint8Array>,
|
||||||
|
): Promise<void> => {
|
||||||
|
const ownerId = requireCurrentPasskeyMaterialOwner(material)
|
||||||
|
const materialHash = walletMaterialHash(material)
|
||||||
|
const serialized = serializeWalletMaterial(material)
|
||||||
|
await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, async () => {
|
||||||
|
const provenSlots: ProvenSlot[] = readPasskeySlots().map((slot) => {
|
||||||
|
const prfOutput = prfOutputs.get(slot.credentialId)
|
||||||
|
if (!prfOutput) {
|
||||||
|
throw new Error('Missing old PRF output for a passkey slot - refusing a partial re-wrap.')
|
||||||
|
}
|
||||||
|
return {slot, prfOutput}
|
||||||
|
})
|
||||||
|
for (const {slot, prfOutput} of provenSlots) {
|
||||||
|
if (slot.materialHash !== materialHash) {
|
||||||
|
throw new Error('Passkey slot does not match the saved wallet material.')
|
||||||
|
}
|
||||||
|
const current = await unwrapWalletMaterialWithPrf(prfOutput, slot)
|
||||||
|
if (serializeWalletMaterial(current) !== serialized) {
|
||||||
|
throw new Error('Passkey slot contains different wallet material.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const rewrapped: PasskeySlot[] = []
|
||||||
|
for (const {slot, prfOutput} of provenSlots) {
|
||||||
|
rewrapped.push({...slot, ...(await wrapWalletMaterialWithPrf(prfOutput, material))})
|
||||||
|
}
|
||||||
|
if (requireCurrentPasskeyMaterialOwner(material) !== ownerId) {
|
||||||
|
throw new Error('Saved wallet material changed during passkey re-wrap.')
|
||||||
|
}
|
||||||
|
const currentSlots = readPasskeySlots()
|
||||||
|
if (
|
||||||
|
currentSlots.length !== provenSlots.length ||
|
||||||
|
currentSlots.some((current, index) => {
|
||||||
|
const original = provenSlots[index]?.slot
|
||||||
|
return original === undefined || !passkeySlotsEqual(original, current)
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
throw new Error('Passkey slots changed during re-wrap.')
|
||||||
|
}
|
||||||
|
writePasskeySlots(ownerId, rewrapped)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -9,11 +9,11 @@
|
|||||||
// 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 {parseWalletMaterial, serializeWalletMaterial} from './storage/storedSecret'
|
||||||
import type {WalletMaterialV2} from './storage/storedSecret'
|
import type {WalletMaterialV2} from './storage/storedSecret'
|
||||||
|
import {serializedWalletMaterialHash, walletMaterialHash} from './storage/walletMaterial'
|
||||||
|
|
||||||
const WRAP_KEY_HKDF_INFO = 'sattle-passkey-wrap-v2'
|
const WRAP_KEY_HKDF_INFO = 'sattle-passkey-wrap-v2'
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ export const wrapWalletMaterialWithPrf = async (
|
|||||||
return {
|
return {
|
||||||
hkdfSalt: bytesToHex(hkdfSalt),
|
hkdfSalt: bytesToHex(hkdfSalt),
|
||||||
iv: bytesToHex(iv),
|
iv: bytesToHex(iv),
|
||||||
materialHash: bytesToHex(sha256(utf8ToBytes(serialized))),
|
materialHash: walletMaterialHash(material),
|
||||||
wrappedMaterial: bytesToHex(ciphertext),
|
wrappedMaterial: bytesToHex(ciphertext),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,7 +81,7 @@ export const unwrapWalletMaterialWithPrf = async (
|
|||||||
new Uint8Array(hexToBytes(wrap.wrappedMaterial)),
|
new Uint8Array(hexToBytes(wrap.wrappedMaterial)),
|
||||||
)
|
)
|
||||||
const serialized = new TextDecoder().decode(plaintext)
|
const serialized = new TextDecoder().decode(plaintext)
|
||||||
if (bytesToHex(sha256(utf8ToBytes(serialized))) !== wrap.materialHash) {
|
if (serializedWalletMaterialHash(serialized) !== wrap.materialHash) {
|
||||||
throw new InvalidPasskeyMaterialError()
|
throw new InvalidPasskeyMaterialError()
|
||||||
}
|
}
|
||||||
const material = parseWalletMaterial(serialized)
|
const material = parseWalletMaterial(serialized)
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import {hmac} from '@noble/hashes/hmac.js'
|
||||||
|
import {sha256} from '@noble/hashes/sha2.js'
|
||||||
|
import {bytesToHex} from '@noble/hashes/utils.js'
|
||||||
|
import {beforeEach, describe, expect, it} from 'vitest'
|
||||||
|
|
||||||
|
import {deriveWalletMaterial, saveWalletMaterial} from './keys'
|
||||||
|
import {
|
||||||
|
getPasskeyPrfOutput,
|
||||||
|
registerPasskey,
|
||||||
|
rewrapAllSlots,
|
||||||
|
unlockWalletMaterialWithPasskey,
|
||||||
|
} from './passkeys'
|
||||||
|
import type {CeremonyCredential, PasskeyCredentials} 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 ALTERED_ROOT = {...MATERIAL, cashRootHex: OTHER_MATERIAL.cashRootHex}
|
||||||
|
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(12)
|
||||||
|
private readonly secret = new Uint8Array(32).fill(13)
|
||||||
|
onCreate?: () => Promise<void>
|
||||||
|
onGet?: () => Promise<void>
|
||||||
|
|
||||||
|
create = async (options?: CredentialCreationOptions): Promise<CeremonyCredential> => {
|
||||||
|
await this.onCreate?.()
|
||||||
|
return this.credential(options?.publicKey?.extensions?.prf?.eval?.first)
|
||||||
|
}
|
||||||
|
|
||||||
|
get = async (options?: CredentialRequestOptions): Promise<CeremonyCredential | null> => {
|
||||||
|
await this.onGet?.()
|
||||||
|
const allowed = options?.publicKey?.allowCredentials ?? []
|
||||||
|
return allowed.some(
|
||||||
|
(descriptor) => bytesToHex(toBytes(descriptor.id)) === bytesToHex(this.credentialId),
|
||||||
|
)
|
||||||
|
? 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) ?? ''
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
stubLocalStorage()
|
||||||
|
await saveWalletMaterial(MATERIAL)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('exact wallet material passkey commitment', () => {
|
||||||
|
it('rejects a wrong old PRF before changing any slot bytes', async () => {
|
||||||
|
const authenticator = new FakeAuthenticator()
|
||||||
|
const slot = await registerPasskey(MATERIAL, {credentials: authenticator})
|
||||||
|
const before = rawSlots()
|
||||||
|
const wrongPrf = new Uint8Array(32).fill(99)
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
rewrapAllSlots(MATERIAL, new Map([[slot.credentialId, wrongPrf]])),
|
||||||
|
).rejects.toThrow()
|
||||||
|
expect(rawSlots()).toBe(before)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects same-owner altered-root registration against encrypted saved material', async () => {
|
||||||
|
const authenticator = new FakeAuthenticator()
|
||||||
|
await saveWalletMaterial(MATERIAL, 'password')
|
||||||
|
|
||||||
|
await expect(registerPasskey(ALTERED_ROOT, {credentials: authenticator})).rejects.toThrow(
|
||||||
|
'saved wallet material',
|
||||||
|
)
|
||||||
|
expect(rawSlots()).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects registration when same-owner saved material changes during the ceremony', async () => {
|
||||||
|
const authenticator = new FakeAuthenticator()
|
||||||
|
authenticator.onCreate = () => saveWalletMaterial(ALTERED_ROOT)
|
||||||
|
|
||||||
|
await expect(registerPasskey(MATERIAL, {credentials: authenticator})).rejects.toThrow(
|
||||||
|
'saved wallet material',
|
||||||
|
)
|
||||||
|
expect(rawSlots()).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects unlock after same-owner saved material was replaced', async () => {
|
||||||
|
const authenticator = new FakeAuthenticator()
|
||||||
|
await registerPasskey(MATERIAL, {credentials: authenticator})
|
||||||
|
await saveWalletMaterial(ALTERED_ROOT, 'password')
|
||||||
|
|
||||||
|
await expect(unlockWalletMaterialWithPasskey({credentials: authenticator})).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects same-owner saved-material replacement during the unlock ceremony', async () => {
|
||||||
|
const authenticator = new FakeAuthenticator()
|
||||||
|
await registerPasskey(MATERIAL, {credentials: authenticator})
|
||||||
|
authenticator.onGet = () => saveWalletMaterial(ALTERED_ROOT)
|
||||||
|
|
||||||
|
await expect(unlockWalletMaterialWithPasskey({credentials: authenticator})).rejects.toThrow(
|
||||||
|
'changed',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a valid slot usable when its old PRF proves the existing wrap', async () => {
|
||||||
|
const authenticator = new FakeAuthenticator()
|
||||||
|
const slot = await registerPasskey(MATERIAL, {credentials: authenticator})
|
||||||
|
const oldPrf = await getPasskeyPrfOutput(slot.credentialId, {credentials: authenticator})
|
||||||
|
|
||||||
|
await rewrapAllSlots(MATERIAL, new Map([[slot.credentialId, oldPrf]]))
|
||||||
|
|
||||||
|
await expect(unlockWalletMaterialWithPasskey({credentials: authenticator})).resolves.toEqual(
|
||||||
|
MATERIAL,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -10,7 +10,6 @@ import {beforeEach, describe, expect, it} from 'vitest'
|
|||||||
import {
|
import {
|
||||||
deriveWalletMaterial,
|
deriveWalletMaterial,
|
||||||
saveWalletMaterial,
|
saveWalletMaterial,
|
||||||
serializeWalletMaterial,
|
|
||||||
} from './keys'
|
} from './keys'
|
||||||
import {
|
import {
|
||||||
derivePasskeyWrapKey,
|
derivePasskeyWrapKey,
|
||||||
@@ -135,7 +134,7 @@ describe('v2 passkey material', () => {
|
|||||||
const authenticator = new FakeAuthenticator()
|
const authenticator = new FakeAuthenticator()
|
||||||
const slot = await registerPasskey(MATERIAL, {credentials: authenticator})
|
const slot = await registerPasskey(MATERIAL, {credentials: authenticator})
|
||||||
const prfOutput = await getPasskeyPrfOutput(slot.credentialId, {credentials: authenticator})
|
const prfOutput = await getPasskeyPrfOutput(slot.credentialId, {credentials: authenticator})
|
||||||
const materialHash = bytesToHex(sha256(utf8ToBytes(serializeWalletMaterial(MATERIAL))))
|
const materialHash = bytesToHex(sha256(utf8ToBytes(payload)))
|
||||||
const wrap = await encryptPayload(prfOutput, payload, materialHash)
|
const wrap = await encryptPayload(prfOutput, payload, materialHash)
|
||||||
localStorage.setItem(SLOT_STORAGE_KEY, JSON.stringify([{...slot, ...wrap}]))
|
localStorage.setItem(SLOT_STORAGE_KEY, JSON.stringify([{...slot, ...wrap}]))
|
||||||
const before = rawSlots()
|
const before = rawSlots()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import './passkeys.crypto.cases'
|
import './passkeys.crypto.cases'
|
||||||
|
import './passkeys.commitment.cases'
|
||||||
import './passkeys.material.cases'
|
import './passkeys.material.cases'
|
||||||
import './passkeys.registration.cases'
|
import './passkeys.registration.cases'
|
||||||
import './passkeys.ownership-a.cases'
|
import './passkeys.ownership-a.cases'
|
||||||
|
|||||||
+17
-33
@@ -36,13 +36,14 @@ import {
|
|||||||
writePasskeySlots,
|
writePasskeySlots,
|
||||||
} from './storage/passkeySlots'
|
} from './storage/passkeySlots'
|
||||||
import type {WalletMaterialV2} from './storage/storedSecret'
|
import type {WalletMaterialV2} from './storage/storedSecret'
|
||||||
import {walletMaterialOwnerId} from './storage/storedSecret'
|
import {walletMaterialHash, walletMaterialOwnerId} from './storage/storedSecret'
|
||||||
import {savedWalletMaterialOwnerId} from './walletMaterialStorage'
|
import {savedWalletMaterialHash, savedWalletMaterialOwnerId} from './walletMaterialStorage'
|
||||||
import {unwrapWalletMaterialWithPrf, wrapWalletMaterialWithPrf} from './passkeyWrap'
|
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 {rewrapAllSlots} from './passkeyRewrap'
|
||||||
export {
|
export {
|
||||||
derivePasskeyWrapKey,
|
derivePasskeyWrapKey,
|
||||||
InvalidPasskeyMaterialError,
|
InvalidPasskeyMaterialError,
|
||||||
@@ -218,6 +219,9 @@ export const registerPasskey = async (
|
|||||||
version: PASSKEY_SLOT_VERSION,
|
version: PASSKEY_SLOT_VERSION,
|
||||||
}
|
}
|
||||||
await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => {
|
await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, () => {
|
||||||
|
if (requireCurrentPasskeyMaterialOwner(walletMaterial) !== ownerId) {
|
||||||
|
throw new Error('Saved wallet material changed during passkey registration.')
|
||||||
|
}
|
||||||
const slots = readPasskeySlots().filter((s) => s.credentialId !== credentialId)
|
const slots = readPasskeySlots().filter((s) => s.credentialId !== credentialId)
|
||||||
slots.push(slot)
|
slots.push(slot)
|
||||||
writePasskeySlots(ownerId, slots)
|
writePasskeySlots(ownerId, slots)
|
||||||
@@ -232,10 +236,14 @@ export const unlockWalletMaterialWithPasskey = async (
|
|||||||
options: {credentials?: PasskeyCredentials} = {},
|
options: {credentials?: PasskeyCredentials} = {},
|
||||||
): Promise<WalletMaterialV2> => {
|
): Promise<WalletMaterialV2> => {
|
||||||
const ownerId = savedWalletMaterialOwnerId()
|
const ownerId = savedWalletMaterialOwnerId()
|
||||||
|
const materialHash = savedWalletMaterialHash()
|
||||||
const slots = readPasskeySlots()
|
const slots = readPasskeySlots()
|
||||||
if (ownerId === null || slots.length === 0) {
|
if (ownerId === null || materialHash === null || slots.length === 0) {
|
||||||
throw new Error('No passkeys registered on this device.')
|
throw new Error('No passkeys registered on this device.')
|
||||||
}
|
}
|
||||||
|
if (slots.some((slot) => slot.materialHash !== materialHash)) {
|
||||||
|
throw new Error('Passkey slot belongs to different wallet material.')
|
||||||
|
}
|
||||||
const credentials = options.credentials ?? defaultCredentials()
|
const credentials = options.credentials ?? defaultCredentials()
|
||||||
const assertion = await credentials.get({
|
const assertion = await credentials.get({
|
||||||
publicKey: {
|
publicKey: {
|
||||||
@@ -261,6 +269,9 @@ export const unlockWalletMaterialWithPasskey = async (
|
|||||||
if (savedWalletMaterialOwnerId() !== ownerId) {
|
if (savedWalletMaterialOwnerId() !== ownerId) {
|
||||||
throw new Error('This passkey belongs to a different wallet.')
|
throw new Error('This passkey belongs to a different wallet.')
|
||||||
}
|
}
|
||||||
|
if (savedWalletMaterialHash() !== materialHash) {
|
||||||
|
throw new Error('Saved wallet material changed during the unlock ceremony.')
|
||||||
|
}
|
||||||
const currentSlot = readPasskeySlots().find((candidate) => candidate.credentialId === credentialId)
|
const currentSlot = readPasskeySlots().find((candidate) => candidate.credentialId === credentialId)
|
||||||
if (currentSlot === undefined || !passkeySlotsEqual(slot, currentSlot)) {
|
if (currentSlot === undefined || !passkeySlotsEqual(slot, currentSlot)) {
|
||||||
throw new Error('The passkey slot changed during the unlock ceremony.')
|
throw new Error('The passkey slot changed during the unlock ceremony.')
|
||||||
@@ -269,6 +280,9 @@ export const unlockWalletMaterialWithPasskey = async (
|
|||||||
if (savedWalletMaterialOwnerId() !== ownerId || walletMaterialOwnerId(material) !== ownerId) {
|
if (savedWalletMaterialOwnerId() !== ownerId || walletMaterialOwnerId(material) !== ownerId) {
|
||||||
throw new Error('This passkey belongs to a different wallet.')
|
throw new Error('This passkey belongs to a different wallet.')
|
||||||
}
|
}
|
||||||
|
if (savedWalletMaterialHash() !== materialHash || walletMaterialHash(material) !== materialHash) {
|
||||||
|
throw new Error('Saved wallet material changed during the unlock ceremony.')
|
||||||
|
}
|
||||||
return material
|
return material
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,33 +308,3 @@ export const removePasskey = async (credentialId: string): Promise<boolean> => {
|
|||||||
})
|
})
|
||||||
return removed
|
return removed
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 because password and passkey slots are
|
|
||||||
// independent wraps of the same canonical material.
|
|
||||||
export const rewrapAllSlots = async (
|
|
||||||
material: WalletMaterialV2,
|
|
||||||
prfOutputs: ReadonlyMap<string, Uint8Array>,
|
|
||||||
): Promise<void> => {
|
|
||||||
const ownerId = requireCurrentPasskeyMaterialOwner(material)
|
|
||||||
await withStorageLock(PASSKEY_SLOTS_STORAGE_KEY, async () => {
|
|
||||||
const slots = readPasskeySlots()
|
|
||||||
const rewrapped: PasskeySlot[] = []
|
|
||||||
for (const slot of slots) {
|
|
||||||
const prfOutput = prfOutputs.get(slot.credentialId)
|
|
||||||
if (!prfOutput) {
|
|
||||||
throw new Error('Missing fresh PRF output for a passkey slot - refusing a partial re-wrap.')
|
|
||||||
}
|
|
||||||
rewrapped.push({
|
|
||||||
...slot,
|
|
||||||
...(await wrapWalletMaterialWithPrf(prfOutput, material)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
writePasskeySlots(ownerId, rewrapped)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
linkingPubKeyHex,
|
linkingPubKeyHex,
|
||||||
savedKeyOwnerId,
|
savedKeyOwnerId,
|
||||||
saveWalletMaterial,
|
saveWalletMaterial,
|
||||||
|
walletMaterialHash,
|
||||||
} from './keys'
|
} from './keys'
|
||||||
import {migrateLegacyPasskeySlots, readPasskeySlots} from './passkeys'
|
import {migrateLegacyPasskeySlots, readPasskeySlots} from './passkeys'
|
||||||
import type {PasskeySlot} from './passkeys'
|
import type {PasskeySlot} from './passkeys'
|
||||||
@@ -28,7 +29,7 @@ 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),
|
||||||
materialHash: '44'.repeat(32),
|
materialHash: walletMaterialHash(MATERIAL),
|
||||||
wrappedMaterial: '55'.repeat(128),
|
wrappedMaterial: '55'.repeat(128),
|
||||||
createdAt: 1,
|
createdAt: 1,
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -5,8 +5,12 @@
|
|||||||
|
|
||||||
import {isJsonObject} from '../jsonParsing'
|
import {isJsonObject} from '../jsonParsing'
|
||||||
import type {WalletMaterialV2} from './storedSecret'
|
import type {WalletMaterialV2} from './storedSecret'
|
||||||
import {serializeWalletMaterial, walletMaterialOwnerId} from './storedSecret'
|
import {serializeWalletMaterial, walletMaterialHash, walletMaterialOwnerId} from './storedSecret'
|
||||||
import {getPlainWalletMaterial, savedWalletMaterialOwnerId} from '../walletMaterialStorage'
|
import {
|
||||||
|
getPlainWalletMaterial,
|
||||||
|
savedWalletMaterialHash,
|
||||||
|
savedWalletMaterialOwnerId,
|
||||||
|
} from '../walletMaterialStorage'
|
||||||
import {isWalletOwnerId} from './walletOwner'
|
import {isWalletOwnerId} from './walletOwner'
|
||||||
|
|
||||||
export type PasskeyWrap = {
|
export type PasskeyWrap = {
|
||||||
@@ -39,10 +43,13 @@ export const passkeySlotsEqual = (left: PasskeySlot, right: PasskeySlot): boolea
|
|||||||
|
|
||||||
export const requireCurrentPasskeyMaterialOwner = (material: WalletMaterialV2): string => {
|
export const requireCurrentPasskeyMaterialOwner = (material: WalletMaterialV2): string => {
|
||||||
const ownerId = savedWalletMaterialOwnerId()
|
const ownerId = savedWalletMaterialOwnerId()
|
||||||
|
const materialHash = savedWalletMaterialHash()
|
||||||
const plaintext = getPlainWalletMaterial()
|
const plaintext = getPlainWalletMaterial()
|
||||||
if (
|
if (
|
||||||
ownerId === null ||
|
ownerId === null ||
|
||||||
|
materialHash === null ||
|
||||||
walletMaterialOwnerId(material) !== ownerId ||
|
walletMaterialOwnerId(material) !== ownerId ||
|
||||||
|
walletMaterialHash(material) !== materialHash ||
|
||||||
(plaintext !== null && serializeWalletMaterial(plaintext) !== serializeWalletMaterial(material))
|
(plaintext !== null && serializeWalletMaterial(plaintext) !== serializeWalletMaterial(material))
|
||||||
) {
|
) {
|
||||||
throw new Error('Passkey operation requires the proven saved wallet material.')
|
throw new Error('Passkey operation requires the proven saved wallet material.')
|
||||||
@@ -175,10 +182,22 @@ 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) || savedWalletMaterialOwnerId() !== ownerId) {
|
const materialHash = savedWalletMaterialHash()
|
||||||
|
if (
|
||||||
|
!isWalletOwnerId(ownerId) ||
|
||||||
|
savedWalletMaterialOwnerId() !== ownerId ||
|
||||||
|
materialHash === null
|
||||||
|
) {
|
||||||
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 ||
|
||||||
|
slot.materialHash !== materialHash,
|
||||||
|
)
|
||||||
|
) {
|
||||||
throw new Error('Refusing to write a passkey slot for a different wallet.')
|
throw new Error('Refusing to write a passkey slot for a different wallet.')
|
||||||
}
|
}
|
||||||
const preserved = readStoredPasskeySlots()
|
const preserved = readStoredPasskeySlots()
|
||||||
@@ -188,13 +207,18 @@ export const writePasskeySlots = (ownerId: string, slots: PasskeySlot[]): void =
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const adoptLegacyPasskeySlots = (ownerId: string): number => {
|
export const adoptLegacyPasskeySlots = (ownerId: string): number => {
|
||||||
|
const materialHash = savedWalletMaterialHash()
|
||||||
if (!isWalletOwnerId(ownerId) || savedWalletMaterialOwnerId() !== 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()
|
||||||
let adopted = 0
|
let adopted = 0
|
||||||
const migrated = stored.map((slot) => {
|
const migrated = stored.map((slot) => {
|
||||||
if (slot.isCurrent || (slot.claimedOwnerId !== null && slot.claimedOwnerId !== ownerId)) {
|
if (
|
||||||
|
slot.isCurrent ||
|
||||||
|
slot.record.materialHash !== materialHash ||
|
||||||
|
(slot.claimedOwnerId !== null && slot.claimedOwnerId !== ownerId)
|
||||||
|
) {
|
||||||
return slot.record
|
return slot.record
|
||||||
}
|
}
|
||||||
adopted += 1
|
adopted += 1
|
||||||
|
|||||||
@@ -3,21 +3,24 @@
|
|||||||
// canonical material encoding so hostile storage cannot smuggle a legacy key,
|
// canonical material encoding so hostile storage cannot smuggle a legacy key,
|
||||||
// a future field, or a malformed BIP-32 root across the unlock boundary.
|
// a future field, or a malformed BIP-32 root across the unlock boundary.
|
||||||
|
|
||||||
import {secp256k1} from '@noble/curves/secp256k1.js'
|
|
||||||
import {bytesToHex, hexToBytes} from '@noble/hashes/utils.js'
|
|
||||||
import {cashNodeFromHex, cashNodeToHex} from 'lnurlcash-kit'
|
|
||||||
import {isJsonObject} from '../jsonParsing'
|
import {isJsonObject} from '../jsonParsing'
|
||||||
import {isWalletOwnerId} from './walletOwner'
|
import {isWalletOwnerId} from './walletOwner'
|
||||||
|
import {
|
||||||
|
parseWalletMaterial,
|
||||||
|
serializedWalletMaterialHash,
|
||||||
|
STORED_WALLET_MATERIAL_VERSION,
|
||||||
|
} from './walletMaterial'
|
||||||
|
export {
|
||||||
|
parseWalletMaterial,
|
||||||
|
serializeWalletMaterial,
|
||||||
|
walletMaterialHash,
|
||||||
|
walletMaterialOwnerId,
|
||||||
|
WALLET_MATERIAL_VERSION,
|
||||||
|
} from './walletMaterial'
|
||||||
|
export type {WalletMaterialV2} from './walletMaterial'
|
||||||
|
|
||||||
export const STORED_SECRET_VERSION = 1 as const
|
export const STORED_SECRET_VERSION = 1 as const
|
||||||
export const WALLET_MATERIAL_VERSION = 2 as const
|
export {STORED_WALLET_MATERIAL_VERSION} from './walletMaterial'
|
||||||
export const STORED_WALLET_MATERIAL_VERSION = 2 as const
|
|
||||||
|
|
||||||
export type WalletMaterialV2 = {
|
|
||||||
readonly version: typeof WALLET_MATERIAL_VERSION
|
|
||||||
readonly linkingKeyHex: string
|
|
||||||
readonly cashRootHex: string
|
|
||||||
}
|
|
||||||
|
|
||||||
type PlainStoredSecret = {
|
type PlainStoredSecret = {
|
||||||
readonly enc: false
|
readonly enc: false
|
||||||
@@ -36,7 +39,7 @@ type EncryptedStoredSecret = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type StoredSecret = PlainStoredSecret | EncryptedStoredSecret
|
export type StoredSecret = PlainStoredSecret | EncryptedStoredSecret
|
||||||
export type StoredWalletMaterial = StoredSecret
|
export type StoredWalletMaterial = StoredSecret & {readonly materialHash: string}
|
||||||
|
|
||||||
type ParsedStoredSecret = {
|
type ParsedStoredSecret = {
|
||||||
readonly secret: StoredSecret
|
readonly secret: StoredSecret
|
||||||
@@ -44,66 +47,24 @@ type ParsedStoredSecret = {
|
|||||||
readonly isCurrent: boolean
|
readonly isCurrent: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ParsedStoredWalletMaterial = Omit<ParsedStoredSecret, 'secret'> & {
|
||||||
|
readonly secret: StoredWalletMaterial
|
||||||
|
}
|
||||||
|
|
||||||
const PLAIN_KEYS = ['enc', 'value', 'ownerId', 'version'] as const
|
const PLAIN_KEYS = ['enc', 'value', 'ownerId', 'version'] as const
|
||||||
const ENCRYPTED_KEYS = ['enc', 'salt', 'iv', 'ciphertext', 'ownerId', 'version'] as const
|
const ENCRYPTED_KEYS = ['enc', 'salt', 'iv', 'ciphertext', 'ownerId', 'version'] as const
|
||||||
const WALLET_MATERIAL_KEYS = ['version', 'linkingKeyHex', 'cashRootHex'] as const
|
const PLAIN_MATERIAL_KEYS = [...PLAIN_KEYS, 'materialHash'] as const
|
||||||
|
const ENCRYPTED_MATERIAL_KEYS = [...ENCRYPTED_KEYS, 'materialHash'] as const
|
||||||
|
|
||||||
const hasOnlyKeys = (record: Record<string, unknown>, allowed: readonly string[]): boolean =>
|
const hasOnlyKeys = (record: Record<string, unknown>, allowed: readonly string[]): boolean =>
|
||||||
Object.keys(record).every((key) => allowed.includes(key))
|
Object.keys(record).every((key) => allowed.includes(key))
|
||||||
|
|
||||||
const hasExactlyKeys = (record: Record<string, unknown>, expected: readonly string[]): boolean =>
|
|
||||||
Object.keys(record).length === expected.length && hasOnlyKeys(record, expected)
|
|
||||||
|
|
||||||
export const serializeWalletMaterial = (material: WalletMaterialV2): string =>
|
|
||||||
JSON.stringify({
|
|
||||||
version: WALLET_MATERIAL_VERSION,
|
|
||||||
linkingKeyHex: material.linkingKeyHex,
|
|
||||||
cashRootHex: material.cashRootHex,
|
|
||||||
})
|
|
||||||
|
|
||||||
export const parseWalletMaterial = (serialized: string): WalletMaterialV2 | null => {
|
|
||||||
let value: unknown
|
|
||||||
try {
|
|
||||||
value = JSON.parse(serialized)
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof SyntaxError) return null
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!isJsonObject(value) ||
|
|
||||||
!hasExactlyKeys(value, WALLET_MATERIAL_KEYS) ||
|
|
||||||
value.version !== WALLET_MATERIAL_VERSION ||
|
|
||||||
typeof value.linkingKeyHex !== 'string' ||
|
|
||||||
!/^[0-9a-f]{64}$/.test(value.linkingKeyHex) ||
|
|
||||||
typeof value.cashRootHex !== 'string' ||
|
|
||||||
!/^[0-9a-f]{128}$/.test(value.cashRootHex)
|
|
||||||
) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
const linkingKey = hexToBytes(value.linkingKeyHex)
|
|
||||||
const cashRoot = cashNodeFromHex(value.cashRootHex)
|
|
||||||
if (
|
|
||||||
!secp256k1.utils.isValidSecretKey(linkingKey) ||
|
|
||||||
!secp256k1.utils.isValidSecretKey(cashRoot.privateKey) ||
|
|
||||||
cashNodeToHex(cashRoot) !== value.cashRootHex
|
|
||||||
) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
const material: WalletMaterialV2 = {
|
|
||||||
version: WALLET_MATERIAL_VERSION,
|
|
||||||
linkingKeyHex: value.linkingKeyHex,
|
|
||||||
cashRootHex: value.cashRootHex,
|
|
||||||
}
|
|
||||||
return serializeWalletMaterial(material) === serialized ? material : null
|
|
||||||
}
|
|
||||||
|
|
||||||
export const walletMaterialOwnerId = (material: WalletMaterialV2): string =>
|
|
||||||
bytesToHex(secp256k1.getPublicKey(hexToBytes(material.linkingKeyHex), true))
|
|
||||||
|
|
||||||
type StoredSecretParserOptions = {
|
type StoredSecretParserOptions = {
|
||||||
readonly version: number
|
readonly version: number
|
||||||
readonly acceptsPlaintext: (value: string) => boolean
|
readonly acceptsPlaintext: (value: string) => boolean
|
||||||
readonly acceptsUnversionedOwner: boolean
|
readonly acceptsUnversionedOwner: boolean
|
||||||
|
readonly plainKeys: readonly string[]
|
||||||
|
readonly encryptedKeys: readonly string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseStoredSecretWith = (
|
const parseStoredSecretWith = (
|
||||||
@@ -116,7 +77,7 @@ const parseStoredSecretWith = (
|
|||||||
if (
|
if (
|
||||||
typeof stored.value !== 'string' ||
|
typeof stored.value !== 'string' ||
|
||||||
!options.acceptsPlaintext(stored.value) ||
|
!options.acceptsPlaintext(stored.value) ||
|
||||||
!hasOnlyKeys(stored, PLAIN_KEYS)
|
!hasOnlyKeys(stored, options.plainKeys)
|
||||||
) {
|
) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -131,7 +92,7 @@ const parseStoredSecretWith = (
|
|||||||
stored.ciphertext.length === 0 ||
|
stored.ciphertext.length === 0 ||
|
||||||
stored.ciphertext.length % 2 !== 0 ||
|
stored.ciphertext.length % 2 !== 0 ||
|
||||||
!/^[0-9a-f]+$/i.test(stored.ciphertext) ||
|
!/^[0-9a-f]+$/i.test(stored.ciphertext) ||
|
||||||
!hasOnlyKeys(stored, ENCRYPTED_KEYS)
|
!hasOnlyKeys(stored, options.encryptedKeys)
|
||||||
) {
|
) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -170,14 +131,35 @@ export const parseStoredSecret = (stored: unknown): ParsedStoredSecret | null =>
|
|||||||
version: STORED_SECRET_VERSION,
|
version: STORED_SECRET_VERSION,
|
||||||
acceptsPlaintext: (value) => /^[0-9a-f]{64}$/i.test(value),
|
acceptsPlaintext: (value) => /^[0-9a-f]{64}$/i.test(value),
|
||||||
acceptsUnversionedOwner: true,
|
acceptsUnversionedOwner: true,
|
||||||
|
plainKeys: PLAIN_KEYS,
|
||||||
|
encryptedKeys: ENCRYPTED_KEYS,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const parseStoredWalletMaterial = (stored: unknown): ParsedStoredSecret | null =>
|
export const parseStoredWalletMaterial = (stored: unknown): ParsedStoredWalletMaterial | null => {
|
||||||
parseStoredSecretWith(stored, {
|
if (
|
||||||
|
!isJsonObject(stored) ||
|
||||||
|
typeof stored.materialHash !== 'string' ||
|
||||||
|
!/^[0-9a-f]{64}$/.test(stored.materialHash)
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const materialHash = stored.materialHash
|
||||||
|
const parsed = parseStoredSecretWith(stored, {
|
||||||
version: STORED_WALLET_MATERIAL_VERSION,
|
version: STORED_WALLET_MATERIAL_VERSION,
|
||||||
acceptsPlaintext: (value) => parseWalletMaterial(value) !== null,
|
acceptsPlaintext: (value) => parseWalletMaterial(value) !== null,
|
||||||
acceptsUnversionedOwner: false,
|
acceptsUnversionedOwner: false,
|
||||||
|
plainKeys: PLAIN_MATERIAL_KEYS,
|
||||||
|
encryptedKeys: ENCRYPTED_MATERIAL_KEYS,
|
||||||
})
|
})
|
||||||
|
if (
|
||||||
|
parsed === null ||
|
||||||
|
(parsed.secret.enc === false && serializedWalletMaterialHash(parsed.secret.value) !== materialHash)
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const secret: StoredWalletMaterial = {...parsed.secret, materialHash}
|
||||||
|
return {...parsed, secret}
|
||||||
|
}
|
||||||
|
|
||||||
export const isValidStoredSecret = (stored: unknown): stored is StoredSecret =>
|
export const isValidStoredSecret = (stored: unknown): stored is StoredSecret =>
|
||||||
parseStoredSecret(stored) !== null
|
parseStoredSecret(stored) !== null
|
||||||
@@ -224,6 +206,7 @@ export const stampStoredWalletMaterialOwner = (
|
|||||||
return {
|
return {
|
||||||
enc: false,
|
enc: false,
|
||||||
value: stored.value,
|
value: stored.value,
|
||||||
|
materialHash: stored.materialHash,
|
||||||
ownerId,
|
ownerId,
|
||||||
version: STORED_WALLET_MATERIAL_VERSION,
|
version: STORED_WALLET_MATERIAL_VERSION,
|
||||||
}
|
}
|
||||||
@@ -233,6 +216,7 @@ export const stampStoredWalletMaterialOwner = (
|
|||||||
salt: stored.salt,
|
salt: stored.salt,
|
||||||
iv: stored.iv,
|
iv: stored.iv,
|
||||||
ciphertext: stored.ciphertext,
|
ciphertext: stored.ciphertext,
|
||||||
|
materialHash: stored.materialHash,
|
||||||
ownerId,
|
ownerId,
|
||||||
version: STORED_WALLET_MATERIAL_VERSION,
|
version: STORED_WALLET_MATERIAL_VERSION,
|
||||||
}
|
}
|
||||||
@@ -247,3 +231,18 @@ export const stripStoredSecretOwner = (stored: StoredSecret): StoredSecret => {
|
|||||||
ciphertext: stored.ciphertext,
|
ciphertext: stored.ciphertext,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const stripStoredWalletMaterialOwner = (
|
||||||
|
stored: StoredWalletMaterial,
|
||||||
|
): StoredWalletMaterial => {
|
||||||
|
if (stored.enc === false) {
|
||||||
|
return {enc: false, value: stored.value, materialHash: stored.materialHash}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
enc: true,
|
||||||
|
salt: stored.salt,
|
||||||
|
iv: stored.iv,
|
||||||
|
ciphertext: stored.ciphertext,
|
||||||
|
materialHash: stored.materialHash,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// Canonical wallet material combines stable LUD-05 identity with the BIP-32
|
||||||
|
// cash root. Its hash commits to equality and freshness only; wallet identity
|
||||||
|
// remains derived exclusively from the linking key.
|
||||||
|
|
||||||
|
import {secp256k1} from '@noble/curves/secp256k1.js'
|
||||||
|
import {sha256} from '@noble/hashes/sha2.js'
|
||||||
|
import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js'
|
||||||
|
import {cashNodeFromHex, cashNodeToHex} from 'lnurlcash-kit'
|
||||||
|
|
||||||
|
import {isJsonObject} from '../jsonParsing'
|
||||||
|
|
||||||
|
export const WALLET_MATERIAL_VERSION = 2 as const
|
||||||
|
export const STORED_WALLET_MATERIAL_VERSION = 2 as const
|
||||||
|
|
||||||
|
export type WalletMaterialV2 = {
|
||||||
|
readonly version: typeof WALLET_MATERIAL_VERSION
|
||||||
|
readonly linkingKeyHex: string
|
||||||
|
readonly cashRootHex: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const WALLET_MATERIAL_KEYS = ['version', 'linkingKeyHex', 'cashRootHex'] as const
|
||||||
|
|
||||||
|
const hasExactlyKeys = (value: Record<string, unknown>, expected: readonly string[]): boolean =>
|
||||||
|
Object.keys(value).length === expected.length &&
|
||||||
|
Object.keys(value).every((key) => expected.includes(key))
|
||||||
|
|
||||||
|
export const serializeWalletMaterial = (material: WalletMaterialV2): string =>
|
||||||
|
JSON.stringify({
|
||||||
|
version: WALLET_MATERIAL_VERSION,
|
||||||
|
linkingKeyHex: material.linkingKeyHex,
|
||||||
|
cashRootHex: material.cashRootHex,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const serializedWalletMaterialHash = (serialized: string): string =>
|
||||||
|
bytesToHex(sha256(utf8ToBytes(serialized)))
|
||||||
|
|
||||||
|
export const walletMaterialHash = (material: WalletMaterialV2): string =>
|
||||||
|
serializedWalletMaterialHash(serializeWalletMaterial(material))
|
||||||
|
|
||||||
|
export const parseWalletMaterial = (serialized: string): WalletMaterialV2 | null => {
|
||||||
|
let value: unknown
|
||||||
|
try {
|
||||||
|
value = JSON.parse(serialized)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SyntaxError) return null
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!isJsonObject(value) ||
|
||||||
|
!hasExactlyKeys(value, WALLET_MATERIAL_KEYS) ||
|
||||||
|
value.version !== WALLET_MATERIAL_VERSION ||
|
||||||
|
typeof value.linkingKeyHex !== 'string' ||
|
||||||
|
!/^[0-9a-f]{64}$/.test(value.linkingKeyHex) ||
|
||||||
|
typeof value.cashRootHex !== 'string' ||
|
||||||
|
!/^[0-9a-f]{128}$/.test(value.cashRootHex)
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const linkingKey = hexToBytes(value.linkingKeyHex)
|
||||||
|
const cashRoot = cashNodeFromHex(value.cashRootHex)
|
||||||
|
if (
|
||||||
|
!secp256k1.utils.isValidSecretKey(linkingKey) ||
|
||||||
|
!secp256k1.utils.isValidSecretKey(cashRoot.privateKey) ||
|
||||||
|
cashNodeToHex(cashRoot) !== value.cashRootHex
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const material: WalletMaterialV2 = {
|
||||||
|
version: WALLET_MATERIAL_VERSION,
|
||||||
|
linkingKeyHex: value.linkingKeyHex,
|
||||||
|
cashRootHex: value.cashRootHex,
|
||||||
|
}
|
||||||
|
return serializeWalletMaterial(material) === serialized ? material : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const walletMaterialOwnerId = (material: WalletMaterialV2): string =>
|
||||||
|
bytesToHex(secp256k1.getPublicKey(hexToBytes(material.linkingKeyHex), true))
|
||||||
@@ -9,11 +9,13 @@ import {
|
|||||||
stampStoredWalletMaterialOwner,
|
stampStoredWalletMaterialOwner,
|
||||||
storedWalletMaterialClaimedOwnerId,
|
storedWalletMaterialClaimedOwnerId,
|
||||||
storedWalletMaterialOwnerId,
|
storedWalletMaterialOwnerId,
|
||||||
stripStoredSecretOwner,
|
stripStoredWalletMaterialOwner,
|
||||||
|
walletMaterialHash,
|
||||||
walletMaterialOwnerId,
|
walletMaterialOwnerId,
|
||||||
STORED_WALLET_MATERIAL_VERSION,
|
STORED_WALLET_MATERIAL_VERSION,
|
||||||
} from './storage/storedSecret'
|
} from './storage/storedSecret'
|
||||||
import type {StoredWalletMaterial, WalletMaterialV2} from './storage/storedSecret'
|
import type {StoredWalletMaterial, WalletMaterialV2} from './storage/storedSecret'
|
||||||
|
import {serializedWalletMaterialHash} from './storage/walletMaterial'
|
||||||
import {decryptSecretParts, encryptSecretParts} from './passwordWrap'
|
import {decryptSecretParts, encryptSecretParts} from './passwordWrap'
|
||||||
import {WALLET_MATERIAL_STORAGE_KEY} from './storage/walletOwnerEvents'
|
import {WALLET_MATERIAL_STORAGE_KEY} from './storage/walletOwnerEvents'
|
||||||
|
|
||||||
@@ -86,6 +88,9 @@ export const savedWalletMaterialOwnerId = (): string | null => {
|
|||||||
export const savedWalletMaterialOwnerMatches = (material: WalletMaterialV2): boolean =>
|
export const savedWalletMaterialOwnerMatches = (material: WalletMaterialV2): boolean =>
|
||||||
savedWalletMaterialOwnerId() === walletMaterialOwnerId(material)
|
savedWalletMaterialOwnerId() === walletMaterialOwnerId(material)
|
||||||
|
|
||||||
|
export const savedWalletMaterialHash = (): string | null =>
|
||||||
|
readStoredWalletMaterial()?.materialHash ?? null
|
||||||
|
|
||||||
export const getPlainWalletMaterial = (): WalletMaterialV2 | null => {
|
export const getPlainWalletMaterial = (): WalletMaterialV2 | null => {
|
||||||
const stored = readStoredWalletMaterial()
|
const stored = readStoredWalletMaterial()
|
||||||
return stored === null ? null : materialFromPlaintext(stored)
|
return stored === null ? null : materialFromPlaintext(stored)
|
||||||
@@ -96,6 +101,7 @@ export const saveWalletMaterial = async (
|
|||||||
password?: string,
|
password?: string,
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const value = serializeWalletMaterial(material)
|
const value = serializeWalletMaterial(material)
|
||||||
|
const materialHash = walletMaterialHash(material)
|
||||||
const ownerId = walletMaterialOwnerId(material)
|
const ownerId = walletMaterialOwnerId(material)
|
||||||
if (!password) {
|
if (!password) {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
@@ -103,6 +109,7 @@ export const saveWalletMaterial = async (
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
enc: false,
|
enc: false,
|
||||||
value,
|
value,
|
||||||
|
materialHash,
|
||||||
ownerId,
|
ownerId,
|
||||||
version: STORED_WALLET_MATERIAL_VERSION,
|
version: STORED_WALLET_MATERIAL_VERSION,
|
||||||
}),
|
}),
|
||||||
@@ -115,6 +122,7 @@ export const saveWalletMaterial = async (
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
enc: true,
|
enc: true,
|
||||||
...parts,
|
...parts,
|
||||||
|
materialHash,
|
||||||
ownerId,
|
ownerId,
|
||||||
version: STORED_WALLET_MATERIAL_VERSION,
|
version: STORED_WALLET_MATERIAL_VERSION,
|
||||||
}),
|
}),
|
||||||
@@ -122,10 +130,11 @@ export const saveWalletMaterial = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const restoreWalletMaterialStored = (stored: StoredWalletMaterial): void => {
|
export const restoreWalletMaterialStored = (stored: StoredWalletMaterial): void => {
|
||||||
if (parseStoredWalletMaterial(stored) === null) throw new InvalidWalletMaterialError()
|
const parsed = parseStoredWalletMaterial(stored)
|
||||||
|
if (parsed === null) throw new InvalidWalletMaterialError()
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
WALLET_MATERIAL_STORAGE_KEY,
|
WALLET_MATERIAL_STORAGE_KEY,
|
||||||
JSON.stringify(stripStoredSecretOwner(stored)),
|
JSON.stringify(stripStoredWalletMaterialOwner(parsed.secret)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,7 +143,11 @@ export const decryptSavedWalletMaterial = async (
|
|||||||
): Promise<WalletMaterialV2> => {
|
): Promise<WalletMaterialV2> => {
|
||||||
const stored = readStoredWalletMaterial()
|
const stored = readStoredWalletMaterial()
|
||||||
if (stored === null || !stored.enc) throw new NoEncryptedWalletMaterialError()
|
if (stored === null || !stored.enc) throw new NoEncryptedWalletMaterialError()
|
||||||
const material = parseWalletMaterial(await decryptSecretParts(stored, password))
|
const serialized = await decryptSecretParts(stored, password)
|
||||||
|
if (serializedWalletMaterialHash(serialized) !== stored.materialHash) {
|
||||||
|
throw new WalletMaterialProofMismatchError()
|
||||||
|
}
|
||||||
|
const material = parseWalletMaterial(serialized)
|
||||||
if (material === null) throw new InvalidWalletMaterialError()
|
if (material === null) throw new InvalidWalletMaterialError()
|
||||||
const claimedOwnerId = storedWalletMaterialClaimedOwnerId(stored)
|
const claimedOwnerId = storedWalletMaterialClaimedOwnerId(stored)
|
||||||
if (claimedOwnerId !== null && claimedOwnerId !== walletMaterialOwnerId(material)) {
|
if (claimedOwnerId !== null && claimedOwnerId !== walletMaterialOwnerId(material)) {
|
||||||
@@ -146,7 +159,11 @@ export const decryptSavedWalletMaterial = async (
|
|||||||
export const ensureSavedWalletMaterialOwner = (material: WalletMaterialV2): void => {
|
export const ensureSavedWalletMaterialOwner = (material: WalletMaterialV2): void => {
|
||||||
const stored = readStoredWalletMaterial()
|
const stored = readStoredWalletMaterial()
|
||||||
if (stored === null) return
|
if (stored === null) return
|
||||||
if (stored.enc === false && stored.value !== serializeWalletMaterial(material)) {
|
const serialized = serializeWalletMaterial(material)
|
||||||
|
if (
|
||||||
|
walletMaterialHash(material) !== stored.materialHash ||
|
||||||
|
(stored.enc === false && stored.value !== serialized)
|
||||||
|
) {
|
||||||
throw new WalletMaterialProofMismatchError()
|
throw new WalletMaterialProofMismatchError()
|
||||||
}
|
}
|
||||||
const ownerId = walletMaterialOwnerId(material)
|
const ownerId = walletMaterialOwnerId(material)
|
||||||
|
|||||||
Reference in New Issue
Block a user