Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
253 changes: 225 additions & 28 deletions darepod/seed_storage_wasm.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,60 +3,257 @@
package darepod

import (
"encoding/base64"
"errors"
"fmt"
"strings"
"syscall/js"
)

const wasmSeedStoragePrefix = "darepod:encrypted-seed:"
// The encrypted seed is persisted as a single file in the origin-private file
// system (OPFS). OPFS is reachable from both the window and worker globals,
// unlike localStorage which is window-only, so the daemon can run inside a Web
// Worker. SeedFilePath produces a "/dir/dir/wallet_seed.enc" path; it is split
// on "/" and walked as OPFS directory handles, persisting the seed at that
// nested path in the OPFS origin (the daemon's SQLite data is stored
// separately, under hashed flat names at the OPFS root). These functions block
// the calling goroutine on the async OPFS promises, so they keep their
// synchronous signatures; they must run off the JS call stack (the verb
// goroutines spawned by promise() satisfy this).

// SaveEncryptedSeed stores the encrypted seed in browser localStorage. The
// errOPFSUnavailable is returned when the OPFS API is absent from the current
// JS context. This is a deterministic capability gap rather than a transient
// failure, so callers can safely treat it as "no seed has ever been persisted".
var errOPFSUnavailable = errors.New("OPFS (navigator.storage.getDirectory) " +
"is unavailable")

// opfsError wraps a rejected OPFS promise, preserving the DOMException name
// (e.g. "NotFoundError") alongside its string form so callers can tell a
// genuinely-absent file apart from an ambiguous runtime failure.
type opfsError struct {
name string
msg string
}

// Error returns the string form of the underlying OPFS rejection.
func (e *opfsError) Error() string {
return e.msg
}

// isNotFound reports whether err is an OPFS rejection caused by a missing file
// or directory (a DOMException with name "NotFoundError").
func isNotFound(err error) bool {
var oerr *opfsError

return errors.As(err, &oerr) && oerr.name == "NotFoundError"
}

// awaitJSPromise blocks the calling goroutine until the JS promise settles and
// returns its resolved value, or an error built from the rejection reason. The
// channel is buffered so the settle callback never blocks the JS event loop. p
// must be a thenable (a real Promise); every OPFS caller here passes one.
func awaitJSPromise(p js.Value) (js.Value, error) {
type settled struct {
value js.Value
err error
}
ch := make(chan settled, 1)

onResolve := js.FuncOf(func(_ js.Value, args []js.Value) any {
var v js.Value
if len(args) > 0 {
v = args[0]
}
ch <- settled{value: v}

return nil
})
defer onResolve.Release()

onReject := js.FuncOf(func(_ js.Value, args []js.Value) any {
oerr := &opfsError{msg: "promise rejected"}
if len(args) > 0 && args[0].Truthy() {
reason := args[0]
oerr.msg = reason.Call("toString").String()

// DOMExceptions carry the failure kind on .name
// (e.g. "NotFoundError"); preserve it so callers can
// distinguish a missing file from other failures.
if n := reason.Get("name"); n.Type() == js.TypeString {
oerr.name = n.String()
}
}
ch <- settled{err: oerr}

return nil
})
defer onReject.Release()

p.Call("then", onResolve, onReject)

res := <-ch

return res.value, res.err
}

// opfsRootDir resolves the OPFS root directory handle, returning an error when
// OPFS is unavailable in the current context.
func opfsRootDir() (js.Value, error) {
storage := js.Global().Get("navigator").Get("storage")
if !storage.Truthy() || storage.Get("getDirectory").IsUndefined() {
return js.Value{}, errOPFSUnavailable
}

return awaitJSPromise(storage.Call("getDirectory"))
}

// splitSeedPath splits a "/a/b/file" seed path into its directory segments and
// the final file name, skipping empty segments.
func splitSeedPath(path string) (dirs []string, file string) {
var parts []string
for _, p := range strings.Split(path, "/") {
if p != "" {
parts = append(parts, p)
}
}

if len(parts) == 0 {
return nil, ""
}

return parts[:len(parts)-1], parts[len(parts)-1]
}

// opfsSeedDir walks the OPFS directory chain for the seed path, optionally
// creating it, and returns the parent directory handle and the file name.
func opfsSeedDir(path string, create bool) (js.Value, string, error) {
dirs, file := splitSeedPath(path)
if file == "" {
return js.Value{}, "", fmt.Errorf("invalid seed path %q", path)
}

dir, err := opfsRootDir()
if err != nil {
return js.Value{}, "", err
}

opts := map[string]any{"create": create}
for _, name := range dirs {
dir, err = awaitJSPromise(
dir.Call("getDirectoryHandle", name, opts),
)
if err != nil {
return js.Value{}, "", err
}
}

return dir, file, nil
}

// openSeedFile resolves the handle for an existing seed file, returning the
// underlying OPFS error (e.g. a NotFoundError) when the file or one of its
// parent directories is absent.
func openSeedFile(path string) (js.Value, error) {
dir, file, err := opfsSeedDir(path, false)
if err != nil {
return js.Value{}, err
}

return awaitJSPromise(
dir.Call(
"getFileHandle", file, map[string]any{
"create": false,
},
),
)
}

// SaveEncryptedSeed writes the encrypted seed ciphertext to its OPFS file. The
// payload is already password-encrypted by EncryptSeed before this boundary.
func SaveEncryptedSeed(path string, ciphertext []byte) error {
storage := js.Global().Get("localStorage")
if storage.IsUndefined() || storage.IsNull() {
return fmt.Errorf("browser localStorage is unavailable")
dir, file, err := opfsSeedDir(path, true)
if err != nil {
return fmt.Errorf("opening seed directory for %q: %w", path,
err)
}

storage.Call(
"setItem", wasmSeedStoragePrefix+path,
base64.StdEncoding.EncodeToString(ciphertext),
handle, err := awaitJSPromise(
dir.Call(
"getFileHandle", file, map[string]any{
"create": true,
},
),
)
if err != nil {
return fmt.Errorf("creating seed file %q: %w", path, err)
}

writable, err := awaitJSPromise(handle.Call("createWritable"))
if err != nil {
return fmt.Errorf("opening seed file %q for write: %w", path,
err)
}

buf := js.Global().Get("Uint8Array").New(len(ciphertext))
js.CopyBytesToJS(buf, ciphertext)

// A writable stream holds an exclusive lock on the file until it is
// closed, so close it unconditionally even when the write fails;
// otherwise the leaked lock blocks later reads and writes until the
// stream is garbage-collected. The write error takes precedence.
_, writeErr := awaitJSPromise(writable.Call("write", buf))
_, closeErr := awaitJSPromise(writable.Call("close"))

if writeErr != nil {
return fmt.Errorf("writing seed file %q: %w", path, writeErr)
}
if closeErr != nil {
return fmt.Errorf("closing seed file %q: %w", path, closeErr)
}

return nil
}

// LoadEncryptedSeed reads the encrypted seed ciphertext from browser
// localStorage.
// LoadEncryptedSeed reads the encrypted seed ciphertext from its OPFS file.
func LoadEncryptedSeed(path string) ([]byte, error) {
storage := js.Global().Get("localStorage")
if storage.IsUndefined() || storage.IsNull() {
return nil, fmt.Errorf("browser localStorage is unavailable")
handle, err := openSeedFile(path)
if err != nil {
return nil, fmt.Errorf("reading seed file %q: %w", path, err)
}

value := storage.Call("getItem", wasmSeedStoragePrefix+path)
if value.IsNull() || value.IsUndefined() {
return nil, fmt.Errorf("reading seed file %q: not found", path)
fileObj, err := awaitJSPromise(handle.Call("getFile"))
if err != nil {
return nil, fmt.Errorf("opening seed file %q: %w", path, err)
}

data, err := base64.StdEncoding.DecodeString(value.String())
bufVal, err := awaitJSPromise(fileObj.Call("arrayBuffer"))
if err != nil {
return nil, fmt.Errorf("decode seed file %q: %w", path, err)
return nil, fmt.Errorf("reading seed file %q: %w", path, err)
}

arr := js.Global().Get("Uint8Array").New(bufVal)
data := make([]byte, arr.Get("length").Int())
js.CopyBytesToGo(data, arr)

return data, nil
}

// SeedFileExists returns true if an encrypted seed exists in browser
// localStorage for the network data directory.
// SeedFileExists reports whether an encrypted seed file exists in OPFS for the
// network data directory. It returns false only when absence is confirmed: a
// NotFoundError for the file or one of its parent directories, or OPFS being
// unavailable (so nothing could ever have been persisted). Any other error is
// ambiguous (transient I/O, quota, a handle locked by a concurrent writer), so
// the seed is reported as present to keep the daemon out of the "no wallet"
// state, where a later InitWallet would overwrite a seed that may exist.
func SeedFileExists(networkDir string) bool {
path := SeedFilePath(networkDir)
storage := js.Global().Get("localStorage")
if storage.IsUndefined() || storage.IsNull() {
return false
}
_, err := openSeedFile(SeedFilePath(networkDir))
switch {
case err == nil:
return true

value := storage.Call("getItem", wasmSeedStoragePrefix+path)
case isNotFound(err), errors.Is(err, errOPFSUnavailable):
return false

return !value.IsNull() && !value.IsUndefined()
default:
return true
}
}
Loading