feat: bind saved keys to versioned wallet owners

This commit is contained in:
2026-08-22 16:54:48 +02:00
parent 0e0133f550
commit acfab83438
6 changed files with 788 additions and 100 deletions
+101
View File
@@ -0,0 +1,101 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { bytesToHex } from '@noble/hashes/utils.js';
import { linkingPubKeyHex, savedKeyOwnerId } from '@/lnurlcash/keys';
import { wrapLinkingKeyWithPrf } from '@/lnurlcash/passkeys';
import { readPasskeySlots } from '@/lnurlcash/storage/passkeySlots';
import { parseJsonObject, stubLocalStorage } from '@/lnurlcash/test-utils';
const pluginMocks = vi.hoisted(() => ({
authenticate: vi.fn<() => Promise<void>>(),
secureGet: vi.fn<(key: string) => Promise<string | null>>(),
}));
vi.mock('@aparajita/capacitor-biometric-auth', () => ({
AndroidBiometryStrength: { weak: 'weak' },
BiometricAuth: { authenticate: pluginMocks.authenticate },
BiometryError: class BiometryError extends Error {},
BiometryErrorType: { userCancel: 'userCancel' },
}));
vi.mock('@aparajita/capacitor-secure-storage', () => ({
SecureStorage: { get: pluginMocks.secureGet },
}));
vi.mock('./platform', () => ({ isNative: () => true }));
import { unlockWithBiometrics } from './biometricUnlock';
const LINKING_KEY = new Uint8Array(32).fill(7);
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9);
const WRAP_SECRET = new Uint8Array(32).fill(3);
beforeEach(() => {
vi.clearAllMocks();
stubLocalStorage();
pluginMocks.authenticate.mockResolvedValue();
pluginMocks.secureGet.mockResolvedValue(bytesToHex(WRAP_SECRET));
});
describe('biometric unlock owner proof', () => {
it('cannot return a key or adopt legacy owner data when the stored pubkey is wrong', async () => {
// Given an ownerless legacy wallet and credential residue plus a biometric wrap
// whose claimed pubkey does not match the key it unwraps
const legacyKey = { enc: false, value: bytesToHex(LINKING_KEY) };
const legacySlot = {
credentialId: '11'.repeat(16),
hkdfSalt: '22'.repeat(16),
iv: '33'.repeat(12),
wrappedKey: '44'.repeat(48),
createdAt: 1,
};
const legacyNwc = [
{
clientPubkey: '55'.repeat(32),
relays: ['wss://relay.example'],
budget: { maxMsat: 1000, periodMs: 60_000 },
spent: { periodStart: 0, msat: 0 },
createdAt: 1,
},
];
const legacyTrust = [
{
server: 'legacy.example',
mintPubkey: '02' + 'aa'.repeat(32),
addedAt: 1,
locked: false,
},
];
localStorage.setItem('sattle_linking_key', JSON.stringify(legacyKey));
localStorage.setItem('sattle_passkey_slots', JSON.stringify([legacySlot]));
localStorage.setItem('sattle_nwc_connections', JSON.stringify(legacyNwc));
localStorage.setItem('sattle_nwc_enabled', 'true');
localStorage.setItem('sattle_trusted_mints', JSON.stringify(legacyTrust));
const wrap = await wrapLinkingKeyWithPrf(WRAP_SECRET, LINKING_KEY);
localStorage.setItem(
'sattle_biometric_wrap',
JSON.stringify({
...wrap,
pubkey: linkingPubKeyHex(OTHER_LINKING_KEY),
createdAt: 1,
}),
);
const before = new Map([
['sattle_linking_key', localStorage.getItem('sattle_linking_key')],
['sattle_passkey_slots', localStorage.getItem('sattle_passkey_slots')],
['sattle_nwc_connections', localStorage.getItem('sattle_nwc_connections')],
['sattle_nwc_enabled', localStorage.getItem('sattle_nwc_enabled')],
['sattle_trusted_mints', localStorage.getItem('sattle_trusted_mints')],
]);
// When biometric unwrap reaches the pubkey proof check
const attempt = unlockWithBiometrics();
// Then no key crosses the capability boundary and no legacy namespace is adopted
await expect(attempt).rejects.toThrow('different wallet');
expect(savedKeyOwnerId()).toBeNull();
expect(readPasskeySlots()).toEqual([]);
expect(parseJsonObject(localStorage.getItem('sattle_linking_key') ?? '{}')).toEqual(legacyKey);
for (const [key, value] of before) expect(localStorage.getItem(key)).toBe(value);
});
});
+316
View File
@@ -0,0 +1,316 @@
// Saved linking-key record tests. The baseline describes pin the observable
// behavior of saveLinkingKey/decryptSavedLinkingKey/getPlainLinkingKey/
// restoreLinkingKeyStored as it existed before the owner marker (they must
// keep passing unchanged); the owner-marker describes cover the ownerId
// field that binds the saved key to its one proven wallet identity.
// Node env: in-memory localStorage stub, native WebCrypto.
import {beforeEach, describe, expect, it} from 'vitest'
import {bytesToHex} from '@noble/hashes/utils.js'
import {
decryptSavedLinkingKey,
encryptSecretParts,
ensureSavedKeyOwner,
getPlainLinkingKey,
linkingPubKeyHex,
restoreLinkingKeyStored,
savedKeyExists,
savedKeyIsEncrypted,
savedKeyOwnerMatches,
savedKeyOwnerId,
saveLinkingKey,
} from './keys'
import {isWalletOwnerId} from './storage/walletOwner'
import {parseJsonObject, stubLocalStorage} from './test-utils'
import './keys.version.cases'
const LINKING_KEY = new Uint8Array(32).fill(7)
const OTHER_KEY = new Uint8Array(32).fill(9)
const PASSWORD = 'hunter2'
const STORAGE_KEY = 'sattle_linking_key'
const readRawRecord = (): Record<string, unknown> => {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw === null) throw new Error('expected a saved linking-key record')
return parseJsonObject(raw)
}
beforeEach(() => {
stubLocalStorage()
})
// hand-written records in the pre-owner-marker shape - what every wallet
// created before this change has on disk
const saveLegacyPlaintext = (key: Uint8Array): void => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({enc: false, value: bytesToHex(key)}))
}
const saveLegacyEncrypted = async (
key: Uint8Array,
password: string,
): Promise<Record<string, unknown>> => {
const parts = await encryptSecretParts(bytesToHex(key), password)
const record: Record<string, unknown> = {enc: true, ...parts}
localStorage.setItem(STORAGE_KEY, JSON.stringify(record))
return record
}
describe('baseline: saved-key record behavior', () => {
it('saves a plaintext key and reads it back', async () => {
await saveLinkingKey(LINKING_KEY)
expect(savedKeyExists()).toBe(true)
expect(savedKeyIsEncrypted()).toBe(false)
expect(readRawRecord()).toMatchObject({enc: false, value: bytesToHex(LINKING_KEY)})
expect(getPlainLinkingKey()).toEqual(LINKING_KEY)
})
it('saves a password-encrypted key and decrypts it with the password', async () => {
await saveLinkingKey(LINKING_KEY, PASSWORD)
expect(savedKeyIsEncrypted()).toBe(true)
const record = readRawRecord()
expect(record.enc).toBe(true)
expect(record.value).toBeUndefined()
// an encrypted record never reads through the plaintext path
expect(getPlainLinkingKey()).toBeNull()
expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY)
})
it('rejects the wrong password via the GCM auth tag', async () => {
await saveLinkingKey(LINKING_KEY, PASSWORD)
await expect(decryptSavedLinkingKey('wrong password')).rejects.toThrow()
})
it('throws when asked to decrypt a plaintext record', async () => {
await saveLinkingKey(LINKING_KEY)
await expect(decryptSavedLinkingKey(PASSWORD)).rejects.toThrow(
'No encrypted linking key saved.',
)
})
it('restores an ownerless record verbatim and reads it back', async () => {
const parts = await encryptSecretParts(bytesToHex(LINKING_KEY), PASSWORD)
const record = {enc: true as const, ...parts}
restoreLinkingKeyStored(record)
expect(readRawRecord()).toEqual(record)
expect(savedKeyIsEncrypted()).toBe(true)
expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY)
})
it('drops a malformed stored record instead of trusting it', () => {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({enc: true, salt: 'zz', iv: '00', ciphertext: ''}),
)
expect(savedKeyExists()).toBe(false)
localStorage.setItem(STORAGE_KEY, 'not json')
expect(savedKeyExists()).toBe(false)
expect(getPlainLinkingKey()).toBeNull()
})
})
describe('owner marker on new writes', () => {
it('matches the canonical owner derived from the saved linking key', async () => {
// Given a newly saved owner-bearing key
await saveLinkingKey(LINKING_KEY)
// When its freshly derived linking key is compared
const matches = savedKeyOwnerMatches(LINKING_KEY)
// Then the saved owner matches
expect(matches).toBe(true)
})
it('does not match a different linking key', async () => {
// Given a key owned by this wallet
await saveLinkingKey(LINKING_KEY)
// When a foreign freshly derived linking key is compared
const matches = savedKeyOwnerMatches(OTHER_KEY)
// Then the foreign key is rejected
expect(matches).toBe(false)
})
it('stamps the same canonical owner on plaintext and encrypted saves', async () => {
await saveLinkingKey(LINKING_KEY)
const plainOwner = savedKeyOwnerId()
expect(plainOwner).toBe(linkingPubKeyHex(LINKING_KEY))
await saveLinkingKey(LINKING_KEY, PASSWORD)
expect(savedKeyOwnerId()).toBe(plainOwner)
expect(savedKeyOwnerId()).toBe(linkingPubKeyHex(LINKING_KEY))
})
it('derives the owner as the lowercase 66-char compressed pubkey hex', async () => {
await saveLinkingKey(LINKING_KEY)
expect(savedKeyOwnerId()).toMatch(/^0[23][0-9a-f]{64}$/)
})
})
describe('owner marker on legacy records', () => {
it('reads a legacy record without ownerId as ownerless', async () => {
saveLegacyPlaintext(LINKING_KEY)
expect(savedKeyOwnerId()).toBeNull()
// the record itself stays a fully valid saved key
expect(getPlainLinkingKey()).toEqual(LINKING_KEY)
await saveLegacyEncrypted(LINKING_KEY, PASSWORD)
expect(savedKeyOwnerId()).toBeNull()
expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY)
})
it('rejects malformed owner-bearing records instead of treating them as legacy', () => {
const real = linkingPubKeyHex(LINKING_KEY)
const junk: unknown[] = [
real.slice(1), // wrong length (65)
real + '00', // wrong length (68)
real.toUpperCase(), // uppercase hex
'zz' + real.slice(2), // non-hex
'04' + real.slice(2), // not a compressed-pubkey prefix
'02' + 'ff'.repeat(32), // hex of the right length, not a curve point
42, // wrong type
null,
{pubkey: real},
'',
]
for (const ownerId of junk) {
saveLegacyPlaintext(LINKING_KEY)
localStorage.setItem(STORAGE_KEY, JSON.stringify({...readRawRecord(), ownerId}))
// the junk marker never reads as an owner...
expect(savedKeyOwnerId()).toBeNull()
// ...or downgrades to an adoptable ownerless legacy record
expect(savedKeyExists()).toBe(false)
expect(getPlainLinkingKey()).toBeNull()
}
})
it('stamps the owner after a password unlock without touching the ciphertext', async () => {
// Given a legacy encrypted record with no owner marker
const before = await saveLegacyEncrypted(LINKING_KEY, PASSWORD)
// When ownership is proven by a successful unlock and then stamped
const linkingKey = await decryptSavedLinkingKey(PASSWORD)
ensureSavedKeyOwner(linkingKey)
// Then the marker names the proven key and every ciphertext byte is
// preserved
const after = readRawRecord()
expect(after.ownerId).toBe(linkingPubKeyHex(LINKING_KEY))
expect(after.ciphertext).toBe(before.ciphertext)
expect(after.salt).toBe(before.salt)
expect(after.iv).toBe(before.iv)
})
it('stamps a plaintext legacy record after a plaintext unlock', () => {
saveLegacyPlaintext(LINKING_KEY)
const linkingKey = getPlainLinkingKey()
if (linkingKey === null) throw new Error('expected a plaintext key')
ensureSavedKeyOwner(linkingKey)
expect(readRawRecord()).toEqual({
enc: false,
value: bytesToHex(LINKING_KEY),
version: 1,
ownerId: linkingPubKeyHex(LINKING_KEY),
})
})
it('is idempotent - stamping twice leaves storage untouched after the first write', async () => {
const before = await saveLegacyEncrypted(LINKING_KEY, PASSWORD)
const linkingKey = await decryptSavedLinkingKey(PASSWORD)
ensureSavedKeyOwner(linkingKey)
const afterFirst = localStorage.getItem(STORAGE_KEY)
ensureSavedKeyOwner(linkingKey)
expect(localStorage.getItem(STORAGE_KEY)).toBe(afterFirst)
expect(readRawRecord().ciphertext).toBe(before.ciphertext)
})
it('writes nothing when a new-format record is already correctly stamped', async () => {
await saveLinkingKey(LINKING_KEY, PASSWORD)
const raw = localStorage.getItem(STORAGE_KEY)
ensureSavedKeyOwner(LINKING_KEY)
expect(localStorage.getItem(STORAGE_KEY)).toBe(raw)
})
it('refuses to restamp a record owned by a different wallet', async () => {
await saveLinkingKey(OTHER_KEY, PASSWORD)
const before = localStorage.getItem(STORAGE_KEY)
expect(() => ensureSavedKeyOwner(LINKING_KEY)).toThrow()
// the failed stamp leaves the record - marker included - untouched
expect(localStorage.getItem(STORAGE_KEY)).toBe(before)
expect(savedKeyOwnerId()).toBe(linkingPubKeyHex(OTHER_KEY))
})
it('refuses to stamp a key that contradicts a plaintext record', () => {
saveLegacyPlaintext(OTHER_KEY)
expect(() => ensureSavedKeyOwner(LINKING_KEY)).toThrow()
expect(savedKeyOwnerId()).toBeNull()
})
it('does not adopt a record carrying a junk owner marker', () => {
saveLegacyPlaintext(LINKING_KEY)
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({...readRawRecord(), ownerId: 'obviously junk'}),
)
ensureSavedKeyOwner(LINKING_KEY)
expect(savedKeyOwnerId()).toBeNull()
expect(savedKeyExists()).toBe(false)
})
it('is a no-op when no record is saved at all', () => {
ensureSavedKeyOwner(LINKING_KEY)
expect(savedKeyExists()).toBe(false)
})
})
describe('owner marker on restore', () => {
it('strips the unproven ownerId a restored record arrives with', async () => {
// a backup file can claim any marker - only a freshly derived key may
// establish ownership, so restore installs the secret parts alone
restoreLinkingKeyStored({
enc: false,
value: bytesToHex(LINKING_KEY),
ownerId: linkingPubKeyHex(OTHER_KEY),
})
expect(savedKeyOwnerId()).toBeNull()
expect(getPlainLinkingKey()).toEqual(LINKING_KEY)
// the first proven unlock then establishes the true owner
ensureSavedKeyOwner(LINKING_KEY)
expect(savedKeyOwnerId()).toBe(linkingPubKeyHex(LINKING_KEY))
})
it('strips a junk ownerId from a restored encrypted record', async () => {
const parts = await encryptSecretParts(bytesToHex(LINKING_KEY), PASSWORD)
restoreLinkingKeyStored({enc: true, ...parts, ownerId: 42})
expect(savedKeyOwnerId()).toBeNull()
expect(await decryptSavedLinkingKey(PASSWORD)).toEqual(LINKING_KEY)
})
})
describe('isWalletOwnerId', () => {
it('accepts exactly what linkingPubKeyHex produces', () => {
expect(isWalletOwnerId(linkingPubKeyHex(LINKING_KEY))).toBe(true)
expect(isWalletOwnerId(linkingPubKeyHex(OTHER_KEY))).toBe(true)
})
it('rejects everything else', () => {
const real = linkingPubKeyHex(LINKING_KEY)
expect(isWalletOwnerId(real.toUpperCase())).toBe(false)
expect(isWalletOwnerId(real.slice(0, 64))).toBe(false)
expect(isWalletOwnerId('02' + 'ff'.repeat(32))).toBe(false)
expect(isWalletOwnerId(66)).toBe(false)
expect(isWalletOwnerId(undefined)).toBe(false)
expect(isWalletOwnerId(null)).toBe(false)
})
})
+103 -100
View File
@@ -1,8 +1,4 @@
import { import {mnemonicToSeedSync, generateMnemonic, validateMnemonic} from '@scure/bip39'
mnemonicToSeedSync,
generateMnemonic,
validateMnemonic
} from '@scure/bip39'
import {wordlist} from '@scure/bip39/wordlists/english.js' import {wordlist} from '@scure/bip39/wordlists/english.js'
import {HDKey, HARDENED_OFFSET} from '@scure/bip32' import {HDKey, HARDENED_OFFSET} from '@scure/bip32'
import {hmac} from '@noble/hashes/hmac.js' import {hmac} from '@noble/hashes/hmac.js'
@@ -10,6 +6,20 @@ 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 {
parseStoredSecret,
stampStoredSecretOwner,
storedSecretClaimedOwnerId,
storedSecretOwnerId,
stripStoredSecretOwner,
STORED_SECRET_VERSION,
type StoredSecret,
} from './storage/storedSecret'
import {LINKING_KEY_STORAGE_KEY} from './storage/walletOwnerEvents'
export {isValidStoredSecret} from './storage/storedSecret'
export type {StoredSecret} from './storage/storedSecret'
// 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
// linking key (and thus decrypts the same bearer tokens) no matter where // linking key (and thus decrypts the same bearer tokens) no matter where
@@ -30,16 +40,12 @@ 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 = ( export const deriveLud05LinkingKey = (seedPhrase: string, domain: string): Uint8Array => {
seedPhrase: string,
domain: string
): Uint8Array => {
const seed = mnemonicToSeedSync(seedPhrase.trim().toLowerCase()) 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")
if (!hashingKeyNode.privateKey) if (!hashingKeyNode.privateKey) throw new Error('Could not derive hashing key')
throw new Error('Could not derive hashing key')
const suffix = lud05PathSuffix(hashingKeyNode.privateKey, domain) const suffix = lud05PathSuffix(hashingKeyNode.privateKey, domain)
// path suffix longs are raw BIP32 child indices: whether each level ends up // path suffix longs are raw BIP32 child indices: whether each level ends up
@@ -55,12 +61,9 @@ export const deriveLud05LinkingKey = (
// 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
export const lud05PathSuffix = ( export const lud05PathSuffix = (hashingKey: Uint8Array, domain: string): number[] => {
hashingKey: Uint8Array,
domain: string
): number[] => {
const material = hmac(sha256, hashingKey, utf8ToBytes(domain)) const material = hmac(sha256, hashingKey, utf8ToBytes(domain))
return [0, 4, 8, 12].map(i => readUint32BE(material, i)) return [0, 4, 8, 12].map((i) => readUint32BE(material, i))
} }
export const deriveWalletLinkingKey = (seedPhrase: string): Uint8Array => export const deriveWalletLinkingKey = (seedPhrase: string): Uint8Array =>
@@ -75,56 +78,21 @@ export const linkingPubKeyHex = (linkingPrivKey: Uint8Array): string =>
// GCM's auth tag doubles as the "wrong password" check on decrypt. // GCM's auth tag doubles as the "wrong password" check on decrypt.
const PBKDF2_ITERATIONS = 210_000 const PBKDF2_ITERATIONS = 210_000
export type StoredSecret =
| {enc: false; value: string}
| {enc: true; salt: string; iv: string; ciphertext: string}
// strict shape check on a StoredSecret - a plaintext form must be exactly a
// 32-byte hex key, an encrypted form must carry hex salt/iv/ciphertext of
// the sizes encryptSecretParts produces. Guards the backup-restore path
// (storage.ts's applyBackup), where a crafted file would otherwise get an
// arbitrary "linking key" installed verbatim.
export const isValidStoredSecret = (
stored: unknown
): stored is StoredSecret => {
if (typeof stored !== 'object' || stored === null) return false
const s = stored as Record<string, unknown>
if (s.enc === false) {
return typeof s.value === 'string' && /^[0-9a-f]{64}$/i.test(s.value)
}
if (s.enc === true) {
return (
typeof s.salt === 'string' &&
/^[0-9a-f]{32}$/i.test(s.salt) &&
typeof s.iv === 'string' &&
/^[0-9a-f]{24}$/i.test(s.iv) &&
typeof s.ciphertext === 'string' &&
s.ciphertext.length > 0 &&
s.ciphertext.length % 2 === 0 &&
/^[0-9a-f]+$/i.test(s.ciphertext)
)
}
return false
}
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
try { try {
const parsed: unknown = JSON.parse(raw) const parsed: unknown = JSON.parse(raw)
return isValidStoredSecret(parsed) ? parsed : null return parseStoredSecret(parsed)?.secret ?? null
} catch { } catch {
return null return null
} }
} }
const deriveAesKeyFromPassword = ( const deriveAesKeyFromPassword = (password: string, salt: Uint8Array): Promise<CryptoKey> =>
password: string,
salt: Uint8Array
): Promise<CryptoKey> =>
crypto.subtle crypto.subtle
.importKey('raw', utf8ToBytes(password), 'PBKDF2', false, ['deriveKey']) .importKey('raw', utf8ToBytes(password), 'PBKDF2', false, ['deriveKey'])
.then(baseKey => .then((baseKey) =>
crypto.subtle.deriveKey( crypto.subtle.deriveKey(
// the copy pins the TS type to Uint8Array<ArrayBuffer> - hexToBytes // the copy pins the TS type to Uint8Array<ArrayBuffer> - hexToBytes
// returns Uint8Array<ArrayBufferLike>, which BufferSource rejects // returns Uint8Array<ArrayBufferLike>, which BufferSource rejects
@@ -132,13 +100,13 @@ const deriveAesKeyFromPassword = (
name: 'PBKDF2', name: 'PBKDF2',
salt: new Uint8Array(salt), salt: new Uint8Array(salt),
iterations: PBKDF2_ITERATIONS, iterations: PBKDF2_ITERATIONS,
hash: 'SHA-256' hash: 'SHA-256',
}, },
baseKey, baseKey,
{name: 'AES-GCM', length: 256}, {name: 'AES-GCM', length: 256},
false, false,
['encrypt', 'decrypt'] ['encrypt', 'decrypt'],
) ),
) )
export type EncryptedSecretParts = { export type EncryptedSecretParts = {
@@ -149,29 +117,25 @@ export type EncryptedSecretParts = {
export const encryptSecretParts = async ( export const encryptSecretParts = async (
value: string, value: string,
password: string password: string,
): Promise<EncryptedSecretParts> => { ): Promise<EncryptedSecretParts> => {
const salt = crypto.getRandomValues(new Uint8Array(16)) const salt = crypto.getRandomValues(new Uint8Array(16))
const iv = crypto.getRandomValues(new Uint8Array(12)) const iv = crypto.getRandomValues(new Uint8Array(12))
const aesKey = await deriveAesKeyFromPassword(password, salt) const aesKey = await deriveAesKeyFromPassword(password, salt)
const ciphertext = new Uint8Array( const ciphertext = new Uint8Array(
await crypto.subtle.encrypt( await crypto.subtle.encrypt({name: 'AES-GCM', iv}, aesKey, utf8ToBytes(value)),
{name: 'AES-GCM', iv},
aesKey,
utf8ToBytes(value)
)
) )
return { return {
salt: bytesToHex(salt), salt: bytesToHex(salt),
iv: bytesToHex(iv), iv: bytesToHex(iv),
ciphertext: bytesToHex(ciphertext) ciphertext: bytesToHex(ciphertext),
} }
} }
// rejects (WebCrypto's own auth-tag check) if the password is wrong // rejects (WebCrypto's own auth-tag check) if the password is wrong
export const decryptSecretParts = async ( export const decryptSecretParts = async (
parts: EncryptedSecretParts, parts: EncryptedSecretParts,
password: string password: string,
): Promise<string> => { ): Promise<string> => {
const salt = hexToBytes(parts.salt) const salt = hexToBytes(parts.salt)
const iv = hexToBytes(parts.iv) const iv = hexToBytes(parts.iv)
@@ -179,7 +143,7 @@ export const decryptSecretParts = async (
const plaintext = await crypto.subtle.decrypt( const plaintext = await crypto.subtle.decrypt(
{name: 'AES-GCM', iv}, {name: 'AES-GCM', iv},
aesKey, aesKey,
hexToBytes(parts.ciphertext) hexToBytes(parts.ciphertext),
) )
return new TextDecoder().decode(plaintext) return new TextDecoder().decode(plaintext)
} }
@@ -188,17 +152,64 @@ export const decryptSecretParts = async (
// it was derived from is shown once at setup and never stored. Everything // 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, // 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. // so protecting this one record with a password protects the whole wallet.
const LINKING_KEY_STORAGE_KEY = 'sattle_linking_key' //
// The record also carries an ownerId marker: the lowercase compressed
// pubkey hex of the key itself (storage/walletOwner.ts), binding every
// other wallet-owned record (passkeys, NWC, trusted mints) to this exact
// identity. Failure modes of the marker API:
// - new writes always carry the marker derived from the key being saved;
// - a record restored from a backup/relay is installed OWNERLESS - its
// file-carried marker is an unproven claim and is stripped on restore;
// - an ownerless legacy record stays usable but cannot establish ownership;
// malformed or unsupported owner-bearing metadata rejects the whole record;
// - ensureSavedKeyOwner stamps the marker after the caller proved the key
// (successful password/plaintext unlock or matching biometric unwrap),
// preserving ciphertext byte-for-byte; it refuses to restamp a record
// already owned by a different valid owner, and refuses a key that
// contradicts a plaintext record.
export const savedKeyExists = (): boolean => readSecret(LINKING_KEY_STORAGE_KEY) !== null
export const savedKeyExists = (): boolean => export const savedKeyIsEncrypted = (): boolean => readSecret(LINKING_KEY_STORAGE_KEY)?.enc === true
readSecret(LINKING_KEY_STORAGE_KEY) !== null
export const savedKeyIsEncrypted = (): boolean =>
readSecret(LINKING_KEY_STORAGE_KEY)?.enc === true
export const getSavedLinkingKeyStored = (): StoredSecret | null => export const getSavedLinkingKeyStored = (): StoredSecret | null =>
readSecret(LINKING_KEY_STORAGE_KEY) readSecret(LINKING_KEY_STORAGE_KEY)
// the proven owner of the saved key, or null when there is no record or it
// carries no current version-1 marker (ownerless or compatible unversioned)
export const savedKeyOwnerId = (): string | null => {
const stored = readSecret(LINKING_KEY_STORAGE_KEY)
return stored === null ? null : storedSecretOwnerId(stored)
}
// Compares only against an owner freshly derived from a linking key. An
// ownerless or unversioned saved marker never matches.
export const savedKeyOwnerMatches = (linkingKey: Uint8Array): boolean =>
savedKeyOwnerId() === linkingPubKeyHex(linkingKey)
// Stamps the owner marker onto the existing record. Call ONLY with the key
// just proven against this record (decryptSavedLinkingKey / a plaintext
// read / a biometric unwrap whose stored pubkey matched) - the marker is
// derived from that key, never from a stored claim. No saved record or an
// already-correct marker: no write. A DIFFERENT valid owner, or a key that
// contradicts a plaintext record, throws and leaves storage untouched.
export const ensureSavedKeyOwner = (linkingKey: Uint8Array): void => {
const stored = readSecret(LINKING_KEY_STORAGE_KEY)
if (stored === null) return
const ownerId = linkingPubKeyHex(linkingKey)
if (stored.enc === false && stored.value.toLowerCase() !== bytesToHex(linkingKey)) {
throw new Error('Proven key does not match the saved wallet key.')
}
const claimedOwnerId = storedSecretClaimedOwnerId(stored)
if (storedSecretOwnerId(stored) === ownerId) return
if (claimedOwnerId !== null && claimedOwnerId !== ownerId) {
throw new Error('Saved wallet key is owned by a different wallet.')
}
localStorage.setItem(
LINKING_KEY_STORAGE_KEY,
JSON.stringify(stampStoredSecretOwner(stored, ownerId)),
)
}
export const getPlainLinkingKey = (): Uint8Array | null => { export const getPlainLinkingKey = (): Uint8Array | null => {
const stored = readSecret(LINKING_KEY_STORAGE_KEY) const stored = readSecret(LINKING_KEY_STORAGE_KEY)
if (stored === null || stored.enc === true) return null if (stored === null || stored.enc === true) return null
@@ -207,30 +218,33 @@ export const getPlainLinkingKey = (): Uint8Array | null => {
export const saveLinkingKey = async ( export const saveLinkingKey = async (
linkingPrivKey: Uint8Array, linkingPrivKey: Uint8Array,
password?: string password?: string,
): Promise<void> => { ): Promise<void> => {
const hex = bytesToHex(linkingPrivKey) const hex = bytesToHex(linkingPrivKey)
const ownerId = linkingPubKeyHex(linkingPrivKey)
if (!password) { if (!password) {
localStorage.setItem( localStorage.setItem(
LINKING_KEY_STORAGE_KEY, LINKING_KEY_STORAGE_KEY,
JSON.stringify({enc: false, value: hex}) JSON.stringify({enc: false, value: hex, ownerId, version: STORED_SECRET_VERSION}),
) )
return return
} }
const parts = await encryptSecretParts(hex, password) const parts = await encryptSecretParts(hex, password)
localStorage.setItem( localStorage.setItem(
LINKING_KEY_STORAGE_KEY, LINKING_KEY_STORAGE_KEY,
JSON.stringify({enc: true, ...parts}) JSON.stringify({enc: true, ...parts, ownerId, version: STORED_SECRET_VERSION}),
) )
} }
// installs a record from a backup/relay. Any ownerId it carries is an
// unproven claim by whoever produced that file, so the marker is stripped
// here - the first proven unlock re-establishes it (see the failure-model
// comment above)
export const restoreLinkingKeyStored = (stored: StoredSecret): void => { export const restoreLinkingKeyStored = (stored: StoredSecret): void => {
localStorage.setItem(LINKING_KEY_STORAGE_KEY, JSON.stringify(stored)) localStorage.setItem(LINKING_KEY_STORAGE_KEY, JSON.stringify(stripStoredSecretOwner(stored)))
} }
export const decryptSavedLinkingKey = async ( export const decryptSavedLinkingKey = async (password: string): Promise<Uint8Array> => {
password: string
): Promise<Uint8Array> => {
const stored = readSecret(LINKING_KEY_STORAGE_KEY) const stored = readSecret(LINKING_KEY_STORAGE_KEY)
if (!stored || !stored.enc) throw new Error('No encrypted linking key saved.') if (!stored || !stored.enc) throw new Error('No encrypted linking key saved.')
return hexToBytes(await decryptSecretParts(stored, password)) return hexToBytes(await decryptSecretParts(stored, password))
@@ -246,43 +260,32 @@ export const clearSavedLinkingKey = (): void => {
// a fresh device and every previously exported ciphertext decrypts again. // a fresh device and every previously exported ciphertext decrypts again.
const BEARER_KEY_CONTEXT = 'lnurlcash-bearer-encryption-v1' const BEARER_KEY_CONTEXT = 'lnurlcash-bearer-encryption-v1'
export const deriveBearerAesKey = ( export const deriveBearerAesKey = (linkingPrivKey: Uint8Array): Promise<CryptoKey> => {
linkingPrivKey: Uint8Array const material = sha256(new Uint8Array([...linkingPrivKey, ...utf8ToBytes(BEARER_KEY_CONTEXT)]))
): Promise<CryptoKey> => { return crypto.subtle.importKey('raw', material, 'AES-GCM', false, ['encrypt', 'decrypt'])
const material = sha256(
new Uint8Array([...linkingPrivKey, ...utf8ToBytes(BEARER_KEY_CONTEXT)])
)
return crypto.subtle.importKey('raw', material, 'AES-GCM', false, [
'encrypt',
'decrypt'
])
} }
export type EncryptedRecordParts = {iv: string; ciphertext: string} export type EncryptedRecordParts = {iv: string; ciphertext: string}
export const encryptRecord = async ( export const encryptRecord = async (
aesKey: CryptoKey, aesKey: CryptoKey,
value: object value: object,
): Promise<EncryptedRecordParts> => { ): Promise<EncryptedRecordParts> => {
const iv = crypto.getRandomValues(new Uint8Array(12)) const iv = crypto.getRandomValues(new Uint8Array(12))
const ciphertext = new Uint8Array( const ciphertext = new Uint8Array(
await crypto.subtle.encrypt( await crypto.subtle.encrypt({name: 'AES-GCM', iv}, aesKey, utf8ToBytes(JSON.stringify(value))),
{name: 'AES-GCM', iv},
aesKey,
utf8ToBytes(JSON.stringify(value))
)
) )
return {iv: bytesToHex(iv), ciphertext: bytesToHex(ciphertext)} return {iv: bytesToHex(iv), ciphertext: bytesToHex(ciphertext)}
} }
export const decryptRecord = async <T>( export const decryptRecord = async (
aesKey: CryptoKey, aesKey: CryptoKey,
parts: EncryptedRecordParts parts: EncryptedRecordParts,
): Promise<T> => { ): Promise<unknown> => {
const plaintext = await crypto.subtle.decrypt( const plaintext = await crypto.subtle.decrypt(
{name: 'AES-GCM', iv: hexToBytes(parts.iv)}, {name: 'AES-GCM', iv: hexToBytes(parts.iv)},
aesKey, aesKey,
hexToBytes(parts.ciphertext) hexToBytes(parts.ciphertext),
) )
return JSON.parse(new TextDecoder().decode(plaintext)) as T return JSON.parse(new TextDecoder().decode(plaintext))
} }
+102
View File
@@ -0,0 +1,102 @@
import {beforeEach, describe, expect, it} from 'vitest'
import {bytesToHex} from '@noble/hashes/utils.js'
import {
ensureSavedKeyOwner,
getPlainLinkingKey,
isValidStoredSecret,
linkingPubKeyHex,
savedKeyExists,
savedKeyOwnerId,
saveLinkingKey,
} from './keys'
import {parseJsonObject, stubLocalStorage} from './test-utils'
const LINKING_KEY = new Uint8Array(32).fill(7)
const OTHER_LINKING_KEY = new Uint8Array(32).fill(9)
const STORAGE_KEY = 'sattle_linking_key'
const readRawRecord = (): Record<string, unknown> => {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw === null) throw new Error('expected a saved linking-key record')
return parseJsonObject(raw)
}
beforeEach(() => {
stubLocalStorage()
})
describe('saved-key schema version', () => {
it('writes version 1 on current plaintext and encrypted records', async () => {
// Given a linking key saved through each current persistence path
await saveLinkingKey(LINKING_KEY)
const plaintext = readRawRecord()
await saveLinkingKey(LINKING_KEY, 'correct horse')
const encrypted = readRawRecord()
// When the persisted schema metadata is inspected
// Then both owner-bearing records carry the recognized discriminator
expect(plaintext.version).toBe(1)
expect(encrypted.version).toBe(1)
})
it('upgrades an unversioned same-owner record only after key proof', () => {
// Given the valid owner-bearing shape written before schema versioning
const ownerId = linkingPubKeyHex(LINKING_KEY)
localStorage.setItem(
STORAGE_KEY,
JSON.stringify({enc: false, value: bytesToHex(LINKING_KEY), ownerId}),
)
// When it is read before and then stamped after the plaintext key proves ownership
expect(savedKeyOwnerId()).toBeNull()
const provenKey = getPlainLinkingKey()
if (provenKey === null) throw new Error('expected the compatible plaintext key')
ensureSavedKeyOwner(provenKey)
// Then it becomes an explicitly versioned current record
expect(readRawRecord()).toEqual({
enc: false,
value: bytesToHex(LINKING_KEY),
ownerId,
version: 1,
})
})
it.each([2, '1', null])('rejects unsupported or malformed version %j', (version) => {
// Given an otherwise valid owner-bearing record with unrecognized metadata
const record = {
enc: false,
value: bytesToHex(LINKING_KEY),
ownerId: linkingPubKeyHex(LINKING_KEY),
version,
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(record))
// When the saved-key boundary parses it
// Then the record is neither usable nor eligible for legacy adoption
expect(isValidStoredSecret(record)).toBe(false)
expect(savedKeyExists()).toBe(false)
expect(savedKeyOwnerId()).toBeNull()
expect(getPlainLinkingKey()).toBeNull()
})
it('rejects a foreign current owner at the proven-key stamping boundary', () => {
// Given a versioned record whose owner conflicts with its plaintext key
const record = {
enc: false,
value: bytesToHex(LINKING_KEY),
ownerId: linkingPubKeyHex(OTHER_LINKING_KEY),
version: 1,
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(record))
const before = localStorage.getItem(STORAGE_KEY)
// When the actual key is proven
const stamp = () => ensureSavedKeyOwner(LINKING_KEY)
// Then the foreign claim fails closed without rewriting storage
expect(stamp).toThrow('different wallet')
expect(localStorage.getItem(STORAGE_KEY)).toBe(before)
})
})
+130
View File
@@ -0,0 +1,130 @@
// Saved linking-key records have two safe at-rest forms: ownerless legacy
// records and version-1 owner-bearing records. The briefly shipped
// unversioned owner-bearing shape remains readable only so a proven key can
// upgrade it; it never establishes ownership by itself. Any other metadata
// is rejected rather than downgraded to adoptable legacy data.
import {isJsonObject} from '../jsonParsing'
import {isWalletOwnerId} from './walletOwner'
export const STORED_SECRET_VERSION = 1 as const
type PlainStoredSecret = {
readonly enc: false
readonly value: string
readonly ownerId?: unknown
readonly version?: unknown
}
type EncryptedStoredSecret = {
readonly enc: true
readonly salt: string
readonly iv: string
readonly ciphertext: string
readonly ownerId?: unknown
readonly version?: unknown
}
export type StoredSecret = PlainStoredSecret | EncryptedStoredSecret
type ParsedStoredSecret = {
readonly secret: StoredSecret
readonly claimedOwnerId: string | null
readonly isCurrent: boolean
}
const PLAIN_KEYS = ['enc', 'value', 'ownerId', 'version'] as const
const ENCRYPTED_KEYS = ['enc', 'salt', 'iv', 'ciphertext', 'ownerId', 'version'] as const
const hasOnlyKeys = (record: Record<string, unknown>, allowed: readonly string[]): boolean =>
Object.keys(record).every((key) => allowed.includes(key))
export const parseStoredSecret = (stored: unknown): ParsedStoredSecret | null => {
if (!isJsonObject(stored)) return null
let secret: StoredSecret
if (stored.enc === false) {
if (
typeof stored.value !== 'string' ||
!/^[0-9a-f]{64}$/i.test(stored.value) ||
!hasOnlyKeys(stored, PLAIN_KEYS)
) {
return null
}
secret = {enc: false, value: stored.value}
} else if (stored.enc === true) {
if (
typeof stored.salt !== 'string' ||
!/^[0-9a-f]{32}$/i.test(stored.salt) ||
typeof stored.iv !== 'string' ||
!/^[0-9a-f]{24}$/i.test(stored.iv) ||
typeof stored.ciphertext !== 'string' ||
stored.ciphertext.length === 0 ||
stored.ciphertext.length % 2 !== 0 ||
!/^[0-9a-f]+$/i.test(stored.ciphertext) ||
!hasOnlyKeys(stored, ENCRYPTED_KEYS)
) {
return null
}
secret = {
enc: true,
salt: stored.salt,
iv: stored.iv,
ciphertext: stored.ciphertext,
}
} else {
return null
}
const hasOwner = Object.hasOwn(stored, 'ownerId')
const hasVersion = Object.hasOwn(stored, 'version')
if (!hasOwner && !hasVersion) return {secret, claimedOwnerId: null, isCurrent: false}
if (!isWalletOwnerId(stored.ownerId)) return null
if (!hasVersion) {
return {
secret: {...secret, ownerId: stored.ownerId},
claimedOwnerId: stored.ownerId,
isCurrent: false,
}
}
if (stored.version !== STORED_SECRET_VERSION) return null
return {
secret: {...secret, ownerId: stored.ownerId, version: STORED_SECRET_VERSION},
claimedOwnerId: stored.ownerId,
isCurrent: true,
}
}
export const isValidStoredSecret = (stored: unknown): stored is StoredSecret =>
parseStoredSecret(stored) !== null
export const storedSecretOwnerId = (stored: StoredSecret): string | null => {
const parsed = parseStoredSecret(stored)
return parsed?.isCurrent === true ? parsed.claimedOwnerId : null
}
export const storedSecretClaimedOwnerId = (stored: StoredSecret): string | null =>
parseStoredSecret(stored)?.claimedOwnerId ?? null
export const stampStoredSecretOwner = (stored: StoredSecret, ownerId: string): StoredSecret => {
if (stored.enc === false) {
return {enc: false, value: stored.value, ownerId, version: STORED_SECRET_VERSION}
}
return {
enc: true,
salt: stored.salt,
iv: stored.iv,
ciphertext: stored.ciphertext,
ownerId,
version: STORED_SECRET_VERSION,
}
}
export const stripStoredSecretOwner = (stored: StoredSecret): StoredSecret => {
if (stored.enc === false) return {enc: false, value: stored.value}
return {
enc: true,
salt: stored.salt,
iv: stored.iv,
ciphertext: stored.ciphertext,
}
}
+36
View File
@@ -0,0 +1,36 @@
// Wallet owner marker. The saved linking-key record (and, in later work,
// every credential-ish record: passkey slots, NWC connections, the
// trusted-mint registry) carries an ownerId binding it to exactly one
// wallet identity, so a restored or foreign wallet can never inherit
// residue from a previous one and a stale tab cannot act for a replaced
// owner.
//
// The canonical ownerId is linkingPubKeyHex(linkingKey): the lowercase
// 66-char compressed secp256k1 pubkey hex of the wallet's LUD-05 linking
// key. It is a PUBLIC value - knowing it proves nothing. That is why a
// marker may only be WRITTEN from a freshly derived or freshly proven key
// (a new save, or ensureSavedKeyOwner after a successful unlock) and never
// copied from a backup file, a credential id, or any other stored claim.
//
// Failure model: localStorage is hand-editable and backups are hostile
// input, so a marker is never trusted on presence alone. Anything that is
// not byte-exactly a valid compressed-pubkey hex - wrong length, wrong
// case, non-hex, off-curve, non-string - is rejected by its owning schema.
// Only a record with no ownership metadata at all is legacy ownerless data;
// malformed or future metadata must never be downgraded into that adoptable
// path.
import {secp256k1} from '@noble/curves/secp256k1.js'
// strict shape AND curve check: exactly what linkingPubKeyHex can produce
export const isWalletOwnerId = (ownerId: unknown): ownerId is string => {
if (typeof ownerId !== 'string' || !/^0[23][0-9a-f]{64}$/.test(ownerId)) {
return false
}
try {
secp256k1.Point.fromHex(ownerId)
return true
} catch {
return false
}
}