From 1c8d999ce4d26e7ff4e22a54f4753ff797924184 Mon Sep 17 00:00:00 2001 From: Ravi Pina Date: Tue, 16 Jun 2026 14:18:29 -0700 Subject: [PATCH] feat: encrypt WLAN secrets at rest, add decrypt keyword Refresh now captures PSK/RADIUS secrets and encrypts them (enc:) before writing the cache, resolving the password from WIFIMGR_PASSWORD or a prompt. Meraki needs a per-SSID GET to obtain the PSK; Mist stops discarding it. The import commands gain a decrypt keyword: secrets masks as *secret*, decrypt reveals plaintext. --- cmd/import_api_site.go | 86 ++++++++++++++++++----- cmd/import_api_site_test.go | 58 +++++++++++---- cmd/import_api_templates.go | 21 +++--- cmd/import_api_templates_test.go | 8 +-- cmd/import_roundtrip_test.go | 2 +- docs/configuration.md | 30 ++++++++ internal/cmdutils/positional_args.go | 4 ++ internal/cmdutils/positional_args_test.go | 6 +- internal/vendors/cache_manager.go | 18 +++++ internal/vendors/cache_manager_refresh.go | 55 +++++++++++++++ internal/vendors/meraki/wlans.go | 72 +++++++++++++++---- internal/vendors/mist/wlans.go | 15 ++-- internal/vendors/wlan_secrets_test.go | 62 ++++++++++++++++ 13 files changed, 376 insertions(+), 61 deletions(-) create mode 100644 internal/vendors/wlan_secrets_test.go diff --git a/cmd/import_api_site.go b/cmd/import_api_site.go index 8e3ab28..249c5e8 100644 --- a/cmd/import_api_site.go +++ b/cmd/import_api_site.go @@ -32,6 +32,7 @@ import ( "github.com/ravinald/wifimgr/internal/cmdutils" "github.com/ravinald/wifimgr/internal/config" + "github.com/ravinald/wifimgr/internal/encryption" "github.com/ravinald/wifimgr/internal/logging" "github.com/ravinald/wifimgr/internal/vendors" "github.com/ravinald/wifimgr/internal/vendors/meraki" @@ -53,7 +54,7 @@ const ( // importAPISiteCmd represents the "import api site" command var importAPISiteCmd = &cobra.Command{ - Use: "site [config|inventory|all] [full|type ] [source ] [secrets] [compare] [save] [file ]", + Use: "site [config|inventory|all] [full|type ] [source ] [secrets|decrypt] [compare] [save] [file ]", Short: "Import site configuration from API cache", Long: `Import site configuration from the API cache. @@ -106,7 +107,9 @@ Arguments: - ap Access point configurations only - switch Switch configurations only - gateway Gateway/firewall configurations only - secrets Optional. Include sensitive data (PSK, RADIUS secrets) - redacted by default + secrets Optional. Include PSK/RADIUS secrets, masked as "*secret*" + decrypt Optional. Include secrets decrypted to plaintext (implies secrets); + needs the encryption password (WIFIMGR_PASSWORD or prompt) compare Optional. Compare API state with existing import file (using jsondiff) save Optional. Write to import file (default: print to STDOUT) file Optional. Keyword followed by output filename (relative to config_dir or absolute) @@ -114,7 +117,7 @@ Arguments: What it Does: 1. Retrieves site configuration from the specified API cache 2. Optionally filters to specific scope (wlans, profiles, ap, switch, gateway) - 3. Redacts secrets by default (use 'secrets' to include) + 3. Omits secrets by default; 'secrets' masks them, 'decrypt' reveals plaintext 4. Prints to STDOUT or saves to a single import file if 'save' specified 5. With 'compare': shows diff between API and existing local import file @@ -279,7 +282,11 @@ func runImportAPISite(cmd *cobra.Command, args []string) error { // Build the combined ImportFile envelope (site config + site-local WLAN // templates live side-by-side so the file self-describes). - exportData, err := buildSiteExportData(cacheAccessor, site, parsed.scope, parsed.IncludeSecrets) + reveal, err := resolveSecretReveal(parsed.IncludeSecrets, parsed.Decrypt) + if err != nil { + return err + } + exportData, err := buildSiteExportData(cacheAccessor, site, parsed.scope, reveal) if err != nil { return fmt.Errorf("failed to build export data: %w", err) } @@ -582,10 +589,56 @@ type templatesEnvelope struct { Device map[string]map[string]any `json:"device,omitempty"` } +// maskedSecret is the placeholder emitted for a secret the caller asked to +// include but not decrypt. +const maskedSecret = "*secret*" + +// secretReveal controls how WLAN secrets are emitted on import. include turns +// the secret fields on at all; decrypt swaps the masked placeholder for the +// plaintext, which needs the encryption password. +type secretReveal struct { + include bool + decrypt bool + password string +} + +// resolveSecretReveal builds the reveal policy from the parsed keywords, +// prompting for the encryption password when decrypt was requested. +func resolveSecretReveal(include, decrypt bool) (secretReveal, error) { + r := secretReveal{include: include || decrypt, decrypt: decrypt} + if decrypt { + pw, err := encryption.GetPasswordOrPrompt("Enter encryption password to decrypt secrets: ") + if err != nil { + return secretReveal{}, fmt.Errorf("decrypt requires the encryption password: %w", err) + } + r.password = pw + } + return r, nil +} + +// revealSecret renders a stored (encrypted) secret for output: the masked +// placeholder unless decrypt was requested, in which case it's decrypted with +// the supplied password. A decrypt failure falls back to the mask so a wrong +// password neither leaks ciphertext nor aborts the export. +func revealSecret(stored string, reveal secretReveal) string { + if !reveal.decrypt { + return maskedSecret + } + if !encryption.IsEncrypted(stored) { + return stored + } + plain, err := encryption.Decrypt(stored, reveal.password) + if err != nil { + logging.Warnf("Failed to decrypt secret, masking instead: %v", err) + return maskedSecret + } + return plain +} + // buildSiteExportData builds an ImportFile-shaped envelope containing both // the site config and the companion WLAN templates used by that site. Returns // nil when the scope produces nothing to emit. -func buildSiteExportData(cacheAccessor *vendors.CacheAccessor, site *vendors.SiteInfo, scope ImportScope, includeSecrets bool) (*importEnvelope, error) { +func buildSiteExportData(cacheAccessor *vendors.CacheAccessor, site *vendors.SiteInfo, scope ImportScope, reveal secretReveal) (*importEnvelope, error) { siteSlug := slug(site.Name) // WLANs always processed first so label references are ready to attach. @@ -593,7 +646,7 @@ func buildSiteExportData(cacheAccessor *vendors.CacheAccessor, site *vendors.Sit var wlanTemplates map[string]map[string]any var wlanAssign map[string][]string if scope == ScopeFull || scope == ScopeWLANs { - wlanLabels, wlanTemplates, wlanAssign = buildWLANsExport(cacheAccessor, site.ID, siteSlug, includeSecrets) + wlanLabels, wlanTemplates, wlanAssign = buildWLANsExport(cacheAccessor, site.ID, siteSlug, reveal) } env := &importEnvelope{ @@ -969,9 +1022,9 @@ func buildDevicesExport(cacheAccessor *vendors.CacheAccessor, siteID string, sou // to specific APs (apply_to=aps); the caller wires those into device-level wlan // lists so apply resolves them back to the same ap_ids — a functional no-op. // Meraki carries its availability inside the WLAN's vendor block instead. -func buildWLANsExport(cacheAccessor *vendors.CacheAccessor, siteID, siteSlug string, includeSecrets bool) ([]string, map[string]map[string]any, map[string][]string) { +func buildWLANsExport(cacheAccessor *vendors.CacheAccessor, siteID, siteSlug string, reveal secretReveal) ([]string, map[string]map[string]any, map[string][]string) { wlans := cacheAccessor.GetWLANsBySite(siteID) - labels, profiles, vendorBlocks := synthesizeWLANLabels(wlans, siteSlug, includeSecrets) + labels, profiles, vendorBlocks := synthesizeWLANLabels(wlans, siteSlug, reveal) if len(profiles) == 0 { return labels, nil, nil } @@ -1056,7 +1109,7 @@ func profileToMap(p *config.WLANProfile) (map[string]any, error) { // matching WLANProfile map, and any per-label vendor blocks (e.g. the Meraki // SSID slot) that pin the WLAN to its native identity. Separated so unit tests // can drive it directly. -func synthesizeWLANLabels(vendorWLANs []*vendors.WLAN, siteSlug string, includeSecrets bool) ([]string, map[string]*config.WLANProfile, map[string]map[string]any) { +func synthesizeWLANLabels(vendorWLANs []*vendors.WLAN, siteSlug string, reveal secretReveal) ([]string, map[string]*config.WLANProfile, map[string]map[string]any) { if len(vendorWLANs) == 0 { return nil, nil, nil } @@ -1074,7 +1127,7 @@ func synthesizeWLANLabels(vendorWLANs []*vendors.WLAN, siteSlug string, includeS } used[base]++ - profiles[label] = convertVendorWLANToProfile(w, includeSecrets) + profiles[label] = convertVendorWLANToProfile(w, reveal) if block := vendorBlockForWLAN(w); block != nil { vendorBlocks[label] = block } @@ -1179,8 +1232,9 @@ func merakiSlot(w *vendors.WLAN) (int, bool) { // (network IDs, ipAssignmentMode, etc.) is deliberately dropped here — this is // the portable half. The one binding worth keeping, the Meraki SSID slot, rides // alongside in a vendor block (see vendorBlockForWLAN), not in the profile. PSK -// and RADIUS secrets honor includeSecrets and are otherwise omitted. -func convertVendorWLANToProfile(w *vendors.WLAN, includeSecrets bool) *config.WLANProfile { +// and RADIUS secrets honor the reveal policy: omitted unless requested, masked +// as a placeholder by default, and decrypted only when decrypt was passed. +func convertVendorWLANToProfile(w *vendors.WLAN, reveal secretReveal) *config.WLANProfile { profile := &config.WLANProfile{ SSID: w.SSID, Enabled: w.Enabled, @@ -1192,8 +1246,8 @@ func convertVendorWLANToProfile(w *vendors.WLAN, includeSecrets bool) *config.WL }, } - if includeSecrets && w.PSK != "" { - profile.Auth.PSK = w.PSK + if reveal.include && w.PSK != "" { + profile.Auth.PSK = revealSecret(w.PSK, reveal) } if pairwise := derivePairwiseFromConfig(w.Config, w.EncryptionMode); len(pairwise) > 0 { @@ -1204,8 +1258,8 @@ func convertVendorWLANToProfile(w *vendors.WLAN, includeSecrets bool) *config.WL servers := make([]config.RADIUSServer, 0, len(w.RadiusServers)) for _, rs := range w.RadiusServers { cs := config.RADIUSServer{Host: rs.Host, Port: rs.Port} - if includeSecrets { - cs.Secret = rs.Secret + if reveal.include && rs.Secret != "" { + cs.Secret = revealSecret(rs.Secret, reveal) } servers = append(servers, cs) } diff --git a/cmd/import_api_site_test.go b/cmd/import_api_site_test.go index f482132..de1d93a 100644 --- a/cmd/import_api_site_test.go +++ b/cmd/import_api_site_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/ravinald/wifimgr/internal/config" + "github.com/ravinald/wifimgr/internal/encryption" "github.com/ravinald/wifimgr/internal/vendors" ) @@ -194,7 +195,7 @@ func TestConvertVendorWLANToProfile_Meraki(t *testing.T) { "splashPage": "Click-through", }, } - got := convertVendorWLANToProfile(w, true) + got := convertVendorWLANToProfile(w, secretReveal{include: true, decrypt: true}) if got.SSID != "Scale Guest" || !got.Enabled || got.VLANID != 10 || got.Band != "dual" { t.Errorf("basic fields wrong: %+v", got) @@ -213,12 +214,45 @@ func TestConvertVendorWLANToProfile_Meraki(t *testing.T) { } // With includeSecrets=false, PSK must be stripped. - gotNoSec := convertVendorWLANToProfile(w, false) + gotNoSec := convertVendorWLANToProfile(w, secretReveal{}) if gotNoSec.Auth.PSK != "" { t.Errorf("PSK leaked when includeSecrets=false: %q", gotNoSec.Auth.PSK) } } +// TestConvertVendorWLANToProfile_SecretReveal checks the three output states for +// a secret stored encrypted in the cache: omitted, masked, and decrypted. +func TestConvertVendorWLANToProfile_SecretReveal(t *testing.T) { + const password = "correct horse battery staple" + encPSK, err := encryption.Encrypt("s3cr3t-pass", password) + if err != nil { + t.Fatalf("seed encrypt: %v", err) + } + w := &vendors.WLAN{SSID: "Scale", Enabled: true, AuthType: "psk", PSK: encPSK} + + // Omitted: no secrets keyword. + if got := convertVendorWLANToProfile(w, secretReveal{}); got.Auth.PSK != "" { + t.Errorf("PSK should be omitted, got %q", got.Auth.PSK) + } + + // Masked: secrets without decrypt never exposes ciphertext or plaintext. + if got := convertVendorWLANToProfile(w, secretReveal{include: true}); got.Auth.PSK != maskedSecret { + t.Errorf("PSK = %q, want %q", got.Auth.PSK, maskedSecret) + } + + // Decrypted: correct password yields the plaintext. + got := convertVendorWLANToProfile(w, secretReveal{include: true, decrypt: true, password: password}) + if got.Auth.PSK != "s3cr3t-pass" { + t.Errorf("decrypted PSK = %q, want %q", got.Auth.PSK, "s3cr3t-pass") + } + + // Wrong password falls back to the mask rather than leaking ciphertext. + bad := convertVendorWLANToProfile(w, secretReveal{include: true, decrypt: true, password: "wrong"}) + if bad.Auth.PSK != maskedSecret { + t.Errorf("bad-password PSK = %q, want %q", bad.Auth.PSK, maskedSecret) + } +} + // TestConvertVendorWLANToProfile_MerakiRealWorld mirrors the exact field shape // a Meraki site produces today (verbose bandSelection string, legacy "wpa" // encryption mode, iPSK auth type) and checks the output is canonical. @@ -235,7 +269,7 @@ func TestConvertVendorWLANToProfile_MerakiRealWorld(t *testing.T) { "bandSelection": "Dual band operation with Band Steering", }, } - got := convertVendorWLANToProfile(w, false) + got := convertVendorWLANToProfile(w, secretReveal{}) if got.Band != "dual" { t.Errorf("Band = %q, want %q", got.Band, "dual") @@ -268,7 +302,7 @@ func TestConvertVendorWLANToProfile_MistEnterprise(t *testing.T) { "portal": map[string]any{"enabled": true, "auth": "sponsor"}, }, } - got := convertVendorWLANToProfile(w, true) + got := convertVendorWLANToProfile(w, secretReveal{include: true, decrypt: true}) if got.Auth.Type != "eap" { t.Errorf("expected Auth.Type=eap, got %q", got.Auth.Type) @@ -287,7 +321,7 @@ func TestConvertVendorWLANToProfile_MistEnterprise(t *testing.T) { } // includeSecrets=false strips RADIUS secret but keeps host/port. - gotNoSec := convertVendorWLANToProfile(w, false) + gotNoSec := convertVendorWLANToProfile(w, secretReveal{}) if gotNoSec.Auth.RADIUSServers[0].Secret != "" { t.Errorf("RADIUS secret leaked: %q", gotNoSec.Auth.RADIUSServers[0].Secret) } @@ -301,7 +335,7 @@ func TestSynthesizeWLANLabels(t *testing.T) { {SSID: "Scale Guest", Enabled: true, AuthType: "open"}, {SSID: "Scale Robotics", Enabled: true, AuthType: "psk"}, } - labels, profiles, _ := synthesizeWLANLabels(wlans, "mx-mex-904", false) + labels, profiles, _ := synthesizeWLANLabels(wlans, "mx-mex-904", secretReveal{}) if len(labels) != 2 || len(profiles) != 2 { t.Fatalf("expected 2 labels/profiles, got %d/%d", len(labels), len(profiles)) @@ -324,7 +358,7 @@ func TestSynthesizeWLANLabels_CollisionSuffix(t *testing.T) { {SSID: "corp-net", Enabled: true, AuthType: "psk"}, {SSID: "CORP__NET", Enabled: true, AuthType: "psk"}, } - labels, profiles, _ := synthesizeWLANLabels(wlans, "site", false) + labels, profiles, _ := synthesizeWLANLabels(wlans, "site", secretReveal{}) want := []string{"site--corp-net", "site--corp-net-2", "site--corp-net-3"} if !equalStrings(labels, want) { @@ -336,7 +370,7 @@ func TestSynthesizeWLANLabels_CollisionSuffix(t *testing.T) { } func TestSynthesizeWLANLabels_Empty(t *testing.T) { - labels, profiles, vendorBlocks := synthesizeWLANLabels(nil, "site", false) + labels, profiles, vendorBlocks := synthesizeWLANLabels(nil, "site", secretReveal{}) if labels != nil || profiles != nil || vendorBlocks != nil { t.Errorf("empty input should return nil/nil/nil, got %v/%v/%v", labels, profiles, vendorBlocks) } @@ -350,7 +384,7 @@ func TestSynthesizeWLANLabels_PinsMerakiSlot(t *testing.T) { {SSID: "Scale Guest", Enabled: true, AuthType: "open", SourceVendor: "meraki", ID: "L_123:3", Config: map[string]any{"number": float64(3)}}, {SSID: "Corp", Enabled: true, AuthType: "psk", SourceVendor: "mist", ID: "uuid-abc"}, } - _, templates := buildWLANsExportFromWLANs(wlans, "site", false) + _, templates := buildWLANsExportFromWLANs(wlans, "site", secretReveal{}) block, ok := templates["site--scale-guest"]["meraki:"].(map[string]any) if !ok { @@ -472,7 +506,7 @@ func TestExportRoundTripsThroughLoaderTypes(t *testing.T) { wlans := []*vendors.WLAN{ {SSID: "Scale Guest", Enabled: true, AuthType: "open"}, } - labels, templates := buildWLANsExportFromWLANs(wlans, "mx-mex-904", false) + labels, templates := buildWLANsExportFromWLANs(wlans, "mx-mex-904", secretReveal{}) env := &importEnvelope{ Version: 1, @@ -530,8 +564,8 @@ func TestExportRoundTripsThroughLoaderTypes(t *testing.T) { // buildWLANsExportFromWLANs is a test helper mirroring buildWLANsExport but // bypasses the cache accessor so the test can drive synthesizeWLANLabels // directly and then mimic the profile-to-map conversion. -func buildWLANsExportFromWLANs(ws []*vendors.WLAN, siteSlug string, includeSecrets bool) ([]string, map[string]map[string]any) { - labels, profiles, vendorBlocks := synthesizeWLANLabels(ws, siteSlug, includeSecrets) +func buildWLANsExportFromWLANs(ws []*vendors.WLAN, siteSlug string, reveal secretReveal) ([]string, map[string]map[string]any) { + labels, profiles, vendorBlocks := synthesizeWLANLabels(ws, siteSlug, reveal) if len(profiles) == 0 { return labels, nil } diff --git a/cmd/import_api_templates.go b/cmd/import_api_templates.go index aae52af..b2c9794 100644 --- a/cmd/import_api_templates.go +++ b/cmd/import_api_templates.go @@ -48,7 +48,7 @@ const ( // into a standalone ImportFile. Scoped to a specific API because template // namespaces live at the API/org boundary. var importAPITemplatesCmd = &cobra.Command{ - Use: "templates [target ] [type wlan|rf|device|gateway|all] [save] [file ] [secrets]", + Use: "templates [target ] [type wlan|rf|device|gateway|all] [save] [file ] [secrets|decrypt]", Short: "Import vendor-level templates from API cache", Long: `Import vendor-level (org-scoped) templates from a specific API. @@ -70,8 +70,9 @@ Arguments: type Optional. Template kind to export (default: wlan). Currently only "wlan" is supported; "rf", "device", "gateway", "all" are reserved for later coverage. - secrets Optional. Include sensitive data (PSK, RADIUS secrets) — - redacted by default. + secrets Optional. Include PSK/RADIUS secrets, masked as "*secret*". + decrypt Optional. Include secrets decrypted to plaintext (implies + secrets); needs WIFIMGR_PASSWORD or an interactive prompt. save Optional. Write to import file (default: print to STDOUT). file Optional. Keyword followed by output filename (relative to config_dir or absolute). @@ -188,7 +189,11 @@ func runImportAPITemplates(cmd *cobra.Command, args []string) error { } // Build the envelope. For now only WLANs are supported. - env, err := buildTemplatesExportData(cacheAccessor, parsed.apiLabel, parsed.templateType, parsed.IncludeSecrets) + reveal, err := resolveSecretReveal(parsed.IncludeSecrets, parsed.Decrypt) + if err != nil { + return err + } + env, err := buildTemplatesExportData(cacheAccessor, parsed.apiLabel, parsed.templateType, reveal) if err != nil { return fmt.Errorf("failed to build templates export: %w", err) } @@ -243,7 +248,7 @@ func resolveTemplateImportPath(outputFile, configDir, apiLabel string, kind Temp // buildTemplatesExportData materializes an ImportFile envelope containing // vendor-level templates for the given API. -func buildTemplatesExportData(cacheAccessor *vendors.CacheAccessor, apiLabel string, kind TemplateImportType, includeSecrets bool) (*importEnvelope, error) { +func buildTemplatesExportData(cacheAccessor *vendors.CacheAccessor, apiLabel string, kind TemplateImportType, reveal secretReveal) (*importEnvelope, error) { env := &importEnvelope{ Version: 1, Source: &importSourceExport{ @@ -256,7 +261,7 @@ func buildTemplatesExportData(cacheAccessor *vendors.CacheAccessor, apiLabel str switch kind { case TemplateTypeWLAN: wlans := collectOrgWLANs(cacheAccessor, apiLabel) - templates := synthesizeOrgWLANTemplates(wlans, includeSecrets) + templates := synthesizeOrgWLANTemplates(wlans, reveal) if len(templates) == 0 { return env, nil } @@ -290,7 +295,7 @@ func collectOrgWLANs(cacheAccessor *vendors.CacheAccessor, apiLabel string) []*v // synthesizeOrgWLANTemplates builds the Templates.WLAN map for org-level // WLANs. Labels are bare slugs of the SSID — no site prefix, because the // scope IS the whole org. Collisions append a counter suffix. -func synthesizeOrgWLANTemplates(ws []*vendors.WLAN, includeSecrets bool) map[string]map[string]any { +func synthesizeOrgWLANTemplates(ws []*vendors.WLAN, reveal secretReveal) map[string]map[string]any { if len(ws) == 0 { return nil } @@ -308,7 +313,7 @@ func synthesizeOrgWLANTemplates(ws []*vendors.WLAN, includeSecrets bool) map[str } used[base]++ - profile := convertVendorWLANToProfile(w, includeSecrets) + profile := convertVendorWLANToProfile(w, reveal) m, err := profileToMap(profile) if err != nil { logging.Warnf("[import templates] failed to serialize WLAN %q: %v", w.SSID, err) diff --git a/cmd/import_api_templates_test.go b/cmd/import_api_templates_test.go index e198b92..bf578e3 100644 --- a/cmd/import_api_templates_test.go +++ b/cmd/import_api_templates_test.go @@ -68,7 +68,7 @@ func TestSynthesizeOrgWLANTemplates_BareSlugs(t *testing.T) { {SSID: "Corp Net", Enabled: true, AuthType: "wpa2-enterprise"}, {SSID: "Guest Wi-Fi", Enabled: true, AuthType: "open"}, } - out := synthesizeOrgWLANTemplates(ws, false) + out := synthesizeOrgWLANTemplates(ws, secretReveal{}) if len(out) != 2 { t.Fatalf("expected 2 templates, got %d", len(out)) } @@ -84,7 +84,7 @@ func TestSynthesizeOrgWLANTemplates_CollisionSuffix(t *testing.T) { {SSID: "Corp Net", Enabled: true, AuthType: "open"}, {SSID: "corp-net", Enabled: true, AuthType: "psk"}, } - out := synthesizeOrgWLANTemplates(ws, false) + out := synthesizeOrgWLANTemplates(ws, secretReveal{}) if len(out) != 2 { t.Fatalf("expected 2 templates, got %d", len(out)) } @@ -96,7 +96,7 @@ func TestSynthesizeOrgWLANTemplates_CollisionSuffix(t *testing.T) { } func TestSynthesizeOrgWLANTemplates_Empty(t *testing.T) { - if got := synthesizeOrgWLANTemplates(nil, false); got != nil { + if got := synthesizeOrgWLANTemplates(nil, secretReveal{}); got != nil { t.Errorf("empty input should return nil, got %v", got) } } @@ -113,7 +113,7 @@ func TestOrgTemplatesRoundTripThroughLoader(t *testing.T) { API: "mist-prod", Kind: "wlan-templates", }, - Templates: &templatesEnvelope{WLAN: synthesizeOrgWLANTemplates(ws, false)}, + Templates: &templatesEnvelope{WLAN: synthesizeOrgWLANTemplates(ws, secretReveal{})}, } data, err := json.Marshal(env) diff --git a/cmd/import_roundtrip_test.go b/cmd/import_roundtrip_test.go index bb89044..3cd1412 100644 --- a/cmd/import_roundtrip_test.go +++ b/cmd/import_roundtrip_test.go @@ -94,7 +94,7 @@ func runRoundTripCase(t *testing.T, vendor, siteName string, seedWLANs []*vendor siteSlug := slug(siteName) // Step 1+2: export pipeline (no cache accessor needed for this slice). - labels, profiles, _ := synthesizeWLANLabels(seedWLANs, siteSlug, true /* includeSecrets */) + labels, profiles, _ := synthesizeWLANLabels(seedWLANs, siteSlug, secretReveal{include: true, decrypt: true}) if len(labels) != len(seedWLANs) { t.Fatalf("label count = %d, want %d", len(labels), len(seedWLANs)) } diff --git a/docs/configuration.md b/docs/configuration.md index 67d4de1..89c0644 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -378,6 +378,36 @@ When `WIFIMGR_PASSWORD` is set (either as an environment variable or in `.env.wi **Security note:** The password remains available in memory for the duration of the session when loaded via `.env.wifimgr` with the `-e` flag, enabling decryption of multiple values without re-prompting. +### Secrets in the Cache + +WLAN secrets (PSK, RADIUS shared secret) are encrypted at rest. When `refresh` pulls +WLANs from a vendor API, it encrypts any captured secret with the same `enc:` scheme +before writing the cache file — secrets never land on disk in the clear. Refresh resolves +the encryption password from `WIFIMGR_PASSWORD` or prompts for it once. + +Because the password protects the cache, use the **same** password across refreshes; a +secret encrypted with one password can't be decrypted with another. If you refresh with a +different password, re-run `refresh` to re-encrypt under the intended one. + +The `import api site` and `import api templates` commands read these encrypted secrets: + +| Keyword | Output for a stored secret | +|-------------|---------------------------------------------------------------| +| *(none)* | Secret field omitted | +| `secrets` | Field present, masked as `*secret*` | +| `decrypt` | Field present, decrypted to plaintext (implies `secrets`) | + +`decrypt` needs the encryption password (`WIFIMGR_PASSWORD` or prompt). A wrong password +falls back to the `*secret*` mask rather than emitting ciphertext. + +```bash +wifimgr import api site US-SFO-LAB type wlans secrets # PSK shown as *secret* +WIFIMGR_PASSWORD=… wifimgr import api site US-SFO-LAB type wlans decrypt # plaintext PSK +``` + +> Meraki returns the PSK only on its per-SSID endpoint, so `refresh` fetches each SSID +> individually to capture it. Meraki does not return RADIUS shared secrets at all. + ### PSK Validation When encrypting WiFi passwords, use `encrypt psk` to validate against IEEE 802.11i requirements: diff --git a/internal/cmdutils/positional_args.go b/internal/cmdutils/positional_args.go index f3e08e9..6a821bc 100644 --- a/internal/cmdutils/positional_args.go +++ b/internal/cmdutils/positional_args.go @@ -225,6 +225,7 @@ func ParseApplyArgs(args []string) (*ParsedApplyArgs, error) { // identically across commands; the command-specific keywords stay local. type ImportOutputArgs struct { IncludeSecrets bool + Decrypt bool // reveal plaintext secrets; implies IncludeSecrets SaveMode bool OutputFile string } @@ -237,6 +238,9 @@ func (o *ImportOutputArgs) Consume(args []string, i int) (matched bool, last int switch strings.ToLower(args[i]) { case "secrets": o.IncludeSecrets = true + case "decrypt": + o.Decrypt = true + o.IncludeSecrets = true case "save": o.SaveMode = true case "file": diff --git a/internal/cmdutils/positional_args_test.go b/internal/cmdutils/positional_args_test.go index fe001b7..6c637ea 100644 --- a/internal/cmdutils/positional_args_test.go +++ b/internal/cmdutils/positional_args_test.go @@ -113,11 +113,13 @@ func TestImportOutputArgsConsume(t *testing.T) { wantMatch bool wantLast int wantSecrets bool + wantDecrypt bool wantSave bool wantFile string wantErr string }{ {name: "secrets", args: []string{"secrets"}, wantMatch: true, wantLast: 0, wantSecrets: true}, + {name: "decrypt implies secrets", args: []string{"decrypt"}, wantMatch: true, wantLast: 0, wantSecrets: true, wantDecrypt: true}, {name: "save", args: []string{"save"}, wantMatch: true, wantLast: 0, wantSave: true}, {name: "file consumes operand", args: []string{"file", "x.json"}, wantMatch: true, wantLast: 1, wantFile: "x.json"}, {name: "file strips quotes", args: []string{"file", `"q.json"`}, wantMatch: true, wantLast: 1, wantFile: "q.json"}, @@ -140,8 +142,8 @@ func TestImportOutputArgsConsume(t *testing.T) { if matched != tc.wantMatch || last != tc.wantLast { t.Errorf("matched=%v last=%d, want matched=%v last=%d", matched, last, tc.wantMatch, tc.wantLast) } - if o.IncludeSecrets != tc.wantSecrets || o.SaveMode != tc.wantSave || o.OutputFile != tc.wantFile { - t.Errorf("state = %+v, want secrets=%v save=%v file=%q", o, tc.wantSecrets, tc.wantSave, tc.wantFile) + if o.IncludeSecrets != tc.wantSecrets || o.Decrypt != tc.wantDecrypt || o.SaveMode != tc.wantSave || o.OutputFile != tc.wantFile { + t.Errorf("state = %+v, want secrets=%v decrypt=%v save=%v file=%q", o, tc.wantSecrets, tc.wantDecrypt, tc.wantSave, tc.wantFile) } }) } diff --git a/internal/vendors/cache_manager.go b/internal/vendors/cache_manager.go index 27b0b5e..d125d2b 100644 --- a/internal/vendors/cache_manager.go +++ b/internal/vendors/cache_manager.go @@ -7,6 +7,7 @@ import ( "path/filepath" "sync" + "github.com/ravinald/wifimgr/internal/encryption" "github.com/ravinald/wifimgr/internal/helpers" "github.com/ravinald/wifimgr/internal/logging" ) @@ -23,6 +24,23 @@ type CacheManager struct { // API is strictly ordered. Combined with WriteFileAtomic this makes // in-process concurrent refreshes safe. labelMus sync.Map // map[string]*sync.Mutex + + // secretPw caches the encryption password used to protect WLAN secrets at + // rest. Resolved once per process so a parallel refresh-all prompts at most + // once; see secretPassword. + secretPwOnce sync.Once + secretPw string + secretPwErr error +} + +// secretPassword resolves the password used to encrypt WLAN secrets in the +// cache — from WIFIMGR_PASSWORD or an interactive prompt — once per process. +func (c *CacheManager) secretPassword() (string, error) { + c.secretPwOnce.Do(func() { + c.secretPw, c.secretPwErr = encryption.GetPasswordOrPrompt( + "Enter encryption password to protect WLAN secrets in cache: ") + }) + return c.secretPw, c.secretPwErr } // RefreshOptions controls the behavior of cache refresh operations. diff --git a/internal/vendors/cache_manager_refresh.go b/internal/vendors/cache_manager_refresh.go index f8fd0a3..e4b290e 100644 --- a/internal/vendors/cache_manager_refresh.go +++ b/internal/vendors/cache_manager_refresh.go @@ -7,9 +7,46 @@ import ( "sync" "time" + "github.com/ravinald/wifimgr/internal/encryption" "github.com/ravinald/wifimgr/internal/logging" ) +// wlanHasPlaintextSecret reports whether a freshly fetched WLAN carries a PSK or +// RADIUS secret that still needs encrypting before it can be cached. +func wlanHasPlaintextSecret(w *WLAN) bool { + if w.PSK != "" && !encryption.IsEncrypted(w.PSK) { + return true + } + for _, rs := range w.RadiusServers { + if rs.Secret != "" && !encryption.IsEncrypted(rs.Secret) { + return true + } + } + return false +} + +// encryptWLANSecrets replaces a WLAN's plaintext PSK and RADIUS secrets with +// enc: ciphertext so secrets never reach the cache file in the clear. Empty or +// already-encrypted values pass through untouched. +func encryptWLANSecrets(w *WLAN, password string) error { + enc := func(v string) (string, error) { + if v == "" || encryption.IsEncrypted(v) { + return v, nil + } + return encryption.Encrypt(v, password) + } + var err error + if w.PSK, err = enc(w.PSK); err != nil { + return err + } + for i := range w.RadiusServers { + if w.RadiusServers[i].Secret, err = enc(w.RadiusServers[i].Secret); err != nil { + return err + } + } + return nil +} + // skipDeviceConfig reports whether a device's per-device config fetch should be // skipped (and its prior config carried forward) for this refresh. A device is // skipped when it falls outside the site scope, or — under a managed refresh — @@ -315,7 +352,25 @@ func (c *CacheManager) doRefreshAPI(ctx context.Context, apiLabel string, opts R if cache.WLANs == nil { cache.WLANs = make(map[string]*WLAN) } + // Secrets must never hit the cache file in the clear. Resolve the + // password once, and only when a WLAN actually carries a secret, so + // secret-free refreshes never prompt. + var pw string + for _, w := range wlans { + if wlanHasPlaintextSecret(w) { + pw, err = c.secretPassword() + if err != nil { + return fmt.Errorf("cache: resolve secret password: %w", err) + } + break + } + } for _, w := range wlans { + if pw != "" { + if encErr := encryptWLANSecrets(w, pw); encErr != nil { + return fmt.Errorf("cache: encrypt WLAN %s secrets: %w", w.ID, encErr) + } + } cache.WLANs[w.ID] = w } fmt.Printf(" %d WLANs\n", len(wlans)) diff --git a/internal/vendors/meraki/wlans.go b/internal/vendors/meraki/wlans.go index 6103707..6383fa2 100644 --- a/internal/vendors/meraki/wlans.go +++ b/internal/vendors/meraki/wlans.go @@ -61,8 +61,14 @@ func (s *wlansService) Get(ctx context.Context, id string) (*vendors.WLAN, error return nil, err } + return s.fetchSSID(ctx, networkID, number) +} + +// fetchSSID does a single SSID GET with retry/rate-limiting and returns the +// vendor WLAN, including the PSK parsed from the raw response body (the SDK's +// typed response omits it). Shared by Get and the per-network refresh. +func (s *wlansService) fetchSSID(ctx context.Context, networkID, number string) (*vendors.WLAN, error) { retryState := NewRetryState(s.retryConfig) - var ssid *meraki.ResponseWirelessGetNetworkWirelessSSID for { if s.rateLimiter != nil { @@ -71,33 +77,52 @@ func (s *wlansService) Get(ctx context.Context, id string) (*vendors.WLAN, error } } + var ssid *meraki.ResponseWirelessGetNetworkWirelessSSID + var rawPSK string var fetchErr error if s.suppressOutput { restore := suppressStdout() - ssid, _, fetchErr = s.dashboard.Wireless.GetNetworkWirelessSSID(networkID, number) + r, resp, e := s.dashboard.Wireless.GetNetworkWirelessSSID(networkID, number) restore() + ssid, fetchErr = r, e + if e == nil && resp != nil { + rawPSK = pskFromBody(resp.Body()) + } } else { - ssid, _, fetchErr = s.dashboard.Wireless.GetNetworkWirelessSSID(networkID, number) + r, resp, e := s.dashboard.Wireless.GetNetworkWirelessSSID(networkID, number) + ssid, fetchErr = r, e + if e == nil && resp != nil { + rawPSK = pskFromBody(resp.Body()) + } } if fetchErr == nil { - break + if ssid == nil { + return nil, fmt.Errorf("SSID not found: %s:%s", networkID, number) + } + return convertMerakiSSID(ssid, networkID, s.orgID, rawPSK), nil } if !retryState.ShouldRetry(fetchErr) { - return nil, fmt.Errorf("failed to get SSID %s: %w", id, fetchErr) + return nil, fmt.Errorf("failed to get SSID %s:%s: %w", networkID, number, fetchErr) } if waitErr := retryState.WaitBeforeRetry(ctx, nil); waitErr != nil { return nil, fmt.Errorf("retry wait failed: %w", waitErr) } } +} - if ssid == nil { - return nil, fmt.Errorf("SSID not found: %s", id) +// pskFromBody extracts the psk from a raw SSID GET body. The dashboard returns +// it only for psk auth mode; anything else yields "". +func pskFromBody(body []byte) string { + var parsed struct { + Psk string `json:"psk"` } - - return convertMerakiSSID(ssid, networkID, s.orgID), nil + if err := json.Unmarshal(body, &parsed); err != nil { + return "" + } + return parsed.Psk } // BySSID finds SSIDs by their SSID name across all networks. @@ -561,10 +586,20 @@ func (s *wlansService) getSSIDsForNetwork(ctx context.Context, networkID, networ for _, ssid := range *ssids { // Only include enabled SSIDs with a configured name // (Meraki has 15 SSID slots, most are unconfigured) - if ssid.Enabled != nil && *ssid.Enabled && ssid.Name != "" { - wlan := convertMerakiSSIDItem(&ssid, networkID, networkName, s.orgID) - result = append(result, wlan) + if ssid.Enabled == nil || !*ssid.Enabled || ssid.Name == "" || ssid.Number == nil { + continue } + // The list omits the PSK, so fetch each SSID individually to capture it. + // On GET failure, fall back to the list item so the SSID still caches + // (without its passphrase). + wlan, err := s.fetchSSID(ctx, networkID, strconv.Itoa(*ssid.Number)) + if err != nil { + logging.Warnf("[meraki] Failed to get SSID %s:%d, caching without PSK: %v", networkID, *ssid.Number, err) + wlan = convertMerakiSSIDItem(&ssid, networkID, networkName, s.orgID) + } else if wlan.Config != nil && networkName != "" { + wlan.Config["networkName"] = networkName + } + result = append(result, wlan) } return result, nil @@ -619,7 +654,9 @@ func convertMerakiSSIDItem(ssid *meraki.ResponseItemWirelessGetNetworkWirelessSS } // convertMerakiSSID converts a single Meraki SSID to vendor-agnostic WLAN type. -func convertMerakiSSID(ssid *meraki.ResponseWirelessGetNetworkWirelessSSID, networkID, orgID string) *vendors.WLAN { +// rawPSK is the passphrase parsed from the raw GET body: the SDK's typed +// response drops the psk field, so callers extract it with pskFromBody. +func convertMerakiSSID(ssid *meraki.ResponseWirelessGetNetworkWirelessSSID, networkID, orgID, rawPSK string) *vendors.WLAN { wlan := &vendors.WLAN{ OrgID: orgID, SiteID: networkID, @@ -644,6 +681,11 @@ func convertMerakiSSID(ssid *meraki.ResponseWirelessGetNetworkWirelessSSID, netw wlan.EncryptionMode = ssid.EncryptionMode wlan.Band = ssid.BandSelection + // PSK is only present on the per-SSID GET (the list endpoint omits it) and + // only for psk auth mode. It rides the typed field, where the refresh layer + // encrypts it before caching; the config map below stays scrubbed. + wlan.PSK = rawPSK + // Convert RADIUS servers if ssid.RadiusServers != nil { for _, rs := range *ssid.RadiusServers { @@ -657,8 +699,10 @@ func convertMerakiSSID(ssid *meraki.ResponseWirelessGetNetworkWirelessSSID, netw } } - // Store full config + // Store full config, then strip the secret so it never persists in the + // clear; the encrypted copy lives in wlan.PSK. wlan.Config = ssidToMap(ssid) + delete(wlan.Config, "psk") return wlan } diff --git a/internal/vendors/mist/wlans.go b/internal/vendors/mist/wlans.go index 345bf9f..9ed9e3e 100644 --- a/internal/vendors/mist/wlans.go +++ b/internal/vendors/mist/wlans.go @@ -317,7 +317,11 @@ func convertMistWLAN(w *api.MistWLAN, orgID string) *vendors.WLAN { if w.Auth.Type != nil { wlan.AuthType = *w.Auth.Type } - // Don't copy PSK to cache for security - will be populated from user config when needed + // Secrets ride the typed fields, where the refresh layer encrypts them + // before they reach the cache. The raw config map below stays scrubbed. + if w.Auth.PSK != nil { + wlan.PSK = *w.Auth.PSK + } // RADIUS servers for enterprise auth if w.Auth.Enterprise != nil && w.Auth.Enterprise.Radius != nil { @@ -328,13 +332,16 @@ func convertMistWLAN(w *api.MistWLAN, orgID string) *vendors.WLAN { if w.Auth.Enterprise.Radius.Port != nil { server.Port = *w.Auth.Enterprise.Radius.Port } - // Don't copy RADIUS secret to cache for security + if w.Auth.Enterprise.Radius.Secret != nil { + server.Secret = *w.Auth.Enterprise.Radius.Secret + } wlan.RadiusServers = []vendors.RadiusServer{server} } - // Store full config map for round-trip accuracy using JSON marshaling + // Store full config map for round-trip accuracy using JSON marshaling. + // Strip secrets from the map: they live encrypted in the typed fields, and + // the map is persisted verbatim. wlan.Config = mistWLANToMap(w) - // Remove sensitive data from cached config if auth, ok := wlan.Config["auth"].(map[string]interface{}); ok { delete(auth, "psk") if enterprise, ok := auth["enterprise"].(map[string]interface{}); ok { diff --git a/internal/vendors/wlan_secrets_test.go b/internal/vendors/wlan_secrets_test.go new file mode 100644 index 0000000..f2e4f59 --- /dev/null +++ b/internal/vendors/wlan_secrets_test.go @@ -0,0 +1,62 @@ +package vendors + +import ( + "testing" + + "github.com/ravinald/wifimgr/internal/encryption" +) + +func TestWLANHasPlaintextSecret(t *testing.T) { + enc, err := encryption.Encrypt("p", "pw") + if err != nil { + t.Fatalf("encrypt: %v", err) + } + cases := []struct { + name string + w *WLAN + want bool + }{ + {"no secrets", &WLAN{SSID: "open"}, false}, + {"plaintext psk", &WLAN{PSK: "p"}, true}, + {"encrypted psk", &WLAN{PSK: enc}, false}, + {"plaintext radius", &WLAN{RadiusServers: []RadiusServer{{Host: "h", Secret: "s"}}}, true}, + {"encrypted radius", &WLAN{RadiusServers: []RadiusServer{{Host: "h", Secret: enc}}}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := wlanHasPlaintextSecret(tc.w); got != tc.want { + t.Errorf("wlanHasPlaintextSecret = %v, want %v", got, tc.want) + } + }) + } +} + +func TestEncryptWLANSecrets(t *testing.T) { + const pw = "battery-horse" + w := &WLAN{ + PSK: "top-secret", + RadiusServers: []RadiusServer{{Host: "h", Secret: "radius-secret"}}, + } + if err := encryptWLANSecrets(w, pw); err != nil { + t.Fatalf("encryptWLANSecrets: %v", err) + } + + if !encryption.IsEncrypted(w.PSK) { + t.Fatalf("PSK not encrypted: %q", w.PSK) + } + if got, _ := encryption.Decrypt(w.PSK, pw); got != "top-secret" { + t.Errorf("PSK round-trip = %q, want %q", got, "top-secret") + } + if !encryption.IsEncrypted(w.RadiusServers[0].Secret) { + t.Fatalf("RADIUS secret not encrypted: %q", w.RadiusServers[0].Secret) + } + + // Re-encrypting is a no-op: already-encrypted values pass through unchanged. + prev := w.PSK + if err := encryptWLANSecrets(w, pw); err != nil { + t.Fatalf("second encryptWLANSecrets: %v", err) + } + if w.PSK != prev { + t.Errorf("re-encrypt changed already-encrypted PSK") + } +}