Files
mcias/internal/grpcserver/tokenservice.go
Kyle Isom d87b4b4042 Add vault seal/unseal lifecycle
- New internal/vault package: thread-safe Vault struct with
  seal/unseal state, key material zeroing, and key derivation
- REST: POST /v1/vault/unseal, POST /v1/vault/seal,
  GET /v1/vault/status; health returns sealed status
- UI: /unseal page with passphrase form, redirect when sealed
- gRPC: sealedInterceptor rejects RPCs when sealed
- Middleware: RequireUnsealed blocks all routes except exempt
  paths; RequireAuth reads pubkey from vault at request time
- Startup: server starts sealed when passphrase unavailable
- All servers share single *vault.Vault by pointer
- CSRF manager derives key lazily from vault

Security: Key material is zeroed on seal. Sealed middleware
runs before auth. Handlers fail closed if vault becomes sealed
mid-request. Unseal endpoint is rate-limited (3/s burst 5).
No CSRF on unseal page (no session to protect; chicken-and-egg
with master key). Passphrase never logged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-14 23:55:37 -07:00

131 lines
4.2 KiB
Go

// tokenServiceServer implements mciasv1.TokenServiceServer.
package grpcserver
import (
"context"
"errors"
"fmt"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
mciasv1 "git.wntrmute.dev/kyle/mcias/gen/mcias/v1"
"git.wntrmute.dev/kyle/mcias/internal/db"
"git.wntrmute.dev/kyle/mcias/internal/model"
"git.wntrmute.dev/kyle/mcias/internal/token"
)
type tokenServiceServer struct {
mciasv1.UnimplementedTokenServiceServer
s *Server
}
// ValidateToken validates a JWT and returns its claims.
// Public RPC — no auth required.
//
// Security: Always returns a valid=false response on any error; never
// exposes which specific validation step failed.
func (t *tokenServiceServer) ValidateToken(_ context.Context, req *mciasv1.ValidateTokenRequest) (*mciasv1.ValidateTokenResponse, error) {
tokenStr := req.Token
if tokenStr == "" {
return &mciasv1.ValidateTokenResponse{Valid: false}, nil
}
pubKey, pkErr := t.s.vault.PubKey()
if pkErr != nil {
return &mciasv1.ValidateTokenResponse{Valid: false}, nil
}
claims, err := token.ValidateToken(pubKey, tokenStr, t.s.cfg.Tokens.Issuer)
if err != nil {
return &mciasv1.ValidateTokenResponse{Valid: false}, nil
}
rec, err := t.s.db.GetTokenRecord(claims.JTI)
if err != nil || rec.IsRevoked() {
return &mciasv1.ValidateTokenResponse{Valid: false}, nil
}
return &mciasv1.ValidateTokenResponse{
Valid: true,
Subject: claims.Subject,
Roles: claims.Roles,
ExpiresAt: timestamppb.New(claims.ExpiresAt),
}, nil
}
// IssueServiceToken issues a token for a system account. Admin only.
func (ts *tokenServiceServer) IssueServiceToken(ctx context.Context, req *mciasv1.IssueServiceTokenRequest) (*mciasv1.IssueServiceTokenResponse, error) {
if err := ts.s.requireAdmin(ctx); err != nil {
return nil, err
}
if req.AccountId == "" {
return nil, status.Error(codes.InvalidArgument, "account_id is required")
}
acct, err := ts.s.db.GetAccountByUUID(req.AccountId)
if err != nil {
return nil, status.Error(codes.NotFound, "account not found")
}
if acct.AccountType != model.AccountTypeSystem {
return nil, status.Error(codes.InvalidArgument, "token issue is only for system accounts")
}
privKey, pkErr := ts.s.vault.PrivKey()
if pkErr != nil {
return nil, status.Error(codes.Unavailable, "vault sealed")
}
tokenStr, claims, err := token.IssueToken(privKey, ts.s.cfg.Tokens.Issuer, acct.UUID, nil, ts.s.cfg.ServiceExpiry())
if err != nil {
return nil, status.Error(codes.Internal, "internal error")
}
// Atomically revoke existing system token (if any), track the new token,
// and update system_tokens — all in a single transaction.
// Security: prevents inconsistent state if a crash occurs mid-operation.
var oldJTI string
existing, err := ts.s.db.GetSystemToken(acct.ID)
if err == nil && existing != nil {
oldJTI = existing.JTI
}
if err := ts.s.db.IssueSystemToken(oldJTI, claims.JTI, acct.ID, claims.IssuedAt, claims.ExpiresAt); err != nil {
return nil, status.Error(codes.Internal, "internal error")
}
actorClaims := claimsFromContext(ctx)
var actorID *int64
if actorClaims != nil {
if a, err := ts.s.db.GetAccountByUUID(actorClaims.Subject); err == nil {
actorID = &a.ID
}
}
ts.s.db.WriteAuditEvent(model.EventTokenIssued, actorID, &acct.ID, peerIP(ctx), //nolint:errcheck
fmt.Sprintf(`{"jti":%q}`, claims.JTI))
return &mciasv1.IssueServiceTokenResponse{
Token: tokenStr,
ExpiresAt: timestamppb.New(claims.ExpiresAt),
}, nil
}
// RevokeToken revokes a token by JTI. Admin only.
func (ts *tokenServiceServer) RevokeToken(ctx context.Context, req *mciasv1.RevokeTokenRequest) (*mciasv1.RevokeTokenResponse, error) {
if err := ts.s.requireAdmin(ctx); err != nil {
return nil, err
}
if req.Jti == "" {
return nil, status.Error(codes.InvalidArgument, "jti is required")
}
if err := ts.s.db.RevokeToken(req.Jti, "admin revocation"); err != nil {
if errors.Is(err, db.ErrNotFound) {
return nil, status.Error(codes.NotFound, "token not found or already revoked")
}
return nil, status.Error(codes.Internal, "internal error")
}
ts.s.db.WriteAuditEvent(model.EventTokenRevoked, nil, nil, peerIP(ctx), //nolint:errcheck
fmt.Sprintf(`{"jti":%q}`, req.Jti))
return &mciasv1.RevokeTokenResponse{}, nil
}