Skip to content

Ios rc signature verification - #372

Open
yuvrajjsingh0 wants to merge 5 commits into
mainfrom
ios-rc-signature-verification
Open

Ios rc signature verification#372
yuvrajjsingh0 wants to merge 5 commits into
mainfrom
ios-rc-signature-verification

Conversation

@yuvrajjsingh0

@yuvrajjsingh0 yuvrajjsingh0 commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary by CodeRabbit

  • New Features

    • Added Integrity settings for managing application signing keys.
    • Create, view, copy, download, enable/disable, and set default signing keys.
    • Release configurations can now include cryptographic signatures for integrity verification.
    • iOS integrations can provide trusted public keys and reject tampered configurations.
    • Settings navigation and permission-based access controls are now available.
  • Documentation

    • Added dashboard guidance and client verification instructions, including key rotation and signature handling.

Verify the X-Airborne-Signature header on the release config before it is
parsed or applied, matching the server-side signing added in e7b8044. The
integrator supplies trusted public keys through a new publicKeys() delegate
method, keyed by key ID the same way namespace() is supplied, and the SDK
checks the ECDSA P-256 / SHA-256 signature over the exact raw response bytes.

- AJPReleaseConfigVerifier (AirborneSwiftCore): SPKI PEM -> raw x963 point
  extraction that stays on the iOS 13 floor, X-Airborne-Signature parsing,
  and P256 verification. AJPReleaseConfigTrustStore parses keys eagerly so a
  malformed PEM surfaces at boot instead of silently disabling verification.
- Wire publicKeys() through AirborneDelegate and AJPApplicationManagerDelegate.
- Hook verification into fetchReleaseConfigWithCompletionHandler before the
  manifest is parsed, and reject non-2xx responses outright. A rejected config
  is never parsed, staged to temp, or applied; the SDK falls back to the
  currently installed bundle via the existing failure path.
- Emit signature_* fields on release_config_fetch plus dedicated
  release_config_signature_missing / _verification_failed /
  signing_key_invalid events.
- Docs: native verification in the iOS API reference and the signing guide
  (including an iOS 13 SecKey correction), and the new events in the
  callbacks reference.

Backward compatible: verification is skipped when no keys are configured, and
a response with no signature header is tolerated (so keys can ship before
signing is enabled server-side), while a present-but-invalid signature is
rejected.

test
@semanticdiff-com

semanticdiff-com Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review changes with  SemanticDiff

Changed Files
File Status
  airborne_dashboard/next.config.mjs  97% smaller
  airborne_server/src/release.rs  21% smaller
  airborne_server/src/main.rs  7% smaller
  airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwift/AJPApplicationManager.swift  5% smaller
  airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwift/AJPApplicationManagerDelegate.swift  5% smaller
  airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwift/Airborne.swift  3% smaller
  airborne_server/src/types.rs  1% smaller
  airborne_server/src/utils/db/models.rs  1% smaller
  Cargo.lock Unsupported file format
  airborne_dashboard/app/dashboard/[orgId]/[appId]/settings/integrity/page.tsx  0% smaller
  airborne_dashboard/app/dashboard/[orgId]/[appId]/settings/layout.tsx  0% smaller
  airborne_dashboard/app/dashboard/[orgId]/[appId]/settings/page.tsx  0% smaller
  airborne_dashboard/components/settings/integrity/create-key-dialog.tsx  0% smaller
  airborne_dashboard/components/settings/integrity/key-actions.tsx  0% smaller
  airborne_dashboard/components/settings/integrity/public-key-dialog.tsx  0% smaller
  airborne_dashboard/components/settings/integrity/signing-keys-card.tsx  0% smaller
  airborne_dashboard/components/settings/settings-tabs.tsx  0% smaller
  airborne_dashboard/components/shared-layout.tsx  0% smaller
  airborne_dashboard/lib/name-validation.ts  0% smaller
  airborne_dashboard/types/integrity.ts  0% smaller
  airborne_docs/docs/dashboard/integrity.mdx Unsupported file format
  airborne_docs/docs/dashboard/overview.mdx Unsupported file format
  airborne_docs/docs/guides/verify-the-release-config-signature.mdx Unsupported file format
  airborne_docs/docs/react-native-sdk/reference/callbacks-and-events.md Unsupported file format
  airborne_docs/docs/react-native-sdk/reference/ios-api.md Unsupported file format
  airborne_docs/docs/server/configuration.md Unsupported file format
  airborne_docs/openapi/airborne.openapi.json  0% smaller
  airborne_docs/sidebars.ts  0% smaller
  airborne_docs/static/docs_static/img/screenshots/dark/integrity-keys.png Unsupported file format
  airborne_docs/static/docs_static/img/screenshots/light/integrity-keys.png Unsupported file format
  airborne_sdk_iOS/hyper-ota/Airborne.xcodeproj/project.pbxproj Unsupported file format
  airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwiftCore/AJPReleaseConfigVerifier.swift  0% smaller
  airborne_sdk_iOS/hyper-ota/AirborneTestAppTests/AJPReleaseConfigLiveVerificationTests.swift  0% smaller
  airborne_sdk_iOS/hyper-ota/AirborneTestAppTests/AJPReleaseConfigVerifierTests.swift  0% smaller
  airborne_server/.env.example Unsupported file format
  airborne_server/Cargo.toml Unsupported file format
  airborne_server/migrations/20260714120000_add_signing_keys/down.sql Unsupported file format
  airborne_server/migrations/20260714120000_add_signing_keys/up.sql Unsupported file format
  airborne_server/src/config.rs Unsupported file format
  airborne_server/src/organisation/application.rs Unsupported file format
  airborne_server/src/organisation/application/properties.rs  0% smaller
  airborne_server/src/release/utils.rs  0% smaller
  airborne_server/src/signing.rs  0% smaller
  airborne_server/src/signing/types.rs  0% smaller
  airborne_server/src/signing/utils.rs  0% smaller
  airborne_server/src/utils.rs  0% smaller
  airborne_server/src/utils/advisory_lock.rs  0% smaller
  airborne_server/src/utils/db/schema.rs  0% smaller
  airborne_server/src/utils/moka.rs  0% smaller
  airborne_server/src/utils/redis.rs  0% smaller
  smithy/models/errors.smithy Unsupported file format
  smithy/models/main.smithy Unsupported file format
  smithy/models/release.smithy Unsupported file format
  smithy/models/signing.smithy Unsupported file format

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 04b275b8-7625-46a9-bc00-65d4119084d1

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ios-rc-signature-verification

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Caution

Failed to replace (edit) comment. This is likely due to insufficient permissions or the comment being deleted.

Error details
putComment timed out

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 12

🧹 Nitpick comments (4)
airborne_server/src/config.rs (1)

268-271: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Consider rejecting/clamping a zero RC_SIGNATURE_CACHE_TTL.

rc_signature_cache_ttl flows straight into set_ex (SET … EX <ttl>), and Redis errors on EX 0. An operator setting RC_SIGNATURE_CACHE_TTL=0 to "disable" the cache would get an error log plus a CACHE_FAILS increment on every signed release-config response rather than a disabled cache.

♻️ Clamp to a minimum
-            rc_signature_cache_ttl: parse_env(
-                "RC_SIGNATURE_CACHE_TTL",
-                DEFAULT_RC_SIGNATURE_CACHE_TTL_SECS,
-            ),
+            rc_signature_cache_ttl: parse_env(
+                "RC_SIGNATURE_CACHE_TTL",
+                DEFAULT_RC_SIGNATURE_CACHE_TTL_SECS,
+            )
+            .max(1),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@airborne_server/src/config.rs` around lines 268 - 271, Update the
RC_SIGNATURE_CACHE_TTL parsing in the config construction to clamp parsed values
to a minimum of one second before assigning rc_signature_cache_ttl, preventing
zero from reaching the Redis set_ex path. Preserve the existing default and
positive TTL behavior.
airborne_server/src/signing/utils.rs (1)

306-308: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Nit: key.map(Some).ok_or_else(...) reads oddly.

key.map(Some) on an Option<T> to get Option<Option<T>> just to ok_or_else it is harder to follow than the equivalent direct form.

♻️ Simpler equivalent
-    key.map(Some).ok_or_else(|| {
-        ABError::BadRequest("Unknown or disabled signing key in X-Signing-Key-Id".to_string())
-    })
+    match key {
+        Some(key) => Ok(Some(key)),
+        None => Err(ABError::BadRequest(
+            "Unknown or disabled signing key in X-Signing-Key-Id".to_string(),
+        )),
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@airborne_server/src/signing/utils.rs` around lines 306 - 308, In the
signing-key lookup flow, replace the nested-option construction using
key.map(Some).ok_or_else with the direct Result conversion for the existing key
Option, while preserving the same BadRequest error for missing or disabled keys.
airborne_server/src/utils/redis.rs (1)

228-253: 🩺 Stability & Availability | 🔵 Trivial | 🏗️ Heavy lift

index_drop is a three-step read-modify-delete; consider chunking the DEL and closing the window.

Two things worth tightening on the invalidation path:

  • SMEMBERS returns the whole set into memory and the members are deleted in a single DEL. For a busy application (one entry per config_version × selector) that is one unbounded, blocking command against Redis.
  • A sign_release_config running concurrently can SADD a member after SMEMBERS and before the final DEL of the index, leaving a signature entry that this invalidation misses; it then survives until the next invalidation or the TTL.

SSCAN + chunked DEL, or a small Lua script that pops and deletes atomically, addresses both.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@airborne_server/src/utils/redis.rs` around lines 228 - 253, Update
RedisClient::index_drop to avoid loading the entire index set and to close the
concurrent SADD race: replace the SMEMBERS/read-then-delete sequence with an
atomic Lua-based pop-and-delete operation, or use SSCAN with bounded deletion
chunks plus an atomic final cleanup that cannot miss members added during
invalidation. Preserve the existing error metrics, logging, and ABError behavior
for Redis failures.
airborne_server/migrations/20260714120000_add_signing_keys/up.sql (1)

13-13: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Redundant indexes alongside the UNIQUE constraint.

signing_keys_name_idx (org_id, app_id, name) duplicates the index Postgres already builds to enforce UNIQUE (org_id, app_id, name) (Line 13). signing_keys_org_app_idx (org_id, app_id) is a leftmost prefix of that same index, so it adds no additional query coverage either. Both add write overhead with no query-planning benefit.

♻️ Proposed cleanup
-CREATE INDEX signing_keys_org_app_idx
-    ON hyperotaserver.signing_keys (org_id, app_id);
-
-CREATE INDEX signing_keys_name_idx
-    ON hyperotaserver.signing_keys (org_id, app_id, name);
+-- The UNIQUE (org_id, app_id, name) constraint already provides a covering
+-- index for lookups on (org_id, app_id) and (org_id, app_id, name).

Also applies to: 21-29

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@airborne_server/migrations/20260714120000_add_signing_keys/up.sql` at line
13, Remove the redundant signing_keys_name_idx and signing_keys_org_app_idx
index definitions from the migration, retaining the UNIQUE (org_id, app_id,
name) constraint so PostgreSQL supplies the necessary index. Leave any unrelated
indexes and migration behavior unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@airborne_dashboard/app/dashboard/`[orgId]/[appId]/settings/integrity/page.tsx:
- Around line 16-34: The authorization guard in IntegrityPage must run during
render rather than inside useEffect. After permissions.isReady is true, call
notFound() when read_signing_keys is unavailable before rendering
SigningKeysCard; otherwise preserve the existing canRead, canCreate, and
canUpdate permission props.

In `@airborne_docs/docs/react-native-sdk/reference/callbacks-and-events.md`:
- Around line 92-100: The release_config_fetch payload example is missing the
documented signature_error field. Update the signature fields example near
signature_present, signature_verified, and signature_key_id to include
signature_error with the stable failure reason codes, while preserving the
existing success-case fields.

In `@airborne_docs/docs/server/configuration.md`:
- Line 60: Update the documented default for RC_SIGNATURE_CACHE_TTL in the
configuration table to match DEFAULT_RC_SIGNATURE_CACHE_TTL_SECS, preserving the
existing one-hour value and the surrounding cache-tuning descriptions.

In `@airborne_docs/openapi/airborne.openapi.json`:
- Line 2120: The ListSigningKeys documentation incorrectly claims the response
is never empty. Update the corresponding doc comment for ListSigningKeys in
signing.smithy to state that applications created before signing support may
have no keys until the signingkeys boot migration runs, then regenerate the
OpenAPI output.

In
`@airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwift/AJPApplicationManager.swift`:
- Around line 1236-1246: Update the signature-header handling around
AJPSignatureVerificationError.missingHeader to support configurable enforcement
when the trust store contains keys: allow the existing warning-and-nil path only
in an explicitly permissive rollout mode, and reject missing signatures in
strict mode. Add or reuse a delegate/configuration switch for this policy,
preserving compatibility while enabling integrators to require signatures.

In `@airborne_server/src/release.rs`:
- Around line 1474-1482: Update sign_release_config or the serve_release_handler
call site so Redis signature-cache read errors from
cache.get::<CachedSignature>(key).await are downgraded to the existing
unsigned-signing fallback instead of propagated via await?. Preserve successful
cached-signature and private-key decrypt/sign behavior, returning Ok(None) with
the existing “Serving unsigned” handling for cache failures.

In `@airborne_server/src/signing/utils.rs`:
- Around line 73-88: The ActiveSigningKey Redis cache can contain plaintext
private keys when no master encryption key is configured. Update the caching
flow around ActiveSigningKey and resolve_signing_key to skip Redis caching when
master_encryption_key is None, while preserving existing caching when encryption
is enabled; document the field only if the implementation still permits
plaintext caching.
- Around line 418-427: Add an alert-grade metric in the signing failure branch
of the release-config signing flow, alongside the existing error log and before
returning Ok(None), to count unsigned serves for applications configured for
signing. Preserve the current fail-open response behavior, and use the existing
metrics/instrumentation symbol or pattern rather than changing SDK verification
behavior.
- Around line 696-720: Add or verify a database migration declaring a unique
constraint/index on the organisation, application, and default-key condition
used by the has_default check in the signing-key provisioning flow. Ensure the
insert in the run_blocking block violates that constraint for concurrent
default-key creation, preserving the existing conflict-handling path.
- Around line 370-400: The cache lookup in sign_release_config must be scoped to
the exact body being signed. Include a digest or equivalent body-specific
context in signature_cache_key and use it consistently for lookup and storage,
or bypass caching when the body cannot be safely keyed; preserve cache reuse
only when organisation, application, signing selector, config_version, and body
bytes all match.
- Around line 553-575: Wrap the has_default existence check and the subsequent
NewSigningKey insert in the same conn.transaction closure within the signing-key
creation flow. Preserve the existing default assignment logic while ensuring
concurrent requests cannot both observe no default and insert competing default
keys, following the transaction pattern used by set_default_key.

In `@airborne_server/src/utils/moka.rs`:
- Around line 7-9: Update the decrypted signing-key cache path used by AppState
and MokaCache::new("airborne") to use a dedicated policy with a substantially
shorter TTL and idle timeout than DEFAULT_TTL and DEFAULT_TTI. Keep general
cache defaults unchanged, and ensure the key-specific cache configuration is
actually applied rather than only defining new constants.

---

Nitpick comments:
In `@airborne_server/migrations/20260714120000_add_signing_keys/up.sql`:
- Line 13: Remove the redundant signing_keys_name_idx and
signing_keys_org_app_idx index definitions from the migration, retaining the
UNIQUE (org_id, app_id, name) constraint so PostgreSQL supplies the necessary
index. Leave any unrelated indexes and migration behavior unchanged.

In `@airborne_server/src/config.rs`:
- Around line 268-271: Update the RC_SIGNATURE_CACHE_TTL parsing in the config
construction to clamp parsed values to a minimum of one second before assigning
rc_signature_cache_ttl, preventing zero from reaching the Redis set_ex path.
Preserve the existing default and positive TTL behavior.

In `@airborne_server/src/signing/utils.rs`:
- Around line 306-308: In the signing-key lookup flow, replace the nested-option
construction using key.map(Some).ok_or_else with the direct Result conversion
for the existing key Option, while preserving the same BadRequest error for
missing or disabled keys.

In `@airborne_server/src/utils/redis.rs`:
- Around line 228-253: Update RedisClient::index_drop to avoid loading the
entire index set and to close the concurrent SADD race: replace the
SMEMBERS/read-then-delete sequence with an atomic Lua-based pop-and-delete
operation, or use SSCAN with bounded deletion chunks plus an atomic final
cleanup that cannot miss members added during invalidation. Preserve the
existing error metrics, logging, and ABError behavior for Redis failures.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b25f03cc-9b25-41cd-85f5-b0fade8917d3

📥 Commits

Reviewing files that changed from the base of the PR and between a706e7f and 2c499cf.

⛔ Files ignored due to path filters (3)
  • Cargo.lock is excluded by !**/*.lock
  • airborne_docs/static/docs_static/img/screenshots/dark/integrity-keys.png is excluded by !**/*.png
  • airborne_docs/static/docs_static/img/screenshots/light/integrity-keys.png is excluded by !**/*.png
📒 Files selected for processing (51)
  • airborne_dashboard/app/dashboard/[orgId]/[appId]/settings/integrity/page.tsx
  • airborne_dashboard/app/dashboard/[orgId]/[appId]/settings/layout.tsx
  • airborne_dashboard/app/dashboard/[orgId]/[appId]/settings/page.tsx
  • airborne_dashboard/components/settings/integrity/create-key-dialog.tsx
  • airborne_dashboard/components/settings/integrity/key-actions.tsx
  • airborne_dashboard/components/settings/integrity/public-key-dialog.tsx
  • airborne_dashboard/components/settings/integrity/signing-keys-card.tsx
  • airborne_dashboard/components/settings/settings-tabs.tsx
  • airborne_dashboard/components/shared-layout.tsx
  • airborne_dashboard/lib/name-validation.ts
  • airborne_dashboard/next.config.mjs
  • airborne_dashboard/types/integrity.ts
  • airborne_docs/docs/dashboard/integrity.mdx
  • airborne_docs/docs/dashboard/overview.mdx
  • airborne_docs/docs/guides/verify-the-release-config-signature.mdx
  • airborne_docs/docs/react-native-sdk/reference/callbacks-and-events.md
  • airborne_docs/docs/react-native-sdk/reference/ios-api.md
  • airborne_docs/docs/server/configuration.md
  • airborne_docs/openapi/airborne.openapi.json
  • airborne_docs/sidebars.ts
  • airborne_sdk_iOS/hyper-ota/Airborne.xcodeproj/project.pbxproj
  • airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwift/AJPApplicationManager.swift
  • airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwift/AJPApplicationManagerDelegate.swift
  • airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwift/Airborne.swift
  • airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwiftCore/AJPReleaseConfigVerifier.swift
  • airborne_sdk_iOS/hyper-ota/AirborneTestAppTests/AJPReleaseConfigLiveVerificationTests.swift
  • airborne_sdk_iOS/hyper-ota/AirborneTestAppTests/AJPReleaseConfigVerifierTests.swift
  • airborne_server/.env.example
  • airborne_server/Cargo.toml
  • airborne_server/migrations/20260714120000_add_signing_keys/down.sql
  • airborne_server/migrations/20260714120000_add_signing_keys/up.sql
  • airborne_server/src/config.rs
  • airborne_server/src/main.rs
  • airborne_server/src/organisation/application.rs
  • airborne_server/src/organisation/application/properties.rs
  • airborne_server/src/release.rs
  • airborne_server/src/release/utils.rs
  • airborne_server/src/signing.rs
  • airborne_server/src/signing/types.rs
  • airborne_server/src/signing/utils.rs
  • airborne_server/src/types.rs
  • airborne_server/src/utils.rs
  • airborne_server/src/utils/advisory_lock.rs
  • airborne_server/src/utils/db/models.rs
  • airborne_server/src/utils/db/schema.rs
  • airborne_server/src/utils/moka.rs
  • airborne_server/src/utils/redis.rs
  • smithy/models/errors.smithy
  • smithy/models/main.smithy
  • smithy/models/release.smithy
  • smithy/models/signing.smithy

Comment on lines +16 to +34
export default function IntegrityPage() {
const permissions = usePagePermissions(PAGE_AUTHZ);
const canRead = permissions.isReady && permissions.can("read_signing_keys");

useEffect(() => {
if (permissions.isReady && !permissions.can("read_signing_keys")) {
notFound();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [permissions.isReady, permissions.checks]);

return (
<SigningKeysCard
canRead={canRead}
canCreate={permissions.can("create_signing_key")}
canUpdate={permissions.can("update_signing_key")}
/>
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does calling notFound() from next/navigation inside a React useEffect actually trigger the not-found UI in Next.js 15?

💡 Result:

No, calling notFound from next/navigation inside a React useEffect does not trigger the expected Next.js not-found UI [1][2]. The notFound function works by throwing a specific error (NEXT_NOT_FOUND) that must be caught by the React rendering process to terminate the current segment and render the not-found.js file [3][2]. Because useEffect runs after the component has already finished rendering, calling notFound inside it occurs outside of the rendering task [1][2]. Consequently, Next.js cannot catch the error in the way required to swap in the not-found UI, and the function will effectively fail to perform its intended navigation [1][2]. To correctly trigger the not-found UI, you should call notFound during the component's render phase [1][2]. If your data fetching is asynchronous or client-side, the recommended pattern is to store the result in the component's state and conditionally call notFound in the body of your component [1][2]: // Recommended pattern for client-side data export default function Page({ params }) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); useEffect( => { fetchData(params.id).then((result) => { setData(result); setLoading(false); }); }, [params.id]); if (!loading &&!data) { notFound; // Called during render } if (loading) return

Loading...
; return
{data.name}
; }

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate and inspect the reported file and relevant auth state behavior without running repository code.
file="airborne_dashboard/app/dashboard/[orgId]/[appId]/settings/integrity/page.tsx"
if [ -f "$file" ]; then
  wc -l "$file"
  cat -n "$file"
else
  echo "Reported file not found at $file"
  fd -i 'page.tsx' airborne_dashboard/app/dashboard 2>/dev/null | head -50
fi

# Locate usePagePermissions implementation and related constants to see when isReady/checks stabilize.
rg -n "function usePagePermissions|const usePagePermissions|createSign|read_signing_keys|PAGE_AUTHZ|checks" airborne_dashboard -S

Repository: juspay/airborne

Length of output: 11428


Call notFound() during the render phase, not from useEffect.

notFound() is ignored when called inside useEffect; an unauthorized user can finish the first render with canRead={false} and stay on the loading/empty SigningKeysCard UI instead of being routed to the 404 boundary. Move the guard into the render path after permissions.isReady is stabilized.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@airborne_dashboard/app/dashboard/`[orgId]/[appId]/settings/integrity/page.tsx
around lines 16 - 34, The authorization guard in IntegrityPage must run during
render rather than inside useEffect. After permissions.isReady is true, call
notFound() when read_signing_keys is unavailable before rendering
SigningKeysCard; otherwise preserve the existing canRead, canCreate, and
canUpdate permission props.

Comment on lines +92 to +100
// Signature fields — present only when public keys are configured (iOS):
signature_present: true, // whether the response carried a signature header
signature_verified: true, // whether it verified against a trusted key
signature_key_id: "<key id>" // the key that verified it
}
}
```

When verification is configured but fails, `signature_verified` is `false` and `signature_error` carries a stable reason code (`signature_mismatch`, `untrusted_key_id`, `malformed_signature`, `unsupported_alg`, `malformed_header`, `no_trusted_keys`). The config is discarded and the app keeps its installed bundle.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

signature_error is described in prose but missing from the payload example.

Line 100 documents signature_error as part of the release_config_fetch value, yet the block at Lines 92-96 only lists signature_present, signature_verified, and signature_key_id. Readers taking the block as the authoritative shape will miss it.

📝 Proposed fix
     // Signature fields — present only when public keys are configured (iOS):
     signature_present: true,        // whether the response carried a signature header
     signature_verified: true,       // whether it verified against a trusted key
-    signature_key_id: "<key id>"    // the key that verified it
+    signature_key_id: "<key id>",   // the key that verified it
+    signature_error: "<reason>"     // present only when signature_verified is false
   }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Signature fields — present only when public keys are configured (iOS):
signature_present: true, // whether the response carried a signature header
signature_verified: true, // whether it verified against a trusted key
signature_key_id: "<key id>" // the key that verified it
}
}
```
When verification is configured but fails, `signature_verified` is `false` and `signature_error` carries a stable reason code (`signature_mismatch`, `untrusted_key_id`, `malformed_signature`, `unsupported_alg`, `malformed_header`, `no_trusted_keys`). The config is discarded and the app keeps its installed bundle.
// Signature fields — present only when public keys are configured (iOS):
signature_present: true, // whether the response carried a signature header
signature_verified: true, // whether it verified against a trusted key
signature_key_id: "<key id>", // the key that verified it
signature_error: "<reason>" // present only when signature_verified is false
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@airborne_docs/docs/react-native-sdk/reference/callbacks-and-events.md` around
lines 92 - 100, The release_config_fetch payload example is missing the
documented signature_error field. Update the signature fields example near
signature_present, signature_verified, and signature_key_id to include
signature_error with the stable failure reason codes, while preserving the
existing success-case fields.

| Variable | Required | Default / Example | Purpose |
| --- | --- | --- | --- |
| `REDIS_URL` | No | `redis://localhost:6379` | Redis connection string. When set, the server enables PKCE/nonce protection on the OIDC flow and caches hot reads. When unset, Redis is skipped entirely. |
| `RC_SIGNATURE_CACHE_TTL` | No | `3600` | Time to live, in seconds, for each cached release-config signature. Only applies when `REDIS_URL` is set. |

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n 'RC_SIGNATURE_CACHE_TTL|rc_signature_cache_ttl' airborne_server --no-heading -C2
fd -i 'moka.rs' airborne_server --exec cat -n
rg -n 'max_capacity|time_to_live|time_to_idle' airborne_server --no-heading -C1

Repository: juspay/airborne

Length of output: 10454


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== configuration docs table =="
sed -n '55,70p' airborne_docs/docs/server/configuration.md

echo
echo "== release-config cache construction/usages =="
rg -n 'MokaCache::new|signature_cache_ttl|rc_signature_cache_ttl|CACHED.*SIGNATURE|cached.*signature|signature.*cache' airborne_server/src --no-heading -C 2

echo
echo "== config env parser =="
sed -n '90,280p' airborne_server/src/config.rs

Repository: juspay/airborne

Length of output: 21412


Fix the default value for RC_SIGNATURE_CACHE_TTL

airborne_server/src/config.rs uses DEFAULT_RC_SIGNATURE_CACHE_TTL_SECS = 60 * 60, so the documented default should be one hour, not 3600. The other cache-tuning claims are accurate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@airborne_docs/docs/server/configuration.md` at line 60, Update the documented
default for RC_SIGNATURE_CACHE_TTL in the configuration table to match
DEFAULT_RC_SIGNATURE_CACHE_TTL_SECS, preserving the existing one-hour value and
the surrounding cache-tuning descriptions.

}
"/api/signing-keys": {
"get": {
"description": "List the signing keys of an application, with the default key first. Every application\nis provisioned with a default key when it is created, so this is never empty. The\nprivate key is never returned — only the public key, which is enough to verify a\nsigned release config. Pass the organisation and application in the x-organisation and\nx-application headers. Requires a bearer token.",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

"this is never empty" contradicts the documented backfill path.

integrity.mdx (Line 15) and the verification guide (Line 32) both state that applications predating signing have no keys until the signingkeys boot migration runs. For those applications ListSigningKeys does return an empty list, so this description is wrong for exactly the case a reader is most likely to hit.

This file is generated — fix the doc comment in the Smithy source (smithy/models/signing.smithy) and regenerate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@airborne_docs/openapi/airborne.openapi.json` at line 2120, The
ListSigningKeys documentation incorrectly claims the response is never empty.
Update the corresponding doc comment for ListSigningKeys in signing.smithy to
state that applications created before signing support may have no keys until
the signingkeys boot migration runs, then regenerate the OpenAPI output.

Comment on lines +1236 to +1246
guard let signatureHeader = signatureHeader, !signatureHeader.isEmpty else {
// An unsigned response is not an error: the server omits the header when the
// application has no signing key. Tolerating it is what lets apps ship public keys
// before signing is switched on server-side.
logData["signature_present"] = false
let value = NSMutableDictionary()
value["reason"] = AJPSignatureVerificationError.missingHeader.reasonCode
value["trusted_key_ids"] = trustStore.keys.keys.sorted().joined(separator: ",")
self.tracker.trackLog("release_config_signature_missing", value: value, level: "warning")
return nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Header-stripping downgrade is accepted even when keys are configured.

With a configured trust store, an active network attacker (or proxy) only has to drop X-Airborne-Signature to get an arbitrary body accepted — verification silently becomes opt-out. The staged-rollout rationale is sound, but it should be a temporary posture, not permanent. Consider an enforcement switch (e.g. a delegate flag or a second delegate method) so integrators can require a signature once server-side signing is on, and default it to strict in a later major version.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@airborne_sdk_iOS/hyper-ota/Airborne/AirborneSwift/AJPApplicationManager.swift`
around lines 1236 - 1246, Update the signature-header handling around
AJPSignatureVerificationError.missingHeader to support configurable enforcement
when the trust store contains keys: allow the existing warning-and-nil path only
in an explicitly permissive rollout mode, and reject missing signatures in
strict mode. Add or reuse a delegate/configuration switch for this policy,
preserving compatibility while enabling integrators to require signatures.

Comment on lines +370 to +400
/// Sign a release-config body, reusing a cached signature for the same signing
/// key selector and `config.version`. A new config version must be minted
/// whenever the resolved release body changes.
pub async fn sign_release_config(
state: &AppState,
organisation: &str,
application: &str,
requested_key_id: Option<&str>,
config_version: &str,
body: &[u8],
) -> Result<Option<String>, ABError> {
// An empty config version would collide across releases, so never key on one.
let cacheable = !config_version.is_empty();
let selector = signature_cache_selector(requested_key_id)?;

let cache_key = state
.redis_cache
.as_ref()
.filter(|_| cacheable)
.map(|cache| {
(
cache,
signature_cache_key(cache, organisation, application, &selector, config_version),
)
});

if let Some((cache, key)) = &cache_key {
if let Some(hit) = cache.get::<CachedSignature>(key).await? {
return Ok(Some(signature_header_value(&hit.key_id, &hit.signature)));
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n -B3 -A12 'sign_release_config' airborne_server/src/release.rs
rg -nP -C4 '\bconfig\.version\b|config_version' airborne_server/src/release/utils.rs

Repository: juspay/airborne

Length of output: 2328


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== file sizes =="
wc -l airborne_server/src/release.rs airborne_server/src/signing/utils.rs 2>/dev/null || true

echo "== release handler around signature call =="
sed -n '1430,1495p' airborne_server/src/release.rs 2>/dev/null || true

echo "== release response / body construction references =="
rg -n -B10 -A35 'serve_release_handler|release_response|body|config_version' airborne_server/src/release.rs | sed -n '1,260p'

echo "== signing helper =="
sed -n '330,450p' airborne_server/src/signing/utils.rs 2>/dev/null || true

echo "== cache helper definitions =="
rg -n -B8 -A18 'fn signature_cache_key|signature_cache_selector|CachedSignature' airborne_server/src/signing/utils.rs 2>/dev/null || true

echo "== config.version handling in release utils =="
rg -n -B8 -A25 'config_version|config\.version|config-version|version' airborne_server/src/release/utils.rs 2>/dev/null | sed -n '1,320p'

Repository: juspay/airborne

Length of output: 37609


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate serve release handler definition/context =="
rg -n -B5 -A180 'async fn\s+.*serve.*release|serve_release_handler|ServeReleaseResponse|ServeRelease|get_release|read.*release' airborne_server/src/release.rs | sed -n '1,280p'

echo "== build_overrides package config property extraction =="
sed -n '470,830p' airborne_server/src/release/utils.rs 2>/dev/null || true

echo "== search for BuildOverrides and config_version extraction in utils =="
rg -n -B10 -A90 'struct BuildOverrides|impl BuildOverrides|config_version|BuildOverrides|PackageConfig|release_config' airborne_server/src/release/utils.rs | sed -n '1,320p'

echo "== deterministic shape of cache key vs body dependency from source =="
python3 - <<'PY'
from pathlib import Path
import re

release = Path('airborne_server/src/release.rs').read_text()
utils = Path('airborne_server/src/release/utils.rs').read_text()
signing = Path('airborne_server/src/signing/utils.rs').read_text()

checks = {
    "signing_cache_key_uses_config_version_only": 'signature_cache_key(cache, organisation, application, &selector, config_version)' in signing,
    "handler_passes_release_config_version_and_body": release.count('signing::utils::sign_release_config(\n        &state,') > 0 and '&release_response.config.version,\n        &body,' in release,
    "cache_hit_returns_entry_signature_only": 'return Ok(Some(signature_header_value(&hit.key_id, &hit.signature)));' in signing,
}
for key,val in checks.items():
    print(f"{key}: {val}")
PY

Repository: juspay/airborne

Length of output: 38081


Keep the release-config signature cache scoped to the exact response body.

sign_release_config now returns a cached signature for the same (org, app, signing_key, config.version), but serve_release builds and signs the whole release body, which includes resolved file URLs/checksums that can vary by x-dimension. A hit can serve a signature over a different body, causing clients to reject the config before falling off OTA updates. Cache only when the signature was computed over the exact body bytes, or include a body digest dimension/context in the cache key.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@airborne_server/src/signing/utils.rs` around lines 370 - 400, The cache
lookup in sign_release_config must be scoped to the exact body being signed.
Include a digest or equivalent body-specific context in signature_cache_key and
use it consistently for lookup and storage, or bypass caching when the body
cannot be safely keyed; preserve cache reuse only when organisation,
application, signing selector, config_version, and body bytes all match.

Comment on lines +418 to +427
let signature = match private_key.and_then(|private_key| sign_payload(&private_key, body)) {
Ok(signature) => signature,
Err(e) => {
error!(
"Failed to sign release config for {organisation}/{application}: {e}. \
Serving unsigned."
);
return Ok(None);
}
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Signing failures fail open, and the iOS client tolerates a missing header — together that is a silent downgrade path.

A signing error here logs and serves the config unsigned. verifyReleaseConfigSignature in airborne_sdk_iOS/.../AJPApplicationManager.swift treats an absent X-Airborne-Signature as acceptable, so anything that makes signing fail for an application (unreadable key material, a master-key rotation that breaks decryption) downgrades every client from verified to unverified without any client-visible signal.

Fail-open is a defensible availability choice, but it deserves an alert-grade signal rather than only an error! log — a metric on unsigned-serves-for-a-signing-app so this cannot go unnoticed. Worth also considering whether the SDK should distinguish "app has no keys" from "app has keys but this response was unsigned" (the client cannot tell today).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@airborne_server/src/signing/utils.rs` around lines 418 - 427, Add an
alert-grade metric in the signing failure branch of the release-config signing
flow, alongside the existing error log and before returning Ok(None), to count
unsigned serves for applications configured for signing. Preserve the current
fail-open response behavior, and use the existing metrics/instrumentation symbol
or pattern rather than changing SDK verification behavior.

Comment on lines +553 to +575
run_blocking!({
let mut conn = pool.get()?;

// The very first key for an application becomes its default, so an app
// is never left with keys but nothing to sign with.
let has_default = diesel::select(diesel::dsl::exists(
signing_keys_table
.filter(sk_org_id.eq(&organisation))
.filter(sk_app_id.eq(&application))
.filter(sk_is_default.eq(true)),
))
.get_result::<bool>(&mut conn)?;

let new_key = NewSigningKey {
id: Uuid::new_v4(),
org_id: organisation.clone(),
app_id: application.clone(),
name: key_id.clone(),
algorithm: ALGORITHM.to_string(),
public_key: keypair.public_key.clone(),
private_key_encrypted: private_key_encrypted.clone(),
is_default: !has_default,
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

has_default check and the insert are not atomic.

Two concurrent POST /signing-keys for an application with no default both read has_default = false and both insert with is_default = true. Unless the migration adds a partial unique index on (org_id, app_id) WHERE is_default, the application ends up with two defaults and fetch_default_key_db returns whichever row the planner picks — different instances can then sign with different keys. Wrapping the exists-check and insert in conn.transaction (as set_default_key already does) closes it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@airborne_server/src/signing/utils.rs` around lines 553 - 575, Wrap the
has_default existence check and the subsequent NewSigningKey insert in the same
conn.transaction closure within the signing-key creation flow. Preserve the
existing default assignment logic while ensuring concurrent requests cannot both
observe no default and insert competing default keys, following the transaction
pattern used by set_default_key.

Comment on lines +696 to +720
run_blocking!({
let mut conn = pool.get()?;

let has_default = diesel::select(diesel::dsl::exists(
signing_keys_table
.filter(sk_org_id.eq(&organisation))
.filter(sk_app_id.eq(&application))
.filter(sk_is_default.eq(true)),
))
.get_result::<bool>(&mut conn)?;

if has_default {
return Ok(());
}

let new_key = NewSigningKey {
id: Uuid::new_v4(),
org_id: organisation.clone(),
app_id: application.clone(),
name: DEFAULT_KEY_ID.to_string(),
algorithm: ALGORITHM.to_string(),
public_key: keypair.public_key.clone(),
private_key_encrypted: private_key_encrypted.clone(),
is_default: true,
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Correctness here is fully delegated to a unique constraint.

The has_default read and the insert are separate statements, so the comment at Line 727 ("Another request or instance provisioned it first") only holds if the migration actually declares a unique index that a second default insert violates. Without it, concurrent boots/app-creations insert two is_default = true rows and no error is raised.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@airborne_server/src/signing/utils.rs` around lines 696 - 720, Add or verify a
database migration declaring a unique constraint/index on the organisation,
application, and default-key condition used by the has_default check in the
signing-key provisioning flow. Ensure the insert in the run_blocking block
violates that constraint for concurrent default-key creation, preserving the
existing conflict-handling path.

Comment on lines +7 to +9
const DEFAULT_CAPACITY: u64 = 1_024;
const DEFAULT_TTL: Duration = Duration::from_secs(60 * 60);
const DEFAULT_TTI: Duration = Duration::from_secs(15 * 60);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check how AppState.moka_cache is constructed and whether decrypted-key caching uses a dedicated policy.
rg -nP -A5 'MokaCache::(new|with_policy)\(' airborne_server/src

Repository: juspay/airborne

Length of output: 2000


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== main.rs AppState construction =="
sed -n '420,455p' airborne_server/src/main.rs

echo
echo "== moka.rs defaults and new implementation =="
sed -n '1,80p' airborne_server/src/utils/moka.rs

echo
echo "== search for with_policy or key policy construction =="
rg -n 'with_policy|MokaCache::|get_or_try_set|decrypted_signing_key_cache_key|signing_key' airborne_server/src

Repository: juspay/airborne

Length of output: 9406


Use a short, key-specific TTL for decrypted signing keys.

AppState is initialized with MokaCache::new("airborne"), and decrypted signing key material is cached via that same shared instance; there is no with_policy(...) usage for this code path. With the current defaults, plaintext private key material can persist for up to 60m TTL / 15m idle time. Use a dedicated cache policy or shorter TTL for decrypted key material.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@airborne_server/src/utils/moka.rs` around lines 7 - 9, Update the decrypted
signing-key cache path used by AppState and MokaCache::new("airborne") to use a
dedicated policy with a substantially shorter TTL and idle timeout than
DEFAULT_TTL and DEFAULT_TTI. Keep general cache defaults unchanged, and ensure
the key-specific cache configuration is actually applied rather than only
defining new constants.

Replace the keyid -> public-key map with one pinned (key id, public key).
publicKeys() -> [String: String] on AirborneDelegate becomes publicKey() ->
String plus signingKeyId() -> String. The SDK accepts a release config only
when the response's keyid equals signingKeyId() and the signature verifies
against publicKey().

Trusting exactly one key means changing it requires an app release, rather
than rotating in place across several trusted keys.

- AJPReleaseConfigTrustStore now holds a single (keyID, publicKey); verify()
  takes expectedKeyID + trustedKey; the .noTrustedKeys error is removed.
- A half-configuration (a key id without a PEM, or a malformed PEM) is
  configured-but-unusable: signed configs are rejected (invalid_signing_config)
  rather than silently accepted, and a boot-time signing_key_invalid event fires.
- Update the delegate wiring, manager hook, unit + live tests, and docs
  (rotation now requires an app release on iOS).
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.

1 participant