Skip to content

Interoperability with pam_authnft — per-session kernel-enforced network policy from OIDC claims#14

Merged
prodnull merged 4 commits into
prodnull:mainfrom
Strykar:feat/claims-keyring
Apr 19, 2026
Merged

Interoperability with pam_authnft — per-session kernel-enforced network policy from OIDC claims#14
prodnull merged 4 commits into
prodnull:mainfrom
Strykar:feat/claims-keyring

Conversation

@Strykar
Copy link
Copy Markdown
Contributor

@Strykar Strykar commented Apr 18, 2026

Summary

Export authenticated session metadata through the Linux kernel keyring so
that a downstream PAM session module (pam_authnft) can enforce per-session
network policy at the kernel level. Each SSH login gets nftables rules
scoped to its cgroup, tagged with the OIDC identity that authorized it,
and correlated in journald with the prmana auth event. A containerized
interop test builds both projects from source, runs a real PAM session with
systemd and nftables, and produces an audit report with packet-level
enforcement proof.

No new dependencies. Non-fatal on failure. 31 lines added, 3 removed.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Refactoring (no functional changes)
  • CI/CD changes

Related Issues

N/A — new interoperability feature.

Changes Made

  • Extended the kernel-keyring payload to include user, uid, and
    optionally acr (authentication strength) and dpop (DPoP JWK
    thumbprint) alongside the existing jti, exp, iss, sid fields.
  • Payload is versioned (v=1; prefix) with percent-encoded delimiters
    to prevent injection via claim values.
  • Key is published to the process keyring first with locked-down
    permissions (SETPERM + SET_TIMEOUT), then linked into the session
    keyring — closing the TOCTOU window between add_key and SETPERM.
  • Detects shared @us session keyring and refuses to publish (non-fatal).
    Operator must run pam_keyinit.so force revoke before prmana.
  • #[cfg(target_os = "linux")] on keyring module and call site.

Problem

prmana authenticates. But once the session is open, there is no
kernel-level enforcement tying that session's network traffic to the
identity that was just verified. An authenticated user's packets are
indistinguishable from any other process on the host. Session isolation
is left to application-layer controls.

What this enables

When paired with pam_authnft
(GPL-2.0, independent project, no shared codebase):

  • Per-session microsegmentation. Each login gets its own nftables
    chain and three per-session sets bound to its cgroupv2 scope. Firewall
    rules match on the session's cgroup + source IP. Other sessions on the
    same host are unaffected — per-session isolation is verified by
    integration test 10.13.

  • Policy derived from identity claims. The per-user nftables fragment
    can encode allowed ports, denied networks, compliance zones — driven
    by what the IdP puts in the token. The kernel enforces it.

  • Auditable chain of custody. The nftables set element carries
    prmana's claims as its comment. A single journalctl query on the
    shared correlation token returns both the auth event and the session
    event. No log correlation infrastructure required.

  • Zero daemons, zero sidecars. The bridge is a kernel keyring entry
    and a PAM environment variable. Cleanup is automatic — the kernel
    tears down the keyring when the session ends, pam_authnft deletes the
    per-session chain, sets, and jump rule on close.

Mechanism

After successful authentication, prmana publishes session claims to the
kernel's process keyring via add_key(2), applies UID-only ACL and
token-aligned TTL while the key is still in @p, then links into the
session keyring and exposes the serial through pam_putenv. pam_authnft
reads the serial, fetches the payload via keyctl(2), sanitizes it, and
embeds it in the nftables element. No IPC, no files, no sockets — the
kernel manages the lifecycle.

The sequence:

  1. add_key("user", "prmana_<sid>", payload, PROCESS_KEYRING) — publish claims to @p
  2. keyctl(SET_TIMEOUT) — align lifetime with token expiry
  3. keyctl(SETPERM) — lock to POSSESSOR-only view/read/search
  4. keyctl(LINK, serial, SESSION_KEYRING) — move to session keyring
  5. keyctl(UNLINK, serial, PROCESS_KEYRING) — remove from @p
  6. pam_putenv("PRMANA_KEY=<serial>") — expose to downstream modules

What gets shared at authentication time

Field Source Example
v Payload version 1
jti JWT ID from the token f47ac10b
exp Token expiry (Unix timestamp) 1745053200
iss OIDC issuer idp.example.com
sid prmana session ID demo
user Mapped Unix username alice
uid NSS-resolved UID 1001
acr Authentication strength (if present) urn:example:mfa
dpop DPoP JWK thumbprint (if present) SHA256:x4Ei9K2p

Values containing ;, =, or % are percent-encoded. Additional fields
can be added by pushing more pairs — the format is extensible and
pam_authnft treats the payload as opaque.

Note: nftables limits element comments to 128 characters. The full
claim set is always available in the journal (AUTHNFT_CLAIMS_TAG) and
the session JSON at /run/authnft/sessions/<scope>.json.

What the operator sees

Live nftables state during a session (from the containerized interop test):

table inet authnft {
    set session_interop_test_180_v4 {
        typeof socket cgroupv2 level 0 . ip saddr
        flags timeout
        elements = { "authnft.slice/authnft-interop-test-180.scope" . 10.42.7.131 timeout 1d expires 23h59m59s comment "interop-test (PID:180) [v=1;jti=f47ac10b;exp=1745053200;iss=idp.example.com;sid=demo;dept=sre;pool=east]" }
    }

    chain filter {
        type filter hook input priority filter - 1; policy accept;
        ct state established,related accept
        jump session_interop_test_180
    }

    chain session_interop_test_180 {
        socket cgroupv2 level 2 . ip saddr @session_interop_test_180_v4 tcp dport { 22, 443, 8443, 9090 } accept comment "prmana: allowed by token claim"
        socket cgroupv2 level 2 . ip saddr @session_interop_test_180_v4 ip daddr 10.0.0.0/8 drop comment "prmana: blocked by token claim"
    }
}

Deployment

session  optional  pam_keyinit.so force revoke
session  optional  pam_prmana.so [existing args]
session  required  pam_authnft.so claims_env=PRMANA_KEY

Two independent projects. Apache-2.0 + GPL-2.0, composed through PAM and
the kernel keyring. No license entanglement, no shared trust boundary.

Security Checklist

  • I have not introduced any new dependencies, OR new dependencies have been reviewed for security
  • I have not modified cryptographic code, OR changes have been reviewed by a security-aware maintainer
  • I have not modified authentication/authorization logic, OR changes follow existing security patterns
  • I have not introduced any hardcoded secrets, tokens, or credentials
  • I have not disabled or weakened any security controls
  • I have added/updated tests for security-critical code paths
  • I have reviewed my changes for OWASP Top 10 vulnerabilities

Testing

  • Unit tests pass (cargo test)
  • Integration tests pass (make test-integration)
  • Manual testing performed (describe below)

Test Environment

  • OS: Fedora (container), Arch Linux 6.18.22-1-lts (host)
  • IdP: Simulated (pam_keyring_demo.so in container test)
  • Rust version: 1.88+

Manual Test Steps

A containerized end-to-end interop test is available in the pam_authnft
project. Requires only podman — no host mutation:

git clone https://github.com/identd-ng/pam_authnft
cd pam_authnft && git checkout feat/prmana-interop-demo
make interop-container

Builds both modules inside a Fedora container with systemd, runs a real
PAM session, and produces an audit report covering: kernel keyring state,
live nftables table, three packet tests (port 443 accepted, port 80 to
10.0.0.0/8 dropped, port 9090 accepted) with tcpdump, correlated journal
events, and session metadata.

Results at /tmp/authnft-interop-result/audit_report.txt.

Sample report: https://gist.github.com/Strykar/08b2ae0b9113a0974219a48cd00ca881

Documentation

  • I have updated relevant documentation
  • I have added/updated code comments where necessary
  • CHANGELOG.md has been updated (for user-facing changes)

Reviewer Notes

This patch addresses all five blockers from the initial review:

  1. AUTHNFT_CORRELATION removed — consumer-agnostic; downstream
    can build prmana-<sid> from PRMANA_SESSION_ID.
  2. @us detection — refuses to publish if session keyring resolves
    to the shared user-session keyring.
  3. TOCTOU closed — key created in @p, locked down, then linked
    to session keyring.
  4. Versioned payloadv=1; prefix, percent-encoded delimiters.
  5. Linux-only#[cfg(target_os = "linux")] on module and call site.

The keyring payload format is extensible — additional token claims can be
included by pushing more key=value pairs. pam_authnft sanitizes the
payload to [A-Za-z0-9_=,.:;/-] before embedding it in nftables
comments, so injection is not possible.

License clarification needed: I noticed that CONTRIBUTING.md states
contributions are licensed under CC BY-NC-SA 4.0, while the LICENSE file
and Cargo.toml specify Apache-2.0, and the PR template references
"Apache-2.0 OR MIT." I have left the contributing/license checkboxes
unchecked until the intended license is confirmed. Happy to agree once
clarified.

Commit signing: The contributing guidelines require Gitsign (Sigstore
keyless signing). These commits have DCO sign-off but are not yet
Gitsign-signed. I can re-sign with Gitsign if that is a hard requirement
for merge.


By submitting this PR, I confirm that:

  • I have read and agree to the Contributing Guidelines
  • My contribution is made under the project's dual license (Apache-2.0 OR MIT)
  • I have signed off my commits (DCO)

@Strykar Strykar force-pushed the feat/claims-keyring branch from e1fda0f to 266dd0e Compare April 18, 2026 16:31
@Strykar Strykar marked this pull request as ready for review April 18, 2026 16:32
@Strykar Strykar force-pushed the feat/claims-keyring branch 2 times, most recently from 6922054 to 70616ca Compare April 18, 2026 17:45
@prodnull
Copy link
Copy Markdown
Owner

@Strykar, thanks for this. The direction is right. pam_putenv across the sshd privsep boundary is exactly the fragile channel the kernel keyring replaces cleanly. I've put this through a full threat model before landing on requirements, and want to share where we are before you put more cycles in.

Scope

We'll take the generic primitive: keyring entry plus PRMANA_KEY export. We won't take AUTHNFT_CORRELATION. That export couples pam-prmana to a specific downstream, and any consumer can build prmana-<sid> themselves from the sid field in the payload or from the existing PRMANA_SESSION_ID env var. Keeping prmana consumer-agnostic matters for other integrations on the roadmap.

What needs to change before we can merge

  1. Session-keyring anchor. add_key(… KEY_SPEC_SESSION_KEYRING) resolves to the shared per-UID user-session keyring (@us) on any stack that hasn't run pam_keyinit.so force revoke first. See user-session-keyring(7). That silently widens the blast radius to every concurrent login for the same user. Prmana needs to guarantee isolation itself. Either call keyctl(KEYCTL_JOIN_SESSION_KEYRING, NULL) when @us is the target, or detect the @us case and refuse to publish (non-fatal, consistent with the rest of the module). Shipping a security primitive that depends on an operator remembering a PAM config line isn't a guarantee.

  2. No race between add_key and the final ACL. The current add_keySET_TIMEOUTSETPERM order leaves the key with default perms (KEY_USR_ALL | KEY_POS_ALL) until SETPERM lands. A same-UID process that wins the race can KEYCTL_UPDATE the payload to forge claims, KEYCTL_LINK into a keyring it controls for persistence past our TTL, or KEYCTL_SETPERM it wider. Two reasons this matters: prmana will also run in sudo step-up context in an upcoming phase where auth executes as the user rather than root, and a parallel SSH session for the same user is a valid attacker too. The fix is to publish into a keyring you exclusively possess (process keyring @p), apply SETPERM and SET_TIMEOUT there, then KEYCTL_LINK into the session keyring. On SETPERM failure, KEYCTL_UNLINK from the process keyring so no partial state escapes.

  3. Versioned, escaped payload. k=v;… with raw claim values is a wire protocol, and consumers will depend on it the moment we ship. Today ; or = in any value silently corrupts parsing. If an IdP permits them in iss, or a username contains one, you can inject ;acr=3; and a downstream consumer that trusts acr mis-grants. Two things: prefix the payload with v=1; so we have a version handle for future changes, and reject or percent-encode ;, =, \0, CR, LF in every value before concatenation. Egress sanitisation is the publisher's job. Relying on "pam_authnft will clean on ingress" doesn't generalise to the next consumer.

  4. Linux-only build. pub mod keyring; needs #[cfg(target_os = "linux")], and so does the call site in pam_sm_authenticate. Without it, cargo check -p pam-prmana --all-features fails on macOS (verified locally: four errors around SYS_add_key, SYS_keyctl, __errno_location). One-liner, no behaviour change on Linux.

  5. ABI contract in docs. PRMANA_KEY and the prmana_<sid> payload schema become a stable contract the moment this ships. We'll land an ADR (026) and a new TB-8 trust-boundary block in docs/threat-model.md alongside the merge. Scope: what PRMANA_KEY guarantees, teardown expectations (kernel lifecycle plus pam_keyinit revoke dependency), residual risks, and the deprecation path. I'll draft those and share back on this PR.

Unrelated but also blocking

  • PR base is pre-sync. Needs a rebase onto current main (65c5c30) before merge.
  • CONTRIBUTING.md / LICENSE / PR template license triad you flagged: that's on us. Fix is coming on a separate PR this week so it doesn't hold this one up.
  • DCO vs Gitsign: CONTRIBUTING.md asks for Gitsign, your commits are DCO-signed. I'd rather relax CONTRIBUTING.md than ask you to re-sign; will confirm and update.

If any of the above reads as unreasonable, say so. Alternative path: we can take the primitive over as a fresh PR crediting you, which may be easier than rewriting against internal assumptions that haven't landed in OSS yet. Either way works.

Strykar added 3 commits April 19, 2026 20:06
Signed-off-by: Strykar <2946372+Strykar@users.noreply.github.com>
…lation token

Signed-off-by: Strykar <2946372+Strykar@users.noreply.github.com>
Co-Authored-By: Claude Opus
1. Remove AUTHNFT_CORRELATION export — consumer-agnostic
2. Detect shared @us keyring, refuse to publish
3. Close TOCTOU: publish to @p, lock down, then KEYCTL_LINK
4. Versioned payload (v=1;) with percent-encoded delimiters
5. #[cfg(target_os = "linux")] on keyring module and call site

Co-Authored-By: Claude Opus
@Strykar Strykar force-pushed the feat/claims-keyring branch from 70616ca to 4fa6b12 Compare April 19, 2026 14:38
Three maintainer fixes on top of @Strykar's review-response commit:

1. format_claims: skip oversized pairs whole rather than truncate mid-
   escape. The previous out.truncate(MAX_PAYLOAD) could land between
   '%' and '3D', handing a corrupt percent-escape to decoders. Now the
   function pre-computes each encoded pair length and skips ones that
   won't fit whole. Later pairs still get a chance to fit, so payloads
   with one oversized claim still include shorter subsequent claims.

2. Add negative test for the @us refusal path. publish() is refactored
   to delegate to publish_with_detector() which takes a pluggable
   is_shared detector. The public API is unchanged; the test injects
   || true and asserts KeyringError::SharedSessionKeyring is returned
   before any kernel state is created. Corresponds to invariant prodnull#1 in
   ADR-026.

3. Drop CString::new("user").unwrap() in favour of the c-string literal
   c"user". No runtime allocation, no unwrap path. Fixes a clippy
   unwrap_used violation the crate's #![deny(clippy::unwrap_used)]
   would otherwise catch.

New tests added:
 - format_claims_skips_oversized_pair_whole
 - format_claims_never_truncates_mid_escape
 - format_claims_fits_typical_payload
 - publish_refuses_shared_session_keyring
 - publish_proceeds_when_session_is_isolated

Verified locally: cargo fmt --check, cargo clippy --all-targets
--all-features -- -D warnings (macOS + Linux), and the full pam-prmana
keyring test suite (9/9 pass on Linux).

Refs: prodnull#14, ADR-026.

Co-Authored-By: Avinash H. Duduskar <Strykar@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@prodnull
Copy link
Copy Markdown
Owner

Fast turnaround, thanks. All five requirements are satisfied. I pushed one maintainer commit (a653f59) with polish on top:

  1. format_claims now skips oversized pairs whole rather than truncating with out.truncate(MAX_PAYLOAD). The previous trim could land between % and 3D and hand a corrupt percent-escape to decoders.
  2. Added a negative test for the @us refusal path. publish() delegates to an internal publish_with_detector() taking a pluggable is_shared detector so the refusal branch can be exercised without a specific kernel config. Public API unchanged.
  3. Replaced CString::new("user").unwrap() with the c"user" c-string literal. The crate's #![deny(clippy::unwrap_used)] was otherwise going to bite in CI.

Verified locally: cargo fmt --check, cargo clippy --all-targets --all-features -- -D warnings on macOS and Linux, and the full pam-prmana keyring test suite (9/9 pass on Linux including the two new negative tests).

ADR-026 (kernel-keyring session-claims publication) and the new TB-8 block in docs/threat-model.md land on our side in a separate commit — I'll open a PR against main for those today. They formalise the wire contract (v=1; prefix, percent-encoding rules, PRMANA_KEY ABI guarantee, non-fatal semantics) so downstream integrators have something to point at.

Nothing blocking from my end now. Once CI goes green on this branch I can merge.

prodnull added a commit that referenced this pull request Apr 19, 2026
ADR-026 captures the architectural decision behind PR #14
(kernel-keyring session-claims publication by pam-prmana): adopt the
generic primitive, decline consumer-specific env var exports, with five
binding invariants covering keyring anchoring, atomic ACL, versioned/
escaped payload format, Linux-only build, and the PRMANA_KEY ABI
commitment.

TB-8 in docs/threat-model.md is the trust-boundary analysis consumed by
the ADR: POSSESSOR semantics (inherited across fork/execve including
setuid, contrary to "current process only" mental models), nine
threats with STRIDE categories, normative mitigations tied 1:1 to the
ADR invariants, four residual risks, and a positive/negative/
adversarial/fuzz test matrix.

Supporting doc updates:
 - docs/adr/README.md: index entry for 026.
 - docs/pam-integration.md: recommend pam_keyinit.so force revoke in
   the SSH example; new section documenting the v=1 wire contract,
   keyring semantics, non-fatal failure mode, and a C example for
   consumers reading the payload.
 - docs/security-guide.md: operator bullet about pam_keyinit in the
   Production Requirements list.

The capability was threat-modeled adversarially by two independent AI
reviewers (Codex and Gemini-3-pro) before acceptance. Convergence on
Option 2 scope and on the five merge preconditions.

Refs: #14.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
prodnull added a commit that referenced this pull request Apr 19, 2026
Three files disagreed on what license prmana ships under and what
commit-provenance record contributors owe:

- LICENSE: Apache-2.0.
- CONTRIBUTING.md: CC BY-NC-SA 4.0 (a content license that forbids
  commercial use — completely wrong for OSS code).
- .github/PULL_REQUEST_TEMPLATE.md: "dual license (Apache-2.0 OR MIT)"
  plus "I have signed off my commits (DCO)".

LICENSE is authoritative. This commit aligns the other two on
Apache-2.0 and drops the phantom MIT half of the "dual license" line.

Signing: CONTRIBUTING.md required Gitsign; the PR template already
asked for DCO. External contributors to Linux-ecosystem projects
almost universally use DCO. Rather than turn away quality
contributions over a signing-format mismatch, the signing section
now accepts either Gitsign OR DCO (`git commit -s`). Gitsign remains
preferred for maintainer commits; that can be enforced via branch
protection on main rather than via CONTRIBUTING.md text.

@Strykar's PR #14 was DCO-signed. This change reconciles the
project's stated expectation with the commit-provenance form
external contributors actually use.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@prodnull
Copy link
Copy Markdown
Owner

The license triad fix you flagged is up as #18 (CONTRIBUTING.md → Apache-2.0, PR template drops "OR MIT", signing section now accepts Gitsign or DCO). Your DCO-signed commits on this PR are fine as-is — no action needed from you.

prodnull added a commit that referenced this pull request Apr 19, 2026
Three files disagreed on what license prmana ships under and what
commit-provenance record contributors owe:

- LICENSE: Apache-2.0.
- CONTRIBUTING.md: CC BY-NC-SA 4.0 (a content license that forbids
  commercial use — completely wrong for OSS code).
- .github/PULL_REQUEST_TEMPLATE.md: "dual license (Apache-2.0 OR MIT)"
  plus "I have signed off my commits (DCO)".

LICENSE is authoritative. This commit aligns the other two on
Apache-2.0 and drops the phantom MIT half of the "dual license" line.

Signing: CONTRIBUTING.md required Gitsign; the PR template already
asked for DCO. External contributors to Linux-ecosystem projects
almost universally use DCO. Rather than turn away quality
contributions over a signing-format mismatch, the signing section
now accepts either Gitsign OR DCO (`git commit -s`). Gitsign remains
preferred for maintainer commits; that can be enforced via branch
protection on main rather than via CONTRIBUTING.md text.

@Strykar's PR #14 was DCO-signed. This change reconciles the
project's stated expectation with the commit-provenance form
external contributors actually use.

Co-authored-by: prmana Developers <prodnull@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Owner

@prodnull prodnull left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All five merge requirements satisfied. Threat model + adversarial review captured in ADR-026 (merging separately as #17). Maintainer polish commit a653f59 keeps the contract enforceable. Thanks for the fast turnaround.

@prodnull prodnull merged commit 847d2ef into prodnull:main Apr 19, 2026
prodnull added a commit that referenced this pull request Apr 19, 2026
ADR-026 captures the architectural decision behind PR #14
(kernel-keyring session-claims publication by pam-prmana): adopt the
generic primitive, decline consumer-specific env var exports, with five
binding invariants covering keyring anchoring, atomic ACL, versioned/
escaped payload format, Linux-only build, and the PRMANA_KEY ABI
commitment.

TB-8 in docs/threat-model.md is the trust-boundary analysis consumed by
the ADR: POSSESSOR semantics (inherited across fork/execve including
setuid, contrary to "current process only" mental models), nine
threats with STRIDE categories, normative mitigations tied 1:1 to the
ADR invariants, four residual risks, and a positive/negative/
adversarial/fuzz test matrix.

Supporting doc updates:
 - docs/adr/README.md: index entry for 026.
 - docs/pam-integration.md: recommend pam_keyinit.so force revoke in
   the SSH example; new section documenting the v=1 wire contract,
   keyring semantics, non-fatal failure mode, and a C example for
   consumers reading the payload.
 - docs/security-guide.md: operator bullet about pam_keyinit in the
   Production Requirements list.

The capability was threat-modeled adversarially by two independent AI
reviewers (Codex and Gemini-3-pro) before acceptance. Convergence on
Option 2 scope and on the five merge preconditions.

Refs: #14.

Co-authored-by: prmana Developers <prodnull@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Strykar Strykar deleted the feat/claims-keyring branch April 19, 2026 16:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants