From 5f45253b7c4839191b12f3d8cc20951107eb6cf3 Mon Sep 17 00:00:00 2001 From: adeshmukh-ks Date: Thu, 30 Apr 2026 15:34:29 +0530 Subject: [PATCH] AWS lambda list record sample script and readme --- .../list_records_lambda_function/.gitignore | 7 + .../list_records_lambda_function/README.md | 189 ++++++++++ .../lambda_function.py | 329 ++++++++++++++++++ .../requirements.txt | 2 + 4 files changed, 527 insertions(+) create mode 100644 examples/sdk_examples/aws_cloud_sample/list_records_lambda_function/.gitignore create mode 100644 examples/sdk_examples/aws_cloud_sample/list_records_lambda_function/README.md create mode 100644 examples/sdk_examples/aws_cloud_sample/list_records_lambda_function/lambda_function.py create mode 100644 examples/sdk_examples/aws_cloud_sample/list_records_lambda_function/requirements.txt diff --git a/examples/sdk_examples/aws_cloud_sample/list_records_lambda_function/.gitignore b/examples/sdk_examples/aws_cloud_sample/list_records_lambda_function/.gitignore new file mode 100644 index 00000000..3ad496e4 --- /dev/null +++ b/examples/sdk_examples/aws_cloud_sample/list_records_lambda_function/.gitignore @@ -0,0 +1,7 @@ +# Dependencies are produced with: pip install -r requirements.txt -t . +# Track only hand-edited example files; do not commit the install tree. +/* +!/.gitignore +!/lambda_function.py +!/requirements.txt +!/README.md diff --git a/examples/sdk_examples/aws_cloud_sample/list_records_lambda_function/README.md b/examples/sdk_examples/aws_cloud_sample/list_records_lambda_function/README.md new file mode 100644 index 00000000..c7f9626a --- /dev/null +++ b/examples/sdk_examples/aws_cloud_sample/list_records_lambda_function/README.md @@ -0,0 +1,189 @@ +# Keeper Records Lambda (Config + Password Fallback) + +This Lambda function logs in to Keeper and lists user's vault records. + +It is designed to: +- use `config.json` from Keeper first (stored in AWS Secrets Manager as `keeper-secret-1`) +- automatically fall back to a password secret (`keeper-secret-2`) when config-based login is not valid for that run + +This makes the Lambda non-interactive and suitable for AWS execution. + +## What is in GitHub, and what you must build + +This repository (and the `keeper-sdk-python` project on GitHub) includes **source only**: `lambda_function.py`, `requirements.txt`, and this guide. **Third-party dependencies are not checked in**—on purpose, so the repo stays small and maintainable (a `.gitignore` in this folder is meant to keep the `pip install -t` output from being re-committed). **The Lambda will not work** until you install those dependencies into the same directory as the handler, then **zip** that full tree, then **upload** the zip to AWS Lambda. See [Build the deployment package](#build-the-deployment-package) below for the exact commands. + +## What This Function Does + +1. Reads Keeper config from AWS Secrets Manager (`keeper-secret-1`) +2. Attempts non-interactive login using persisted config/session data +3. If Keeper requests password (`LoginStepPassword`), reads password from `keeper-secret-2` +4. Completes login, syncs the vault, and returns records + +## Prerequisites + +- AWS account with permissions to create and run Lambda + Secrets Manager secrets +- Keeper account credentials +- A machine with Python **3.11+** and `pip` (or a CI image) to run `pip install` for the deployment package +- Local machine with Keeper SDK workflow that generates `~/.keeper/config.json` +- For the Lambda **runtime** in AWS: set **Python 3.11** to match a typical build (see the packaging section below for matching Linux vs macOS/Windows when building the zip) + +## Build the deployment package + +Do this **before** you upload code to Lambda. The handler file alone is not enough: Lambda needs `boto3`, `keepersdk`, and all transitive dependencies installed next to `lambda_function.py` inside the zip. + +1. Open a shell and go to the directory that contains `lambda_function.py` and `requirements.txt` (this folder). + +2. Install dependencies into the **same directory** (typical for Lambda function packages): + + ```text + pip install -r requirements.txt -t . + ``` + +3. If `requirements.txt` points at `../../../../keepersdk-package`, that path only works from a full clone of `keeper-sdk-python` at the expected path. If you are using a standalone copy of the example, edit `requirements.txt` and use the `keepersdk` line from [PyPI](https://pypi.org/project/keepersdk/) instead, then run the `pip` command again. + +4. **Build the zip for Lambda (Linux).** The runtime is Amazon Linux; if you run `pip` on **macOS or Windows**, the wheels you get may not run on Lambda. For production packages, use a **Linux** environment with **Python 3.11** (for example a container image or Amazon Linux 2) and run the same `pip install` there before zipping. + +5. From *inside* this directory (so paths at the root of the zip are correct for Lambda), create a zip of **everything** needed, including the installed site-packages. For example: + + ```text + zip -r ../list_records_lambda_function.zip . + ``` + + (Adjust the name or parent path as you like; the important part is that the archive contains `lambda_function.py` and the top-level import packages, e.g. `boto3/`, `keepersdk/`, and their dependencies, at the **root** of the zip.) + +6. You will **upload** this zip in the Lambda console (or via CLI/API) as the function code. **Without this step, the function will fail** at import time because dependencies are not present in GitHub and are not on Lambda by default. + +## Step 1: Prepare Keeper Config Locally + +Login to Keeper locally with your email and password, then: + +- register device +- enable persistent login +- set a long timeout (for example, 30 days) + +This creates/updates your local Keeper config at: + +- `~/.keeper/config.json` + +## Step 2: Create Secret `keeper-secret-1` (Config Secret) + +In AWS Secrets Manager, create a secret named: + +- `keeper-secret-1` + +Paste the contents of `~/.keeper/config.json` as plaintext JSON. + +### Optional safety note (base64) + +If you are not comfortable pasting raw JSON, you can base64-encode it first and store that value. +In that case, `lambda_function.py` can be updated to decode the base64 payload before parsing JSON. + +> Current implementation expects JSON config content as the secret payload. + +## Step 3: Create Secret `keeper-secret-2` (Password Fallback) + +Create another secret named: + +- `keeper-secret-2` + +Store password as JSON, for example: + +```json +{"password":"Pass@123"} +``` + +Why this exists: + +- Normally, Lambda will authenticate from `keeper-secret-1` config. +- If config/session data is stale or invalid for a run, Keeper may require password verification. +- Lambda is non-interactive, so it uses `keeper-secret-2` automatically as fallback. + +## Step 4: Configure Lambda Function + +Create/update your Lambda with `lambda_function.py`. + +Use these settings and paths in AWS Lambda console: + +- **Memory**: Go to `Configuration` -> `General configuration` -> `Edit`, set to `512 MB`. +- **Timeout**: In `Configuration` -> `General configuration` -> `Edit`, set at least `15-30 seconds` (30 seconds recommended). +- **Runtime**: Under code section, open `Runtime settings` (Code properties) and set to `Python 3.11`. + +### Lambda Execution Role Permissions + +Make sure the Lambda execution role has at least: + +- `secretsmanager:GetSecretValue` +- `secretsmanager:DescribeSecret` (recommended) +- `logs:CreateLogGroup` +- `logs:CreateLogStream` +- `logs:PutLogEvents` + +If secrets use a customer-managed KMS key, also allow: + +- `kms:Decrypt` for that key + +Scope secret access to: + +- `keeper-secret-1` +- `keeper-secret-2` + +## Step 5: Upload Deployment Package + +In Lambda code source: + +1. Click **Upload from** +2. Select **.zip file** +3. Upload the **zip you built** in [Build the deployment package](#build-the-deployment-package) (for example `list_records_lambda_function.zip`). Do not expect a pre-built zip to be present in the GitHub tree; you must build it locally or in CI. + +That package must include dependencies installed with `pip install -r requirements.txt -t .`, so that components such as `boto3` and `keepersdk` are importable in Lambda. + +## Step 6: Set Environment Variables (Optional/Recommended) + +The function supports these environment variables: + +- `KEEPER_SECRET_ID` (default: `keeper-secret-1`) +- `KEEPER_PASSWORD_SECRET_ID` (default: `keeper-secret-2`) +- `AWS_REGION` or `KEEPER_AWS_REGION` +- `KEEPER_USERNAME` (optional fallback if config lacks username) +- `KEEPER_SERVER` (optional fallback if config lacks server) + +If you keep the default secret names, you can skip the first two. + +## Step 7: Test the Function + +1. Create a default test event (any name is fine) +2. Click the blue **Test** button (under Deploy) + +Expected result: + +- `statusCode: 200` +- response body with `ok: true` +- `record_count` and `records` list returned + +If fallback is used, logs include a message similar to: + +- `Config-based resume login is not valid for this run; falling back to master password from secret 'keeper-secret-2'.` + +## Troubleshooting + +- **`Missing username in config secret`** + - Ensure `last_login` exists in `keeper-secret-1`, or set `KEEPER_USERNAME`. + +- **Password fallback verification fails** + - Validate `keeper-secret-2` format and correct password value. + +- **Secrets Manager access errors** + - Check Lambda role IAM permissions and KMS decrypt permissions. + +- **Timeout or slow execution** + - Increase lambda timeout to 30 seconds or more and retry. + +- **Non-interactive step errors (2FA/SSO/device approval loops)** + - Re-run local Keeper login, confirm persistent login/device registration, and refresh `keeper-secret-1` from latest `~/.keeper/config.json`. + +## Security Recommendations + +- Do not print secrets or passwords in logs. +- Restrict IAM permissions to only required secret ARNs. +- Rotate password in `keeper-secret-2` as needed. +- Prefer AWS-managed encryption/KMS and audit secret access with CloudTrail. diff --git a/examples/sdk_examples/aws_cloud_sample/list_records_lambda_function/lambda_function.py b/examples/sdk_examples/aws_cloud_sample/list_records_lambda_function/lambda_function.py new file mode 100644 index 00000000..5dfb5e16 --- /dev/null +++ b/examples/sdk_examples/aws_cloud_sample/list_records_lambda_function/lambda_function.py @@ -0,0 +1,329 @@ +import base64 +import json +import logging +import os +import sqlite3 +from typing import Any, Dict, List, Optional + +import boto3 + +from keepersdk import utils +from keepersdk.authentication import configuration, endpoint, keeper_auth, login_auth +from keepersdk.vault import sqlite_storage, vault_online, vault_record + + +logger = utils.get_logger() +logger.setLevel(logging.INFO) +if not logger.handlers: + _handler = logging.StreamHandler() + _handler.setLevel(logging.INFO) + _handler.setFormatter( + logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") + ) + logger.addHandler(_handler) + + +class SecretsManagerJsonLoader(configuration.IJsonLoader): + """IJsonLoader implementation backed by AWS Secrets Manager.""" + + def __init__(self, secret_id: str, region_name: Optional[str] = None) -> None: + self.secret_id = secret_id + self.client = boto3.client("secretsmanager", region_name=region_name) + self._cached_bytes: Optional[bytes] = None + self._dirty = False + + def load_json(self) -> bytes: + if self._cached_bytes is not None: + return self._cached_bytes + + response = self.client.get_secret_value(SecretId=self.secret_id) + if "SecretString" in response: + secret_bytes = response["SecretString"].encode("utf-8") + else: + # Secrets Manager returns base64-encoded bytes for SecretBinary. + secret_bytes = base64.b64decode(response["SecretBinary"]) + + # Normalize empty payloads to JSON object. + payload = secret_bytes.strip() or b"{}" + self._cached_bytes = payload + return payload + + def store_json(self, data: bytes) -> None: + # JsonConfigurationStorage.put() calls this; persist later with flush(). + self._cached_bytes = data + self._dirty = True + + def flush(self) -> None: + if not self._dirty or self._cached_bytes is None: + return + self.client.put_secret_value( + SecretId=self.secret_id, + SecretString=self._cached_bytes.decode("utf-8"), + ) + self._dirty = False + + +def enable_persistent_login(keeper_auth_context: keeper_auth.KeeperAuth) -> None: + """Enable persistent login and register device key for future resume login.""" + keeper_auth.set_user_setting(keeper_auth_context, "persistent_login", "1") + keeper_auth.register_data_key_for_device(keeper_auth_context) + mins_per_day = 60 * 24 + timeout_in_minutes = mins_per_day * 30 + keeper_auth.set_user_setting( + keeper_auth_context, "logout_timer", str(timeout_in_minutes) + ) + + +def _fail_non_interactive(step: Any) -> None: + raise RuntimeError( + "Non-interactive Lambda login cannot continue. " + f"Received step: {type(step).__name__}. " + "Run one interactive login locally to fully approve/register this device, " + "then update the same config JSON in Secrets Manager." + ) + + +def _configuration_health(conf: configuration.IKeeperConfiguration) -> Dict[str, Any]: + """Return non-sensitive diagnostics for secret/config completeness.""" + user = conf.last_login or "" + has_user = bool(user) + has_server = bool(conf.last_server) + user_cfg = conf.users().get(user) if user else None + last_device_token = "" + if user_cfg and user_cfg.last_device: + last_device_token = user_cfg.last_device.device_token or "" + has_last_device = bool(last_device_token) + + device_cfg = conf.devices().get(last_device_token) if last_device_token else None + has_device = bool(device_cfg) + has_private_key = bool(device_cfg and device_cfg.private_key) + has_clone_code = bool( + device_cfg + and device_cfg.get_server_info() + and device_cfg.get_server_info().get(conf.last_server) + and device_cfg.get_server_info().get(conf.last_server).clone_code + ) + return { + "has_last_login": has_user, + "has_last_server": has_server, + "users_count": len(list(conf.users().list())), + "devices_count": len(list(conf.devices().list())), + "has_user_last_device": has_last_device, + "has_device_entry_for_last_device": has_device, + "has_device_private_key": has_private_key, + "has_clone_code_for_last_server": has_clone_code, + } + + +def _password_not_allowed_error() -> RuntimeError: + return RuntimeError( + "Keeper requested Password step in Lambda, but this function is configured " + "for password-free persistent login only. Update Secrets Manager with your " + "latest local Keeper config after enabling persistent login on the same device." + ) + + +def _fetch_password_from_secret( + secret_id: str, region_name: Optional[str] = None +) -> str: + """Read Keeper password from Secrets Manager secret string/binary.""" + client = boto3.client("secretsmanager", region_name=region_name) + response = client.get_secret_value(SecretId=secret_id) + if "SecretString" in response: + secret_text = response["SecretString"] + else: + secret_text = base64.b64decode(response["SecretBinary"]).decode("utf-8") + + value = secret_text.strip() + if not value: + raise ValueError(f"Password secret '{secret_id}' is empty.") + + # Accept either a raw secret string or a JSON object with a password field. + try: + parsed = json.loads(value) + except json.JSONDecodeError: + parsed = None + + if isinstance(parsed, dict): + for key in ("password", "keeper_password", "value"): + candidate = parsed.get(key) + if isinstance(candidate, str) and candidate.strip(): + return candidate.strip() + raise ValueError( + f"Password secret '{secret_id}' JSON must include one of: " + "'password', 'keeper_password', or 'value'." + ) + + return value + + +def login_from_secret( + secret_id: str, region_name: Optional[str] = None +) -> keeper_auth.KeeperAuth: + """ + Login with Keeper config stored in Secrets Manager. + Works only for resume/session-based non-interactive login in Lambda. + """ + loader = SecretsManagerJsonLoader(secret_id=secret_id, region_name=region_name) + config_storage = configuration.JsonConfigurationStorage(loader=loader) + conf = config_storage.get() + + if not conf.last_server: + conf.last_server = os.getenv("KEEPER_SERVER", "keepersecurity.com") + if not conf.last_login: + env_user = os.getenv("KEEPER_USERNAME", "").strip() + if env_user: + conf.last_login = env_user + + if not conf.last_login: + raise ValueError( + "Missing username in config secret. " + "Set last_login/user in secret JSON or KEEPER_USERNAME env var." + ) + + config_storage.put(conf) + + keeper_endpoint = endpoint.KeeperEndpoint(config_storage, conf.last_server) + password_secret_id = os.getenv("KEEPER_PASSWORD_SECRET_ID", "keeper-secret-2") + password_from_secret: Optional[str] = None + + login_ctx = login_auth.LoginAuth(keeper_endpoint) + login_ctx.resume_session = True + login_ctx.login(conf.last_login) + + approval_attempted = False + used_interactive_step = False + + while not login_ctx.login_step.is_final(): + step = login_ctx.login_step + if isinstance(step, login_auth.LoginStepDeviceApproval): + if approval_attempted: + _fail_non_interactive(step) + approval_attempted = True + step.resume() + used_interactive_step = True + continue + + if isinstance(step, login_auth.LoginStepPassword): + logger.info( + "Config-based resume login is not valid for this run; " + "falling back to master password from secret '%s'.", + password_secret_id, + ) + if password_from_secret is None: + password_from_secret = _fetch_password_from_secret( + secret_id=password_secret_id, region_name=region_name + ) + try: + step.verify_password(password_from_secret) + used_interactive_step = True + continue + except Exception as ex: + raise RuntimeError( + "Keeper requested password fallback, but verification failed " + f"using secret '{password_secret_id}'." + ) from ex + + if isinstance( + step, + ( + login_auth.LoginStepTwoFactor, + login_auth.LoginStepSsoToken, + login_auth.LoginStepSsoDataKey, + ), + ): + _fail_non_interactive(step) + + if isinstance(step, login_auth.LoginStepError): + raise RuntimeError(f"Keeper login error: ({step.code}) {step.message}") + + _fail_non_interactive(step) + + if not isinstance(login_ctx.login_step, login_auth.LoginStepConnected): + raise RuntimeError("Login did not reach connected state.") + + keeper_ctx = login_ctx.login_step.take_keeper_auth() + + # If login needed any step loop, ensure persistent login/device key are set. + if used_interactive_step: + enable_persistent_login(keeper_ctx) + + # Persist refreshed config (clone codes/device/server mappings) back to secret. + config_storage.put(config_storage.get()) + loader.flush() + return keeper_ctx + + +def list_records(keeper_auth_context: keeper_auth.KeeperAuth) -> List[Dict[str, Any]]: + """Sync vault and return records shaped like the example output.""" + conn = sqlite3.Connection("file::memory:", uri=True) + vault_storage = sqlite_storage.SqliteVaultStorage( + lambda: conn, + vault_owner=bytes(keeper_auth_context.auth_context.username, "utf-8"), + ) + + vault = vault_online.VaultOnline(keeper_auth_context, vault_storage) + try: + vault.sync_down() + output: List[Dict[str, Any]] = [] + for record in vault.vault_data.records(): + item: Dict[str, Any] = { + "Title": record.title, + "Record UID": record.record_uid, + "Record Type": record.record_type, + } + if record.version == 2: + legacy_record = vault.vault_data.load_record(record.record_uid) + if isinstance(legacy_record, vault_record.PasswordRecord): + item["Username"] = legacy_record.login + item["URL"] = legacy_record.link + output.append(item) + return output + finally: + vault.close() + keeper_auth_context.close() + + +def lambda_handler(event, context): + """ + Lambda handler. + Env vars: + - KEEPER_SECRET_ID (default: keeper-secret-1) + - KEEPER_PASSWORD_SECRET_ID (default: keeper-secret-2) + - AWS_REGION / KEEPER_AWS_REGION (optional override) + - KEEPER_USERNAME (optional fallback if secret lacks last_login) + - KEEPER_SERVER (optional fallback if secret lacks last_server) + """ + secret_id = os.getenv("KEEPER_SECRET_ID", "keeper-secret-1") + region = os.getenv("KEEPER_AWS_REGION") or os.getenv("AWS_REGION") + run_diagnostics = bool(event and event.get("diagnose_config")) + + try: + if run_diagnostics: + loader = SecretsManagerJsonLoader(secret_id=secret_id, region_name=region) + config_storage = configuration.JsonConfigurationStorage(loader=loader) + conf = config_storage.get() + diagnostics = _configuration_health(conf) + return { + "statusCode": 200, + "body": json.dumps({"ok": True, "diagnostics": diagnostics}), + } + + keeper_ctx = login_from_secret(secret_id=secret_id, region_name=region) + records = list_records(keeper_ctx) + return { + "statusCode": 200, + "body": json.dumps( + { + "ok": True, + "record_count": len(records), + "records": records, + } + ), + } + except Exception as e: + logger.exception("Keeper Lambda execution failed") + return { + "statusCode": 500, + "body": json.dumps({"ok": False, "error": str(e)}), + } diff --git a/examples/sdk_examples/aws_cloud_sample/list_records_lambda_function/requirements.txt b/examples/sdk_examples/aws_cloud_sample/list_records_lambda_function/requirements.txt new file mode 100644 index 00000000..cfe35b4e --- /dev/null +++ b/examples/sdk_examples/aws_cloud_sample/list_records_lambda_function/requirements.txt @@ -0,0 +1,2 @@ +boto3 +keepersdk