Skip to content

Security Hardening: AppArmor, Stream Authentication, and Supply Chain Audits

Portal OS operates as a spatial computing environment that streams live compositor output to AR glasses over untrusted network segments, executes foreign x86/Windows binaries for compatibility, and links against a large dependency surface of FFI-heavy crates. These three realities — unencrypted RTP broadcast, foreign-code execution, and deep transitive dependency trees — demand a layered security posture. This page documents the four defensive pillars in production: AppArmor confinement for executable execution control, HMAC-SHA256 stream authentication and pipeline injection prevention for the streaming pipeline, cargo-vet / cargo-deny / SBOM for supply chain integrity, and multi-layer secret scanning with custom static analysis rules.

Sources: SECURITY.md#L1-L70

The following diagram shows how the four pillars interlock across the build, deployment, and runtime phases:

graph TB
    subgraph Build["Build & CI Phase"]
        DENY["cargo-deny<br/>advisories · licenses · bans"]
        VET["cargo-vet<br/>audits.toml + imports.lock"]
        SBOM["cargo-cyclonedx<br/>CycloneDX JSON + XML"]
        SEMGREP["Semgrep Rules<br/>5 custom patterns"]
        GITLEAKS_CI["Gitleaks CI<br/>PR + push scan"]
        TRUFFLE_CI["TruffleHog CI<br/>verified-secrets scan"]
    end

    subgraph Release["Release Phase"]
        COSIGN["cosign keyless<br/>Sigstore / OIDC"]
        MINISIGN["minisign<br/>Ed25519 offline key"]
        VERIFY["verify_release.sh<br/>round-trip check"]
    end

    subgraph Runtime["Runtime Phase"]
        APPARMOR["AppArmor<br/>portal-x86-restrict"]
        HMAC["HMAC-SHA256<br/>per-packet RTP auth"]
        VALIDATE["validate_safe_token<br/>pipeline injection guard"]
        SYSTEMD["Systemd Hardening<br/>NoNewPrivileges · ProtectSystem"]
    end

    subgraph Monitoring["Continuous Monitoring"]
        TRUFFLE_DAILY["TruffleHog Daily<br/>per-subsystem scan"]
        TRUFFLE_WEEKLY["TruffleHog Weekly<br/>full-repo scan"]
        DEPENDABOT["Dependabot<br/>weekly dep updates"]
        CARGO_AUDIT["cargo-audit<br/>daily RustSec check"]
    end

    DENY --> RELEASE_BINARY["Signed Release Binaries"]
    VET --> RELEASE_BINARY
    SBOM --> RELEASE_BINARY
    COSIGN --> RELEASE_BINARY
    MINISIGN --> RELEASE_BINARY
    VERIFY --> RELEASE_BINARY

    RELEASE_BINARY --> APPARMOR
    RELEASE_BINARY --> HMAC
    RELEASE_BINARY --> VALIDATE
    RELEASE_BINARY --> SYSTEMD

    SEMGREP -.-> DENY
    GITLEAKS_CI -.-> TRUFFLE_CI
    TRUFFLE_DAILY --> MONITORING
    TRUFFLE_WEEKLY --> MONITORING
    DEPENDABOT --> MONITORING
    CARGO_AUDIT --> MONITORING

AppArmor: Confining Foreign Executable Execution

Section titled “AppArmor: Confining Foreign Executable Execution”

The Portal platform runs Wine for x86/Windows application compatibility. Without confinement, a malicious .exe downloaded by a user could execute anywhere on the filesystem. The AppArmor profile portal-x86-restrict establishes a deny-by-default, allow-by-exception policy that blocks Windows and x86 executable execution outside managed directories.

The profile operates with explicit deny rules for executable file extensions across home directories and temporary paths, while allowing execution only from curated managed folders:

Rule Pattern Action Rationale
/home/**.exe, .EXE, .msi, .MSI, .bat, .cmd Deny execute Block arbitrary Windows binaries in user space
/tmp/**.exe, /var/tmp/**.exe Deny execute Prevent execution from transient download locations
/home/*/Applications/Windows/** Allow execute (inherit) Managed Wine application directory
/home/*/Applications/x86_Linux/** Allow execute (inherit) Managed x86 Linux binary directory

The profile uses ABI version 4.0 and includes the abstractions/base template for standard system access patterns. The ixr permission flags on allow rules mean inherit-execute (the binary runs under the same profile), ensuring the restriction follows into any child processes spawned by the allowed executables.

Sources: portal/apparmor/portal-x86-restrict#L1-L26

Stream Authentication: HMAC-SHA256 Per-Packet Integrity

Section titled “Stream Authentication: HMAC-SHA256 Per-Packet Integrity”

The Portal streaming daemon captures compositor output via DMA-BUF, encodes it to H.265, and broadcasts RTP/UDP packets to AR glasses on the local access point. Without authentication, a man-in-the-middle attacker could inject forged RTP packets — corrupting the rendered frame or injecting crafted H.265 NAL units that trigger decoder vulnerabilities. The HMAC-SHA256 mechanism provides per-packet integrity so the glasses receiver can reject any packet whose tag fails verification.

The authentication system has three components working in sequence:

sequenceDiagram
    participant Env as Environment Variable
    participant Main as portal_stream.c main()
    participant Probe as hmac_pad_probe()
    participant HMAC as hmac_util.c
    participant Sink as udpsink (GStreamer)

    Env->>Main: PORTAL_STREAM_HMAC_KEY (64 hex chars)
    Main->>Main: portal_hex_decode_key() → 32-byte key
    Main->>Main: G.hmac_enabled = 1
    Main->>Sink: Attach probe to udpsink sink pad

    loop Each RTP packet
        Sink->>Probe: GstBuffer (complete RTP packet)
        Probe->>Probe: gst_buffer_make_writable()
        Probe->>Probe: Stage 1: gst_buffer_map (READ)
        Probe->>HMAC: portal_compute_hmac_sha256(key, data, len)
        HMAC-->>Probe: 32-byte tag
        Probe->>Probe: Stage 2: gst_rtp_buffer_map (RW)
        Probe->>Probe: add_extension_twobyte_header(app_id=1, tag)
        Probe-->>Sink: Modified buffer with HMAC extension
    end

The HMAC helper wraps OpenSSL’s HMAC() one-shot API with EVP_sha256 as the digest. The implementation validates all inputs defensively — NULL key, NULL output buffer, and NULL data with non-zero length are all rejected with return value 0. The hex key decoder rejects malformed input (wrong length, non-hex characters) at startup, preventing a garbled key from silently producing incorrect tags.

The key constants follow RFC 2104 / FIPS 198-1 recommendations:

Constant Value Meaning
PORTAL_HMAC_KEY_LEN 32 bytes Recommended HMAC-SHA256 key size
PORTAL_HMAC_TAG_LEN 32 bytes Full HMAC-SHA256 digest length
PORTAL_HMAC_KEY_HEX_LEN 64 chars Hex-encoded key length

Sources: portal/streaming/hmac_util.c#L1-L68, portal/streaming/hmac_util.h#L1-L45

The HMAC tag is embedded as an RFC 5285 two-byte header extension (profile 0x100, app id 1). The two-byte form is mandatory because the 32-byte HMAC-SHA256 tag exceeds the one-byte form’s 16-byte per-element limit. Receivers that do not validate the extension simply ignore it per RFC 3550 §5.3.1, preserving backward compatibility with unauthenticated glasses.

The probe uses a two-stage map pattern: first gst_buffer_map reads the raw on-wire packet bytes (header + payload) for HMAC input, then gst_rtp_buffer_map writes the extension into the RTP structure. This separation is necessary because GstBuffer provides raw bytes while GstRTPBuffer understands RTP framing semantics.

The HMAC pad probe implements a strict fail-closed policy — if any step in the authentication chain fails, the packet is dropped and logged, never sent unauthenticated:

Failure Condition Probe Action Rationale
Buffer not writable DROP + log Cannot guarantee buffer ownership
gst_buffer_map fails DROP + log Cannot read packet bytes for HMAC
portal_compute_hmac_sha256 fails DROP + log Never send a packet that should be tagged but wasn’t
gst_rtp_buffer_map fails DROP + log Cannot write extension into RTP structure

If the PORTAL_STREAM_HMAC_KEY environment variable is absent or malformed at startup, the daemon falls back to unauthenticated mode with a warning. This preserves v0.2.x backward compatibility but logs an explicit (insecure) annotation. The typed error code STREAM_ERR_AUTH (value 4) is defined for future enforcement.

Sources: portal/streaming/portal_stream.c#L452-L534, portal/streaming/portal_stream.h#L1-L20, portal/streaming/portal_stream.c#L697-L714

The HMAC implementation has comprehensive cmocka unit tests with RFC 4231 known-answer test vectors:

Test Validates
RFC 4231 §4.2 (Case 1) Key 0x0b*20, data “Hi There” → expected digest
RFC 4231 §4.3 (Case 2) Key “Jefe”, data “what do ya want for nothing?” → expected digest
NULL key rejection Returns 0, no crash
NULL output buffer Returns 0, no crash
NULL data + non-zero length Returns 0 (defensive contract)
Zero-length data Deterministic digest (HMAC of inner pad only)
Valid hex decode 64 hex chars → correct 32-byte key
Short hex (63 chars) Rejected with return 0
Long hex (65 chars) Rejected with return 0
Non-hex character (‘g’) Rejected with return 0

Sources: portal/streaming/tests/test_hmac_util.c#L1-L200

Pipeline Injection Prevention: validate_safe_token

Section titled “Pipeline Injection Prevention: validate_safe_token”

Before the GStreamer pipeline string is constructed via snprintf, every externally-sourced token (host, port, bind address, multicast interface) passes through a metacharacter rejection filter. The validate_safe_token function rejects any string containing shell-injection characters that could corrupt the GStreamer pipeline DSL:

Rejected Character Injection Vector
; Command separator (e.g., host;rm)
| Pipeline injection (e.g., a|b)
$ Shell variable expansion (e.g., $HOME)
` Command substitution (e.g., a`b`)

If any token fails validation, setup_pipeline returns STREAM_ERR_CONFIG before gst_parse_launch is ever called. The test suite covers 12 cases including infix injection (192.168.1.1;rm -rf /), legitimate values (IPv4 addresses, ports, interface names, Wayland output names with hyphens), and NULL/empty rejection.

Sources: portal/streaming/portal_stream.c#L444-L450, portal/streaming/portal_stream.c#L557-L564, portal/streaming/tests/test_validate_safe_token.c#L1-L161

Supply Chain Audits: cargo-vet, cargo-deny, and SBOM

Section titled “Supply Chain Audits: cargo-vet, cargo-deny, and SBOM”

The deny.toml configuration enforces four checks as CI release gates: advisories, licenses, bans, and sources. Each ignored advisory carries a human-readable reason and re-verification date.

Check Category Policy Enforcement
Advisories Deny all RustSec classes (vulns, unmaintained, unsound, yanked) 9 advisories ignored — each with reason + verification date
Licenses Allow-list of 14 approved licenses; confidence threshold 0.93 28 workspace crates clarified as MIT
Bans multiple-versions = "deny" with documented skip entries 26 skip entries, grouped by ecosystem split cause
Sources unknown-registry = "deny", unknown-git = "deny" No crates.io之外的registry

The ignored advisories are carefully categorized by transitive dependency path. For example, RUSTSEC-2023-0089 (atomic-polyfill unmaintained) traces through heapless v0.7 → postcard v1.1 → pcp-stream, pcp-ipc, portal-input, portal-spatial, portal-voice, with no upgrade path until heapless releases a new version. Each was verified as still in-tree on 2026-07-20 via cargo tree -i.

Sources: deny.toml#L1-L21, deny.toml#L211-L382, deny.toml#L23-L50

The supply-chain/ directory implements Mozilla’s cargo-vet framework with a tiered audit priority system. The project imports audit attestations from Google and Mozilla as cascade trust — crates already vetted by these organizations are transitively trusted without duplicate review.

The audit priority document identifies the top 20 highest-risk dependencies across six tiers ranked by blast radius:

Tier Risk Category Example Crates Audit Status
Tier 1 FFI + Unsafe (memory safety boundary) libc, nix, libloading, evdev, wayland-backend 8 crates audited
Tier 2 Network I/O (attack surface) mio, socket2, signal-hook-registry 3 crates audited
Tier 3 Cryptographic primitives ed25519-dalek, sha2, getrandom 3 crates audited
Tier 4 Concurrency + Data parking_lot, crossbeam-channel, dashmap 3 crates audited
Tier 5 Build-time code generation bindgen, cbindgen 2 crates audited
Tier 6 Database (SQL injection) rusqlite 1 crate audited

Each audited crate in audits.toml records the version, criteria (safe-to-deploy or safe-to-run), and review notes documenting what was examined. For example, the ed25519-dalek audit notes constant-time implementation review, RFC 8032 compliance verification, and Mozilla’s formal audit at v2.1.0.

Sources: supply-chain/audit-priorities.md#L1-L99, supply-chain/audits.toml#L1-L88, supply-chain/config.toml#L1-L12

Release builds produce CycloneDX 1.5 SBOMs in both JSON and XML formats via cargo-cyclonedx. The SBOM is generated per-crate and uploaded as a release artifact alongside the signed binaries. The generate_sbom.sh script handles collection from nested crate directories into a clean output directory.

Sources: scripts/generate_sbom.sh#L1-L127, .github/workflows/security.yml#L32-L45

Release Artifact Signing: Dual Signature Scheme

Section titled “Release Artifact Signing: Dual Signature Scheme”

Portal uses a dual-signature scheme to ensure artifact authenticity even if one signing infrastructure is compromised:

Scheme Algorithm Key Management Transparency
cosign (Sigstore) Ed25519 Keyless via GitHub OIDC Rekor transparency log
minisign Ed25519 Offline secret key (hardware token) Public key in keys/portal-release.pub

The release workflow (release.yml) signs five production binaries: portal-stream, portal-voiced, portal-llmd, portal-keyboardd, and portal-wm. Each binary receives .sig (raw signature), .pem (Fulcio certificate), and .bundle (Rekor entry) from cosign, plus .minisig from minisign. SBOM files (.cdx.json, .cdx.xml) are also signed by both schemes.

The key policy mandates that secret keys are never committed and are generated on an offline machine. Trust bootstrapping uses cosign’s keyless signatures until minisign trust is established, after which cosign may be retired.

Sources: .github/workflows/release.yml#L1-L136, scripts/sign_release.sh#L1-L168, scripts/cosign-sign.sh#L1-L167, keys/README.md#L1-L60

Portal implements secret detection at three temporal layers — pre-commit, CI gate, and scheduled monitoring — using two complementary tools with different detection philosophies:

graph LR
    subgraph Pre-Commit["Pre-Commit (Local)"]
        GL_PC["Gitleaks protect<br/>staged changes only"]
        TH_PC["TruffleHog<br/>HEAD diff, verified-only"]
    end

    subgraph CI["CI Gate (Per PR/Push)"]
        GL_CI["Gitleaks Action<br/>full history"]
        TH_CI["TruffleHog Docker<br/>verified, fail on find"]
    end

    subgraph Scheduled["Scheduled Monitoring"]
        TH_DAILY["TruffleHog Daily<br/>per-subsystem matrix"]
        TH_WEEKLY["TruffleHog Weekly<br/>full-repo history"]
    end

    DEV["Developer"] --> Pre-Commit
    PR["Pull Request"] --> CI
    CRON["Cron Schedule"] --> Scheduled
Layer Tool Scope Detection Method False Positive Rate
Pre-commit Gitleaks Staged files only Regex + entropy Low-moderate
Pre-commit TruffleHog HEAD → working tree Verified against live APIs Near-zero
CI gate Gitleaks Full git history Regex + entropy Low-moderate
CI gate TruffleHog Full history, PR-scoped Verified against live APIs Near-zero
Daily TruffleHog Per-subsystem matrix Verified, creates GitHub issue Near-zero
Weekly TruffleHog Full repository Verified, creates GitHub issue Near-zero

Beyond the default Gitleaks ruleset, Portal defines six custom detection patterns targeting platform-specific secret categories: Wayland socket paths, SSH private key blocks, streaming IP:port endpoints, DRM device paths, extended GitHub token patterns, and high-entropy internal IP:port combinations. Each rule includes carefully scoped allowlists for test fixtures, documentation, compositor configs, and lock files.

Sources: .gitleaks.toml#L1-L170, .pre-commit-config.yaml#L1-L35, .github/workflows/gitleaks-scan.yml#L1-L26, .github/workflows/trufflehog-scan.yml#L1-L25, .github/workflows/trufflehog-daily-subsystem.yml#L1-L119, .github/workflows/trufflehog-weekly-repo.yml#L1-L64

Five Portal-specific Semgrep rules target vulnerability patterns unique to the codebase’s FFI, streaming, and compositor integration:

Rule ID Language Severity Mode Detection Target
gstreamer-pipeline-injection C ERROR Taint argv → gst_parse_launch data flow
strncpy-unterminated C WARNING Pattern Missing null terminator after strncpy
unsafe-extern-no-catch-unwind Rust WARNING Pattern Panic through C frames in extern "C" callbacks
warp-renderer-no-drop Rust WARNING Pattern GL resource leak (missing Drop impl)
frame-cancel-no-backoff C INFO Pattern Infinite retry loop without backoff

The taint-mode gstreamer-pipeline-injection rule is particularly significant — it traces data flow from argv parameters through to gst_parse_launch calls, complementing the runtime validate_safe_token guard with compile-time detection. Each rule includes test files with both vulnerable and safe code patterns for validation.

Sources: .semgrep-rules/RULE_REPORT.md#L1-L58

A comprehensive supply chain risk audit evaluated all ~90 direct Rust dependencies plus vendored C/C++ code against six risk criteria. 31 dependencies were flagged with at least one risk factor, with the dominant concerns being absent security contacts (28 crates) and low popularity under 500 GitHub stars (17 crates).

The most actionable findings were three replacement targets: dirs (archived) → directories v5, urlencoding (low popularity) → percent-encoding, and lazy_static (nursery) → std::sync::LazyLock. The unavoidable high-risk dependencies — drm, gbm, evdev, alsa, rknn-sys, whisper.cpp — are hardware-specific with no alternatives, and are mitigated through version pinning, lockfile review, and the cargo-vet audit process.

Sources: .supply-chain-risk-auditor/results.md#L1-L100

A separate sharp-edges audit identified five HIGH-severity pitfalls in the FFI and GPU integration code. These are not actively exploited vulnerabilities but represent trapdoors for future developers:

Finding Category Risk Mitigation
SE-001: rknn_input.buf raw pointer with DMA flag Primitive vs Semantic NPU DMA reads past allocation Builder pattern with DMA-safe buffer validation
SE-002: RkllmContext::run() always returns empty string Silent Failure Success masquerades as empty result Collect callback output via Arc<Mutex<String>>
SE-003: RkllmCallback panic through C frames Primitive vs Semantic Undefined behavior on unwind catch_unwind + abort handler
SE-004: Dual rknn_param default sources Dangerous Default Rust vs C SDK defaults disagree Remove Rust Default impl
SE-005: WarpRenderer no Drop Silent Failure Permanent GL resource leak on embedded GPU Implement Drop that calls cleanup()

Sources: .supply-chain-risk-auditor/sharp-edges-report.md#L1-L80

Insecure Defaults Audit: Hardening Findings

Section titled “Insecure Defaults Audit: Hardening Findings”

A static analysis pass across all systemd services, compositor config, and the streaming C codebase identified 14 findings (2 CRITICAL, 4 HIGH, 5 MEDIUM, 3 LOW). The audit confirmed strong negative results: no hardcoded passwords, no 0.0.0.0 bind-all, no weak crypto, no world-writable file permissions, no CORS wildcards, and no debug_assertions gating security checks.

The two CRITICAL findings — hardcoded IP fallback in the streaming binary and hardcoded bind address in the GStreamer pipeline string — were both remediated in the v0.2.1 hardening pass. The fix eliminated all hardcoded deployment IPs from source, requiring PORTAL_STREAM_TARGET to be provided via environment variable or argv, with non-zero exit on absence.

Finding Severity Status Remediation
FID-001: Hardcoded IP fallback CRITICAL Fixed (v0.2.1) No DEFAULT_HOST; fail-loud if env var absent
FID-002: Hardcoded bind address CRITICAL Fixed (v0.2.1) Bind address from PORTAL_STREAM_BIND env var
FID-003: Unencrypted RTP stream HIGH Mitigated by HMAC HMAC-SHA256 per-packet authentication added
FID-004: No receiver authentication HIGH Mitigated by HMAC Receivers can validate HMAC extension
FID-005: portal-input runs as root HIGH Tracked Documented; udev rules as future work
FID-006: portal-file-watcherd no hardening HIGH Tracked Apply portal-launcher.service hardening template

Sources: docs/INSECURE_DEFAULTS_AUDIT.md#L1-L250

The project maintains a formal vulnerability disclosure program with severity-based response timelines and PGP-encrypted reporting. The SECURITY.md defines supported versions (v0.2.0 current, v0.1.0 EOL) and a Safe Harbor clause under the Demain Source License §6 permitting security research in good faith.

Severity Acknowledgment Resolution Target
Critical 48 hours 90 days
High 5 business days 120 days
Medium 7 business days Next release
Low 7 business days Best effort

The .well-known/security.txt follows RFC 9116, publishing the contact address, expiry date, preferred languages (en, fr), and canonical URL. Dependabot runs weekly across GitHub Actions, Cargo, and Docker ecosystems with scoped PR labels and commit message prefixes.

Sources: SECURITY.md#L1-L70, .well-known/security.txt#L1-L8, .github/dependabot.yml#L1-L51

The following table consolidates all security-related CI workflows:

Workflow Trigger Tool Scope
security.yml → cargo-audit Push, PR, daily cron rustsec/audit-check RustSec advisory database
security.yml → cargo-deny Push, PR EmbarkStudios/cargo-deny Advisories + licenses + bans + sources
security.yml → SBOM Tag push only cargo-cyclonedx CycloneDX JSON + XML per crate
gitleaks-scan.yml Push, PR gitleaks-action Full git history, pattern + entropy
trufflehog-scan.yml Push, PR trufflehog Docker Full history, verified-only
trufflehog-daily-subsystem.yml Daily cron trufflehog Docker 18-subsystem matrix scan
trufflehog-weekly-repo.yml Weekly cron (Mon) trufflehog Docker Full repository, all paths
release.yml Tag push (v*) cosign + minisign Binary + SBOM signing
miri.yml (Configured) Miri Undefined behavior in unsafe Rust

All workflow actions use pinned commit SHAs (not floating tags) to prevent supply chain attacks on the CI pipeline itself, and persist-credentials: false prevents token leakage to subsequent steps.

Sources: .github/workflows/security.yml#L1-L45, .github/workflows/release.yml#L1-L136