Word | What it means here |
SDK | A library published so others can build on a product. Here: go-Passbolt. |
WebAssembly (Wasm) | Code for a small virtual processor that is easy to translate into other formats. |
WASI / wasip1 | Basic services a Wasm program can request, such as clocks, randomness and printing. wasip1 is Go's target for it. |
w2c2 | A tool that translates WebAssembly into C. |
Homebrew | Hobbyist software for a game console. |
devkitARM | The compiler used for 3DS homebrew. |
libctru | The homebrew library that exposes 3DS hardware such as networking, screens and buttons. |
Binding | Code that lets one language call a library written in another. |
cgo / FFI | cgo lets Go talk to C. An FFI lets languages such as Python, Swift or Rust call C. |
S2K | The deliberately slow step that derives the key used to unlock a private key from a passphrase. |

Nobody asked for it, but here it is: welcome to the first episode of a series about the most unexpected places Passbolt can run.
Somewhere along the way, a developer found a Super Mushroom and decided to take Passbolt on a Nintendo adventure.

Picture this: a Nintendo 3DS flips open. Instead of Mario, the Passbolt logo appears. Pick a private key, tap out the passphrase with the stylus, and a few seconds later, the team's passwords fill the top screen!
Here we gooo!
The context first
Passbolt is an open-source credential manager that encrypts data directly on the user's device. Its Go SDK, go-passbolt, handles the cryptography, so developers don't have to build it from scratch.
Why a 3DS? makes an interesting test: an aging processor, limited memory, and no official Go support. If the SDK can run here, it could work on other unsupported devices with a C compiler. Here's how we made it work. Is this safe to use? Absolutely not. Don’t try this at home. Or do, but just for fun, with test credentials.
FYI: Essential steps are marked separately from the just-for-the-giggles extras.



Terms to know
How to read this post
The badges provide additional context for each section of the blog.
- 🧩 Wasm route. Useful when Go cannot build for the target. Supported platforms can use the much easier route in section 7.
- 🎮 3DS only. Console-specific work.
- 🎨 Just for fun. Completely unnecessary. Naturally, quite a lot of effort went here.
- ⚡ Speed-up. Optional optimisations covered in the bonus sections.
1. The challenge: Go doesn't speak 3DS
Go supports Windows, macOS, Linux, Android, iOS and plenty of processors. The 3DS isn't one of them (shocking, right? 😲). Its ARM11 processor is familiar territory for Go, but the operating system: Nintendo's Horizon, is a complete stranger.
That leaves two obvious paths, both far too much work for a project that exists mostly as a joke:
- Teach Go about Horizon: rewrite how Go starts threads, allocates memory and handles errors, for a system documented only by hobbyists.
- Rewrite a Passbolt client in C: OpenPGP, GPGAuth and all the decryption, reimplemented as thousands of lines of fresh, unreviewed security code.
So the project set one rule: the SDK stays untouched, without any forks or patches. The only other dependency is stealthrocket/net, a small networking helper that shows up later.
Why the normal route stops here
On supported platforms, this problem is easy. cgo and -buildmode=c-shared turn Go code into a regular C library. Section 7 shows the whole approach.
Horizon simply is not one of Go 1.27's 32-bit ARM targets:
$ go tool dist list | grep arm$
android/arm
freebsd/arm
linux/arm
netbsd/arm
openbsd/arm
plan9/arm
Pretending the 3DS is Linux gets nowhere either. Go would immediately start making system calls Horizon has never heard of.
Hence, it's time for a translator.
2. The plan: translate twice
WebAssembly (Wasm) acts as a translator by providing a portable instruction format for a small virtual processor.
Here, WebAssembly (Wasm) plays the translator: a portable instruction format for a small virtual processor. Go compiles to it via the wasip1/wasm target, w2c2 converts that output to C, and devkitARM takes it from there to the 3DS:
Go → WebAssembly → C → 3DS

The .wasm file exists only during the build and never reaches the console. The route sounds ridiculous. It also works.
3. Building the first working version
Version one had modest ambitions: log in, list passwords, decrypt one, log out.
Yes it is text only. Fancy buttons could wait.
3.1 🧩 Keep the SDK surface tiny
The SDK can do considerably more, but the first app needs four actions. go/core/core.go therefore exposes four functions:
func (s *Session) Login(privateKey string, passphrase []byte) (LoginStats, error)
func (s *Session) List() ([]Entry, error)
func (s *Session) Get(id string) (*Secret, error)
func (s *Session) Logout() error
They are ordinary Go and contain nothing 3DS-specific, and their results also serialise cleanly to JSON. This small surface matters because every function crossing the language boundary needs wiring by hand later.
3.2 🧩 Compile the SDK to WebAssembly
The build itself is pleasantly uneventful:
GOOS=wasip1 GOARCH=wasm go build -tags getaddrinfo
-buildmode=c-shared -trimpath -ldflags="-s -w" ./wasm
GOOS=wasip1 GOARCH=wasm selects the WebAssembly target. -buildmode=c-shared creates a reactor, a module that waits for calls like a library instead of executing once and exiting. That is why main can stay empty.
The getaddrinfo tag gets its own adventure in bonus 2.
Four seconds later, the result is a 16 MB Passbolt.wasm containing 8,278 functions.
go-Passbolt, gopenpgp and Go's HTTP client all just compile unchanged 😍.
The resulting module has three important parts:
- Memory. Go's variables, heap and stack live in one byte array owned by the module. It starts around 7 MB and can grow.
- Exports. Functions the outside world can call. A plain build exposes
_initialize, which starts Go, and the module's memory. - Imports. Services Go expects from its environment, such as time, randomness and I/O. This build needs 29 of them.

A normal Wasm runtime such as wasmtime or wazero supplies those imports, but there is no Wasm runtime on the 3DS. That job falls to the host.
3.3 🧩 Turn WebAssembly into C
Next stop:
w2c2 -f 200 -d gnu-ld Passbolt.wasm Passbolt.c
The result is roughly six million lines of generated C. Reasonable.

w2c2 preserves both sides of the Wasm boundary. Exports become callable C functions while imports become unresolved declarations that somebody still needs to implement.
It also turns friendly names into masterpieces such as:
pb_login → m8_PassboltExport8_pbX5Flogin
The generated code sits between two much smaller hand-written layers:
This structure becomes important later as most of these six million lines never need human attention. Which is fortunate.
3.4 🧩 Getting data across the boundary
The Go module now needs some useful entry points.
go/wasm/main.go exports five with //go:wasmexport:
pb_initpb_loginpb_listpb_get_secretpb_logout
After w2c2, C can call each one.
There is one complication: WebAssembly functions pass numbers, while login requires things like private keys and passphrases.
Fortunately, text is just bytes with coordinates. A string in memory can be described with two numbers: the address of its first byte, and its length.

Go owns its Wasm memory. C can see that memory as a block inside the device's address space, but Go can't access C memory outside of what was allocated for it.
So C copies inputs into memory allocated by Go first.

Go's wasip1 target does not expose malloc and free, so the wrapper adds its own:
pb_alloc(n)reserves space;pb_freeclears and releases it.
The clearing matters because passphrases travel through this memory. Results use the same bridge in reverse. Each exported call returns a status code, while the actual JSON response waits in a result buffer until C copies it out and clears it.
For example: pb_alloc(3000) returns 70000 for the key and pb_alloc(12) returns 73000 for the passphrase. C copies both in and calls pb_login(70000, 3000, 73000, 12), then fetches 250 bytes of JSON waiting at 80000.

The entire contract becomes:
- strings travel as pointer + length;
- calls return 0 or -1;
- JSON answers wait in a result buffer;
- temporary sensitive memory gets cleared.
A login export stays pleasantly small:
//go:wasmexport pb_login
func pbLogin(keyPtr unsafe.Pointer, keyLen uint32, passPtr unsafe.Pointer, passLen uint32) int32 {
return call(func(s *core.Session) (any, error) {
return s.Login(string(mem(keyPtr, keyLen)), mem(passPtr, passLen))
})
}
Every exported function goes through the same helper, which serialises the result and parks it for C:
var result []byte
func call(f func(*core.Session) (any, error)) int32 {
if session == nil {
return fail(core.ErrNotLoggedIn)
}
v, err := f(session)
if err != nil {
return fail(err)
}
var b []byte
if v != nil {
if b, err = json.Marshal(v); err != nil {
return fail(err)
}
}
setResult(b)
return statusOK
}
func setResult(b []byte) {
clear(result) // may contain a password
result = b
}
3.5 🧩 Give C a sane API
Implementing borrow, copy, call, fetch, and free manually throughout an application would get old fast. So host/pb.c hides it behind a conventional C API.
The increasingly civilised journey of one function looks like this:
Much better.
host/pb.h exposes the whole interface:
void pb_open(const char* const* env);
int pb_init(const char* config_json, pb_buf* out);
int pb_login(const char* armored_key, size_t key_len, const char* passphrase, size_t pass_len, pb_buf* out);
int pb_list(pb_buf* out);
int pb_get_secret(const char* id, pb_buf* out);
int pb_logout(pb_buf* out);
void pb_buf_free(pb_buf* b);
Three helpers handle the memory ceremony:
putcopies input into Go memory;dropreturns that memory;takecopies the result out and clears the Go-side buffer.
int pb_login(const char* armored_key, size_t key_len, const char* passphrase, size_t pass_len, pb_buf* out) {
U32 k = put(armored_key, key_len);
U32 p = put(passphrase, pass_len);
U32 status = m8_PassboltExport8_pbX5Flogin(&g_inst, k, (U32)key_len, p, (U32)pass_len);
drop(p);
drop(k);
return take(status, out);
}
Two details matter for safety and correctness:
- Go's Wasm memory can move when it grows, so C resolves its address on every access.
- Results can contain passwords, so both Go and C clear sensitive buffers before freeing them.
The C binding is just 139 lines. C can now call Go, which immediately starts asking for favours.
3.6 🧩 Replacing the missing Wasm runtime
Remember those 29 imports? They cover things such as:
- startup;
- time and sleeping;
- secure randomness;
- reading and writing bytes;
- file operations.
A Wasm engine would usually implement them. Since this project has no engine, host/wasi.c provides the required environment in about 900 lines of C.
For example, Go asks WASI for random bytes. The implementation checks that the target buffer really sits inside Go's memory, fills it, then returns a status code:
U32 i22_wasiX5FsnapshotX5Fpreview110_randomX5Fget(wasmModuleInstance* inst, U32 buf, U32 len) {
U8* p = mem_ptr(buf, len);
if (!p) return W_FAULT;
return plat_random(p, len) == 0 ? W_SUCCESS : W_IO;
}
The interesting part is plat_random.
wasi.c itself stays mostly platform-independent. Device-specific behaviour is squeezed into six functions:
uint64_t plat_realtime_ns(void);
uint64_t plat_monotonic_ns(void);
void plat_sleep_ns(uint64_t ns);
int plat_random(void* buf, size_t len);
void plat_console_write(int fd, const void* buf, size_t len);
void plat_fatal(const char* msg, int code);
On macOS, all six fit into 56 lines using getentropy, clock_gettime and friends.
A new platform only needs another implementation of that layer. The roughly 900 lines of WASI plumbing and error tables were another excellent job for Claude Code. Thanks, Claude Code.
File-related imports simply return failure because the app passes its configuration and keys directly into Go.
Networking was less willing to cooperate. Bonus 2 covers that story.
3.7 🧩 Prove the translation before blaming Nintendo
Before putting anything on real hardware, a small C command-line program tested the translated SDK against a local Passbolt server in Docker. Two users covered both key types:
- alice: Curve25519;
- bob: RSA-3072.
Login, list and decrypt were compared with the native Go implementation, and both produced exactly 2,784 bytes, byte for byte.
This means the translation worked. Now Nintendo could take the blame for everything else.
3.8 🎮 Compile it on the 3DS
The layering pays off here. Moving from macOS to the 3DS changes only a few pieces:
A full 3DS build takes about a minute.

To “talk to the hardware” platform_3ds.c implements the six platform functions through libctru:
The clock needed some care. The 3DS counts milliseconds since 1900 in local time and has no concept of a time zone. Passbolt and HTTPS expect UTC.
A utc_offset_minutes setting bridges the two:
#define EPOCH_1900_TO_1970_MS 2208988800000ULL
uint64_t plat_realtime_ns(void) {
int64_t ms = (int64_t)(osGetTime() - EPOCH_1900_TO_1970_MS)
- (int64_t)pb3ds_utc_offset_minutes * 60000;
return (uint64_t)ms * 1000000ULL;
}
3.9 🎮 Fitting Go into 90 MB and the first actual app
A New 3DS gives an application roughly 90 MB of memory. w2c2 normally grows Go's memory with realloc. On a constrained device, that can require the old and new blocks to coexist temporarily and gradually fragment the available space.
host/mem_3ds.c takes a simpler approach:
- measure free memory at startup;
- reserve 6 MB for C;
- allocate one block of up to 64 MB for Go;
- grow inside that reservation.
GOMEMLIMIT is set to three quarters of the block so garbage collection starts before memory gets tight.
In practice, Go used around 13 MB. The generated C nests are deep enough to overflow the default 32 KB stack but the fix is pleasantly small:
u32 __stacksize__ = 256 * 1024;
app/3ds/main.c is about 400 lines, and it:
- starts the networking and random-number services;
- reads
config.jsonand private keys from/3ds/Passbolt/; - reserves Go's memory;
- starts the translated runtime;
- lets the user select a key and enter a hidden passphrase;
- logs in and lists resources;
- decrypts the selected password.
Every operation is timed into timings.csv.
And of course, the first real login failed before reaching the server. Four console-specific bugs were waiting. Bonus 3 talks about the crime scene.
4. 🎨 Making it feel like a real app
The text console worked and that was enough proof. But it was nowhere near enough fun.
So the project acquired:
- A proper touch interface. Passbolt's light theme, details on the top screen, searchable list below. About 2,200 lines of C, comfortably more than the binding itself.
- Fonts and icons. Open Sans, Inconsolata and Lucide converted into 3DS-friendly formats.
- A worker thread. Key unlocking takes seconds. Go runs on the New 3DS's spare core so the interface remains responsive.
- A HOME Menu tile. Title, banner and jingle included. Obviously.




5. How much code did this actually take?
Looking only at hand-written code:
The reusable core is surprisingly compact: about 350 lines of Go and 1,200 lines of C make the SDK callable through this route. The 3DS-specific foundation adds roughly another 600 lines, although the largest part of the project is the interface.
A highly sensible allocation of effort for something that began as a joke.

6. How fast is Passbolt on a 3DS?
The test setup includes: a new Nintendo 3DS, 804 MHz, Wi-Fi, local Passbolt server and single runs.
With a modern Curve25519 key, the vault opens in a few seconds and an individual password decrypts in under half a second.
RSA-3072 gives the ARM11 considerably more exercise, at roughly 2.5 seconds per private-key operation. Those figures were recorded before some later optimisations.
The unlock numbers above already include the SHA-256 speed-up from bonus 4. Before that optimization, unlocking took 7.3 seconds for alice and 8.5 seconds for bob. A perfectly acceptable result for a handheld released in 2014.
7. The useful part: Call the SDK from any language
The 3DS needs this scenic route because Go can’t target Horizon directly but most modern platforms skip all of that. On Windows, macOS, Linux, Android and iOS, cgo compiles the SDK straight into a C library, and any language with an FFI can then call it.
- Expose a small Go API: write a
mainpackage that imports"C", calls the SDK, and marks the functions to expose with//export. - Return simple data such as JSON as JSON text crosses any language boundary without fuss.
- Compile it as a C library:
go build -buildmode=c-sharedproduces the library plus a C header. - Call it from the target language.
Here’s a minimal working wrapper: one function that logs in, lists every password name as JSON, and logs out. It’s the same idea as section 3.4, except cgo handles the memory. list is ordinary Go, and pb_list only converts between C and Go:
go
package main
// #include <stdlib.h>
import "C"
import (
"context"
"encoding/json"
"net/http"
"unsafe"
"github.com/passbolt/go-passbolt/api"
"github.com/passbolt/go-passbolt/helper"
)
func main() {}
// list logs in and returns the name of every password.
func list(url, key, passphrase string) ([]string, error) {
ctx := context.Background()
c, err := api.NewClient(http.DefaultClient, "", url, key, passphrase)
if err != nil {
return nil, err
}
if err := c.Login(ctx); err != nil {
return nil, err
}
defer c.Logout(ctx)
resources, err := c.GetResources(ctx, nil)
if err != nil {
return nil, err
}
names := []string{}
for _, r := range resources {
rType, err := c.GetResourceTypeCached(ctx, r.ResourceTypeID)
if err != nil {
return nil, err
}
_, name, _, _, _, _, err := helper.GetResourceFromDataWithOptions(c, r, api.Secret{}, *rType, false)
if err != nil {
return nil, err
}
names = append(names, name)
}
return names, nil
}
// pb_list returns a JSON string that C must free with pb_free.
//
//export pb_list
func pb_list(url, key, passphrase *C.char) *C.char {
names, err := list(C.GoString(url), C.GoString(key), C.GoString(passphrase))
var v any = names
if err != nil {
v = map[string]string{"error": err.Error()}
}
b, _ := json.Marshal(v)
return C.CString(string(b))
}
//export pb_free
func pb_free(s *C.char) { C.free(unsafe.Pointer(s)) }
Every string the library returns must go back through pb_free. To build it:
go build -buildmode=c-shared -o libpassbolt.dylib . # .so on Linux, .dll on Windows
This also writes libpassbolt.h. On iOS, use -buildmode=c-archive for a static library instead.
Python can call it with nothing but its standard library (ctypes):
import ctypes, json, os
lib = ctypes.CDLL("./libpassbolt.dylib")
lib.pb_list.restype = ctypes.c_void_p # keep the raw pointer to free it later
ptr = lib.pb_list(b"http://localhost:18080", open("alice.asc", "rb").read(),
os.environ["PB_PASSPHRASE"].encode())
names = json.loads(ctypes.string_at(ptr))
lib.pb_free(ctypes.c_void_p(ptr))
print(names)
Against the test server, this lists Alice's 20 passwords in about 200 ms. A real app would keep the session open between calls, as core does in 3.1, and add more functions shaped like this one.
Other languages work the same way:
- Swift / Objective-C: include the generated header and call the functions.
- Rust: declare them in an
extern "C"block. - C#:
[DllImport("passbolt")](P/Invoke). - Java / Kotlin: JNA, or JNI on Android.
- JavaScript (Node): an FFI package such as koffi.
Save the WebAssembly route for devices Go doesn’t support but that have a C compiler, such as game consoles and some embedded boards, or for running the SDK inside a Wasm engine.
The broader takeaway is: keep the SDK surface small, use a lightweight data format, and keep platform-specific work contained. That same pattern scales from Python, C, Swift and Rust to, and as it turns out, a game console too.
Bonus 1: 🧩 the eight-hour compile
Remember those six million lines of generated C? Most of the trouble came from a single startup function.
Go programs ship with static data such as strings, type descriptions and cryptographic lookup tables. In WebAssembly, these become data segments that copy bytes into specific memory addresses at startup. This module had exactly 100,000 data segments, totalling 4.8 MB, and w2c2 translated each one into two C statements inside the same function.
The result was roughly 200,000 lines in one function. gcc spent more than eight hours trying to optimise it, while clang still needed more than ten minutes with optimisation disabled.
The fix was simple: turn code back into data.
First, w2c2's -d gnu-ld option moves the raw bytes into a separate file, embedded with .incbin. Then tools/pack_data_segments.py converts the remaining copy instructions into a table:
static const U32 m8_PassboltDataSegments[100000][3] = {
{132736U,0U,202826U},
{335570U,202826U,1528U},
/* ... */
};
for (k = 0; k < 100000U; k++) {
LOAD_DATA((*i->m0),
m8_PassboltDataSegments[k][0],
m8_PassboltData + m8_PassboltDataSegments[k][1],
m8_PassboltDataSegments[k][2]);
}
Startup still performs the same 100,000 copies, but the compiler now sees a table and one loop instead of 200,000 independent statements. The full build dropped to 34 seconds.

The conversion script deliberately accepts only the exact patterns produced by the current w2c2 version, so a future format change breaks the build loudly instead of quietly producing bad output.
Bonus 2: 🧩 networking when WASI cannot dial out
One awkward detail in the Wasm plan is that WASI preview 1 cannot open outbound network connections. It can use sockets supplied by the host, which works well for server-style workloads, but go-Passbolt needs to initiate its own connection to a Passbolt server.
Under wasip1, Go's net package simply returns "not implemented".
Some Wasm engines fill that gap with socket extensions, and stealthrocket/net exposes them through a normal Go dialer. That keeps the Go-side change tiny:
Transport: &http.Transport{
DialContext: wasip1.DialContext,
...
}
Everything above that stays standard Go:
go-Passbolt → net/http → crypto/tls → stealthrocket/net → wasi.c → system sockets
On macOS, those sockets come from POSIX; on the 3DS, from libctru.
DNS uses the same approach. The getaddrinfo build tag lets the host resolve names, avoiding another DNS implementation inside the Wasm module.
Bonus 3: 🎮 Bugs that waited for real hardware
Everything worked on macOS. The first 3DS login spent 7.4 seconds accomplishing absolutely nothing useful. It never reached the server. With no debugger available, every network call went into go.log on the SD card.
That log found four problems:
- "Still connecting" looked like an error. libctru uses code
-26for an in-progress connection. Its own conversion function fixed the interpretation. - DNS returned port zero. The lookup returned an address without the port, so the host layer now fills it in.
- Successful connections waited around unnoticed. Polling the connection every 50 ms cut login time from 8.9 seconds to 1.1.
- Logout reused a dead connection. Passbolt's nginx closes idle connections after five seconds, so Go now drops them after four:
IdleConnTimeout: 4 * time.Second,
Lesson: when the debugger does not exist, a good log file becomes extremely persuasive.
Bonus 4: ⚡ making key unlock less leisurely
Private-key unlocking is intentionally expensive. The S2K step repeatedly hashes the passphrase over 32 MB of data to make password guessing costly.
A Mac handles that in about 19 ms. But the 3DS needed:
- 7.3 seconds for Curve25519;
- 8.5 seconds for RSA-3072.
The no-patch rule still applied, so the encryption library stayed untouched. Fortunately, Go allows applications to register their own SHA-256 implementation.
go/hostsha256 therefore registers an implementation backed by ARM11-optimised C through //go:wasmimport.
It runs a self-test first. If the test fails, Go's standard SHA-256 remains in place.
func Register() error {
if err := selfTest(); err != nil {
return err
}
crypto.RegisterHash(crypto.SHA256, New)
return nil
}
Unlock time dropped from:
- 7.3 to 2.7 seconds for alice;
- 8.5 to 3.7 seconds for bob.
The SDK and its dependencies stay unchanged.
That turned out to be the broader lesson of the whole project: look for the extension points a library already provides before reaching for a fork.
Sometimes the cleanest hack is the one the architecture already left room for.
Game over (for now)
Passbolt's Go SDK now runs unchanged on a Nintendo 3DS, despite having no official Go support. WebAssembly and C made it possible, proving that the same SDK can reach even unsupported platforms without being rewritten.
And that's the bigger takeaway: with cgo, developers can bring Passbolt to applications built in Python, Swift, Rust, and beyond.
Passbolt's Go SDK now runs unchanged on a Nintendo 3DS, which Go doesn't officially support. WebAssembly and C helped bridge the gap, but the good news is that you don't need that workaround for your own applications.
You can build a C library directly from the Passbolt Go SDK and reuse it across different languages and platforms, without rewriting the SDK or dealing with the 3DS-specific complexity.
Over to the community
The 3DS is only the first stop. Next up in the series: more devices that were never supposed to support Passbolt, starting with an e-reader!
Have you ever run Passbolt somewhere unexpected? A smart fridge, a car dashboard, a Raspberry Pi taped behind a printer? Share it in the Passbolt community thread here. The more unlikely the better, and the best ones might just inspire our upcoming episodes.
Want to dive deeper? Explore the full project details on github here.
Continue reading

2 min. read
Goodbye, DigitalOcean
Passbolt Community Edition is being deprecated on the DigitalOcean Marketplace from 1 December 2026. Learn what this means for existing instances and how to continue updating or migrate to a new server.

6 min. read
Making autofill improvements: Tackling the complexity of modern web
More accurate autofill, fewer false positives, and smoother sign-ins. Discover how Passbolt rebuilt its autofill detection algorithm in 5.17 to handle complex login forms.