Go client
The Go client is the github.com/openbasil/basil-go module, package basil. It talks to
the broker over the local Unix socket, maps every RPC to context-aware Go methods, and keeps the root
package lean: SPIFFE helpers and streaming encryption live in separate subpackages.
The broker still authenticates the caller with SO_PEERCRED. The Go client does not present a token
or certificate; run the process under the uid/gid evidence you want Basil to resolve to a policy
subject.
Install and connect
import basil "github.com/openbasil/basil-go"
client, err := basil.Dial("/run/basil/basil.sock")
if err != nil {
return err
}
defer client.Close()
Dial is lazy: an unreachable socket fails on the first RPC, not at construction. The transport is
plain gRPC over a Unix socket with a pinned localhost HTTP/2 authority; without that authority, a raw
filesystem path can leak into :authority and be rejected by the HTTP/2 stack.
Options:
| Option | Use |
|---|---|
basil.WithTimeout(d) | Per-RPC timeout when the caller's context has no deadline. Default is 30s; pass 0 to require caller deadlines. |
basil.WithDialOptions(...) | Extra gRPC dial options such as interceptors or message-size limits. The Unix dialer and local transport credentials are fixed. |
Errors
Broker failures surface as *basil.StatusError:
sig, err := client.Sign(ctx, "web.tls.signing_key", msg)
if se, ok := basil.AsStatusError(err); ok {
log.Printf("code=%s reason=%s op=%s", se.Code, se.Reason, se.Op)
}
Code is the canonical gRPC status code. Reason and Op come from Basil's BrokerErrorInfo
detail, for example UNAUTHORIZED / sign or BACKEND_UNAVAILABLE / encrypt. The type works with
errors.As and status.Code(err). Use basil.FromError(err) when you need to normalize an error from
the raw go-spiffe client returned by spiffe.Client.Workload().
Verify and ValidateNatsJwt have authoritative negative results: Verify returns (false, nil)
for a bad signature, and NATS validation returns Valid=false with a typed reason. A non-nil error
means the broker could not perform the operation.
Signing and key lifecycle
msg := []byte("release v1.2.3")
sig, err := client.Sign(ctx, "web.tls.signing_key", msg)
ok, err := client.Verify(ctx, "web.tls.signing_key", msg, sig)
pub, err := client.GetPublicKey(ctx, "web.tls.signing_key", nil)
sig, err = client.SignWithAlgorithm(ctx, "nats.account", msg,
basil.SigningAlgorithmEd25519NKey)
The broker signs the message bytes you pass, not a caller-prehashed digest. The algorithm is normally
derived from the catalog key; use SignWithAlgorithm / VerifyWithAlgorithm only when a key supports
an explicit profile such as NATS NKey signing.
Create or import keys through the catalog name. Only the public half comes back:
h, err := client.NewKey(ctx, "app.pqc.sign", basil.KeyTypeMLDSA65)
h, err = client.Import(ctx, "nats.operator", basil.KeyTypeEd25519,
basil.Ed25519SeedMaterial(seed))
keys, err := client.ImportSet(ctx, []basil.ImportEntry{
{KeyID: "nats.operator", KeyType: basil.KeyTypeEd25519,
Material: basil.Ed25519SeedMaterial(operatorSeed)},
{KeyID: "web.tls", KeyType: basil.KeyTypeRSA2048,
Material: basil.PKCS8DERMaterial(rsaDER)},
})
KeyMaterial is deliberately sealed and write-only: callers can construct
Ed25519SeedMaterial or PKCS8DERMaterial, but no RPC returns imported private material. Custody and
storage stay catalog-controlled.
AEAD and KEM envelopes
ct, err := client.Encrypt(ctx, "app.aead", basil.AeadAlgorithmAES256GCM, plaintext, aad)
pt, err := client.Decrypt(ctx, "app.aead", ct, aad)
wrapped, err := client.WrapEnvelope(ctx, "app.kem",
basil.KemAlgorithmMLKEM768, basil.EnvelopeAlgorithmAES256GCM, cek, aad)
cek, err = client.UnwrapEnvelope(ctx, "app.kem", wrapped, aad)
For AEAD, Basil owns the nonce. Treat *basil.Ciphertext as opaque and round-trip its suite, version,
nonce, and ciphertext unchanged. KEM envelopes use *basil.KemEnvelope for X25519 or ML-KEM wrapping;
the private decapsulation key stays custodied by the broker.
For files or large payloads, use the separate stream subpackage. It encrypts an
io.Reader into an io.Writer in bounded chunks, wire-identical to Rust basil::stream.
Secrets and catalog
sec, err := client.GetSecret(ctx, "app.db_password", nil) // nil = latest
ver, err := client.SetSecret(ctx, "app.db_password", []byte("new value"))
ver, err = client.RotateSecret(ctx, "app.db_password")
entries, err := client.ListCatalog(ctx, nil) // nil = no prefix filter
ListCatalog drains the server stream into []basil.CatalogEntry. It returns inventory metadata, not
secret values.
Minting, NATS, and certificates
Generic JWT minting stays on MintingService:
cred, err := client.MintJwt(ctx, basil.JwtRequest{
KeyID: "app.jwt_issuer",
Subject: "svc-a",
TTL: 15 * time.Minute,
Claims: map[string]any{"scope": "orders:read"},
})
NATS-specific calls live on the NatsService sub-client:
user, err := client.Nats().MintNatsUser(ctx, basil.NatsUserRequest{
KeyID: "nats.account",
SubjectUserNKey: userNKey,
Name: "orders-api",
TTL: time.Hour,
})
signed, err := client.Nats().SignNatsJwt(ctx, basil.NatsJwtRequest{
KeyID: "nats.account",
Claims: claims,
ExpectedType: basil.NatsJwtTypeUser,
TTL: time.Hour,
})
validation, err := client.Nats().ValidateNatsJwt(ctx, basil.ValidateNatsJwtRequest{
JWT: token,
AllowedSigners: []basil.AllowedSigner{
basil.AllowedSignerKeyID("nats.account"),
basil.AllowedSignerNatsPublicKey("ADVCJ4FZLS..."),
},
ExpectedType: basil.NatsJwtTypeUser,
})
NatsJwtRequest.Claims may be a map, struct, json.RawMessage, []byte, or JSON string. Use
json.RawMessage or []byte when you need byte-exact claim JSON, and use
json.Decoder.UseNumber before placing decoded JSON in a map with large integer claims. The
NATS JWT reference documents every account and user claim
SignNatsJwt accepts and the semantic defaults Basil applies.
ValidateNatsJwt returns Valid, Reason, Subject, Issuer, JWTType,
MatchedSignerKeyID, ExpiresAt, and IssuedAt. Reasons include malformed, bad signature, unknown
signer, expired, not-yet-valid, and wrong type.
NATS curve xkey boxes also live on NatsService:
box, err := client.Nats().EncryptNatsCurve(ctx, basil.NatsCurveEncryptRequest{
KeyID: "nats.xkey",
RecipientPublicXKey: "XBCRECIPIENT...",
Plaintext: payload,
})
payload, err = client.Nats().DecryptNatsCurve(ctx, basil.NatsCurveDecryptRequest{
KeyID: "nats.xkey",
SenderPublicXKey: "XBCSENDER...",
Ciphertext: box,
})
See NATS integration for the service split, catalog shape, policy grants, and the
xkv1 wire format.
Certificate issuance returns the sole broker result containing private key material: the freshly minted leaf key that a TLS server needs.
cert, err := client.IssueCertificate(ctx, basil.CertificateRequest{
IssuerKeyID: "web.tls.cert_issuer",
CommonName: "svc.example.org",
DNSSANs: []string{"svc.example.org"},
TTL: 24 * time.Hour,
})
// cert.CertChainDER, cert.PrivateKeyDER, cert.CAChainDERStatus and admin
st, err := client.Status(ctx)
health, err := client.Health(ctx)
ready, err := client.Readiness(ctx)
Health is cheap liveness. Readiness probes backend reachability and key presence, returning only
counts and coarse reasons.
Admin mutations are permission-gated and not implied by data-plane grants:
reload, err := client.Reload(ctx, false) // check=true dry-runs without swapping
explain, err := client.Explain(ctx, "svc.app", "sign", "app.signing")
revoked, err := client.Revoke(ctx, "example.org", jti, expiresAtUnix)
Reload re-reads the broker's configured on-disk catalog/policy paths; the request carries no config.
A validation or routing rejection is returned as ReloadResult.Rejection, not by swapping in a bad
generation.
Watch is a long-lived server stream for key rotation, bundle changes, and revocations. It is exempt
from the default per-RPC timeout; the caller owns and closes it.
watch, err := client.Watch(ctx, basil.EventKindKeyRotated)
if err != nil {
return err
}
defer watch.Close()
for ev, err := range watch.Events() {
if err != nil {
return err
}
if ev.Kind == basil.EventKindKeyRotated {
log.Printf("%s -> v%d", ev.KeyRotated.KeyID, ev.KeyRotated.NewVersion)
}
}
You can also use Recv() directly; clean close returns io.EOF.
SPIFFE subpackage
The github.com/openbasil/basil-go/spiffe subpackage wraps go-spiffe's Workload API client
over the Basil socket. It attaches the mandatory workload.spiffe.io: true header, parses SVIDs and
bundles into standard go-spiffe types, and normalizes Basil gRPC errors with basil.FromError.
import "github.com/openbasil/basil-go/spiffe"
sc, err := spiffe.Dial(ctx, "/run/basil/basil.sock")
if err != nil {
return err
}
defer sc.Close()
x509svid, err := sc.FetchX509SVID(ctx)
x509svids, err := sc.FetchX509SVIDs(ctx)
x509Bundles, err := sc.FetchX509Bundles(ctx)
x509Context, err := sc.FetchX509Context(ctx)
jwt, err := sc.FetchJWTSVID(ctx, "orders")
jwts, err := sc.FetchJWTSVIDs(ctx, "orders")
jwtBundles, err := sc.FetchJWTBundles(ctx)
validated, err := sc.ValidateJWTSVID(ctx, token, "orders")
The X.509-SVID includes the workload's own private key, as the SPIFFE standard requires. For long-running processes, use rotation-aware sources:
xsrc, err := spiffe.NewX509Source(ctx, "/run/basil/basil.sock")
defer xsrc.Close()
jsrc, err := spiffe.NewJWTSource(ctx, "/run/basil/basil.sock")
defer jsrc.Close()
WatchX509Context and WatchJWTBundles expose the raw rotation streams when you need to react to
updates yourself. Workload() returns the underlying go-spiffe client for advanced cases.
Streaming subpackage
The github.com/openbasil/basil-go/stream subpackage encrypts files and large payloads in
bounded chunks. It is wire-identical to Rust basil::stream, and it is isolated so callers that do not
need post-quantum streaming do not link those dependencies.
import "github.com/openbasil/basil-go/stream"
cek, err := stream.EncryptAEAD(dst, src, stream.SuiteAES256GCM,
stream.GenerateCEK(), stream.DefaultChunkSize)
err = stream.DecryptAEAD(out, encrypted, cek)
err = stream.EncryptMLKEM(dst, src, stream.SuiteMLKEM768, recipientPubKey,
stream.DefaultChunkSize)
rec := stream.NewBrokerCEKRecovery(client, "app.kem_key", stream.SuiteMLKEM768)
err = stream.DecryptMLKEM(ctx, out, encrypted, rec)
See Streaming encryption for the container, CEK recovery model, and interop tests.
Where to go next
- NATS integration: NATS service split, validation, and xkey boxes.
- Streaming encryption: file and large-payload encryption.
- Integration patterns: native client, sidecar, and pre-fetch.