sattle: Quasar PWA scaffold with lnurlcash-kit protocol core

M1: Quasar 2 / Vue 3 / TS / Pinia / i18n / PWA app shell in the lnurl-wallet
palette (#002222/#004444/#55ffcc, Noto Sans). Protocol layer is the external
lnurlcash-kit library (git dependency on the fork until upstream merges the
prepare fix / publishes to npm); keys.ts + receive.ts vendored from
lnurl-wallet with the derivation domain renamed to 'sattle'. Nix dev shell,
ci workflow (typecheck, vitest, pwa build).
This commit is contained in:
2026-08-19 13:29:34 +02:00
commit 54d6112414
60 changed files with 14447 additions and 0 deletions
+288
View File
@@ -0,0 +1,288 @@
import {
mnemonicToSeedSync,
generateMnemonic,
validateMnemonic
} from '@scure/bip39'
import {wordlist} from '@scure/bip39/wordlists/english.js'
import {HDKey, HARDENED_OFFSET} from '@scure/bip32'
import {hmac} from '@noble/hashes/hmac.js'
import {sha256} from '@noble/hashes/sha2.js'
import {secp256k1} from '@noble/curves/secp256k1.js'
import {bytesToHex, hexToBytes, utf8ToBytes} from '@noble/hashes/utils.js'
// The wallet's identity is derived against this fixed domain rather than
// window.location.hostname, so the same seed phrase always yields the same
// linking key (and thus decrypts the same bearer tokens) no matter where
// this static build happens to be hosted - github.io, a mirror, file://.
export const WALLET_DOMAIN = 'sattle'
export const generateSeedPhrase = (): string => generateMnemonic(wordlist, 128)
export const isValidSeedPhrase = (phrase: string): boolean =>
validateMnemonic(phrase.trim().toLowerCase(), wordlist)
const readUint32BE = (bytes: Uint8Array, offset: number): number =>
((bytes[offset] << 24) |
(bytes[offset + 1] << 16) |
(bytes[offset + 2] << 8) |
bytes[offset + 3]) >>>
0
// 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
export const deriveLud05LinkingKey = (
seedPhrase: string,
domain: string
): Uint8Array => {
const seed = mnemonicToSeedSync(seedPhrase.trim().toLowerCase())
const master = HDKey.fromMasterSeed(seed)
const hashingKeyNode = master.derive("m/138'/0")
if (!hashingKeyNode.privateKey)
throw new Error('Could not derive hashing key')
const suffix = lud05PathSuffix(hashingKeyNode.privateKey, domain)
// path suffix longs are raw BIP32 child indices: whether each level ends up
// hardened depends solely on its own magnitude (>= 2^31), never forced
let node = master.deriveChild(138 + HARDENED_OFFSET)
for (const index of suffix) {
node = node.deriveChild(index)
}
if (!node.privateKey) throw new Error('Could not derive linking key')
return node.privateKey
}
// 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
// directly - see keys.test.ts
export const lud05PathSuffix = (
hashingKey: Uint8Array,
domain: string
): number[] => {
const material = hmac(sha256, hashingKey, utf8ToBytes(domain))
return [0, 4, 8, 12].map(i => readUint32BE(material, i))
}
export const deriveWalletLinkingKey = (seedPhrase: string): Uint8Array =>
deriveLud05LinkingKey(seedPhrase, WALLET_DOMAIN)
export const linkingPubKeyHex = (linkingPrivKey: Uint8Array): string =>
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
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 raw = localStorage.getItem(storageKey)
if (!raw) return null
try {
const parsed: unknown = JSON.parse(raw)
return isValidStoredSecret(parsed) ? parsed : null
} catch {
return null
}
}
const deriveAesKeyFromPassword = (
password: string,
salt: Uint8Array
): Promise<CryptoKey> =>
crypto.subtle
.importKey('raw', utf8ToBytes(password), 'PBKDF2', false, ['deriveKey'])
.then(baseKey =>
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.
const LINKING_KEY_STORAGE_KEY = 'sattle_linking_key'
export const savedKeyExists = (): boolean =>
readSecret(LINKING_KEY_STORAGE_KEY) !== null
export const savedKeyIsEncrypted = (): boolean =>
readSecret(LINKING_KEY_STORAGE_KEY)?.enc === true
export const getSavedLinkingKeyStored = (): StoredSecret | null =>
readSecret(LINKING_KEY_STORAGE_KEY)
export const getPlainLinkingKey = (): Uint8Array | null => {
const stored = readSecret(LINKING_KEY_STORAGE_KEY)
if (stored === null || stored.enc === true) return null
return hexToBytes(stored.value)
}
export const saveLinkingKey = async (
linkingPrivKey: Uint8Array,
password?: string
): Promise<void> => {
const hex = bytesToHex(linkingPrivKey)
if (!password) {
localStorage.setItem(
LINKING_KEY_STORAGE_KEY,
JSON.stringify({enc: false, value: hex})
)
return
}
const parts = await encryptSecretParts(hex, password)
localStorage.setItem(
LINKING_KEY_STORAGE_KEY,
JSON.stringify({enc: true, ...parts})
)
}
export const restoreLinkingKeyStored = (stored: StoredSecret): void => {
localStorage.setItem(LINKING_KEY_STORAGE_KEY, JSON.stringify(stored))
}
export const decryptSavedLinkingKey = async (
password: string
): Promise<Uint8Array> => {
const stored = readSecret(LINKING_KEY_STORAGE_KEY)
if (!stored || !stored.enc) throw new Error('No encrypted linking key saved.')
return hexToBytes(await decryptSecretParts(stored, password))
}
export const clearSavedLinkingKey = (): void => {
localStorage.removeItem(LINKING_KEY_STORAGE_KEY)
}
// The bearer-encryption key is derived (not random): sha256 over the linking
// key plus a fixed context string. Deterministic derivation is what makes
// backup/restore work with nothing but the seed phrase - restore the seed on
// a fresh device and every previously exported ciphertext decrypts again.
const BEARER_KEY_CONTEXT = 'lnurlcash-bearer-encryption-v1'
export const deriveBearerAesKey = (
linkingPrivKey: Uint8Array
): Promise<CryptoKey> => {
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 const encryptRecord = async (
aesKey: CryptoKey,
value: object
): Promise<EncryptedRecordParts> => {
const iv = crypto.getRandomValues(new Uint8Array(12))
const ciphertext = new Uint8Array(
await crypto.subtle.encrypt(
{name: 'AES-GCM', iv},
aesKey,
utf8ToBytes(JSON.stringify(value))
)
)
return {iv: bytesToHex(iv), ciphertext: bytesToHex(ciphertext)}
}
export const decryptRecord = async <T>(
aesKey: CryptoKey,
parts: EncryptedRecordParts
): Promise<T> => {
const plaintext = await crypto.subtle.decrypt(
{name: 'AES-GCM', iv: hexToBytes(parts.iv)},
aesKey,
hexToBytes(parts.ciphertext)
)
return JSON.parse(new TextDecoder().decode(plaintext)) as T
}
+82
View File
@@ -0,0 +1,82 @@
import {
resolveNoteInput,
noteK1,
noteDeclaredAmount,
serverOf,
fetchNoteInfo,
rotateNote,
withNewK1,
NoteSpentError,
NoteUnknownError
} from 'lnurlcash-kit'
import type {Bearer, NewBearer} from './types'
// shared by Scan and Paste: resolve whatever came in to a note URL, ask the
// issuing service what it is worth (an informational GET - per spec this
// always puts k1 on the wire, so receive.ts's caller should rotate right
// after, see secureReceivedNote). Returns the note even when the info fetch
// fails - a bearer is better stored unverified than dropped.
export const receiveNote = async (
input: string,
existing: Bearer[]
): Promise<NewBearer> => {
const url = resolveNoteInput(input)
if (!url) {
throw new Error('Not an LNURLcash bearer note (needs a k1).')
}
const k1 = noteK1(url)
if (
existing.some(
b => noteK1(b.url) === k1 && serverOf(b.url) === serverOf(url)
)
) {
throw new Error('This note is already in your wallet.')
}
try {
const info = await fetchNoteInfo(url)
return {
url,
callback: info.callback,
amount: info.maxWithdrawable,
verified: true,
mintPubkey: info.mintPubkey
}
} catch (err) {
// the service positively told us this k1 is dead - that's worth more
// than the sender's own claim, so don't paper over it with an
// unverified fallback the way an unreachable/unknown-shaped error
// below does. The caller (ReceiveDialog.tsx) surfaces this and never stores
// the note.
if (err instanceof NoteSpentError || err instanceof NoteUnknownError) {
throw err
}
// service unreachable (or some other non-definitive failure) - fall
// back to the sender's own (unverified) declared amount so the note
// isn't shown as worth nothing
return {
url,
callback: '',
amount: noteDeclaredAmount(url) ?? 0,
verified: false
}
}
}
// After receiving a note, rotate it: the previous holder (and anything that
// logged the URL in transit, since the informational GET above already put
// k1 on the wire) still knows the old secret - a rotate burns it and mints
// a fresh one only this wallet knows. Returns the updated note URL. Throws
// when the service refuses (e.g. a plain LUD-03 withdraw link that doesn't
// speak lnurlcash) - the caller should warn, not fail the receive.
export const secureReceivedNote = async (note: {
url: string
callback: string
amount: number
}): Promise<string> => {
const k1 = noteK1(note.url)
if (!k1 || !note.callback) {
throw new Error('Note has no callback to rotate against yet.')
}
const result = await rotateNote(note.callback, k1)
return withNewK1(note.url, result.k1, note.amount, result.signature)
}
+49
View File
@@ -0,0 +1,49 @@
// Shared note types for the protocol core. In lnurl-wallet these lived in
// storage.ts (Bearer) and WalletContext.tsx (NewBearer); here they are
// extracted framework-free so receive.ts doesn't pull in app state modules.
// One bearer note held by this wallet - the decrypted, in-memory shape.
// `url` is the note's withdraw LNURL with the secret as its k1 param (so it
// IS the asset); the displayable bech32/lnurlw:// forms are re-encoded from
// it on demand.
export type Bearer = {
id: string
url: string
callback: string // the mutating callback from the withdrawRequest JSON, '' until first verified
amount: number // msat, last known (maxWithdrawable) - refreshed on demand
verified: boolean // false while the issuing service hasn't confirmed the note yet
// the issuing service's signing pubkey, cached once seen (withdrawRequest/
// payRequest's optional mintPubkey) - lets a note's ?sig= be checked
// offline against it without a network round trip
mintPubkey?: string
// a local-only lock, not a server-verified state: true once this wallet
// has melted/handed over the note, or the holder marked it manually. It
// just disables further mutating actions here so this copy can't be
// reused by accident - it says nothing about whether the service has
// actually burned it yet
spent?: boolean
// manual display order within its mint group - absent means "never
// manually placed", which sorts by -createdAt instead, i.e. newest first
sortIndex?: number
// a free-text note the holder can attach for their own reference (e.g.
// "rent", "gift for Alex") - purely local, never sent anywhere, no
// protocol meaning at all
label?: string
// present if this note's secret lives on a paired LNURLvault device,
// never in this browser's storage - the device's own note id. When set,
// `url` never carries a real k1 (see lnurlcash.ts's withoutK1) - it's a
// blank mirror, kept only so this bearer displays like any other
// (amount/host/label/state)
deviceId?: string
createdAt: number
updatedAt: number
}
export type NewBearer = {
url: string
callback: string
amount: number
verified: boolean
mintPubkey?: string
deviceId?: string
}