Skip to content

Commit c8eca65

Browse files
committed
feat: add audit logging for account key hash updates
1 parent a9afe5b commit c8eca65

File tree

2 files changed

+96
-1
lines changed

2 files changed

+96
-1
lines changed

cmd/keymaster/main.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ package main
1212
import (
1313
"bufio"
1414
"context"
15+
"database/sql"
1516
"encoding/json"
1617
"errors"
1718
"fmt"
19+
"io"
1820
"os"
1921
"strings"
2022
"time"
@@ -342,6 +344,7 @@ Running without a subcommand will launch the interactive TUI.`,
342344
deployCmd,
343345
rotateKeyCmd,
344346
auditCmd,
347+
auditCompareCmd,
345348
importCmd,
346349
transferCmd,
347350
trustHostCmd,
@@ -512,6 +515,73 @@ Use --mode=serial to only verify the Keymaster header serial number on the remot
512515
},
513516
}
514517

518+
// auditCompareCmd compares a local or fetched authorized_keys file against
519+
// the stored `accounts.key_hash` for a single account.
520+
var auditCompareCmd = &cobra.Command{
521+
Use: "audit-compare <account-identifier> [file]",
522+
Short: "Compare an authorized_keys file to account key_hash",
523+
Long: "Provide an account identifier and a local file (or omit the file to fetch from the host).",
524+
Args: cobra.RangeArgs(1, 2),
525+
PreRunE: setupDefaultServices,
526+
Run: func(cmd *cobra.Command, args []string) {
527+
identifier := args[0]
528+
var fileArg string
529+
if len(args) > 1 {
530+
fileArg = args[1]
531+
}
532+
533+
st := &cliStoreAdapter{}
534+
accounts, err := st.GetAllAccounts()
535+
if err != nil {
536+
log.Fatalf("error fetching accounts: %v", err)
537+
}
538+
accPtr, err := core.FindAccountByIdentifier(identifier, accounts)
539+
if err != nil {
540+
log.Fatalf("%v", err)
541+
}
542+
account := *accPtr
543+
544+
var content []byte
545+
if fileArg != "" {
546+
if fileArg == "-" {
547+
content, err = io.ReadAll(os.Stdin)
548+
if err != nil {
549+
log.Fatalf("read stdin: %v", err)
550+
}
551+
} else {
552+
content, err = os.ReadFile(fileArg)
553+
if err != nil {
554+
log.Fatalf("open file: %v", err)
555+
}
556+
}
557+
} else {
558+
dm := &cliDeployerManager{}
559+
content, err = dm.FetchAuthorizedKeys(account)
560+
if err != nil {
561+
log.Fatalf("fetch remote authorized_keys: %v", err)
562+
}
563+
}
564+
565+
gotHash := db.HashAuthorizedKeysContent(content)
566+
567+
// Read stored hash from DB
568+
var stored sql.NullString
569+
if err := db.QueryRawInto(context.Background(), db.BunDB(), &stored, "SELECT key_hash FROM accounts WHERE id = ?", account.ID); err != nil {
570+
log.Fatalf("query key_hash: %v", err)
571+
}
572+
if !stored.Valid || stored.String == "" {
573+
fmt.Printf("Account %s (id=%d) has no stored key_hash; computed=%s\n", account.String(), account.ID, gotHash)
574+
return
575+
}
576+
577+
if stored.String == gotHash {
578+
fmt.Printf("MATCH: account=%s id=%d key_hash=%s\n", account.String(), account.ID, gotHash)
579+
} else {
580+
fmt.Printf("MISMATCH: account=%s id=%d\n stored=%s\n computed=%s\n", account.String(), account.ID, stored.String, gotHash)
581+
}
582+
},
583+
}
584+
515585
// importCmd represents the 'import' command.
516586
// It parses a standard authorized_keys file and adds the public keys
517587
// found within it to the Keymaster database.

internal/db/keyhash_bun.go

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@ import (
1010
"database/sql"
1111
"fmt"
1212

13-
"github.com/toeirei/keymaster/internal/model"
1413
"sort"
1514
"strings"
1615
"time"
16+
17+
"github.com/toeirei/keymaster/internal/model"
1718
)
1819

1920
// computeAccountKeyHashTx computes a deterministic fingerprint of the authorized_keys
@@ -136,6 +137,30 @@ func MaybeMarkAccountDirtyTx(ctx context.Context, q execRawProvider, accountID i
136137
if _, err := ExecRaw(ctx, q, "UPDATE accounts SET key_hash = ?, is_dirty = ? WHERE id = ?", newHash, true, accountID); err != nil {
137138
return MapDBError(err)
138139
}
140+
// Record audit entry with the new fingerprint instead of storing/printing full authorized_keys
141+
details := fmt.Sprintf("account:%d key_hash:%s", accountID, newHash)
142+
if _, err := ExecRaw(ctx, q, "INSERT INTO audit_log (username, action, details) VALUES (?, ?, ?)", "system", "ACCOUNT_KEY_HASH_UPDATED", details); err != nil {
143+
return MapDBError(err)
144+
}
139145
}
140146
return nil
141147
}
148+
149+
// HashAuthorizedKeysContent normalizes a raw authorized_keys payload and
150+
// returns the SHA256 hex fingerprint using the same basic normalization
151+
// rules we expect on-disk: normalize CRLF to LF and trim trailing whitespace
152+
// on each line so hashes computed from files transferred between platforms
153+
// remain stable.
154+
func HashAuthorizedKeysContent(raw []byte) string {
155+
s := string(raw)
156+
// Normalize CRLF -> LF
157+
s = strings.ReplaceAll(s, "\r\n", "\n")
158+
// Trim trailing spaces/tabs per-line
159+
lines := strings.Split(s, "\n")
160+
for i := range lines {
161+
lines[i] = strings.TrimRight(lines[i], " \t")
162+
}
163+
norm := strings.Join(lines, "\n")
164+
sum := sha256.Sum256([]byte(norm))
165+
return fmt.Sprintf("%x", sum[:])
166+
}

0 commit comments

Comments
 (0)