mirror of
https://github.com/tompro/sattle.git
synced 2026-10-26 01:49:58 +00:00
feat(keys): persist BIP-32 wallet material
This commit is contained in:
@@ -0,0 +1,196 @@
|
|||||||
|
import {describe, expect, it} from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
decryptSavedWalletMaterial,
|
||||||
|
deriveWalletMaterial,
|
||||||
|
encryptSecretParts,
|
||||||
|
ensureSavedWalletMaterialOwner,
|
||||||
|
getPlainWalletMaterial,
|
||||||
|
isValidStoredWalletMaterial,
|
||||||
|
linkingPubKeyHex,
|
||||||
|
restoreWalletMaterialStored,
|
||||||
|
savedWalletMaterialExists,
|
||||||
|
savedWalletMaterialIsEncrypted,
|
||||||
|
savedWalletMaterialOwnerId,
|
||||||
|
saveWalletMaterial,
|
||||||
|
serializeWalletMaterial,
|
||||||
|
walletMaterialLinkingKey,
|
||||||
|
} from './keys'
|
||||||
|
import {parseJsonObject, stubLocalStorage} from './test-utils'
|
||||||
|
|
||||||
|
const FIXED_MNEMONIC =
|
||||||
|
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
|
||||||
|
const PASSWORD = 'correct horse battery staple'
|
||||||
|
const MATERIAL_STORAGE_KEY = 'sattle_wallet_material_v2'
|
||||||
|
const LEGACY_STORAGE_KEY = 'sattle_linking_key'
|
||||||
|
const LINKING_KEY_HEX = 'da2a752d3d28668288ff50bed644e4a95f726d8711fd8754060f0a9e378f84aa'
|
||||||
|
const CASH_ROOT_HEX =
|
||||||
|
'c7a2496e9b453a67c5d2a1f04936ec1259440d45454c795a99a66269e4cd3005111e1cc966fca2fe32f054f14caceab90449e536d94cf6935ea12a087e414f60'
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WalletMaterialV2 persistence', () => {
|
||||||
|
it('round-trips canonical plaintext material in the new namespace', async () => {
|
||||||
|
// Given complete wallet material and an unrelated legacy record
|
||||||
|
stubLocalStorage()
|
||||||
|
localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify({enc: false, value: '07'.repeat(32)}))
|
||||||
|
const material = deriveWalletMaterial(FIXED_MNEMONIC)
|
||||||
|
|
||||||
|
// When the material is saved without a password
|
||||||
|
await saveWalletMaterial(material)
|
||||||
|
|
||||||
|
// Then only the new namespace carries the exact complete material
|
||||||
|
expect(savedWalletMaterialExists()).toBe(true)
|
||||||
|
expect(savedWalletMaterialIsEncrypted()).toBe(false)
|
||||||
|
expect(getPlainWalletMaterial()).toEqual(material)
|
||||||
|
expect(readRawMaterialRecord()).toEqual({
|
||||||
|
enc: false,
|
||||||
|
value: serializeWalletMaterial(material),
|
||||||
|
ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)),
|
||||||
|
version: 2,
|
||||||
|
})
|
||||||
|
expect(localStorage.getItem(LEGACY_STORAGE_KEY)).not.toBeNull()
|
||||||
|
expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).not.toContain(FIXED_MNEMONIC)
|
||||||
|
expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).not.toContain('"seed"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('round-trips canonical password-encrypted material without plaintext fields', async () => {
|
||||||
|
// Given complete wallet material
|
||||||
|
stubLocalStorage()
|
||||||
|
const material = deriveWalletMaterial(FIXED_MNEMONIC)
|
||||||
|
|
||||||
|
// When the material is password protected
|
||||||
|
await saveWalletMaterial(material, PASSWORD)
|
||||||
|
|
||||||
|
// Then only authenticated decryption returns both parts
|
||||||
|
expect(savedWalletMaterialIsEncrypted()).toBe(true)
|
||||||
|
expect(getPlainWalletMaterial()).toBeNull()
|
||||||
|
expect(await decryptSavedWalletMaterial(PASSWORD)).toEqual(material)
|
||||||
|
await expect(decryptSavedWalletMaterial('wrong password')).rejects.toThrow()
|
||||||
|
const raw = JSON.stringify(readRawMaterialRecord())
|
||||||
|
expect(raw).not.toContain(LINKING_KEY_HEX)
|
||||||
|
expect(raw).not.toContain(CASH_ROOT_HEX)
|
||||||
|
expect(raw).not.toContain(FIXED_MNEMONIC)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('strips restored owner claims until the material proves its real owner', () => {
|
||||||
|
// Given plaintext material carrying a foreign owner claim
|
||||||
|
stubLocalStorage()
|
||||||
|
const material = deriveWalletMaterial(FIXED_MNEMONIC)
|
||||||
|
const foreignOwner = linkingPubKeyHex(new Uint8Array(32).fill(9))
|
||||||
|
|
||||||
|
// When the record is restored and subsequently proven by plaintext unlock
|
||||||
|
restoreWalletMaterialStored({
|
||||||
|
enc: false,
|
||||||
|
value: serializeWalletMaterial(material),
|
||||||
|
ownerId: foreignOwner,
|
||||||
|
version: 2,
|
||||||
|
})
|
||||||
|
const beforeProof = readRawMaterialRecord()
|
||||||
|
const unlocked = getPlainWalletMaterial()
|
||||||
|
if (unlocked === null) throw new Error('expected restored plaintext material')
|
||||||
|
ensureSavedWalletMaterialOwner(unlocked)
|
||||||
|
|
||||||
|
// Then the claim was absent before proof and only the derived owner was stamped
|
||||||
|
expect(beforeProof).toEqual({enc: false, value: serializeWalletMaterial(material)})
|
||||||
|
expect(savedWalletMaterialOwnerId()).toBe(linkingPubKeyHex(walletMaterialLinkingKey(material)))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a plaintext owner mismatch without returning or stamping material', () => {
|
||||||
|
// Given canonical material stamped with a foreign current owner
|
||||||
|
stubLocalStorage()
|
||||||
|
const material = deriveWalletMaterial(FIXED_MNEMONIC)
|
||||||
|
localStorage.setItem(
|
||||||
|
MATERIAL_STORAGE_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
enc: false,
|
||||||
|
value: serializeWalletMaterial(material),
|
||||||
|
ownerId: linkingPubKeyHex(new Uint8Array(32).fill(9)),
|
||||||
|
version: 2,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const before = localStorage.getItem(MATERIAL_STORAGE_KEY)
|
||||||
|
|
||||||
|
// When it is read or offered for proof
|
||||||
|
const unlocked = getPlainWalletMaterial()
|
||||||
|
const stamp = () => ensureSavedWalletMaterialOwner(material)
|
||||||
|
|
||||||
|
// Then neither operation trusts or rewrites the foreign record
|
||||||
|
expect(unlocked).toBeNull()
|
||||||
|
expect(stamp).toThrow('different wallet')
|
||||||
|
expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).toBe(before)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects an encrypted owner mismatch after authentication', async () => {
|
||||||
|
// Given valid encrypted material whose owner marker was replaced
|
||||||
|
stubLocalStorage()
|
||||||
|
const material = deriveWalletMaterial(FIXED_MNEMONIC)
|
||||||
|
await saveWalletMaterial(material, PASSWORD)
|
||||||
|
const foreign = {
|
||||||
|
...readRawMaterialRecord(),
|
||||||
|
ownerId: linkingPubKeyHex(new Uint8Array(32).fill(9)),
|
||||||
|
}
|
||||||
|
localStorage.setItem(MATERIAL_STORAGE_KEY, JSON.stringify(foreign))
|
||||||
|
const before = localStorage.getItem(MATERIAL_STORAGE_KEY)
|
||||||
|
|
||||||
|
// When the authenticated material contradicts the current owner marker
|
||||||
|
const unlock = decryptSavedWalletMaterial(PASSWORD)
|
||||||
|
|
||||||
|
// Then no material is returned or marker changed
|
||||||
|
await expect(unlock).rejects.toThrow('different wallet')
|
||||||
|
expect(localStorage.getItem(MATERIAL_STORAGE_KEY)).toBe(before)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['legacy key-only plaintext', LINKING_KEY_HEX, 2],
|
||||||
|
['wrong outer version', serializeWalletMaterial(deriveWalletMaterial(FIXED_MNEMONIC)), 1],
|
||||||
|
[
|
||||||
|
'malformed material plaintext',
|
||||||
|
JSON.stringify({version: 2, linkingKeyHex: LINKING_KEY_HEX, cashRootHex: '00'}),
|
||||||
|
2,
|
||||||
|
],
|
||||||
|
])('rejects %s records at the storage boundary', (_name, value, version) => {
|
||||||
|
// Given an invalid record under the v2 material namespace
|
||||||
|
stubLocalStorage()
|
||||||
|
const material = deriveWalletMaterial(FIXED_MNEMONIC)
|
||||||
|
const record = {
|
||||||
|
enc: false,
|
||||||
|
value,
|
||||||
|
ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)),
|
||||||
|
version,
|
||||||
|
}
|
||||||
|
localStorage.setItem(MATERIAL_STORAGE_KEY, JSON.stringify(record))
|
||||||
|
|
||||||
|
// When storage helpers parse it
|
||||||
|
// Then the record is unusable as wallet material
|
||||||
|
expect(isValidStoredWalletMaterial(record)).toBe(false)
|
||||||
|
expect(savedWalletMaterialExists()).toBe(false)
|
||||||
|
expect(getPlainWalletMaterial()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects authenticated ciphertext containing malformed material', async () => {
|
||||||
|
// Given valid AES-GCM parts whose plaintext is not a v2 material envelope
|
||||||
|
stubLocalStorage()
|
||||||
|
const parts = await encryptSecretParts(LINKING_KEY_HEX, PASSWORD)
|
||||||
|
const material = deriveWalletMaterial(FIXED_MNEMONIC)
|
||||||
|
localStorage.setItem(
|
||||||
|
MATERIAL_STORAGE_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
enc: true,
|
||||||
|
...parts,
|
||||||
|
ownerId: linkingPubKeyHex(walletMaterialLinkingKey(material)),
|
||||||
|
version: 2,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// When the password successfully authenticates the malformed plaintext
|
||||||
|
const unlock = decryptSavedWalletMaterial(PASSWORD)
|
||||||
|
|
||||||
|
// Then it still fails closed as invalid material
|
||||||
|
await expect(unlock).rejects.toThrow('valid v2 wallet material')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import {describe, expect, it} from 'vitest'
|
||||||
|
import {bytesToHex} from '@noble/hashes/utils.js'
|
||||||
|
import {getPublicKey} from 'nostr-tools/pure'
|
||||||
|
import {cashNodeToHex} from 'lnurlcash-kit'
|
||||||
|
|
||||||
|
import {
|
||||||
|
deriveBearerAesKey,
|
||||||
|
deriveWalletLinkingKey,
|
||||||
|
deriveWalletMaterial,
|
||||||
|
linkingPubKeyHex,
|
||||||
|
parseWalletMaterial,
|
||||||
|
serializeWalletMaterial,
|
||||||
|
walletMaterialCashRoot,
|
||||||
|
walletMaterialLinkingKey,
|
||||||
|
} from './keys'
|
||||||
|
import {backupPubkey, deriveBackupKey} from './nostrBackup'
|
||||||
|
import {deriveNwcWalletKey, nwcWalletPubkey} from './nwc'
|
||||||
|
|
||||||
|
const FIXED_MNEMONIC =
|
||||||
|
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
|
||||||
|
const CLIENT_PUBKEY = getPublicKey(new Uint8Array(32).fill(17))
|
||||||
|
const LINKING_KEY_HEX = 'da2a752d3d28668288ff50bed644e4a95f726d8711fd8754060f0a9e378f84aa'
|
||||||
|
const CASH_ROOT_HEX =
|
||||||
|
'c7a2496e9b453a67c5d2a1f04936ec1259440d45454c795a99a66269e4cd3005111e1cc966fca2fe32f054f14caceab90449e536d94cf6935ea12a087e414f60'
|
||||||
|
|
||||||
|
describe('fixed mnemonic identity characterization', () => {
|
||||||
|
it('pins every public linking-key-derived identity and bearer encryption behavior', async () => {
|
||||||
|
// Given a mnemonic accepted by the existing wallet derivation
|
||||||
|
const linkingKey = deriveWalletLinkingKey(FIXED_MNEMONIC)
|
||||||
|
const bearerKey = await deriveBearerAesKey(linkingKey)
|
||||||
|
|
||||||
|
// When its owner, bearer, Nostr, and NWC observables are derived
|
||||||
|
const bearerCiphertext = new Uint8Array(
|
||||||
|
await crypto.subtle.encrypt(
|
||||||
|
{name: 'AES-GCM', iv: new Uint8Array(12)},
|
||||||
|
bearerKey,
|
||||||
|
new TextEncoder().encode('sattle-vector-v1'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Then they match the pre-v2 wallet's independently captured vectors
|
||||||
|
expect(linkingPubKeyHex(linkingKey)).toBe(
|
||||||
|
'0249f93654b6db2c0c4194c2fbc5eb54ff36f055e82bb867321a4c7ec7f7c31481',
|
||||||
|
)
|
||||||
|
expect(bytesToHex(bearerCiphertext)).toBe(
|
||||||
|
'4caf30a7c3fdb53ad3edefb34f202a67a2e969f267db3a5842ee6cfd23032e78',
|
||||||
|
)
|
||||||
|
expect(backupPubkey(deriveBackupKey(linkingKey))).toBe(
|
||||||
|
'6b6dc0633162e1985316d06edb85a99cc31f89b9c42d350ffdcf57853b875577',
|
||||||
|
)
|
||||||
|
expect(nwcWalletPubkey(deriveNwcWalletKey(linkingKey, CLIENT_PUBKEY))).toBe(
|
||||||
|
'3aa81f13d0dbccba0df5772abccda43c9a51c1dc99ea9a76090cada33b9fc587',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('WalletMaterialV2 derivation and serialization', () => {
|
||||||
|
it('derives the unchanged linking key and kit cash root from one mnemonic', () => {
|
||||||
|
// Given the fixed recovery mnemonic
|
||||||
|
// When its complete wallet material is derived
|
||||||
|
const material = deriveWalletMaterial(FIXED_MNEMONIC)
|
||||||
|
|
||||||
|
// Then both independently captured vectors and the exact schema are stable
|
||||||
|
expect(material).toEqual({
|
||||||
|
version: 2,
|
||||||
|
linkingKeyHex: LINKING_KEY_HEX,
|
||||||
|
cashRootHex: CASH_ROOT_HEX,
|
||||||
|
})
|
||||||
|
expect(Object.keys(material)).toEqual(['version', 'linkingKeyHex', 'cashRootHex'])
|
||||||
|
expect(walletMaterialLinkingKey(material)).toEqual(deriveWalletLinkingKey(FIXED_MNEMONIC))
|
||||||
|
expect(cashNodeToHex(walletMaterialCashRoot(material))).toBe(CASH_ROOT_HEX)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('round-trips only the canonical exact serialization', () => {
|
||||||
|
// Given valid v2 material
|
||||||
|
const material = deriveWalletMaterial(FIXED_MNEMONIC)
|
||||||
|
|
||||||
|
// When it crosses the serialization boundary
|
||||||
|
const serialized = serializeWalletMaterial(material)
|
||||||
|
|
||||||
|
// Then its bytes and parsed value are canonical
|
||||||
|
expect(serialized).toBe(
|
||||||
|
`{"version":2,"linkingKeyHex":"${LINKING_KEY_HEX}","cashRootHex":"${CASH_ROOT_HEX}"}`,
|
||||||
|
)
|
||||||
|
expect(parseWalletMaterial(serialized)).toEqual(material)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['legacy key only', LINKING_KEY_HEX],
|
||||||
|
[
|
||||||
|
'wrong version',
|
||||||
|
JSON.stringify({version: 1, linkingKeyHex: LINKING_KEY_HEX, cashRootHex: CASH_ROOT_HEX}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'missing field',
|
||||||
|
JSON.stringify({version: 2, linkingKeyHex: LINKING_KEY_HEX}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'extra field',
|
||||||
|
JSON.stringify({
|
||||||
|
version: 2,
|
||||||
|
linkingKeyHex: LINKING_KEY_HEX,
|
||||||
|
cashRootHex: CASH_ROOT_HEX,
|
||||||
|
seed: 'forbidden',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'uppercase linking key',
|
||||||
|
JSON.stringify({
|
||||||
|
version: 2,
|
||||||
|
linkingKeyHex: LINKING_KEY_HEX.toUpperCase(),
|
||||||
|
cashRootHex: CASH_ROOT_HEX,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'truncated linking key',
|
||||||
|
JSON.stringify({
|
||||||
|
version: 2,
|
||||||
|
linkingKeyHex: LINKING_KEY_HEX.slice(2),
|
||||||
|
cashRootHex: CASH_ROOT_HEX,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'uppercase cash root',
|
||||||
|
JSON.stringify({
|
||||||
|
version: 2,
|
||||||
|
linkingKeyHex: LINKING_KEY_HEX,
|
||||||
|
cashRootHex: CASH_ROOT_HEX.toUpperCase(),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'truncated cash root',
|
||||||
|
JSON.stringify({
|
||||||
|
version: 2,
|
||||||
|
linkingKeyHex: LINKING_KEY_HEX,
|
||||||
|
cashRootHex: CASH_ROOT_HEX.slice(2),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'invalid cash root private key',
|
||||||
|
JSON.stringify({
|
||||||
|
version: 2,
|
||||||
|
linkingKeyHex: LINKING_KEY_HEX,
|
||||||
|
cashRootHex: '00'.repeat(32) + CASH_ROOT_HEX.slice(64),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'non-canonical field order',
|
||||||
|
JSON.stringify({cashRootHex: CASH_ROOT_HEX, linkingKeyHex: LINKING_KEY_HEX, version: 2}),
|
||||||
|
],
|
||||||
|
])('rejects %s', (_name, serialized) => {
|
||||||
|
// Given malformed or non-canonical serialized material
|
||||||
|
// When it crosses the parser
|
||||||
|
const parsed = parseWalletMaterial(serialized)
|
||||||
|
|
||||||
|
// Then no trusted material is returned
|
||||||
|
expect(parsed).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
+40
-73
@@ -5,6 +5,7 @@ import {hmac} from '@noble/hashes/hmac.js'
|
|||||||
import {sha256} from '@noble/hashes/sha2.js'
|
import {sha256} from '@noble/hashes/sha2.js'
|
||||||
import {secp256k1} from '@noble/curves/secp256k1.js'
|
import {secp256k1} from '@noble/curves/secp256k1.js'
|
||||||
import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js'
|
import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js'
|
||||||
|
import {cashNodeFromHex, cashNodeToHex, deriveCashRoot, type CashNode} from 'lnurlcash-kit'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
parseStoredSecret,
|
parseStoredSecret,
|
||||||
@@ -14,11 +15,21 @@ import {
|
|||||||
stripStoredSecretOwner,
|
stripStoredSecretOwner,
|
||||||
STORED_SECRET_VERSION,
|
STORED_SECRET_VERSION,
|
||||||
type StoredSecret,
|
type StoredSecret,
|
||||||
|
type WalletMaterialV2,
|
||||||
} from './storage/storedSecret'
|
} from './storage/storedSecret'
|
||||||
|
import {decryptSecretParts, encryptSecretParts} from './passwordWrap'
|
||||||
import {LINKING_KEY_STORAGE_KEY} from './storage/walletOwnerEvents'
|
import {LINKING_KEY_STORAGE_KEY} from './storage/walletOwnerEvents'
|
||||||
|
|
||||||
export {isValidStoredSecret} from './storage/storedSecret'
|
export {
|
||||||
export type {StoredSecret} from './storage/storedSecret'
|
isValidStoredSecret,
|
||||||
|
isValidStoredWalletMaterial,
|
||||||
|
parseWalletMaterial,
|
||||||
|
serializeWalletMaterial,
|
||||||
|
} from './storage/storedSecret'
|
||||||
|
export type {StoredSecret, StoredWalletMaterial, WalletMaterialV2} from './storage/storedSecret'
|
||||||
|
export {decryptSecretParts, encryptSecretParts} from './passwordWrap'
|
||||||
|
export type {EncryptedSecretParts} from './passwordWrap'
|
||||||
|
export * from './walletMaterialStorage'
|
||||||
|
|
||||||
// The wallet's identity is derived against this fixed domain rather than
|
// The wallet's identity is derived against this fixed domain rather than
|
||||||
// window.location.hostname, so the same seed phrase always yields the same
|
// window.location.hostname, so the same seed phrase always yields the same
|
||||||
@@ -40,8 +51,7 @@ const readUint32BE = (bytes: Uint8Array, offset: number): number =>
|
|||||||
|
|
||||||
// LUD-05: BIP32-based linking-key derivation, same scheme as lnurl_server -
|
// LUD-05: BIP32-based linking-key derivation, same scheme as lnurl_server -
|
||||||
// a seed restored there or here produces the same identity for a given domain
|
// a seed restored there or here produces the same identity for a given domain
|
||||||
export const deriveLud05LinkingKey = (seedPhrase: string, domain: string): Uint8Array => {
|
const deriveLud05LinkingKeyFromSeed = (seed: Uint8Array, domain: string): Uint8Array => {
|
||||||
const seed = mnemonicToSeedSync(seedPhrase.trim().toLowerCase())
|
|
||||||
const master = HDKey.fromMasterSeed(seed)
|
const master = HDKey.fromMasterSeed(seed)
|
||||||
|
|
||||||
const hashingKeyNode = master.derive("m/138'/0")
|
const hashingKeyNode = master.derive("m/138'/0")
|
||||||
@@ -58,6 +68,9 @@ export const deriveLud05LinkingKey = (seedPhrase: string, domain: string): Uint8
|
|||||||
return node.privateKey
|
return node.privateKey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const deriveLud05LinkingKey = (seedPhrase: string, domain: string): Uint8Array =>
|
||||||
|
deriveLud05LinkingKeyFromSeed(mnemonicToSeedSync(seedPhrase.trim().toLowerCase()), domain)
|
||||||
|
|
||||||
// the HMAC half of the derivation, split out so the LUD-05 test vector
|
// the HMAC half of the derivation, split out so the LUD-05 test vector
|
||||||
// (which starts from a fixed hashingPrivKey, not a seed phrase) can pin it
|
// (which starts from a fixed hashingPrivKey, not a seed phrase) can pin it
|
||||||
// directly - see keys.test.ts
|
// directly - see keys.test.ts
|
||||||
@@ -69,15 +82,28 @@ export const lud05PathSuffix = (hashingKey: Uint8Array, domain: string): number[
|
|||||||
export const deriveWalletLinkingKey = (seedPhrase: string): Uint8Array =>
|
export const deriveWalletLinkingKey = (seedPhrase: string): Uint8Array =>
|
||||||
deriveLud05LinkingKey(seedPhrase, WALLET_DOMAIN)
|
deriveLud05LinkingKey(seedPhrase, WALLET_DOMAIN)
|
||||||
|
|
||||||
|
export const deriveWalletMaterial = (seedPhrase: string): WalletMaterialV2 => {
|
||||||
|
const seed = mnemonicToSeedSync(seedPhrase.trim().toLowerCase())
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
version: 2,
|
||||||
|
linkingKeyHex: bytesToHex(deriveLud05LinkingKeyFromSeed(seed, WALLET_DOMAIN)),
|
||||||
|
cashRootHex: cashNodeToHex(deriveCashRoot(seed)),
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
seed.fill(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const walletMaterialLinkingKey = (material: WalletMaterialV2): Uint8Array =>
|
||||||
|
hexToBytes(material.linkingKeyHex)
|
||||||
|
|
||||||
|
export const walletMaterialCashRoot = (material: WalletMaterialV2): CashNode =>
|
||||||
|
cashNodeFromHex(material.cashRootHex)
|
||||||
|
|
||||||
export const linkingPubKeyHex = (linkingPrivKey: Uint8Array): string =>
|
export const linkingPubKeyHex = (linkingPrivKey: Uint8Array): string =>
|
||||||
bytesToHex(secp256k1.getPublicKey(linkingPrivKey, true))
|
bytesToHex(secp256k1.getPublicKey(linkingPrivKey, true))
|
||||||
|
|
||||||
// Encrypted-at-rest localStorage secret, same shape as lnurl_server's: the
|
|
||||||
// stored value is either plaintext or, if the holder opted in with a
|
|
||||||
// password, AES-GCM ciphertext keyed by a PBKDF2 stretch of that password -
|
|
||||||
// GCM's auth tag doubles as the "wrong password" check on decrypt.
|
|
||||||
const PBKDF2_ITERATIONS = 210_000
|
|
||||||
|
|
||||||
const readSecret = (storageKey: string): StoredSecret | null => {
|
const readSecret = (storageKey: string): StoredSecret | null => {
|
||||||
const raw = localStorage.getItem(storageKey)
|
const raw = localStorage.getItem(storageKey)
|
||||||
if (!raw) return null
|
if (!raw) return null
|
||||||
@@ -89,69 +115,10 @@ const readSecret = (storageKey: string): StoredSecret | null => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const deriveAesKeyFromPassword = (password: string, salt: Uint8Array): Promise<CryptoKey> =>
|
// The legacy alpha lifecycle below persists only the linking key under its old
|
||||||
crypto.subtle
|
// namespace. Task 5 replaces that lifecycle with the v2 material repository;
|
||||||
.importKey('raw', utf8ToBytes(password), 'PBKDF2', false, ['deriveKey'])
|
// these APIs stay byte-compatible until the serialized reset can switch every
|
||||||
.then((baseKey) =>
|
// caller atomically rather than creating a split-key wallet.
|
||||||
crypto.subtle.deriveKey(
|
|
||||||
// the copy pins the TS type to Uint8Array<ArrayBuffer> - hexToBytes
|
|
||||||
// returns Uint8Array<ArrayBufferLike>, which BufferSource rejects
|
|
||||||
{
|
|
||||||
name: 'PBKDF2',
|
|
||||||
salt: new Uint8Array(salt),
|
|
||||||
iterations: PBKDF2_ITERATIONS,
|
|
||||||
hash: 'SHA-256',
|
|
||||||
},
|
|
||||||
baseKey,
|
|
||||||
{name: 'AES-GCM', length: 256},
|
|
||||||
false,
|
|
||||||
['encrypt', 'decrypt'],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
export type EncryptedSecretParts = {
|
|
||||||
salt: string
|
|
||||||
iv: string
|
|
||||||
ciphertext: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const encryptSecretParts = async (
|
|
||||||
value: string,
|
|
||||||
password: string,
|
|
||||||
): Promise<EncryptedSecretParts> => {
|
|
||||||
const salt = crypto.getRandomValues(new Uint8Array(16))
|
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(12))
|
|
||||||
const aesKey = await deriveAesKeyFromPassword(password, salt)
|
|
||||||
const ciphertext = new Uint8Array(
|
|
||||||
await crypto.subtle.encrypt({name: 'AES-GCM', iv}, aesKey, utf8ToBytes(value)),
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
salt: bytesToHex(salt),
|
|
||||||
iv: bytesToHex(iv),
|
|
||||||
ciphertext: bytesToHex(ciphertext),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// rejects (WebCrypto's own auth-tag check) if the password is wrong
|
|
||||||
export const decryptSecretParts = async (
|
|
||||||
parts: EncryptedSecretParts,
|
|
||||||
password: string,
|
|
||||||
): Promise<string> => {
|
|
||||||
const salt = hexToBytes(parts.salt)
|
|
||||||
const iv = hexToBytes(parts.iv)
|
|
||||||
const aesKey = await deriveAesKeyFromPassword(password, salt)
|
|
||||||
const plaintext = await crypto.subtle.decrypt(
|
|
||||||
{name: 'AES-GCM', iv},
|
|
||||||
aesKey,
|
|
||||||
hexToBytes(parts.ciphertext),
|
|
||||||
)
|
|
||||||
return new TextDecoder().decode(plaintext)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The linking key is the only secret this wallet persists - the seed phrase
|
|
||||||
// it was derived from is shown once at setup and never stored. Everything
|
|
||||||
// else at rest (the bearer tokens) is encrypted with a key derived from it,
|
|
||||||
// so protecting this one record with a password protects the whole wallet.
|
|
||||||
//
|
//
|
||||||
// The record also carries an ownerId marker: the lowercase compressed
|
// The record also carries an ownerId marker: the lowercase compressed
|
||||||
// pubkey hex of the key itself (storage/walletOwner.ts), binding every
|
// pubkey hex of the key itself (storage/walletOwner.ts), binding every
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import {
|
|||||||
} from './keys'
|
} from './keys'
|
||||||
import {parseJsonObject, stubLocalStorage} from './test-utils'
|
import {parseJsonObject, stubLocalStorage} from './test-utils'
|
||||||
|
|
||||||
|
import './keys.material.cases'
|
||||||
|
import './keys.material-storage.cases'
|
||||||
|
|
||||||
const LINKING_KEY = new Uint8Array(32).fill(7)
|
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 STORAGE_KEY = 'sattle_linking_key'
|
const STORAGE_KEY = 'sattle_linking_key'
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Password wraps protect serialized wallet secrets with authenticated AES-GCM.
|
||||||
|
// PBKDF2 parameters and wire fields stay centralized so legacy linking-key and
|
||||||
|
// v2 wallet-material records remain interoperable with their own readers.
|
||||||
|
|
||||||
|
import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js'
|
||||||
|
|
||||||
|
const PBKDF2_ITERATIONS = 210_000
|
||||||
|
|
||||||
|
const deriveAesKeyFromPassword = (password: string, salt: Uint8Array): Promise<CryptoKey> =>
|
||||||
|
crypto.subtle
|
||||||
|
.importKey('raw', utf8ToBytes(password), 'PBKDF2', false, ['deriveKey'])
|
||||||
|
.then((baseKey) =>
|
||||||
|
crypto.subtle.deriveKey(
|
||||||
|
{
|
||||||
|
name: 'PBKDF2',
|
||||||
|
salt: new Uint8Array(salt),
|
||||||
|
iterations: PBKDF2_ITERATIONS,
|
||||||
|
hash: 'SHA-256',
|
||||||
|
},
|
||||||
|
baseKey,
|
||||||
|
{name: 'AES-GCM', length: 256},
|
||||||
|
false,
|
||||||
|
['encrypt', 'decrypt'],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
export type EncryptedSecretParts = {
|
||||||
|
readonly salt: string
|
||||||
|
readonly iv: string
|
||||||
|
readonly ciphertext: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const encryptSecretParts = async (
|
||||||
|
value: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<EncryptedSecretParts> => {
|
||||||
|
const salt = crypto.getRandomValues(new Uint8Array(16))
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(12))
|
||||||
|
const aesKey = await deriveAesKeyFromPassword(password, salt)
|
||||||
|
const ciphertext = new Uint8Array(
|
||||||
|
await crypto.subtle.encrypt({name: 'AES-GCM', iv}, aesKey, utf8ToBytes(value)),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
salt: bytesToHex(salt),
|
||||||
|
iv: bytesToHex(iv),
|
||||||
|
ciphertext: bytesToHex(ciphertext),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const decryptSecretParts = async (
|
||||||
|
parts: EncryptedSecretParts,
|
||||||
|
password: string,
|
||||||
|
): Promise<string> => {
|
||||||
|
const salt = hexToBytes(parts.salt)
|
||||||
|
const iv = hexToBytes(parts.iv)
|
||||||
|
const aesKey = await deriveAesKeyFromPassword(password, salt)
|
||||||
|
const plaintext = await crypto.subtle.decrypt(
|
||||||
|
{name: 'AES-GCM', iv},
|
||||||
|
aesKey,
|
||||||
|
hexToBytes(parts.ciphertext),
|
||||||
|
)
|
||||||
|
return new TextDecoder().decode(plaintext)
|
||||||
|
}
|
||||||
@@ -1,13 +1,23 @@
|
|||||||
// Saved linking-key records have two safe at-rest forms: ownerless legacy
|
// Saved secret records bind either the legacy linking key or canonical v2
|
||||||
// records and version-1 owner-bearing records. The briefly shipped
|
// wallet material to a proven owner. The v2 parser accepts only one exact,
|
||||||
// unversioned owner-bearing shape remains readable only so a proven key can
|
// canonical material encoding so hostile storage cannot smuggle a legacy key,
|
||||||
// upgrade it; it never establishes ownership by itself. Any other metadata
|
// a future field, or a malformed BIP-32 root across the unlock boundary.
|
||||||
// is rejected rather than downgraded to adoptable legacy data.
|
|
||||||
|
|
||||||
|
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'
|
||||||
|
|
||||||
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 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
|
||||||
@@ -26,6 +36,7 @@ type EncryptedStoredSecret = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type StoredSecret = PlainStoredSecret | EncryptedStoredSecret
|
export type StoredSecret = PlainStoredSecret | EncryptedStoredSecret
|
||||||
|
export type StoredWalletMaterial = StoredSecret
|
||||||
|
|
||||||
type ParsedStoredSecret = {
|
type ParsedStoredSecret = {
|
||||||
readonly secret: StoredSecret
|
readonly secret: StoredSecret
|
||||||
@@ -35,17 +46,76 @@ type ParsedStoredSecret = {
|
|||||||
|
|
||||||
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 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))
|
||||||
|
|
||||||
export const parseStoredSecret = (stored: unknown): ParsedStoredSecret | null => {
|
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 = {
|
||||||
|
readonly version: number
|
||||||
|
readonly acceptsPlaintext: (value: string) => boolean
|
||||||
|
readonly acceptsUnversionedOwner: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseStoredSecretWith = (
|
||||||
|
stored: unknown,
|
||||||
|
options: StoredSecretParserOptions,
|
||||||
|
): ParsedStoredSecret | null => {
|
||||||
if (!isJsonObject(stored)) return null
|
if (!isJsonObject(stored)) return null
|
||||||
let secret: StoredSecret
|
let secret: StoredSecret
|
||||||
if (stored.enc === false) {
|
if (stored.enc === false) {
|
||||||
if (
|
if (
|
||||||
typeof stored.value !== 'string' ||
|
typeof stored.value !== 'string' ||
|
||||||
!/^[0-9a-f]{64}$/i.test(stored.value) ||
|
!options.acceptsPlaintext(stored.value) ||
|
||||||
!hasOnlyKeys(stored, PLAIN_KEYS)
|
!hasOnlyKeys(stored, PLAIN_KEYS)
|
||||||
) {
|
) {
|
||||||
return null
|
return null
|
||||||
@@ -80,23 +150,41 @@ export const parseStoredSecret = (stored: unknown): ParsedStoredSecret | null =>
|
|||||||
if (!hasOwner && !hasVersion) return {secret, claimedOwnerId: null, isCurrent: false}
|
if (!hasOwner && !hasVersion) return {secret, claimedOwnerId: null, isCurrent: false}
|
||||||
if (!isWalletOwnerId(stored.ownerId)) return null
|
if (!isWalletOwnerId(stored.ownerId)) return null
|
||||||
if (!hasVersion) {
|
if (!hasVersion) {
|
||||||
|
if (!options.acceptsUnversionedOwner) return null
|
||||||
return {
|
return {
|
||||||
secret: {...secret, ownerId: stored.ownerId},
|
secret: {...secret, ownerId: stored.ownerId},
|
||||||
claimedOwnerId: stored.ownerId,
|
claimedOwnerId: stored.ownerId,
|
||||||
isCurrent: false,
|
isCurrent: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (stored.version !== STORED_SECRET_VERSION) return null
|
if (stored.version !== options.version) return null
|
||||||
return {
|
return {
|
||||||
secret: {...secret, ownerId: stored.ownerId, version: STORED_SECRET_VERSION},
|
secret: {...secret, ownerId: stored.ownerId, version: options.version},
|
||||||
claimedOwnerId: stored.ownerId,
|
claimedOwnerId: stored.ownerId,
|
||||||
isCurrent: true,
|
isCurrent: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const parseStoredSecret = (stored: unknown): ParsedStoredSecret | null =>
|
||||||
|
parseStoredSecretWith(stored, {
|
||||||
|
version: STORED_SECRET_VERSION,
|
||||||
|
acceptsPlaintext: (value) => /^[0-9a-f]{64}$/i.test(value),
|
||||||
|
acceptsUnversionedOwner: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const parseStoredWalletMaterial = (stored: unknown): ParsedStoredSecret | null =>
|
||||||
|
parseStoredSecretWith(stored, {
|
||||||
|
version: STORED_WALLET_MATERIAL_VERSION,
|
||||||
|
acceptsPlaintext: (value) => parseWalletMaterial(value) !== null,
|
||||||
|
acceptsUnversionedOwner: false,
|
||||||
|
})
|
||||||
|
|
||||||
export const isValidStoredSecret = (stored: unknown): stored is StoredSecret =>
|
export const isValidStoredSecret = (stored: unknown): stored is StoredSecret =>
|
||||||
parseStoredSecret(stored) !== null
|
parseStoredSecret(stored) !== null
|
||||||
|
|
||||||
|
export const isValidStoredWalletMaterial = (stored: unknown): stored is StoredWalletMaterial =>
|
||||||
|
parseStoredWalletMaterial(stored) !== null
|
||||||
|
|
||||||
export const storedSecretOwnerId = (stored: StoredSecret): string | null => {
|
export const storedSecretOwnerId = (stored: StoredSecret): string | null => {
|
||||||
const parsed = parseStoredSecret(stored)
|
const parsed = parseStoredSecret(stored)
|
||||||
return parsed?.isCurrent === true ? parsed.claimedOwnerId : null
|
return parsed?.isCurrent === true ? parsed.claimedOwnerId : null
|
||||||
@@ -105,6 +193,15 @@ export const storedSecretOwnerId = (stored: StoredSecret): string | null => {
|
|||||||
export const storedSecretClaimedOwnerId = (stored: StoredSecret): string | null =>
|
export const storedSecretClaimedOwnerId = (stored: StoredSecret): string | null =>
|
||||||
parseStoredSecret(stored)?.claimedOwnerId ?? null
|
parseStoredSecret(stored)?.claimedOwnerId ?? null
|
||||||
|
|
||||||
|
export const storedWalletMaterialOwnerId = (stored: StoredWalletMaterial): string | null => {
|
||||||
|
const parsed = parseStoredWalletMaterial(stored)
|
||||||
|
return parsed?.isCurrent === true ? parsed.claimedOwnerId : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const storedWalletMaterialClaimedOwnerId = (
|
||||||
|
stored: StoredWalletMaterial,
|
||||||
|
): string | null => parseStoredWalletMaterial(stored)?.claimedOwnerId ?? null
|
||||||
|
|
||||||
export const stampStoredSecretOwner = (stored: StoredSecret, ownerId: string): StoredSecret => {
|
export const stampStoredSecretOwner = (stored: StoredSecret, ownerId: string): StoredSecret => {
|
||||||
if (stored.enc === false) {
|
if (stored.enc === false) {
|
||||||
return {enc: false, value: stored.value, ownerId, version: STORED_SECRET_VERSION}
|
return {enc: false, value: stored.value, ownerId, version: STORED_SECRET_VERSION}
|
||||||
@@ -119,6 +216,28 @@ export const stampStoredSecretOwner = (stored: StoredSecret, ownerId: string): S
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const stampStoredWalletMaterialOwner = (
|
||||||
|
stored: StoredWalletMaterial,
|
||||||
|
ownerId: string,
|
||||||
|
): StoredWalletMaterial => {
|
||||||
|
if (stored.enc === false) {
|
||||||
|
return {
|
||||||
|
enc: false,
|
||||||
|
value: stored.value,
|
||||||
|
ownerId,
|
||||||
|
version: STORED_WALLET_MATERIAL_VERSION,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
enc: true,
|
||||||
|
salt: stored.salt,
|
||||||
|
iv: stored.iv,
|
||||||
|
ciphertext: stored.ciphertext,
|
||||||
|
ownerId,
|
||||||
|
version: STORED_WALLET_MATERIAL_VERSION,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const stripStoredSecretOwner = (stored: StoredSecret): StoredSecret => {
|
export const stripStoredSecretOwner = (stored: StoredSecret): StoredSecret => {
|
||||||
if (stored.enc === false) return {enc: false, value: stored.value}
|
if (stored.enc === false) return {enc: false, value: stored.value}
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// Their payload may be stale when writes arrive faster than delivery.
|
// Their payload may be stale when writes arrive faster than delivery.
|
||||||
|
|
||||||
export const LINKING_KEY_STORAGE_KEY = 'sattle_linking_key'
|
export const LINKING_KEY_STORAGE_KEY = 'sattle_linking_key'
|
||||||
|
export const WALLET_MATERIAL_STORAGE_KEY = 'sattle_wallet_material_v2'
|
||||||
|
|
||||||
// Each subscription binds its own storage listener and removes exactly it on
|
// Each subscription binds its own storage listener and removes exactly it on
|
||||||
// unsubscribe: no shared reference counting, so an abandoned subscriber can
|
// unsubscribe: no shared reference counting, so an abandoned subscriber can
|
||||||
@@ -9,7 +10,13 @@ export const LINKING_KEY_STORAGE_KEY = 'sattle_linking_key'
|
|||||||
export const onSavedKeyStorageChange = (listener: () => void): (() => void) => {
|
export const onSavedKeyStorageChange = (listener: () => void): (() => void) => {
|
||||||
const target = typeof window === 'undefined' ? null : window
|
const target = typeof window === 'undefined' ? null : window
|
||||||
const onStorage = (event: StorageEvent): void => {
|
const onStorage = (event: StorageEvent): void => {
|
||||||
if (event.key !== LINKING_KEY_STORAGE_KEY && event.key !== null) return
|
if (
|
||||||
|
event.key !== LINKING_KEY_STORAGE_KEY &&
|
||||||
|
event.key !== WALLET_MATERIAL_STORAGE_KEY &&
|
||||||
|
event.key !== null
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
listener()
|
listener()
|
||||||
}
|
}
|
||||||
target?.addEventListener('storage', onStorage)
|
target?.addEventListener('storage', onStorage)
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
// The v2 wallet-material repository owns one authenticated serialization under
|
||||||
|
// a new namespace. Owner markers remain claims on restore and become trusted
|
||||||
|
// only after plaintext or password proof reproduces the linking-key owner.
|
||||||
|
|
||||||
|
import {
|
||||||
|
parseStoredWalletMaterial,
|
||||||
|
parseWalletMaterial,
|
||||||
|
serializeWalletMaterial,
|
||||||
|
stampStoredWalletMaterialOwner,
|
||||||
|
storedWalletMaterialClaimedOwnerId,
|
||||||
|
storedWalletMaterialOwnerId,
|
||||||
|
stripStoredSecretOwner,
|
||||||
|
walletMaterialOwnerId,
|
||||||
|
STORED_WALLET_MATERIAL_VERSION,
|
||||||
|
} from './storage/storedSecret'
|
||||||
|
import type {StoredWalletMaterial, WalletMaterialV2} from './storage/storedSecret'
|
||||||
|
import {decryptSecretParts, encryptSecretParts} from './passwordWrap'
|
||||||
|
import {WALLET_MATERIAL_STORAGE_KEY} from './storage/walletOwnerEvents'
|
||||||
|
|
||||||
|
export class InvalidWalletMaterialError extends Error {
|
||||||
|
override readonly name = 'InvalidWalletMaterialError'
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super('Saved plaintext is not valid v2 wallet material.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WalletMaterialOwnerMismatchError extends Error {
|
||||||
|
override readonly name = 'WalletMaterialOwnerMismatchError'
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super('Saved wallet material is owned by a different wallet.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WalletMaterialProofMismatchError extends Error {
|
||||||
|
override readonly name = 'WalletMaterialProofMismatchError'
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super('Proven material does not match the saved wallet material.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NoEncryptedWalletMaterialError extends Error {
|
||||||
|
override readonly name = 'NoEncryptedWalletMaterialError'
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super('No encrypted wallet material saved.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const readStoredWalletMaterial = (): StoredWalletMaterial | null => {
|
||||||
|
const raw = localStorage.getItem(WALLET_MATERIAL_STORAGE_KEY)
|
||||||
|
if (raw === null) return null
|
||||||
|
let value: unknown
|
||||||
|
try {
|
||||||
|
value = JSON.parse(raw)
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SyntaxError) return null
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return parseStoredWalletMaterial(value)?.secret ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
const materialFromPlaintext = (stored: StoredWalletMaterial): WalletMaterialV2 | null => {
|
||||||
|
if (stored.enc) return null
|
||||||
|
const material = parseWalletMaterial(stored.value)
|
||||||
|
if (material === null) return null
|
||||||
|
const claim = storedWalletMaterialClaimedOwnerId(stored)
|
||||||
|
return claim === null || claim === walletMaterialOwnerId(material) ? material : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const savedWalletMaterialExists = (): boolean => readStoredWalletMaterial() !== null
|
||||||
|
|
||||||
|
export const savedWalletMaterialIsEncrypted = (): boolean =>
|
||||||
|
readStoredWalletMaterial()?.enc === true
|
||||||
|
|
||||||
|
export const getSavedWalletMaterialStored = (): StoredWalletMaterial | null =>
|
||||||
|
readStoredWalletMaterial()
|
||||||
|
|
||||||
|
export const savedWalletMaterialOwnerId = (): string | null => {
|
||||||
|
const stored = readStoredWalletMaterial()
|
||||||
|
return stored === null ? null : storedWalletMaterialOwnerId(stored)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const savedWalletMaterialOwnerMatches = (material: WalletMaterialV2): boolean =>
|
||||||
|
savedWalletMaterialOwnerId() === walletMaterialOwnerId(material)
|
||||||
|
|
||||||
|
export const getPlainWalletMaterial = (): WalletMaterialV2 | null => {
|
||||||
|
const stored = readStoredWalletMaterial()
|
||||||
|
return stored === null ? null : materialFromPlaintext(stored)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const saveWalletMaterial = async (
|
||||||
|
material: WalletMaterialV2,
|
||||||
|
password?: string,
|
||||||
|
): Promise<void> => {
|
||||||
|
const value = serializeWalletMaterial(material)
|
||||||
|
const ownerId = walletMaterialOwnerId(material)
|
||||||
|
if (!password) {
|
||||||
|
localStorage.setItem(
|
||||||
|
WALLET_MATERIAL_STORAGE_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
enc: false,
|
||||||
|
value,
|
||||||
|
ownerId,
|
||||||
|
version: STORED_WALLET_MATERIAL_VERSION,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const parts = await encryptSecretParts(value, password)
|
||||||
|
localStorage.setItem(
|
||||||
|
WALLET_MATERIAL_STORAGE_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
enc: true,
|
||||||
|
...parts,
|
||||||
|
ownerId,
|
||||||
|
version: STORED_WALLET_MATERIAL_VERSION,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const restoreWalletMaterialStored = (stored: StoredWalletMaterial): void => {
|
||||||
|
if (parseStoredWalletMaterial(stored) === null) throw new InvalidWalletMaterialError()
|
||||||
|
localStorage.setItem(
|
||||||
|
WALLET_MATERIAL_STORAGE_KEY,
|
||||||
|
JSON.stringify(stripStoredSecretOwner(stored)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const decryptSavedWalletMaterial = async (
|
||||||
|
password: string,
|
||||||
|
): Promise<WalletMaterialV2> => {
|
||||||
|
const stored = readStoredWalletMaterial()
|
||||||
|
if (stored === null || !stored.enc) throw new NoEncryptedWalletMaterialError()
|
||||||
|
const material = parseWalletMaterial(await decryptSecretParts(stored, password))
|
||||||
|
if (material === null) throw new InvalidWalletMaterialError()
|
||||||
|
const claimedOwnerId = storedWalletMaterialClaimedOwnerId(stored)
|
||||||
|
if (claimedOwnerId !== null && claimedOwnerId !== walletMaterialOwnerId(material)) {
|
||||||
|
throw new WalletMaterialOwnerMismatchError()
|
||||||
|
}
|
||||||
|
return material
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ensureSavedWalletMaterialOwner = (material: WalletMaterialV2): void => {
|
||||||
|
const stored = readStoredWalletMaterial()
|
||||||
|
if (stored === null) return
|
||||||
|
if (stored.enc === false && stored.value !== serializeWalletMaterial(material)) {
|
||||||
|
throw new WalletMaterialProofMismatchError()
|
||||||
|
}
|
||||||
|
const ownerId = walletMaterialOwnerId(material)
|
||||||
|
const claimedOwnerId = storedWalletMaterialClaimedOwnerId(stored)
|
||||||
|
if (storedWalletMaterialOwnerId(stored) === ownerId) return
|
||||||
|
if (claimedOwnerId !== null && claimedOwnerId !== ownerId) {
|
||||||
|
throw new WalletMaterialOwnerMismatchError()
|
||||||
|
}
|
||||||
|
localStorage.setItem(
|
||||||
|
WALLET_MATERIAL_STORAGE_KEY,
|
||||||
|
JSON.stringify(stampStoredWalletMaterialOwner(stored, ownerId)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clearSavedWalletMaterial = (): void => {
|
||||||
|
localStorage.removeItem(WALLET_MATERIAL_STORAGE_KEY)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user