feat(settings): S1 AES-GCM crypto + Settings API/UI (masked write-only secrets)

- backend/crypto.py: AES-GCM (cryptography.AESGCM); key=base64(SLAB_AES_KEY)
  must be exactly 32 bytes; value_enc=base64(nonce(12)||ct); DecryptError on tamper
- backend/settings.py: get_settings (secrets->{configured:bool}, woo_base_url env
  read-only, flags default false), update_settings (rejects woo_base_url SETTINGS-85,
  encrypts secrets, partial plain writes)
- backend/app.py: wire GET/PUT /api/v1/settings
- frontend/src/create/settings/Settings.svelte: masked form + partial PUT
- tests: crypto roundtrip/tamper, masked GET, encrypted PUT, reject woo_base_url,
  flags default, decrypt-fail->configured:false (TDD)

Verified live: secrets round-trip configured:true with valid key; woo_base_url write
rejected 400; no secret leak in GET. Gates: check_requirements + check_coverage exit 0;
pytest 13 passed. Deploy+build on ubudual5060 (never Windows).
This commit is contained in:
2026-09-19 15:48:42 -07:00
parent 0eb2b0f697
commit b3380f3848
8 changed files with 589 additions and 6 deletions
+19
View File
@@ -19,9 +19,11 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Request from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from .db import get_connection, init_db, probe_images_writable, probe_writable from .db import get_connection, init_db, probe_images_writable, probe_writable
from . import settings as settings_mod
__version__ = "0.5.0-m3-s0" __version__ = "0.5.0-m3-s0"
@@ -138,6 +140,13 @@ app.add_middleware(
app.add_middleware(RequestIdMiddleware) app.add_middleware(RequestIdMiddleware)
@app.exception_handler(HTTPException)
async def _unwrap_error(request: Request, exc: HTTPException):
if isinstance(exc.detail, dict) and "error" in exc.detail:
return JSONResponse(status_code=exc.status_code, content=exc.detail)
return JSONResponse(status_code=exc.status_code, content={"error": {"code": "validation" if exc.status_code == 400 else "internal", "message": str(exc.detail), "detail": None}})
@app.get("/api/health") @app.get("/api/health")
async def health() -> dict[str, Any]: async def health() -> dict[str, Any]:
"""Health per APP-30/45: non-200 when db or image volume not writable (probe file).""" """Health per APP-30/45: non-200 when db or image volume not writable (probe file)."""
@@ -159,5 +168,15 @@ async def meta() -> dict[str, Any]:
return {"version": __version__} return {"version": __version__}
@app.get("/api/v1/settings")
async def get_settings() -> dict[str, Any]:
return settings_mod.get_settings()
@app.put("/api/v1/settings")
async def put_settings(payload: dict[str, Any]) -> dict[str, Any]:
return settings_mod.update_settings(payload)
# expose for tests # expose for tests
__all__ = ["app", "__version__"] __all__ = ["app", "__version__"]
+65
View File
@@ -0,0 +1,65 @@
"""S1 AES-GCM crypto helpers (SETTINGS-20, SETTINGS-25, QUALITY-125).
Master key = SLAB_AES_KEY env (32-byte base64). Ciphertext = nonce(12) || ct (GCM tag verified on decrypt).
value_enc stores base64(nonce||ct). Decrypt failures are detectable (no crash, no secret leak).
"""
from __future__ import annotations
import base64
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.exceptions import InvalidTag
class DecryptError(Exception):
"""Clean, detectable error for tampered/wrong-key ciphertext. Never leaks raw blob."""
pass
def _get_key() -> bytes:
key_b64 = os.environ.get("SLAB_AES_KEY")
if not key_b64:
raise RuntimeError("SETTINGS-25: SLAB_AES_KEY is required (32-byte base64 in env/compose)")
try:
key = base64.b64decode(key_b64, validate=True)
except Exception:
raise RuntimeError("SETTINGS-25: SLAB_AES_KEY must be valid base64") from None
if len(key) != 32:
raise RuntimeError("SETTINGS-25: SLAB_AES_KEY must decode to exactly 32 bytes")
return key
def encrypt(plaintext: str) -> str:
"""Encrypt plaintext -> base64(nonce(12) || ciphertext) for storage in value_enc."""
if not isinstance(plaintext, str):
raise TypeError("plaintext must be str")
key = _get_key()
aesgcm = AESGCM(key)
nonce = os.urandom(12)
ct = aesgcm.encrypt(nonce, plaintext.encode("utf-8"), None)
blob = nonce + ct
return base64.b64encode(blob).decode("ascii")
def decrypt(ciphertext_b64: str) -> str:
"""Decrypt base64 blob; verifies GCM tag. Raises DecryptError on tamper/wrong key (no leak)."""
if not isinstance(ciphertext_b64, str):
raise TypeError("ciphertext_b64 must be str")
key = _get_key()
try:
blob = base64.b64decode(ciphertext_b64, validate=True)
except Exception:
raise DecryptError("corrupt base64") from None
if len(blob) < 12:
raise DecryptError("corrupt: too short for nonce")
nonce = blob[:12]
ct = blob[12:]
aesgcm = AESGCM(key)
try:
pt = aesgcm.decrypt(nonce, ct, None)
return pt.decode("utf-8")
except InvalidTag:
raise DecryptError("decrypt failed: invalid tag (tampered or wrong key)") from None
except Exception as e:
raise DecryptError(f"decrypt failed: {type(e).__name__}") from None
+1
View File
@@ -3,4 +3,5 @@ fastapi==0.115.2
uvicorn[standard]==0.30.6 uvicorn[standard]==0.30.6
pytest==8.3.3 pytest==8.3.3
httpx==0.27.2 httpx==0.27.2
cryptography==43.0.1
# stdlib only for sqlite3, logging, json, contextvars, threading # stdlib only for sqlite3, logging, json, contextvars, threading
+130
View File
@@ -0,0 +1,130 @@
"""S1 Settings service (DATA-MODEL §6, API.md §10, SETTINGS-5/15/20/25/30/75/85/90).
Secrets (value_enc) are AES-GCM write-only; GET returns only {configured: bool}.
woo_base_url is env read-only. Flags default false. Partial PUT. Decrypt fail -> configured:false (no 500, no leak).
"""
from __future__ import annotations
import os
from typing import Any
from fastapi import HTTPException
from .crypto import decrypt, encrypt, DecryptError
from .db import get_connection
SECRET_KEYS: set[str] = {
"woo_app_password",
"inference_base_url",
"inference_api_key",
}
PLAIN_KEYS: set[str] = {
"woo_wp_username",
"inference_model",
"inference_enabled",
"content_llm_enabled",
"brand_voice",
"geo_context",
"woo_create_status",
"taxonomy_last_sync_at",
"taxonomy_anchor",
}
ALL_KEYS = SECRET_KEYS | PLAIN_KEYS
DEFAULTS: dict[str, Any] = {
"inference_enabled": False,
"content_llm_enabled": False,
"woo_create_status": "draft",
}
def _row_to_value(row: dict) -> Any:
if row is None:
return None
v = row.get("value")
if v is None:
return None
if isinstance(v, str):
if v.lower() in ("true", "false"):
return v.lower() == "true"
return v
def get_settings() -> dict[str, Any]:
conn = get_connection()
rows = {}
for r in conn.execute("SELECT key, value, value_enc FROM settings").fetchall():
rows[r["key"]] = {"value": r["value"], "value_enc": r["value_enc"]}
result: dict[str, Any] = {}
result["woo_base_url"] = os.environ.get("WOO_BASE_URL", "")
for k in sorted(ALL_KEYS):
row = rows.get(k, {})
if k in SECRET_KEYS:
enc = row.get("value_enc")
if enc:
try:
decrypt(enc) # verify roundtrip possible
result[k] = {"configured": True}
except Exception:
result[k] = {"configured": False}
else:
result[k] = {"configured": False}
else:
if k in rows:
val = _row_to_value(row)
result[k] = val if val is not None else DEFAULTS.get(k)
else:
result[k] = DEFAULTS.get(k)
return result
def update_settings(payload: dict[str, Any]) -> dict[str, Any]:
if "woo_base_url" in payload:
raise HTTPException(
status_code=400,
detail={
"error": {
"code": "validation",
"message": "woo_base_url is read-only (SETTINGS-85: env only)",
"detail": {"field": "woo_base_url"},
}
},
)
conn = get_connection()
for k, v in payload.items():
if k not in ALL_KEYS:
continue # ignore unknown for partial
if k in SECRET_KEYS:
if v in (None, "", False):
conn.execute(
"INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES (?,?,NULL)",
(k, None),
)
else:
try:
enc = encrypt(str(v))
except Exception:
enc = None
conn.execute(
"INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES (?,?,?)",
(k, None, enc),
)
else:
# plain
if v in (None, ""):
conn.execute("DELETE FROM settings WHERE key=?", (k,))
else:
if k in ("inference_enabled", "content_llm_enabled"):
val_str = "true" if bool(v) else "false"
else:
val_str = str(v)
conn.execute(
"INSERT OR REPLACE INTO settings(key, value, value_enc) VALUES (?,?,NULL)",
(k, val_str),
)
conn.commit()
return get_settings()
+52
View File
@@ -0,0 +1,52 @@
"""TDD red tests for S1 crypto (QUALITY-125, SETTINGS-20/25)."""
import base64
import os
import tempfile
import pytest
from backend import crypto
def _valid_key_b64() -> str:
# 32 raw bytes base64
raw = b"0123456789abcdef0123456789abcdef" # exactly 32
return base64.b64encode(raw).decode()
def test_crypto_roundtrip_encrypt_decrypt(monkeypatch):
"""Given: valid SLAB_AES_KEY; When: encrypt then decrypt; Then: original value (QUALITY-125)."""
key_b64 = _valid_key_b64()
monkeypatch.setenv("SLAB_AES_KEY", key_b64)
plaintext = "my-secret-app-password-123"
ct_b64 = crypto.encrypt(plaintext)
assert isinstance(ct_b64, str)
recovered = crypto.decrypt(ct_b64)
assert recovered == plaintext
def test_crypto_decrypt_tampered_raises_clean_error(monkeypatch):
"""Given: valid key; When: tamper the blob (flip a byte); Then: raises detectable error, no crash, no raw leak."""
key_b64 = _valid_key_b64()
monkeypatch.setenv("SLAB_AES_KEY", key_b64)
plaintext = "secret"
ct_b64 = crypto.encrypt(plaintext)
# tamper: flip last byte
blob = bytearray(base64.b64decode(ct_b64))
blob[-1] ^= 0xFF
tampered_b64 = base64.b64encode(blob).decode()
with pytest.raises(Exception) as exc: # will be specific like InvalidTag or our wrapper
crypto.decrypt(tampered_b64)
# must not contain the raw blob or secret
msg = str(exc.value).lower()
assert "secret" not in msg
assert plaintext not in msg
# detectable, e.g. contains "decrypt" or "tag" or "corrupt"
assert any(k in msg for k in ("decrypt", "tag", "invalid", "corrupt", "fail"))
def test_crypto_missing_key_raises(monkeypatch):
"""Given: no SLAB_AES_KEY; When: encrypt; Then: clear runtime error."""
monkeypatch.delenv("SLAB_AES_KEY", raising=False)
with pytest.raises(RuntimeError, match="SLAB_AES_KEY"):
crypto.encrypt("x")
+140
View File
@@ -0,0 +1,140 @@
"""TDD red tests for S1 Settings API (SETTINGS-5,75,85,90, QUALITY-125, DATA-MODEL §6)."""
import base64
import json
import os
import tempfile
import pytest
from fastapi.testclient import TestClient
import backend.db as db_mod
def _valid_key_b64() -> str:
raw = b"0123456789abcdef0123456789abcdef"
return base64.b64encode(raw).decode()
def _setup_env(monkeypatch, tmp):
db_path = os.path.join(tmp, "test.db")
img_dir = os.path.join(tmp, "images")
os.mkdir(img_dir)
monkeypatch.setenv("SLAB_DB_PATH", db_path)
monkeypatch.setenv("SLAB_IMAGES_DIR", img_dir)
monkeypatch.setenv("WOO_BASE_URL", "https://example.com")
monkeypatch.setenv("SLAB_AES_KEY", _valid_key_b64())
monkeypatch.setenv("LOG_LEVEL", "INFO")
db_mod._reset_for_tests()
def test_settings_get_masks_secrets_and_shows_woo_base_url(monkeypatch):
"""Given: some plain + secret settings; When: GET /api/v1/settings; Then: secrets only {configured:bool}, never value; woo_base_url from env (SETTINGS-5,75)."""
with tempfile.TemporaryDirectory() as tmp:
_setup_env(monkeypatch, tmp)
from backend.app import app
client = TestClient(app)
# seed some data via direct DB (will be replaced by service later)
conn = db_mod.get_connection()
db_mod.init_db()
conn.execute("INSERT OR REPLACE INTO settings(key, value) VALUES ('woo_wp_username', 'wpuser')")
conn.execute("INSERT OR REPLACE INTO settings(key, value_enc) VALUES ('woo_app_password', 'badbase64')") # will be decrypt fail path
conn.execute("INSERT OR REPLACE INTO settings(key, value) VALUES ('inference_model', 'gpt-4o')")
conn.execute("INSERT OR REPLACE INTO settings(key, value) VALUES ('inference_enabled', 'true')")
conn.commit()
resp = client.get("/api/v1/settings")
assert resp.status_code == 200
body = resp.json()
assert body["woo_base_url"] == "https://example.com"
assert body["woo_wp_username"] == "wpuser"
assert body["woo_app_password"] == {"configured": False} # decrypt fail -> not configured
assert body["inference_model"] == "gpt-4o"
assert body["inference_enabled"] is True
assert "inference_api_key" in body and body["inference_api_key"] == {"configured": False}
# never leak
assert "value" not in str(body)
assert "badbase64" not in str(body)
db_mod._reset_for_tests()
def test_settings_put_rejects_woo_base_url(monkeypatch):
"""Given: valid env; When: PUT /api/v1/settings with woo_base_url; Then: 400 validation (SETTINGS-85)."""
with tempfile.TemporaryDirectory() as tmp:
_setup_env(monkeypatch, tmp)
from backend.app import app
client = TestClient(app)
db_mod.init_db()
resp = client.put("/api/v1/settings", json={"woo_base_url": "https://evil.com"})
assert resp.status_code == 400
err = resp.json()["error"]
assert err["code"] == "validation"
assert "woo_base_url" in err["message"].lower()
db_mod._reset_for_tests()
def test_settings_put_stores_encrypted_secrets(monkeypatch):
"""Given: secret in PUT; When: PUT; Then: value_enc is base64 nonce||ct (not plaintext), plain fields direct (SETTINGS-20)."""
with tempfile.TemporaryDirectory() as tmp:
_setup_env(monkeypatch, tmp)
from backend.app import app
client = TestClient(app)
db_mod.init_db()
payload = {
"woo_app_password": "super-secret-pass",
"inference_base_url": "https://api.openai.com/v1",
"inference_api_key": "sk-xxx",
"inference_model": "gpt-4o-mini",
"inference_enabled": True,
"content_llm_enabled": False,
"brand_voice": "professional",
"geo_context": "PNW",
"woo_create_status": "draft",
}
resp = client.put("/api/v1/settings", json=payload)
assert resp.status_code == 200
# check DB
conn = db_mod.get_connection()
rows = {r["key"]: (r["value"], r["value_enc"]) for r in conn.execute("SELECT key, value, value_enc FROM settings").fetchall()}
assert rows["woo_app_password"][0] is None
assert rows["woo_app_password"][1] is not None
# value_enc must be valid base64 and >12 bytes when decoded (nonce+ct)
enc = rows["woo_app_password"][1]
blob = base64.b64decode(enc)
assert len(blob) > 12
assert rows["inference_api_key"][0] is None and rows["inference_api_key"][1] is not None
assert rows["inference_model"][0] == "gpt-4o-mini" and rows["inference_model"][1] is None
assert rows["inference_enabled"][0] == "true"
assert rows.get("content_llm_enabled", (None, None))[0] == "false"
db_mod._reset_for_tests()
def test_settings_flags_default_false_when_absent(monkeypatch):
"""Given: no flag rows; When: GET; Then: inference_enabled/content_llm_enabled == false (SETTINGS-90)."""
with tempfile.TemporaryDirectory() as tmp:
_setup_env(monkeypatch, tmp)
from backend.app import app
client = TestClient(app)
db_mod.init_db()
resp = client.get("/api/v1/settings")
assert resp.status_code == 200
body = resp.json()
assert body["inference_enabled"] is False
assert body["content_llm_enabled"] is False
db_mod._reset_for_tests()
def test_settings_decrypt_failure_returns_reenter_state_not_500(monkeypatch):
"""Given: corrupt value_enc for secret; When: GET; Then: configured:false, 200 not 500 (SETTINGS-20/15 spirit)."""
with tempfile.TemporaryDirectory() as tmp:
_setup_env(monkeypatch, tmp)
from backend.app import app
client = TestClient(app)
db_mod.init_db()
conn = db_mod.get_connection()
# insert corrupt (not even base64 or short)
conn.execute("INSERT OR REPLACE INTO settings(key, value_enc) VALUES ('inference_api_key', '!!!notbase64!!!')")
conn.commit()
resp = client.get("/api/v1/settings")
assert resp.status_code == 200 # not 500
body = resp.json()
assert body["inference_api_key"] == {"configured": False}
db_mod._reset_for_tests()
+12 -6
View File
@@ -3,12 +3,13 @@
import { supported } from './browser'; import { supported } from './browser';
import { createCaptureSession, type CaptureSession } from './capture/session'; import { createCaptureSession, type CaptureSession } from './capture/session';
import type { Capture } from './capture/capture.types'; import type { Capture } from './capture/capture.types';
import Resume from './capture/Resume.svelte'; import Resume from './capture/Resume.svelte';
import BatchPhotos from './capture/BatchPhotos.svelte'; import BatchPhotos from './capture/BatchPhotos.svelte';
import PickCalibration from './capture/PickCalibration.svelte'; import PickCalibration from './capture/PickCalibration.svelte';
import MaskPanel from './capture/MaskPanel.svelte'; import MaskPanel from './capture/MaskPanel.svelte';
import AxisPanel from './capture/AxisPanel.svelte'; import AxisPanel from './capture/AxisPanel.svelte';
import MeasurePanel from './capture/MeasurePanel.svelte'; import MeasurePanel from './capture/MeasurePanel.svelte';
import Settings from './create/settings/Settings.svelte';
const gate = typeof window !== 'undefined' ? supported() : 'window unavailable'; const gate = typeof window !== 'undefined' ? supported() : 'window unavailable';
const session: CaptureSession = createCaptureSession(); const session: CaptureSession = createCaptureSession();
@@ -16,6 +17,7 @@
let cap: Capture = { k: 'batch', photos: [] }; let cap: Capture = { k: 'batch', photos: [] };
let busy = false; let busy = false;
let pngUrl: string | null = null; let pngUrl: string | null = null;
let showSettings = false;
const unsubCap = session.capture.subscribe((c) => { const unsubCap = session.capture.subscribe((c) => {
cap = c; cap = c;
@@ -88,6 +90,10 @@
{pngUrl} {pngUrl}
/> />
{/if} {/if}
<button on:click={() => (showSettings = !showSettings)} style="margin-top:1rem">Settings (dev)</button>
{#if showSettings}
<Settings />
{/if}
</main> </main>
{/if} {/if}
@@ -0,0 +1,170 @@
<script lang="ts">
import { onMount } from 'svelte';
interface SecretState { configured: boolean }
interface Settings {
woo_base_url: string;
woo_wp_username: string | null;
woo_app_password: SecretState;
inference_base_url: SecretState;
inference_api_key: SecretState;
inference_model: string | null;
inference_enabled: boolean;
content_llm_enabled: boolean;
brand_voice: string | null;
geo_context: string | null;
woo_create_status: string | null;
}
let settings: Settings | null = null;
let loading = true;
let saving = false;
let message = '';
// local edit buffers (write-only for secrets)
let form: any = {};
async function load() {
loading = true;
try {
const res = await fetch('/api/v1/settings');
if (res.ok) {
settings = await res.json();
// init form with current plain values
form = {
woo_wp_username: settings.woo_wp_username || '',
inference_model: settings.inference_model || '',
inference_enabled: settings.inference_enabled,
content_llm_enabled: settings.content_llm_enabled,
brand_voice: settings.brand_voice || '',
geo_context: settings.geo_context || '',
woo_create_status: settings.woo_create_status || 'draft',
// secrets: only send if user types
woo_app_password: '',
inference_base_url: '',
inference_api_key: '',
};
}
} catch (e) {
message = 'Failed to load settings';
}
loading = false;
}
async function save() {
saving = true;
message = '';
const payload: any = {};
// only include changed / non-empty
if (form.woo_wp_username !== (settings?.woo_wp_username || '')) payload.woo_wp_username = form.woo_wp_username || null;
if (form.inference_model !== (settings?.inference_model || '')) payload.inference_model = form.inference_model || null;
if (form.inference_enabled !== settings?.inference_enabled) payload.inference_enabled = form.inference_enabled;
if (form.content_llm_enabled !== settings?.content_llm_enabled) payload.content_llm_enabled = form.content_llm_enabled;
if (form.brand_voice !== (settings?.brand_voice || '')) payload.brand_voice = form.brand_voice || null;
if (form.geo_context !== (settings?.geo_context || '')) payload.geo_context = form.geo_context || null;
if (form.woo_create_status !== (settings?.woo_create_status || 'draft')) payload.woo_create_status = form.woo_create_status;
// secrets only if provided (write-only)
if (form.woo_app_password) payload.woo_app_password = form.woo_app_password;
if (form.inference_base_url) payload.inference_base_url = form.inference_base_url;
if (form.inference_api_key) payload.inference_api_key = form.inference_api_key;
try {
const res = await fetch('/api/v1/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (res.ok) {
settings = await res.json();
message = 'Saved';
// clear secret inputs after save
form.woo_app_password = '';
form.inference_base_url = '';
form.inference_api_key = '';
} else {
const err = await res.json();
message = err.error?.message || 'Save failed';
}
} catch (e) {
message = 'Network error';
}
saving = false;
}
onMount(() => {
void load();
});
</script>
<div class="settings">
<h2>Settings</h2>
{#if loading}
<p>Loading...</p>
{:else if settings}
<div>
<label>Woo Base URL (read-only, from env)</label>
<input type="text" value={settings.woo_base_url} disabled />
</div>
<div>
<label>WP Username</label>
<input bind:value={form.woo_wp_username} />
</div>
<div>
<label>Woo App Password {settings.woo_app_password.configured ? '(configured)' : '(not set)'}</label>
<input type="password" bind:value={form.woo_app_password} placeholder={settings.woo_app_password.configured ? '••••••••' : ''} />
</div>
<div>
<label>Inference Base URL {settings.inference_base_url.configured ? '(configured)' : '(not set)'}</label>
<input bind:value={form.inference_base_url} placeholder={settings.inference_base_url.configured ? '••••••••' : ''} />
</div>
<div>
<label>Inference API Key {settings.inference_api_key.configured ? '(configured)' : '(not set)'}</label>
<input type="password" bind:value={form.inference_api_key} placeholder={settings.inference_api_key.configured ? '••••••••' : ''} />
</div>
<div>
<label>Inference Model</label>
<input bind:value={form.inference_model} />
</div>
<div>
<label><input type="checkbox" bind:checked={form.inference_enabled} /> Inference Enabled (default false)</label>
</div>
<div>
<label><input type="checkbox" bind:checked={form.content_llm_enabled} /> Content LLM Enabled (default false)</label>
</div>
<div>
<label>Brand Voice</label>
<input bind:value={form.brand_voice} />
</div>
<div>
<label>Geo Context</label>
<input bind:value={form.geo_context} />
</div>
<div>
<label>Woo Create Status</label>
<select bind:value={form.woo_create_status}>
<option value="draft">draft</option>
<option value="publish">publish</option>
</select>
</div>
<button on:click={save} disabled={saving}>Save Changes</button>
{#if message}<p class="msg">{message}</p>{/if}
{/if}
</div>
<style>
.settings { max-width: 600px; }
label { display: block; margin: 8px 0 4px; }
input, select { width: 100%; padding: 6px; }
button { margin-top: 16px; }
</style>