- 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>
131 lines
4.2 KiB
Go
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
|
|
}
|