mirror of
https://github.com/tompro/sattle.git
synced 2026-08-27 07:15:59 +00:00
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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user