Skip to content

WASM panic (nilPanic / unreachable) in Go 1.25 crypto path during RS256 JWT signing #5291

@jeroenrinzema

Description

@jeroenrinzema

When compiling a WASM plugin module (-buildmode=c-shared) with TinyGo 0.40.1 using its bundled Go 1.25.x toolchain, calling rsa.SignPKCS1v15 (which internally uses sha256.Sum256) causes a runtime panic:

panic: runtime error: nil pointer dereference
wasm stack trace:
    main.runtime.runtimePanicAt(i32,i32)
    main.runtime.nilPanic()
    main.(*crypto/internal/fips140/sha256.Digest).Sum(i32,i32,i32,i32)
    main.send() i32

The panic occurs inside the new crypto/internal/fips140/sha256 path that Go 1.24+ introduced as part of the FIPS 140-3 crypto module restructuring. This path appears to have uninitialized state when invoked through TinyGo's WASM c-shared build mode.

Key observations:

  • c-shared mode panics, but command mode (-target=wasi without -buildmode=c-shared) works fine with identical crypto code
  • The panic is in crypto/internal/fips140/sha256.Digest.Sum, not the old crypto/sha256 path. This is the Go 1.24+ FIPS internal restructuring
  • Downgrading to Go 1.23.x (before the FIPS restructuring) avoids the panic entirely

This is likely related to #4777, which reported that crypto/sha256 became unimportable on the wasm target with Go 1.24+ due to crypto/internal/sysrand issues. The current issue is the runtime manifestation: even when the build succeeds, the FIPS 140 internal state appears uninitialized in c-shared/exported-function context.

Environment

Component Version
TinyGo 0.40.1
Go (bundled) 1.25.6
LLVM 20.1.1
Target wasi
Build mode c-shared
WASM runtime Extism CLI (wazero-based) / also reproducible with wasmtime for command mode control
Build host darwin/arm64
$ tinygo version
tinygo version 0.40.1 darwin/arm64 (using go version go1.25.6 and LLVM version 20.1.1)

Reproducer

Repository: https://github.com/jeroenrinzema/tinygo-wasm-RS256

The reproducer contains two files with build tags to isolate the test cases:

Failing case: c-shared / plugin mode (-tags=reproplugin)

tinygo build -tags=reproplugin -target=wasi -buildmode=c-shared -opt=2 -no-debug -o repro_plugin.wasm .
extism call --wasi repro_plugin.wasm send

Output:

panic: runtime error: nil pointer dereference
Error: wasm error: unreachable
wasm stack trace:
    main.runtime.runtimePanicAt(i32,i32)
    main.runtime.nilPanic()
    main.(*crypto/internal/fips140/sha256.Digest).Sum(i32,i32,i32,i32)
    main.send() i32
returned non-zero exit code: 1

Passing case: command mode (-tags=reprocmd)

tinygo build -tags=reprocmd -target=wasi -opt=2 -no-debug -o repro_cmd.wasm .
wasmtime run repro_cmd.wasm

Output:

ok: signature bytes=128

Minimal reproduction code (plugin mode)

//go:build reproplugin

package main

import (
    "crypto"
    "crypto/rand"
    "crypto/rsa"
    "crypto/sha256"
    "crypto/x509"
    "encoding/base64"
)

// Test RSA-1024 key in PKCS#8 DER format (base64-encoded, not sensitive)
const pkcs8PrivateKeyDERBase64 = "MIICdgIBADANBgkqhkiG9w0BAQEFAASC..."

//go:export send
func send() int32 {
    der, _ := base64.StdEncoding.DecodeString(pkcs8PrivateKeyDERBase64)
    keyAny, _ := x509.ParsePKCS8PrivateKey(der)
    rsaKey := keyAny.(*rsa.PrivateKey)

    signingInput := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0ZXN0IiwiaWF0IjoxNzEwMDAwMDAwfQ"
    digest := sha256.Sum256([]byte(signingInput))  // <-- panics here internally
    _, err := rsa.SignPKCS1v15(rand.Reader, rsaKey, crypto.SHA256, digest[:])
    if err != nil {
        panic(err)
    }
    return 0
}

func main() {}

The nil panic in crypto/internal/fips140/sha256.Digest.Sum suggests that the Digest struct's internal state (likely the hash function table or init vector) is not properly initialized when called from an exported WASM function in c-shared mode.

Go 1.24 moved SHA-256 (and other crypto primitives) behind crypto/internal/fips140/... as part of the FIPS 140-3 validation effort. These internal packages may rely on init() functions or module-level state that TinyGo doesn't fully wire up in the c-shared WASM execution path, particularly since _start/main() initialization may not run before exported functions are called.

The fact that command mode works (where main() runs first, executing all init() chains) but c-shared mode panics (where the exported function send() is called directly) strongly points to missing initialization of the FIPS 140 crypto internals in the c-shared startup path.

Workaround

We worked around this downstream by avoiding the affected stdlib paths entirely in WASM modules, using a local SHA-256 implementation and a local RSA PKCS#1 v1.5 signing helper. This avoids the FIPS 140 internal code path. We would prefer to use standard library crypto.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions