RFC 0028: Channel-agnostic sender authentication strength - #51
RFC 0028: Channel-agnostic sender authentication strength#51omarshahine wants to merge 4 commits into
Conversation
|
Codex review: needs real behavior proof before merge. Reviewed August 15, 2026, 7:08 PM ET / 23:08 UTC. ClawSweeper reviewWhat this changesThis PR adds RFC 0028, defining ordered sender-identifier authentication strengths for channel ingress authorization while preserving the existing default. Merge readinessKeep open: the RFC is marked accepted and linked to implementation, but the lifecycle-required maintainer-discussion record and authorized merge path remain unresolved. Priority: P2 Review scores
Verification
How this fits togetherThe RFC repository records accepted OpenClaw architecture decisions. This document guides OpenClaw channel-ingress implementation, where sender identifiers are evaluated before authorization. flowchart LR
A[Channel transport] --> B[Sender identifier]
B --> C[Authentication strength]
C --> D[Ingress authorization]
D --> E[Agent access]
F[RFC 0028] --> C
Decision needed
Why: The repository’s approval rules and maintainer-discussion process cannot be satisfied or reconfigured by an automated document review. Before merge
Findings
Agent review detailsSecurityNone. Review metrics
Merge-risk optionsMaintainer options:
Technical reviewBest possible solution: Record the required maintainer discussion and use the authorized RFC approver path to merge the accepted design; retain the linked OpenClaw implementation work as the delivery vehicle. Do we have a high-confidence way to reproduce the issue? Not applicable: this PR changes an RFC document; runtime behavior belongs to the linked implementation work. Is this the best way to solve the issue? Unclear: the design is a focused path, but merging it should wait for the repository-required maintainer-discussion and authorized acceptance record. Full review comments:
Overall correctness: patch is incorrect AGENTS.md: not found in the target repository. Codex review notes: model internal, reasoning high; reviewed against af708f0ddb6f. LabelsLabel justifications:
EvidenceWhat I checked:
Likely related people:
Rank-up movesOptional improvements that raise the rating; they are not merge blockers.
Rating scale
Overall follows the weaker of proof and patch quality. Workflow
HistoryReview history (29 earlier review cycles; latest 8 shown)
|
|
Pushed an amendment that came out of trying to write the email consumer end to end. It found a real hole in the first draft. Per-message strength could not reach the kernel. Strength was field-declared and resolved through a value-only predicate ( Two changes:
Also added a Worked Example: Email Channel section, illustrative only, nothing in core. It exists to prove the primitive is sufficient, on the theory that if the example cannot be written cleanly the primitive is wrong. It covers the three-layer split (per-provider adapter, shared verdict normalization, shared strength mapping, none of it in the SDK), the provenance rules that stop a forged Two new unresolved questions, one of which argues against part of my own amendment: no bundled channel needs the per-message map today, so by this RFC's own speculative-surface standard it is fair to ask whether it belongs in v1 or whether email should wait for a later change. |
|
Grounded the worked example in a real implementation, which improved the design in two ways and turned up two live bypasses worth recording here as motivation. The example is now an Apple Mail channel bound to the agent's own mailbox, rather than a generic email sketch. That removes any question of whose inbox is being authorized, and it is the harder of the two email cases, because Replaced my Two provenance rules are now normative, because a production implementation was missing both, in two independent reimplementations, and each omission was independently sufficient for a full bypass:
The end-to-end outcomes table now includes both as blocked rows. This is also the strongest evidence I have for the RFC's central claim: the derivation is where the security lives, every channel that reinvents it will get it wrong, and a shared vocabulary plus a shared mapping is how that stops. Added an unresolved question this raises against my own design: core stays ignorant of how strength was derived, but the failure above suggests core should at least document the provenance obligation next to the type, or ship a conformance checklist for channel authors, even though it cannot enforce it. |
* feat(openclaw): map mail auth results onto per-identifier strength First code for the Apple Mail channel, and the shim boundary for OpenClaw RFC 0027 (openclaw/rfcs#51). Core does not ship IdentifierAuthentication yet, so the type is declared locally and the gate is applied plugin-side; when the kernel lands the primitive this module keeps its logic and only its consumers change. Scores each identifier a message yields separately, which is the point of the RFC being per-identifier rather than one per-message trust score: - display name is never above mutable - domain reaches verified on a DKIM pass or an aligned SPF pass - address reaches verified only when auth-check's own verdict does, which requires a signature from that sender's configured expectedDkimDomains - nothing is promoted when provenance was never established, because the only evidence available then is sender-writable Tests use node:test with Node's native type stripping, so no test-runner dependency is added to the plugin. Covers the forged-header case, unaligned SPF, an unexpected signer (domain verified, address not), unenrolled senders, and the min(entry, subject) composition rule. Verified against the live mailbox: both sample messages produce {address: verified, domain: verified, displayName: mutable}, matching the unit expectations. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WxM7Yy33nFJM71NCaBqhyz * fix(openclaw): require DKIM alignment before verifying the sender domain domain was promoted on any passing DKIM signature, including one whose header.d is unrelated to the From domain. That authenticates the signer, not the sender, so any domain able to sign anything could vouch for any sender. Same defect alignment fixes for SPF, which this module already required. Adds the alignment check plus a domainsAlign helper mirroring the one in MailCLI.swift, so the two sides cannot drift in interpretation. A test asserted the old behavior and was wrong; it now expects asserted for an unaligned signature. Added coverage for subdomain alignment and for the case that actually motivates per-identifier scoring: an aligned but unexpected signer, where the domain claim stands and the address claim does not. Found by Greptile on #80. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WxM7Yy33nFJM71NCaBqhyz --------- Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
omarshahine
left a comment
There was a problem hiding this comment.
Read this end to end against the shipped apple-pim POC (#78-#85). The three-level scale, min(entry, subject), and PR 4 as the explicit go/no-go are all stronger than where I'd have landed, and cutting cryptographic because no channel documents its trust chain is the right asymmetry (adding a level later is cheaper than removing one).
One substantive finding, inline: the worked-example code promotes sender domain to verified on an unaligned DKIM pass. That is the defect class the RFC exists to prevent, and the implementation this RFC cites as proof already gets it right - so the fix is to bring the sample up to strength.ts, not the other way around.
Worth noting for the Motivation section's own argument: this is the third instance of the same defect class in this line of work, after the two authserv-id/SPF bypasses fixed in apple-pim#78. "Deriving strength is subtle and easy to get wrong, which is why it belongs in a shared primitive" is a claim the RFC's own sample just demonstrated.
Separate, smaller, and outside this file (apple-pim, not blocking the RFC): MailCLI.swift emits verdict: "suspicious" for two different failures - no Authentication-Results from a trusted authserv-id (a provenance failure, ~L2288) and checks that ran and failed (~L2376). strength.ts gates provenance on verdict !== "unknown", so the provenance failure passes that gate. It fails closed today only because that Swift branch emits an empty checks object; populate checks there later and it silently promotes. Either emit unknown for the no-trusted-AR branch, or gate provenanceEstablished() on an explicit provenance field rather than inferring it from the verdict.
| | Identifier | Strength | Justification | | ||
| | --- | --- | --- | | ||
| | display name | `mutable` | Sender-chosen, always, exactly like every other channel's display name | | ||
| | sender domain | `verified` when a trusted-boundary result shows DKIM pass, or aligned SPF pass | The domain-level claim the transport actually proved | |
There was a problem hiding this comment.
DKIM pass, or aligned SPF pass - the alignment qualifier needs to apply to both.
Suggest: aligned DKIM pass, or aligned SPF pass.
Same omission in provenance rule 4 (L419, An unaligned pass proves nothing), which is written for SPF only and should cover DKIM too. See the inline note on the sample below for the exploit.
| const dkimOk = verdict.dkim === "pass"; | ||
| const domain = dkimOk || spfOk ? "verified" : "asserted"; |
There was a problem hiding this comment.
This promotes the domain identifier on a bare DKIM pass with no alignment between header.d and the From domain.
Attacker owns evil.com and signs validly for it, with From: omar@shahine.com. The boundary stamps a genuine dkim=pass header.d=evil.com, so source is trusted-authserv and provenance rules 1-3 all pass. The sender-domain identifier normalizes from the From address, so shahine.com lands at verified on a signature from a domain with no relationship to it. An operator running allowFrom: ["shahine.com"] with minIdentifierAuthentication: "verified" gets driven - which is precisely the Motivation incident, one level up.
strength.ts in the POC already does this correctly, with the reasoning spelled out:
// A DKIM pass authenticates the *signing* domain (header.d), which is not automatically
// the sender's domain. Promoting on an unaligned signature would let any domain that can
// sign anything vouch for this sender, the same defect alignment fixes for SPF.
const dkimPassed = result.checks?.dkim?.result === "pass";
const dkimAligned =
dkimPassed && domainsAlign(result.checks?.dkim?.signingDomain, senderDomain(result.sender));
const domain = dkimAligned || spfPassedAligned ? "verified" : "asserted";Suggested sample fix:
const dkimOk = verdict.dkim === "pass";
const dkimAligned =
dkimOk && domainsAlign(verdict.dkimSigningDomain, senderDomain(sender.address));
const domain = dkimAligned || spfOk ? "verified" : "asserted";Note signerExpected on L533 is unaffected - the expectedDkimDomains check is a stronger operator assertion and is exactly what makes the Fastmail relay case (header.d=messagingengine.com for a shahine.com address) work at the address level. This finding only concerns the domain level, where there is no such assertion to lean on.
The acceptance-scenario row at L616 (asserted on address, verified on domain) also reads as a pass under the current sample when the signing domain is unrelated to the From domain, and should be re-checked after the fix.
… kernel Implements PR 2 of openclaw/rfcs#51. Replaces the two-level mutable-identifier gate with an ordered `IdentifierAuthentication` scale (verified > asserted > unverified > mutable) plus a per-message subject strength map, so a channel can say how strongly an identifier names its holder AND how strongly one message proved the sender holds it. The gate takes min(entry, subject). Behavior is unchanged for every channel on main: an entry no channel described resolves to `asserted` (clears the shipped minimum), `dangerous: true` resolves to `mutable`, and `mutableIdentifierMatching` maps onto the same scale. No new SDK-exported names (the surface budget enforces this); the types stay internal to the kernel until the RFC's SDK-surface PR. `subjectAuthentication` is optional on the public `ChannelIngressState` so external constructors are not broken. Test fixtures use neutral example addresses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WxM7Yy33nFJM71NCaBqhyz
… kernel Implements PR 2 of openclaw/rfcs#51. Replaces the two-level mutable-identifier gate with an ordered `IdentifierAuthentication` scale (verified > asserted > unverified > mutable) plus a per-message subject strength map, so a channel can say how strongly an identifier names its holder AND how strongly one message proved the sender holds it. The gate takes min(entry, subject). Behavior is unchanged for every channel on main: an entry no channel described resolves to `asserted` (clears the shipped minimum), `dangerous: true` resolves to `mutable`, and `mutableIdentifierMatching` maps onto the same scale. No new SDK-exported names (the surface budget enforces this); the types stay internal to the kernel until the RFC's SDK-surface PR. `subjectAuthentication` is optional on the public `ChannelIngressState` so external constructors are not broken. Test fixtures use neutral example addresses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WxM7Yy33nFJM71NCaBqhyz
6c450c0 to
1238682
Compare
… kernel Implements PR 2 of openclaw/rfcs#51. Replaces the two-level mutable-identifier gate with an ordered `IdentifierAuthentication` scale (verified > asserted > unverified > mutable) plus a per-message subject strength map, so a channel can say how strongly an identifier names its holder AND how strongly one message proved the sender holds it. The gate takes min(entry, subject). Behavior is unchanged for every channel on main: an undescribed entry resolves to `asserted` (clears the shipped minimum), `dangerous: true` resolves to `mutable`, and `mutableIdentifierMatching` maps onto the same scale. No new SDK-exported names (the surface budget enforces this); the types stay internal until the RFC's SDK-surface PR. `subjectAuthentication` is optional on the public `ChannelIngressState` so external constructors are not broken. Test fixtures use neutral example addresses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WxM7Yy33nFJM71NCaBqhyz
Draft RFC for a graded IdentifierAuthentication scale (verified > asserted > unverified > mutable) gating channel ingress, with per-kind min(entry, subject) matching. PR 2 of the stack in openclaw/openclaw#116281. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
f6ccdf6 to
405c422
Compare
|
@clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. Re-review progress:
|
…ion PRs Explicitly scopes out a new operator-facing config surface in v1 (default minimum stays asserted; requiring verified is opt-in in code), and links the kernel (#116281) and SDK (#117121) PRs from the stack table. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Supersede the closed implementation PRs with #123782 (kernel, exact match provenance) and #123793 (SDK + folded-in channel migration), resolve the per-kind collapse question the refresh eliminated, and add the not-a-second-identity-system non-goal from the execution-identity harmonization. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Maintainer decision: accepted. I accept RFC 0027's four-level contract as proposed:
Implementation: land #123782 (kernel, exact match provenance) then #123793 (SDK contract + the six bundled-channel migrations). The kernel must not ship alone. |
0027 was taken by the enterprise-platform RFC merged 2026-08-01; this RFC becomes 0028. Status accepted per the maintainer decision on PR #51; implementation tracked in openclaw/openclaw#124218. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
@joshavant question on process: this RFC is accepted (decision recorded above, frontmatter updated, implementation tracked in openclaw/openclaw#124218), but merging is blocked by the |
Summary
Generalizes the existing boolean
dangerousidentifier flag in the ingress kernel into a small ordered authentication strength on the same field, evaluated by the same gate, so an operator can require that a sender's identifier was actually authenticated by the transport before it authorizes anything.Today "not mutable" conflates "the transport authenticated this identifier" with "the transport handed us a string." A Signal ACI, a Discord snowflake, and an unauthenticated SMTP
From:header are all equally non-dangerous.docs/security/THREAT-MODEL-ATLAS.mdalready tracks this as T-ACCESS-002 (residual risk Medium) with recommendation R-008.Shape
Three levels,
verified > asserted > mutable.mutableis today'sdangerous: true. The default policy minimum isasserted, which is exactly today's default behavior, so no current deployment changes. Requiringverifiedis new and opt-in.Deliberately not in scope: DKIM/SPF/DMARC parsing in core, capability tiers, a second gate,
toolsBySenderkeying, and any new operator-facing config surface in v1.Status
status: draft, per the repo lifecycle.issueleft blank until accepted. Amaintainer-discussionthread still needs to be created.Notes for reviewers
Two things I want pushback on specifically:
verifiedintocryptographicandauthenticated. I dropped it because the kernel offers one admission threshold and no channel currently documents where it sits, so encoding a cross-channel ordering would assert something the codebase cannot substantiate. Rationale section covers this.dangerous: truetoday cannot each state a defensible strength for their identifiers, the abstraction is wrong and the stack should stop there.The RFC also surfaces an adjacent gap it does not fix: plugin channels only reach the DM-policy security audit if they implement the optional
plugin.security.resolveDmPolicyhook (src/security/audit-channel.ts:333-346), so a plugin channel can silently skip that audit. That likely deserves its own issue.