I - Why develop desktop applications today?
This is a question that all developers have asked themselves, especially if they come from the webdev world: "If I can run almost anything that will render in the browser and serve almost any purpose I want, who would need to download our application and run it on their computer?". But aside from the obvious requirement of the work we are doing (for ourselves or for a company, e.g. being able to make use of all the OS features, better performance, offline capabilities, improved security and integration, etc.), there is the experience that we as developers gain from touching new aspects of programming that will always enrich us.
If you are passionate about Golang, like me, and you have developed backend in this language, but you have also done frontend with HTML, CSS and JavaScript (or some of its frameworks) this post is for you, because without needing to learn a new technology you are more than capable of creating desktop applications.
II - The answer is called Wails
Chances are you already know Electron or Tauri. Both use web technologies for the frontend; the first one uses JavaScript (or rather, NodeJs) in its backend, and the second one uses Rust. But both have more or less notable drawbacks. Electron apps have very large binaries (because they package an entire Chromium browser) and consume a lot of memory. Tauri apps improve these aspects (among other things, because they use WebView2 [Windows]/WebKit [macOS & Linux] instead of Chromium), but the binary is still relatively large and their compilation times are… are those of Rust 😰 (not to mention their learning curve, although I love Rust, I really mean it 😀).
By using Wails, you get the best of all these worlds of desktop application development with web technologies that I just described, plus all the advantages that come with using Go:
- easy to learn and extraordinarily expressive language,
- fast execution and, above all, fast compilation,
- "out of the box" cross-compilation,
- small binaries that run with moderate memory consumption (as an example, the application we will develop here would be ~100 Mb with Electron and with other native GUI frameworks like Fyne, ~20 Mb; with Wails it is only 4 Mb 😀!!),
- and the possibility to use the web framework of your choice (even Vanilla JS) with which you get the ease of designing "modern" UIs that improve the user experience.
Yes, if I wanted to use Go to create desktop applications there are other possibilities (native or not). I would mention Fyne and go-gtk. Fyne is a GUI framework that allows the creation of native apps easily and although they may have an elegant design, the capabilities of the framework are somewhat limited or require a great effort from the developer to achieve the same thing that other tools and/or languages would allow you to do easily. I can say the same about go-gtk, which is a Go binding for GTK: yes, it is true that you will get native applications whose limits will be in your own capabilities, but getting into the GTK library is like going on an expedition through the jungle 😰…
III - An approach to Wails: Nu-i uita - Minimalist password manager
First of all, for those who are wondering what Nu-i uita means: in Romanian it roughly means "don't forget them". I thought it was an original name...
You can see the entire code of the application in this GitHub repository. If you want to try it out right away, you can download the executable from here (for Windows & Linux).
I will briefly describe how the application works: the user logs in for the first time and the login window asks him to enter a master password. This is saved encrypted using the password itself as the encryption key. This login window leads to another interface where the user can list the saved passwords for the corresponding websites and usernames used (you can also search in this list by username or website). You can click on each item in the list and see its details, copy it to the clipboard, edit it or delete it. Also, when adding new items it will encrypt your passwords using the master password as the key. In the configuration window you can choose the language of your choice (currently only English and Spanish), delete all stored data, export it or import it from a backup file. When importing data, the user will be asked for the master password that was used when the export was performed, and the imported data will now be saved and encrypted with the current master password. Subsequently, whenever the user logs back into the application, he or she will be prompted to enter the current master password.
I am not going to dwell on the requirements you need to use Wails because it is well explained in its excellent documentation. In any case, it is essential that you install its powerful CLI (go install github.com/wailsapp/wails/v2/cmd/wails@latest
), which allows you to generate a scaffolding for the application, hot-reload when editing code, and build executables (including cross-compilation).
The Wails CLI allows you to generate projects with a variety of frontend frameworks, but for some reason the creators of Wails seem to prefer Svelte... because it's the first option they mention. When you use the command wails init -n myproject -t svelte-ts
, you generate a project with Svelte3 and TypeScript.
If for some reason you prefer to use Svelte5 with its new runes system feature, I have created a bash script that automates the generation of projects with Svelte5. In this case, you will also need to have the Wails CLI installed.
The features of the application I mentioned above constitute the requirements of any todoapp (which is always a good way to learn something new in programming), but here we add a plus of features (e.g. both in the backend, the use of symmetric encryption, and in the frontend, use of Internationalization) that make it a little more useful and instructive than a simple todoapp.
Ok, enough of the introduction, so let's get down to business 😀.
IV - Wails project structure: an overview of how this framework works
If you choose to create a Wails project with Svelte+Typescript with the CLI by running the command wails init -n myproject -t svelte-ts
(or with the bash script I created and that I already told you about before, that generates Wails projects with Svelte5) you will have a directory structure very similar to this one:
.
├── app.go
├── build
│ ├── appicon.png
│ ├── darwin
│ │ ├── Info.dev.plist
│ │ └── Info.plist
│ ├── README.md
│ └── windows
│ ├── icon.ico
│ ├── info.json
│ ├── installer
│ │ ├── project.nsi
│ │ └── wails_tools.nsh
│ └── wails.exe.manifest
├── frontend
│ ├── index.html
│ ├── package.json
│ ├── package.json.md5
│ ├── package-lock.json
│ ├── postcss.config.js
│ ├── README.md
│ ├── src
│ │ ├── App.svelte
│ │ ├── assets
│ │ │ ├── fonts
│ │ │ │ ├── nunito-v16-latin-regular.woff2
│ │ │ │ └── OFL.txt
│ │ │ └── images
│ │ │ └── logo-universal.png
│ │ ├── lib
│ │ │ ├── BackBtn.svelte
│ │ │ ├── BottomActions.svelte
│ │ │ ├── EditActions.svelte
│ │ │ ├── EntriesList.svelte
│ │ │ ├── Language.svelte
│ │ │ ├── popups
│ │ │ │ ├── alert-icons.ts
│ │ │ │ └── popups.ts
│ │ │ ├── ShowPasswordBtn.svelte
│ │ │ └── TopActions.svelte
│ │ ├── locales
│ │ │ ├── en.json
│ │ │ └── es.json
│ │ ├── main.ts
│ │ ├── pages
│ │ │ ├── About.svelte
│ │ │ ├── AddPassword.svelte
│ │ │ ├── Details.svelte
│ │ │ ├── EditPassword.svelte
│ │ │ ├── Home.svelte
│ │ │ ├── Login.svelte
│ │ │ └── Settings.svelte
│ │ ├── style.css
│ │ └── vite-env.d.ts
│ ├── svelte.config.js
│ ├── tailwind.config.js
│ ├── tsconfig.json
│ ├── tsconfig.node.json
│ ├── vite.config.ts
│ └── wailsjs
│ ├── go
│ │ ├── main
│ │ │ ├── App.d.ts
│ │ │ └── App.js
│ │ └── models.ts
│ └── runtime
│ ├── package.json
│ ├── runtime.d.ts
│ └── runtime.js
├── go.mod
├── go.sum
├── internal
│ ├── db
│ │ └── db.go
│ └── models
│ ├── crypto.go
│ ├── master_password.go
│ └── password_entry.go
├── LICENSE
├── main.go
├── Makefile
├── README.md
├── scripts
└── wails.json
What you just saw is the finished application structure. The only difference with the one generated by the Wails CLI is that with it you will get the scaffolding of a Wails application with a Svelte3+TypeScript frontend, and with my script, in addition to having Svelte5, Tailwindcss+Daisyui is integrated.
But let's see how a Wails application works in general and at the same time particularizing the explanation for our case:
As Wails documentation says: "A Wails application is a standard Go application, with a webkit frontend. The Go part of the application consists of the application code and a runtime library that provides a number of useful operations, like controlling the application window. The frontend is a webkit window that will display the frontend assets". In short, and as we probably already know if we have created desktop applications with web technologies, very briefly explained, the application consists of a backend (in our case written in Go) and a frontend whose assets are managed by a Webkit window (in the case of Windows OS, Webview2), something like the essence of a web server/browser that serves/renders the frontend assets.
The main application in our specific case in which we want the application to be able to run on both Windows and Linux consists of the following code:
/* main.go */
package main
import (
"embed"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
"github.com/wailsapp/wails/v2/pkg/options/linux"
)
//go:embed all:frontend/dist
var assets embed.FS
//go:embed build/appicon.png
var icon []byte
func main() {
// Create an instance of the app structure
app := NewApp()
// Create application with options
err := wails.Run(&options.App{
// Title: "Nu-i uita • minimalist password manager",
Width: 450,
Height: 300,
DisableResize: true,
AssetServer: &assetserver.Options{
Assets: assets,
},
BackgroundColour: &options.RGBA{R: 27, G: 38, B: 54, A: 1},
OnStartup: app.startup,
OnBeforeClose: app.beforeClose,
Bind: []interface{}{
app,
},
// Linux platform specific options
Linux: &linux.Options{
Icon: icon,
// WindowIsTranslucent: true,
WebviewGpuPolicy: linux.WebviewGpuPolicyNever,
// ProgramName: "wails",
},
})
if err != nil {
println("Error:", err.Error())
}
}
The first thing we need to do is instantiate a struct (with the NewApp function), which we agreed to call App
, which must have a field with a Go Context. Then, wails' Run method is the one that starts the application. We need to pass it a series of options. One of these mandatory options is the assets
. Once Wails compiles the frontend, it generates it in the "frontend/dist" folder. Using the //go:embed all:frontend/dist
directive (that magical feature of Go) we can embed our entire frontend in the final executable. In the case of Linux, if we want to embed the application icon, we must also use the //go:embed
directive.
I won't go into the rest of the options, which you can check in the documentation. I'll just say two things related to the options. The first is that the title that appears in the application's title bar can be set here as an option, but in our application, where the user can choose the language they want, we will set them (using the Wails runtime) when we receive the language change event that the user may make. We'll see this later.
The second important option-related issue is the Bind option. The documentation explains its meaning very well: "The Bind option is one of the most important options in a Wails application. It specifies which struct methods to expose to the frontend. Think of structs like controllers in a traditional web application." Indeed: the public methods of the App structure, which are those that expose the backend to the frontend, perform the magic of "connecting" Go with JavaScript. Those public methods of said struct are converted into JavaScript functions that return a promise by the compilation performed by Wails.
The other important form of communication between the backend and the frontend (which we use effectively in this application) is events. Wails provides an event system, where events can be emitted or received by Go or JavaScript. Optionally, data can be passed with the events. Studying the way we use events in our application will lead us to analyze the struct App
:
/* app.go */
package main
import (
"context"
"github.com/emarifer/Nu-i-uita/internal/db"
"github.com/emarifer/Nu-i-uita/internal/models"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
// App struct
type App struct {
ctx context.Context
db *db.Db
selectedDirectory string
selectedFile string
}
// NewApp creates a new App application struct
func NewApp() *App {
db := db.NewDb()
return &App{db: db}
}
// startup is called when the app starts. The context is saved
// so we can call the runtime methods
func (a *App) startup(ctx context.Context) {
var fileLocation string
a.ctx = ctx
runtime.EventsOn(a.ctx, "change_titles", func(optionalData ...interface{}) {
if appTitle, ok := optionalData[0].(string); ok {
runtime.WindowSetTitle(a.ctx, appTitle)
}
if selectedDirectory, ok := optionalData[1].(string); ok {
a.selectedDirectory = selectedDirectory
}
if selectedFile, ok := optionalData[2].(string); ok {
a.selectedFile = selectedFile
}
})
runtime.EventsOn(a.ctx, "quit", func(optionalData ...interface{}) {
runtime.Quit(a.ctx)
})
runtime.EventsOn(a.ctx, "export_data", func(optionalData ...interface{}) {
d, _ := runtime.OpenDirectoryDialog(a.ctx, runtime.
OpenDialogOptions{
Title: a.selectedDirectory,
})
if d != "" {
f, err := a.db.GenerateDump(d)
if err != nil {
runtime.EventsEmit(a.ctx, "saved_as", err.Error())
return
}
runtime.EventsEmit(a.ctx, "saved_as", f)
}
})
runtime.EventsOn(a.ctx, "import_data", func(optionalData ...interface{}) {
fileLocation, _ = runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
Title: a.selectedFile,
})
// fmt.Println("SELECTED FILE:", fileLocation)
if fileLocation != "" {
runtime.EventsEmit(a.ctx, "enter_password")
}
})
runtime.EventsOn(a.ctx, "password", func(optionalData ...interface{}) {
// fmt.Printf("MY PASS: %v", optionalData...)
if pass, ok := optionalData[0].(string); ok {
if len(fileLocation) != 0 {
err := a.db.ImportDump(pass, fileLocation)
if err != nil {
runtime.EventsEmit(a.ctx, "imported_data", err.Error())
return
}
runtime.EventsEmit(a.ctx, "imported_data", "success")
}
}
})
}
// beforeClose is called when the application is about to quit,
// either by clicking the window close button or calling runtime.Quit.
// Returning true will cause the application to continue, false will continue shutdown as normal.
func (a *App) beforeClose(ctx context.Context) (prevent bool) {
defer a.db.Close()
return false
}
...
The first thing we see is the struct App
which has a field that stores a Go Context, needed by Wails, and a pointer to the struct Db
(related to the database, as we will see). The other 2 properties are strings that we configure so that the native dialogs (managed by the backend) present titles according to the language selected by the user. The function that acts as the constructor (NewApp) for App
simply creates the pointer to the database struct.
Next we see 2 methods required by the options that Wails needs: startup and beforeClose, which we will pass respectively to the OnStartup and OnBeforeClose options. By doing so they will automatically receive a Go Context. beforeClose simply closes the connection to the database when closing the application. But startup does more. First, it sets the context it receives in its corresponding field. Second, it registers a series of event listeners in the backend that we will need to trigger a series of actions.
In Wails all event listeners have this signature:
EventsOn(
ctx context.Context,
eventName string,
callback func(optionalData ...interface{}),
) func()
That is, it receives the context (the one we save in the ctx
field of App), the name of the event (which we will have established in the frontend) and a callback that will execute the action we need, and which in turn can receive optional parameters, of type any
or empty interface (interface{}
), which is the same, so we will have to make type assertions.
Some of the listeners we declare have nested event emitters declared within them that will be received on the frontend and trigger certain actions there. His signature looks like this:
EventsEmit(
ctx context.Context,
eventName string,
optionalData ...interface{},
)
I'm not going to go into detail about what these listeners do, not just for brevity but also because Go is expressive enough that you can tell what they do just by reading the code. I'll just explain a few of them. The "change_titles" listener expects to receive an event with that name. This event is triggered when the user changes the interface language, changing the title of the application window's title bar to the value received by the listener itself. We use the Wails runtime
package to achieve this. The event also receives the titles of the "Select Directory" and "Select File" dialogs which are stored in separate properties of the App
struct to be used when needed. As you can see, we need this event because these "native" actions need to be performed from the backend.
Special mention for the listeners "import_data" and "password" which are, so to speak, chained. The first one ("import_data"), when received, triggers the opening of a dialog box with the runtime.OpenFileDialog
method. As we can see, this method receives among its options the title to be displayed, which is stored in the selectedFile
field of the App
struct, as we already explained. If the user selects a file and, therefore, the fileLocation
variable is not empty, an event is emitted (called "enter_password") that is received in the frontend to show a popup in which the user is asked to enter the master password that he used when he made the export. When the user does so, the frontend emits an event ("password"), which we receive in our backend listener. The data received (the master password) and the path to the backup file are used by a method of the Db
struct, which represents the database (ImportDump
). Depending on the result of the execution of said method, a new event ("imported_data") is emitted that will trigger a pop-up window in the frontend with the successful or failed result of the import.
As we can see, Wails events are a powerful and effective way of communication between the backend and the frontend.
The rest of the methods of the App
struct are nothing more than the methods that the backend exposes to the frontend, as we already explained, and which are basically the CRUD operations with the database and which, therefore, we explain below.
V - Backend: the plumbing of the app
For this backend part I was inspired (making some modifications) by this post (here on DEV.to) by vikkio88 and his repo of a password manager, that he first created with C#/Avalonia and then adapted to use Go/Fyne (Muscurd-ig).
The "lowest level" part of the backend is the one related to password encryption. The most essential are these 3 functions:
/* crypto.go */
func keyfy(password string) []byte {
// this is to pad the password to make it 32
password = fmt.Sprintf("%032s", password)
key := ""
for i := 0; i < 32; i++ {
key += string(password[i])
}
return []byte(key)
}
func encrypt(plaintext string, key []byte) ([]byte, error) {
aes, err := aes.NewCipher(key)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(aes)
if err != nil {
return nil, err
}
// We need a 12-byte nonce for GCM (modifiable if you use cipher.NewGCMWithNonceSize())
// A nonce should always be randomly generated for every encryption.
nonce := make([]byte, gcm.NonceSize())
_, err = rand.Read(nonce)
// https://cs.opensource.google/go/go/+/refs/tags/go1.23.4:src/crypto/rand/rand.go;l=26
if err != nil {
return nil, err
}
// ciphertext here is actually nonce+ciphertext
// So that when we decrypt, just knowing the nonce size
// is enough to separate it from the ciphertext.
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return ciphertext, nil
}
func decrypt(ciphertext []byte, key []byte) (string, error) {
aes, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(aes)
if err != nil {
return "", err
}
// Since we know the ciphertext is actually nonce+ciphertext
// And len(nonce) == NonceSize(). We can separate the two.
// Therefore, we are sure that len(ciphertext) < nonceSize
// will never be true.
nonceSize := gcm.NonceSize()
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", err
}
return string(plaintext), nil
}
I won't go into the details of symmetric encryption with AES in Go. Everything you need to know is well explained in this post, here on DEV.to.
AES is a block cipher algorithm that takes a fixed-size key and fixed-size plaintext, and returns fixed-size ciphertext. Because the block size of AES is set to 16 bytes, the plaintext must be at least 16 bytes long. Which causes a problem for us since we want to be able to encrypt/decrypt arbitrary sized data. To solve the problem of minimum plaintext block size the block cipher modes exist. Here I use GCM mode because it is one of the most widely adopted symmetric block cipher modes. GCM requires an IV (initialization vector [array]) which must always be randomly generated (the term used for such an array is nonce
).
Basically, the encrypt function takes a plaintext to encrypt and a secret key that will always be 32 bytes long and generates an AES encryptor with that key. With that encryptor we generate a gcm object that we use to create a 12-byte initialization vector (nonce). The Seal method of the gcm object allows us to "join" and encrypt the plaintext (as a slice of bytes) with the vector nonce and finally convert its result back into a string.
The decrypt function does the opposite: the first part of it is equal to encrypt, then since we know that the ciphertext is actually nonce+ciphertext, we can split the ciphertext into its 2 components. The NonceSize method of the gcm object always results in "12" (which is the length of the nonce
), and thus we split the byte slice at the same time as we decrypt it with the Open method of the gcm object. Finally, we convert the result into a string.
The keyfy function ensures that we have a 32-byte secret key (by padding it with "0" to reach that length). We will see that in the frontend we make sure that the user does not enter characters of more than one byte (non-ASCII characters), so that the result of this function is always 32 bytes long.
The rest of the code in this file is essentially responsible for encoding/decoding to base64 the input/output of the functions described above.
To store all the application data we use cloverDB. It is a lightweight and embedded document-oriented NoSQL Database, similar to MongoDB. One of the features of this database is that when records are saved, they are assigned an ID (by default, the field is designated as _id
, a bit like what happens in MongoDB) which is a uuid string (v4). So if we want to sort the records by entry order, we must assign them a timestamp when they are stored.
Based on these facts, we will create our models and their associated methods (master_password.go & password_entry.go):
/* master_password.go */
type MasterPassword struct {
id string
Value string `clover:"value"`
clear string
}
/* password_entry.go */
type PasswordEntry struct {
Id string
Website string
Username string
Password string
Timestamp int64
}
...
// PasswordEntryDTO represents the object that will be saved in the database.
// This struct is used so that the change of state that means
// the passage from an unencrypted password to an encrypted one
// is "detected" by the type system, making it impossible
// to save objects with an unencrypted password in the database.
type PasswordEntryDTO struct {
Id string
Website string `clover:"website"`
Username string `clover:"username"`
Password string `clover:"password"`
Timestamp int64 `clover:"timestamp"`
}
MasterPassword has a private field (clear
) that is not stored/retrieved (hence no clover tag) in/from the database, i.e. it only lives in memory and is not stored on disk. This property is the unencrypted master password itself and will be used as an encryption key for password entries. This value is stored by a setter on the MasterPassword object or set (by a callback) as a non-exported (private) field in the struct Db
of the package of the same name (db.go
). For password inputs we use 2 structs, one that does not have the encrypted password and another one in which the password is already encrypted, which is the object that will actually be stored in the database (similar to a DTO, data transfer object). The encryption/decryption methods of both structs internally use a Crypto
object, which has a property with the encryption key (which is the master password converted to a 32-byte long slice):
/* crypto.go */
type Crypto struct {
key []byte
}
func newCrypto(passkey string) Crypto {
key := keyfy(passkey)
return Crypto{key}
}
Master Password has 3 methods that play an important role in data saving/recovery:
/* master_password.go */
func (m *MasterPassword) GetCrypto() Crypto {
return newCrypto(m.clear)
}
func (m *MasterPassword) SetClear(clear string) {
m.clear = clear
}
func (m *MasterPassword) Check(value string, cb func(v string)) bool {
c := newCrypto(value)
v, err := c.decryptB64(m.Value)
if err != nil {
return false
}
if v == value {
cb(value)
return true
}
return false
}
GetCrypto allows you to get the current instance of the Crypto object so that the db.go
package can encrypt/decrypt password entries. SetClear is the setter we mentioned earlier and Check is the function that verifies if the master password entered by the user is correct; as we can see, in addition to the password, it takes as an argument a callback, which depending on the case will be the aforementioned setter (when we import data from the backup file) or the SetMasterPassword method of the db.go
package that sets the value in the private field of the Db
struct when the user logs in.
I'm not going to explain in detail all the methods of the db.go package because most of its code is related to the way of working with cloverDB, which you can check in its documentation, although I have already mentioned some important things that will be used here.
/* db.go */
type Db struct {
clover *c.DB
cachedMp *models.MasterPassword
}
...
func setupCollections(db *c.DB) {
db.CreateCollection(language_collection)
db.CreateCollection(master_password_collection)
db.CreateCollection(password_entry_collection)
}
func NewDb() *Db {
// It is necessary to create the folder that
// will contain the DB storage files
err := os.MkdirAll(db_files, os.ModePerm)
if err != nil {
panic(err)
}
db, err := c.Open(db_files)
if err != nil {
panic(err)
}
setupCollections(db)
return &Db{clover: db}
}
func (db *Db) Close() error {
return db.clover.Close()
}
func (db *Db) GetLanguageCode() string {
doc, err := db.clover.FindFirst(query.NewQuery(language_collection))
if err != nil {
panic(err)
}
if doc == nil {
return ""
}
if code, ok := doc.Get("code").(string); ok {
return code
}
return ""
}
func (db *Db) SaveLanguageCode(code string) {
db.clover.Delete(query.NewQuery(language_collection))
doc := d.NewDocument()
doc.Set("code", code)
_, err := db.clover.InsertOne(language_collection, doc)
if err != nil {
panic(err)
}
}
First we have the struct that will store a pointer to the cloverDB
instance. It also stores a pointer to a "full" instance of the MasterPassword struct. "Full" here means that it stores both the encrypted master password (meaning it exists in the database and is therefore the current master password) and the unencrypted master password, which will be used for encryption of the password entries. Next we have setupCollections, NewDb, and Close, which are functions and methods to setup the database when the application is started and closed. cloverDB does not automatically create a storage file/directory when instantiated with the Open method, instead we have to create it manually. Finally, GetLanguageCode and SaveLanguageCode are methods to retrieve/save the application language selected by the user. Since the selected language code is a small string ("en" or "es"), for simplicity we don't use a struct to store it: for example, to retrieve the language code from the collection (cloverDB works with "documents" and "collections", similar to MongoDB), we simply pass the key under which it is stored ("code") and make a type assertion.
When the user logs in for the first time, the master password is saved in the database: this value (unencrypted) is set in the clear
field of the MasterPassword object, which also stores the already encrypted password, and is saved in the Db
struct:
/* db.go */
...
func (db *Db) InsertMasterPassword(mpStr string) string {
mp := models.NewMasterPassword(mpStr)
doc := d.NewDocumentOf(mp)
id, err := db.clover.InsertOne(master_password_collection, doc)
if err != nil {
panic(err)
}
mp.SetClear(mpStr) // ⇐ ⇐
db.cachedMp = &mp // ⇐ ⇐
return id
}
...
The master password recovery is done twice when starting the application:
- to check if there is a master password stored in the database.
- to obtain a MasterPassword instance and to be able to use its Check method and verify that the password provided by the user is correct.
In both cases, the RecoverMasterPassword method is called, which only if there is a master password stored will set the instance in the cachedMp
field of the Db
struct:
/* db.go */
...
func (db *Db) RecoverMasterPassword() models.MasterPassword {
doc, err := db.clover.FindFirst(query.NewQuery(master_password_collection))
if err != nil {
panic(err)
}
if doc == nil {
return models.MasterPassword{}
}
var mp models.MasterPassword
err = doc.Unmarshal(&mp)
if err != nil {
panic(err)
}
if mp.Value != "" { // ⇐ ⇐
db.cachedMp = &mp // ⇐ ⇐
}
return mp
}
...
Next, there are 2 small but important pieces of code:
-
SetMasterPassword, which, as I mentioned, is used as a callback to the Check method of the MasterPassword object and will set the unencrypted master password in the
cachedMp
field of theDb
struct only if that field is not nil. -
getCryptoInstance, which will return an instance of the Crypto object only if
cachedMp
is not nil. Otherwise, it will panic the application: although in theory this situation cannot happen if the user is authenticated in the application, but for security reasons we terminate the application if it happens:
/* db.go */
...
func (db *Db) SetMasterPassword(v string) {
if db.cachedMp != nil {
db.cachedMp.SetClear(v) // ⇐ ⇐
}
}
func (db *Db) getCryptoInstance() *models.Crypto {
if db.cachedMp == nil {
panic("crypto instance is nil") // ⇐ ⇐
}
instance := db.cachedMp.GetCrypto()
return &instance
}
...
Beyond the usual CRUD operations typical of any todoapp, we have other functions or methods to comment on:
/* db.go */
...
func (db *Db) loadManyPasswordEntry(docs []*d.Document) []models.PasswordEntry {
crypto := db.getCryptoInstance()
result := make([]models.PasswordEntry, len(docs))
for i, doc := range docs {
dto := loadPasswordEntryDTO(doc)
result[i] = dto.ToPasswordEntry(crypto)
}
return result
}
func loadPasswordEntryDTO(doc *d.Document) *models.PasswordEntryDTO {
var dto models.PasswordEntryDTO
doc.Unmarshal(&dto)
dto.Id = doc.ObjectId()
return &dto
}
func loadManyPasswordEntryDTO(docs []*d.Document) []models.PasswordEntryDTO {
result := make([]models.PasswordEntryDTO, len(docs))
for i, doc := range docs {
result[i] = *loadPasswordEntryDTO(doc)
}
return result
}
...
loadPasswordEntryDTO is a helper function that creates a PasswordEntryDTO object from a single document obtained from cloverDB
. loadManyPasswordEntryDTO does the same, but from a slice of cloverDB
documents, generating a slice of PasswordEntryDTO. Finally, loadManyPasswordEntry does the same as loadManyPasswordEntryDTO but also decrypts documents obtained from cloverDB
from an instance of the Crypto object generated by the getCryptoInstance method.
Finally, among the methods not related to CRUD, we have those used in the export/import of data:
/* db.go */
...
type DbDump struct {
Mp string
Pwds []models.PasswordEntryDTO
LanguageCode string
}
...
func (db *Db) GenerateDump(baseFolder string) (string, error) {
dumpDate := time.Now().Format("200601021504")
fileName := filepath.Join(baseFolder, fmt.Sprintf("pwds_%s.%s", dumpDate, dump_file_extension))
f, err := os.Create(fileName)
if err != nil {
return "", err
}
defer f.Close()
mp := db.RecoverMasterPassword()
pwDtosDocs, _ := db.clover.FindAll(
query.NewQuery(password_entry_collection),
)
pwds := loadManyPasswordEntryDTO(pwDtosDocs)
lc := db.GetLanguageCode()
data := DbDump{
mp.Value,
pwds,
lc,
}
encoder := gob.NewEncoder(f)
errEncoding := encoder.Encode(data)
return fileName, errEncoding
}
func (db *Db) ImportDump(password string, dumpFileLocation string) error {
file, err := os.Open(dumpFileLocation)
if err != nil {
return err
}
defer file.Close()
decoder := gob.NewDecoder(file)
var importedDump DbDump
err = decoder.Decode(&importedDump)
if err != nil {
return errors.New("cannot read data dump")
}
mp := models.NewMasterPasswordFromB64(importedDump.Mp)
if !mp.Check(password, mp.SetClear) {
return errors.New("invalid dump password")
}
cryptoImport := mp.GetCrypto()
for _, dto := range importedDump.Pwds {
pe := dto.ToPasswordEntry(&cryptoImport)
db.InsertPasswordEntry(pe)
}
db.SaveLanguageCode(importedDump.LanguageCode)
return nil
}
GenerateDump uses the DbDump struct which will be the object saved in the backup file. It takes as its name the path of the directory selected by the user, a date format and an ad hoc extension. Then we create a DbDump instance with the encrypted master password, the DTOs slice (with its corresponding passwords also encrypted) and the language code saved by the user in the database. This object is finally encoded in binary by the Golang gob
package in the file we have created, returning the name of the file to the UI to inform the user of its successful creation.
On the other hand, ImportDump takes as arguments the master password that the UI asks the user for, which is the password in effect when the export was performed, and the path to the backup file. Now, it decrypts the selected file using the DbDump structure and then obtains a MasterPassword instance from the encrypted master password stored in DbDump. In the next step, we verify that the password supplied by the user is correct, while setting the clear field in the MasterPassword instance:
if !mp.Check(password, mp.SetClear /* ⇐ ⇐ */) {
return errors.New("invalid dump password")
}
Finally, we get an instance of the Crypto object from the MasterPasword instance created in the previous step and do 2 things in the following loop:
- We decrypt the DTOs and convert them into PasswordEntry with the already decrypted password and
- We insert the PasswordEntry's into the database which will now be encrypted with the new master password.
The last thing we have left is to save the language code in the database.
And that's enough for today, this tutorial has already gotten long 😮💨.
In the second part, we will detail the frontend side which, as I said, is made with Svelte.
If you are impatient, as I already told you, you can find all the code in this repo.
See you in the second part. Happy coding 😀!!
Top comments (0)