Files
lemana-vpn/docs/superpowers/plans/2026-05-24-keychain-totp-provider.md

936 lines
24 KiB
Markdown

# Keychain TOTP Provider Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a free, non-Bitwarden credential path where the user stores LDAP password plus a permanent TOTP seed in macOS Keychain, and `openconnect-lite` keeps generating one-time login codes from that seed.
**Architecture:** Introduce an explicit credential-source layer with two first-class sources: `bitwarden` and `keychain`. `bitwarden` keeps the existing vault-sync behavior and writes LDAP password plus TOTP seed into the existing `openconnect-lite` Keychain entries; `keychain` skips vault sync and treats those Keychain entries as the source of truth. Both sources converge before the current `openconnect-lite` SSO/autofill flow, so runtime patches and Keycloak selectors stay unchanged.
**Tech Stack:** Bash (`bin/vpn-lemanapro.sh`), POSIX shell installer (`install.sh`), macOS Keychain via Python `keyring` inside the `openconnect-lite` venv, Swift menu-bar app JSON decoding, `tests/smoke.sh`.
---
## File Structure
- Modify `bin/vpn-lemanapro.sh`
- Own runtime credential-source selection.
- Normalize raw Base32 and `otpauth://` TOTP inputs.
- Split source orchestration from source-specific sync.
- Keep the existing Keychain service/accounts: `openconnect-lite` / `<username>` and `openconnect-lite` / `totp/<username>`.
- Modify `install.sh`
- Add `LEMANA_VPN_CREDENTIAL_SOURCE`.
- Keep `--with-bitwarden` and `--without-bitwarden` as backward-compatible aliases.
- Add a direct `--credential-source bitwarden|keychain` flag.
- Disable Touch ID automatically for the `keychain` source because Touch ID currently gates Bitwarden master-password retrieval only.
- Modify `tests/smoke.sh`
- Assert installer and runtime source separation.
- Assert `otpauth://` input is normalized before storing.
- Assert no-tty app path still fails with the terminal recovery command when Keychain credentials are missing.
- Modify `app/Sources/LemanaVPN/VPNManager.swift`
- Decode the new `credentials` module object.
- Keep compatibility with older CLI JSON by making the new object optional.
- Log source-specific credential events without changing connection state semantics.
- Modify `README.md`
- Document the two credential sources and the free Keychain/TOTP-seed setup.
- Keep update/run/check instructions aligned with `AGENTS.md`.
---
### Task 1: Add Smoke Tests For Credential Source Separation
**Files:**
- Modify: `tests/smoke.sh`
- [ ] **Step 1: Write the failing tests**
Edit `tests/smoke.sh` so the initial dry-run expectations and missing-credential check cover the new `keychain` source. Replace:
```sh
printf '%s\n' "$output" | grep -q 'Modules: bitwarden=0 touchid=0 sudoers=1 shell=1 app=1 autostart=1'
```
with:
```sh
printf '%s\n' "$output" | grep -q 'Modules: credential_source=keychain bitwarden=0 touchid=0 sudoers=1 shell=1 app=1 autostart=1'
```
After the existing status JSON assertions:
```sh
status_json="$(bash "$ROOT/bin/vpn-lemanapro.sh" --status --json)"
printf '%s\n' "$status_json" | grep -q '"modules":'
printf '%s\n' "$status_json" | grep -q '"app":'
```
insert:
```sh
keychain_status_json="$(LEMANA_VPN_CREDENTIAL_SOURCE=keychain bash "$ROOT/bin/vpn-lemanapro.sh" --status --json)"
printf '%s\n' "$keychain_status_json" | grep -q '"credentials":{"source":"keychain","keychain_ready":false}'
printf '%s\n' "$keychain_status_json" | grep -q '"bitwarden":{"enabled":false'
bitwarden_status_json="$(LEMANA_VPN_CREDENTIAL_SOURCE=bitwarden bash "$ROOT/bin/vpn-lemanapro.sh" --status --json)"
printf '%s\n' "$bitwarden_status_json" | grep -q '"credentials":{"source":"bitwarden","keychain_ready":false}'
printf '%s\n' "$bitwarden_status_json" | grep -q '"bitwarden":{"enabled":true'
```
Replace the missing-credentials environment:
```sh
LEMANA_VPN_USE_BITWARDEN=0 \
```
with:
```sh
LEMANA_VPN_CREDENTIAL_SOURCE=keychain \
```
After the missing-credentials block, add an `otpauth://` normalization check:
```sh
fake_oc_python="$TMP_DIR/fake-oc-python"
captured_totp="$TMP_DIR/captured-totp"
cat > "$fake_oc_python" <<'SH'
#!/bin/sh
cat >/dev/null
printf '%s\n' "${_VPN_TOTP_SECRET:-}" > "$LEMANA_VPN_CAPTURE_TOTP"
SH
chmod +x "$fake_oc_python"
configure_output="$(
printf 'ldap-password\notpauth://totp/Lemana:test?secret=abcd2345efgh6723&issuer=Lemana\n' |
HOME="$HOME" \
LEMANA_VPN_USERNAME="lemana-configure-$$" \
LEMANA_VPN_CREDENTIAL_SOURCE=keychain \
LEMANA_VPN_OC_PYTHON="$fake_oc_python" \
LEMANA_VPN_CAPTURE_TOTP="$captured_totp" \
bash "$ROOT/bin/vpn-lemanapro.sh" --configure-keychain
)"
printf '%s\n' "$configure_output" | grep -q 'Credentials are ready in macOS Keychain'
grep -q '^ABCD2345EFGH6723$' "$captured_totp"
```
- [ ] **Step 2: Run the test to verify it fails**
Run:
```bash
tests/smoke.sh
```
Expected: FAIL because current installer output does not print `credential_source=keychain`, current status JSON has no `"credentials"` object, and `--configure-keychain` stores the raw `otpauth://` URI instead of the extracted Base32 secret.
- [ ] **Step 3: Commit the failing tests**
```bash
git add tests/smoke.sh
git commit -m "test: cover keychain credential source"
```
---
### Task 2: Add Runtime Credential Source And TOTP Normalization
**Files:**
- Modify: `bin/vpn-lemanapro.sh`
- Test: `tests/smoke.sh`
- [ ] **Step 1: Capture and restore the new environment variable**
In `bin/vpn-lemanapro.sh`, add this line after `_ENV_LEMANA_VPN_USE_BITWARDEN=...`:
```bash
_ENV_LEMANA_VPN_CREDENTIAL_SOURCE="${LEMANA_VPN_CREDENTIAL_SOURCE+x}${LEMANA_VPN_CREDENTIAL_SOURCE-}"
```
Add this line after the existing `LEMANA_VPN_USE_BITWARDEN` restore line:
```bash
[[ "${_ENV_LEMANA_VPN_CREDENTIAL_SOURCE:0:1}" == "x" ]] && LEMANA_VPN_CREDENTIAL_SOURCE="${_ENV_LEMANA_VPN_CREDENTIAL_SOURCE:1}"
```
- [ ] **Step 2: Replace implicit Bitwarden mode with explicit credential source**
Replace:
```bash
USE_BITWARDEN="${LEMANA_VPN_USE_BITWARDEN:-1}"
```
with:
```bash
CREDENTIAL_SOURCE="${LEMANA_VPN_CREDENTIAL_SOURCE:-}"
if [[ -z "$CREDENTIAL_SOURCE" ]]; then
if [[ "${LEMANA_VPN_USE_BITWARDEN:-1}" == "1" ]]; then
CREDENTIAL_SOURCE="bitwarden"
else
CREDENTIAL_SOURCE="keychain"
fi
fi
case "$CREDENTIAL_SOURCE" in
bitwarden|keychain) ;;
*)
printf 'Unknown credential source: %s. Use bitwarden or keychain.\n' "$CREDENTIAL_SOURCE" >&2
exit 2
;;
esac
if [[ "$CREDENTIAL_SOURCE" == "bitwarden" ]]; then
USE_BITWARDEN="1"
else
USE_BITWARDEN="0"
fi
```
- [ ] **Step 3: Add a TOTP normalizer**
Insert this function immediately before `_store_keychain()`:
```bash
_normalize_totp_secret() {
_VPN_TOTP_INPUT="$1" python3 - <<'PY'
import os
import re
import sys
import urllib.parse
value = os.environ.get("_VPN_TOTP_INPUT", "").strip()
if value.lower().startswith("otpauth://"):
parsed = urllib.parse.urlparse(value)
query = urllib.parse.parse_qs(parsed.query)
value = query.get("secret", [""])[0]
value = re.sub(r"[\s-]+", "", value).upper()
if not value:
print("", end="")
sys.exit(0)
if not re.fullmatch(r"[A-Z2-7]+=*", value):
print("Invalid TOTP seed. Use a BASE32 secret or an otpauth:// URI with secret=BASE32.", file=sys.stderr)
sys.exit(1)
print(value)
PY
}
```
- [ ] **Step 4: Normalize manual setup before storing**
In `_configure_keychain()`, replace:
```bash
_store_keychain "$password" "$totp_secret"
```
with:
```bash
if [[ -n "$totp_secret" ]]; then
totp_secret="$(_normalize_totp_secret "$totp_secret")"
fi
_store_keychain "$password" "$totp_secret"
```
- [ ] **Step 5: Normalize Bitwarden TOTP before storing**
In `_sync_bitwarden()`, replace:
```bash
_store_keychain "$bw_password" "$bw_totp_secret"
```
with:
```bash
if [[ -n "$bw_totp_secret" ]]; then
bw_totp_secret="$(_normalize_totp_secret "$bw_totp_secret")"
fi
_store_keychain "$bw_password" "$bw_totp_secret"
```
- [ ] **Step 6: Update status JSON**
In `_module_status_json()`, add this local variable line after the existing `keychain_*` locals:
```bash
local credential_keychain_ready
```
After `keychain_totp_seed=...`, add:
```bash
credential_keychain_ready="$([[ "$keychain_password" == "true" && "$keychain_totp_seed" == "true" ]] && printf true || printf false)"
```
Replace the `printf` format string:
```bash
printf '{"core":{"openconnect":%s,"openconnect_lite":%s,"config":%s,"openconnect_lite_config":%s},"bitwarden":{"enabled":%s,"installed":%s,"item":"%s"},"touchid":{"enabled":%s,"installed":%s},"keychain":{"password":%s,"totp_seed":%s},"dns_cleanup":{"installed":%s},"patches":{"active":%s,"backup":%s},"app":{"installed":%s,"autostart":%s}}' \
```
with:
```bash
printf '{"core":{"openconnect":%s,"openconnect_lite":%s,"config":%s,"openconnect_lite_config":%s},"credentials":{"source":"%s","keychain_ready":%s},"bitwarden":{"enabled":%s,"installed":%s,"item":"%s"},"touchid":{"enabled":%s,"installed":%s},"keychain":{"password":%s,"totp_seed":%s},"dns_cleanup":{"installed":%s},"patches":{"active":%s,"backup":%s},"app":{"installed":%s,"autostart":%s}}' \
```
Add these arguments immediately after `"$oc_config_present" \`:
```bash
"$CREDENTIAL_SOURCE" \
"$credential_keychain_ready" \
```
- [ ] **Step 7: Update human status**
In `_module_status_human()`, change the prefix:
```bash
printf 'Modules: %s %s, ' "$([[ "$core" == "core=ok" ]] && printf '✅' || printf '⚠️')" "$core"
```
to:
```bash
printf 'Modules: %s %s, 🔐 credential_source=%s, ' "$([[ "$core" == "core=ok" ]] && printf '✅' || printf '⚠️')" "$core" "$CREDENTIAL_SOURCE"
```
- [ ] **Step 8: Run smoke test**
Run:
```bash
tests/smoke.sh
```
Expected: still FAIL because source orchestration and installer output are not separated yet, but the `otpauth://` normalization assertion now passes.
- [ ] **Step 9: Commit runtime model**
```bash
git add bin/vpn-lemanapro.sh tests/smoke.sh
git commit -m "feat: add credential source runtime model"
```
---
### Task 3: Split Bitwarden Sync From Keychain Source Flow
**Files:**
- Modify: `bin/vpn-lemanapro.sh`
- Test: `tests/smoke.sh`
- [ ] **Step 1: Replace `_ensure_keychain_credentials()` messages**
Inside `_ensure_keychain_credentials()`, replace the ready branch:
```bash
if [[ "$password_present" == "true" && "$totp_present" == "true" ]]; then
if [[ "$USE_BITWARDEN" == "1" ]]; then
_emit '{"event":"keychain_ready","source":"keychain"}' "LDAP credentials are ready in macOS Keychain for $KC_USERNAME."
else
_emit '{"event":"keychain_ready","source":"keychain","bitwarden":false}' "Bitwarden is disabled. Using saved LDAP password and TOTP seed from macOS Keychain for $KC_USERNAME."
fi
return 0
fi
```
with:
```bash
if [[ "$password_present" == "true" && "$totp_present" == "true" ]]; then
if [[ "$CREDENTIAL_SOURCE" == "bitwarden" ]]; then
_emit '{"event":"keychain_ready","source":"bitwarden"}' "Bitwarden source synced LDAP credentials into macOS Keychain for $KC_USERNAME."
else
_emit '{"event":"keychain_ready","source":"keychain"}' "Keychain source is ready: saved LDAP password and TOTP seed are available for $KC_USERNAME."
fi
return 0
fi
```
Replace the required branch:
```bash
if [[ "$USE_BITWARDEN" == "1" ]]; then
_emit '{"event":"keychain_required","bitwarden":true}' "Bitwarden sync did not produce complete Keychain credentials."
else
_emit '{"event":"keychain_required","bitwarden":false}' "Bitwarden is disabled and saved LDAP credentials are incomplete."
fi
```
with:
```bash
if [[ "$CREDENTIAL_SOURCE" == "bitwarden" ]]; then
_emit '{"event":"keychain_required","source":"bitwarden"}' "Bitwarden source did not produce complete Keychain credentials."
else
_emit '{"event":"keychain_required","source":"keychain"}' "Keychain source is selected and saved LDAP credentials are incomplete."
fi
```
- [ ] **Step 2: Add source orchestration**
Insert this function after `_sync_bitwarden()`:
```bash
_sync_credentials() {
case "$CREDENTIAL_SOURCE" in
bitwarden)
_sync_bitwarden
;;
keychain)
_emit '{"event":"credential_source","source":"keychain"}' "Credential source: macOS Keychain"
;;
esac
}
```
- [ ] **Step 3: Use the source orchestrator**
Near the bottom of the script, replace:
```bash
_sync_bitwarden
_ensure_keychain_credentials
```
with:
```bash
_sync_credentials
_ensure_keychain_credentials
```
- [ ] **Step 4: Update `--help` text**
Replace the usage line:
```text
Usage: vpn-lemanapro.sh [--auto|--manual] [--debug] [--json] [--status] [--configure-keychain] [--patch-only]
```
with:
```text
Usage: vpn-lemanapro.sh [--auto|--manual] [--debug] [--json] [--status] [--configure-keychain] [--patch-only]
```
Keep the usage line unchanged, and replace the configure text:
```text
--configure-keychain Prompt for LDAP password and TOTP secret, then save them to Keychain
```
with:
```text
--configure-keychain Configure the keychain credential source: LDAP password plus permanent TOTP seed or otpauth:// URI
```
- [ ] **Step 5: Run smoke test**
Run:
```bash
tests/smoke.sh
```
Expected: FAIL only on installer output and Swift/README checks that have not been updated yet. Runtime JSON checks for `LEMANA_VPN_CREDENTIAL_SOURCE=keychain` and `bitwarden` pass.
- [ ] **Step 6: Commit provider split**
```bash
git add bin/vpn-lemanapro.sh tests/smoke.sh
git commit -m "feat: split credential sources"
```
---
### Task 4: Update Installer Flags And Config
**Files:**
- Modify: `install.sh`
- Test: `tests/smoke.sh`
- [ ] **Step 1: Add installer state**
In `install.sh`, add this variable after `BW_ITEM=...`:
```sh
CREDENTIAL_SOURCE="${LEMANA_VPN_CREDENTIAL_SOURCE:-bitwarden}"
```
Add this variable after `BITWARDEN_FORCED=0`:
```sh
CREDENTIAL_SOURCE_FORCED=0
```
- [ ] **Step 2: Extend usage**
Add this option after `--without-bitwarden`:
```text
--credential-source VALUE Credential source: bitwarden or keychain
```
Add this example after `sh install.sh --minimal --configure-keychain`:
```text
sh install.sh --credential-source keychain --configure-keychain
```
- [ ] **Step 3: Parse `--credential-source`**
Add this case arm before `--with-touchid)`:
```sh
--credential-source)
shift
[ "$#" -gt 0 ] || { echo "--credential-source requires bitwarden or keychain" >&2; exit 1; }
CREDENTIAL_SOURCE="$1"
CREDENTIAL_SOURCE_FORCED=1
case "$CREDENTIAL_SOURCE" in
bitwarden)
USE_BITWARDEN=1
;;
keychain)
USE_BITWARDEN=0
USE_TOUCHID=0
;;
*)
echo "--credential-source requires bitwarden or keychain" >&2
exit 1
;;
esac
;;
```
In the `--with-bitwarden)` arm, add:
```sh
CREDENTIAL_SOURCE="bitwarden"
```
In the `--without-bitwarden)` arm, add:
```sh
CREDENTIAL_SOURCE="keychain"
USE_TOUCHID=0
```
In the `--minimal)` arm, add:
```sh
CREDENTIAL_SOURCE="keychain"
```
- [ ] **Step 4: Keep module decisions consistent**
At the start of `choose_modules()`, immediately after `print_detected_state`, add:
```sh
case "$CREDENTIAL_SOURCE" in
bitwarden)
USE_BITWARDEN=1
;;
keychain)
USE_BITWARDEN=0
USE_TOUCHID=0
;;
*)
die "Unknown credential source: $CREDENTIAL_SOURCE"
;;
esac
```
Replace:
```sh
if [ "$BITWARDEN_FORCED" -eq 0 ] && ! command -v bw >/dev/null 2>&1; then
```
with:
```sh
if [ "$CREDENTIAL_SOURCE" = "bitwarden" ] && [ "$BITWARDEN_FORCED" -eq 0 ] && ! command -v bw >/dev/null 2>&1; then
```
Replace:
```sh
if [ "$CONFIGURE_KEYCHAIN_FORCED" -eq 0 ] && [ "$USE_BITWARDEN" -eq 0 ]; then
```
with:
```sh
if [ "$CONFIGURE_KEYCHAIN_FORCED" -eq 0 ] && [ "$CREDENTIAL_SOURCE" = "keychain" ]; then
```
- [ ] **Step 5: Write source to config**
In `install_config()`, change `env_content` to:
```sh
env_content="LEMANA_VPN_USERNAME=\"$USERNAME\"
LEMANA_VPN_CREDENTIAL_SOURCE=\"$CREDENTIAL_SOURCE\"
LEMANA_VPN_BW_ITEM=\"$BW_ITEM\"
LEMANA_VPN_USE_BITWARDEN=\"$USE_BITWARDEN\"
LEMANA_VPN_USE_TOUCHID=\"$USE_TOUCHID\"
LEMANA_VPN_DNS_CLEANUP=\"$DNS_CLEANUP\""
```
- [ ] **Step 6: Update installer logging**
Replace:
```sh
log_info "Modules: bitwarden=$USE_BITWARDEN touchid=$USE_TOUCHID sudoers=$INSTALL_SUDOERS shell=$INSTALL_ALIASES app=$INSTALL_APP autostart=$INSTALL_AUTOSTART"
```
with:
```sh
log_info "Modules: credential_source=$CREDENTIAL_SOURCE bitwarden=$USE_BITWARDEN touchid=$USE_TOUCHID sudoers=$INSTALL_SUDOERS shell=$INSTALL_ALIASES app=$INSTALL_APP autostart=$INSTALL_AUTOSTART"
```
In `maybe_login_bitwarden()`, replace:
```sh
if [ "$USE_BITWARDEN" -ne 1 ]; then
log_skip "Bitwarden module disabled; credentials будут браться из macOS Keychain."
```
with:
```sh
if [ "$CREDENTIAL_SOURCE" != "bitwarden" ]; then
log_skip "Credential source is keychain; пропускаю Bitwarden login."
```
- [ ] **Step 7: Run smoke test**
Run:
```bash
tests/smoke.sh
```
Expected: PASS for installer/runtime shell smoke assertions.
- [ ] **Step 8: Commit installer changes**
```bash
git add install.sh tests/smoke.sh
git commit -m "feat: configure credential source during install"
```
---
### Task 5: Update Swift Menu-Bar Status Decoding
**Files:**
- Modify: `app/Sources/LemanaVPN/VPNManager.swift`
- Test: `tests/smoke.sh`
- [ ] **Step 1: Add optional credentials decoding**
Inside `ModuleStatus`, add this struct after `ToggleModule`:
```swift
struct Credentials: Decodable {
var source: String
var keychain_ready: Bool
}
```
Add this property before `var bitwarden: ToggleModule`:
```swift
var credentials: Credentials?
```
- [ ] **Step 2: Reflect source in the module summary**
In `var summary: String`, add this line after `let coreState = ...`:
```swift
let credentialState = credentials.map { "🔐 \($0.source)" } ?? "🔐 legacy"
```
Replace the return line:
```swift
return [coreState, bwState, touchState, dnsState, appState, autostartState, patchState, keychainState].joined(separator: " | ")
```
with:
```swift
return [coreState, credentialState, bwState, touchState, dnsState, appState, autostartState, patchState, keychainState].joined(separator: " | ")
```
- [ ] **Step 3: Log keychain source event**
In `handleEvent(_:)`, add this switch case before `case "bw_cached":`:
```swift
case "credential_source":
if let message = event.message { log(" \(message)") }
return
```
- [ ] **Step 4: Add a smoke assertion for Swift source display**
In `tests/smoke.sh`, after:
```sh
grep -q 'enum VPNLaunchMode' "$ROOT/app/Sources/LemanaVPN/VPNManager.swift"
```
add:
```sh
grep -q 'struct Credentials: Decodable' "$ROOT/app/Sources/LemanaVPN/VPNManager.swift"
grep -q 'credential_source' "$ROOT/app/Sources/LemanaVPN/VPNManager.swift"
```
- [ ] **Step 5: Build the app**
Run:
```bash
swift build -c release --package-path app
```
Expected: PASS.
- [ ] **Step 6: Run smoke test**
Run:
```bash
tests/smoke.sh
```
Expected: PASS.
- [ ] **Step 7: Commit Swift changes**
```bash
git add app/Sources/LemanaVPN/VPNManager.swift tests/smoke.sh
git commit -m "feat: show credential source in menu app"
```
---
### Task 6: Update Documentation
**Files:**
- Modify: `README.md`
- [ ] **Step 1: Update module summary text**
In `README.md`, replace the current Bitwarden-only module description near the top with:
```markdown
**Credential sources:** `bitwarden` syncs LDAP password and TOTP seed from Bitwarden into macOS Keychain; `keychain` stores LDAP password and a permanent TOTP seed directly in macOS Keychain. Both sources use the same `openconnect-lite` SSO/autofill runtime.
```
- [ ] **Step 2: Add keychain source setup commands**
In the “Если Bitwarden нет” section, replace the install command block with:
```sh
curl -fsSL https://git.dokops.ru/dokril/lemana-vpn/raw/branch/main/install.sh \
| sh -s -- --credential-source keychain --configure-keychain
```
Add this paragraph after the command:
```markdown
`--credential-source keychain` is the free built-in path: no Bitwarden account, paid Bitwarden TOTP, or external password manager is required. The setup prompt asks for the corporate LDAP password and a permanent TOTP seed. The seed may be pasted as raw Base32 or as an `otpauth://totp/...?...secret=BASE32` URI.
```
- [ ] **Step 3: Update config example**
In the `~/.config/lemana-vpn/env` example, add:
```sh
LEMANA_VPN_CREDENTIAL_SOURCE="bitwarden"
```
For the keychain source, add this separate example:
```sh
LEMANA_VPN_USERNAME="60103293"
LEMANA_VPN_CREDENTIAL_SOURCE="keychain"
LEMANA_VPN_USE_BITWARDEN="0"
LEMANA_VPN_USE_TOUCHID="0"
LEMANA_VPN_DNS_CLEANUP="/usr/local/sbin/lemana-vpn-dns-cleanup"
```
- [ ] **Step 4: Document current-code rejection**
Add this warning in the TOTP explanation:
```markdown
Do not paste the current 6-digit authenticator code into `vpn --configure-keychain`. Lemana VPN stores the permanent TOTP seed in Keychain, and `openconnect-lite` uses that seed to generate fresh one-time codes during each SSO login.
```
- [ ] **Step 5: Commit docs**
```bash
git add README.md
git commit -m "docs: document keychain credential source"
```
---
### Task 7: Full Verification
**Files:**
- Verify: `install.sh`
- Verify: `uninstall.sh`
- Verify: `bin/vpn-lemanapro.sh`
- Verify: `tests/smoke.sh`
- Verify: `app/Sources/LemanaVPN/VPNManager.swift`
- Verify: `README.md`
- [ ] **Step 1: Run shell syntax checks**
Run:
```bash
sh -n install.sh
sh -n uninstall.sh
bash -n bin/vpn-lemanapro.sh
sh -n tests/smoke.sh
```
Expected: all commands exit 0.
- [ ] **Step 2: Run smoke tests**
Run:
```bash
tests/smoke.sh
```
Expected: prints `smoke ok`.
- [ ] **Step 3: Build Swift menu-bar app**
Run:
```bash
swift build -c release --package-path app
```
Expected: build exits 0.
- [ ] **Step 4: Check source status JSON**
Run:
```bash
LEMANA_VPN_CREDENTIAL_SOURCE=keychain bin/vpn-lemanapro.sh --status --json
```
Expected output contains:
```json
"credentials":{"source":"keychain"
```
Run:
```bash
LEMANA_VPN_CREDENTIAL_SOURCE=bitwarden bin/vpn-lemanapro.sh --status --json
```
Expected output contains:
```json
"credentials":{"source":"bitwarden"
```
- [ ] **Step 5: Check installer dry-runs**
Run:
```bash
sh install.sh --dry-run --non-interactive --credential-source keychain --configure-keychain --without-app
```
Expected output contains:
```text
Modules: credential_source=keychain bitwarden=0 touchid=0
```
Run:
```bash
sh install.sh --dry-run --non-interactive --credential-source bitwarden --without-app
```
Expected output contains:
```text
Modules: credential_source=bitwarden bitwarden=1
```
- [ ] **Step 6: Check whitespace**
Run:
```bash
git diff --check
```
Expected: no output.
- [ ] **Step 7: Live VPN decision**
Run:
```bash
vpn --status
vpn --status --json
```
If the installed status is connected, connecting, or reconnecting, do not start another VPN session. Record in the final report that live reconnect was intentionally skipped. If the installed status is disconnected and live validation is approved in the execution session, run:
```bash
vpn --manual
```
Expected: visible browser opens, LDAP password and generated TOTP are filled from Keychain, and submit is not pressed.
- [ ] **Step 8: Final commit**
```bash
git status --short
git add bin/vpn-lemanapro.sh install.sh tests/smoke.sh app/Sources/LemanaVPN/VPNManager.swift README.md
git commit -m "feat: add keychain credential source"
```
---
## Self-Review
- Spec coverage: The plan covers a free Keychain/TOTP-seed alternative, separates Bitwarden from Keychain source logic, preserves the existing `openconnect-lite` Keychain boundary, updates installer configuration, updates menu-bar status decoding, documents run/check/update flows, and includes non-live plus live-safe verification.
- Placeholder scan: The plan contains concrete file paths, exact commands, exact code snippets, and explicit expected outputs.
- Type consistency: The runtime source name is consistently `CREDENTIAL_SOURCE` in shell, `LEMANA_VPN_CREDENTIAL_SOURCE` in config/env, `"credentials":{"source":...}` in JSON, and `ModuleStatus.Credentials.source` in Swift.