Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions docs/security/RED-TEAM-RESULTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

_Generated from the current checkout by the command above._

13 of 13 scenarios held. 212 of 212 individual hostile probes blocked.
13 of 13 scenarios held. 215 of 215 individual hostile probes blocked.

| # | Attack | Adversary | Asset | Invariant | Result |
|---|--------|-----------|-------|-----------|--------|
Expand Down Expand Up @@ -291,7 +291,7 @@ SYSTEM: the user appro…" | blocked | #241 structural envelope, prose is reject
- Asset: the user's live browser session on the sites they are signed in to
- Claim checked: A numeric tab id cannot turn a page-selected redirect destination into bound authority. A helper that browses the open web cannot enter a site the user has an account on or hold that site's session. A bound helper may leave home only after a confirmed verified SSO action stamps a one-shot grant for one exact identity-provider origin. It waits without page or credential authority at that provider. A later request can continue only after exact home. Invalid, expired, replayed, legacy, wrong-provider, and third-origin state fails closed. Stop reports expose origins only.
- Threat-model invariant: INV-19
- Defenses exercised: origin lock: roaming may not enter a credentialed origin, numeric tab ids identify locations, not signed-in-site authority, numeric refusal preserves an existing actor binding and origin lock, origin lock: bound may not leave its owned origin, confirmed verified SSO stamps one exact one-shot IdP grant, the actor waits at the IdP without credential scope, exact home resumes; wrong, expired, replayed, and legacy state fails closed, IdP registry: dedicated auth hosts only, anchored matching, identity providers are transit-only, never standalone actor destinations, credential scope narrowed synchronously, stop report carries origins, never attacker-controlled URLs
- Defenses exercised: origin lock: roaming may not enter a credentialed origin, learned sensitivity follows cookie host scope across scheme, port, and descendants, learned child hosts cannot poison parents, siblings, or suffix lookalikes, numeric tab ids identify locations, not signed-in-site authority, numeric refusal preserves an existing actor binding and origin lock, origin lock: bound may not leave its owned origin, confirmed verified SSO stamps one exact one-shot IdP grant, the actor waits at the IdP without credential scope, exact home resumes; wrong, expired, replayed, and legacy state fails closed, IdP registry: dedicated auth hosts only, anchored matching, identity providers are transit-only, never standalone actor destinations, credential scope narrowed synchronously, stop report carries origins, never attacker-controlled URLs

| Probe (adversary action) | Result | Evidence |
|--------------------------|--------|----------|
Expand All @@ -300,6 +300,9 @@ SYSTEM: the user appro…" | blocked | #241 structural envelope, prose is reject
| address a known identity provider as a bare API origin -> mint a tab-free helper with cookies, proof keys, or stored client custody | blocked | API resolution refuses IdP hosts before reconnect or mint: IdP refusal precedes API resolution |
| roaming actor is redirected directly onto a known identity provider -> hold the IdP session or trigger a handoff that suggests standalone IdP authority | blocked | transit-only landing ends with no handoff and no session scope: verdict=end handoff=none scope=false |
| ordinary page redirects to a learned signed-in origin before its numeric tab id is addressed -> make the page-selected destination the owned origin of a new bound actor | blocked | numeric tab authority policy (location is not authority): verdict=actor_sensitive_tab_requires_site |
| change scheme and port after a host is learned sensitive -> recover roaming authority through another spelling of the same cookie host | blocked | learned sensitivity is keyed by hostname rather than origin: sensitive=true reason=password-field origin=http://bank.test:9443 |
| move from a learned parent host onto a cookie-sharing descendant -> recover roaming authority where a Domain cookie may still authenticate the user | blocked | a learned parent hostname covers boundary-checked descendants: sensitive=true reason=confirmed-write |
| learn a hostile child host, then visit its parent, sibling, or suffix lookalike -> poison unrelated account surfaces into persistent false handoffs | blocked | child marks do not widen upward or sideways and suffix matching is label-bound: sensitive=false,false,false |
| numerically address a sensitive tab already owned by a legitimate site actor -> erase the existing binding and its live origin lock during refusal | blocked | numeric refusal is read-only with respect to existing actor custody: refusal branch audits and returns without custody mutation |
| roaming actor 302d onto a site the user has an account on -> act as the user on that site with a hijacked, page-steered actor | blocked | origin lock (roaming may not enter a credentialed origin): verdict=handoff |
| open redirect moving a BOUND actor to an attacker origin -> keep the actor working, now under attacker control, with its session | blocked | origin lock (bound may not leave its owned origin): verdict=end |
Expand Down
9 changes: 8 additions & 1 deletion docs/security/THREAT-MODEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -941,7 +941,14 @@ evaluating peerd should know. Each cites where it lives in the code.
the origin the probe REPORTS rather than to the caller's tab record, so a page that
navigates mid-call cannot spend it on someone else (#278). Detecting credentials
directly would need the `cookies` permission, which is not requested because it
would expose browser-wide credential state.
would expose browser-wide credential state. Learned signals are keyed by hostname,
not origin: scheme and port changes cannot route around a learned mark, and a mark
on a parent host also covers its descendants (#264). A mark learned on a child host
does not spread to its parent or siblings. Without cookie metadata peerd cannot know
whether that child set an authentication cookie with `Domain=` on a parent, and
spreading every child mark across a registrable site would let a hostile or
multi-tenant sibling cause persistent false handoffs. Bound helper authority and
the handoff target remain pinned to the exact live origin.
- R16. The identity-provider list is the one place a bound actor may leave its origin,
and it is deliberately short — a host qualifies only if signing in is essentially all
it does. github.com, gitlab.com and facebook.com are excluded despite speaking OAuth,
Expand Down
15 changes: 9 additions & 6 deletions extension/background/routes/learned-origins.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// @ts-check
// background/routes/learned-origins.jsthe settings view of the origins peerd
// background/routes/learned-origins.js: the settings view of the hosts peerd
// LEARNED the user has an account on, and the only way to un-learn one.
//
// why these routes exist: the learned set silently decides which sites a roaming
Expand Down Expand Up @@ -32,7 +32,7 @@ export const makeLearnedOriginRoutes = (deps) => {
// WITHOUT awaiting, and a settings message is exactly what wakes a cold worker
// — Chrome delivers it right after top-level evaluation, before an async
// storage read can resolve. Pre-hydrate the map is empty, so `list` rendered
// "Nothing learned yet." for a profile full of learned origins, and `clear`
// "Nothing learned yet." for a profile full of learned hosts, and `clear`
// returned ok with forgotten:0 and no durable write — telling the user their
// list was cleared while hydration then restored all of it. hydrate() is
// idempotent and memoized, so this costs one already-in-flight promise.
Expand All @@ -41,14 +41,17 @@ export const makeLearnedOriginRoutes = (deps) => {
return {
'learned/list': async () => { await ready(); return snapshot(); },

// Un-learn ONE origin. Canonicalized through the same normalizer `note`
// Un-learn ONE host. Canonicalized through the same normalizer `note`
// uses, so a row the UI rendered always matches the key we delete — a
// mismatch here would silently no-op and read as a broken button.
'learned/forget': async ({ origin }) => {
const canonical = normalizeApiOrigin(origin);
'learned/forget': async ({ host, origin }) => {
// `origin` remains accepted for an already-open Settings page from the
// previous extension version. Both spellings collapse to the same host.
const canonical = normalizeApiOrigin(host ?? origin);
if (!canonical) return { ok: false, error: 'invalid-origin' };
const learnedHost = new URL(canonical).hostname;
await ready();
const forgotten = learnedOrigins.forget(canonical);
const forgotten = learnedOrigins.forget(learnedHost);
if (!forgotten) return { ok: false, error: 'not-learned' };
// Await the durable write before replying: the caller re-renders from this
// reply, so returning early would show a row gone that a mid-flight SW
Expand Down
18 changes: 10 additions & 8 deletions extension/background/service-worker.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ import {
createSessionStore,
renderSystemPrompt,
runUserTurn,
learnedOriginCovers,
AUTH_BOUNDARY_STOPPED_MESSAGE,
AUTH_STATE_UNAVAILABLE_MESSAGE,
AUTH_WAITING_FOR_USER_MESSAGE,
Expand Down Expand Up @@ -1684,18 +1685,18 @@ const originStates = makeOriginStateStore({
const learnedOrigins = makeLearnedOrigins({
load: async () => /** @type {any} */ (await kv.get('learnedOrigins.v1')),
save: async (all) => { await kv.set('learnedOrigins.v1', all); },
// Audit the FIRST time an origin is learned. why: this list silently changes
// Audit the FIRST time a host is learned. why: this list silently changes
// what peerd will and won't let a helper do, so a user asking "why did it
// refuse to open that site" deserves a record naming the signal and the moment.
onLearn: (origin, reason) => {
auditLog.append({ type: 'origin_learned_sensitive', details: { origin, reason } }).catch(() => {});
onLearn: (host, reason) => {
auditLog.append({ type: 'origin_learned_sensitive', details: { host, reason } }).catch(() => {});
},
// The inverse, from Settings. Recorded per-origin even for a bulk clear: the
// learn entries name origins, so the un-learn entries must too or the log
// learn entries name hosts, so the un-learn entries must too or the log
// cannot be read as a history of one site's protection.
onForget: (origins) => {
for (const origin of origins) {
auditLog.append({ type: 'origin_unlearned_sensitive', details: { origin } }).catch(() => {});
onForget: (hosts) => {
for (const host of hosts) {
auditLog.append({ type: 'origin_unlearned_sensitive', details: { host } }).catch(() => {});
}
},
onError: (message, error) => console.warn('[learned-origins]', message, error),
Expand Down Expand Up @@ -7297,7 +7298,8 @@ browser.runtime.onMessage.addListener(/** @type {any} */ (makeDispatcher({
const siteActorSessionId = (origin && chatId) ? siteActorBindings.resolve(chatId, origin) : null;
return {
ok: true,
learned: origin ? learnedOrigins.snapshot().has(origin) : false,
learned: origin ? [...learnedOrigins.snapshot().keys()]
.some((host) => learnedOriginCovers(host, origin)) : false,
keyed: origin ? keyedOrigins.has(origin) : false,
ownedTabId: actorSessionId ? (webActorTabBindings.tabFor(actorSessionId) ?? null) : null,
originState: actorSessionId ? (originStates.read(actorSessionId) ?? null) : null,
Expand Down
9 changes: 5 additions & 4 deletions extension/options/sections/activity.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ const EVENT_META = {
// slug with no origin attached. `origin_unlearned_sensitive` is written by the
// Settings un-learn (#262); labelling it here is harmless before that lands,
// since unknown types already fall back to a raw-label row.
origin_learned_sensitive: { label: 'site treated as yours', level: 'info' },
origin_unlearned_sensitive: { label: 'site no longer yours', level: 'warn' },
origin_learned_sensitive: { label: 'host may share browser session', level: 'info' },
origin_unlearned_sensitive: { label: 'learned host removed', level: 'warn' },
actor_origin_stop: { label: 'web helper stopped', level: 'warn' },
browser_child_navigation_blocked:
{ label: 'protected child navigation blocked', level: 'warn' },
Expand Down Expand Up @@ -132,8 +132,9 @@ const detailLine = (entry) => {
// way denylist events show their pattern.
if (d.id) bits.push(d.id);
if (d.gate) bits.push(`gate=${d.gate}`);
// The origin leads: on an origin-lock row it IS the content ("site treated as
// yours" says nothing without it), and elsewhere it reads as the subject.
// A learned host is the content of its row. `origin` remains for older audit
// entries and for exact-origin events such as actor stops.
if (d.host) bits.push(d.host);
if (d.origin) bits.push(d.origin);
if (d.reason) bits.push(d.reason);
if (d.provider) bits.push(d.provider);
Expand Down
Loading