Skip to content

Security: MontFerret/wire

docs/security.md

Security configuration

Wire exposes the host's configured runtime across an RPC boundary. The host chooses which capabilities are safe to expose, where to listen, certificate identities and trust roots, and authentication/authorization policy. Construction never opens a listener. Explicit Run creates TCP transport at the supplied address; Serve accepts a caller-created listener. Both use identical constructor credentials and middleware. gRPC closes accepted listeners when serving returns.

A server without configured transport credentials provides no encryption or authentication, including on loopback. client.New defaults to verified TLS with system trust roots; connecting to an unsecured server requires WithInsecure. Neither side automatically falls back to plaintext. WithInsecure disables encryption and peer authentication, rather than disabling TLS certificate checks. Wire does not load or generate certificates, validate JWTs/API keys, or add credentials to protobuf messages or UAPI interfaces.

TLS host and client

These examples share endpoint 127.0.0.1:50051. The host supplies a server certificate with DNS SAN wire.test, signed by the CA in serverRoots. Certificate loading and lifecycle are application responsibilities.

func serveTLS(ctx context.Context, hostRuntime api.Runtime, serverCertificate tls.Certificate) error {
    transport := credentials.NewTLS(&tls.Config{
        MinVersion:   tls.VersionTLS12,
        Certificates: []tls.Certificate{serverCertificate},
    })
    srv, err := server.New(hostRuntime, server.WithTransportCredentials(transport))
    if err != nil {
        return err
    }
    return srv.Run(ctx, "127.0.0.1:50051")
}

The matching client verifies both the root and certificate identity:

func runTLS(ctx context.Context, serverRoots *x509.CertPool) (out *api.Output, err error) {
    remote, err := client.New(ctx,
        "127.0.0.1:50051",
        client.WithTransportCredentials(credentials.NewTLS(&tls.Config{
            MinVersion: tls.VersionTLS12,
            RootCAs:    serverRoots,
            ServerName: "wire.test",
        })),
    )
    if err != nil {
        return nil, err
    }
    defer func() { err = errors.Join(err, remote.Close()) }()
    return remote.Run(ctx, api.NewAnonymousSource("RETURN 1"))
}

grpc.NewClient creates transport configuration without connection I/O. client.New performs an actual Connect handshake. Never treat successful transport construction as proof that TLS or authentication succeeded. Do not disable server certificate verification.

TLS with system trust

For inventory.example.com:50051, the host supplies a server certificate whose DNS SAN is inventory.example.com, signed by a CA trusted by the client system. The host's chosen address must resolve to an interface on which it can listen. The matching pair needs no custom client trust configuration:

func serveInventory(ctx context.Context, hostRuntime api.Runtime, certificate tls.Certificate) error {
    srv, err := server.New(hostRuntime, server.WithTransportCredentials(
        credentials.NewTLS(&tls.Config{Certificates: []tls.Certificate{certificate}}),
    ))
    if err != nil {
        return err
    }
    return srv.Run(ctx, "inventory.example.com:50051")
}

func runInventory(ctx context.Context) (out *api.Output, err error) {
    remote, err := client.New(ctx, "inventory.example.com:50051")
    if err != nil {
        return nil, err
    }
    defer func() { err = errors.Join(err, remote.Close()) }()
    return remote.Run(ctx, api.NewAnonymousSource("RETURN 1"))
}

Default client TLS configurations are fresh per construction, with RootCAs nil and ordinary chain/identity verification enabled. Wire does not load certificate files, modify caller credentials, or own the external resources they reference.

Mutual TLS

Use the private-CA example's endpoint 127.0.0.1:50051, server identity wire.test, and serverRoots. The client supplies a certificate with client-authentication usage, signed by the CA in clientRoots. Configure the host's TLS policy as:

serverTLS := &tls.Config{
    MinVersion:   tls.VersionTLS12,
    Certificates: []tls.Certificate{serverCertificate},
    ClientAuth:   tls.RequireAndVerifyClientCert,
    ClientCAs:    clientRoots,
}
srv, err := server.New(hostRuntime,
    server.WithTransportCredentials(credentials.NewTLS(serverTLS)),
)
if err != nil {
    return err
}
return srv.Run(ctx, "127.0.0.1:50051")

The matching client configuration is:

func runMTLS(ctx context.Context, serverRoots *x509.CertPool, certificate tls.Certificate) (out *api.Output, err error) {
    clientTLS := &tls.Config{
        MinVersion:   tls.VersionTLS12,
        RootCAs:      serverRoots,
        ServerName:   "wire.test",
        Certificates: []tls.Certificate{certificate},
    }
    remote, err := client.New(ctx, "127.0.0.1:50051",
        client.WithTransportCredentials(credentials.NewTLS(clientTLS)),
    )
    if err != nil {
        return nil, err
    }
    defer func() { err = errors.Join(err, remote.Close()) }()
    return remote.Run(ctx, api.NewAnonymousSource("RETURN 1"))
}

Host middleware can inspect peer.FromContext(ctx) and credentials.TLSInfo.State.VerifiedChains. Certificate verification authenticates peers; deciding what each peer may do remains host authorization policy.

TLS plus host token middleware

The following is host application code, not Wire authentication APIs. The host supplies validateToken, including its own issuance, expiry, and authorization rules. Failures do not disclose token values or validator error text.

func tokenInterceptors(validateToken func(context.Context, string) error) (
    grpc.UnaryServerInterceptor, grpc.StreamServerInterceptor,
) {
    authenticate := func(ctx context.Context) error {
        values := metadata.ValueFromIncomingContext(ctx, "authorization")
        if len(values) != 1 {
            return status.Error(codes.Unauthenticated, "authentication required")
        }
        token, ok := strings.CutPrefix(values[0], "Bearer ")
        if !ok || token == "" || validateToken(ctx, token) != nil {
            return status.Error(codes.Unauthenticated, "authentication required")
        }
        return nil
    }
    unary := func(ctx context.Context, req any, _ *grpc.UnaryServerInfo, next grpc.UnaryHandler) (any, error) {
        if err := authenticate(ctx); err != nil {
            return nil, err
        }
        return next(ctx, req)
    }
    stream := func(host any, ss grpc.ServerStream, _ *grpc.StreamServerInfo, next grpc.StreamHandler) error {
        if err := authenticate(ss.Context()); err != nil {
            return err
        }
        return next(host, ss)
    }
    return unary, stream
}

Configure both RPC types with a server certificate. Mutual TLS can be added using the client-certificate policy above:

serverTLS := &tls.Config{
    MinVersion:   tls.VersionTLS12,
    Certificates: []tls.Certificate{serverCertificate},
}
unary, stream := tokenInterceptors(validateToken)
srv, err := server.New(hostRuntime,
    server.WithTransportCredentials(credentials.NewTLS(serverTLS)),
    server.WithUnaryInterceptors(unary),
    server.WithStreamInterceptors(stream),
)
if err != nil {
    return err
}
return srv.Run(ctx, "127.0.0.1:50051", server.WithShutdownTimeout(10*time.Second))

Use connection-level client PerRPCCredentials so each unary operation and new stream carries the token, including calls after client.New and detached cleanup:

type bearerToken string

func (token bearerToken) GetRequestMetadata(context.Context, ...string) (map[string]string, error) {
    return map[string]string{"authorization": "Bearer " + string(token)}, nil
}
func (bearerToken) RequireTransportSecurity() bool { return true }

func runAuthenticated(ctx context.Context, serverRoots *x509.CertPool, hostIssuedToken string) (out *api.Output, err error) {
    clientTLS := &tls.Config{
        MinVersion: tls.VersionTLS12,
        RootCAs:    serverRoots,
        ServerName: "wire.test",
    }
    // Add the client certificate from the mTLS example if the host requires it.
    remote, err := client.New(ctx, "127.0.0.1:50051",
        client.WithTransportCredentials(credentials.NewTLS(clientTLS)),
        client.WithPerRPCCredentials(bearerToken(hostIssuedToken)),
    )
    if err != nil {
        return nil, err
    }
    defer func() { err = errors.Join(err, remote.Close()) }()
    return remote.Run(ctx, api.NewAnonymousSource("RETURN 1"))
}

A token attached only to the constructor context does not authenticate subsequent calls. Authentication hooks must cover unary and streaming RPCs across all services, including operation calls, execution watches, and debugger command/watch streams. Hosts can compute or refresh per-call credentials in their credential provider.

Caller-configured transport

Use From for specialized gRPC dialers, interceptors, or transport limits. This example uses the same 127.0.0.1:50051 TLS host and wire.test identity. The caller owns the channel on construction failure and after all logical runtimes close:

func runBorrowed(ctx context.Context, serverRoots *x509.CertPool) (out *api.Output, err error) {
    conn, err := grpc.NewClient("127.0.0.1:50051",
        grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{
            RootCAs: serverRoots, ServerName: "wire.test",
        })),
        grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(8 << 20)),
    )
    if err != nil {
        return nil, err
    }
    defer func() { err = errors.Join(err, conn.Close()) }()
    remote, err := client.From(ctx, conn)
    if err != nil {
        return nil, err
    }
    defer func() { err = errors.Join(err, remote.Close()) }()
    return remote.Run(ctx, api.NewAnonymousSource("RETURN 1"))
}

Server message limits still apply. From accepts no transport options and never closes the borrowed connection. Multiple logical runtimes may share it without acquiring one another's lifetimes. An owned channel remains alive after ordinary runtime/plan API closure while admitted work or descendants retain it; callers must close those descendants. Failed startup and final release attempt bounded logical cleanup before owned channel closure, even when cleanup fails. Detached rollback may add time after the startup deadline.

Option validation

server.New and Server.Run apply every non-nil option once in registration order, collecting failures with errors.Join. Nil options are rejected. Construction and startup reservation/listening do not proceed when any option fails, even if a later valid option overrides the same setting. Invalid options never run their setters.

Validation uses github.com/ziflex/go-options named builders and collection validators. Their named outer errors contain relative diagnostic labels, such as [2] for an interceptor or ["max connections"] for a limit field. Interceptor failures retain ascending index order; ordering among invalid limit fields is unspecified. These labels are diagnostic text, not machine-readable paths.

Collection wrappers set ValidationError.OmitValue and omit aggregate values. Child causes and non-secret rejected values remain available, for example unary interceptors: [2]: must not be nil: value=<nil>. Credentials and unrelated runtime identity metadata are excluded from validation messages. Numeric values, empty identity names, and nil entries provide rejected-value context. The server accepted values and defaults are unchanged: empty interceptor lists are no-ops, every limit is positive, and explicit non-positive shutdown timeouts fail.

Use errors.As to inspect the first matching validation error in the joined chain, while retaining the complete error for reporting all failures:

var invalid gooptions.ValidationError
if errors.As(err, &invalid) {
    return fmt.Errorf("invalid option %s: %w", invalid.Field, err)
}

Import gooptions "github.com/ziflex/go-options". Standard errors.Is and errors.As retain access to validator causes. These are constructor configuration errors; RPC authentication and Wire's sanitized runtime error contracts remain unchanged.

Client options follow the same ordered validation convention. Nil/typed-nil credentials fail locally; repeated transport credentials use the last valid value and repeated per-RPC options append providers in gRPC registration order. WithInsecure is idempotent and conflicts with explicit transport credentials in either order. No later option erases earlier validation failures. gRPC rejects plaintext combined with credentials requiring transport security, without sending protected metadata. Credential providers own refresh and concurrency behavior; Wire adds no token cache or lifecycle. See client options.

Middleware and trust boundaries

Repeated interceptor options append in registration order. Wire recovery is the outermost wrapper, followed by host middleware, then the handler. Host middleware can reject requests before handler resource allocation or runtime work. Ordinary host authentication/authorization statuses pass through unchanged; invocation panics receive Wire's existing sanitized internal status. Captured interceptor lists are copied, but middleware closures must be safe for concurrent RPCs. Unary replacement contexts and wrapped stream contexts reach the next handler; incoming metadata and peer authentication information remain available.

Configured message and resource limits still apply with middleware installed. Do not use authentication as a substitute for runtime policy or resource limits.

Authentication is not tenant isolation. Wire's logical connection/resource ownership is not bound to a middleware principal. Do not assume different validated users are isolated by token verification alone.

Stream middleware runs at establishment. An already-open stream does not acquire automatic token revocation or expiry enforcement. Those policies, including cancellation of established streams when required, belong to the host.

Run closes its listener and manages shutdown with a default 30-second budget, starting when shutdown begins. An earlier explicit deadline shortens it; later callers cannot extend it. Timeout forces transport shutdown and returns an error matching context.DeadlineExceeded, joined with other known failures. Hosted cleanup may remain pending under its original owner; subsequent Shutdown calls observe its retained settlement result. Successful return means managed cleanup settled. Wire never closes the borrowed host runtime. See the serving lifecycle.

There aren't any published security advisories