Add device passkeys for operator access #486

Merged
timmy merged 1 commits from timmy/485-device-passkeys into main 2026-08-10 13:16:22 +00:00
15 changed files with 1228 additions and 33 deletions

View File

@ -154,6 +154,9 @@ export STACKCHAIN_DASHBOARD_ACCESS_TOKEN='<operator-sign-in-secret>'
export STACKCHAIN_DASHBOARD_SESSION_SECRET='<independent-cookie-signing-secret>' export STACKCHAIN_DASHBOARD_SESSION_SECRET='<independent-cookie-signing-secret>'
# Optional; defaults to STACKCHAIN_STATE_DIR/sessions.sqlite3. # Optional; defaults to STACKCHAIN_STATE_DIR/sessions.sqlite3.
export STACKCHAIN_SESSION_DB='/var/lib/stackchain-dashboard/sessions.sqlite3' export STACKCHAIN_SESSION_DB='/var/lib/stackchain-dashboard/sessions.sqlite3'
# Recommended behind a proxy; WebAuthn assertions must match these public values.
export STACKCHAIN_PASSKEY_RP_ID='forge.example.com'
export STACKCHAIN_PASSKEY_ORIGIN='https://forge.example.com'
# Optional; defaults to eight hours. # Optional; defaults to eight hours.
export STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS=28800 export STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS=28800
# Optional; explicit pointer, keyboard, or touch activity renews this idle window. # Optional; explicit pointer, keyboard, or touch activity renews this idle window.
@ -196,8 +199,18 @@ session hashes, CSRF proofs, or source addresses. The registry also stores each
session's last explicit activity. Existing two-column registries are migrated in session's last explicit activity. Existing two-column registries are migrated in
place, their live sessions remain valid, and their idle clock starts at migration. place, their live sessions remain valid, and their idle clock starts at migration.
After token bootstrap, **Active devices → Add a passkey for this device** enrolls a
WebAuthn credential with required user verification. That device can then sign in
and authorize high-impact actions with its biometric/PIN gesture. The access token
remains the recovery fallback for browsers without WebAuthn or devices without an
enrolled credential. Registration and authentication challenges are exact-purpose,
single-use, and short-lived. Remotely revoking an enrolled device deletes its passkey
as well as its active session; signing out normally keeps the passkey available for
the next sign-in.
High-impact actions—merging a pull request, closing an assigned issue, revoking a High-impact actions—merging a pull request, closing an assigned issue, revoking a
remote device, or signing out every device—require the operator access token again. remote device, or signing out every device—require a passkey assertion or the
operator access token again.
The server issues a random 90-second grant bound to the active session, exact action, The server issues a random 90-second grant bound to the active session, exact action,
and exact target. Only its digest is stored, and the grant is consumed atomically on and exact target. Only its digest is stored, and the grant is consumed atomically on
first use. Expired, replayed, cross-session, and target-substituted grants fail before first use. Expired, replayed, cross-session, and target-substituted grants fail before

View File

@ -36,6 +36,7 @@
<button id="close-active-devices" type="button" aria-label="Close active devices">Close</button> <button id="close-active-devices" type="button" aria-label="Close active devices">Close</button>
</div> </div>
<div id="active-devices-status" class="small" role="status" aria-live="polite"></div> <div id="active-devices-status" class="small" role="status" aria-live="polite"></div>
<button id="enroll-passkey" type="button">Add a passkey for this device</button>
<div id="active-devices-list" class="active-devices-list"></div> <div id="active-devices-list" class="active-devices-list"></div>
</section> </section>
</div> </div>

View File

@ -20,6 +20,8 @@
const form = options.form; const form = options.form;
const status = options.status; const status = options.status;
const button = options.button; const button = options.button;
const passkeyButton = options.passkeyButton;
const credentials = options.credentials;
const fetchImpl = options.fetchImpl; const fetchImpl = options.fetchImpl;
const location = options.location; const location = options.location;
const clearPrivateDeviceData = options.clearPrivateDeviceData; const clearPrivateDeviceData = options.clearPrivateDeviceData;
@ -28,6 +30,34 @@
const clearIntervalImpl = options.clearIntervalImpl || clearInterval; const clearIntervalImpl = options.clearIntervalImpl || clearInterval;
let timer = null; let timer = null;
function decodeBase64Url(value) {
const padded = String(value).replaceAll('-', '+').replaceAll('_', '/')
+ '='.repeat((4 - String(value).length % 4) % 4);
return Uint8Array.from(atob(padded), character => character.charCodeAt(0));
}
function encodeBase64Url(value) {
if (value === null || value === undefined) return null;
const bytes = new Uint8Array(value);
let binary = '';
bytes.forEach(byte => { binary += String.fromCharCode(byte); });
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
}
function credentialJSON(credential) {
return {
id: credential.id,
type: credential.type,
rawId: encodeBase64Url(credential.rawId),
response: {
authenticatorData: encodeBase64Url(credential.response.authenticatorData),
clientDataJSON: encodeBase64Url(credential.response.clientDataJSON),
signature: encodeBase64Url(credential.response.signature),
userHandle: encodeBase64Url(credential.response.userHandle),
},
};
}
if (continuation !== './') status.textContent = 'Sign in to continue your shared capture.'; if (continuation !== './') status.textContent = 'Sign in to continue your shared capture.';
async function showReason(reason) { async function showReason(reason) {
@ -95,18 +125,60 @@
status.textContent = 'Sign-in failed. Check the token and try again.'; status.textContent = 'Sign-in failed. Check the token and try again.';
} }
return { submit, showReason }; async function signInWithPasskey(deviceLabel = 'This device') {
if (!credentials?.get) {
status.textContent = 'Passkeys are not supported in this browser. Use the access token.';
return false;
}
status.textContent = 'Waiting for your passkey…';
try {
const optionsResponse = await fetchImpl('api/v1/passkeys/authentication/options', {
method: 'POST', headers: { Accept: 'application/json' },
});
if (!optionsResponse.ok) throw new Error('No enrolled passkey');
const publicKey = await optionsResponse.json();
const challenge = publicKey.challenge;
publicKey.challenge = decodeBase64Url(publicKey.challenge);
publicKey.allowCredentials = (publicKey.allowCredentials || []).map(item => ({
...item, id: decodeBase64Url(item.id),
}));
const credential = await credentials.get({ publicKey });
const response = await fetchImpl('api/v1/passkeys/authentication/verify', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge,
credential: credentialJSON(credential),
device_label: deviceLabel,
action: 'sign_in',
target: 'dashboard',
}),
});
if (!response.ok) throw new Error('Passkey verification failed');
location.replace(continuation);
return true;
} catch (_error) {
status.textContent = 'Passkey sign-in was not completed. Try again or use the access token.';
return false;
}
}
if (passkeyButton) passkeyButton.disabled = !credentials?.get;
return { submit, signInWithPasskey, showReason };
})); }));
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
const form = document.getElementById('sign-in'); const form = document.getElementById('sign-in');
const status = document.getElementById('status'); const status = document.getElementById('status');
const button = document.getElementById('submit-sign-in'); const button = document.getElementById('submit-sign-in');
const passkeyButton = document.getElementById('passkey-sign-in');
const loginParams = new URLSearchParams(window.location.search); const loginParams = new URLSearchParams(window.location.search);
const controller = createLoginController({ const controller = createLoginController({
form, form,
status, status,
button, button,
passkeyButton,
credentials: window.navigator?.credentials,
fetchImpl: fetch.bind(window), fetchImpl: fetch.bind(window),
location: window.location, location: window.location,
continuation: loginParams.get('continue'), continuation: loginParams.get('continue'),
@ -120,4 +192,8 @@ if (typeof document !== 'undefined') {
const deviceLabel = data.get('device_label'); const deviceLabel = data.get('device_label');
controller.submit(accessToken, deviceLabel); controller.submit(accessToken, deviceLabel);
}); });
passkeyButton?.addEventListener('click', () => {
const data = new FormData(form);
controller.signInWithPasskey(data.get('device_label'));
});
} }

View File

@ -13,6 +13,7 @@
indexedDB: root.indexedDB, indexedDB: root.indexedDB,
caches: root.caches, caches: root.caches,
serviceWorker: root.navigator?.serviceWorker, serviceWorker: root.navigator?.serviceWorker,
credentials: root.navigator?.credentials,
MessageChannel: root.MessageChannel, MessageChannel: root.MessageChannel,
location: root.location, location: root.location,
addActivityListener: (type, listener, options) => root.addEventListener(type, listener, options), addActivityListener: (type, listener, options) => root.addEventListener(type, listener, options),
@ -38,6 +39,7 @@
const devicesList = root.document.getElementById('active-devices-list'); const devicesList = root.document.getElementById('active-devices-list');
const devicesStatus = root.document.getElementById('active-devices-status'); const devicesStatus = root.document.getElementById('active-devices-status');
const closeDevices = root.document.getElementById('close-active-devices'); const closeDevices = root.document.getElementById('close-active-devices');
const enrollPasskey = root.document.getElementById('enroll-passkey');
const renderDevices = async () => { const renderDevices = async () => {
devicesStatus.textContent = 'Loading active devices…'; devicesStatus.textContent = 'Loading active devices…';
devicesList.replaceChildren(); devicesList.replaceChildren();
@ -83,6 +85,18 @@
devicesSheet.hidden = true; devicesSheet.hidden = true;
devicesButton?.focus(); devicesButton?.focus();
}); });
enrollPasskey?.addEventListener('click', async () => {
enrollPasskey.disabled = true;
devicesStatus.textContent = 'Waiting for your device passkey…';
try {
await boundary.enrollPasskey();
devicesStatus.textContent = 'Passkey enrolled. You can use it at sign-in and authorization prompts.';
} catch (_error) {
devicesStatus.textContent = 'Passkey enrollment was not completed. Try again.';
} finally {
enrollPasskey.disabled = false;
}
});
boundary.refreshOfflineLease().then(valid => { boundary.refreshOfflineLease().then(valid => {
if (valid) boundary.resumeQueuedWork(); if (valid) boundary.resumeQueuedWork();
}); });
@ -93,7 +107,7 @@
root.stackchainSession = boundary; root.stackchainSession = boundary;
} }
})(typeof window !== 'undefined' ? window : this, function createSessionBoundary({ })(typeof window !== 'undefined' ? window : this, function createSessionBoundary({
cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, MessageChannel, location, confirmAction, addActivityListener, cookie, origin, base, fetchImpl, localStorage, sessionStorage, indexedDB, caches, serviceWorker, credentials, MessageChannel, location, confirmAction, addActivityListener,
promptAuthorization = () => null, promptAuthorization = () => null,
onExpired = () => {}, onExpired = () => {},
onClearError = () => {}, onClearError = () => {},
@ -164,6 +178,33 @@
return entry ? decodeURIComponent(entry.slice('stackchain_csrf='.length)) : ''; return entry ? decodeURIComponent(entry.slice('stackchain_csrf='.length)) : '';
} }
function decodeBase64Url(value) {
const padded = String(value).replaceAll('-', '+').replaceAll('_', '/')
+ '='.repeat((4 - String(value).length % 4) % 4);
return Uint8Array.from(atob(padded), character => character.charCodeAt(0));
}
function encodeBase64Url(value) {
if (value === null || value === undefined) return null;
let binary = '';
new Uint8Array(value).forEach(byte => { binary += String.fromCharCode(byte); });
return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
}
function authenticationCredentialJSON(credential) {
return {
id: credential.id,
type: credential.type,
rawId: encodeBase64Url(credential.rawId),
response: {
authenticatorData: encodeBase64Url(credential.response.authenticatorData),
clientDataJSON: encodeBase64Url(credential.response.clientDataJSON),
signature: encodeBase64Url(credential.response.signature),
userHandle: encodeBase64Url(credential.response.userHandle),
},
};
}
function isSameOrigin(input) { function isSameOrigin(input) {
try { return new URL(String(input?.url || input), origin).origin === origin; } try { return new URL(String(input?.url || input), origin).origin === origin; }
catch (_error) { return false; } catch (_error) { return false; }
@ -219,6 +260,37 @@
} }
} }
async function authorizeWithPasskey(details) {
if (!credentials?.get) return null;
const headers = new Headers({ Accept: 'application/json', 'Content-Type': 'application/json' });
const csrf = csrfToken();
if (csrf) headers.set('X-CSRF-Token', csrf);
const optionsResponse = await fetchWithDeadline(base + 'api/v1/passkeys/authorization/options', {
method: 'POST', headers,
body: JSON.stringify({ action: details.action, target: details.target }),
}, 'POST', 'passkey-authorization');
if (!optionsResponse.ok) return null;
const publicKey = await optionsResponse.json();
const challenge = publicKey.challenge;
publicKey.challenge = decodeBase64Url(publicKey.challenge);
publicKey.allowCredentials = (publicKey.allowCredentials || []).map(item => ({
...item, id: decodeBase64Url(item.id),
}));
const credential = await credentials.get({ publicKey });
const verified = await fetchWithDeadline(base + 'api/v1/passkeys/authorization/verify', {
method: 'POST', headers,
body: JSON.stringify({
challenge,
credential: authenticationCredentialJSON(credential),
action: details.action,
target: details.target,
}),
}, 'POST', 'passkey-authorization');
if (!verified.ok) return null;
const payload = await verified.json().catch(() => ({}));
return payload.grant || null;
}
async function sessionFetch(input, options = {}, allowStepUp = true) { async function sessionFetch(input, options = {}, allowStepUp = true) {
const method = String(options.method || input?.method || 'GET').toUpperCase(); const method = String(options.method || input?.method || 'GET').toUpperCase();
const requestOptions = { ...options }; const requestOptions = { ...options };
@ -233,31 +305,37 @@
const payload = await response.clone().json().catch(() => ({})); const payload = await response.clone().json().catch(() => ({}));
const detail = payload?.detail || {}; const detail = payload?.detail || {};
if (detail.code === 'step_up_required' && detail.action && detail.target) { if (detail.code === 'step_up_required' && detail.action && detail.target) {
const accessToken = await promptAuthorization({ let grant = null;
action: detail.action, try { grant = await authorizeWithPasskey(detail); }
target: detail.target, catch (_error) { /* Cancellation and unavailable passkeys fall back to recovery. */ }
}); if (!grant) {
if (!accessToken) return response; const accessToken = await promptAuthorization({
const authorizationHeaders = new Headers({
Accept: 'application/json',
'Content-Type': 'application/json',
});
const csrf = csrfToken();
if (csrf) authorizationHeaders.set('X-CSRF-Token', csrf);
const authorization = await fetchWithDeadline(base + 'api/v1/fresh-authorization', {
method: 'POST',
headers: authorizationHeaders,
body: JSON.stringify({
access_token: accessToken,
action: detail.action, action: detail.action,
target: detail.target, target: detail.target,
}), });
}, 'POST', 'fresh-authorization'); if (!accessToken) return response;
if (!authorization.ok) return authorization; const authorizationHeaders = new Headers({
const grant = await authorization.json().catch(() => ({})); Accept: 'application/json',
if (!grant.grant) return response; 'Content-Type': 'application/json',
});
const csrf = csrfToken();
if (csrf) authorizationHeaders.set('X-CSRF-Token', csrf);
const authorization = await fetchWithDeadline(base + 'api/v1/fresh-authorization', {
method: 'POST',
headers: authorizationHeaders,
body: JSON.stringify({
access_token: accessToken,
action: detail.action,
target: detail.target,
}),
}, 'POST', 'fresh-authorization');
if (!authorization.ok) return authorization;
const authorizationPayload = await authorization.json().catch(() => ({}));
grant = authorizationPayload.grant;
}
if (!grant) return response;
const retryHeaders = new Headers(requestOptions.headers || input?.headers || {}); const retryHeaders = new Headers(requestOptions.headers || input?.headers || {});
retryHeaders.set('X-Step-Up-Grant', grant.grant); retryHeaders.set('X-Step-Up-Grant', grant);
return sessionFetch(input, { ...requestOptions, headers: retryHeaders }, false); return sessionFetch(input, { ...requestOptions, headers: retryHeaders }, false);
} }
} }
@ -374,6 +452,41 @@
} catch (_error) { /* A later service-worker activation can clear stale caches. */ } } catch (_error) { /* A later service-worker activation can clear stale caches. */ }
} }
async function enrollPasskey() {
if (!credentials?.create) throw new Error('Passkeys are not supported');
const optionsResponse = await sessionFetch(
base + 'api/v1/passkeys/registration/options', { method: 'POST' }
);
if (!optionsResponse.ok) throw new Error('Could not start passkey enrollment');
const publicKey = await optionsResponse.json();
const challenge = publicKey.challenge;
publicKey.challenge = decodeBase64Url(publicKey.challenge);
publicKey.user.id = decodeBase64Url(publicKey.user.id);
publicKey.excludeCredentials = (publicKey.excludeCredentials || []).map(item => ({
...item, id: decodeBase64Url(item.id),
}));
const credential = await credentials.create({ publicKey });
const response = await sessionFetch(base + 'api/v1/passkeys/registration/verify', {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge,
credential: {
id: credential.id,
type: credential.type,
rawId: encodeBase64Url(credential.rawId),
response: {
attestationObject: encodeBase64Url(credential.response.attestationObject),
clientDataJSON: encodeBase64Url(credential.response.clientDataJSON),
transports: credential.response.getTransports?.() || [],
},
},
}),
});
if (!response.ok) throw new Error('Could not verify passkey enrollment');
return true;
}
async function listActiveDevices() { async function listActiveDevices() {
const response = await sessionFetch(base + 'api/v1/sessions'); const response = await sessionFetch(base + 'api/v1/sessions');
if (!response.ok) throw new Error('Could not load active devices'); if (!response.ok) throw new Error('Could not load active devices');
@ -424,6 +537,7 @@
signOut, signOut,
signOutAllDevices, signOutAllDevices,
listActiveDevices, listActiveDevices,
enrollPasskey,
revokeActiveDevice, revokeActiveDevice,
clearPrivateDeviceData, clearPrivateDeviceData,
handleServiceWorkerMessage, handleServiceWorkerMessage,

View File

@ -2,4 +2,6 @@ fastapi==0.133.1
httpx==0.28.1 httpx==0.28.1
pydantic==2.13.4 pydantic==2.13.4
pytest==9.1.1 pytest==9.1.1
rjsmin==1.2.5
uvicorn==0.41.0 uvicorn==0.41.0
webauthn==3.0.0

View File

@ -110,7 +110,10 @@ def idle_timeout_seconds() -> int:
def issue_session( def issue_session(
now: int | None = None, *, device_label: str = "This device" now: int | None = None,
*,
device_label: str = "This device",
management_id: str | None = None,
) -> tuple[str, Session]: ) -> tuple[str, Session]:
issued_at = int(time.time() if now is None else now) issued_at = int(time.time() if now is None else now)
ttl = int(os.getenv("STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS", str(DEFAULT_TTL_SECONDS))) ttl = int(os.getenv("STACKCHAIN_DASHBOARD_SESSION_TTL_SECONDS", str(DEFAULT_TTL_SECONDS)))
@ -127,7 +130,10 @@ def issue_session(
encoded = _encode(payload) encoded = _encode(payload)
signature = _encode(hmac.new(_secret(), encoded.encode(), hashlib.sha256).digest()) signature = _encode(hmac.new(_secret(), encoded.encode(), hashlib.sha256).digest())
_session_store(now).activate( _session_store(now).activate(
session.session_id, session.expires_at, device_label=device_label session.session_id,
session.expires_at,
device_label=device_label,
management_id=management_id,
) )
return f"{encoded}.{signature}", session return f"{encoded}.{signature}", session
@ -187,6 +193,10 @@ async def active_devices(session: Session):
return await asyncio.to_thread(_session_store().list_active, session.session_id) return await asyncio.to_thread(_session_store().list_active, session.session_id)
async def session_management_id(session: Session) -> str:
return await asyncio.to_thread(_session_store().management_id, session.session_id)
async def touch_session(session: Session) -> bool: async def touch_session(session: Session) -> bool:
return await asyncio.to_thread( return await asyncio.to_thread(
_session_store().touch, _session_store().touch,

View File

@ -8,6 +8,8 @@ import re
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
import rjsmin
SCRIPT_TAG = re.compile(r'^<script src="(static/[^"?]+\.js)"></script>$', re.MULTILINE) SCRIPT_TAG = re.compile(r'^<script src="(static/[^"?]+\.js)"></script>$', re.MULTILINE)
WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js" WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js"
@ -29,7 +31,10 @@ def _bundle(frontend_dir: Path, sources: tuple[str, ...]) -> bytes:
for source in sources: for source in sources:
path = frontend_dir / source.removeprefix("static/") path = frontend_dir / source.removeprefix("static/")
chunks.append(f"/* {source} */\n".encode() + path.read_bytes() + b"\n;\n") chunks.append(f"/* {source} */\n".encode() + path.read_bytes() + b"\n;\n")
return b"".join(chunks) source = b"".join(chunks)
revision = hashlib.sha256(source).hexdigest()
minified = rjsmin.jsmin(source.decode()).encode()
return minified + f';"source-sha256:{revision}";'.encode()
def build_frontend(frontend_dir: Path) -> FrontendBuild: def build_frontend(frontend_dir: Path) -> FrontendBuild:

View File

@ -21,7 +21,7 @@ from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field, PositiveInt, field_validator, model_validator from pydantic import BaseModel, Field, PositiveInt, field_validator, model_validator
from src import dashboard_auth, gitea_proxy from src import dashboard_auth, gitea_proxy, passkeys
from src.available_issue_snapshot_store import AvailableIssueSnapshotStore from src.available_issue_snapshot_store import AvailableIssueSnapshotStore
from src.compression import NegotiatedGZipMiddleware from src.compression import NegotiatedGZipMiddleware
from src.gitea_proxy import ( from src.gitea_proxy import (
@ -40,6 +40,7 @@ from src.idempotency import IdempotencyLedger, IdempotencyLedgerBusy
from src.login_attempt_store import LoginAttemptStore, LoginAttemptStoreError, client_source from src.login_attempt_store import LoginAttemptStore, LoginAttemptStoreError, client_source
from src.live_snapshot_store import LiveSnapshotState, LiveSnapshotStore, RefreshLeaseLost from src.live_snapshot_store import LiveSnapshotState, LiveSnapshotStore, RefreshLeaseLost
from src.models import Issue, Milestone, PullRequest, Repo, User from src.models import Issue, Milestone, PullRequest, Repo, User
from src.passkey_store import PasskeyStore
from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit
from src.suggestion_engine import compute from src.suggestion_engine import compute
from src.later_store import LaterStore from src.later_store import LaterStore
@ -199,12 +200,57 @@ class DashboardSignIn(BaseModel):
class FreshAuthorization(BaseModel): class FreshAuthorization(BaseModel):
access_token: str = Field(min_length=1, max_length=1_024) access_token: str = Field(min_length=1, max_length=1_024)
action: Literal[
"merge_pull",
"close_issue",
"revoke_device",
"revoke_all_sessions",
"enroll_passkey",
]
target: str = Field(min_length=1, max_length=255)
class PasskeyCeremony(BaseModel):
challenge: str = Field(min_length=20, max_length=200)
credential: dict = Field()
class PasskeyAuthentication(PasskeyCeremony):
device_label: str = Field(default="This device", min_length=1, max_length=64)
action: Literal[
"sign_in", "merge_pull", "close_issue", "revoke_device", "revoke_all_sessions"
] = "sign_in"
target: str = Field(default="dashboard", min_length=1, max_length=255)
class PasskeyAuthorizationTarget(BaseModel):
action: Literal[ action: Literal[
"merge_pull", "close_issue", "revoke_device", "revoke_all_sessions" "merge_pull", "close_issue", "revoke_device", "revoke_all_sessions"
] ]
target: str = Field(min_length=1, max_length=255) target: str = Field(min_length=1, max_length=255)
class PasskeyAuthorization(PasskeyCeremony, PasskeyAuthorizationTarget):
pass
def _passkey_store() -> PasskeyStore:
state_dir = os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state")
database = os.getenv(
"STACKCHAIN_SESSION_DB", os.path.join(state_dir, "sessions.sqlite3")
)
return PasskeyStore(database, clock=time.time)
def _passkey_relying_party(request: Request) -> tuple[str, str]:
rp_id = os.getenv("STACKCHAIN_PASSKEY_RP_ID", request.url.hostname or "")
origin = os.getenv(
"STACKCHAIN_PASSKEY_ORIGIN",
f"{request.url.scheme}://{request.url.netloc}",
)
return rp_id, origin
def _login_attempt_store() -> LoginAttemptStore: def _login_attempt_store() -> LoginAttemptStore:
state_dir = os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state") state_dir = os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state")
return LoginAttemptStore( return LoginAttemptStore(
@ -696,6 +742,13 @@ async def require_operator_session(request: Request, call_next):
} }
or path.startswith("/static/") or path.startswith("/static/")
or (path == "/api/v1/session" and request.method == "POST") or (path == "/api/v1/session" and request.method == "POST")
or (
path in {
"/api/v1/passkeys/authentication/options",
"/api/v1/passkeys/authentication/verify",
}
and request.method == "POST"
)
) )
session = None session = None
session_reason = None session_reason = None
@ -969,6 +1022,256 @@ async def fresh_authorization(payload: FreshAuthorization, request: Request):
) )
@app.post("/api/v1/passkeys/registration/options", status_code=201)
async def create_passkey_registration_options(
request: Request,
step_up_grant: str | None = Header(
default=None, alias="X-Step-Up-Grant", max_length=128
),
):
await _require_step_up(
request,
step_up_grant,
action="enroll_passkey",
target="current_device",
)
rp_id, _origin = _passkey_relying_party(request)
store = _passkey_store()
existing = await asyncio.to_thread(store.all)
options, challenge = passkeys.registration_options(
rp_id=rp_id,
excluded=[item.credential_id for item in existing],
)
await asyncio.to_thread(
store.issue_challenge,
challenge,
session_id=request.state.dashboard_session.session_id,
purpose="registration",
action="enroll_passkey",
target="current_device",
)
return JSONResponse(
options,
status_code=201,
headers={"Cache-Control": "no-store"},
)
@app.post("/api/v1/passkeys/registration/verify", status_code=201)
async def verify_passkey_registration(payload: PasskeyCeremony, request: Request):
try:
challenge = passkeys.decode(payload.challenge)
except (ValueError, TypeError):
raise HTTPException(status_code=400, detail="Invalid passkey ceremony")
store = _passkey_store()
valid = await asyncio.to_thread(
store.consume_challenge,
challenge,
session_id=request.state.dashboard_session.session_id,
purpose="registration",
action="enroll_passkey",
target="current_device",
)
if not valid:
raise HTTPException(status_code=409, detail="Passkey challenge expired or already used")
rp_id, origin = _passkey_relying_party(request)
try:
verified = await asyncio.to_thread(
passkeys.verify_registration,
credential=payload.credential,
challenge=challenge,
rp_id=rp_id,
origin=origin,
)
devices = await dashboard_auth.active_devices(request.state.dashboard_session)
current = next(device for device in devices if device.current)
await asyncio.to_thread(
store.register,
credential_id=verified.credential_id,
public_key=verified.credential_public_key,
sign_count=verified.sign_count,
device_label=current.device_label,
management_id=current.management_id,
)
except dashboard_auth.SessionStoreError:
raise HTTPException(status_code=503, detail="Passkey registry is temporarily unavailable")
except Exception as exc:
raise HTTPException(status_code=400, detail="Passkey verification failed") from exc
return JSONResponse(
{"enrolled": True}, status_code=201, headers={"Cache-Control": "no-store"}
)
@app.post("/api/v1/passkeys/authentication/options")
async def create_passkey_authentication_options(request: Request):
store = _passkey_store()
credentials = await asyncio.to_thread(store.all)
if not credentials:
raise HTTPException(status_code=404, detail="No passkeys enrolled")
rp_id, _origin = _passkey_relying_party(request)
options, challenge = passkeys.authentication_options(
rp_id=rp_id,
credentials=[item.credential_id for item in credentials],
)
await asyncio.to_thread(
store.issue_challenge,
challenge,
session_id=None,
purpose="authentication",
action="sign_in",
target="dashboard",
)
return JSONResponse(options, headers={"Cache-Control": "no-store"})
@app.post("/api/v1/passkeys/authorization/options")
async def create_passkey_authorization_options(
payload: PasskeyAuthorizationTarget, request: Request
):
store = _passkey_store()
credentials = await asyncio.to_thread(store.all)
if not credentials:
raise HTTPException(status_code=404, detail="No passkeys enrolled")
rp_id, _origin = _passkey_relying_party(request)
options, challenge = passkeys.authentication_options(
rp_id=rp_id,
credentials=[item.credential_id for item in credentials],
)
await asyncio.to_thread(
store.issue_challenge,
challenge,
session_id=request.state.dashboard_session.session_id,
purpose="authorization",
action=payload.action,
target=payload.target,
)
return JSONResponse(options, headers={"Cache-Control": "no-store"})
@app.post("/api/v1/passkeys/authorization/verify", status_code=201)
async def verify_passkey_authorization(
payload: PasskeyAuthorization, request: Request
):
try:
challenge = passkeys.decode(payload.challenge)
credential_id = passkeys.decode(str(payload.credential.get("id", "")))
except (ValueError, TypeError):
raise HTTPException(status_code=400, detail="Invalid passkey ceremony")
store = _passkey_store()
valid = await asyncio.to_thread(
store.consume_challenge,
challenge,
session_id=request.state.dashboard_session.session_id,
purpose="authorization",
action=payload.action,
target=payload.target,
)
stored = await asyncio.to_thread(store.get, credential_id)
if not valid or stored is None:
raise HTTPException(status_code=409, detail="Passkey challenge expired or already used")
rp_id, origin = _passkey_relying_party(request)
try:
verified = await asyncio.to_thread(
passkeys.verify_authentication,
credential=payload.credential,
challenge=challenge,
rp_id=rp_id,
origin=origin,
stored=stored,
)
updated = await asyncio.to_thread(
store.update_counter, stored.credential_id, verified.new_sign_count
)
if not updated:
raise ValueError("stale passkey counter")
grant = await dashboard_auth.issue_step_up(
request.state.dashboard_session,
action=payload.action,
target=payload.target,
)
except dashboard_auth.SessionStoreError:
raise HTTPException(status_code=503, detail="Passkey registry is temporarily unavailable")
except Exception as exc:
raise HTTPException(status_code=401, detail="Passkey authorization failed") from exc
return JSONResponse(
{"grant": grant, "expires_in": dashboard_auth.STEP_UP_TTL_SECONDS},
status_code=201,
headers={"Cache-Control": "no-store"},
)
@app.post("/api/v1/passkeys/authentication/verify")
async def verify_passkey_authentication(
payload: PasskeyAuthentication, request: Request, response: Response
):
if payload.action != "sign_in" or payload.target != "dashboard":
raise HTTPException(status_code=400, detail="Invalid passkey sign-in target")
try:
challenge = passkeys.decode(payload.challenge)
credential_id = passkeys.decode(str(payload.credential.get("id", "")))
except (ValueError, TypeError):
raise HTTPException(status_code=400, detail="Invalid passkey ceremony")
store = _passkey_store()
valid = await asyncio.to_thread(
store.consume_challenge,
challenge,
session_id=None,
purpose="authentication",
action="sign_in",
target="dashboard",
)
stored = await asyncio.to_thread(store.get, credential_id)
if not valid or stored is None:
raise HTTPException(status_code=401, detail="Passkey sign-in failed")
rp_id, origin = _passkey_relying_party(request)
try:
verified = await asyncio.to_thread(
passkeys.verify_authentication,
credential=payload.credential,
challenge=challenge,
rp_id=rp_id,
origin=origin,
stored=stored,
)
updated = await asyncio.to_thread(
store.update_counter, stored.credential_id, verified.new_sign_count
)
if not updated:
raise ValueError("stale passkey counter")
await dashboard_auth.revoke_managed_session(stored.management_id)
signed, session = await asyncio.to_thread(
dashboard_auth.issue_session,
device_label=stored.device_label,
management_id=stored.management_id,
)
except dashboard_auth.SessionStoreError:
raise HTTPException(status_code=503, detail="Passkey registry is temporarily unavailable")
except Exception as exc:
raise HTTPException(status_code=401, detail="Passkey sign-in failed") from exc
path = dashboard_auth.cookie_path(request)
max_age = max(1, session.expires_at - int(time.time()))
response.set_cookie(
dashboard_auth.SESSION_COOKIE,
signed,
max_age=max_age,
path=path,
secure=True,
httponly=True,
samesite="strict",
)
response.set_cookie(
dashboard_auth.CSRF_COOKIE,
session.csrf,
max_age=max_age,
path=path,
secure=True,
httponly=False,
samesite="strict",
)
response.headers["Cache-Control"] = "no-store"
return {"authenticated": True, "method": "passkey"}
@app.get("/api/v1/session") @app.get("/api/v1/session")
async def session_status(request: Request): async def session_status(request: Request):
session = request.state.dashboard_session session = request.state.dashboard_session
@ -1196,6 +1499,7 @@ async def revoke_active_device(
target = next( target = next(
(device for device in devices if device.management_id == management_id), None (device for device in devices if device.management_id == management_id), None
) )
await asyncio.to_thread(_passkey_store().revoke_management_id, management_id)
revoked = await dashboard_auth.revoke_managed_session(management_id) revoked = await dashboard_auth.revoke_managed_session(management_id)
except dashboard_auth.SessionStoreError: except dashboard_auth.SessionStoreError:
return JSONResponse( return JSONResponse(
@ -1223,6 +1527,7 @@ async def sign_out_all_devices(
target="all", target="all",
) )
try: try:
await asyncio.to_thread(_passkey_store().revoke_all)
await dashboard_auth.revoke_all_sessions() await dashboard_auth.revoke_all_sessions()
except dashboard_auth.SessionStoreError: except dashboard_auth.SessionStoreError:
return JSONResponse( return JSONResponse(

199
src/passkey_store.py Normal file
View File

@ -0,0 +1,199 @@
"""Durable, one-time WebAuthn challenges and device-bound passkey credentials."""
import hashlib
import sqlite3
from dataclasses import dataclass
from pathlib import Path
from typing import Callable
from src.session_store import SessionStoreError
@dataclass(frozen=True)
class StoredPasskey:
credential_id: bytes
public_key: bytes
sign_count: int
device_label: str
management_id: str
class PasskeyStore:
def __init__(self, path: str | Path, *, clock: Callable[[], float]) -> None:
self.path = Path(path)
self.clock = clock
@staticmethod
def _digest(value: bytes | str) -> str:
raw = value if isinstance(value, bytes) else value.encode()
return hashlib.sha256(raw).hexdigest()
def _connect(self) -> sqlite3.Connection:
try:
self.path.parent.mkdir(parents=True, exist_ok=True)
connection = sqlite3.connect(self.path, timeout=0.1)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS passkey_credentials (
credential_id BLOB PRIMARY KEY,
public_key BLOB NOT NULL,
sign_count INTEGER NOT NULL,
device_label TEXT NOT NULL,
management_id TEXT NOT NULL UNIQUE,
created_at INTEGER NOT NULL
)
"""
)
connection.execute(
"""
CREATE TABLE IF NOT EXISTS passkey_challenges (
challenge_hash TEXT PRIMARY KEY,
session_hash TEXT,
purpose TEXT NOT NULL,
action TEXT NOT NULL,
target TEXT NOT NULL,
expires_at INTEGER NOT NULL
)
"""
)
return connection
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
def issue_challenge(
self,
challenge: bytes,
*,
session_id: str | None,
purpose: str,
action: str,
target: str,
ttl_seconds: int = 120,
) -> None:
now = int(self.clock())
try:
with self._connect() as connection:
connection.execute("DELETE FROM passkey_challenges WHERE expires_at <= ?", (now,))
connection.execute(
"INSERT INTO passkey_challenges("
"challenge_hash, session_hash, purpose, action, target, expires_at"
") VALUES (?, ?, ?, ?, ?, ?)",
(
self._digest(challenge),
self._digest(session_id) if session_id else None,
purpose,
action,
target,
now + max(1, ttl_seconds),
),
)
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
def consume_challenge(
self,
challenge: bytes,
*,
session_id: str | None,
purpose: str,
action: str,
target: str,
) -> bool:
now = int(self.clock())
try:
with self._connect() as connection:
connection.execute("DELETE FROM passkey_challenges WHERE expires_at <= ?", (now,))
cursor = connection.execute(
"DELETE FROM passkey_challenges WHERE challenge_hash = ? "
"AND session_hash IS ? AND purpose = ? AND action = ? AND target = ? "
"AND expires_at > ?",
(
self._digest(challenge),
self._digest(session_id) if session_id else None,
purpose,
action,
target,
now,
),
)
return cursor.rowcount == 1
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
def register(
self,
*,
credential_id: bytes,
public_key: bytes,
sign_count: int,
device_label: str,
management_id: str,
) -> None:
try:
with self._connect() as connection:
connection.execute(
"INSERT INTO passkey_credentials(credential_id, public_key, sign_count, "
"device_label, management_id, created_at) VALUES (?, ?, ?, ?, ?, ?)",
(
credential_id,
public_key,
sign_count,
device_label,
management_id,
int(self.clock()),
),
)
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
def all(self) -> list[StoredPasskey]:
try:
with self._connect() as connection:
rows = connection.execute(
"SELECT credential_id, public_key, sign_count, device_label, management_id "
"FROM passkey_credentials ORDER BY created_at DESC"
).fetchall()
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
return [StoredPasskey(*row) for row in rows]
def get(self, credential_id: bytes) -> StoredPasskey | None:
try:
with self._connect() as connection:
row = connection.execute(
"SELECT credential_id, public_key, sign_count, device_label, management_id "
"FROM passkey_credentials WHERE credential_id = ?",
(credential_id,),
).fetchone()
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
return StoredPasskey(*row) if row else None
def update_counter(self, credential_id: bytes, new_sign_count: int) -> bool:
try:
with self._connect() as connection:
cursor = connection.execute(
"UPDATE passkey_credentials SET sign_count = ? "
"WHERE credential_id = ? AND sign_count <= ?",
(new_sign_count, credential_id, new_sign_count),
)
return cursor.rowcount == 1
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
def revoke_management_id(self, management_id: str) -> None:
try:
with self._connect() as connection:
connection.execute(
"DELETE FROM passkey_credentials WHERE management_id = ?", (management_id,)
)
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc
def revoke_all(self) -> None:
try:
with self._connect() as connection:
connection.execute("DELETE FROM passkey_credentials")
connection.execute("DELETE FROM passkey_challenges")
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Passkey registry is temporarily unavailable") from exc

87
src/passkeys.py Normal file
View File

@ -0,0 +1,87 @@
"""WebAuthn passkey ceremony helpers for the single dashboard operator."""
import base64
import json
import secrets
from webauthn import (
generate_authentication_options,
generate_registration_options,
options_to_json,
verify_authentication_response,
verify_registration_response,
)
from webauthn.helpers.structs import (
AuthenticatorSelectionCriteria,
PublicKeyCredentialDescriptor,
ResidentKeyRequirement,
UserVerificationRequirement,
)
def encode(value: bytes) -> str:
return base64.urlsafe_b64encode(value).rstrip(b"=").decode()
def decode(value: str) -> bytes:
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
def registration_options(*, rp_id: str, excluded: list[bytes] | None = None) -> tuple[dict, bytes]:
challenge = secrets.token_bytes(32)
options = generate_registration_options(
rp_id=rp_id,
rp_name="Stackchain Dashboard",
user_name="stackchain-operator",
user_display_name="Stackchain operator",
challenge=challenge,
exclude_credentials=[
PublicKeyCredentialDescriptor(id=credential_id)
for credential_id in (excluded or [])
],
authenticator_selection=AuthenticatorSelectionCriteria(
resident_key=ResidentKeyRequirement.PREFERRED,
user_verification=UserVerificationRequirement.REQUIRED,
),
)
return json.loads(options_to_json(options)), challenge
def authentication_options(*, rp_id: str, credentials: list[bytes]) -> tuple[dict, bytes]:
challenge = secrets.token_bytes(32)
options = generate_authentication_options(
rp_id=rp_id,
challenge=challenge,
allow_credentials=[
PublicKeyCredentialDescriptor(id=credential_id)
for credential_id in credentials
],
user_verification=UserVerificationRequirement.REQUIRED,
)
return json.loads(options_to_json(options)), challenge
def verify_registration(
*, credential: dict, challenge: bytes, rp_id: str, origin: str
):
return verify_registration_response(
credential=credential,
expected_challenge=challenge,
expected_rp_id=rp_id,
expected_origin=origin,
require_user_verification=True,
)
def verify_authentication(
*, credential: dict, challenge: bytes, rp_id: str, origin: str, stored
):
return verify_authentication_response(
credential=credential,
expected_challenge=challenge,
expected_rp_id=rp_id,
expected_origin=origin,
credential_public_key=stored.public_key,
credential_current_sign_count=stored.sign_count,
require_user_verification=True,
)

View File

@ -117,7 +117,12 @@ class SessionStore:
raise SessionStoreError("Session registry is temporarily unavailable") from exc raise SessionStoreError("Session registry is temporarily unavailable") from exc
def activate( def activate(
self, session_id: str, expires_at: int, *, device_label: str = "This device" self,
session_id: str,
expires_at: int,
*,
device_label: str = "This device",
management_id: str | None = None,
) -> None: ) -> None:
label = " ".join(str(device_label).split())[:64] or "This device" label = " ".join(str(device_label).split())[:64] or "This device"
now = int(self.clock()) now = int(self.clock())
@ -133,7 +138,7 @@ class SessionStore:
( (
self._digest(session_id), self._digest(session_id),
expires_at, expires_at,
secrets.token_urlsafe(18), management_id or secrets.token_urlsafe(18),
label, label,
now, now,
now, now,
@ -241,6 +246,19 @@ class SessionStore:
for row in rows for row in rows
] ]
def management_id(self, session_id: str) -> str:
try:
with self._connect() as connection:
row = connection.execute(
"SELECT management_id FROM active_sessions WHERE session_hash = ?",
(self._digest(session_id),),
).fetchone()
except (OSError, sqlite3.Error) as exc:
raise SessionStoreError("Session registry is temporarily unavailable") from exc
if row is None:
raise SessionStoreError("Session is no longer active")
return row[0]
def revoke_managed(self, management_id: str) -> bool: def revoke_managed(self, management_id: str) -> bool:
try: try:
with self._connect() as connection: with self._connect() as connection:

View File

@ -15,8 +15,8 @@ LOGIN_HTML = """<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Sign in · Stackchain Dashboard</title> <title>Sign in · Stackchain Dashboard</title>
<style>body{margin:0;background:#07111f;color:#eef6ff;font:16px system-ui;display:grid;min-height:100vh;place-items:center}main{box-sizing:border-box;width:min(90vw,24rem);padding:2rem;border:1px solid #29415d;border-radius:1rem;background:#0d1b2b}label,input,button{display:block;width:100%;box-sizing:border-box}input,button{min-height:48px;margin-top:.6rem;border-radius:.6rem;border:1px solid #49647f;padding:.75rem}button{margin-top:1rem;background:#55d6be;color:#06121b;font-weight:700}p{color:#a9bed3}</style></head> <style>body{margin:0;background:#07111f;color:#eef6ff;font:16px system-ui;display:grid;min-height:100vh;place-items:center}main{box-sizing:border-box;width:min(90vw,24rem);padding:2rem;border:1px solid #29415d;border-radius:1rem;background:#0d1b2b}label,input,button{display:block;width:100%;box-sizing:border-box}input,button{min-height:48px;margin-top:.6rem;border-radius:.6rem;border:1px solid #49647f;padding:.75rem}button{margin-top:1rem;background:#55d6be;color:#06121b;font-weight:700}p{color:#a9bed3}</style></head>
<body><main><h1>Operator sign in</h1><p>Enter the dashboard access token. It is exchanged for a private, short-lived session and is never stored on this device.</p> <body><main><h1>Operator sign in</h1><p>Use an enrolled passkey, or enter the dashboard access token for bootstrap and recovery. The token is exchanged for a private, short-lived session and is never stored on this device.</p>
<form id="sign-in"><label>Device name<input name="device_label" type="text" autocomplete="name" maxlength="64" value="This device" required></label><label>Access token<input name="access_token" type="password" autocomplete="current-password" required></label><button id="submit-sign-in">Sign in</button><p id="status" role="status" aria-live="polite"></p></form></main> <form id="sign-in"><label>Device name<input name="device_label" type="text" autocomplete="name" maxlength="64" value="This device" required></label><button id="passkey-sign-in" type="button">Sign in with a passkey</button><p>Recovery</p><label>Access token<input name="access_token" type="password" autocomplete="current-password" required></label><button id="submit-sign-in">Sign in with access token</button><p id="status" role="status" aria-live="polite"></p></form></main>
<script src="static/private-device-data.js"></script><script src="static/login.js"></script></body></html>""" <script src="static/private-device-data.js"></script><script src="static/login.js"></script></body></html>"""

View File

@ -39,6 +39,239 @@ async def fresh_grant(client, action: str, target: str) -> str:
return response.json()["grant"] return response.json()["grant"]
@pytest.mark.anyio
async def test_passkey_enrollment_options_require_fresh_authorization_and_are_one_time(
access_control,
):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session",
json={
"access_token": "correct horse battery staple",
"device_label": "Timmy's phone",
},
)
headers = {
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
}
missing = await client.post("/api/v1/passkeys/registration/options", headers=headers)
grant = await fresh_grant(client, "enroll_passkey", "current_device")
created = await client.post(
"/api/v1/passkeys/registration/options",
headers={**headers, "X-Step-Up-Grant": grant},
)
replayed = await client.post(
"/api/v1/passkeys/registration/options",
headers={**headers, "X-Step-Up-Grant": grant},
)
assert missing.status_code == 428
assert missing.json()["detail"]["action"] == "enroll_passkey"
assert created.status_code == 201
assert created.headers["cache-control"] == "no-store"
options = created.json()
assert options["rp"] == {"id": "test", "name": "Stackchain Dashboard"}
assert options["user"]["name"] == "stackchain-operator"
assert options["authenticatorSelection"]["userVerification"] == "required"
assert options["challenge"]
assert replayed.status_code == 428
@pytest.mark.anyio
async def test_enrolled_passkey_can_sign_in_without_the_operator_token(
access_control, monkeypatch
):
class VerifiedRegistration:
credential_id = b"phone-credential"
credential_public_key = b"credential-public-key"
sign_count = 0
class VerifiedAuthentication:
new_sign_count = 1
monkeypatch.setattr(
main.passkeys,
"verify_registration",
lambda **_kwargs: VerifiedRegistration(),
raising=False,
)
monkeypatch.setattr(
main.passkeys,
"verify_authentication",
lambda **_kwargs: VerifiedAuthentication(),
raising=False,
)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as bootstrap:
await bootstrap.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
)
csrf_headers = {
"Origin": "https://test",
"X-CSRF-Token": bootstrap.cookies["stackchain_csrf"],
}
grant = await fresh_grant(bootstrap, "enroll_passkey", "current_device")
options = await bootstrap.post(
"/api/v1/passkeys/registration/options",
headers={**csrf_headers, "X-Step-Up-Grant": grant},
)
enrolled = await bootstrap.post(
"/api/v1/passkeys/registration/verify",
json={"challenge": options.json()["challenge"], "credential": {"id": "fake"}},
headers=csrf_headers,
)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as returning:
sign_in_options = await returning.post("/api/v1/passkeys/authentication/options")
signed_in = await returning.post(
"/api/v1/passkeys/authentication/verify",
json={
"challenge": sign_in_options.json()["challenge"],
"credential": {"id": "cGhvbmUtY3JlZGVudGlhbA"},
"device_label": "Phone",
"action": "sign_in",
"target": "dashboard",
},
)
assert enrolled.status_code == 201
assert enrolled.json() == {"enrolled": True}
assert sign_in_options.status_code == 200
assert sign_in_options.json()["allowCredentials"][0]["id"] == "cGhvbmUtY3JlZGVudGlhbA"
assert signed_in.status_code == 200
assert signed_in.json() == {"authenticated": True, "method": "passkey"}
assert "stackchain_session=" in signed_in.headers["set-cookie"]
assert "correct horse battery staple" not in signed_in.text
@pytest.mark.anyio
async def test_passkey_fresh_authorization_is_exact_target_bound_and_single_use(
access_control, monkeypatch
):
class VerifiedRegistration:
credential_id = b"phone-credential"
credential_public_key = b"credential-public-key"
sign_count = 0
class VerifiedAuthentication:
new_sign_count = 1
monkeypatch.setattr(
main.passkeys, "verify_registration", lambda **_kwargs: VerifiedRegistration()
)
monkeypatch.setattr(
main.passkeys, "verify_authentication", lambda **_kwargs: VerifiedAuthentication()
)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
await client.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
)
headers = {
"Origin": "https://test",
"X-CSRF-Token": client.cookies["stackchain_csrf"],
}
enrollment_grant = await fresh_grant(client, "enroll_passkey", "current_device")
registration = await client.post(
"/api/v1/passkeys/registration/options",
headers={**headers, "X-Step-Up-Grant": enrollment_grant},
)
await client.post(
"/api/v1/passkeys/registration/verify",
json={"challenge": registration.json()["challenge"], "credential": {"id": "fake"}},
headers=headers,
)
options = await client.post(
"/api/v1/passkeys/authorization/options",
json={"action": "close_issue", "target": "stackchain/api#7"},
headers=headers,
)
ceremony = {
"challenge": options.json()["challenge"],
"credential": {"id": "cGhvbmUtY3JlZGVudGlhbA"},
"action": "close_issue",
"target": "stackchain/api#7",
}
authorized = await client.post(
"/api/v1/passkeys/authorization/verify", json=ceremony, headers=headers
)
replayed = await client.post(
"/api/v1/passkeys/authorization/verify", json=ceremony, headers=headers
)
wrong_target = await client.post(
"/api/v1/passkeys/authorization/verify",
json={**ceremony, "target": "stackchain/api#8"},
headers=headers,
)
assert options.status_code == 200
assert authorized.status_code == 201
assert authorized.json()["grant"]
assert authorized.json()["expires_in"] == 90
assert replayed.status_code == 409
assert wrong_target.status_code == 409
@pytest.mark.anyio
async def test_revoking_an_enrolled_device_also_revokes_its_passkey(
access_control, monkeypatch
):
class VerifiedRegistration:
credential_id = b"phone-credential"
credential_public_key = b"credential-public-key"
sign_count = 0
monkeypatch.setattr(
main.passkeys, "verify_registration", lambda **_kwargs: VerifiedRegistration()
)
transport = httpx.ASGITransport(app=main.app)
async with (
httpx.AsyncClient(transport=transport, base_url="https://test") as phone,
httpx.AsyncClient(transport=transport, base_url="https://test") as laptop,
):
await phone.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple", "device_label": "Phone"},
)
phone_headers = {
"Origin": "https://test",
"X-CSRF-Token": phone.cookies["stackchain_csrf"],
}
enrollment_grant = await fresh_grant(phone, "enroll_passkey", "current_device")
registration = await phone.post(
"/api/v1/passkeys/registration/options",
headers={**phone_headers, "X-Step-Up-Grant": enrollment_grant},
)
await phone.post(
"/api/v1/passkeys/registration/verify",
json={"challenge": registration.json()["challenge"], "credential": {"id": "fake"}},
headers=phone_headers,
)
await laptop.post(
"/api/v1/session",
json={"access_token": "correct horse battery staple", "device_label": "Laptop"},
)
devices = (await laptop.get("/api/v1/sessions")).json()["devices"]
phone_device = next(item for item in devices if item["device_label"] == "Phone")
grant = await fresh_grant(laptop, "revoke_device", phone_device["management_id"])
revoked = await laptop.delete(
f"/api/v1/sessions/{phone_device['management_id']}",
headers={
"Origin": "https://test",
"X-CSRF-Token": laptop.cookies["stackchain_csrf"],
"X-Step-Up-Grant": grant,
},
)
passkey_options = await laptop.post("/api/v1/passkeys/authentication/options")
assert revoked.status_code == 200
assert passkey_options.status_code == 404
@pytest.mark.anyio @pytest.mark.anyio
async def test_default_operator_mode_fails_closed_before_gitea_when_secrets_are_missing(monkeypatch): async def test_default_operator_mode_fails_closed_before_gitea_when_secrets_are_missing(monkeypatch):
monkeypatch.delenv("STACKCHAIN_DASHBOARD_AUTH_MODE", raising=False) monkeypatch.delenv("STACKCHAIN_DASHBOARD_AUTH_MODE", raising=False)

View File

@ -297,6 +297,77 @@ process.stdout.write(JSON.stringify(state));
assert result["replacedAfterDeletion"] is True assert result["replacedAfterDeletion"] is True
def test_enroll_passkey_bootstraps_with_fresh_authorization_and_web_authentication():
script = f"""
const createSessionBoundary=require({json.dumps(str(SESSION_JS))});
const requests=[];
const responses=[
{{status:428,payload:{{detail:{{code:'step_up_required',action:'enroll_passkey',target:'current_device'}}}}}},
{{status:201,payload:{{grant:'bootstrap-grant',expires_in:90}}}},
{{status:201,payload:{{challenge:'AQID',rp:{{id:'forge.example',name:'Stackchain Dashboard'}},user:{{id:'BAUG',name:'stackchain-operator',displayName:'Stackchain operator'}},pubKeyCredParams:[],excludeCredentials:[],authenticatorSelection:{{userVerification:'required'}}}}}},
{{status:201,payload:{{enrolled:true}}}},
];
const credential={{id:'new-passkey',type:'public-key',rawId:Uint8Array.from([1]).buffer,response:{{attestationObject:Uint8Array.from([2]).buffer,clientDataJSON:Uint8Array.from([3]).buffer,getTransports:()=>['internal']}}}};
const boundary=createSessionBoundary({{
cookie:()=> 'stackchain_csrf=proof',origin:'https://forge.example',base:'/dashboard/',
credentials:{{create:async options=>{{globalThis.creation=options.publicKey;return credential;}}}},
promptAuthorization:async()=> 'recovery-token',
fetchImpl:async(url,options={{}})=>{{requests.push({{url:String(url),body:options.body||null,grant:new Headers(options.headers||{{}}).get('X-Step-Up-Grant')}});const item=responses.shift();return new Response(JSON.stringify(item.payload),{{status:item.status,headers:{{'Content-Type':'application/json'}}}});}},
location:{{replace:()=>{{}}}},
}});
(async()=>{{const enrolled=await boundary.enrollPasskey();process.stdout.write(JSON.stringify({{enrolled,requests,creation:globalThis.creation}}));}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True
)
output = json.loads(result.stdout)
assert output["enrolled"] is True
assert output["creation"]["challenge"] == {"0": 1, "1": 2, "2": 3}
assert output["creation"]["user"]["id"] == {"0": 4, "1": 5, "2": 6}
assert output["requests"][2]["grant"] == "bootstrap-grant"
verification = json.loads(output["requests"][3]["body"])
assert verification["challenge"] == "AQID"
assert verification["credential"]["response"]["transports"] == ["internal"]
def test_high_impact_fetch_uses_passkey_before_access_token_fallback():
script = f"""
const createSessionBoundary = require({json.dumps(str(SESSION_JS))});
const requests=[];
const responses=[
{{status:428,payload:{{detail:{{code:'step_up_required',action:'close_issue',target:'stackchain/api#7'}}}}}},
{{status:200,payload:{{challenge:'AQID',rpId:'forge.example',userVerification:'required',allowCredentials:[{{type:'public-key',id:'BAUG'}}]}}}},
{{status:201,payload:{{grant:'passkey-grant',expires_in:90}}}},
{{status:200,payload:{{closed:true}}}},
];
let prompted=false;
const credential={{id:'credential',type:'public-key',rawId:Uint8Array.from([4,5,6]).buffer,response:{{authenticatorData:Uint8Array.from([7]).buffer,clientDataJSON:Uint8Array.from([8]).buffer,signature:Uint8Array.from([9]).buffer,userHandle:null}}}};
const boundary=createSessionBoundary({{
cookie:()=> 'stackchain_csrf=proof',origin:'https://forge.example',base:'/dashboard/',
credentials:{{get:async()=>credential}},
promptAuthorization:async()=>{{prompted=true;return 'must-not-be-requested';}},
fetchImpl:async(url,options={{}})=>{{requests.push({{url:String(url),body:options.body||null,grant:new Headers(options.headers||{{}}).get('X-Step-Up-Grant')}});const item=responses.shift();return new Response(JSON.stringify(item.payload),{{status:item.status,headers:{{'Content-Type':'application/json'}}}});}},
location:{{replace:()=>{{}}}},
}});
(async()=>{{const response=await boundary.fetch('/dashboard/api/v1/repos/stackchain/api/issues/7/close',{{method:'PATCH'}});process.stdout.write(JSON.stringify({{status:response.status,requests,prompted}}));}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(
["node", "-e", script], text=True, capture_output=True, check=True
)
output = json.loads(result.stdout)
assert output["status"] == 200
assert output["prompted"] is False
assert output["requests"][1]["url"].endswith("/passkeys/authorization/options")
assert json.loads(output["requests"][1]["body"]) == {
"action": "close_issue",
"target": "stackchain/api#7",
}
assert output["requests"][2]["url"].endswith("/passkeys/authorization/verify")
assert output["requests"][3]["grant"] == "passkey-grant"
def test_high_impact_fetch_prompts_once_and_retries_original_request_with_grant(): def test_high_impact_fetch_prompts_once_and_retries_original_request_with_grant():
result = run_session_scenario( result = run_session_scenario(
""" """

View File

@ -193,6 +193,67 @@ const controller = createLoginController({{
) )
def test_passkey_login_uses_web_authentication_without_sending_the_operator_token():
harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))});
const requests = [];
const state = {{ replaced: null }};
const credential = {{
id:'credential-id', type:'public-key', rawId:Uint8Array.from([1,2,3]).buffer,
response:{{
authenticatorData:Uint8Array.from([4]).buffer,
clientDataJSON:Uint8Array.from([5]).buffer,
signature:Uint8Array.from([6]).buffer,
userHandle:null,
}},
}};
const controller = createLoginController({{
form:{{reset:()=>{{}}}}, status:{{textContent:''}}, button:{{disabled:false}},
passkeyButton:{{disabled:false}},
credentials:{{get:async options=>{{ state.publicKey=options.publicKey; return credential; }}}},
fetchImpl:async (url, options={{}})=>{{
requests.push({{url, body:options.body ? JSON.parse(options.body) : null}});
if (url.endsWith('/options')) return new Response(JSON.stringify({{
challenge:'AQID', rpId:'forge.example', userVerification:'required',
allowCredentials:[{{type:'public-key',id:'BAUG'}}],
}}),{{status:200,headers:{{'Content-Type':'application/json'}}}});
return new Response('{{}}',{{status:200}});
}},
location:{{replace:value=>state.replaced=value}},
}});
(async()=>{{
await controller.signInWithPasskey('Timmys Pixel');
process.stdout.write(JSON.stringify({{requests,state}}));
}})().catch(error=>{{console.error(error);process.exit(1);}});
"""
result = subprocess.run(
["node", "-e", harness], text=True, capture_output=True, check=True
)
output = json.loads(result.stdout)
assert output["state"]["publicKey"]["challenge"] == {"0": 1, "1": 2, "2": 3}
assert output["state"]["replaced"] == "./"
assert output["requests"][1]["url"] == "api/v1/passkeys/authentication/verify"
assert output["requests"][1]["body"] == {
"challenge": "AQID",
"credential": {
"id": "credential-id",
"type": "public-key",
"rawId": "AQID",
"response": {
"authenticatorData": "BA",
"clientDataJSON": "BQ",
"signature": "Bg",
"userHandle": None,
},
},
"device_label": "Timmys Pixel",
"action": "sign_in",
"target": "dashboard",
}
assert "access_token" not in json.dumps(output)
def test_login_sends_a_bounded_device_label_with_the_access_token(): def test_login_sends_a_bounded_device_label_with_the_access_token():
harness = f""" harness = f"""
const createLoginController = require({json.dumps(str(LOGIN_JS))}); const createLoginController = require({json.dumps(str(LOGIN_JS))});