From 4e8ca21fbfa5597a31bbd0a0b6b81f86d41aa76e Mon Sep 17 00:00:00 2001 From: the-code-learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:41:49 +0200 Subject: [PATCH 01/57] harden public HTTP handler --- internal/api/public_handler.go | 37 ++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 internal/api/public_handler.go diff --git a/internal/api/public_handler.go b/internal/api/public_handler.go new file mode 100644 index 00000000..c28d70e3 --- /dev/null +++ b/internal/api/public_handler.go @@ -0,0 +1,37 @@ +package api + +import ( + "net/http" + "strings" +) + +// PublicHandlerOptions controls which development-only surfaces are exposed by +// the process-facing HTTP handler. Development endpoints are disabled unless +// explicitly enabled by the caller. +type PublicHandlerOptions struct { + EnableDevEndpoints bool +} + +// PublicHandler wraps the internal mux with process-boundary protections that +// should apply to externally exposed node HTTP traffic. +func (s *Server) PublicHandler(options PublicHandlerOptions) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !options.EnableDevEndpoints && strings.HasPrefix(r.URL.Path, "/v1/dev/") { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "development endpoint disabled"}) + return + } + + if r.URL.Path == "/v1/internal/snapshot" { + if requestSourceNode(r) == "" { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "internal snapshot requires a peer source"}) + return + } + if err := s.validatePeerRequest(r); err != nil { + writeJSON(w, statusForError(err), map[string]string{"error": err.Error()}) + return + } + } + + s.mux.ServeHTTP(w, r) + }) +} From 6c350533815f3e5104e093d1ab57e259a4b2dba7 Mon Sep 17 00:00:00 2001 From: the-code-learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:42:02 +0200 Subject: [PATCH 02/57] test public HTTP hardening --- internal/api/public_handler_test.go | 82 +++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 internal/api/public_handler_test.go diff --git a/internal/api/public_handler_test.go b/internal/api/public_handler_test.go new file mode 100644 index 00000000..b7f2c69f --- /dev/null +++ b/internal/api/public_handler_test.go @@ -0,0 +1,82 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestPublicHandlerBlocksDevelopmentEndpointsByDefault(t *testing.T) { + server, err := NewServerWithConfig(Config{ + DataDir: t.TempDir(), + BlockInterval: 0, + SyncInterval: 0, + EnableBlockProduction: false, + EnablePeerSync: false, + }) + if err != nil { + t.Fatalf("create server: %v", err) + } + defer server.Close() + + request := httptest.NewRequest(http.MethodPost, "/v1/dev/faucet", nil) + recorder := httptest.NewRecorder() + server.PublicHandler(PublicHandlerOptions{}).ServeHTTP(recorder, request) + + if recorder.Code != http.StatusNotFound { + t.Fatalf("expected disabled dev endpoint status 404, got %d", recorder.Code) + } +} + +func TestPublicHandlerKeepsHealthPublic(t *testing.T) { + server, err := NewServerWithConfig(Config{ + DataDir: t.TempDir(), + BlockInterval: 0, + SyncInterval: 0, + EnableBlockProduction: false, + EnablePeerSync: false, + }) + if err != nil { + t.Fatalf("create server: %v", err) + } + defer server.Close() + + request := httptest.NewRequest(http.MethodGet, "/health", nil) + recorder := httptest.NewRecorder() + server.PublicHandler(PublicHandlerOptions{}).ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("expected health status 200, got %d", recorder.Code) + } +} + +func TestPublicHandlerRequiresPeerSourceForInternalSnapshot(t *testing.T) { + server, err := NewServerWithConfig(Config{ + DataDir: t.TempDir(), + BlockInterval: 0, + SyncInterval: 0, + EnableBlockProduction: false, + EnablePeerSync: false, + }) + if err != nil { + t.Fatalf("create server: %v", err) + } + defer server.Close() + + request := httptest.NewRequest(http.MethodGet, "/v1/internal/snapshot", nil) + recorder := httptest.NewRecorder() + server.PublicHandler(PublicHandlerOptions{}).ServeHTTP(recorder, request) + + if recorder.Code != http.StatusForbidden { + t.Fatalf("expected internal snapshot status 403 without peer source, got %d", recorder.Code) + } + + request = httptest.NewRequest(http.MethodGet, "/v1/internal/snapshot", nil) + request.Header.Set(sourceNodeHeader, "peer-node") + recorder = httptest.NewRecorder() + server.PublicHandler(PublicHandlerOptions{}).ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("expected internal snapshot status 200 for peer source when strict identity is disabled, got %d", recorder.Code) + } +} From f56f006b3a5428e84ac8da4e984a63c317fd0463 Mon Sep 17 00:00:00 2001 From: the-code-learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:42:20 +0200 Subject: [PATCH 03/57] sign internal peer snapshot requests --- internal/api/peer_transport.go | 41 ++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 12 deletions(-) diff --git a/internal/api/peer_transport.go b/internal/api/peer_transport.go index d04c4b0e..bf10284e 100644 --- a/internal/api/peer_transport.go +++ b/internal/api/peer_transport.go @@ -68,7 +68,15 @@ func (t *httpPeerTransport) FetchBlock(peerURL string, height uint64) (ledger.Bl } func (t *httpPeerTransport) FetchSnapshot(peerURL string) (ledger.Snapshot, error) { - response, err := t.client.Get(peerURL + "/v1/internal/snapshot") + request, err := http.NewRequest(http.MethodGet, peerURL+"/v1/internal/snapshot", nil) + if err != nil { + return ledger.Snapshot{}, err + } + if err := t.applyPeerHeaders(request); err != nil { + return ledger.Snapshot{}, err + } + + response, err := t.client.Do(request) if err != nil { return ledger.Snapshot{}, err } @@ -115,17 +123,8 @@ func (t *httpPeerTransport) postJSON(target string, payload any) error { return err } request.Header.Set("Content-Type", "application/json") - request.Header.Set(sourceNodeHeader, t.sourceNode) - if t.identitySigner != nil { - identity, err := t.identitySigner.Build(time.Now().UTC()) - if err != nil { - return err - } - request.Header.Set(sourceValidatorHeader, identity.ValidatorAddress) - request.Header.Set(sourceIdentityPayloadHeader, identity.Payload) - request.Header.Set(sourcePublicKeyHeader, identity.PublicKey) - request.Header.Set(sourceSignatureHeader, identity.Signature) - request.Header.Set(sourceSignedAtHeader, identity.SignedAt.UTC().Format(time.RFC3339Nano)) + if err := t.applyPeerHeaders(request); err != nil { + return err } response, err := t.client.Do(request) @@ -138,3 +137,21 @@ func (t *httpPeerTransport) postJSON(target string, payload any) error { } return nil } + +func (t *httpPeerTransport) applyPeerHeaders(request *http.Request) error { + request.Header.Set(sourceNodeHeader, t.sourceNode) + if t.identitySigner == nil { + return nil + } + + identity, err := t.identitySigner.Build(time.Now().UTC()) + if err != nil { + return err + } + request.Header.Set(sourceValidatorHeader, identity.ValidatorAddress) + request.Header.Set(sourceIdentityPayloadHeader, identity.Payload) + request.Header.Set(sourcePublicKeyHeader, identity.PublicKey) + request.Header.Set(sourceSignatureHeader, identity.Signature) + request.Header.Set(sourceSignedAtHeader, identity.SignedAt.UTC().Format(time.RFC3339Nano)) + return nil +} From 68a5e1a7ebf3e749716a828dbe310efb0d1d1351 Mon Sep 17 00:00:00 2001 From: the-code-learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:42:43 +0200 Subject: [PATCH 04/57] disable development endpoints by default --- cmd/node/main.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/cmd/node/main.go b/cmd/node/main.go index 37e76216..27bcc79b 100644 --- a/cmd/node/main.go +++ b/cmd/node/main.go @@ -19,6 +19,7 @@ func main() { } config := api.DefaultConfig() + enableDevEndpoints := false if nodeID := os.Getenv("ZEPHYR_NODE_ID"); nodeID != "" { config.NodeID = nodeID } @@ -125,6 +126,13 @@ func main() { } config.RequireConsensusCertificates = parsed } + if enabled := os.Getenv("ZEPHYR_ENABLE_DEV_ENDPOINTS"); enabled != "" { + parsed, err := strconv.ParseBool(enabled) + if err != nil { + log.Fatalf("invalid ZEPHYR_ENABLE_DEV_ENDPOINTS %q", enabled) + } + enableDevEndpoints = parsed + } server, err := api.NewServerWithConfig(config) if err != nil { @@ -133,7 +141,7 @@ func main() { defer server.Close() log.Printf( - "zephyr node %s listening on %s (validator: %s, data dir: %s, block interval: %s, consensus automation: %t, consensus interval: %s, round timeout: %s, peer sync: %t, structured logs: %t, peer identity required: %t, peer bindings: %d, proposer schedule enforced: %t, consensus certificates required: %t, peers: %d)", + "zephyr node %s listening on %s (validator: %s, data dir: %s, block interval: %s, consensus automation: %t, consensus interval: %s, round timeout: %s, peer sync: %t, structured logs: %t, peer identity required: %t, peer bindings: %d, proposer schedule enforced: %t, consensus certificates required: %t, dev endpoints: %t, peers: %d)", config.NodeID, addr, config.ValidatorAddress, @@ -148,9 +156,12 @@ func main() { len(config.PeerValidatorBindings), config.EnforceProposerSchedule, config.RequireConsensusCertificates, + enableDevEndpoints, len(config.PeerURLs), ) - if err := http.ListenAndServe(addr, server.Handler()); err != nil { + + handler := server.PublicHandler(api.PublicHandlerOptions{EnableDevEndpoints: enableDevEndpoints}) + if err := http.ListenAndServe(addr, handler); err != nil { log.Fatalf("server stopped: %v", err) } } From b3fcc35bb2877932a87210f51b8d35ffff1af1c2 Mon Sep 17 00:00:00 2001 From: the-code-learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:42:54 +0200 Subject: [PATCH 05/57] add encrypted wallet backup types --- apps/wallet/src/types.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/apps/wallet/src/types.ts b/apps/wallet/src/types.ts index 5402aadc..69decf44 100644 --- a/apps/wallet/src/types.ts +++ b/apps/wallet/src/types.ts @@ -6,6 +6,23 @@ export type StoredAccount = { publicKeySpki: string } +export type EncryptedWalletBackup = { + version: 1 + address: string + createdAt: string + publicKeyJwk: JsonWebKey + publicKeySpki: string + encryption: { + algorithm: 'AES-GCM' + kdf: 'PBKDF2' + hash: 'SHA-256' + iterations: number + salt: string + iv: string + ciphertext: string + } +} + export type TransactionDraft = { from: string to: string From c30be6e5d8954acd9ce500ad5a60f951b458fc4b Mon Sep 17 00:00:00 2001 From: the-code-learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:43:41 +0200 Subject: [PATCH 06/57] encrypt wallet private keys at rest --- apps/wallet/src/lib/wallet.ts | 339 ++++++++++++++++++++++++++++++++-- 1 file changed, 319 insertions(+), 20 deletions(-) diff --git a/apps/wallet/src/lib/wallet.ts b/apps/wallet/src/lib/wallet.ts index 7ab71534..bf16948d 100644 --- a/apps/wallet/src/lib/wallet.ts +++ b/apps/wallet/src/lib/wallet.ts @@ -1,6 +1,16 @@ -import type { SignedTransactionEnvelope, StoredAccount, TransactionDraft } from '../types' +import type { + EncryptedWalletBackup, + SignedTransactionEnvelope, + StoredAccount, + TransactionDraft +} from '../types' -const STORAGE_KEY = 'zephyr.wallet.account' +const STORAGE_KEY = 'zephyr.wallet.encrypted-account.v1' +const LEGACY_STORAGE_KEY = 'zephyr.wallet.account' +const MIN_PASSPHRASE_LENGTH = 10 +const KDF_ITERATIONS = 310_000 +const MIN_KDF_ITERATIONS = 100_000 +const MAX_KDF_ITERATIONS = 1_000_000 export async function createAccount(): Promise { const keyPair = await crypto.subtle.generateKey( @@ -29,45 +39,78 @@ export async function createAccount(): Promise { } } -export function loadStoredAccount(): StoredAccount | null { - const raw = localStorage.getItem(STORAGE_KEY) - if (!raw) { - return null +export function hasStoredAccount(): boolean { + return localStorage.getItem(STORAGE_KEY) !== null || localStorage.getItem(LEGACY_STORAGE_KEY) !== null +} + +export function hasLegacyStoredAccount(): boolean { + return localStorage.getItem(STORAGE_KEY) === null && localStorage.getItem(LEGACY_STORAGE_KEY) !== null +} + +export function loadStoredBackup(): string | null { + return localStorage.getItem(STORAGE_KEY) +} + +export async function unlockStoredAccount(passphrase: string): Promise { + requirePassphrase(passphrase) + + const encryptedRaw = localStorage.getItem(STORAGE_KEY) + if (encryptedRaw) { + return decryptBackup(parseEncryptedBackup(encryptedRaw), passphrase) } - try { - return JSON.parse(raw) as StoredAccount - } catch { - localStorage.removeItem(STORAGE_KEY) - return null + const legacyRaw = localStorage.getItem(LEGACY_STORAGE_KEY) + if (!legacyRaw) { + throw new Error('No stored wallet found') } + + const legacyAccount = await parseLegacyAccount(legacyRaw) + await saveAccount(legacyAccount, passphrase) + return legacyAccount } -export function saveAccount(account: StoredAccount): void { - localStorage.setItem(STORAGE_KEY, JSON.stringify(account)) +export async function saveAccount(account: StoredAccount, passphrase: string): Promise { + const raw = await exportAccount(account, passphrase) + localStorage.setItem(STORAGE_KEY, raw) + localStorage.removeItem(LEGACY_STORAGE_KEY) + return raw } export function clearAccount(): void { localStorage.removeItem(STORAGE_KEY) + localStorage.removeItem(LEGACY_STORAGE_KEY) } -export function exportAccount(account: StoredAccount): string { - return JSON.stringify(account, null, 2) +export async function exportAccount(account: StoredAccount, passphrase: string): Promise { + requirePassphrase(passphrase) + await assertAccountIntegrity(account) + const backup = await encryptAccount(account, passphrase) + return JSON.stringify(backup, null, 2) } -export function importAccount(raw: string): StoredAccount { - const parsed = JSON.parse(raw) as StoredAccount - if (!parsed.address || !parsed.publicKeyJwk || !parsed.privateKeyJwk || !parsed.publicKeySpki) { - throw new Error('Invalid wallet backup') +export async function importAccount(raw: string, passphrase: string): Promise { + requirePassphrase(passphrase) + + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + throw new Error('Wallet backup is not valid JSON') } - return parsed + if (isEncryptedBackupCandidate(parsed)) { + return decryptBackup(parseEncryptedBackup(raw), passphrase) + } + + return parseLegacyAccount(raw) } export async function signTransaction( account: StoredAccount, draft: TransactionDraft ): Promise { + await assertAccountIntegrity(account) + const privateKey = await crypto.subtle.importKey( 'jwk', account.privateKeyJwk, @@ -104,6 +147,244 @@ export async function signTransaction( } } +async function encryptAccount(account: StoredAccount, passphrase: string): Promise { + const salt = crypto.getRandomValues(new Uint8Array(16)) + const iv = crypto.getRandomValues(new Uint8Array(12)) + const key = await deriveEncryptionKey(passphrase, salt) + + const metadata = { + version: 1 as const, + address: account.address, + createdAt: account.createdAt, + publicKeyJwk: account.publicKeyJwk, + publicKeySpki: account.publicKeySpki + } + + const plaintext = new TextEncoder().encode(JSON.stringify(account.privateKeyJwk)) + const ciphertext = await crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv, + additionalData: new TextEncoder().encode(walletAAD(metadata)), + tagLength: 128 + }, + key, + plaintext + ) + + return { + ...metadata, + encryption: { + algorithm: 'AES-GCM', + kdf: 'PBKDF2', + hash: 'SHA-256', + iterations: KDF_ITERATIONS, + salt: bytesToBase64(salt), + iv: bytesToBase64(iv), + ciphertext: bytesToBase64(new Uint8Array(ciphertext)) + } + } +} + +async function decryptBackup(backup: EncryptedWalletBackup, passphrase: string): Promise { + const salt = base64ToBytes(backup.encryption.salt) + const iv = base64ToBytes(backup.encryption.iv) + const ciphertext = base64ToBytes(backup.encryption.ciphertext) + const key = await deriveEncryptionKey(passphrase, salt, backup.encryption.iterations) + + let plaintext: ArrayBuffer + try { + plaintext = await crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv, + additionalData: new TextEncoder().encode(walletAAD(backup)), + tagLength: 128 + }, + key, + ciphertext + ) + } catch { + throw new Error('Invalid wallet passphrase or corrupted backup') + } + + let privateKeyJwk: JsonWebKey + try { + privateKeyJwk = JSON.parse(new TextDecoder().decode(plaintext)) as JsonWebKey + } catch { + throw new Error('Encrypted wallet payload is corrupted') + } + + const account: StoredAccount = { + address: backup.address, + createdAt: backup.createdAt, + publicKeyJwk: backup.publicKeyJwk, + privateKeyJwk, + publicKeySpki: backup.publicKeySpki + } + + await assertAccountIntegrity(account) + return account +} + +async function deriveEncryptionKey( + passphrase: string, + salt: Uint8Array, + iterations = KDF_ITERATIONS +): Promise { + const keyMaterial = await crypto.subtle.importKey( + 'raw', + new TextEncoder().encode(passphrase), + 'PBKDF2', + false, + ['deriveKey'] + ) + + return crypto.subtle.deriveKey( + { + name: 'PBKDF2', + hash: 'SHA-256', + salt, + iterations + }, + keyMaterial, + { + name: 'AES-GCM', + length: 256 + }, + false, + ['encrypt', 'decrypt'] + ) +} + +function parseEncryptedBackup(raw: string): EncryptedWalletBackup { + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + throw new Error('Wallet backup is not valid JSON') + } + + if (!isEncryptedBackupCandidate(parsed)) { + throw new Error('Invalid encrypted wallet backup') + } + + const backup = parsed as EncryptedWalletBackup + if ( + !backup.address || + !backup.createdAt || + !backup.publicKeyJwk || + !backup.publicKeySpki || + backup.encryption.algorithm !== 'AES-GCM' || + backup.encryption.kdf !== 'PBKDF2' || + backup.encryption.hash !== 'SHA-256' || + !backup.encryption.salt || + !backup.encryption.iv || + !backup.encryption.ciphertext + ) { + throw new Error('Invalid encrypted wallet backup') + } + + if ( + !Number.isInteger(backup.encryption.iterations) || + backup.encryption.iterations < MIN_KDF_ITERATIONS || + backup.encryption.iterations > MAX_KDF_ITERATIONS + ) { + throw new Error('Unsupported wallet key-derivation parameters') + } + + return backup +} + +async function parseLegacyAccount(raw: string): Promise { + let parsed: StoredAccount + try { + parsed = JSON.parse(raw) as StoredAccount + } catch { + throw new Error('Wallet backup is not valid JSON') + } + + await assertAccountIntegrity(parsed) + return parsed +} + +function isEncryptedBackupCandidate(value: unknown): value is EncryptedWalletBackup { + if (!value || typeof value !== 'object') { + return false + } + + const candidate = value as Partial + return candidate.version === 1 && !!candidate.encryption && typeof candidate.encryption === 'object' +} + +async function assertAccountIntegrity(account: StoredAccount): Promise { + if ( + !account || + !account.address || + !account.createdAt || + !account.publicKeyJwk || + !account.privateKeyJwk || + !account.publicKeySpki + ) { + throw new Error('Invalid wallet backup') + } + + if ( + account.publicKeyJwk.kty !== 'EC' || + account.publicKeyJwk.crv !== 'P-256' || + account.privateKeyJwk.kty !== 'EC' || + account.privateKeyJwk.crv !== 'P-256' || + !account.privateKeyJwk.d || + account.publicKeyJwk.x !== account.privateKeyJwk.x || + account.publicKeyJwk.y !== account.privateKeyJwk.y + ) { + throw new Error('Wallet key material is inconsistent') + } + + const publicKeyBytes = base64ToBytes(account.publicKeySpki) + const publicKeyBuffer = bytesToArrayBuffer(publicKeyBytes) + const derivedAddress = await deriveAddress(publicKeyBuffer) + if (derivedAddress !== account.address) { + throw new Error('Wallet address does not match its public key') + } + + try { + await Promise.all([ + crypto.subtle.importKey( + 'jwk', + account.publicKeyJwk, + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + ['verify'] + ), + crypto.subtle.importKey( + 'jwk', + account.privateKeyJwk, + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + ['sign'] + ) + ]) + } catch { + throw new Error('Wallet key material is invalid') + } +} + +function requirePassphrase(passphrase: string): void { + if (passphrase.length < MIN_PASSPHRASE_LENGTH) { + throw new Error(`Wallet passphrase must be at least ${MIN_PASSPHRASE_LENGTH} characters`) + } +} + +function walletAAD(value: Pick): string { + return canonicalize({ + version: value.version, + address: value.address, + createdAt: value.createdAt, + publicKeySpki: value.publicKeySpki + }) +} + async function deriveAddress(publicKeySpki: ArrayBuffer): Promise { const digest = await crypto.subtle.digest('SHA-256', publicKeySpki) const hex = bytesToHex(new Uint8Array(digest)) @@ -143,3 +424,21 @@ function bytesToBase64(bytes: Uint8Array): string { return btoa(binary) } +function base64ToBytes(value: string): Uint8Array { + let binary: string + try { + binary = atob(value) + } catch { + throw new Error('Wallet backup contains invalid base64 data') + } + + const bytes = new Uint8Array(binary.length) + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index) + } + return bytes +} + +function bytesToArrayBuffer(bytes: Uint8Array): ArrayBuffer { + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer +} From 914dd11cfcbe6b0d9b3b02ef732de4c36929647f Mon Sep 17 00:00:00 2001 From: the-code-learner <142033899+the-code-learner@users.noreply.github.com> Date: Tue, 18 Aug 2026 14:44:58 +0200 Subject: [PATCH 07/57] add encrypted wallet unlock flow --- apps/wallet/src/App.vue | 140 +++++++++++++++++++++++++++++----------- 1 file changed, 103 insertions(+), 37 deletions(-) diff --git a/apps/wallet/src/App.vue b/apps/wallet/src/App.vue index a4a33d89..8d4d6b1c 100644 --- a/apps/wallet/src/App.vue +++ b/apps/wallet/src/App.vue @@ -4,11 +4,13 @@ import { broadcastTransaction, fetchAccount, fundAccount, pingNode } from './lib import { clearAccount, createAccount, - exportAccount, + hasLegacyStoredAccount, + hasStoredAccount, importAccount, - loadStoredAccount, + loadStoredBackup, saveAccount, - signTransaction + signTransaction, + unlockStoredAccount } from './lib/wallet' import type { AccountView, @@ -22,10 +24,13 @@ const apiBase = ref(import.meta.env.VITE_ZEPHYR_API_BASE ?? 'http://localhost:80 const account = ref(null) const accountView = ref(null) const backupDraft = ref('') +const walletPassphrase = ref('') +const storedWalletPresent = ref(false) +const legacyWalletPresent = ref(false) const signedEnvelope = ref(null) const networkResponse = ref(null) const networkHealthy = ref(null) -const statusMessage = ref('Create or import an account to begin.') +const statusMessage = ref('Create, unlock, or import an account to begin.') const faucetAmount = ref(100) const isBusy = ref(false) const isRefreshing = ref(false) @@ -40,7 +45,7 @@ const form = ref({ const shortAddress = computed(() => { if (!account.value) { - return 'No wallet loaded' + return storedWalletPresent.value ? 'Encrypted wallet locked' : 'No wallet loaded' } return `${account.value.address.slice(0, 12)}...${account.value.address.slice(-6)}` @@ -65,12 +70,14 @@ const balancePill = computed(() => { }) onMounted(async () => { - const existing = loadStoredAccount() - if (existing) { - account.value = existing - form.value.from = existing.address - backupDraft.value = exportAccount(existing) - statusMessage.value = 'Recovered wallet from local storage.' + storedWalletPresent.value = hasStoredAccount() + legacyWalletPresent.value = hasLegacyStoredAccount() + + if (legacyWalletPresent.value) { + statusMessage.value = + 'Legacy unencrypted wallet storage detected. Enter a passphrase and unlock it to migrate the private key into encrypted storage.' + } else if (storedWalletPresent.value) { + statusMessage.value = 'Encrypted wallet found on this device. Enter its passphrase to unlock it.' } await refreshNodeState(false) @@ -93,7 +100,9 @@ async function refreshNodeState(updateStatus = true) { } if (!account.value) { - statusMessage.value = 'Node is online. Create or import a wallet to inspect account state.' + statusMessage.value = storedWalletPresent.value + ? 'Node is online. Unlock the encrypted wallet to inspect account state.' + : 'Node is online. Create or import a wallet to inspect account state.' return } @@ -135,20 +144,52 @@ async function refreshAccount() { } } +async function activateAccount(nextAccount: StoredAccount) { + account.value = nextAccount + form.value.from = nextAccount.address + form.value.nonce = 1 + signedEnvelope.value = null + networkResponse.value = null + storedWalletPresent.value = true + legacyWalletPresent.value = false + await refreshNodeState(false) +} + async function handleCreateWallet() { isBusy.value = true try { const nextAccount = await createAccount() - saveAccount(nextAccount) - account.value = nextAccount - form.value.from = nextAccount.address - form.value.nonce = 1 - backupDraft.value = exportAccount(nextAccount) - signedEnvelope.value = null - networkResponse.value = null - await refreshNodeState(false) - statusMessage.value = 'Fresh wallet created and stored locally on this device.' + backupDraft.value = await saveAccount(nextAccount, walletPassphrase.value) + await activateAccount(nextAccount) + walletPassphrase.value = '' + statusMessage.value = + 'Fresh wallet created. Its private key is encrypted at rest; keep the passphrase and encrypted backup safe.' + } catch (error) { + statusMessage.value = error instanceof Error ? error.message : 'Unable to create wallet.' + } finally { + isBusy.value = false + } +} + +async function handleUnlockWallet() { + if (!storedWalletPresent.value) { + statusMessage.value = 'No stored wallet is available to unlock.' + return + } + + isBusy.value = true + + try { + const nextAccount = await unlockStoredAccount(walletPassphrase.value) + await activateAccount(nextAccount) + backupDraft.value = loadStoredBackup() ?? '' + walletPassphrase.value = '' + statusMessage.value = legacyWalletPresent.value + ? 'Legacy wallet migrated into encrypted storage and unlocked.' + : 'Encrypted wallet unlocked for this browser session.' + } catch (error) { + statusMessage.value = error instanceof Error ? error.message : 'Unable to unlock wallet.' } finally { isBusy.value = false } @@ -158,14 +199,11 @@ async function handleImportWallet() { isBusy.value = true try { - const nextAccount = importAccount(backupDraft.value) - saveAccount(nextAccount) - account.value = nextAccount - form.value.from = nextAccount.address - signedEnvelope.value = null - networkResponse.value = null - await refreshNodeState(false) - statusMessage.value = 'Wallet backup imported successfully.' + const nextAccount = await importAccount(backupDraft.value, walletPassphrase.value) + backupDraft.value = await saveAccount(nextAccount, walletPassphrase.value) + await activateAccount(nextAccount) + walletPassphrase.value = '' + statusMessage.value = 'Wallet backup imported and stored locally in encrypted form.' } catch (error) { statusMessage.value = error instanceof Error ? error.message : 'Failed to import wallet backup.' } finally { @@ -177,17 +215,20 @@ function handleClearWallet() { clearAccount() account.value = null accountView.value = null + storedWalletPresent.value = false + legacyWalletPresent.value = false signedEnvelope.value = null networkResponse.value = null form.value.from = '' form.value.nonce = 1 backupDraft.value = '' + walletPassphrase.value = '' statusMessage.value = 'Wallet removed from local storage.' } async function handleFundAccount() { if (!account.value) { - statusMessage.value = 'Create or import a wallet before funding it.' + statusMessage.value = 'Create, unlock, or import a wallet before funding it.' return } @@ -216,7 +257,7 @@ function applySuggestedNonce() { async function handleSignTransaction() { if (!account.value) { - statusMessage.value = 'Create or import a wallet before signing.' + statusMessage.value = 'Create, unlock, or import a wallet before signing.' return } @@ -225,7 +266,7 @@ async function handleSignTransaction() { try { const envelope = await signTransaction(account.value, form.value) signedEnvelope.value = envelope - statusMessage.value = 'Transaction signed locally using your device key.' + statusMessage.value = 'Transaction signed locally using the unlocked device key.' } catch (error) { statusMessage.value = error instanceof Error ? error.message : 'Unable to sign transaction.' } finally { @@ -259,8 +300,9 @@ async function handleBroadcast() {

Zephyr Chain / Phase 1 MVP

Light wallet control without heavy infrastructure.

- This starter wallet keeps keys on-device, signs transactions in the browser, inspects the - current node account view, and can use a local dev faucet to exercise the hardened API flow. + This starter wallet keeps keys on-device, encrypts private key material at rest, signs + transactions in the browser, inspects the current node account view, and can use an explicitly + enabled local dev faucet to exercise the hardened API flow.

Wallet address: {{ shortAddress }} @@ -289,8 +331,32 @@ async function handleBroadcast() { + +

+ The passphrase never leaves the browser. It derives an AES-GCM key used to encrypt private + key material before it is written to local storage or exported as a backup. +

+
+ @@ -300,18 +366,19 @@ async function handleBroadcast() {