Compare commits

...

9 commits

Author SHA1 Message Date
Arena Agent
3c9dc7943a classify Site schema versions before decoding
Some checks failed
sms / robius-sms (push) Waiting to run
sms / android (push) Waiting to run
sms / nigig-sms (push) Waiting to run
sms / supply-chain (push) Waiting to run
spreadsheet / engine-coverage (push) Waiting to run
spreadsheet / ui-controller-coverage (push) Waiting to run
traffic / gates (push) Waiting to run
traffic / nigig-traffic (push) Waiting to run
traffic / supply-chain (push) Waiting to run
nigig-site / SITE-02 native provider/filesystem (macos-latest) (push) Failing after 1h5m28s
nigig-site / Owned paths and honest test contracts (push) Has been cancelled
nigig-site / Cargo check-all-targets (push) Has been cancelled
nigig-site / Cargo clippy-site-owned (push) Has been cancelled
nigig-site / Cargo contained-media-export-fixtures (push) Has been cancelled
nigig-site / Cargo containment-storage-crypto (push) Has been cancelled
nigig-site / Cargo integration-non-live (push) Has been cancelled
nigig-site / Cargo production-dependency-containment (push) Has been cancelled
nigig-site / Cargo site02-crypto (push) Has been cancelled
nigig-site / Cargo site02-repository (push) Has been cancelled
nigig-site / Cargo site02-store (push) Has been cancelled
nigig-site / Cargo unit (push) Has been cancelled
nigig-site / SITE-02 native provider/filesystem (ubuntu-latest) (push) Has been cancelled
nigig-site / SITE-02 native provider/filesystem (windows-latest) (push) Has been cancelled
nigig-site / SITE-02 desktop runtime and normal shutdown (push) Has been cancelled
nigig-site / SITE-02 migration, recovery, and fault corpus (push) Has been cancelled
nigig-site / Media limits (explicitly skipped until enabled) (push) Has been cancelled
nigig-site / Real server interoperability (explicitly skipped until enabled) (push) Has been cancelled
nigig-site / Security and supply-chain baseline (push) Has been cancelled
nigig-site / Release capability gate (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
2026-09-13 07:22:38 +00:00
Arena Agent
bbce8fb015 harden SITE-02 repository and native vault contracts 2026-09-13 06:51:39 +00:00
Arena Agent
92f1508325 site: verify native vault and production dependency trust 2026-09-12 23:03:56 +00:00
Arena Agent
c568a99948 site: exercise abrupt SITE-02 publication exits 2026-09-12 22:21:22 +00:00
Arena Agent
8c786ae163 site: define blocked SITE-02 key lifecycle review 2026-09-12 22:18:22 +00:00
Arena Agent
6d6f887ba6 site: harden SITE-02 process and scope boundaries 2026-09-12 22:11:59 +00:00
Arena Agent
a7a057f44a site: gate SITE-02 runtime and security review 2026-09-12 21:54:30 +00:00
Arena Agent
aa10b06d9b site: prove locked legacy migration contracts 2026-09-12 21:47:35 +00:00
Arena Agent
8dbfae8f72 site: add SITE-02 encrypted repository candidate 2026-09-12 21:44:00 +00:00
25 changed files with 5706 additions and 1022 deletions

View file

@ -38,7 +38,7 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
enforce_release_gates: enforce_release_gates:
description: 'Fail unless runtime, migration, media, and real-server gates are enabled' description: 'Enforce exact SITE-02 approval plus all later release capabilities'
required: true required: true
type: boolean type: boolean
default: false default: false
@ -98,9 +98,15 @@ jobs:
fi fi
printf 'matched %d Site-owned source/manifest/tool files\n' "${#files[@]}" printf 'matched %d Site-owned source/manifest/tool files\n' "${#files[@]}"
test -f crates/apps/nigig-site/src/store.rs test -f crates/apps/nigig-site/src/store.rs
test -f crates/apps/nigig-site/src/repository.rs
test -f crates/apps/nigig-site/SITE_02_SECURITY_REVIEW.md
test -f crates/apps/nigig-site/SITE_02_KEY_LIFECYCLE_DESIGN.md
test -f crates/apps/nigig-site/tests/sync_e2e.rs test -f crates/apps/nigig-site/tests/sync_e2e.rs
test -f crates/apps/nigig-site/tools/ci-cargo.sh test -f crates/apps/nigig-site/tools/ci-cargo.sh
test -x crates/apps/nigig-site/tools/check-production-deps.sh test -x crates/apps/nigig-site/tools/check-production-deps.sh
test -x crates/apps/nigig-site/tools/runtime-smoke.sh
test -x crates/apps/nigig-site/tools/native-keyring-smoke.sh
test -x crates/apps/nigig-site/tools/audit-production-deps.py
test -f .forgejo/workflows/nigig-site.yml test -f .forgejo/workflows/nigig-site.yml
- name: SITE-01 production containment is structural and fail-closed - name: SITE-01 production containment is structural and fail-closed
@ -113,8 +119,13 @@ jobs:
root = Path('crates/apps/nigig-site') root = Path('crates/apps/nigig-site')
lib = (root / 'src/lib.rs').read_text() lib = (root / 'src/lib.rs').read_text()
for module in ('doc_export', 'gif', 'ocr', 'report_pdf', 'video'): for module in ('doc_export', 'gif', 'ocr', 'report_pdf', 'video'):
pattern = rf'#\[cfg\(test\)\]\s+pub mod {module};' pattern = (
assert re.search(pattern, lib), f'{module} must remain test-only' rf'#\[cfg\(all\(test, target_os = "linux"\)\)\]'
rf'\s+pub mod {module};'
)
assert re.search(pattern, lib), (
f'{module} must remain Linux-CI-only test code'
)
manifest = tomllib.loads((root / 'Cargo.toml').read_text()) manifest = tomllib.loads((root / 'Cargo.toml').read_text())
features = set(manifest.get('features', {})) features = set(manifest.get('features', {}))
@ -122,6 +133,20 @@ jobs:
'feature configuration could bypass SITE-01 containment', features 'feature configuration could bypass SITE-01 containment', features
) )
production_deps = manifest.get('dependencies', {}) production_deps = manifest.get('dependencies', {})
assert not manifest.get('dev-dependencies'), (
'broad fixture dependencies must not enter every native test target'
)
linux_dev = manifest.get('target', {}).get(
'cfg(target_os = "linux")', {}
).get('dev-dependencies', {})
for dependency in (
'makepad-test', 'nigig-core', 'nigig-pdf-cos',
'nigig-pdf-document', 'nigig-pdf-graphics',
'nigig_doc_scanner', 'image', 'weezl', 'zip', 'reqwest',
):
assert dependency in linux_dev, (
f'Linux-owned fixture dependency missing: {dependency}'
)
for dependency in ( for dependency in (
'nigig-core', 'nigig-uikit', 'doc-ui', 'reqwest', 'nigig-core', 'nigig-uikit', 'doc-ui', 'reqwest',
'makepad-ai-hub', 'makepad-system-speech', 'makepad-ai-hub', 'makepad-system-speech',
@ -168,6 +193,7 @@ jobs:
'CameraWidget', 'get_latest_location', 'makepad_system_speech', 'CameraWidget', 'get_latest_location', 'makepad_system_speech',
'robius_notification::', 'photos_to_gif_file', 'robius_notification::', 'photos_to_gif_file',
'photos_to_clip_file', 'request_send_text', 'demo-site', 'photos_to_clip_file', 'request_send_text', 'demo-site',
'Muthaiga Villas',
): ):
assert token not in active, f'reachable contained capability found: {token}' assert token not in active, f'reachable contained capability found: {token}'
@ -199,10 +225,89 @@ jobs:
store = (root / 'src/store.rs').read_text() store = (root / 'src/store.rs').read_text()
assert 'pub fn save(' not in store and 'pub fn save_async(' not in store assert 'pub fn save(' not in store and 'pub fn save_async(' not in store
assert 'pub fn load()' not in store assert 'pub fn load()' not in store
assert 'flush_writer_queue' not in store
crypto = (root / 'src/crypto.rs').read_text() crypto = (root / 'src/crypto.rs').read_text()
assert 'set_password' not in crypto and 'load_or_create' not in crypto assert 'set_password' not in crypto and 'load_or_create' not in crypto
print('SITE-01 containment contract passed')
# SITE-02 candidate: strict envelope, exact native-key lookup,
# bounded/coalesced persistence, explicit health, and hard locks.
repository = (root / 'src/repository.rs').read_text()
manifest_targets = manifest.get('target', {})
assert 'keyring' not in production_deps, 'all-in-one keyring facade restored'
assert 'keyring-core' in production_deps and 'zeroize' in production_deps
aes_gcm = production_deps.get('aes-gcm', {})
assert aes_gcm.get('default-features') is False
assert {'aes', 'alloc', 'zeroize'} <= set(aes_gcm.get('features', []))
for dependency in ('aes', 'ghash', 'polyval'):
configured = production_deps.get(dependency, {})
assert 'zeroize' in configured.get('features', []), (
f'crypto backend zeroization feature missing: {dependency}'
)
target_text = (root / 'Cargo.toml').read_text()
for provider in (
'zbus-secret-service-keyring-store',
'apple-native-keyring-store',
'windows-native-keyring-store',
):
assert provider in target_text, f'explicit native provider missing: {provider}'
assert 'windows-sys' in target_text and 'Win32_Storage_FileSystem' in target_text
for token in (
'NIGIG2', 'Payload {', 'aad:', 'MAX_PLAINTEXT_BYTES',
'NonceInvocationLimit', 'AuthenticationFailed', 'cipher_for',
'envelope_matches_independent_aes_gcm_known_answer',
):
assert token in crypto, f'SITE-02 crypto contract missing: {token}'
for token in (
'CredentialPersistence::UntilDelete', 'create_new(true)',
'O_NOFOLLOW', 'try_lock()', 'read_expected_current',
'flush()', 'FlushFailed', 'sync_all()', 'std::fs::rename',
'CanonicalReadbackFailed',
'rollback_publication', 'pending: Option<Pending<T>>',
'pending_depth', 'flush_and_shutdown',
'impl<T> Drop for RepositoryWriter<T>',
'abrupt_process_termination_is_fail_closed_at_every_commit_stage',
'concurrent_writers_serialize_and_exactly_one_stale_commit_fails',
'SchemaVersionProbe', 'open_versioned_json',
'linux_native_provider_real_vault_lifecycle',
'apple_windows_native_provider_real_vault_lifecycle',
'attributes.get("persistence")', 'value == "Local"',
'FILE_ATTRIBUTE_REPARSE_POINT', 'GetFileInformationByHandle',
'nNumberOfLinks', 'windows-reparse-real',
'validate_canonical_permissions', 'reject_symlink_chain',
):
assert token in repository, f'SITE-02 repository contract missing: {token}'
for token in (
'mutate_scoped', 'profile_mutation_fence', 'site_mutation_fence',
'PersistenceHealth', 'accepted_revision', 'durable_revision',
'deny_unknown_fields', 'open_versioned_json::<SiteStore>(STORE_VERSION)',
'OlderVersionMigrationRequired', 'SecurityReviewRequired',
'setup_review_locked', 'migration_review_locked',
):
assert token in store, f'SITE-02 runtime contract missing: {token}'
assert '#[cfg(test)]\n fn migrate_legacy_json' in repository, (
'migration execution harness must remain test-only while review is pending'
)
assert 'pub(crate) mod repository;' in lib
assert 'pub mod repository;' not in lib
assert 'impl Drop for SiteStandaloneApp' in main
assert 'persistence_health' in main and 'flush_and_shutdown(5_000)' in main
assert 'SITE-02-SECURITY-REVIEW-REQUIRED' in store
workflow = Path('.forgejo/workflows/nigig-site.yml').read_text()
assert 'cargo metadata --locked --format-version 1 --all-features' in workflow, (
'RustSec production-graph audit must retain conservative feature resolution'
)
# Every confidential screen mutation is explicitly scoped. The only
# broad mutations left are profile-level site create/select actions.
for screen in ('approvals.rs', 'meetings.rs', 'procurement.rs', 'report_editor.rs'):
text = (root / 'src/site_frame/screens' / screen).read_text()
assert 'SiteStore::mutate(' not in text, f'unscoped mutation in {screen}'
assert 'SiteStore::mutate_profile' not in text, f'profile mutation in {screen}'
assert 'SiteStore::mutate_scoped' in text, f'no scoped mutation in {screen}'
sites = (root / 'src/site_frame/screens/sites.rs').read_text()
assert 'SiteStore::mutate_profile' in sites
assert 'pub fn mutate(' not in store
assert 'selected_or_first' not in store
print('SITE-01 containment and SITE-02 hard-lock contracts passed')
PY PY
- name: Live E2E must be explicitly ignored, never early-return green - name: Live E2E must be explicitly ignored, never early-return green
@ -212,6 +317,9 @@ jobs:
from pathlib import Path from pathlib import Path
p = Path('crates/apps/nigig-site/tests/sync_e2e.rs') p = Path('crates/apps/nigig-site/tests/sync_e2e.rs')
s = p.read_text() s = p.read_text()
assert '#![cfg(target_os = "linux")]' in s, (
'live transport fixture must not enter native provider test builds'
)
assert '#[ignore = ' in s, 'live test must be an explicit ignored test' assert '#[ignore = ' in s, 'live test must be an explicit ignored test'
assert 'NIMANYATTA_E2E_URL' in s, 'live test must name required config' assert 'NIMANYATTA_E2E_URL' in s, 'live test must name required config'
assert 'fn live_round_trip()' in s, 'live test entry point missing' assert 'fn live_round_trip()' in s, 'live test entry point missing'
@ -263,7 +371,16 @@ jobs:
command: cargo test --locked -p nigig-site --tests -- --test-threads=1 command: cargo test --locked -p nigig-site --tests -- --test-threads=1
- label: containment-storage-crypto - label: containment-storage-crypto
artifact: containment-storage artifact: containment-storage
command: cargo test --locked -p nigig-site --lib containment::tests -- --test-threads=1 && cargo test --locked -p nigig-site --lib ai_refine::tests -- --test-threads=1 && cargo test --locked -p nigig-site --lib crypto::tests -- --test-threads=1 && cargo test --locked -p nigig-site --lib store::tests -- --test-threads=1 command: cargo test --locked -p nigig-site --lib containment::tests -- --test-threads=1 && cargo test --locked -p nigig-site --lib ai_refine::tests -- --test-threads=1 && cargo test --locked -p nigig-site --lib crypto::tests -- --test-threads=1 && cargo test --locked -p nigig-site --lib repository::tests -- --test-threads=1 && cargo test --locked -p nigig-site --lib store::tests -- --test-threads=1
- label: site02-crypto
artifact: site02-crypto
command: cargo test --locked -p nigig-site --lib crypto::tests -- --test-threads=1
- label: site02-repository
artifact: site02-repository
command: cargo test --locked -p nigig-site --lib repository::tests -- --test-threads=1
- label: site02-store
artifact: site02-store
command: cargo test --locked -p nigig-site --lib store::tests -- --test-threads=1
- label: contained-media-export-fixtures - label: contained-media-export-fixtures
artifact: contained-media artifact: contained-media
command: cargo test --locked -p nigig-site --lib gif::tests -- --test-threads=1 && cargo test --locked -p nigig-site --lib video::tests -- --test-threads=1 && cargo test --locked -p nigig-site --lib ocr::tests -- --test-threads=1 && cargo test --locked -p nigig-site --lib report_pdf::tests -- --test-threads=1 && cargo test --locked -p nigig-site --lib doc_export::tests -- --test-threads=1 command: cargo test --locked -p nigig-site --lib gif::tests -- --test-threads=1 && cargo test --locked -p nigig-site --lib video::tests -- --test-threads=1 && cargo test --locked -p nigig-site --lib ocr::tests -- --test-threads=1 && cargo test --locked -p nigig-site --lib report_pdf::tests -- --test-threads=1 && cargo test --locked -p nigig-site --lib doc_export::tests -- --test-threads=1
@ -289,20 +406,87 @@ jobs:
if-no-files-found: error if-no-files-found: error
retention-days: 14 retention-days: 14
native-platform-contracts:
name: SITE-02 native provider/filesystem (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 65
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install Linux native build and disposable-vault dependencies
if: runner.os == 'Linux'
shell: bash
env:
NIGIG_SITE_INSTALL_NATIVE_KEYRING: '1'
run: crates/apps/nigig-site/tools/ci-setup-ubuntu.sh
- name: Compile the target-native provider and repository
shell: bash
run: |
set -euo pipefail
mkdir -p /tmp/nigig-site-native-evidence
{
rustc --version --verbose
cargo --version --verbose
cargo check --locked -p nigig-site --tests
cargo clippy --locked -p nigig-site --tests --no-deps -- -D warnings
} 2>&1 | tee /tmp/nigig-site-native-evidence/compile.log
- name: Execute target-native crypto/repository/store contracts
shell: bash
run: |
set -euo pipefail
{
cargo test --locked -p nigig-site --lib crypto::tests -- --test-threads=1
cargo test --locked -p nigig-site --lib repository::tests -- --test-threads=1
cargo test --locked -p nigig-site --lib store::tests -- --test-threads=1
} 2>&1 | tee /tmp/nigig-site-native-evidence/contracts.log
- name: Exercise a disposable real Secret Service vault
if: runner.os == 'Linux'
shell: bash
run: |
set -euo pipefail
crates/apps/nigig-site/tools/native-keyring-smoke.sh \
/tmp/nigig-site-native-keyring 2>&1 | \
tee /tmp/nigig-site-native-evidence/linux-secret-service.log
test ! -e /tmp/nigig-site-native-keyring
- name: Exercise a disposable Apple or Windows native vault
if: runner.os == 'macOS' || runner.os == 'Windows'
shell: bash
env:
NIGIG_SITE_LIVE_KEYRING_TEST: isolated-ci-native-v1
run: |
set -euo pipefail
cargo test --locked -p nigig-site --lib \
repository::tests::apple_windows_native_provider_real_vault_lifecycle -- \
--ignored --exact --test-threads=1 2>&1 | \
tee /tmp/nigig-site-native-evidence/native-vault.log
- name: Upload target-native evidence
if: always()
uses: forgejo/upload-artifact@16871d9e8cfcf27ff31822cac382bbb5450f1e1e # v4
with:
name: nigig-site-native-${{ runner.os }}-${{ github.sha }}
path: /tmp/nigig-site-native-evidence/
if-no-files-found: warn
retention-days: 14
runtime-ui: runtime-ui:
name: Runtime UI (explicitly skipped until enabled) name: SITE-02 desktop runtime and normal shutdown
if: ${{ vars.NIGIG_SITE_RUNTIME_UI_ENABLED == 'true' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 65 timeout-minutes: 65
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- run: crates/apps/nigig-site/tools/ci-setup-ubuntu.sh - run: crates/apps/nigig-site/tools/ci-setup-ubuntu.sh
- name: Execute real runtime assertions - name: Build the real desktop binary
run: |
crates/apps/nigig-site/tools/ci-cargo.sh runtime-build \
cargo build --locked -p nigig-site --bin nigig-site
- name: Render safe mode, close normally, and prove startup wrote no repository
run: | run: |
set -euo pipefail set -euo pipefail
test -f crates/apps/nigig-site/tests/runtime_ui.rs crates/apps/nigig-site/tools/runtime-smoke.sh \
crates/apps/nigig-site/tools/ci-cargo.sh runtime-ui \ target/debug/nigig-site /tmp/nigig-site-ci
cargo test --locked -p nigig-site --test runtime_ui -- --test-threads=1
- if: always() - if: always()
uses: forgejo/upload-artifact@16871d9e8cfcf27ff31822cac382bbb5450f1e1e # v4 uses: forgejo/upload-artifact@16871d9e8cfcf27ff31822cac382bbb5450f1e1e # v4
with: with:
@ -312,19 +496,20 @@ jobs:
retention-days: 14 retention-days: 14
migration-recovery: migration-recovery:
name: Migration and recovery (explicitly skipped until enabled) name: SITE-02 migration, recovery, and fault corpus
if: ${{ vars.NIGIG_SITE_MIGRATION_GATE_ENABLED == 'true' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 65 timeout-minutes: 65
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- run: crates/apps/nigig-site/tools/ci-setup-ubuntu.sh - run: crates/apps/nigig-site/tools/ci-setup-ubuntu.sh
- name: Execute recovery and fault corpus - name: Execute strict envelope and locked migration/recovery tests
run: | run: |
set -euo pipefail set -euo pipefail
test -f crates/apps/nigig-site/tests/storage_recovery.rs crates/apps/nigig-site/tools/ci-cargo.sh migration-recovery bash -lc '
crates/apps/nigig-site/tools/ci-cargo.sh migration-recovery \ cargo test --locked -p nigig-site --lib crypto::tests -- --test-threads=1
cargo test --locked -p nigig-site --test storage_recovery -- --test-threads=1 cargo test --locked -p nigig-site --lib repository::tests -- --test-threads=1
cargo test --locked -p nigig-site --lib store::tests -- --test-threads=1
'
- if: always() - if: always()
uses: forgejo/upload-artifact@16871d9e8cfcf27ff31822cac382bbb5450f1e1e # v4 uses: forgejo/upload-artifact@16871d9e8cfcf27ff31822cac382bbb5450f1e1e # v4
with: with:
@ -384,7 +569,7 @@ jobs:
security-supply-chain: security-supply-chain:
name: Security and supply-chain baseline name: Security and supply-chain baseline
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20 timeout-minutes: 35
steps: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
@ -392,9 +577,28 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
test -f Cargo.lock test -f Cargo.lock
cargo metadata --locked --format-version 1 >/tmp/nigig-site-metadata.json # Resolve a conservative all-feature superset as well as every target
# predicate; the Site crate itself has no feature bypass surface.
cargo metadata --locked --format-version 1 --all-features \
>/tmp/nigig-site-metadata.json
git diff --exit-code -- Cargo.lock git diff --exit-code -- Cargo.lock
- name: Audit the Site production graph against RustSec
run: |
set -euo pipefail
cargo install cargo-audit --version 0.22.2 --locked
set +e
cargo audit --file Cargo.lock --json > /tmp/nigig-site-audit.json
audit_status=$?
set -e
test "$audit_status" -eq 0 || test "$audit_status" -eq 1
test -s /tmp/nigig-site-audit.json
crates/apps/nigig-site/tools/audit-production-deps.py \
/tmp/nigig-site-metadata.json \
/tmp/nigig-site-audit.json \
/tmp/nigig-site-rustsec-report.txt
echo "workspace cargo-audit exit=${audit_status}; scoped report is authoritative for this production graph"
- name: Every live git dependency has a full immutable revision - name: Every live git dependency has a full immutable revision
run: | run: |
set -euo pipefail set -euo pipefail
@ -441,14 +645,83 @@ jobs:
fi fi
echo 'no Cargo failure suppression found' echo 'no Cargo failure suppression found'
- name: SITE-02 review packet and plaintext policy are explicit
run: |
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
root = Path('crates/apps/nigig-site')
review = (root / 'SITE_02_SECURITY_REVIEW.md').read_text()
lifecycle = (root / 'SITE_02_KEY_LIFECYCLE_DESIGN.md').read_text()
assert '**Decision status:** `PROPOSED — NOT APPROVED`' in lifecycle
assert '**Production implementation:** `ABSENT / BLOCKED`' in lifecycle
assert '**Independent cryptography reviewer:** `UNASSIGNED`' in lifecycle
pending = '**Security decision:** `NOT APPROVED`' in review
approved = '**Security decision:** `APPROVED`' in review
assert pending != approved, 'review decision must be exactly pending or approved'
if pending:
assert '**Production activation:** `BLOCKED`' in review
else:
assert '**Production activation:** `APPROVED`' in review
assert '**Independent reviewer:** `UNASSIGNED`' not in review
assert '**Decision status:** `APPROVED`' in lifecycle
assert '**Production implementation:** `IMPLEMENTED / ENABLED`' in lifecycle
assert '**Independent cryptography reviewer:** `UNASSIGNED`' not in lifecycle
for blocker in [f'B{i}' for i in range(1, 13)]:
assert f'| {blocker} |' in review, f'missing review blocker {blocker}'
crypto = (root / 'src/crypto.rs').read_text()
repository = (root / 'src/repository.rs').read_text()
production_repository = repository.split('\n#[cfg(test)]\nmod tests {', 1)[0]
assert len(production_repository) < len(repository), 'test boundary not found'
assert 'const ALG_PLAINTEXT:' not in crypto
assert 'plaintext fallback' in crypto.lower()
assert 'write_all(envelope)' in production_repository
assert 'write_all(&plaintext)' not in production_repository
assert 'set_password' not in production_repository
assert 'set_secret' not in production_repository
assert 'fn create_key' not in production_repository
print('pending review is explicit; production has no plaintext/key-creation path')
PY
- name: Upload RustSec evidence
if: always()
uses: forgejo/upload-artifact@16871d9e8cfcf27ff31822cac382bbb5450f1e1e # v4
with:
name: nigig-site-rustsec-${{ github.sha }}
path: |
/tmp/nigig-site-audit.json
/tmp/nigig-site-rustsec-report.txt
if-no-files-found: warn
retention-days: 14
release-capability-gate: release-capability-gate:
name: Release capability gate name: Release capability gate
if: always()
needs:
- ownership-and-contracts
- cargo-gates
- native-platform-contracts
- runtime-ui
- migration-recovery
- media-limits
- sync-interoperability
- security-supply-chain
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 10
env: env:
OWNERSHIP_RESULT: ${{ needs.ownership-and-contracts.result }}
CARGO_RESULT: ${{ needs.cargo-gates.result }}
NATIVE_PLATFORM_RESULT: ${{ needs.native-platform-contracts.result }}
RUNTIME_RESULT: ${{ needs.runtime-ui.result }}
MIGRATION_RESULT: ${{ needs.migration-recovery.result }}
SECURITY_RESULT: ${{ needs.security-supply-chain.result }}
MEDIA_RESULT: ${{ needs.media-limits.result }}
SYNC_RESULT: ${{ needs.sync-interoperability.result }}
ENFORCE_RELEASE_GATES: ${{ inputs.enforce_release_gates }} ENFORCE_RELEASE_GATES: ${{ inputs.enforce_release_gates }}
RUNTIME_ENABLED: ${{ vars.NIGIG_SITE_RUNTIME_UI_ENABLED }} SITE02_APPROVED: ${{ vars.NIGIG_SITE_02_SECURITY_APPROVED }}
MIGRATION_ENABLED: ${{ vars.NIGIG_SITE_MIGRATION_GATE_ENABLED }} SITE02_APPROVED_COMMIT: ${{ vars.NIGIG_SITE_02_APPROVED_COMMIT }}
MEDIA_ENABLED: ${{ vars.NIGIG_SITE_MEDIA_LIMITS_ENABLED }} MEDIA_ENABLED: ${{ vars.NIGIG_SITE_MEDIA_LIMITS_ENABLED }}
SYNC_ENABLED: ${{ vars.NIGIG_SITE_SYNC_E2E_ENABLED }} SYNC_ENABLED: ${{ vars.NIGIG_SITE_SYNC_E2E_ENABLED }}
NIMANYATTA_E2E_URL: ${{ secrets.NIMANYATTA_E2E_URL }} NIMANYATTA_E2E_URL: ${{ secrets.NIMANYATTA_E2E_URL }}
@ -457,25 +730,48 @@ jobs:
- name: Development status or hard release gate - name: Development status or hard release gate
run: | run: |
set -euo pipefail set -euo pipefail
for status in \
"$OWNERSHIP_RESULT" "$CARGO_RESULT" "$NATIVE_PLATFORM_RESULT" \
"$RUNTIME_RESULT" "$MIGRATION_RESULT" "$SECURITY_RESULT"; do
test "$status" = success
done
release=false release=false
case "${GITHUB_REF:-}" in refs/tags/*) release=true ;; esac case "${GITHUB_REF:-}" in refs/tags/*) release=true ;; esac
if [ "${ENFORCE_RELEASE_GATES:-false}" = true ]; then release=true; fi if [ "${ENFORCE_RELEASE_GATES:-false}" = true ]; then release=true; fi
if [ "$release" != true ]; then if [ "$release" != true ]; then
echo 'Development CI capability status:' echo 'Development CI capability status:'
echo " runtime-ui=${RUNTIME_ENABLED:-false} (disabled jobs are shown as skipped)" echo ' runtime-ui=mandatory job'
echo " migration=${MIGRATION_ENABLED:-false} (disabled jobs are shown as skipped)" echo ' migration/recovery=mandatory locked-design job'
echo " media-limits=${MEDIA_ENABLED:-false} (disabled jobs are shown as skipped)" echo " site02-security-approved=${SITE02_APPROVED:-false}"
echo " sync-e2e=${SYNC_ENABLED:-false} (disabled jobs are shown as skipped)" echo " media-limits=${MEDIA_ENABLED:-false} (later tranche; disabled job is skipped)"
echo " sync-e2e=${SYNC_ENABLED:-false} (later tranche; disabled job is skipped)"
exit 0 exit 0
fi fi
test "${RUNTIME_ENABLED:-false}" = true # Two independent facts are required: a repository variable naming
test "${MIGRATION_ENABLED:-false}" = true # the exact reviewed commit, and a signed-off packet in that commit.
test "${SITE02_APPROVED:-false}" = true
test -n "${SITE02_APPROVED_COMMIT:-}"
test "${SITE02_APPROVED_COMMIT}" = "${GITHUB_SHA}"
grep -Fq '**Security decision:** `APPROVED`' \
crates/apps/nigig-site/SITE_02_SECURITY_REVIEW.md
! grep -Fq '**Independent reviewer:** `UNASSIGNED`' \
crates/apps/nigig-site/SITE_02_SECURITY_REVIEW.md
grep -Fq '**Decision status:** `APPROVED`' \
crates/apps/nigig-site/SITE_02_KEY_LIFECYCLE_DESIGN.md
grep -Fq '**Production implementation:** `IMPLEMENTED / ENABLED`' \
crates/apps/nigig-site/SITE_02_KEY_LIFECYCLE_DESIGN.md
! grep -Fq '**Independent cryptography reviewer:** `UNASSIGNED`' \
crates/apps/nigig-site/SITE_02_KEY_LIFECYCLE_DESIGN.md
# Full application release still requires later-tranche capabilities
# to be enabled and to have actually succeeded for this exact run.
test "$MEDIA_RESULT" = success
test "$SYNC_RESULT" = success
test "${MEDIA_ENABLED:-false}" = true test "${MEDIA_ENABLED:-false}" = true
test "${SYNC_ENABLED:-false}" = true test "${SYNC_ENABLED:-false}" = true
test -n "$NIMANYATTA_E2E_URL" test -n "$NIMANYATTA_E2E_URL"
test -f crates/apps/nigig-site/tests/runtime_ui.rs
test -f crates/apps/nigig-site/tests/storage_recovery.rs
test -f crates/apps/nigig-site/tests/media_limits.rs test -f crates/apps/nigig-site/tests/media_limits.rs
test -d crates/nimanyatta/src test -d crates/nimanyatta/src
echo 'all required release capabilities are configured; their jobs still decide pass/fail' echo 'reviewed commit and all later release capabilities are configured; jobs still decide pass/fail'

15
Cargo.lock generated
View file

@ -38,6 +38,7 @@ dependencies = [
"cfg-if 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)", "cfg-if 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)",
"cipher 0.4.4", "cipher 0.4.4",
"cpufeatures 0.2.17", "cpufeatures 0.2.17",
"zeroize",
] ]
[[package]] [[package]]
@ -63,6 +64,7 @@ dependencies = [
"ctr", "ctr",
"ghash", "ghash",
"subtle", "subtle",
"zeroize",
] ]
[[package]] [[package]]
@ -1802,6 +1804,7 @@ checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [ dependencies = [
"opaque-debug", "opaque-debug",
"polyval", "polyval",
"zeroize",
] ]
[[package]] [[package]]
@ -4046,11 +4049,15 @@ name = "nigig-site"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"aead", "aead",
"aes 0.8.4",
"aes-gcm", "aes-gcm",
"apple-native-keyring-store",
"chrono", "chrono",
"getrandom 0.2.17", "getrandom 0.2.17",
"ghash",
"image", "image",
"keyring", "keyring-core",
"libc",
"makepad-test", "makepad-test",
"makepad-widgets", "makepad-widgets",
"nigig-core", "nigig-core",
@ -4058,6 +4065,7 @@ dependencies = [
"nigig-pdf-document", "nigig-pdf-document",
"nigig-pdf-graphics", "nigig-pdf-graphics",
"nigig_doc_scanner", "nigig_doc_scanner",
"polyval",
"reqwest", "reqwest",
"robius-directories", "robius-directories",
"serde", "serde",
@ -4065,6 +4073,10 @@ dependencies = [
"ulid", "ulid",
"uuid", "uuid",
"weezl 0.1.12 (registry+https://github.com/rust-lang/crates.io-index)", "weezl 0.1.12 (registry+https://github.com/rust-lang/crates.io-index)",
"windows-native-keyring-store",
"windows-sys 0.61.2",
"zbus-secret-service-keyring-store",
"zeroize",
"zip", "zip",
] ]
@ -4755,6 +4767,7 @@ dependencies = [
"cpufeatures 0.2.17", "cpufeatures 0.2.17",
"opaque-debug", "opaque-debug",
"universal-hash", "universal-hash",
"zeroize",
] ]
[[package]] [[package]]

View file

@ -13,12 +13,37 @@ chrono = { version = "0.4", features = ["serde"] }
ulid = { version = "1", features = ["serde"] } ulid = { version = "1", features = ["serde"] }
uuid = { version = "1", features = ["v4", "serde"] } uuid = { version = "1", features = ["v4", "serde"] }
# Fail-closed authenticated storage (see src/crypto.rs and src/store.rs). # Fail-closed authenticated storage (see src/crypto.rs and src/store.rs).
aes-gcm = "0.10" # `aes-gcm` 0.10 does not propagate its optional zeroization into the AES and
# GHASH backends, so the feature-only direct pins below deliberately unify those
# already-transitive crates with their drop-time zeroization support enabled.
aes-gcm = { version = "0.10", default-features = false, features = ["aes", "alloc", "zeroize"] }
aes = { version = "0.8.4", features = ["zeroize"] }
ghash = { version = "0.5.1", features = ["zeroize"] }
polyval = { version = "0.6.2", features = ["zeroize"] }
aead = "0.5" aead = "0.5"
keyring = "4"
getrandom = "0.2" getrandom = "0.2"
zeroize = "1"
keyring-core = "1"
[dev-dependencies] # Use one explicit native credential-store provider per desktop platform. The
# all-in-one `keyring` facade is intentionally not linked by production code.
[target.'cfg(target_os = "linux")'.dependencies]
zbus-secret-service-keyring-store = { version = "1", features = ["crypto-rust"] }
[target.'cfg(target_os = "macos")'.dependencies]
apple-native-keyring-store = { version = "1", features = ["keychain"] }
[target.'cfg(target_os = "windows")'.dependencies]
windows-native-keyring-store = { version = "1", default-features = false }
windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Storage_FileSystem"] }
[target.'cfg(unix)'.dependencies]
libc = "0.2"
# Contained media/export and live-network fixtures execute only in the owned
# Linux CI jobs. Keeping their broad dependency graph out of macOS/Windows test
# builds lets the target-native repository contracts compile independently.
[target.'cfg(target_os = "linux")'.dev-dependencies]
makepad-test = { workspace = true } makepad-test = { workspace = true }
nigig-core = { path = "../../nigig-core" } nigig-core = { path = "../../nigig-core" }
# Legacy media/export implementations are test-only containment fixtures. # Legacy media/export implementations are test-only containment fixtures.

View file

@ -0,0 +1,443 @@
# SITE-02 Key Lifecycle and Recovery Design Proposal
**Document date:** 2026-09-12
**Decision status:** `PROPOSED — NOT APPROVED`
**Production implementation:** `ABSENT / BLOCKED`
**Independent cryptography reviewer:** `UNASSIGNED`
**Applies to:** the `NIGIG2` encrypted repository candidate
This is a review input, not authorization to activate setup, recovery, rotation,
migration, or deletion. The compiled application must continue returning
`SITE-02-SECURITY-REVIEW-REQUIRED` for setup and migration until this design is
approved, implemented, exercised on every supported platform, and tied to an
exact release commit.
## 1. Goals and non-goals
### Goals
1. A missing or locked native-vault record never causes creation of a replacement
key for an existing repository.
2. Initial setup is explicit, recoverable, and transactional across the native
credential store and encrypted repository as far as platform APIs permit.
3. Recovery is controlled by the user and does not depend on a Nigig-operated
plaintext-key service.
4. Key rotation is resumable after a crash and never retires the only key capable
of opening the canonical repository.
5. Deletion distinguishes cryptographic erasure from physical media erasure and
never makes claims the operating system, SSD, backup service, or filesystem
cannot prove.
6. Restored or replaced old ciphertext is detected against a trusted state outside
the repository file.
7. Every lifecycle operation is auditable without logging domain data, DEKs,
recovery secrets, nonces, or credential-store payloads.
### Non-goals
- No password-derived encryption will be invented in this crate.
- No silent cloud escrow, administrator escrow, or telemetry is permitted.
- No recovery secret may be derived from email, phone number, site name, device
identifier, or another low-entropy value.
- “Secure delete” will not mean guaranteed physical overwrite on flash storage,
copy-on-write filesystems, snapshots, backups, or synchronized folders.
- This proposal does not split the legacy whole-store aggregate. SITE-03 must
assign or quarantine records before per-site repositories become authoritative.
## 2. Assets and threat model
### Protected assets
- The 256-bit repository data-encryption key (DEK).
- Recovery identities and recovery packages.
- Confidential site records in memory and at rest.
- Repository identity, key identity, revision, and rollback-anchor state.
- The user's ability to recover data after device loss or vault reset.
### Threats in scope
- Theft or offline copying of the repository directory.
- Accidental native-vault deletion, reset, lock, or transient unavailability.
- Application or operating-system crash at every lifecycle step.
- Restoration of an older repository file or backup.
- Two cooperative Nigig processes attempting lifecycle or write operations.
- Wrong, missing, retired, malformed, or mismatched credentials.
- Support logs, crash artifacts, temporary files, and CI artifacts leaking secrets.
- A user being tricked into believing an unverified recovery kit works.
### Threats not solved solely by this design
- A process already executing as the user can read application plaintext and may
call the user's credential APIs.
- A compromised kernel, unlocked session, malicious accessibility service, or
hostile native-vault implementation is outside this repository boundary.
- An uncooperative writer can ignore advisory locks. Directory ownership and OS
access controls remain mandatory.
- Physical recovery from SSD cells, filesystem snapshots, cloud backups, and
external backup media cannot be disproved by application-level deletion.
## 3. Mandatory invariants
The implementation must make these machine-checkable:
- Existing-envelope open is lookup-only using the exact authenticated
`(store_id, key_id)` pair.
- `Missing`, `Locked/Unavailable`, `Invalid`, `Retired`, and `AuthenticationFailed`
remain distinct typed states and never fall through to setup.
- Key creation is reachable only from an explicit setup or rotation state machine.
- A key is written as `pending` before ciphertext may reference it.
- A key is marked `active` only after canonical ciphertext has been reopened,
authenticated, deserialized, and compared with the intended plaintext.
- An old active key is not retired until the new key and canonical revision are
verified and the user-selected recovery requirement is satisfied.
- Key deletion never occurs in the same unconfirmed action as repository deletion.
- Lifecycle state and audit events contain identifiers and support codes only,
never key material or domain payloads.
- Any unknown lifecycle state fails closed into recovery UI.
## 4. Proposed key records
The current candidate reads a minimal native record:
- `0x01 || 32-byte non-zero DEK`: active;
- `0x02 || 32-byte DEK`: retired.
That format is sufficient only for read-only candidate evaluation. Production
setup requires a versioned, authenticated record with explicit transition data.
The proposed logical record is:
```text
record_version
store_id
key_id
key_state = pending | active | retired
DEK (32 bytes)
committed_revision
committed_ciphertext_digest
optional_pending_revision
optional_pending_ciphertext_digest
created_at
activated_at
optional_retired_at
```
The native credential store is the confidentiality boundary for this record.
Fields also present in the envelope are repeated to detect account-name/provider
mismatches. Timestamps are audit metadata, not security clocks. The exact binary
encoding, digest algorithm, provider size ceilings, update atomicity, and rollback
semantics require reviewer and platform-owner approval.
A credential provider must advertise persistence equivalent to `UntilDelete`.
Session-only or reboot-only stores are rejected. That capability is insufficient
by itself: setup must also explicitly select and verify whether each record is
local, synchronized, or roamable. In particular, the locked Windows adapter
currently documents `Enterprise` as its default for newly written records, which
may roam to another computer for the same user; silent cross-device DEK sharing
would invalidate nonce/device assumptions. The candidate read path rejects a
Windows record not reported as exactly `Local`, but approved setup must still
create and verify that persistence on a real target. Providers must be instantiated
explicitly for Linux Secret Service, Apple Keychain, and Windows Credential
Manager; no plaintext file fallback is permitted. Same-entry lifecycle updates
must be serialized because provider documentation warns that concurrent access
may not be reliably ordered.
## 5. User-controlled recovery proposal
### Selected review candidate: an offline asymmetric recovery identity
The preferred proposal is a standard, independently reviewed `age` v1 X25519
recipient/identity flow, restricted to asymmetric recipients. The crate must not
implement the protocol itself.
During setup:
1. Generate the repository DEK and an independent recovery identity with the OS
CSPRNG.
2. Encrypt a small, versioned recovery payload to the recovery recipient. The
payload contains the DEK, `store_id`, `key_id`, envelope algorithm, and creation
metadata; it contains no domain records.
3. Present the recovery identity once as an offline printable/QR recovery kit.
4. Require the user to re-import or confirm a separately stored kit before setup
becomes active. Merely clicking “I saved it” is insufficient for the default
recoverable mode.
5. Store only the public recipient and encrypted recovery package with application
state. Never store the recovery identity beside the repository or in logs,
clipboard history, analytics, screenshots, or crash reports.
Why this candidate:
- It uses a high-entropy generated identity rather than password crypto.
- It uses an existing interoperable format and implementation instead of a custom
KDF/wrapping construction.
- The public recipient can be retained without enabling recovery.
- Multiple explicitly chosen recipients can support user-held and organization-held
recovery without revealing the DEK to a Nigig service.
Approval is still required for the exact `age` version, dependency provenance,
algorithm agility, payload/AAD binding, printable-kit UX, QR rendering, memory
zeroization, recipient replacement, and loss/abuse model. No `age` dependency is
present in the production candidate today.
### Explicitly rejected shortcuts
- A user password directly used as an AES key.
- Unsalted or ad-hoc hashing of a PIN/passphrase.
- A DEK encoded as an ordinary QR code without encryption and confirmation UX.
- Emailing or uploading the recovery identity by default.
- A “forgot password” endpoint that can silently unwrap every user's repository.
- Reusing the native-vault DEK as the recovery identity or vice versa.
### Optional organization escrow
Organization escrow must be opt-in, visible, revocable, and represented as an
additional public recipient. Policy must identify who controls the private key,
how access is approved and audited, how personnel changes trigger rotation, and
how compromise is handled. The user-held recovery option must not silently become
organization-only escrow.
## 6. Initial setup state machine
Setup starts only when no canonical repository exists and the user chooses
“Create encrypted storage.” The proposed states are:
```text
Absent
-> ConsentRecorded
-> RecoveryPreparedAndConfirmed
-> NativeKeyPending
-> CiphertextPublishedAndVerified
-> NativeKeyActive
-> ReadyEncrypted
```
Required order:
1. Revalidate that the canonical target and managed publication artifacts are
absent under the repository process lock.
2. Record explicit consent without PII.
3. Generate non-zero random `store_id`, `key_id`, and DEK.
4. Prepare and independently re-import/verify the selected recovery kit.
5. Store the DEK as `pending` in the native vault.
6. Publish revision 1 containing an empty, versioned store. Do not seed examples,
demo sites, workers, tasks, or contacts.
7. Reopen and authenticate the canonical file using the exact pending key; verify
identity, revision, and empty schema.
8. Promote the native record to `active` and persist the rollback anchor.
9. Enter `ReadyEncrypted` only after a second readback of the active state.
### Setup crash reconciliation
| Observed state | Required behavior |
|---|---|
| No canonical, no pending key | Remain `Absent`; no cleanup needed |
| No canonical, one matching pending key | Show resumable setup; user may explicitly resume or delete the orphan |
| Canonical references matching pending key | Authenticate/read back, then offer to complete activation |
| Canonical references active key | Verify anchor and open normally |
| Canonical identity and pending key disagree | Recovery-required; never guess or delete |
| Multiple candidate pending keys | Recovery-required with content-free support identifiers |
Automatic deletion of an orphaned native credential is prohibited because the
canonical file may be temporarily unavailable, moved, or awaiting restoration.
## 7. Rotation state machine
Rotation is an explicit operation performed under the repository process lock:
```text
OldActive
-> NewPending
-> NewRecoveryPackageVerified
-> NewCiphertextPublishedAndVerified
-> NewActive
-> OldRetired
-> OptionalOldDeletionAfterRetention
```
1. Reopen the current canonical file and verify its rollback anchor.
2. Generate a new independent DEK and `key_id`; store it as `pending`.
3. Create and verify a recovery package for the new key.
4. Seal a newer revision under the new key, publish it atomically, and read it back.
5. Promote the new record to `active` and update the trusted anchor.
6. Mark the old record `retired`; do not delete its material yet.
7. Retain the old key for an approved bounded rollback window if policy requires.
8. Delete the retired record only after explicit confirmation that preserved old
ciphertext/backups are no longer expected to be recoverable with it.
A pending key may open only a canonical envelope that names its exact identity and
is in a recognized resumable transition. It must not become a general fallback.
A retired key may support an explicit rollback/recovery tool but never normal
writes.
## 8. Rollback anchor and crash consistency
AEAD authentication does not detect replacement of the entire file with an older,
otherwise valid envelope. The proposed trusted anchor is stored with the native
credential record and contains:
- committed repository revision and ciphertext digest;
- optionally one pending revision and ciphertext digest during publication.
Proposed publication protocol:
1. Under the process lock, re-read and authenticate the exact current canonical revision, then encrypt the complete candidate and compute its digest.
2. Write the pending `(revision, digest)` to the native record before publication.
3. Publish/sync/read back the ciphertext using the repository protocol.
4. Promote pending to committed in the native record.
5. Clear the pending slot only after both stores agree.
On open:
- exact committed match opens;
- exact pending match enters resumable reconciliation and may be promoted only
after authentication/readback;
- a lower revision, unknown digest, missing expected file, or contradictory state
enters recovery-required mode without modifying either store.
A native credential record is **not trusted merely because it is outside the repository file**. Platform backup, Keychain synchronization, profile restore, VM/device imaging, or administrator tooling may roll back the ciphertext and credential record together; that coordinated rollback would defeat this anchor and could also repeat a nonce reservation epoch. Approval therefore requires evidence about backup/restore/roaming semantics on each provider and either a genuinely non-rollback monotonic authority or an explicit statement that coordinated rollback is not detected. A remote transparency/monotonic service, hardware-backed counter, or user-verified recovery checkpoint may be required, each with its own availability and privacy trade-offs.
This also requires proof that native-record replacement is sufficiently atomic on each provider. If it is not, a two-record journal with explicit generations may be needed. A recovery restore intentionally resets the anchor only after explicit user authentication and confirmation; that reset must produce an audit event.
## 9. Nonce strategy decision required
The current AES-256-GCM candidate uses random 96-bit nonces and a process-local
`2^32` invocation guard. That does not durably count invocations across restarts,
processes, restored device images, or devices sharing a key.
The reviewer must approve one of these directions before setup is enabled:
1. **Durable reservation:** maintain a monotonic per-key nonce counter/reservation
in the native record, update it before use, and prove crash/concurrency/restore
behavior. Derive the 96-bit nonce from a per-key random epoch plus the reserved
counter. This depends on a trusted non-rollback anchor.
2. **Misuse-resistant envelope algorithm:** adopt an independently reviewed
nonce-misuse-resistant AEAD with a new algorithm identifier and migration plan,
while still using random nonces and bounded use. This changes the primitive and
requires fresh cryptographic review and test vectors.
3. **Keep random GCM nonces:** retain the current construction only with a reviewed
collision/invocation analysis, a durable total-invocation policy, forced key
rotation well below the bound, and device/key-sharing restrictions.
No option is approved by this document. Reusing a deterministic revision nonce
without a trusted anti-rollback mechanism is explicitly prohibited because an old
revision could then reuse a nonce with different plaintext under the same key.
## 10. Recovery operation
Recovery must never be an automatic reaction to `KeyMissing`.
1. Show the authenticated envelope's non-secret store/key identifiers and a
content-free support code.
2. Ask the user to choose an offline recovery identity/package.
3. Parse and decrypt in bounded, zeroizing memory; verify payload version and exact
`store_id/key_id` binding.
4. Use the recovered DEK to authenticate the existing canonical ciphertext before
writing anything.
5. Ask whether to restore the key into the native vault. Store it as `pending`,
read back, then promote to `active` only after canonical verification.
6. Reconcile or explicitly reset the rollback anchor with a visible warning.
7. Never rewrite the canonical repository merely to prove the recovered key.
Wrong recovery identities, malformed packages, and mismatched store IDs are
recoverable errors. They do not alter the repository or native vault.
## 11. Deletion and retention
Deletion requires two independently confirmed choices:
- deletion of active application ciphertext; and
- deletion of native/recovery key material.
The UI must explain that deleting only one side has different consequences. A
recommended cryptographic-erasure flow is:
1. stop acceptance and durably drain or explicitly abandon unsaved changes;
2. enumerate canonical/temp/backup/journal artifacts without following links;
3. obtain explicit confirmation naming the repository, not domain data;
4. remove managed ciphertext and synchronize the parent directory where supported;
5. delete active, pending, and retired native records only after ciphertext handling
has succeeded or the user explicitly accepts key-only destruction;
6. explain that offline recovery kits and external backups remain independently
recoverable until separately destroyed;
7. emit a content-free deletion receipt containing operation ID, time, platform,
support result, and the categories attempted.
The product may claim only “application ciphertext removed” and/or “native key
record deletion requested and confirmed by provider.” It must not claim forensic
physical erasure.
## 12. Content-free lifecycle audit
Allowed audit fields:
- random operation ID;
- lifecycle operation and transition;
- store/key identifiers in bounded hexadecimal form;
- old/new revision numbers;
- provider/platform identifier;
- support-code result;
- trusted timestamp source and app version;
- independent approval/reference identifiers.
Forbidden fields include site names, addresses, contacts, report text, worker IDs,
DEKs, recovery identities, wrapped-key plaintext, vault error strings, file
contents, and full user-selected paths.
Audit storage itself must be authenticated, bounded, and included in the backup,
retention, and deletion threat model. Ordinary console logs are not the audit log.
## 13. Required implementation and test evidence
Before approval, attach exact-commit evidence for all of the following:
### Model/property tests
- Every setup/rotation/recovery state and every legal/illegal transition.
- Crash or injected failure before and after each native-store and filesystem step.
- At most one active key per store after reconciliation.
- Canonical ciphertext always has one known opening path or is explicitly reported
unrecoverable; no code silently creates a replacement.
- Rollback-anchor committed/pending reconciliation matrix.
- Nonce uniqueness/reservation/exhaustion properties for the approved strategy.
- Recovery package wrong-recipient, tamper, version, identity, and size tests.
### Platform tests (Linux, macOS, Windows)
- New, locked, unavailable, read-only, corrupt, duplicate, missing, pending,
active, and retired native-vault records.
- Provider record size and update atomicity under kill/restart.
- Concurrent open, rotation, recovery, and deletion attempts.
- Process-lock timeout and stale-writer conflict UX.
- Filesystem permissions, symlinks/reparse points, hard links, replace/rename,
antivirus/indexer interference, directory sync, and abrupt termination.
- Normal UI close and forced process termination with accepted/durable revisions.
### Recovery drills
- Fresh-device restore using only preserved ciphertext and the user-held kit.
- Wrong kit and damaged kit without mutation.
- Vault reset followed by recovery.
- Rotation with old/new kits and bounded retirement policy.
- Restored old ciphertext detected by the trusted anchor.
- Explicit anchor reset with visible warning and content-free audit.
### Privacy checks
- Sentinel scans over canonical/temp/backup/lock/audit/crash/support/CI artifacts.
- Clipboard and screenshot behavior for recovery identity display.
- Memory-zeroization review, including serialization and provider buffers.
- No external request during local setup/recovery unless organization escrow was
explicitly selected and its protocol independently approved.
## 14. Approval record
| Role | Name | Exact reviewed commit | Decision | Date | Signature/reference |
|---|---|---|---|---|---|
| Independent cryptography reviewer | UNASSIGNED | — | NOT REVIEWED | — | — |
| Independent application-security reviewer | UNASSIGNED | — | NOT REVIEWED | — | — |
| Linux credential-store owner | UNASSIGNED | — | NOT REVIEWED | — | — |
| macOS credential-store owner | UNASSIGNED | — | NOT REVIEWED | — | — |
| Windows credential-store owner | UNASSIGNED | — | NOT REVIEWED | — | — |
| Product/data-retention owner | UNASSIGNED | — | NOT REVIEWED | — | — |
Until those approvals and all corresponding implementation evidence exist, this
proposal closes no SITE-02 blocker by itself. Setup, migration, recovery, rotation,
deletion, SITE-02 completion, and SITE-03 execution remain blocked.

View file

@ -0,0 +1,232 @@
# SITE-02 Cryptography and Repository Security Review Packet
**Packet date:** 2026-09-13
**Security decision:** `NOT APPROVED`
**Production activation:** `BLOCKED`
**Independent reviewer:** `UNASSIGNED`
**Implementation status:** local candidate; Linux test/runtime/native-vault evidence and host cross-compiles only
**Scope:** `crates/apps/nigig-site` encrypted repository, native key binding, writer shutdown, migration design, and release gates
This packet is deliberately not a self-approval. Passing unit tests, Clippy, or a Linux desktop smoke test cannot substitute for independent cryptographic review, native-vault evidence, crash testing, recovery policy, or release governance. First-run setup and all migration execution remain hard-locked with support code `SITE-02-SECURITY-REVIEW-REQUIRED`.
## 1. Decision
The candidate materially improves the SITE-01 containment baseline, but it is **not fit for production activation**. It provides a reviewable `NIGIG2` envelope, exact existing-key lookup, encrypted atomic-publication attempts, canonical readback, rollback attempts, a bounded process lock with stale-writer rejection, a one-slot coalescing writer, explicit accepted/durable health, and test-only migration exercises. It does **not** yet provide an approved setup, recovery, escrow, rotation, deletion, trusted anti-rollback, per-site storage, or production migration lifecycle.
The detailed lifecycle proposal is in `SITE_02_KEY_LIFECYCLE_DESIGN.md`. It selects an offline asymmetric recovery candidate for review, defines setup/rotation/recovery/deletion state machines, and proposes a native-vault rollback anchor. It is explicitly unapproved and has no production implementation.
No reviewer has approved:
- AES-GCM primitive use on every supported processor;
- the 96-bit random-nonce policy across process restarts/devices;
- associated-data and envelope identity semantics;
- OS credential-store behavior on Linux, macOS, and Windows;
- key creation, rotation, recovery, escrow, and secure deletion;
- filesystem crash/power-loss behavior;
- legacy-original retention/disposition; or
- activation of setup or migration UI.
## 2. Candidate boundary
### Compiled production behavior
- Normal open recognizes only strict `NIGIG2` authenticated envelopes and enables writes only for the exact current store schema. After authentication, it probes the scalar schema version as a `u64` before decoding the current shape: syntactically valid older/future shapes enter distinct preserved recovery/migration states, while an exact-current document is decoded with unknown root fields denied rather than silently discarded.
- The envelope is parsed before lookup of the exact `(store_id, key_id)` native credential.
- A missing, invalid, retired/rotated, unavailable, or wrong key is not replaced.
- Absence enters setup-required safe mode; it creates no directory, file, key, or demo data.
- Raw JSON, `NIGIG1` plaintext envelopes, and encrypted `NIGIG1` envelopes are classified for recovery/migration and are not opened by the normal runtime.
- First-run setup and migration entry points return `SITE-02-SECURITY-REVIEW-REQUIRED`.
- Confidential mutations require a valid explicit selected-site scope, except the profile-level site create/select operations; both mutation APIs are crate-private, serialize zeroizing before/after fences that discard out-of-scope changes, and no read helper silently selects the first site.
- Accepted revisions are visibly distinct from durable revisions.
- Normal application destruction requests a bounded five-second writer drain and logs only a content-free support code on failure.
### Deliberately non-production behavior
`migrate_legacy_json` and its plaintext/legacy decryptors compile only under `cfg(test)`. They exercise the proposed consent and preservation contract but cannot be invoked by a production binary. The broad media/export and live-network fixtures are additionally limited to Linux test builds, keeping them out of macOS/Windows native-repository test graphs. There is no production key-creation API in the candidate.
## 3. Envelope specification under review
The binary header is fixed at 68 bytes:
| Offset | Bytes | Meaning | Validation / binding |
|---:|---:|---|---|
| 0 | 6 | ASCII `NIGIG2` | Exact match |
| 6 | 1 | format version (`1`) | Unsupported versions rejected |
| 7 | 1 | algorithm (`1` = AES-256-GCM) | Unsupported algorithms rejected |
| 8 | 16 | random `store_id` | Non-zero; native-key routing; AEAD AAD |
| 24 | 16 | random `key_id` | Non-zero; native-key routing; AEAD AAD |
| 40 | 8 | big-endian repository revision | Non-zero; AEAD AAD |
| 48 | 12 | random GCM nonce | AEAD AAD and nonce input |
| 60 | 8 | big-endian plaintext length | Checked before allocation; AEAD AAD |
| 68 | variable | ciphertext plus 16-byte GCM tag | Exact total length; authenticated |
The full fixed header is passed as AES-GCM associated data. Header identity, revision, nonce, declared length, and algorithm/version are therefore authenticated once the exact key is available. A routing-identity change can fail as `KeyMissing` before AEAD because the altered identity deliberately selects a different native key record; changes that retain key routing fail authentication.
### Primitive
- AES-256-GCM via locked `aes-gcm` 0.10.x and `aead` 0.5.x.
- 256-bit DEK, 96-bit nonce, 128-bit tag.
- Plaintext and key buffers use `zeroize::Zeroizing` where owned by this crate. The locked `aes-gcm`, `aes`, `ghash`, and `polyval` dependency features enable their available temporary-key, key-schedule, and hash-state zeroization paths; this reduces residual state but is not a proof against compiler-created copies, process dumps, or abrupt termination. It is also incomplete upstream: `polyval` 0.6.2's autodetect union uses `ManuallyDrop`, and its ARMv8 PMULL backend explicitly leaves zeroization unimplemented. No complete cryptographic-state-erasure claim is made.
- No plaintext encryption fallback exists.
- A fixed envelope known-answer vector independently generated through Node.js/OpenSSL verifies the exact header, AAD, ciphertext, and tag bytes; every truncated prefix and an extended form are rejected.
- Maximum current whole-store plaintext: 64 MiB; declared and observed sizes are checked before decryption/large allocation, including a sparse oversized-file repository test.
The upstream `aes-gcm` documentation reports a 2020 NCC Group audit with no significant findings, but also warns that its portable implementation is not suitable on processors with variable-time multiplication. That hardware precondition is unresolved (B7).
### Nonce policy
Each seal obtains a fresh 96-bit value from the operating-system RNG. A process-local atomic guard refuses more than `2^32` random-nonce invocations. This follows the broad SP 800-38D random-IV invocation ceiling but does **not** durably account per key across restarts, concurrent processes, restored device images, or multiple devices. Production approval requires a reviewed durable policy and rotation/reconciliation rules (B5).
References for reviewers:
- NIST SP 800-38D: <https://nvlpubs.nist.gov/nistpubs/legacy/sp/nistspecialpublication800-38d.pdf>
- `aes-gcm` 0.10.3 documentation: <https://docs.rs/aes-gcm/0.10.3/aes_gcm/>
- `keyring` ecosystem guidance: <https://docs.rs/keyring/4.1.6/keyring/>
## 4. Native key binding under review
The production crate links `keyring-core` plus one explicit provider per desktop target:
| Target | Provider candidate | Evidence status |
|---|---|---|
| Linux | Secret Service through `zbus-secret-service-keyring-store` | Disposable real vault invalid/active/retired/missing/locked states plus two encrypted revisions/reopens, Unix mode/link checks, and wrong/missing-key preservation pass; no user vault touched |
| macOS | Keychain through `apple-native-keyring-store` | The `aarch64-apple-darwin` library/test graph cross-check compiles and lints; explicit disposable native-vault lifecycle test is declared but not executed locally |
| Windows | Credential Manager through `windows-native-keyring-store` | The `x86_64-pc-windows-msvc` library/test graph, including fail-closed `Local` persistence and reparse/link-count contracts, compiles and lints; explicit isolated native-vault/filesystem execution is declared but not run locally |
Credential account names derive only from hexadecimal `store_id:key_id`; no PII is included. Native records are versioned as one state byte plus a 32-byte DEK: `0x01` active and `0x02` retired. Active all-zero key material is rejected as invalid. A retired marker is differentiated from a deleted/missing record. Provider initialization now rejects a credential store that does not advertise `UntilDelete` persistence. The candidate can read this representation but intentionally provides no production create, rotate, retire, recover, export, or delete operation.
The all-in-one `keyring` facade is not a direct production dependency. `CredentialPersistence::UntilDelete` proves only lifetime class, not non-roaming or non-rollback behavior. The locked Windows adapter documents `Enterprise` as its default for newly written generic credentials and warns that operations on one entry from different threads are not reliably ordered; Microsoft documents that enterprise persistence may expose a credential to the same user on other computers. The candidate now rejects a Windows record whose reported persistence is not exactly `Local` before retrieving its secret. Production creation is absent today, so a reviewed setup/rotation design must still explicitly create and target-natively verify local records and serialize same-entry updates. The locked Apple adapter and feature selection use its legacy User/login keychain by default; Apple documents that a macOS keychain file can be restored from Time Machine. Apple also documents that an iOS/iPadOS local device keychain participates in same-device iCloud backup/restore. Exact deployed backup/restore behavior still requires target-native verification and therefore cannot close B8. These semantics prevent treating a native record as an inherently trusted monotonic anchor.
Provider initialization and error mappings still require platform-owner review and real OS-vault tests.
Platform-semantics references for reviewers:
- Microsoft `CREDENTIAL` persistence values: <https://learn.microsoft.com/en-us/windows/win32/api/wincred/ns-wincred-credentiala>
- Apple macOS Keychain and Time Machine restore: <https://support.apple.com/en-au/guide/keychain-access/kyca2423/mac>
- Apple iCloud Backup and local device-keychain restore: <https://support.apple.com/guide/security/icloud-backup-security-sec2c21e7f49/web/1>
- GNOME libsecret locking/error model: <https://gnome.pages.gitlab.gnome.org/libsecret/migrating-libgnome-keyring.html>
## 5. Publication and durability design under review
For a newer revision, the repository currently attempts:
1. reject symlinks in existing managed path components and managed artifacts; on Windows reject every reparse-point component;
2. open a non-following, owner-checked `0600` lock file and acquire an exclusive process lock within five seconds; Windows opens an existing lock with `FILE_FLAG_OPEN_REPARSE_POINT`, rejects reparse metadata, and requires one link through handle metadata;
3. reopen the canonical document without following a final symlink/reparse point; on Unix require a private file mode, one link, the parent owner, and a parent that is not group/other writable; on Windows require a one-link handle; then verify identity/revision still exactly match the caller's metadata;
4. look up the exact existing DEK;
5. authenticate the current canonical ciphertext under that DEK before creating any publication artifact, so a matching but tampered header cannot authorize overwrite;
6. serialize into a zeroizing in-memory buffer;
7. encrypt before opening a temporary file;
8. create a unique temporary file with `create_new` and, on Unix, no-follow/close-on-exec flags and mode `0600`;
9. write all ciphertext, explicitly `flush`, and `sync_all` the file;
10. rename the old canonical ciphertext to a unique rollback name;
11. rename the new ciphertext into the canonical name;
12. synchronize the parent directory where supported;
13. reopen without following a final symlink, parse, decrypt, and byte-compare canonical ciphertext;
14. remove the rollback ciphertext; and
15. synchronize the parent directory again.
The only persistent companion is an empty `.<managed-stem>.lock` coordination file; it contains no identity, revision, key material, or domain data. Failures before publication clean the temporary file and preserve the canonical bytes. Injected failures after publication attempt to restore the prior canonical ciphertext. A subprocess harness now terminates without unwinding at all 12 exercised commit fault stages through readback, including the explicit flush stage, and verifies that original ciphertext remains recoverable, publication artifacts force recovery, and no sentinel plaintext is present. Unexpected temp/backup artifacts are checked both before and after lock acquisition and force recovery instead of automatic cleanup, including when a prior process dies while a writer waits. The process lock plus in-lock authenticated canonical revision check prevents a matching-but-tampered current envelope or a second cooperative instance from silently authorizing overwrite; a simultaneous two-writer test proves exactly one revision-2 commit wins and the stale peer fails. This is safer than silently choosing a revision, but it is not proof against hostile/uncooperative writers or all filesystem, kernel, device-cache, antivirus, cross-platform, or power-loss behaviors (B6, B8, B9, B11).
`File::sync_all` only attempts to synchronize content and metadata; actual persistence guarantees remain filesystem/platform dependent: <https://doc.rust-lang.org/beta/std/fs/struct.File.html>.
## 6. Writer and UI health semantics
- One process-wide pending slot coalesces complete snapshots; queue depth is at most one.
- Every accepted snapshot includes all prior accepted in-memory changes.
- The writer serializes publication and may skip intermediate revision numbers when pending snapshots coalesce.
- Health exposes accepted revision, durable revision, pending depth, active state, accepting state, and last typed failure.
- `accepted > durable` is explicitly labelled `UNSAVED`.
- A writer failure is sticky and blocks later closures before they mutate the canonical in-memory value.
- Graceful shutdown stops acceptance, drains the pending slot within a caller-supplied bound, and joins only after the worker reports completion; dropping a writer without that explicit path now at least wakes its idle worker and requests a non-blocking drain/stop.
This is still one whole-store writer rather than the plan's final per-site repository/writer architecture. The scope fence excludes the legacy supplier directory because its records have no `site_id`; that shared/ambiguous model must be assigned or quarantined in SITE-03. Cooperative process exclusion and stale-revision rejection now exist, but contention/crash campaigns, per-site isolation, external change ingestion, and hostile-writer handling remain unresolved (B11).
## 7. Migration contract exercised in tests
The test-only migration controller enforces:
- explicit consent before source/key access;
- a distinct, absent target path;
- an already-provisioned exact target key (no create-on-lookup-failure path);
- one bounded in-memory source snapshot;
- separate handling for raw/explicit plaintext and encrypted `NIGIG1`;
- failure without a target when the historical key is missing;
- authenticated `NIGIG2` target publication and semantic readback; and
- byte-identical preservation of the legacy original.
Production migration remains absent. Retention duration, backup interaction, legal/user-confirmed deletion, secure-erasure claims, interrupted GUI resume behavior, and rollback tooling are unresolved (B3, B4, B10).
## 8. Current evidence
Evidence produced locally on Linux with Rust/Cargo 1.97.1:
| Gate | Current result |
|---|---|
| `cargo test --locked -p nigig-site` | Library 80 passed and 1 live-vault test explicitly ignored; binary 1 passed; integration 6 passed and 1 live-server test explicitly ignored; doc tests 0 |
| `cargo test --locked -p nigig-site --lib` | 80 passed, 0 failed, 1 live-vault test explicitly ignored |
| `crypto::tests` | 13 passed, including the independent fixed vector and all-zero pre-use rejection |
| `repository::tests` | 28 normal tests passed; dedicated ignored live-vault test also passed separately |
| `store::tests` | 9 passed, including version-first arbitrary-shape classification, strict current root-schema rejection, and cross-site/profile mutation-fence rejection |
| `cargo check --locked -p nigig-site --all-targets` | Passed |
| crate-owned Clippy, all targets, `--no-deps -D warnings` | Passed |
| crypto feature resolution | `aes-gcm`, AES schedule, GHASH, and POLYVAL `zeroize` features present for Linux x64, Windows x64 MSVC, and macOS ARM64 target graphs |
| workflow source/ownership assertions | Passed locally, including path coverage, immutable action/dependency revisions, hard locks, and no Cargo failure suppression |
| `Cargo.lock` SHA-256 | `ad166a13f0b3e51f9b9b0cde743adbbd3413db2ee0d441faadfb24033042795b` (dependency versions unchanged; crypto-backend `zeroize` and existing `windows-sys` package edges enabled) |
| RustSec production graph | Conservative all-feature/all-target traversal: 0 findings across 356 reachable normal/build packages; all 6 workspace findings proved dev/unrelated-workspace only; DB commit `b50980aad8b8f14f77e25a97b32dd94bf008b0af` |
| desktop binary build | Passed; release ELF SHA-256 `fea50fa70e75a331b3d3e6625cac945332ad16de6a1a4f39fd7afcc487d38762` |
| production binary/source sentinel scan | No fixture plaintext/native-vault test markers in the release binary; no production native-key write/create or plaintext-publication token |
| X11 real-binary smoke | Rendered safe mode, closed through WM close, exited normally, created no repository data |
| Linux native vault | Disposable Gnome Keyring invalid/active/retired/missing/locked states, two encrypted revisions/reopens, private/single-link canonical checks, and wrong/missing-key preservation passed; disposable root removed |
| Windows/macOS provider compile | Host-side library-and-test-target check plus crate-owned Clippy passed for Windows x64 MSVC and macOS ARM64 targets; the Windows GNU library test executable also cross-linked as PE32+ |
| Windows/macOS execution | Not run; mandatory target-native CI now invokes an opt-in isolated credential lifecycle (invalid/zero/active/wrong/retired/missing plus two repository revisions), with Windows local/enterprise persistence, hardlink, and junction rejection checks; the workflow remains unpublished/unexecuted |
| independent review | Not performed |
The broad media/export and live-network fixtures are now Linux-CI-only, so the exact Windows MSVC and macOS ARM64 library test graphs can be type-checked and linted from the Linux host. Those commands still do not link or execute native credential/filesystem behavior, so the workflow continues to require actual Windows and macOS runners; cross-checking is not counted as target-native evidence.
These are candidate-development results, not release evidence. CI results must be attached to the exact reviewed commit, and the Gitdab branch/status policy must prevent bypass.
## 9. Required blocker disposition
| ID | Blocker | Required evidence to close | Status |
|---|---|---|---|
| B1 | No independent cryptography/security reviewer | Named qualified reviewer, dated review, exact commit hash, signed decision and findings disposition | OPEN |
| B2 | No approved first-run key setup | Reviewed UX/consent flow; atomic repository/key transaction; orphan cleanup; real vault evidence; no automatic setup | OPEN |
| B3 | No approved user-controlled recovery/escrow design | Threat model, key wrapping/KDF choice from established construction, recovery authentication, loss/abuse analysis, restore drill | OPEN |
| B4 | Rotation, retirement, revocation, and deletion are incomplete | State machine, old-key availability policy, crash recovery, backups, audit events, secure-deletion claims bounded by platform reality | OPEN |
| B5 | Nonce accounting is only process-local | Reviewed per-key durable/multi-process/device invocation strategy, collision analysis, ceilings, forced rotation and exhaustion tests | OPEN |
| B6 | No trusted anti-rollback anchor; a native record may be restored with ciphertext | Authenticated genuinely non-rollback monotonic authority or explicitly bounded reconciliation design; coordinated backup/device rollback analysis; downgrade and rollback tests | OPEN |
| B7 | AES-GCM hardware timing assumptions unresolved | Supported CPU/platform matrix proving constant-time multiplication requirements or approved alternative primitive/implementation | OPEN |
| B8 | Native providers/filesystem semantics remain unverified on macOS and Windows | Target-native locked/unlocked/missing/corrupt vault tests, permission/reparse tests, rename/replace, directory durability, and shutdown tests on both OSes | OPEN |
| B9 | Abrupt-exit harness exists, but no real power-loss/filesystem campaign | Native SIGKILL/termination plus remount/reboot or equivalent tests on each filesystem/OS, artifact-state matrix, recovery operator procedure | OPEN |
| B10 | Migration retention/disposition and GUI E2E absent | Existing raw and encrypted `NIGIG1` GUI tests; consent audit; restart/resume; backup treatment; explicit retain/delete policy | OPEN |
| B11 | Whole-store writer still lacks final per-site/external-change reconciliation | Per-site bounded writer/repository design; lock contention/crash and shutdown tests on every OS; external revision refresh/conflict UX; hostile-writer analysis | OPEN |
| B12 | Release governance is not independently enforced | Protected Gitdab branch, required exact-commit statuses, independent approval identity, non-bypassable activation and published artifact provenance | OPEN |
## 10. Reviewer checklist
A reviewer must independently verify, not merely accept author assertions:
- [ ] Exact envelope parser bounds and integer arithmetic.
- [ ] Full-header AAD coverage and identity/key lookup behavior.
- [ ] AES-GCM implementation and supported-hardware assumptions.
- [ ] RNG failure behavior and nonce uniqueness/invocation analysis.
- [ ] Key-record format, provider selection, locked-vault behavior, concurrency, and lifecycle.
- [ ] No plaintext in canonical, temp, backup, journal, crash, support, or CI artifacts.
- [ ] Publication/rollback behavior under injected and real crash/power-loss faults.
- [ ] Writer coalescing, sticky failure, bounded shutdown, and unsaved-state UX.
- [ ] Explicit consent, old-key lookup-only behavior, preservation, verification, and disposition in migration.
- [ ] Recovery/escrow design and abuse/loss scenarios.
- [ ] Exact-commit CI, branch protection, approval identity, and activation controls.
## 11. Sign-off (intentionally blank)
| Role | Name | Organization | Exact commit | Decision | Date | Signature/reference |
|---|---|---|---|---|---|---|
| Independent cryptography reviewer | UNASSIGNED | — | — | NOT REVIEWED | — | — |
| Independent application-security reviewer | UNASSIGNED | — | — | NOT REVIEWED | — | — |
| Platform owner (Linux) | UNASSIGNED | — | — | NOT REVIEWED | — | — |
| Platform owner (macOS) | UNASSIGNED | — | — | NOT REVIEWED | — | — |
| Platform owner (Windows) | UNASSIGNED | — | — | NOT REVIEWED | — | — |
Until every required blocker is closed and an exact-commit approval is published, setup/migration must remain locked, SITE-02 must not be called complete, and SITE-03 must not begin.

View file

@ -1,245 +1,598 @@
//! Fail-closed at-rest envelope handling for `store.json`. //! Strict authenticated-at-rest envelopes for the SITE-02 repository candidate.
//! //!
//! SITE-01 is an emergency containment boundary. Production code can only open //! `NIGIG2` authenticates every routing/identity field as AEAD associated data.
//! and write AES-256-GCM envelopes backed by an already-present OS-keystore //! This module never creates or looks up keys and has no plaintext fallback.
//! key. It cannot create a key, accept legacy/plaintext data, or fall back to //! Key lifecycle and atomic publication are repository responsibilities.
//! plaintext. Key setup, migration, rotation, and recovery belong to SITE-02.
use aead::Aead; use aead::{Aead, Payload};
use aes_gcm::{Aes256Gcm, KeyInit, Nonce}; use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
use std::sync::atomic::{AtomicU64, Ordering};
use zeroize::Zeroizing;
const MAGIC: &[u8; 6] = b"NIGIG1"; pub(crate) const MAGIC: &[u8; 6] = b"NIGIG2";
const ALG_PLAINTEXT: u8 = 0x00; const LEGACY_MAGIC: &[u8; 6] = b"NIGIG1";
const ALG_AES256_GCM: u8 = 0x01; const FORMAT_VERSION: u8 = 1;
const ALG_AES256_GCM: u8 = 1;
const LEGACY_ALG_PLAINTEXT: u8 = 0;
const LEGACY_ALG_AES256_GCM: u8 = 1;
const NONCE_LEN: usize = 12; const NONCE_LEN: usize = 12;
const TAG_LEN: usize = 16; const TAG_LEN: usize = 16;
const ID_LEN: usize = 16;
const HEADER_LEN: usize = 6 + 1 + 1 + ID_LEN + ID_LEN + 8 + NONCE_LEN + 8;
const KEYRING_SERVICE: &str = "nigig-site"; /// SITE-02 still stores one legacy aggregate. The 64 MiB ceiling is enforced
const KEYRING_ACCOUNT: &str = "store-dek"; /// before ciphertext or plaintext allocation; later repository segmentation
/// will reduce this to the plan's 16 MiB per-aggregate budget.
pub(crate) const MAX_PLAINTEXT_BYTES: usize = 64 * 1024 * 1024;
pub(crate) const MAX_ENVELOPE_BYTES: usize = HEADER_LEN + MAX_PLAINTEXT_BYTES + TAG_LEN;
/// SP 800-38D permits at most 2^32 invocations under one key when using random
/// 96-bit IVs. This process-local guard is defense in depth; durable per-key
/// accounting/rotation remains a release blocker in the review packet.
const RANDOM_NONCE_INVOCATION_LIMIT: u64 = 1_u64 << 32;
static RANDOM_NONCE_INVOCATIONS: AtomicU64 = AtomicU64::new(0);
pub(crate) type SecretKey = Zeroizing<[u8; 32]>;
pub(crate) type SecretBytes = Zeroizing<Vec<u8>>;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) struct StoreId(pub(crate) [u8; ID_LEN]);
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(crate) struct KeyId(pub(crate) [u8; ID_LEN]);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct EnvelopeHeader {
pub(crate) store_id: StoreId,
pub(crate) key_id: KeyId,
pub(crate) revision: u64,
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct OpenedEnvelope {
pub(crate) header: EnvelopeHeader,
pub(crate) plaintext: SecretBytes,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CryptoError { pub enum CryptoError {
KeyUnavailable,
InvalidKey,
RandomUnavailable, RandomUnavailable,
EncryptionFailed, NonceInvocationLimit,
InvalidKey,
InputTooLarge,
InvalidEnvelope, InvalidEnvelope,
PlaintextMigrationRequired, PlaintextMigrationRequired,
LegacyEncryptedMigrationRequired,
UnsupportedVersion,
UnsupportedAlgorithm, UnsupportedAlgorithm,
AuthenticationFailed, AuthenticationFailed,
EncryptionFailed,
} }
impl CryptoError { impl CryptoError {
pub const fn support_code(self) -> &'static str { pub const fn support_code(self) -> &'static str {
match self { match self {
Self::KeyUnavailable => "SITE-KEY-UNAVAILABLE",
Self::InvalidKey => "SITE-KEY-INVALID",
Self::RandomUnavailable => "SITE-RANDOM-UNAVAILABLE", Self::RandomUnavailable => "SITE-RANDOM-UNAVAILABLE",
Self::EncryptionFailed => "SITE-ENCRYPTION-FAILED", Self::NonceInvocationLimit => "SITE-NONCE-LIMIT",
Self::InvalidKey => "SITE-KEY-INVALID",
Self::InputTooLarge => "SITE-ENVELOPE-LIMIT",
Self::InvalidEnvelope => "SITE-ENVELOPE-INVALID", Self::InvalidEnvelope => "SITE-ENVELOPE-INVALID",
Self::PlaintextMigrationRequired => "SITE-PLAINTEXT-MIGRATION-REQUIRED", Self::PlaintextMigrationRequired => "SITE-PLAINTEXT-MIGRATION-REQUIRED",
Self::LegacyEncryptedMigrationRequired => "SITE-LEGACY-ENCRYPTED-MIGRATION-REQUIRED",
Self::UnsupportedVersion => "SITE-ENVELOPE-VERSION-UNSUPPORTED",
Self::UnsupportedAlgorithm => "SITE-ALGORITHM-UNSUPPORTED", Self::UnsupportedAlgorithm => "SITE-ALGORITHM-UNSUPPORTED",
Self::AuthenticationFailed => "SITE-AUTHENTICATION-FAILED", Self::AuthenticationFailed => "SITE-AUTHENTICATION-FAILED",
Self::EncryptionFailed => "SITE-ENCRYPTION-FAILED",
} }
} }
} }
impl std::fmt::Display for CryptoError { impl std::fmt::Display for CryptoError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.support_code()) formatter.write_str(self.support_code())
} }
} }
fn unhex(s: &str) -> Option<Vec<u8>> { impl std::error::Error for CryptoError {}
if !s.len().is_multiple_of(2) {
return None; pub(crate) trait SecureRandom: Send + Sync {
fn fill(&self, destination: &mut [u8]) -> Result<(), CryptoError>;
}
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct OsRandom;
impl SecureRandom for OsRandom {
fn fill(&self, destination: &mut [u8]) -> Result<(), CryptoError> {
getrandom::getrandom(destination).map_err(|_| CryptoError::RandomUnavailable)
} }
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
.collect()
} }
#[cfg(test)] #[cfg(test)]
fn test_dek_override() -> Option<Result<[u8; 32], CryptoError>> { pub(crate) fn random_id(random: &dyn SecureRandom) -> Result<[u8; ID_LEN], CryptoError> {
let encoded = std::env::var("NIGIG_SITE_TEST_DEK").ok()?; let mut id = [0_u8; ID_LEN];
let bytes = match unhex(&encoded) { random.fill(&mut id)?;
Some(bytes) => bytes, if id == [0_u8; ID_LEN] {
None => return Some(Err(CryptoError::InvalidKey)), return Err(CryptoError::RandomUnavailable);
}
Ok(id)
}
pub(crate) fn parse_header(envelope: &[u8]) -> Result<EnvelopeHeader, CryptoError> {
classify_size_and_magic(envelope)?;
if envelope[6] != FORMAT_VERSION {
return Err(CryptoError::UnsupportedVersion);
}
if envelope[7] != ALG_AES256_GCM {
return Err(CryptoError::UnsupportedAlgorithm);
}
let store_id = StoreId(copy_array::<ID_LEN>(&envelope[8..24])?);
let key_id = KeyId(copy_array::<ID_LEN>(&envelope[24..40])?);
let revision = u64::from_be_bytes(copy_array::<8>(&envelope[40..48])?);
if store_id.0 == [0; ID_LEN] || key_id.0 == [0; ID_LEN] || revision == 0 {
return Err(CryptoError::InvalidEnvelope);
}
let declared = u64::from_be_bytes(copy_array::<8>(&envelope[60..68])?);
let declared = usize::try_from(declared).map_err(|_| CryptoError::InputTooLarge)?;
if declared > MAX_PLAINTEXT_BYTES {
return Err(CryptoError::InputTooLarge);
}
let expected = HEADER_LEN
.checked_add(declared)
.and_then(|size| size.checked_add(TAG_LEN))
.ok_or(CryptoError::InputTooLarge)?;
if envelope.len() != expected {
return Err(CryptoError::InvalidEnvelope);
}
Ok(EnvelopeHeader {
store_id,
key_id,
revision,
})
}
fn plaintext_json_candidate(bytes: &[u8]) -> Option<&[u8]> {
let bytes = bytes.strip_prefix(&[0xef, 0xbb, 0xbf]).unwrap_or(bytes);
let offset = bytes.iter().position(|byte| !byte.is_ascii_whitespace())?;
matches!(bytes[offset], b'{' | b'[').then_some(&bytes[offset..])
}
fn classify_size_and_magic(envelope: &[u8]) -> Result<(), CryptoError> {
if envelope.len() > MAX_ENVELOPE_BYTES {
return Err(CryptoError::InputTooLarge);
}
if envelope.starts_with(LEGACY_MAGIC) {
if envelope.len() < LEGACY_MAGIC.len() + 1 {
return Err(CryptoError::InvalidEnvelope);
}
return match envelope[LEGACY_MAGIC.len()] {
LEGACY_ALG_PLAINTEXT => Err(CryptoError::PlaintextMigrationRequired),
LEGACY_ALG_AES256_GCM => Err(CryptoError::LegacyEncryptedMigrationRequired),
_ => Err(CryptoError::UnsupportedAlgorithm),
}; };
Some(bytes.try_into().map_err(|_| CryptoError::InvalidKey)) }
if plaintext_json_candidate(envelope).is_some() {
return Err(CryptoError::PlaintextMigrationRequired);
}
if envelope.len() < HEADER_LEN + TAG_LEN || !envelope.starts_with(MAGIC) {
return Err(CryptoError::InvalidEnvelope);
}
Ok(())
} }
/// Load an existing key only. This deliberately has no create branch. fn copy_array<const N: usize>(bytes: &[u8]) -> Result<[u8; N], CryptoError> {
pub(crate) fn load_existing_dek() -> Result<[u8; 32], CryptoError> { bytes.try_into().map_err(|_| CryptoError::InvalidEnvelope)
#[cfg(test)]
{
if std::env::var("NIGIG_SITE_NO_KEYSTORE").as_deref() == Ok("1") {
return Err(CryptoError::KeyUnavailable);
}
if let Some(result) = test_dek_override() {
return result;
}
}
let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_ACCOUNT)
.map_err(|_| CryptoError::KeyUnavailable)?;
let encoded = entry
.get_password()
.map_err(|_| CryptoError::KeyUnavailable)?;
let bytes = unhex(&encoded).ok_or(CryptoError::InvalidKey)?;
bytes.try_into().map_err(|_| CryptoError::InvalidKey)
} }
pub(crate) fn encrypt_bytes(plaintext: &[u8], dek: &[u8; 32]) -> Result<Vec<u8>, CryptoError> { fn encode_header(
let mut nonce = [0u8; NONCE_LEN]; header: EnvelopeHeader,
getrandom::getrandom(&mut nonce).map_err(|_| CryptoError::RandomUnavailable)?; nonce: &[u8; NONCE_LEN],
let cipher = Aes256Gcm::new_from_slice(dek).map_err(|_| CryptoError::InvalidKey)?; plaintext_len: usize,
) -> Result<[u8; HEADER_LEN], CryptoError> {
if header.store_id.0 == [0; ID_LEN] || header.key_id.0 == [0; ID_LEN] || header.revision == 0 {
return Err(CryptoError::InvalidEnvelope);
}
let plaintext_len = u64::try_from(plaintext_len).map_err(|_| CryptoError::InputTooLarge)?;
let mut bytes = [0_u8; HEADER_LEN];
bytes[..6].copy_from_slice(MAGIC);
bytes[6] = FORMAT_VERSION;
bytes[7] = ALG_AES256_GCM;
bytes[8..24].copy_from_slice(&header.store_id.0);
bytes[24..40].copy_from_slice(&header.key_id.0);
bytes[40..48].copy_from_slice(&header.revision.to_be_bytes());
bytes[48..60].copy_from_slice(nonce);
bytes[60..68].copy_from_slice(&plaintext_len.to_be_bytes());
Ok(bytes)
}
fn cipher_for(key: &SecretKey) -> Result<Aes256Gcm, CryptoError> {
if key.as_ref() == [0; 32] {
return Err(CryptoError::InvalidKey);
}
Aes256Gcm::new_from_slice(key.as_ref()).map_err(|_| CryptoError::InvalidKey)
}
fn reserve_nonce_invocation() -> Result<(), CryptoError> {
RANDOM_NONCE_INVOCATIONS
.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
(current < RANDOM_NONCE_INVOCATION_LIMIT).then_some(current + 1)
})
.map(|_| ())
.map_err(|_| CryptoError::NonceInvocationLimit)
}
pub(crate) fn seal(
plaintext: &[u8],
header: EnvelopeHeader,
key: &SecretKey,
random: &dyn SecureRandom,
) -> Result<Vec<u8>, CryptoError> {
if plaintext.len() > MAX_PLAINTEXT_BYTES {
return Err(CryptoError::InputTooLarge);
}
let cipher = cipher_for(key)?;
reserve_nonce_invocation()?;
let mut nonce = [0_u8; NONCE_LEN];
random.fill(&mut nonce)?;
let encoded_header = encode_header(header, &nonce, plaintext.len())?;
let ciphertext = cipher let ciphertext = cipher
.encrypt(Nonce::from_slice(&nonce), plaintext) .encrypt(
Nonce::from_slice(&nonce),
Payload {
msg: plaintext,
aad: &encoded_header,
},
)
.map_err(|_| CryptoError::EncryptionFailed)?; .map_err(|_| CryptoError::EncryptionFailed)?;
let mut out = Vec::with_capacity(MAGIC.len() + 1 + NONCE_LEN + ciphertext.len()); let capacity = HEADER_LEN
out.extend_from_slice(MAGIC); .checked_add(ciphertext.len())
out.push(ALG_AES256_GCM); .ok_or(CryptoError::InputTooLarge)?;
out.extend_from_slice(&nonce); let mut envelope = Vec::with_capacity(capacity);
out.extend_from_slice(&ciphertext); envelope.extend_from_slice(&encoded_header);
Ok(out) envelope.extend_from_slice(&ciphertext);
Ok(envelope)
} }
pub(crate) fn decrypt_bytes(envelope: &[u8], dek: &[u8; 32]) -> Result<Vec<u8>, CryptoError> { pub(crate) fn open(envelope: &[u8], key: &SecretKey) -> Result<OpenedEnvelope, CryptoError> {
if envelope.len() < MAGIC.len() + 1 || &envelope[..MAGIC.len()] != MAGIC { let header = parse_header(envelope)?;
return Err(if envelope.first() == Some(&b'{') { let nonce = &envelope[48..60];
CryptoError::PlaintextMigrationRequired let cipher = cipher_for(key)?;
let plaintext = cipher
.decrypt(
Nonce::from_slice(nonce),
Payload {
msg: &envelope[HEADER_LEN..],
aad: &envelope[..HEADER_LEN],
},
)
.map_err(|_| CryptoError::AuthenticationFailed)?;
if plaintext.len() > MAX_PLAINTEXT_BYTES {
return Err(CryptoError::InputTooLarge);
}
Ok(OpenedEnvelope {
header,
plaintext: Zeroizing::new(plaintext),
})
}
/// Read-only compatibility extractor for explicit-consent migration tests. It
/// never writes, creates a key, or accepts encrypted legacy input without its
/// old key. Production migration remains security-review locked.
#[cfg(test)]
pub(crate) fn expose_legacy_plaintext(envelope: &[u8]) -> Result<SecretBytes, CryptoError> {
if envelope.len() > MAX_PLAINTEXT_BYTES + LEGACY_MAGIC.len() + 1 {
return Err(CryptoError::InputTooLarge);
}
if let Some(json) = plaintext_json_candidate(envelope) {
return Ok(Zeroizing::new(json.to_vec()));
}
if envelope.starts_with(LEGACY_MAGIC) && envelope.get(6) == Some(&LEGACY_ALG_PLAINTEXT) {
return Ok(Zeroizing::new(envelope[7..].to_vec()));
}
Err(
if envelope.starts_with(LEGACY_MAGIC) && envelope.get(6) == Some(&LEGACY_ALG_AES256_GCM) {
CryptoError::LegacyEncryptedMigrationRequired
} else { } else {
CryptoError::InvalidEnvelope CryptoError::InvalidEnvelope
}); },
)
}
/// Open the historical encrypted `NIGIG1` form with a key supplied by the
/// migration test controller. This is deliberately separate from normal open.
#[cfg(test)]
pub(crate) fn open_legacy_nigig1(
envelope: &[u8],
key: &SecretKey,
) -> Result<SecretBytes, CryptoError> {
if envelope.len() > MAX_ENVELOPE_BYTES {
return Err(CryptoError::InputTooLarge);
} }
match envelope[MAGIC.len()] { if !envelope.starts_with(LEGACY_MAGIC) || envelope.get(6) != Some(&LEGACY_ALG_AES256_GCM) {
ALG_PLAINTEXT => Err(CryptoError::PlaintextMigrationRequired), return Err(CryptoError::InvalidEnvelope);
ALG_AES256_GCM => { }
let rest = &envelope[MAGIC.len() + 1..]; let rest = &envelope[7..];
if rest.len() < NONCE_LEN + TAG_LEN { if rest.len() < NONCE_LEN + TAG_LEN {
return Err(CryptoError::InvalidEnvelope); return Err(CryptoError::InvalidEnvelope);
} }
let cipher = Aes256Gcm::new_from_slice(dek).map_err(|_| CryptoError::InvalidKey)?; let cipher = cipher_for(key)?;
cipher let plaintext = cipher
.decrypt(Nonce::from_slice(&rest[..NONCE_LEN]), &rest[NONCE_LEN..]) .decrypt(Nonce::from_slice(&rest[..NONCE_LEN]), &rest[NONCE_LEN..])
.map_err(|_| CryptoError::AuthenticationFailed) .map_err(|_| CryptoError::AuthenticationFailed)?;
if plaintext.len() > MAX_PLAINTEXT_BYTES {
return Err(CryptoError::InputTooLarge);
} }
_ => Err(CryptoError::UnsupportedAlgorithm), Ok(Zeroizing::new(plaintext))
}
}
/// Seal bytes using an already-present key. No plaintext output is possible.
pub(crate) fn seal(json: &[u8]) -> Result<Vec<u8>, CryptoError> {
let dek = load_existing_dek()?;
encrypt_bytes(json, &dek)
}
/// Open an encrypted envelope using an already-present key. Legacy raw JSON
/// and explicit-plaintext envelopes are preserved for future migration but are
/// not read by the application in SITE-01.
pub(crate) fn open_envelope(envelope: &[u8]) -> Result<Vec<u8>, CryptoError> {
if envelope.len() < MAGIC.len() + 1 || &envelope[..MAGIC.len()] != MAGIC {
return Err(if envelope.first() == Some(&b'{') {
CryptoError::PlaintextMigrationRequired
} else {
CryptoError::InvalidEnvelope
});
}
match envelope[MAGIC.len()] {
ALG_PLAINTEXT => Err(CryptoError::PlaintextMigrationRequired),
ALG_AES256_GCM => {
let dek = load_existing_dek()?;
decrypt_bytes(envelope, &dek)
}
_ => Err(CryptoError::UnsupportedAlgorithm),
}
}
#[cfg(test)]
fn plaintext_envelope(json: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(MAGIC.len() + 1 + json.len());
out.extend_from_slice(MAGIC);
out.push(ALG_PLAINTEXT);
out.extend_from_slice(json);
out
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
fn test_dek() -> [u8; 32] { struct FixedRandom(u8);
[7u8; 32] impl SecureRandom for FixedRandom {
fn fill(&self, destination: &mut [u8]) -> Result<(), CryptoError> {
destination.fill(self.0);
Ok(())
}
}
struct NonceRandom([u8; NONCE_LEN]);
impl SecureRandom for NonceRandom {
fn fill(&self, destination: &mut [u8]) -> Result<(), CryptoError> {
if destination.len() != self.0.len() {
return Err(CryptoError::RandomUnavailable);
}
destination.copy_from_slice(&self.0);
Ok(())
}
}
struct FailedRandom;
impl SecureRandom for FailedRandom {
fn fill(&self, _destination: &mut [u8]) -> Result<(), CryptoError> {
Err(CryptoError::RandomUnavailable)
}
}
fn key(byte: u8) -> SecretKey {
Zeroizing::new([byte; 32])
}
fn header() -> EnvelopeHeader {
EnvelopeHeader {
store_id: StoreId([1; 16]),
key_id: KeyId([2; 16]),
revision: 7,
}
}
fn legacy_encrypted(plaintext: &[u8], key: &SecretKey) -> Vec<u8> {
let nonce = [3_u8; NONCE_LEN];
let cipher = Aes256Gcm::new_from_slice(key.as_ref()).unwrap();
let ciphertext = cipher
.encrypt(Nonce::from_slice(&nonce), plaintext)
.unwrap();
[
LEGACY_MAGIC.as_slice(),
&[LEGACY_ALG_AES256_GCM],
&nonce,
&ciphertext,
]
.concat()
} }
#[test] #[test]
fn round_trips_arbitrary_json() { fn round_trip_authenticates_identity_revision_and_length() {
let plain = br#"{"sites":[],"reports":[{"id":"r1"}]}"#; let plaintext = br#"{"sites":[{"name":"Confidential Sentinel"}]}"#;
let envelope = encrypt_bytes(plain, &test_dek()).unwrap(); let envelope = seal(plaintext, header(), &key(7), &FixedRandom(9)).unwrap();
assert_eq!(&envelope[..6], MAGIC); assert_eq!(parse_header(&envelope).unwrap(), header());
assert_eq!(envelope[6], ALG_AES256_GCM); let opened = open(&envelope, &key(7)).unwrap();
assert_eq!(decrypt_bytes(&envelope, &test_dek()).unwrap(), plain); assert_eq!(opened.header, header());
assert_eq!(opened.plaintext.as_slice(), plaintext);
assert!(!String::from_utf8_lossy(&envelope).contains("Confidential Sentinel"));
} }
#[test] #[test]
fn nonces_differ_per_seal() { fn envelope_matches_independent_aes_gcm_known_answer() {
let (a, b) = ( fn decode_hex(encoded: &str) -> Vec<u8> {
encrypt_bytes(b"same", &test_dek()).unwrap(), encoded
encrypt_bytes(b"same", &test_dek()).unwrap(), .as_bytes()
); .chunks_exact(2)
assert_ne!(a, b, "reused nonce would be catastrophic"); .map(|pair| {
let digit = |value: u8| match value {
b'0'..=b'9' => value - b'0',
b'a'..=b'f' => value - b'a' + 10,
_ => panic!("invalid test vector"),
};
digit(pair[0]) << 4 | digit(pair[1])
})
.collect()
}
let key = Zeroizing::new(std::array::from_fn(|index| u8::try_from(index).unwrap()));
let header = EnvelopeHeader {
store_id: StoreId(std::array::from_fn(|index| u8::try_from(index).unwrap())),
key_id: KeyId(std::array::from_fn(|index| {
u8::try_from(index + 16).unwrap()
})),
revision: 0x0102_0304_0506_0708,
};
let nonce = std::array::from_fn(|index| u8::try_from(index + 0xa0).unwrap());
// Generated independently with Node.js `crypto.createCipheriv`, backed
// by OpenSSL, using this fixed key/nonce/header/plaintext tuple.
let expected = decode_hex(concat!(
"4e49474947320101000102030405060708090a0b0c0d0e0f",
"101112131415161718191a1b1c1d1e1f0102030405060708",
"a0a1a2a3a4a5a6a7a8a9aaab0000000000000014",
"9d3a0a4826bf6dcd405fa5804e2e85f3409e7b6d",
"cffa6a6f134438389a37c30043091d54"
));
let envelope = seal(
br#"{"vector":"SITE-02"}"#,
header,
&key,
&NonceRandom(nonce),
)
.unwrap();
assert_eq!(envelope, expected);
let opened = open(&expected, &key).unwrap();
assert_eq!(opened.header, header);
assert_eq!(opened.plaintext.as_slice(), br#"{"vector":"SITE-02"}"#);
} }
#[test] #[test]
fn tampered_ciphertext_is_refused() { fn independent_random_nonces_change_ciphertext() {
let mut envelope = encrypt_bytes(b"secret", &test_dek()).unwrap(); let first = seal(b"same", header(), &key(7), &FixedRandom(3)).unwrap();
let last = envelope.len() - 1; let second = seal(b"same", header(), &key(7), &FixedRandom(4)).unwrap();
envelope[last] ^= 0x01; assert_ne!(first, second);
assert_ne!(&first[48..60], &second[48..60]);
}
#[test]
fn modified_header_and_ciphertext_are_authentication_failures() {
let envelope = seal(b"secret", header(), &key(7), &FixedRandom(3)).unwrap();
for index in [16, envelope.len() - 1] {
let mut tampered = envelope.clone();
tampered[index] ^= 1;
assert_eq!( assert_eq!(
decrypt_bytes(&envelope, &test_dek()), open(&tampered, &key(7)),
Err(CryptoError::AuthenticationFailed) Err(CryptoError::AuthenticationFailed)
); );
} }
}
#[test] #[test]
fn wrong_key_is_refused() { fn every_truncated_or_extended_envelope_is_rejected() {
let envelope = encrypt_bytes(b"secret", &test_dek()).unwrap(); let envelope = seal(b"bounded parser", header(), &key(7), &FixedRandom(3)).unwrap();
for end in 0..envelope.len() {
assert!(
parse_header(&envelope[..end]).is_err(),
"accepted prefix {end}"
);
}
assert_eq!(parse_header(&envelope).unwrap(), header());
let mut extended = envelope;
extended.push(0);
assert_eq!(parse_header(&extended), Err(CryptoError::InvalidEnvelope));
}
#[test]
fn wrong_key_is_distinct_from_invalid_structure() {
let envelope = seal(b"secret", header(), &key(7), &FixedRandom(3)).unwrap();
assert_eq!( assert_eq!(
decrypt_bytes(&envelope, &[9u8; 32]), open(&envelope, &key(8)),
Err(CryptoError::AuthenticationFailed) Err(CryptoError::AuthenticationFailed)
); );
assert_eq!(open(b"garbage", &key(7)), Err(CryptoError::InvalidEnvelope));
} }
#[test] #[test]
fn plaintext_is_never_accepted() { fn plaintext_and_legacy_encrypted_forms_are_differentiated() {
let envelope = plaintext_envelope(b"{}");
assert_eq!( assert_eq!(
decrypt_bytes(&envelope, &test_dek()), parse_header(b"{}"),
Err(CryptoError::PlaintextMigrationRequired) Err(CryptoError::PlaintextMigrationRequired)
); );
assert_eq!( assert_eq!(
decrypt_bytes(b"{}", &test_dek()), parse_header(b"NIGIG1\x00{}"),
Err(CryptoError::PlaintextMigrationRequired) Err(CryptoError::PlaintextMigrationRequired)
); );
assert_eq!(
parse_header(b"\xef\xbb\xbf \r\n\t{}"),
Err(CryptoError::PlaintextMigrationRequired)
);
assert_eq!(
expose_legacy_plaintext(b"\xef\xbb\xbf \n{}")
.unwrap()
.as_slice(),
b"{}"
);
let old = legacy_encrypted(b"{}", &key(7));
assert_eq!(
parse_header(&old),
Err(CryptoError::LegacyEncryptedMigrationRequired)
);
assert_eq!(open_legacy_nigig1(&old, &key(7)).unwrap().as_slice(), b"{}");
}
#[test]
fn explicit_plaintext_extractor_rejects_encrypted_legacy_input() {
assert_eq!(expose_legacy_plaintext(b"{}").unwrap().as_slice(), b"{}");
assert_eq!(
expose_legacy_plaintext(b"NIGIG1\x00{}").unwrap().as_slice(),
b"{}"
);
assert_eq!(
expose_legacy_plaintext(&legacy_encrypted(b"{}", &key(7))),
Err(CryptoError::LegacyEncryptedMigrationRequired)
);
} }
#[test] #[test]
fn garbage_and_unknown_alg_rejected() { fn unsupported_version_algorithm_and_zero_ids_are_rejected() {
let envelope = seal(b"{}", header(), &key(7), &FixedRandom(3)).unwrap();
let mut version = envelope.clone();
version[6] = 2;
assert_eq!(parse_header(&version), Err(CryptoError::UnsupportedVersion));
let mut algorithm = envelope.clone();
algorithm[7] = 99;
assert_eq!( assert_eq!(
decrypt_bytes(b"", &test_dek()), parse_header(&algorithm),
Err(CryptoError::InvalidEnvelope)
);
assert_eq!(
decrypt_bytes(b"NOTMAGIC{}", &test_dek()),
Err(CryptoError::InvalidEnvelope)
);
let mut envelope = plaintext_envelope(b"{}");
envelope[6] = 0x7f;
assert_eq!(
decrypt_bytes(&envelope, &test_dek()),
Err(CryptoError::UnsupportedAlgorithm) Err(CryptoError::UnsupportedAlgorithm)
); );
let mut zero_id = envelope;
zero_id[8..24].fill(0);
assert_eq!(parse_header(&zero_id), Err(CryptoError::InvalidEnvelope));
}
#[test]
fn declared_size_is_bounded_before_decryption_or_allocation() {
let mut envelope = vec![0_u8; HEADER_LEN + TAG_LEN];
envelope[..6].copy_from_slice(MAGIC);
envelope[6] = FORMAT_VERSION;
envelope[7] = ALG_AES256_GCM;
envelope[8..24].fill(1);
envelope[24..40].fill(2);
envelope[40..48].copy_from_slice(&1_u64.to_be_bytes());
envelope[60..68].copy_from_slice(
&u64::try_from(MAX_PLAINTEXT_BYTES + 1)
.unwrap()
.to_be_bytes(),
);
assert_eq!(parse_header(&envelope), Err(CryptoError::InputTooLarge));
}
#[test]
fn random_failure_never_emits_an_envelope() {
assert_eq!(random_id(&FixedRandom(5)).unwrap(), [5; 16]);
assert_eq!(
random_id(&FixedRandom(0)),
Err(CryptoError::RandomUnavailable)
);
assert_eq!(
seal(b"{}", header(), &key(7), &FailedRandom),
Err(CryptoError::RandomUnavailable)
);
}
#[test]
fn all_zero_key_material_is_rejected_before_use() {
assert_eq!(
seal(b"{}", header(), &key(0), &FailedRandom),
Err(CryptoError::InvalidKey)
);
let envelope = seal(b"{}", header(), &key(7), &FixedRandom(3)).unwrap();
assert_eq!(open(&envelope, &key(0)), Err(CryptoError::InvalidKey));
}
#[test]
fn invalid_header_cannot_be_sealed() {
let mut invalid = header();
invalid.revision = 0;
assert_eq!(
seal(b"{}", invalid, &key(7), &FixedRandom(3)),
Err(CryptoError::InvalidEnvelope)
);
} }
} }

View file

@ -10,19 +10,20 @@ use makepad_widgets::ScriptVm;
mod ai_refine; mod ai_refine;
pub mod containment; pub mod containment;
mod crypto; mod crypto;
#[cfg(test)] #[cfg(all(test, target_os = "linux"))]
pub mod doc_export; pub mod doc_export;
pub mod domain; pub mod domain;
#[cfg(test)] #[cfg(all(test, target_os = "linux"))]
pub mod gif; pub mod gif;
#[cfg(test)] #[cfg(all(test, target_os = "linux"))]
pub mod ocr; pub mod ocr;
#[cfg(test)] #[cfg(all(test, target_os = "linux"))]
pub mod report_pdf; pub mod report_pdf;
pub(crate) mod repository;
pub mod scheduler; pub mod scheduler;
pub mod site_frame; pub mod site_frame;
pub mod store; pub mod store;
#[cfg(test)] #[cfg(all(test, target_os = "linux"))]
pub mod video; pub mod video;
pub fn script_mod(vm: &mut ScriptVm) { pub fn script_mod(vm: &mut ScriptVm) {

View file

@ -32,6 +32,11 @@ script_mod! {
meetings_page := mod.widgets.MeetingsPage { visible: false } meetings_page := mod.widgets.MeetingsPage { visible: false }
more_page := mod.widgets.MoreHubPage { visible: false } more_page := mod.widgets.MoreHubPage { visible: false }
} }
persistence_banner := Label {
width: Fill, text: "Encrypted repository health unavailable"
margin: Inset{left: 12, right: 12, top: 4, bottom: 4}
draw_text +: { color: #x92400E text_style: theme.font_bold { font_size: 9.0 } }
}
bottom_nav := View { bottom_nav := View {
width: Fill, height: Fit width: Fill, height: Fit
root_nav := mod.widgets.SiteActionBar {} root_nav := mod.widgets.SiteActionBar {}
@ -56,6 +61,7 @@ script_mod! {
draw_bg +: { color: #xFFFFFF border_radius: 14.0 border_size: 1.0 border_color: #xFCA5A5 } draw_bg +: { color: #xFFFFFF border_radius: 14.0 border_size: 1.0 border_color: #xFCA5A5 }
recovery_message := Label { width: Fill, text: "The original store is preserved and all writes are blocked." draw_text +: { color: #x334155 text_style: theme.font_regular { font_size: 12.0 } } } recovery_message := Label { width: Fill, text: "The original store is preserved and all writes are blocked." draw_text +: { color: #x334155 text_style: theme.font_regular { font_size: 12.0 } } }
support_code := Label { width: Fill, text: "" draw_text +: { color: #x991B1B text_style: theme.font_bold { font_size: 11.0 } } } support_code := Label { width: Fill, text: "" draw_text +: { color: #x991B1B text_style: theme.font_bold { font_size: 11.0 } } }
persistence_health := Label { width: Fill, text: "Accepted revision 0; durable revision 0; no unsaved changes." draw_text +: { color: #x92400E text_style: theme.font_bold { font_size: 10.0 } } }
recovery_detail := Label { width: Fill, text: "Do not delete, rename, or replace the store file." draw_text +: { color: #x64748B text_style: theme.font_regular { font_size: 11.0 } } } recovery_detail := Label { width: Fill, text: "Do not delete, rename, or replace the store file." draw_text +: { color: #x64748B text_style: theme.font_regular { font_size: 11.0 } } }
} }
guide_btn := Button { width: Fill, height: 44, text: "Show recovery guidance" draw_bg +: { color: #x0F172A border_radius: 12.0 } draw_text +: { color: #xFFFFFF text_style: theme.font_bold { font_size: 11.0 } } } guide_btn := Button { width: Fill, height: 44, text: "Show recovery guidance" draw_bg +: { color: #x0F172A border_radius: 12.0 } draw_text +: { color: #xFFFFFF text_style: theme.font_bold { font_size: 11.0 } } }
@ -78,9 +84,25 @@ pub struct SiteStandaloneApp {
current: usize, current: usize,
#[rust] #[rust]
store_access: Option<nigig_site::store::StoreAccess>, store_access: Option<nigig_site::store::StoreAccess>,
#[rust]
store_health: Option<nigig_site::store::PersistenceHealth>,
} }
impl ScriptHook for SiteStandaloneApp {} impl ScriptHook for SiteStandaloneApp {}
impl Drop for SiteStandaloneApp {
fn drop(&mut self) {
if !nigig_site::store::SiteStore::flush_and_shutdown(5_000) {
// Content-free operational signal only; never print paths, keys,
// serialized records, or decrypted error details.
eprintln!(
"graceful persistence drain failed: {}",
nigig_site::store::SiteStore::access_state().support_code()
);
}
}
}
impl MatchEvent for SiteStandaloneApp { impl MatchEvent for SiteStandaloneApp {
fn handle_startup(&mut self, cx: &mut Cx) { fn handle_startup(&mut self, cx: &mut Cx) {
// Opening the app is read-only. Existing encrypted bytes remain // Opening the app is read-only. Existing encrypted bytes remain
@ -171,10 +193,19 @@ pub fn nav_index(nav: &nigig_site::site_frame::action_bar::SiteNavAction) -> Opt
impl SiteStandaloneApp { impl SiteStandaloneApp {
fn apply_store_access(&mut self, cx: &mut Cx, access: nigig_site::store::StoreAccess) { fn apply_store_access(&mut self, cx: &mut Cx, access: nigig_site::store::StoreAccess) {
if self.store_access == Some(access) { let health = nigig_site::store::SiteStore::persistence_health();
if self.store_access == Some(access) && self.store_health == Some(health) {
return; return;
} }
self.store_access = Some(access); self.store_access = Some(access);
self.store_health = Some(health);
let health_line = health.status_line();
self.ui
.label(cx, ids!(root.app_shell.persistence_banner))
.set_text(cx, &health_line);
self.ui
.label(cx, ids!(root.recovery_page.persistence_health))
.set_text(cx, &health_line);
let ready = access.permits_confidential_writes(); let ready = access.permits_confidential_writes();
self.ui self.ui
.view(cx, ids!(root.app_shell)) .view(cx, ids!(root.app_shell))

File diff suppressed because it is too large Load diff

View file

@ -18,10 +18,12 @@ pub fn daily_eod_fire_at(
eod_local_hour: u32, eod_local_hour: u32,
now: chrono::DateTime<Utc>, now: chrono::DateTime<Utc>,
) -> chrono::DateTime<Utc> { ) -> chrono::DateTime<Utc> {
let firing_hour = eod_local_hour.saturating_sub(1); // Clamp external configuration before constructing a wall-clock time; an
// invalid hour must never panic an event handler.
let firing_hour = eod_local_hour.clamp(1, 24) - 1;
let firing_naive = chrono::NaiveDateTime::new( let firing_naive = chrono::NaiveDateTime::new(
date, date,
chrono::NaiveTime::from_hms_opt(firing_hour, 0, 0).unwrap(), chrono::NaiveTime::from_hms_opt(firing_hour, 0, 0).expect("clamped hour is in 0..=23"),
); );
let convert = |n: chrono::NaiveDateTime| { let convert = |n: chrono::NaiveDateTime| {
eat() eat()
@ -40,7 +42,7 @@ pub fn daily_eod_fire_at(
/// Record an encrypted local reminder. SITE-01 does not register it with an /// Record an encrypted local reminder. SITE-01 does not register it with an
/// operating-system scheduler or post a notification. /// operating-system scheduler or post a notification.
pub fn schedule_daily_eod(site_id: &str, date: chrono::NaiveDate, eod_local_hour: u32) { pub fn schedule_daily_eod(site_id: &str, date: chrono::NaiveDate, eod_local_hour: u32) -> bool {
// Reminder time is one hour before end of work in fixed EAT wall time. // Reminder time is one hour before end of work in fixed EAT wall time.
// 17:00 EAT EOD → 16:00 EAT = 13:00 UTC. // 17:00 EAT EOD → 16:00 EAT = 13:00 UTC.
let fire_at = daily_eod_fire_at(date, eod_local_hour, Utc::now()); let fire_at = daily_eod_fire_at(date, eod_local_hour, Utc::now());
@ -50,15 +52,15 @@ pub fn schedule_daily_eod(site_id: &str, date: chrono::NaiveDate, eod_local_hour
fire_at, fire_at,
); );
r.site_id = Some(site_id.to_string()); r.site_id = Some(site_id.to_string());
SiteStore::mutate(|s| s.push_reminder(r)); SiteStore::mutate_scoped(site_id, |s| s.push_reminder(r))
} }
/// Record an encrypted local reminder for a scheduled meeting without /// Record an encrypted local reminder for a scheduled meeting without
/// registering or firing an operating-system notification. /// registering or firing an operating-system notification.
pub fn schedule_monthly_before_meeting(site_id: &str, meeting_at: chrono::DateTime<Utc>) { pub fn schedule_monthly_before_meeting(site_id: &str, meeting_at: chrono::DateTime<Utc>) -> bool {
let mut r = crate::domain::reminders::Reminder::monthly_report_before(meeting_at); let mut r = crate::domain::reminders::Reminder::monthly_report_before(meeting_at);
r.site_id = Some(site_id.to_string()); r.site_id = Some(site_id.to_string());
SiteStore::mutate(|s| s.push_reminder(r)); SiteStore::mutate_scoped(site_id, |s| s.push_reminder(r))
} }
#[cfg(test)] #[cfg(test)]
@ -95,11 +97,8 @@ mod tests {
} }
#[test] #[test]
fn schedules_and_fires() { fn schedules_and_fires() {
// Hermetic + serialized against other env-touching tests via the // Pure state transition: persistence integration is covered by the
// shared guard (which also drains the writer before restoring env). // repository/store suites rather than process-global environment state.
let dir = std::env::temp_dir().join(format!("nigig-site-sched-{}", std::process::id()));
let _guard =
crate::store::TempStorePath::set("NIGIG_SITE_STORE_PATH", &dir.join("store.json"));
let mut s = SiteStore { let mut s = SiteStore {
..Default::default() ..Default::default()
}; };
@ -114,6 +113,5 @@ mod tests {
assert_eq!(s.due_reminders(now).len(), 1); assert_eq!(s.due_reminders(now).len(), 1);
s.mark_fired(&id); s.mark_fired(&id);
assert!(s.due_reminders(now).is_empty()); assert!(s.due_reminders(now).is_empty());
let _ = std::fs::remove_dir_all(&dir);
} }
} }

View file

@ -295,8 +295,7 @@ impl Widget for ApprovalsPage {
.text() .text()
.trim() .trim()
.to_string(); .to_string();
let Some(site_id) = crate::store::SiteStore::read().selected_site_id_or_first() let Some(site_id) = crate::store::SiteStore::read().selected_site_id() else {
else {
self.view self.view
.label(cx, ids!(body.new_task_form.date_error)) .label(cx, ids!(body.new_task_form.date_error))
.set_text(cx, "Create or select a real site first"); .set_text(cx, "Create or select a real site first");
@ -353,7 +352,16 @@ impl Widget for ApprovalsPage {
notes: None, notes: None,
}); });
} }
crate::store::SiteStore::mutate(|s| s.push_task(task)); let accepted =
crate::store::SiteStore::mutate_scoped(&site_id, |store| store.push_task(task));
if !accepted {
let message = crate::store::SiteStore::mutation_status(false);
self.view
.label(cx, ids!(body.new_task_form.date_error))
.set_text(cx, &message);
self.view.redraw(cx);
return;
}
self.editing_id = None; self.editing_id = None;
self.view self.view
.view(cx, ids!(body.new_task_form.edit_row)) .view(cx, ids!(body.new_task_form.edit_row))
@ -382,9 +390,24 @@ impl Widget for ApprovalsPage {
.clicked(actions) .clicked(actions)
{ {
if let Some(id) = self.editing_id.clone() { if let Some(id) = self.editing_id.clone() {
crate::store::SiteStore::mutate(|s| { let Some(site_id) = crate::store::SiteStore::read().selected_site_id() else {
s.tasks.retain(|t| t.id != id); self.view
.label(cx, ids!(body.new_task_form.date_error))
.set_text(cx, "Select the task's site before deleting it");
self.view.redraw(cx);
return;
};
let accepted = crate::store::SiteStore::mutate_scoped(&site_id, |store| {
store.tasks.retain(|task| task.id != id);
}); });
if !accepted {
let message = crate::store::SiteStore::mutation_status(false);
self.view
.label(cx, ids!(body.new_task_form.date_error))
.set_text(cx, &message);
self.view.redraw(cx);
return;
}
self.editing_id = None; self.editing_id = None;
self.view self.view
.view(cx, ids!(body.new_task_form.edit_row)) .view(cx, ids!(body.new_task_form.edit_row))
@ -439,17 +462,24 @@ impl Widget for ApprovalsPage {
.button(cx, ids!(body.template_card.template_btn)) .button(cx, ids!(body.template_card.template_btn))
.clicked(actions) .clicked(actions)
{ {
let Some(site_id) = crate::store::SiteStore::read().selected_site_id_or_first() let Some(site_id) = crate::store::SiteStore::read().selected_site_id() else {
else {
self.view self.view
.label(cx, ids!(body.new_task_form.date_error)) .label(cx, ids!(body.new_task_form.date_error))
.set_text(cx, "Create or select a real site first"); .set_text(cx, "Create or select a real site first");
self.view.redraw(cx); self.view.redraw(cx);
return; return;
}; };
crate::store::SiteStore::mutate(|store| { let accepted = crate::store::SiteStore::mutate_scoped(&site_id, |store| {
store.generate_template_for_site(&site_id); store.generate_template_for_site(&site_id);
}); });
if !accepted {
let message = crate::store::SiteStore::mutation_status(false);
self.view
.label(cx, ids!(body.new_task_form.date_error))
.set_text(cx, &message);
self.view.redraw(cx);
return;
}
self.update_task_list(cx); self.update_task_list(cx);
self.view.redraw(cx); self.view.redraw(cx);
} }
@ -531,11 +561,22 @@ impl ApprovalsPage {
let Some(id) = self.editing_id.clone() else { let Some(id) = self.editing_id.clone() else {
return; return;
}; };
crate::store::SiteStore::mutate(|s| { let Some(site_id) = crate::store::SiteStore::read().selected_site_id() else {
if let Some(task) = s.tasks.iter_mut().find(|t| t.id == id) { return;
};
let accepted = crate::store::SiteStore::mutate_scoped(&site_id, |store| {
if let Some(task) = store.tasks.iter_mut().find(|task| task.id == id) {
task.status = status.clone(); task.status = status.clone();
} }
}); });
if !accepted {
let message = crate::store::SiteStore::mutation_status(false);
self.view
.label(cx, ids!(body.new_task_form.date_error))
.set_text(cx, &message);
self.view.redraw(cx);
return;
}
self.update_task_list(cx); self.update_task_list(cx);
self.view.redraw(cx); self.view.redraw(cx);
} }
@ -544,22 +585,35 @@ impl ApprovalsPage {
let Some(id) = self.editing_id.clone() else { let Some(id) = self.editing_id.clone() else {
return; return;
}; };
let Some(site_id) = crate::store::SiteStore::read().selected_site_id() else {
return;
};
let mut found = false; let mut found = false;
let mut pending = false; let mut pending = false;
crate::store::SiteStore::mutate(|s| { let accepted = crate::store::SiteStore::mutate_scoped(&site_id, |store| {
if let Some(task) = s.tasks.iter_mut().find(|t| t.id == id) { if let Some(task) = store.tasks.iter_mut().find(|task| task.id == id) {
found = true; found = true;
if let Some(insp) = task.inspections.iter_mut().find(|i| i.passed.is_none()) { if let Some(inspection) = task
.inspections
.iter_mut()
.find(|inspection| inspection.passed.is_none())
{
pending = true; pending = true;
insp.passed = Some(passed); inspection.passed = Some(passed);
insp.inspected_at = Some(chrono::Utc::now()); inspection.inspected_at = Some(chrono::Utc::now());
if passed { if passed {
task.status = crate::domain::approvals::TaskStatus::Inspected; task.status = crate::domain::approvals::TaskStatus::Inspected;
} }
} }
} }
}); });
if found && pending { if !accepted {
let message = crate::store::SiteStore::mutation_status(false);
self.view
.label(cx, ids!(body.new_task_form.date_error))
.set_text(cx, &message);
self.view.redraw(cx);
} else if found && pending {
self.update_task_list(cx); self.update_task_list(cx);
self.view.redraw(cx); self.view.redraw(cx);
} else if found { } else if found {
@ -572,7 +626,7 @@ impl ApprovalsPage {
fn reload_rows(&mut self) { fn reload_rows(&mut self) {
let store = crate::store::SiteStore::read(); let store = crate::store::SiteStore::read();
let Some(site_id) = store.selected_site_id_or_first() else { let Some(site_id) = store.selected_site_id() else {
self.rows.clear(); self.rows.clear();
return; return;
}; };

View file

@ -142,7 +142,7 @@ impl SiteChatPage {
.set_text(cx, &rooms.join("\n")); .set_text(cx, &rooms.join("\n"));
let history = store let history = store
.selected_or_first() .selected_site()
.and_then(|site| site.chat_room_id) .and_then(|site| site.chat_room_id)
.map(|room| store.thread_lines(&room)) .map(|room| store.thread_lines(&room))
.unwrap_or_default(); .unwrap_or_default();

View file

@ -264,8 +264,7 @@ impl Widget for MeetingsPage {
self.view.redraw(cx); self.view.redraw(cx);
return; return;
}; };
let Some(site_id) = crate::store::SiteStore::read().selected_site_id_or_first() let Some(site_id) = crate::store::SiteStore::read().selected_site_id() else {
else {
self.view self.view
.label(cx, ids!(body.resched_form.resched_error)) .label(cx, ids!(body.resched_form.resched_error))
.set_text(cx, "Create or select a real site first"); .set_text(cx, "Create or select a real site first");
@ -273,20 +272,32 @@ impl Widget for MeetingsPage {
return; return;
}; };
let mut found: Option<(String, chrono::DateTime<chrono::Utc>)> = None; let mut found: Option<(String, chrono::DateTime<chrono::Utc>)> = None;
crate::store::SiteStore::mutate(|s| { let accepted = crate::store::SiteStore::mutate_scoped(&site_id, |store| {
if let Some(m) = s if let Some(meeting) = store.meetings.iter_mut().rev().find(|meeting| {
.meetings meeting.site_id == site_id && meeting.title.eq_ignore_ascii_case(&title)
.iter_mut() }) {
.rev() meeting.reschedule(new_at);
.find(|m| m.site_id == site_id && m.title.eq_ignore_ascii_case(&title)) found = Some((meeting.site_id.clone(), meeting.scheduled_at));
{
m.reschedule(new_at);
found = Some((m.site_id.clone(), m.scheduled_at));
} }
}); });
if !accepted {
let message = crate::store::SiteStore::mutation_status(false);
self.view
.label(cx, ids!(body.resched_form.resched_error))
.set_text(cx, &message);
self.view.redraw(cx);
return;
}
match found { match found {
Some((mid, mat)) => { Some((mid, mat)) => {
crate::scheduler::schedule_monthly_before_meeting(&mid, mat); if !crate::scheduler::schedule_monthly_before_meeting(&mid, mat) {
let message = crate::store::SiteStore::mutation_status(false);
self.view
.label(cx, ids!(body.resched_form.resched_error))
.set_text(cx, &message);
self.view.redraw(cx);
return;
}
self.view self.view
.label(cx, ids!(body.resched_form.resched_error)) .label(cx, ids!(body.resched_form.resched_error))
.set_text(cx, ""); .set_text(cx, "");
@ -318,16 +329,15 @@ impl Widget for MeetingsPage {
if name.is_empty() { if name.is_empty() {
return; return;
} }
let Some(site_id) = crate::store::SiteStore::read().selected_site_id_or_first() let Some(site_id) = crate::store::SiteStore::read().selected_site_id() else {
else {
self.view self.view
.label(cx, ids!(body.live_card.stt_status)) .label(cx, ids!(body.live_card.stt_status))
.set_text(cx, "Create or select a real site first"); .set_text(cx, "Create or select a real site first");
self.view.redraw(cx); self.view.redraw(cx);
return; return;
}; };
crate::store::SiteStore::mutate(|s| { let accepted = crate::store::SiteStore::mutate_scoped(&site_id, |store| {
s.push_contact( store.push_contact(
&site_id, &site_id,
crate::domain::meetings::MeetingAttendee { crate::domain::meetings::MeetingAttendee {
user_id: uuid::Uuid::new_v4().to_string(), user_id: uuid::Uuid::new_v4().to_string(),
@ -336,6 +346,14 @@ impl Widget for MeetingsPage {
}, },
); );
}); });
if !accepted {
let message = crate::store::SiteStore::mutation_status(false);
self.view
.label(cx, ids!(body.live_card.stt_status))
.set_text(cx, &message);
self.view.redraw(cx);
return;
}
self.view self.view
.text_input(cx, ids!(body.project_dir_card.dir_row.dir_name_input)) .text_input(cx, ids!(body.project_dir_card.dir_row.dir_name_input))
.set_text(cx, ""); .set_text(cx, "");
@ -369,8 +387,7 @@ impl Widget for MeetingsPage {
self.view.redraw(cx); self.view.redraw(cx);
return; return;
}; };
let Some(site_id) = crate::store::SiteStore::read().selected_site_id_or_first() let Some(site_id) = crate::store::SiteStore::read().selected_site_id() else {
else {
self.view self.view
.label(cx, ids!(body.schedule_form.schedule_error)) .label(cx, ids!(body.schedule_form.schedule_error))
.set_text(cx, "Create or select a real site first"); .set_text(cx, "Create or select a real site first");
@ -404,9 +421,30 @@ impl Widget for MeetingsPage {
email: None, email: None,
}); });
} }
crate::store::SiteStore::mutate(|s| s.push_meeting(m.clone())); let meeting_site_id = m.site_id.clone();
// 72h reminder for monthly report review (item 8) + meeting reminder let accepted = crate::store::SiteStore::mutate_scoped(&meeting_site_id, |store| {
crate::scheduler::schedule_monthly_before_meeting(&m.site_id, m.scheduled_at); store.push_meeting(m.clone())
});
if !accepted {
let message = crate::store::SiteStore::mutation_status(false);
self.view
.label(cx, ids!(body.schedule_form.schedule_error))
.set_text(cx, &message);
self.view.redraw(cx);
return;
}
// 72h reminder for monthly report review (item 8) + meeting reminder.
if !crate::scheduler::schedule_monthly_before_meeting(
&meeting_site_id,
m.scheduled_at,
) {
let message = crate::store::SiteStore::mutation_status(false);
self.view
.label(cx, ids!(body.schedule_form.schedule_error))
.set_text(cx, &message);
self.view.redraw(cx);
return;
}
self.view self.view
.view(cx, ids!(body.schedule_form)) .view(cx, ids!(body.schedule_form))
.set_visible(cx, false); .set_visible(cx, false);
@ -500,7 +538,7 @@ impl Widget for MeetingsPage {
impl MeetingsPage { impl MeetingsPage {
fn update_meetings_list(&mut self, cx: &mut Cx) { fn update_meetings_list(&mut self, cx: &mut Cx) {
let store = crate::store::SiteStore::read(); let store = crate::store::SiteStore::read();
let Some(site_id) = store.selected_site_id_or_first() else { let Some(site_id) = store.selected_site_id() else {
self.rows.clear(); self.rows.clear();
self.update_directory(cx); self.update_directory(cx);
return; return;
@ -528,7 +566,7 @@ impl MeetingsPage {
fn update_directory(&mut self, cx: &mut Cx) { fn update_directory(&mut self, cx: &mut Cx) {
let store = crate::store::SiteStore::read(); let store = crate::store::SiteStore::read();
let names = store let names = store
.selected_site_id_or_first() .selected_site_id()
.map(|site_id| store.directory_names(&site_id)) .map(|site_id| store.directory_names(&site_id))
.unwrap_or_default(); .unwrap_or_default();
let dir_text = if names.is_empty() { let dir_text = if names.is_empty() {

View file

@ -102,10 +102,10 @@ impl Widget for MoreHubPage {
let access = crate::store::SiteStore::access_state(); let access = crate::store::SiteStore::access_state();
let text = match access { let text = match access {
crate::store::StoreAccess::ReadyEncrypted => { crate::store::StoreAccess::ReadyEncrypted => {
"🔒 Existing store is encrypted; confidential writes are enabled." "🔒 Existing NIGIG2 repository opened with its bound native key."
} }
crate::store::StoreAccess::SetupRequired => { crate::store::StoreAccess::SetupRequired => {
"🔒 Confidential writes locked: secure first-run setup is not available yet." "🔒 Setup and migration hard-locked: SITE-02 independent security review required."
} }
crate::store::StoreAccess::RecoveryRequired(_) => { crate::store::StoreAccess::RecoveryRequired(_) => {
"⚠ Recovery required: original store preserved; all writes are locked." "⚠ Recovery required: original store preserved; all writes are locked."
@ -114,9 +114,14 @@ impl Widget for MoreHubPage {
"⚠ Storage failure: last durable revision preserved; all writes are locked." "⚠ Storage failure: last durable revision preserved; all writes are locked."
} }
}; };
let text = format!(
"{} {}",
text,
crate::store::SiteStore::persistence_health().status_line()
);
self.view self.view
.label(cx.cx, ids!(crypto_text)) .label(cx.cx, ids!(crypto_text))
.set_text(cx.cx, text); .set_text(cx.cx, &text);
self.view.draw_walk(cx, scope, walk) self.view.draw_walk(cx, scope, walk)
} }
} }

View file

@ -249,7 +249,7 @@ impl Widget for ProcurementPage {
self.view.redraw(cx); self.view.redraw(cx);
return; return;
}; };
let Some(site_id) = store.selected_site_id_or_first() else { let Some(site_id) = store.selected_site_id() else {
self.view self.view
.label(cx, ids!(body.material_form.mat_error)) .label(cx, ids!(body.material_form.mat_error))
.set_text(cx, "Create or select a real site first"); .set_text(cx, "Create or select a real site first");
@ -267,7 +267,17 @@ impl Widget for ProcurementPage {
); );
line.supplier_id = supplier_id; line.supplier_id = supplier_id;
sched.lines.push(line); sched.lines.push(line);
crate::store::SiteStore::mutate(|s| s.push_procurement(sched)); let accepted = crate::store::SiteStore::mutate_scoped(&site_id, |store| {
store.push_procurement(sched)
});
if !accepted {
let message = crate::store::SiteStore::mutation_status(false);
self.view
.label(cx, ids!(body.material_form.mat_error))
.set_text(cx, &message);
self.view.redraw(cx);
return;
}
self.view self.view
.label(cx, ids!(body.material_form.mat_error)) .label(cx, ids!(body.material_form.mat_error))
.set_text(cx, ""); .set_text(cx, "");
@ -370,8 +380,15 @@ impl Widget for ProcurementPage {
"factory" => crate::domain::procurement::SupplierKind::Factory, "factory" => crate::domain::procurement::SupplierKind::Factory,
_ => crate::domain::procurement::SupplierKind::Other(kind_raw), _ => crate::domain::procurement::SupplierKind::Other(kind_raw),
}; };
crate::store::SiteStore::mutate(|s| { let Some(site_id) = crate::store::SiteStore::read().selected_site_id() else {
s.push_supplier(crate::domain::procurement::Supplier { self.view
.label(cx, ids!(body.material_form.mat_error))
.set_text(cx, "Select an existing site before changing suppliers");
self.view.redraw(cx);
return;
};
let accepted = crate::store::SiteStore::mutate_scoped(&site_id, |store| {
store.push_supplier(crate::domain::procurement::Supplier {
id: uuid::Uuid::new_v4().to_string(), id: uuid::Uuid::new_v4().to_string(),
name, name,
kind, kind,
@ -380,6 +397,14 @@ impl Widget for ProcurementPage {
address: None, address: None,
}); });
}); });
if !accepted {
let message = crate::store::SiteStore::mutation_status(false);
self.view
.label(cx, ids!(body.material_form.mat_error))
.set_text(cx, &message);
self.view.redraw(cx);
return;
}
self.view self.view
.view(cx, ids!(body.directory_card.supplier_form)) .view(cx, ids!(body.directory_card.supplier_form))
.set_visible(cx, false); .set_visible(cx, false);
@ -444,7 +469,7 @@ impl ProcurementPage {
}) })
.collect(); .collect();
let schedule = store let schedule = store
.selected_site_id_or_first() .selected_site_id()
.and_then(|site_id| store.procurement_for(&site_id)) .and_then(|site_id| store.procurement_for(&site_id))
.map(|sched| { .map(|sched| {
sched sched

View file

@ -270,8 +270,7 @@ impl Widget for ReportEditorPage {
self.list_kind, self.list_kind,
); );
let ws = self.view.text_input(cx, ids!(workstation_input)).text(); let ws = self.view.text_input(cx, ids!(workstation_input)).text();
let Some(site_id) = crate::store::SiteStore::read().selected_site_id_or_first() let Some(site_id) = crate::store::SiteStore::read().selected_site_id() else {
else {
self.view self.view
.label(cx, ids!(refined_preview.refined_text)) .label(cx, ids!(refined_preview.refined_text))
.set_text(cx, "Select an existing site before saving."); .set_text(cx, "Select an existing site before saving.");
@ -284,17 +283,13 @@ impl Widget for ReportEditorPage {
let date = chrono::Local::now().date_naive(); let date = chrono::Local::now().date_naive();
let mut entry = crate::domain::daily_report::DailyTaskEntry::new(title, rich); let mut entry = crate::domain::daily_report::DailyTaskEntry::new(title, rich);
entry.work_station = if ws.trim().is_empty() { None } else { Some(ws) }; entry.work_station = if ws.trim().is_empty() { None } else { Some(ws) };
let saved = crate::store::SiteStore::mutate(|store| { let accepted = crate::store::SiteStore::mutate_scoped(&site_id, |store| {
store.push_task_entry(&site_id, date, entry) store.push_task_entry(&site_id, date, entry)
}); });
let message = if saved { let message = crate::store::SiteStore::mutation_status(accepted);
"Draft saved to encrypted local storage."
} else {
crate::store::SiteStore::access_state().message()
};
self.view self.view
.label(cx, ids!(refined_preview.refined_text)) .label(cx, ids!(refined_preview.refined_text))
.set_text(cx, message); .set_text(cx, &message);
self.view self.view
.view(cx, ids!(refined_preview)) .view(cx, ids!(refined_preview))
.set_visible(cx, true); .set_visible(cx, true);

View file

@ -229,7 +229,7 @@ impl SiteReportsPage {
let store = crate::store::SiteStore::read(); let store = crate::store::SiteStore::read();
let today = chrono::Local::now().date_naive(); let today = chrono::Local::now().date_naive();
let selected = store.selected_or_first(); let selected = store.selected_site();
let (daily, monthly) = if let Some(site) = selected { let (daily, monthly) = if let Some(site) = selected {
let site_reports = store.reports_for_site(&site.id); let site_reports = store.reports_for_site(&site.id);
let today_report = store.report_for_day(&site.id, today); let today_report = store.report_for_day(&site.id, today);

View file

@ -54,7 +54,8 @@ script_mod! {
show_bg: true show_bg: true
draw_bg +: { color: #xFFFFFF border_radius: 14.0 border_size: 1.0 border_color: #xE2E8F0 } draw_bg +: { color: #xFFFFFF border_radius: 14.0 border_size: 1.0 border_color: #xE2E8F0 }
Label { text: "New construction site" draw_text +: { color: #x1C274C text_style: theme.font_bold { font_size: 12.0 } } } Label { text: "New construction site" draw_text +: { color: #x1C274C text_style: theme.font_bold { font_size: 12.0 } } }
site_name_input := TextInput { width: Fill, height: 36, empty_text: "Site name — e.g. Muthaiga Villas Phase 2" draw_bg +: { color: #xF8FAFC border_radius: 10.0 border_size: 1.0 border_color: #xE2E8F0 } } site_status := Label { width: Fill, text: "" draw_text +: { color: #xB91C1C text_style: theme.font_bold { font_size: 10.0 } } }
site_name_input := TextInput { width: Fill, height: 36, empty_text: "Site name" draw_bg +: { color: #xF8FAFC border_radius: 10.0 border_size: 1.0 border_color: #xE2E8F0 } }
site_address_input := TextInput { width: Fill, height: 36, empty_text: "Address / area — e.g. Nairobi, KE" draw_bg +: { color: #xF8FAFC border_radius: 10.0 border_size: 1.0 border_color: #xE2E8F0 } } site_address_input := TextInput { width: Fill, height: 36, empty_text: "Address / area — e.g. Nairobi, KE" draw_bg +: { color: #xF8FAFC border_radius: 10.0 border_size: 1.0 border_color: #xE2E8F0 } }
nature_row := View { nature_row := View {
width: Fill, height: Fit width: Fill, height: Fit
@ -111,9 +112,18 @@ impl Widget for SiteListPage {
.is_some_and(|fe| fe.was_tap()) .is_some_and(|fe| fe.was_tap())
{ {
if let Some(row) = self.rows.get(item_id).cloned() { if let Some(row) = self.rows.get(item_id).cloned() {
crate::store::SiteStore::mutate(|s| { let accepted = crate::store::SiteStore::mutate_profile(|store| {
s.apply_selected(&row.id); store.apply_selected(&row.id);
}); });
if !accepted {
let message = crate::store::SiteStore::mutation_status(false);
self.view
.label(cx, ids!(new_site_form.site_status))
.set_text(cx, &message);
self.view
.view(cx, ids!(new_site_form))
.set_visible(cx, true);
}
self.refresh_rows(cx, None); self.refresh_rows(cx, None);
self.view.redraw(cx); self.view.redraw(cx);
} }
@ -181,10 +191,18 @@ impl Widget for SiteListPage {
}; };
let site = crate::domain::site::Site::new(name, nature, addr); let site = crate::domain::site::Site::new(name, nature, addr);
let new_id = site.id.clone(); let new_id = site.id.clone();
crate::store::SiteStore::mutate(|s| { let accepted = crate::store::SiteStore::mutate_profile(|store| {
s.push_site(site); store.push_site(site);
s.apply_selected(&new_id); store.apply_selected(&new_id);
}); });
if !accepted {
let message = crate::store::SiteStore::mutation_status(false);
self.view
.label(cx, ids!(new_site_form.site_status))
.set_text(cx, &message);
self.view.redraw(cx);
return;
}
self.refresh_rows(cx, None); self.refresh_rows(cx, None);
self.view self.view
.view(cx, ids!(new_site_form)) .view(cx, ids!(new_site_form))
@ -245,7 +263,7 @@ impl SiteListPage {
.unwrap_or_else(|| self.last_search.clone()) .unwrap_or_else(|| self.last_search.clone())
.to_lowercase(); .to_lowercase();
let store = crate::store::SiteStore::read(); let store = crate::store::SiteStore::read();
let selected = store.selected_site_id_or_first(); let selected = store.selected_site_id();
self.rows = store self.rows = store
.sites .sites
.iter() .iter()

View file

@ -123,7 +123,7 @@ impl Widget for WorkersPage {
if self.rows.is_empty() { if self.rows.is_empty() {
let store = crate::store::SiteStore::read(); let store = crate::store::SiteStore::read();
let date = chrono::Local::now().date_naive(); let date = chrono::Local::now().date_naive();
if let Some(site) = store.selected_or_first() { if let Some(site) = store.selected_site() {
if let Some(table) = store if let Some(table) = store
.workers .workers
.iter() .iter()

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,5 @@
#![cfg(target_os = "linux")]
//! Live end-to-end test against a real `nimanyatta` server. //! Live end-to-end test against a real `nimanyatta` server.
//! //!
//! Run: start the server first, then //! Run: start the server first, then

View file

@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""Fail on RustSec findings reachable from nigig-site production dependencies.
Cargo.lock is workspace-wide, so cargo-audit can report packages that are not in
this application's normal/build graph. This script consumes unmodified
`cargo metadata` and `cargo audit --json` output, traverses every non-dev edge
for all target predicates, and makes that containment explicit rather than
ignoring advisory IDs globally.
"""
from __future__ import annotations
import json
import sys
from collections import deque
from pathlib import Path
from typing import Any, NoReturn
def fail(message: str) -> NoReturn:
print(f"ERROR: {message}", file=sys.stderr)
raise SystemExit(1)
def load_json(path: str) -> dict[str, Any]:
try:
value = json.loads(Path(path).read_text())
except (OSError, UnicodeError, json.JSONDecodeError) as error:
fail(f"cannot parse {path}: {error}")
if not isinstance(value, dict):
fail(f"expected a JSON object in {path}")
return value
def main() -> None:
if len(sys.argv) != 4:
fail("usage: audit-production-deps.py METADATA.json AUDIT.json REPORT.txt")
metadata = load_json(sys.argv[1])
audit = load_json(sys.argv[2])
packages = metadata.get("packages")
resolve = metadata.get("resolve")
if not isinstance(packages, list) or not isinstance(resolve, dict):
fail("cargo metadata is missing packages/resolve")
nodes = resolve.get("nodes")
if not isinstance(nodes, list):
fail("cargo metadata is missing resolve.nodes")
roots = [
package
for package in packages
if package.get("name") == "nigig-site"
and str(package.get("manifest_path", "")).replace("\\", "/").endswith(
"/crates/apps/nigig-site/Cargo.toml"
)
]
if len(roots) != 1:
fail(f"expected exactly one nigig-site package, found {len(roots)}")
root_id = roots[0].get("id")
node_by_id = {node.get("id"): node for node in nodes}
package_by_id = {package.get("id"): package for package in packages}
if root_id not in node_by_id or root_id not in package_by_id:
fail("nigig-site is absent from the resolved graph")
reachable: set[str] = set()
queue: deque[str] = deque([root_id])
while queue:
package_id = queue.popleft()
if package_id in reachable:
continue
reachable.add(package_id)
node = node_by_id.get(package_id)
if not isinstance(node, dict):
fail(f"resolved node is missing for {package_id}")
dependencies = node.get("deps")
if not isinstance(dependencies, list):
fail(f"resolved dependencies are malformed for {package_id}")
for dependency in dependencies:
kinds = dependency.get("dep_kinds")
if not isinstance(kinds, list) or not kinds:
fail(f"dependency kinds are missing for {dependency!r}")
# `kind: null` is a normal dependency. Build dependencies execute in
# the production build trust boundary. Dev-only edges are excluded.
if not any(kind.get("kind") in (None, "build") for kind in kinds):
continue
child = dependency.get("pkg")
if child not in node_by_id:
fail(f"resolved child node is missing for {child}")
queue.append(child)
reachable_versions = {
(str(package_by_id[item].get("name")), str(package_by_id[item].get("version")))
for item in reachable
}
vulnerabilities = audit.get("vulnerabilities")
warnings = audit.get("warnings")
database = audit.get("database")
settings = audit.get("settings")
if not isinstance(vulnerabilities, dict) or not isinstance(warnings, dict):
fail("cargo-audit JSON is missing vulnerabilities/warnings")
if not isinstance(database, dict) or not database.get("last-commit"):
fail("cargo-audit JSON is missing database provenance")
if not isinstance(settings, dict):
fail("cargo-audit JSON is missing settings")
if settings.get("ignore") != []:
fail("cargo-audit advisory ignores are forbidden")
if settings.get("target_arch") != [] or settings.get("target_os") != []:
fail("cargo-audit target filters are forbidden")
informational = settings.get("informational_warnings")
if not isinstance(informational, list) or not {
"unmaintained",
"unsound",
"notice",
}.issubset(informational):
fail("cargo-audit informational warnings are not fully enabled")
vulnerability_list = vulnerabilities.get("list")
if not isinstance(vulnerability_list, list):
fail("cargo-audit vulnerability list is malformed")
if vulnerabilities.get("count") != len(vulnerability_list):
fail("cargo-audit vulnerability count does not match its list")
if vulnerabilities.get("found") is not bool(vulnerability_list):
fail("cargo-audit vulnerability flag does not match its list")
findings: list[tuple[str, str, str, str]] = []
excluded: list[tuple[str, str, str, str]] = []
def classify(kind: str, item: dict[str, Any]) -> None:
package = item.get("package")
advisory = item.get("advisory")
if not isinstance(package, dict) or not isinstance(advisory, dict):
fail(f"malformed cargo-audit {kind} item")
name = str(package.get("name"))
version = str(package.get("version"))
advisory_id = str(advisory.get("id"))
record = (kind, advisory_id, name, version)
if (name, version) in reachable_versions:
findings.append(record)
else:
excluded.append(record)
for item in vulnerability_list:
if not isinstance(item, dict):
fail("malformed cargo-audit vulnerability item")
classify("vulnerability", item)
for warning_kind, items in warnings.items():
if not isinstance(items, list):
fail(f"cargo-audit warning list is malformed: {warning_kind}")
for item in items:
if not isinstance(item, dict):
fail(f"malformed cargo-audit warning item: {warning_kind}")
classify(f"warning:{warning_kind}", item)
report_lines = [
"nigig-site production RustSec containment report",
f"reachable normal/build packages (all target predicates): {len(reachable)}",
f"workspace findings reported by cargo-audit: {len(findings) + len(excluded)}",
f"reachable findings: {len(findings)}",
f"excluded dev/unrelated-workspace findings: {len(excluded)}",
"",
]
for prefix, records in (("REACHABLE", findings), ("EXCLUDED", excluded)):
for kind, advisory_id, name, version in sorted(records):
report_lines.append(
f"{prefix} {kind} {advisory_id} {name} {version}"
)
report = "\n".join(report_lines) + "\n"
Path(sys.argv[3]).write_text(report)
print(report, end="")
if findings:
fail("RustSec findings are reachable from nigig-site production dependencies")
if __name__ == "__main__":
main()

View file

@ -12,12 +12,18 @@ command -v rustup >/dev/null || {
exit 1 exit 1
} }
sudo apt-get update -qq packages=(
sudo apt-get install -y -qq \ time pkg-config
time pkg-config \ libwayland-dev libxcursor-dev libxrandr-dev libxi-dev libx11-dev
libwayland-dev libxcursor-dev libxrandr-dev libxi-dev libx11-dev \ libgl1-mesa-dev libasound2-dev libglib2.0-dev libssl-dev
libgl1-mesa-dev libasound2-dev libglib2.0-dev libssl-dev \
libsqlite3-dev libudev-dev libpulse-dev libxkbcommon-dev libdbus-1-dev libsqlite3-dev libudev-dev libpulse-dev libxkbcommon-dev libdbus-1-dev
xvfb xdotool imagemagick
)
if [[ ${NIGIG_SITE_INSTALL_NATIVE_KEYRING:-0} == 1 ]]; then
packages+=(dbus-x11 gnome-keyring libsecret-tools)
fi
sudo apt-get update -qq
sudo apt-get install -y -qq "${packages[@]}"
channel=$(sed -n \ channel=$(sed -n \
's/^[[:space:]]*channel[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' \ 's/^[[:space:]]*channel[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' \

View file

@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Exercise the real Linux Secret Service provider in a disposable session.
# No credential is written to the user's actual HOME, session bus, or keyring.
set -euo pipefail
if [[ "$(uname -s)" != Linux ]]; then
echo "ERROR: native-keyring-smoke.sh is Linux-only." >&2
exit 2
fi
for command in dbus-run-session gnome-keyring-daemon secret-tool cargo mktemp timeout; do
command -v "$command" >/dev/null || {
echo "ERROR: required command is unavailable: $command" >&2
exit 2
}
done
# Preserve the already-installed toolchain/cache locations before HOME is
# redirected. Standard hosted Rust runners keep both beneath the real HOME.
original_home="$HOME"
export CARGO_HOME="${CARGO_HOME:-$original_home/.cargo}"
export RUSTUP_HOME="${RUSTUP_HOME:-$original_home/.rustup}"
if [[ $# -eq 0 ]]; then
work_root="$(mktemp -d /tmp/nigig-site-keyring.XXXXXX)"
else
work_root="$1"
[[ "$work_root" == /tmp/nigig-site-* ]] || {
echo "ERROR: disposable keyring path must be beneath /tmp/nigig-site-*" >&2
exit 2
}
[[ ! -e "$work_root" ]] || {
echo "ERROR: disposable keyring path already exists: $work_root" >&2
exit 2
}
# Plain mkdir is atomic and refuses a symlink/path created after the check.
mkdir -m 700 -- "$work_root"
fi
home="$work_root/home"
runtime="$work_root/runtime"
mkdir -m 700 "$home" "$runtime"
cleanup() {
rm -rf "$work_root"
}
trap cleanup EXIT INT TERM
export HOME="$home"
export XDG_RUNTIME_DIR="$runtime"
export NIGIG_SITE_KEYRING_TEST_HOME="$home"
export NIGIG_SITE_LIVE_KEYRING_TEST=disposable-secret-service-v1
# The fixed string unlocks only this newly-created disposable keyring. It is
# neither an application credential nor persisted outside work_root.
dbus-run-session -- bash -euo pipefail -c '
eval "$(printf site02-test-only-unlock | \
gnome-keyring-daemon --unlock --components=secrets)"
# This command is also a supported standalone smoke: allow a cold, low-core
# host to compile the desktop dependency graph while retaining a hard bound.
timeout --signal=TERM --kill-after=10s 600s \
cargo test --locked -p nigig-site --lib \
repository::tests::linux_native_provider_real_vault_lifecycle -- \
--ignored --exact --test-threads=1
'
echo "native keyring smoke passed: disposable Secret Service lifecycle and encrypted repository"

View file

@ -0,0 +1,107 @@
#!/usr/bin/env bash
# Launch the real desktop binary in an isolated X11 session, prove its window
# renders, capture evidence, request a normal window close, and require a clean
# process exit so SiteStandaloneApp::drop runs its bounded writer drain.
set -euo pipefail
binary=${1:-target/debug/nigig-site}
evidence_dir=${2:-/tmp/nigig-site-ci}
[[ -x "$binary" ]] || {
echo "ERROR: runtime binary is not executable: $binary" >&2
exit 1
}
for command in xvfb-run xdotool import identify timeout; do
command -v "$command" >/dev/null || {
echo "ERROR: required runtime-smoke tool is missing: $command" >&2
exit 1
}
done
mkdir -p "$evidence_dir"
runtime_root=$(mktemp -d)
cleanup() {
rm -rf "$runtime_root"
}
trap cleanup EXIT
mkdir -p "$runtime_root/home" "$runtime_root/data" "$runtime_root/cache"
export HOME="$runtime_root/home"
export XDG_DATA_HOME="$runtime_root/data"
export XDG_CACHE_HOME="$runtime_root/cache"
export NIGIG_SITE_RUNTIME_BINARY="$binary"
export NIGIG_SITE_RUNTIME_ROOT="$runtime_root"
export NIGIG_SITE_RUNTIME_EVIDENCE="$evidence_dir"
timeout --signal=TERM --kill-after=5s 30s xvfb-run -a bash -c '
set -euo pipefail
"$NIGIG_SITE_RUNTIME_BINARY" >"$NIGIG_SITE_RUNTIME_EVIDENCE/runtime-app.log" 2>&1 &
pid=$!
cleanup_child() {
if [[ -n "${pid:-}" ]] && kill -0 "$pid" 2>/dev/null; then
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
fi
}
trap cleanup_child EXIT
window=""
for _ in $(seq 1 200); do
window=$(xdotool search --pid "$pid" --name "^nigig-site$" 2>/dev/null | head -n 1 || true)
[[ -n "$window" ]] && break
if ! kill -0 "$pid" 2>/dev/null; then
cat "$NIGIG_SITE_RUNTIME_EVIDENCE/runtime-app.log" >&2
wait "$pid"
exit 1
fi
sleep 0.1
done
[[ -n "$window" ]] || {
echo "ERROR: nigig-site did not create its desktop window" >&2
cat "$NIGIG_SITE_RUNTIME_EVIDENCE/runtime-app.log" >&2
kill "$pid" 2>/dev/null || true
exit 1
}
[[ $(xdotool getwindowname "$window") == nigig-site ]]
# Wait for the first GL present; finding a mapped-but-unpainted black window
# is not runtime UI evidence.
sleep 2
import -window "$window" "$NIGIG_SITE_RUNTIME_EVIDENCE/runtime-ui.png"
identify "$NIGIG_SITE_RUNTIME_EVIDENCE/runtime-ui.png" \
>"$NIGIG_SITE_RUNTIME_EVIDENCE/runtime-ui-image.txt"
mean=$(identify -format "%[fx:mean]" "$NIGIG_SITE_RUNTIME_EVIDENCE/runtime-ui.png")
awk -v mean="$mean" "BEGIN { exit !(mean > 0.05) }" || {
echo "ERROR: runtime window remained effectively black after first paint" >&2
exit 1
}
# WM_DELETE_WINDOW follows the normal Makepad close path. A forced signal
# would not prove the Rust Drop implementation and is therefore forbidden.
xdotool windowclose "$window"
for _ in $(seq 1 200); do
! kill -0 "$pid" 2>/dev/null && break
sleep 0.1
done
if kill -0 "$pid" 2>/dev/null; then
echo "ERROR: nigig-site ignored a normal window-close request" >&2
kill "$pid" 2>/dev/null || true
exit 1
fi
wait "$pid"
pid=""
'
# An absent repository must stay absent: startup is open-only and setup is
# security-review locked. Cache/shader output is outside XDG_DATA_HOME.
if find "$runtime_root/data" -type f -print -quit | grep -q .; then
echo "ERROR: runtime startup created repository data without setup consent" >&2
find "$runtime_root/data" -type f -print >&2
exit 1
fi
if grep -Eiq 'panicked|fatal|segmentation fault|graceful persistence drain failed' \
"$evidence_dir/runtime-app.log"; then
echo "ERROR: runtime log contains a fatal or shutdown failure" >&2
cat "$evidence_dir/runtime-app.log" >&2
exit 1
fi
[[ -s "$evidence_dir/runtime-ui.png" ]]
[[ -s "$evidence_dir/runtime-ui-image.txt" ]]
echo "runtime smoke passed: isolated startup, rendered window, normal close, no repository creation"