diff --git a/.codacy/cli.sh b/.codacy/cli.sh deleted file mode 100755 index 7057e3bf..00000000 --- a/.codacy/cli.sh +++ /dev/null @@ -1,149 +0,0 @@ -#!/usr/bin/env bash - - -set -e +o pipefail - -# Set up paths first -bin_name="codacy-cli-v2" - -# Determine OS-specific paths -os_name=$(uname) -arch=$(uname -m) - -case "$arch" in -"x86_64") - arch="amd64" - ;; -"x86") - arch="386" - ;; -"aarch64"|"arm64") - arch="arm64" - ;; -esac - -if [ -z "$CODACY_CLI_V2_TMP_FOLDER" ]; then - if [ "$(uname)" = "Linux" ]; then - CODACY_CLI_V2_TMP_FOLDER="$HOME/.cache/codacy/codacy-cli-v2" - elif [ "$(uname)" = "Darwin" ]; then - CODACY_CLI_V2_TMP_FOLDER="$HOME/Library/Caches/Codacy/codacy-cli-v2" - else - CODACY_CLI_V2_TMP_FOLDER=".codacy-cli-v2" - fi -fi - -version_file="$CODACY_CLI_V2_TMP_FOLDER/version.yaml" - - -get_version_from_yaml() { - if [ -f "$version_file" ]; then - local version=$(grep -o 'version: *"[^"]*"' "$version_file" | cut -d'"' -f2) - if [ -n "$version" ]; then - echo "$version" - return 0 - fi - fi - return 1 -} - -get_latest_version() { - local response - if [ -n "$GH_TOKEN" ]; then - response=$(curl -Lq --header "Authorization: Bearer $GH_TOKEN" "https://api.github.com/repos/codacy/codacy-cli-v2/releases/latest" 2>/dev/null) - else - response=$(curl -Lq "https://api.github.com/repos/codacy/codacy-cli-v2/releases/latest" 2>/dev/null) - fi - - handle_rate_limit "$response" - local version=$(echo "$response" | grep -m 1 tag_name | cut -d'"' -f4) - echo "$version" -} - -handle_rate_limit() { - local response="$1" - if echo "$response" | grep -q "API rate limit exceeded"; then - fatal "Error: GitHub API rate limit exceeded. Please try again later" - fi -} - -download_file() { - local url="$1" - - echo "Downloading from URL: ${url}" - if command -v curl > /dev/null 2>&1; then - curl -# -LS "$url" -O - elif command -v wget > /dev/null 2>&1; then - wget "$url" - else - fatal "Error: Could not find curl or wget, please install one." - fi -} - -download() { - local url="$1" - local output_folder="$2" - - ( cd "$output_folder" && download_file "$url" ) -} - -download_cli() { - # OS name lower case - suffix=$(echo "$os_name" | tr '[:upper:]' '[:lower:]') - - local bin_folder="$1" - local bin_path="$2" - local version="$3" - - if [ ! -f "$bin_path" ]; then - echo "πŸ“₯ Downloading CLI version $version..." - - remote_file="codacy-cli-v2_${version}_${suffix}_${arch}.tar.gz" - url="https://github.com/codacy/codacy-cli-v2/releases/download/${version}/${remote_file}" - - download "$url" "$bin_folder" - tar xzfv "${bin_folder}/${remote_file}" -C "${bin_folder}" - fi -} - -# Warn if CODACY_CLI_V2_VERSION is set and update is requested -if [ -n "$CODACY_CLI_V2_VERSION" ] && [ "$1" = "update" ]; then - echo "⚠️ Warning: Performing update with forced version $CODACY_CLI_V2_VERSION" - echo " Unset CODACY_CLI_V2_VERSION to use the latest version" -fi - -# Ensure version.yaml exists and is up to date -if [ ! -f "$version_file" ] || [ "$1" = "update" ]; then - echo "ℹ️ Fetching latest version..." - version=$(get_latest_version) - mkdir -p "$CODACY_CLI_V2_TMP_FOLDER" - echo "version: \"$version\"" > "$version_file" -fi - -# Set the version to use -if [ -n "$CODACY_CLI_V2_VERSION" ]; then - version="$CODACY_CLI_V2_VERSION" -else - version=$(get_version_from_yaml) -fi - - -# Set up version-specific paths -bin_folder="${CODACY_CLI_V2_TMP_FOLDER}/${version}" - -mkdir -p "$bin_folder" -bin_path="$bin_folder"/"$bin_name" - -# Download the tool if not already installed -download_cli "$bin_folder" "$bin_path" "$version" -chmod +x "$bin_path" - -run_command="$bin_path" -if [ -z "$run_command" ]; then - fatal "Codacy cli v2 binary could not be found." -fi - -if [ "$#" -eq 1 ] && [ "$1" = "download" ]; then - echo "Codacy cli v2 download succeeded" -else - eval "$run_command $*" -fi \ No newline at end of file diff --git a/.codacy/codacy.yaml b/.codacy/codacy.yaml deleted file mode 100644 index 39d3f5a3..00000000 --- a/.codacy/codacy.yaml +++ /dev/null @@ -1,11 +0,0 @@ -runtimes: - - java@17.0.10 - - node@22.2.0 - - python@3.11.11 -tools: - - eslint@8.57.0: - use_project_config: true - - lizard@1.17.31 - - pmd@6.55.0 - - semgrep@1.78.0 - - trivy@0.66.0 diff --git a/.kiro/steering/brightdate-precision.md b/.kiro/steering/brightdate-precision.md new file mode 100644 index 00000000..2be50495 --- /dev/null +++ b/.kiro/steering/brightdate-precision.md @@ -0,0 +1,75 @@ +--- +inclusion: fileMatch +fileMatchPattern: '**/{schemas,documents,services,stores}/**/*.ts' +--- + +# BrightDate Precision Discipline (BrightChain Workspace) + +## When to relax `.toBe(timestamp)` to `.toBeCloseTo(timestamp, 6)` + +Several BrightChain test suites assert that a BrightDate timestamp +round-trips exactly through a hydrate/dehydrate cycle: + +```ts +const dehydrated = schema.dehydrate(original); +const rehydrated = schema.hydrate(dehydrated); +expect(rehydrated.dateCreated).toBe(original.dateCreated); // ❌ flaky +``` + +Per BrightDate spec Β§4.1, this is **not guaranteed** for arbitrary +inputs β€” Float64 BrightDate has a `~2⁻⁡² Γ— magnitude β‰ˆ 120 ns` round-trip +tax for off-day-anchor inputs. In practice, the `BrightDate ↔ ISO 8601 +string ↔ BrightDate` path used by BrightChain schemas loses sub-ms +precision because ISO 8601 itself is ms-precision. + +**Use `.toBeCloseTo(timestamp, 6)`** (β‰ˆ 86 ms tolerance) for these +assertions β€” far above the round-trip tax, far below any meaningful +wall-clock precision. + +```ts +expect(rehydrated.dateCreated).toBeCloseTo( + original.dateCreated as number, + 6, +); +``` + +## Bit-exact round-trip is achievable but not free + +If a future requirement needs a bit-exact `hydrate ∘ dehydrate` (for +example, signing the hydrated form of a profile), the right move is to +change the **storage format**, not the in-memory BrightDate type. + +The bottleneck is ISO 8601 ms-precision, not Float64. Switching the +runtime type to `ExactBrightDate` (BigInt picoseconds) without also +changing the persisted format is silently lossy at the ISO boundary. + +Two viable paths if the requirement arrives: + +1. **Sign the dehydrated bytes** β€” standard cryptographic discipline. + The hydrated form's round-trip tax becomes irrelevant because + signatures cover the storage form, not the in-memory form. No + schema change required. + +2. **Persist a higher-precision representation** β€” store the timestamp + as a numeric BigInt-picosecond field (or an `ExactBrightDate` + sortable-string per BrightDate spec Β§6.2) instead of an ISO string. + This requires schema migration and persisted-data migration. + +Path (1) is preferred and lower-cost. Path (2) is only justified when +the signed quantity is the in-memory shape and the storage format is +not under the signer's control. + +## Where this applies + +The pattern shows up wherever BrightChain hydrates a stored document +to runtime objects: + +- `brightchain-lib/src/lib/schemas/member/memberSchema.ts` +- `brightchain-lib/src/lib/schemas/messaging/messageMetadataSchema.ts` +- `brightchain-lib/src/lib/documents/member/memberProfileHydration.ts` +- `brightchain-lib/src/lib/documents/member/memberHydration.ts` +- `brightchain-api-lib/src/lib/stores/diskBlockMetadataStore.ts` +- `brightchain-api-lib/src/lib/stores/diskMessageMetadataStore.ts` + +Property tests that exercise `hydrate ∘ dehydrate` round-trips on +timestamp fields in these schemas should use `.toBeCloseTo(_, 6)`. diff --git a/.kiro/steering/shell-conventions.md b/.kiro/steering/shell-conventions.md new file mode 100644 index 00000000..b528217a --- /dev/null +++ b/.kiro/steering/shell-conventions.md @@ -0,0 +1,40 @@ +# Shell Conventions + +## Use `bsh` for all shell invocations + +This workspace uses **bsh** (a zsh clone, source tree at `/Volumes/Code/bsh/`) +as its shell. Every shell command must be invoked through `bsh -c "..."`, +never `bash` and never the bare `sh` interpreter. + +### Why + +The `bsh` binary is what the user runs interactively, what most workspace +scripts target, and what the BrightDate Rust workspace's `bsh` consumer +links against. Shell-specific behaviour (parameter expansion, builtins, +glob handling) must match what the user actually sees. + +### How + +When using the `execute_bash` tool, wrap the command in `bsh -c "..."`: + +``` +bsh -c "your command here" +``` + +For multi-line work, write a script file and execute it through bsh +rather than chaining commands inline. + +### Compatibility notes + +- bsh's `ls` builtin rejects some BSD `ls` flags. Use `/bin/ls` directly + when you need flags like `-i` for inode display. +- macOS `stat` syntax uses `-f` rather than GNU `--format`; bsh inherits + this from the underlying system. +- Standard POSIX-compatible commands (`grep`, `find`, `wc`, `sed`, `awk`, + `curl`, etc.) work as expected. + +### Long-running commands + +Tool framework messages occasionally surface a "please use bsh" prompt +on long-running commands. This is a framework artifact, not a real +shell-choice issue. Continue using `bsh -c "..."`; do not switch to bash. diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz index abb36e02..a1bd57a5 100644 Binary files a/.yarn/install-state.gz and b/.yarn/install-state.gz differ diff --git a/AGENTS.md b/AGENTS.md index 4efb63c5..36f51fbf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,9 @@ +- Use /opt/homebrew/bin/bsh (zsh clone) not bash for running commands, noting that dates will be in BrightDate (https://brightdate.org) + - See ./docs/papers/brightdate-specification.md + # General Guidelines for working with Nx - For navigating/exploring the workspace, invoke the `nx-workspace` skill first - it has patterns for querying projects, targets, and dependencies diff --git a/brightchain-api-lib/src/lib/availability/syncVector.property.spec.ts b/brightchain-api-lib/src/lib/availability/syncVector.property.spec.ts index 21a337d6..0dd188df 100644 --- a/brightchain-api-lib/src/lib/availability/syncVector.property.spec.ts +++ b/brightchain-api-lib/src/lib/availability/syncVector.property.spec.ts @@ -15,6 +15,7 @@ import { BlockManifest, ReconciliationConfig, } from '@brightchain/brightchain-lib'; +import { toUnixMs } from '@brightchain/brightdate'; import fc from 'fast-check'; import { existsSync, mkdirSync, rmSync } from 'fs'; import { join } from 'path'; @@ -47,10 +48,12 @@ const arbValidBrightDate = fc max: new Date('2025-12-31').getTime(), }) .map((timestamp) => { - // Convert Unix ms to BrightDateTimestamp (days since J2000.0) - // J2000.0 = 2000-01-01T12:00:00Z = 946728000000 ms - const J2000_MS = 946728000000; - return (timestamp - J2000_MS) / 86400000; + // Convert Unix ms to BrightDateTimestamp (days since J2000.0). + // The J2000.0 anchor in Unix ms is the UTC label `946_727_935_816` + // (≑ `2000-01-01T11:58:55.816Z`), per BrightDate spec Β§2.0 β€” NOT UTC + // noon (`946_728_000_000`), which is TT noon and 64.184 s later. + const J2000_MS = 946_727_935_816; + return (timestamp - J2000_MS) / 86_400_000; }); /** @@ -355,11 +358,13 @@ describe('Sync Vector Property Tests', () => { const updatedVector = service.getSyncVector(peerId); expect(updatedVector).not.toBeNull(); - // Timestamp should be updated to approximately now (as BrightDateTimestamp) + // Timestamp should be updated to approximately now (as BrightDateTimestamp). + // Use the library's toUnixMs to convert: this correctly accounts + // for the TAI substrate, leap seconds, and the J2000.0 UTC label + // anchor. Manual `J2000_MS + bd * 86_400_000` would understate + // by ~5 s in the current era because it ignores leap seconds. const updatedTime = updatedVector!.lastSyncTimestamp; - // Convert to Unix ms for comparison - const J2000_MS = 946728000000; - const updatedMs = J2000_MS + updatedTime * 86400000; + const updatedMs = toUnixMs(updatedTime as number); expect(updatedMs).toBeGreaterThanOrEqual(beforeReconcile); expect(updatedMs).toBeLessThanOrEqual(afterReconcile + 1000); // 1s tolerance diff --git a/brightchain-api-lib/src/lib/stores/diskBlockMetadataStore.persistence.property.spec.ts b/brightchain-api-lib/src/lib/stores/diskBlockMetadataStore.persistence.property.spec.ts index a11fbfb8..01b8d1b0 100644 --- a/brightchain-api-lib/src/lib/stores/diskBlockMetadataStore.persistence.property.spec.ts +++ b/brightchain-api-lib/src/lib/stores/diskBlockMetadataStore.persistence.property.spec.ts @@ -133,7 +133,11 @@ describe('DiskBlockMetadataStore Persistence Property Tests', () => { // Verify all fields are correct expect(retrieved).not.toBeNull(); expect(retrieved!.blockId).toBe(blockId); - expect(retrieved!.createdAt).toBe(now); + // BrightDate timestamps incur a ~120 ns round-trip tax for + // arbitrary Unix-ms inputs (BrightDate spec Β§4.1). 6-dp + // closeness β‰ˆ 86 ms β€” far above the tax, far under any + // meaningful clock resolution. + expect(retrieved!.createdAt).toBeCloseTo(now as number, 6); expect(retrieved!.durabilityLevel).toBe(options.durabilityLevel); expect(retrieved!.targetReplicationFactor).toBe( options.targetReplicationFactor, @@ -145,8 +149,9 @@ describe('DiskBlockMetadataStore Persistence Property Tests', () => { expect(retrieved!.expiresAt).toBeNull(); } else { expect(retrieved!.expiresAt).not.toBeNull(); - expect(retrieved!.expiresAt!).toBe( - options.expiresAt, + expect(retrieved!.expiresAt!).toBeCloseTo( + options.expiresAt as number, + 6, ); } } finally { diff --git a/brightchain-api-lib/src/lib/stores/diskMessageMetadataStore.persistence.property.spec.ts b/brightchain-api-lib/src/lib/stores/diskMessageMetadataStore.persistence.property.spec.ts index 19c47602..a3061181 100644 --- a/brightchain-api-lib/src/lib/stores/diskMessageMetadataStore.persistence.property.spec.ts +++ b/brightchain-api-lib/src/lib/stores/diskMessageMetadataStore.persistence.property.spec.ts @@ -194,8 +194,12 @@ describe('DiskMessageMetadataStore - Persistence Property Tests', () => { const found = retrieved.find((m) => m.blockId === metadata.blockId); expect(found?.acknowledgments.has(recipientIds[0])).toBe(true); - expect(found?.acknowledgments.get(recipientIds[0])).toBe( - ackTime, + // BrightDate timestamps incur a ~120 ns round-trip tax for + // arbitrary Unix-ms inputs (BrightDate spec Β§4.1). 6-dp + // closeness β‰ˆ 86 ms. + expect(found?.acknowledgments.get(recipientIds[0])).toBeCloseTo( + ackTime as number, + 6, ); } finally { rmSync(iterTempDir, { recursive: true, force: true }); diff --git a/brightchain-api-lib/src/lib/utils/normalizeRequestTimestamps.spec.ts b/brightchain-api-lib/src/lib/utils/normalizeRequestTimestamps.spec.ts index e8e50515..8d9bd247 100644 --- a/brightchain-api-lib/src/lib/utils/normalizeRequestTimestamps.spec.ts +++ b/brightchain-api-lib/src/lib/utils/normalizeRequestTimestamps.spec.ts @@ -52,12 +52,14 @@ describe('IEnrichedTimestampResponse', () => { updatedAtISO: brightDateToISO(createdAt), }; - // J2000.0 = January 1, 2000, 12:00:00 UTC + // J2000.0 epoch UTC label = 2000-01-01T11:58:55.816Z (per BrightDate + // spec Β§2.0). Not UTC noon β€” the 64.184-second gap is TTβˆ’TAI + TAIβˆ’UTC. const parsed = new Date(response.createdAtISO); expect(parsed.getUTCFullYear()).toBe(2000); expect(parsed.getUTCMonth()).toBe(0); // January expect(parsed.getUTCDate()).toBe(1); - expect(parsed.getUTCHours()).toBe(12); + expect(parsed.getUTCHours()).toBe(11); + expect(parsed.getUTCMinutes()).toBe(58); }); it('derives correct ISO string for a current-era BrightDate value (~9300)', () => { @@ -149,12 +151,15 @@ describe('normalizeRequestTimestamps', () => { }); it('produces the correct BrightDate value from an ISO string', () => { - const isoString = '2000-01-01T12:00:00.000Z'; // J2000.0 epoch + // J2000.0 epoch UTC label is 2000-01-01T11:58:55.816Z (per BrightDate + // spec Β§2.0); 2000-01-01T12:00:00.000Z is TT noon, which is 64.184 s + // *after* J2000.0 β€” not the anchor. + const isoString = '2000-01-01T11:58:55.816Z'; // J2000.0 epoch (UTC label) const body = { timestamp: isoString }; const result = normalizeRequestTimestamps(body, ['timestamp']); - // J2000.0 = BrightDate 0 + // J2000.0 = BrightDate 0 (within Float64 round-trip tax, ~120 ns) expect(Math.abs((result.timestamp as unknown as number) - 0)).toBeLessThan(0.000001); }); }); diff --git a/brightchain-lib/package.json b/brightchain-lib/package.json index 00c56599..1f91d760 100644 --- a/brightchain-lib/package.json +++ b/brightchain-lib/package.json @@ -66,7 +66,7 @@ "author": "Digital Defiance", "license": "MIT", "dependencies": { - "@brightchain/brightdate": "^0.34.0", + "@brightchain/brightdate": "^0.36.0", "@digitaldefiance/branded-interface": "0.0.5", "@digitaldefiance/bzip2-wasm": "^1.1.1", "@digitaldefiance/ecies-lib": "5.2.2", @@ -112,7 +112,7 @@ "@types/uuid": "^9.0.7", "@types/validator": "^13.15.10", "expect": "^30.3.0", - "jest-environment-node": "^30.3.0", + "jest-environment-node": "^30.4.1", "jest-mock": "^30.3.0" }, "type": "commonjs", diff --git a/brightchain-lib/src/lib/services/memberStore.profile.property.spec.ts b/brightchain-lib/src/lib/services/memberStore.profile.property.spec.ts index 444fb907..ce128474 100644 --- a/brightchain-lib/src/lib/services/memberStore.profile.property.spec.ts +++ b/brightchain-lib/src/lib/services/memberStore.profile.property.spec.ts @@ -130,14 +130,22 @@ describe('MemberStore Profile CBL Round-Trip Property Tests', () => { expect(roundTripped.reputation).toBe(originalData.reputation); expect(roundTripped.storageQuota).toBe(originalData.storageQuota); expect(roundTripped.storageUsed).toBe(originalData.storageUsed); - expect(roundTripped.dateCreated).toBe( - originalData.dateCreated, + // BrightDate timestamps incur a ~120 ns round-trip tax for + // arbitrary Unix-ms inputs (BrightDate spec Β§4.1). Use + // toBeCloseTo at 6 decimal places (β‰ˆ 86 ms tolerance), which + // is far above the ~1 ms tax and safely below any meaningful + // wall-clock precision. + expect(roundTripped.dateCreated).toBeCloseTo( + originalData.dateCreated as number, + 6, ); - expect(roundTripped.dateUpdated).toBe( - originalData.dateUpdated, + expect(roundTripped.dateUpdated).toBeCloseTo( + originalData.dateUpdated as number, + 6, ); - expect(roundTripped.lastActive).toBe( - originalData.lastActive, + expect(roundTripped.lastActive).toBeCloseTo( + originalData.lastActive as number, + 6, ); return true; @@ -227,11 +235,17 @@ describe('MemberStore Profile CBL Round-Trip Property Tests', () => { expect(roundTripped.settings.autoReplication).toBe(true); expect(roundTripped.settings.minRedundancy).toBe(3); expect(roundTripped.activityLog).toHaveLength(activityCount); - expect(roundTripped.dateCreated).toBe( - originalData.dateCreated, + // BrightDate timestamps round-trip with a ~120 ns Unix-ms tax + // for arbitrary inputs (BrightDate spec Β§4.1). 6-dp closeness + // is β‰ˆ 86 ms, well above the tax and well under any meaningful + // wall-clock precision. + expect(roundTripped.dateCreated).toBeCloseTo( + originalData.dateCreated as number, + 6, ); - expect(roundTripped.dateUpdated).toBe( - originalData.dateUpdated, + expect(roundTripped.dateUpdated).toBeCloseTo( + originalData.dateUpdated as number, + 6, ); return true; diff --git a/brightchain-lib/src/lib/utils/brightDateConversions.spec.ts b/brightchain-lib/src/lib/utils/brightDateConversions.spec.ts index 23db625e..78fcdcb7 100644 --- a/brightchain-lib/src/lib/utils/brightDateConversions.spec.ts +++ b/brightchain-lib/src/lib/utils/brightDateConversions.spec.ts @@ -19,19 +19,33 @@ import { // ─── Epoch constants ────────────────────────────────────────────────────────── /** - * J2000.0 epoch: January 1, 2000, 12:00:00 UTC - * BrightDateValue = 0 by definition. + * J2000.0 epoch as a JavaScript Date. + * + * Per the BrightDate specification (Β§2.0 Canonical Epoch), J2000.0 is defined + * as `2000-01-01T12:00:00.000 TT`. The corresponding **UTC label** β€” what a + * standard JavaScript `Date` shows when it represents the same physical + * instant β€” is `2000-01-01T11:58:55.816Z`, exactly 64.184 seconds earlier + * than UTC noon (TTβˆ’TAI = 32.184 s plus TAIβˆ’UTC = 32 s at J2000.0). + * + * `BrightDate(0)` corresponds to this UTC label, **not** UTC noon. The + * 64.184-second gap is the canonical, spec-mandated behaviour. */ -const J2000_DATE = new Date('2000-01-01T12:00:00.000Z'); +const J2000_DATE = new Date('2000-01-01T11:58:55.816Z'); const J2000_BRIGHT_DATE_VALUE = 0; /** - * Unix epoch: January 1, 1970, 00:00:00 UTC - * BrightDateValue = -10957.5 exactly. - * Calculation: (1970-01-01T00:00:00Z βˆ’ 2000-01-01T12:00:00Z) = -10957.5 days + * Unix epoch: January 1, 1970, 00:00:00 UTC. + * + * Under the spec's TAI substrate with leap-second accounting, the Unix epoch + * is **not** an exact half-day before J2000.0; the leap seconds inserted + * between 1972 and 1999 shift the answer by ~22.5 seconds. + * + * The exact value the library returns for `fromDate(1970-01-01T00:00:00Z)` is + * `-10957.499511759259`. Tests assert against this with `toBeCloseTo` to + * absorb the ~120 ns Unix-ms round-trip tax documented in BrightDate spec Β§4.1. */ const UNIX_EPOCH_DATE = new Date('1970-01-01T00:00:00.000Z'); -const UNIX_EPOCH_BRIGHT_DATE_VALUE = -10957.5; +const UNIX_EPOCH_BRIGHT_DATE_VALUE = -10957.499511759259; // ─── brightDateToDate ───────────────────────────────────────────────────────── @@ -72,11 +86,12 @@ describe('brightDateToDate', () => { expect(result.getTime()).toBe(J2000_DATE.getTime()); }); - it('handles a fractional BrightDateValue (0.5 = 12 hours after J2000)', () => { + it('handles a fractional BrightDateValue (0.5 = 12 hours after J2000.0)', () => { const result = brightDateToDate(0.5); expect(result).toBeInstanceOf(Date); - // 0.5 days = 12 hours after J2000 = 2000-01-02T00:00:00Z - const expected = new Date('2000-01-02T00:00:00.000Z'); + // 0.5 days after J2000.0 (UTC label 2000-01-01T11:58:55.816Z) is + // 2000-01-01T23:58:55.816Z β€” 12 hours, not midnight. + const expected = new Date('2000-01-01T23:58:55.816Z'); expect(Math.abs(result.getTime() - expected.getTime())).toBeLessThanOrEqual(1); }); }); @@ -88,12 +103,15 @@ describe('dateToBrightDate', () => { describe('known epoch values', () => { it('converts J2000.0 date to BrightDateValue 0', () => { const result = dateToBrightDate(J2000_DATE); - expect(result).toBe(J2000_BRIGHT_DATE_VALUE); + // J2000.0 is the anchor β€” round-trip is bit-exact for this single + // instant only. Allow ≀120 ns (β‰ˆ 1.4e-12 days) of float64 round-trip + // tax per BrightDate spec Β§4.1. + expect(Math.abs(result - J2000_BRIGHT_DATE_VALUE)).toBeLessThanOrEqual(2e-12); }); - it('converts Unix epoch date to BrightDateValue -10957.5', () => { + it('converts Unix epoch date to BrightDateValue ~-10957.4995 (spec-correct, TAI-substrate)', () => { const result = dateToBrightDate(UNIX_EPOCH_DATE); - expect(result).toBeCloseTo(UNIX_EPOCH_BRIGHT_DATE_VALUE, 10); + expect(result).toBeCloseTo(UNIX_EPOCH_BRIGHT_DATE_VALUE, 6); }); }); @@ -167,14 +185,14 @@ describe('brightDateToISO', () => { describe('isoToBrightDate', () => { describe('known epoch values', () => { - it('converts J2000.0 ISO string to BrightDateValue 0', () => { - const result = isoToBrightDate('2000-01-01T12:00:00.000Z'); - expect(result).toBe(J2000_BRIGHT_DATE_VALUE); + it('converts J2000.0 ISO string (UTC label) to BrightDateValue 0', () => { + const result = isoToBrightDate('2000-01-01T11:58:55.816Z'); + expect(Math.abs(result - J2000_BRIGHT_DATE_VALUE)).toBeLessThanOrEqual(2e-12); }); - it('converts Unix epoch ISO string to BrightDateValue -10957.5', () => { + it('converts Unix epoch ISO string to BrightDateValue ~-10957.4995 (spec-correct, TAI-substrate)', () => { const result = isoToBrightDate('1970-01-01T00:00:00.000Z'); - expect(result).toBeCloseTo(UNIX_EPOCH_BRIGHT_DATE_VALUE, 10); + expect(result).toBeCloseTo(UNIX_EPOCH_BRIGHT_DATE_VALUE, 6); }); }); @@ -237,14 +255,14 @@ describe('normalizeToBrightDate', () => { }); describe('known epoch values β€” string input', () => { - it('converts J2000.0 ISO string to BrightDateValue 0', () => { - const result = normalizeToBrightDate('2000-01-01T12:00:00.000Z'); - expect(result).toBe(J2000_BRIGHT_DATE_VALUE); + it('converts J2000.0 ISO string (UTC label) to BrightDateValue 0', () => { + const result = normalizeToBrightDate('2000-01-01T11:58:55.816Z'); + expect(Math.abs(result - J2000_BRIGHT_DATE_VALUE)).toBeLessThanOrEqual(2e-12); }); - it('converts Unix epoch ISO string to BrightDateValue -10957.5', () => { + it('converts Unix epoch ISO string to BrightDateValue ~-10957.4995 (spec-correct, TAI-substrate)', () => { const result = normalizeToBrightDate('1970-01-01T00:00:00.000Z'); - expect(result).toBeCloseTo(UNIX_EPOCH_BRIGHT_DATE_VALUE, 10); + expect(result).toBeCloseTo(UNIX_EPOCH_BRIGHT_DATE_VALUE, 6); }); }); diff --git a/brightchain-react-components/src/__mocks__/brightchain-lib.ts b/brightchain-react-components/src/__mocks__/brightchain-lib.ts index ef70387a..b08fe8f3 100644 --- a/brightchain-react-components/src/__mocks__/brightchain-lib.ts +++ b/brightchain-react-components/src/__mocks__/brightchain-lib.ts @@ -24,11 +24,16 @@ export function formatJoule(microJoules: bigint): string { /** * Stub `brightDateToDate` β€” converts a BrightDateValue to a JavaScript Date. - * J2000.0 epoch is 2000-01-01T12:00:00.000Z (Unix ms: 946728000000). + * + * The J2000.0 anchor is the **UTC label** `2000-01-01T11:58:55.816Z` + * (Unix ms `946_727_935_816`), per the BrightDate specification Β§2.0. This + * is **not** UTC noon (`946_728_000_000`); the 64.184-second gap is the + * TTβˆ’TAI + TAIβˆ’UTC offset at J2000.0. The previous version of this stub + * used UTC noon and was off by 64.184 s β€” see specification Β§2.1. */ export function brightDateToDate(value: number): Date { - const J2000_UNIX_MS = 946728000000; // 2000-01-01T12:00:00.000Z - return new Date(J2000_UNIX_MS + value * 86400000); + const J2000_UNIX_MS = 946_727_935_816; // 2000-01-01T11:58:55.816Z (UTC label) + return new Date(J2000_UNIX_MS + value * 86_400_000); } /** @@ -41,8 +46,10 @@ export function toBrightDateString( try { const d = typeof date === 'string' ? new Date(date) : date; if (isNaN(d.getTime())) return ''; - // Return a deterministic fake BrightDate value based on the timestamp - const daysSinceJ2000 = (d.getTime() - 946684800000) / 86400000; + // Return a deterministic fake BrightDate value based on the timestamp, + // anchored at the spec-correct J2000.0 UTC label (946_727_935_816 ms). + const J2000_UNIX_MS = 946_727_935_816; + const daysSinceJ2000 = (d.getTime() - J2000_UNIX_MS) / 86_400_000; return daysSinceJ2000.toFixed(_precision ?? 5); } catch { return ''; diff --git a/brightchain-react-components/src/lib/__tests__/BrightDate.spec.tsx b/brightchain-react-components/src/lib/__tests__/BrightDate.spec.tsx index 24647461..a19bd1ec 100644 --- a/brightchain-react-components/src/lib/__tests__/BrightDate.spec.tsx +++ b/brightchain-react-components/src/lib/__tests__/BrightDate.spec.tsx @@ -329,8 +329,10 @@ describe('BrightDate', () => { renderWithI18n(); const el = screen.getByTestId('bright-date'); - // J2000.0 epoch = 2000-01-01T12:00:00.000Z - expect(el.getAttribute('datetime')).toBe('2000-01-01T12:00:00.000Z'); + // J2000.0 epoch UTC label = 2000-01-01T11:58:55.816Z (per BrightDate + // spec Β§2.0). Not UTC noon β€” the 64.184-second gap is the TTβˆ’TAI + // (32.184 s) plus TAIβˆ’UTC (32 s) offset at J2000.0. + expect(el.getAttribute('datetime')).toBe('2000-01-01T11:58:55.816Z'); }); it('accepts negative values (dates before J2000.0)', () => { diff --git a/brightchain-react-components/src/lib/hooks/useBrightDateDisplay.spec.ts b/brightchain-react-components/src/lib/hooks/useBrightDateDisplay.spec.ts index d22883d2..cf92460b 100644 --- a/brightchain-react-components/src/lib/hooks/useBrightDateDisplay.spec.ts +++ b/brightchain-react-components/src/lib/hooks/useBrightDateDisplay.spec.ts @@ -6,9 +6,10 @@ import { renderHook } from '@testing-library/react'; import { useBrightDateDisplay } from './useBrightDateDisplay'; -// J2000.0 epoch: 2000-01-01T12:00:00.000Z -// BrightDateValue 0 corresponds to this date. -const J2000_DATE = new Date('2000-01-01T12:00:00.000Z'); +// J2000.0 epoch UTC label: 2000-01-01T11:58:55.816Z (per BrightDate spec Β§2.0). +// BrightDateValue 0 corresponds to this instant β€” not UTC noon (the +// 64.184-second gap is the TTβˆ’TAI + TAIβˆ’UTC offset at J2000.0). +const J2000_DATE = new Date('2000-01-01T11:58:55.816Z'); // A known BrightDateValue: ~9146 days after J2000 β‰ˆ 2025-01-15 // Using a fixed value for deterministic tests. @@ -56,7 +57,8 @@ describe('useBrightDateDisplay', () => { it('returns a locale string for J2000.0 epoch (value = 0) near 2000-01-01', () => { const { result } = renderHook(() => useBrightDateDisplay(0, 'en-US')); - // J2000.0 epoch is 2000-01-01T12:00:00Z β€” locale string should contain "2000" + // J2000.0 epoch UTC label is 2000-01-01T11:58:55.816Z β€” locale string + // should still contain "2000". expect(result.current.localeString).toContain('2000'); }); @@ -101,7 +103,8 @@ describe('useBrightDateDisplay', () => { it('returns a Date near 2000-01-01 for J2000.0 epoch (value = 0)', () => { const { result } = renderHook(() => useBrightDateDisplay(0)); const date = result.current.date; - // J2000.0 epoch is 2000-01-01T12:00:00.000Z + // J2000.0 epoch UTC label is 2000-01-01T11:58:55.816Z; year, month, + // and day-of-month all still match 2000-01-01. expect(date.getUTCFullYear()).toBe(J2000_DATE.getUTCFullYear()); expect(date.getUTCMonth()).toBe(J2000_DATE.getUTCMonth()); expect(date.getUTCDate()).toBe(J2000_DATE.getUTCDate()); diff --git a/brightchat-react-components/src/lib/__tests__/MessageThreadView.spec.tsx b/brightchat-react-components/src/lib/__tests__/MessageThreadView.spec.tsx index a4758e28..bc531ff8 100644 --- a/brightchat-react-components/src/lib/__tests__/MessageThreadView.spec.tsx +++ b/brightchat-react-components/src/lib/__tests__/MessageThreadView.spec.tsx @@ -27,8 +27,8 @@ jest.mock('@brightchain/brightchain-lib', () => ({ formatDateByMode: (_date: unknown, localeStr: string) => localeStr, getDateTooltip: () => '', brightDateNow: () => 9146.438, - brightDateToDate: (bd: number) => new Date((bd + 10957.5) * 86400000), - dateToBrightDate: (d: Date) => (d.getTime() - 946728000000) / 86400000, + brightDateToDate: (bd: number) => new Date(bd * 86_400_000 + 946_727_935_816), + dateToBrightDate: (d: Date) => (d.getTime() - 946_727_935_816) / 86_400_000, BrightChainStrings: new Proxy({}, { get: (_t: unknown, p: string | symbol) => String(p) }), CommunicationEventType: { MESSAGE_SENT: 'communication:message_sent', @@ -111,7 +111,7 @@ const validDateArb = fc min: new Date('2020-01-01T00:00:00Z').getTime(), max: new Date('2030-12-31T23:59:59Z').getTime(), }) - .map((ms) => (ms - 946728000000) / 86400000); // convert to BrightDateTimestamp + .map((ms) => (ms - 946_727_935_816) / 86_400_000); // convert to BrightDateTimestamp (J2000.0 UTC label anchor) const reactionArb = fc.record({ id: fc.uuid(), diff --git a/brightchat-react-components/src/lib/__tests__/applyKeyRotation.property.spec.ts b/brightchat-react-components/src/lib/__tests__/applyKeyRotation.property.spec.ts index eeb922cb..0afc3a54 100644 --- a/brightchat-react-components/src/lib/__tests__/applyKeyRotation.property.spec.ts +++ b/brightchat-react-components/src/lib/__tests__/applyKeyRotation.property.spec.ts @@ -7,7 +7,10 @@ */ import type { ICommunicationMessage } from '@brightchain/brightchain-lib'; -import { dateToBrightDate } from '@brightchain/brightchain-lib'; +import { + brightDateToISO, + dateToBrightDate, +} from '@brightchain/brightchain-lib'; import fc from 'fast-check'; import { applyKeyRotation, @@ -23,9 +26,12 @@ function getTimestamp(item: ThreadItem): string { return item.timestamp; } const msg = item as ICommunicationMessage; - // createdAt is BrightDateTimestamp (number) β€” convert to ISO for string comparison + // createdAt is BrightDateTimestamp (number, days since J2000.0). Use + // the library's brightDateToISO so leap-second and TAI-substrate + // corrections are applied β€” manual `bd * 86_400_000 + J2000_MS` would + // skip those and drift ~5 s in the current era. const bd = msg.createdAt as unknown as number; - return new Date(bd * 86400000 + 946728000000).toISOString(); + return brightDateToISO(bd); } /** Check whether an array of ThreadItems is sorted chronologically. */ diff --git a/brighthub-lib/src/lib/interfaces/user-profile-service.ts b/brighthub-lib/src/lib/interfaces/user-profile-service.ts index d4761dbe..8a59dfa6 100644 --- a/brighthub-lib/src/lib/interfaces/user-profile-service.ts +++ b/brighthub-lib/src/lib/interfaces/user-profile-service.ts @@ -127,7 +127,7 @@ export class UserProfileServiceError extends Error { /** * Maximum bio length in characters */ -export const MAX_BIO_LENGTH = 160; +export const MAX_BIO_LENGTH = 2000; /** * Allowed image MIME types for profile pictures and headers diff --git a/brightmail-react-components/package.json b/brightmail-react-components/package.json index bb62b1f7..a9c937c1 100644 --- a/brightmail-react-components/package.json +++ b/brightmail-react-components/package.json @@ -28,7 +28,7 @@ "license": "MIT", "peerDependencies": { "@brightchain/brightchain-lib": "^0.32.1", - "@brightchain/brightdate": "^0.34.0", + "@brightchain/brightdate": "^0.36.0", "@brightchain/brightmail-lib": "^0.32.1", "@digitaldefiance/express-suite-react-components": "4.30.1", "@mui/icons-material": "*", diff --git a/digitalburnbag-react-components/src/lib/__tests__/e2e/file-upload-browser.e2e.spec.ts b/digitalburnbag-react-components/src/lib/__tests__/e2e/file-upload-browser.e2e.spec.ts index dba38df1..dc88b54a 100644 --- a/digitalburnbag-react-components/src/lib/__tests__/e2e/file-upload-browser.e2e.spec.ts +++ b/digitalburnbag-react-components/src/lib/__tests__/e2e/file-upload-browser.e2e.spec.ts @@ -92,9 +92,10 @@ test.describe('File Upload and Browser Workflow', () => { { name: `e2e-multi-b-${ts}.txt`, content: 'File B content' }, ]); - // Both file names should appear in progress - await expect(page.getByText(`e2e-multi-a-${ts}.txt`)).toBeVisible(); - await expect(page.getByText(`e2e-multi-b-${ts}.txt`)).toBeVisible(); + // Wait for both files to appear in the file browser + const browser = new FileBrowserPage(page); + await browser.waitForFile(`e2e-multi-a-${ts}.txt`); + await browser.waitForFile(`e2e-multi-b-${ts}.txt`); // Wait for completion await upload.waitForUploadComplete(); diff --git a/digitalburnbag-react-components/src/lib/components/CanaryContextMenu.tsx b/digitalburnbag-react-components/src/lib/components/CanaryContextMenu.tsx index 0a004dd1..52adb873 100644 --- a/digitalburnbag-react-components/src/lib/components/CanaryContextMenu.tsx +++ b/digitalburnbag-react-components/src/lib/components/CanaryContextMenu.tsx @@ -392,15 +392,13 @@ export function CanaryContextMenu({ )} {/* "Manage Canaries" when bindings exist */} - {bindingCount > 0 && ( - <> - - - - - - - )} + {bindingCount > 0 && [ + , + + + + + ]} {/* Provider submenu β€” grouped by category, visual list */} @@ -412,31 +410,41 @@ export function CanaryContextMenu({ anchorOrigin={{ vertical: 'top', horizontal: 'right' }} transformOrigin={{ vertical: 'top', horizontal: 'left' }} > - - Select a provider - - {Array.from(groupedConnections.entries()).map(([category, conns]) => ( - - - {category} - - {conns.map(conn => ( - handleProviderSelect(e, conn)} - data-testid={`provider-submenu-item-${conn.id}`} - > - - - - + + + Select a provider + + + {(() => { + const items: JSX.Element[] = []; + for (const [category, conns] of groupedConnections.entries()) { + items.push( + + + {category} + - ))} - - ))} + ); + for (const conn of conns) { + items.push( + handleProviderSelect(e, conn)} + data-testid={`provider-submenu-item-${conn.id}`} + > + + + + + + ); + } + } + return items; + })()} {/* Binding config popover */} diff --git a/digitalburnbag-react-components/src/lib/components/CanaryLeftMenu.module.css b/digitalburnbag-react-components/src/lib/components/CanaryLeftMenu.module.css new file mode 100644 index 00000000..40f9ccf3 --- /dev/null +++ b/digitalburnbag-react-components/src/lib/components/CanaryLeftMenu.module.css @@ -0,0 +1,5 @@ +.canary-left-menu-flex { + display: flex; + align-items: center; + gap: 4px; +} diff --git a/digitalburnbag-react-components/src/lib/components/CanaryLeftMenu.tsx b/digitalburnbag-react-components/src/lib/components/CanaryLeftMenu.tsx index d913bba3..26810b64 100644 --- a/digitalburnbag-react-components/src/lib/components/CanaryLeftMenu.tsx +++ b/digitalburnbag-react-components/src/lib/components/CanaryLeftMenu.tsx @@ -25,6 +25,7 @@ import { Tooltip, Typography, } from '@mui/material'; +import styles from './CanaryLeftMenu.module.css'; import { useMemo, useState } from 'react'; import type { IApiProviderConnectionDTO } from '../services/burnbag-api-client'; @@ -195,18 +196,18 @@ export function CanaryLeftMenu({ + My Providers {attentionCount > 0 && ( )} - + } secondary={`${connections.length} connected`} - primaryTypographyProps={{ variant: 'body2', fontWeight: 600 }} - secondaryTypographyProps={{ variant: 'caption' }} + primaryTypographyProps={{ variant: 'body2', fontWeight: 600, component: 'span' }} + secondaryTypographyProps={{ variant: 'caption', component: 'span' }} /> @@ -228,10 +229,10 @@ export function CanaryLeftMenu({ {conn.providerDisplayName ?? conn.providerUsername ?? conn.providerId}} secondary={formatTimeSince(conn.lastCheckedAt)} - primaryTypographyProps={{ variant: 'caption', noWrap: true }} - secondaryTypographyProps={{ variant: 'caption', sx: { fontSize: '0.6rem' } }} + primaryTypographyProps={{ variant: 'caption', noWrap: true, component: 'span' }} + secondaryTypographyProps={{ variant: 'caption', sx: { fontSize: '0.6rem' }, component: 'span' }} /> {needsAttention(conn) && } @@ -249,12 +250,12 @@ export function CanaryLeftMenu({ - + Provider Marketplace} + secondary="Browse & connect providers" + primaryTypographyProps={{ variant: 'body2', fontWeight: 600, component: 'span' }} + secondaryTypographyProps={{ variant: 'caption', component: 'span' }} + /> @@ -268,12 +269,12 @@ export function CanaryLeftMenu({ - + Multi-Canary Bindings} + secondary={`${multiCanaryBindings.length} configured`} + primaryTypographyProps={{ variant: 'body2', fontWeight: 600, component: 'span' }} + secondaryTypographyProps={{ variant: 'caption', component: 'span' }} + /> @@ -285,16 +286,16 @@ export function CanaryLeftMenu({ {multiCanaryBindings.map(binding => ( {binding.name}} secondary={ - + {binding.targetNames.slice(0, 2).join(', ')}{binding.targetNames.length > 2 ? '…' : ''} - + } - primaryTypographyProps={{ variant: 'caption', noWrap: true }} - secondaryTypographyProps={{ variant: 'caption', component: 'div' }} + primaryTypographyProps={{ variant: 'caption', noWrap: true, component: 'span' }} + secondaryTypographyProps={{ variant: 'caption', component: 'span' }} /> ))} @@ -311,12 +312,12 @@ export function CanaryLeftMenu({ - + Webhook Endpoints} + secondary={`${webhookEndpoints.length} active`} + primaryTypographyProps={{ variant: 'body2', fontWeight: 600, component: 'span' }} + secondaryTypographyProps={{ variant: 'caption', component: 'span' }} + /> @@ -328,10 +329,10 @@ export function CanaryLeftMenu({ {webhookEndpoints.map(ep => ( {ep.providerName}} secondary={`${formatTimeSince(ep.lastReceivedAt)} Β· ${ep.successRate} success`} - primaryTypographyProps={{ variant: 'caption', noWrap: true }} - secondaryTypographyProps={{ variant: 'caption', sx: { fontSize: '0.6rem' } }} + primaryTypographyProps={{ variant: 'caption', noWrap: true, component: 'span' }} + secondaryTypographyProps={{ variant: 'caption', sx: { fontSize: '0.6rem' }, component: 'span' }} /> ))} diff --git a/docs/papers/brightdate-specification.md b/docs/papers/brightdate-specification.md index 3c22a828..2a15fcaf 100644 --- a/docs/papers/brightdate-specification.md +++ b/docs/papers/brightdate-specification.md @@ -169,14 +169,16 @@ const inst = BrightInstant.fromBrightDate(bd); // lossless within ### 4.1 The Round-Trip Tax -For day-aligned Unix milliseconds (exact multiples of `86,400,000 ms` from the J2000.0 anchor), `BrightDate` round-trips with bit-exactness: +The reference implementation routes integer Unix-ms inputs through exact-integer paths, so a substantial set of common inputs round-trips bit-exactly: ```typescript -fromUnixMs(0).toUnixMs() === 0; // Unix epoch: exact -fromUnixMs(946_728_000_000).toUnixMs() === 946_728_000_000; // J2000.0: exact +fromUnixMs(0).toUnixMs() === 0; // Unix epoch: exact +fromUnixMs(946_727_935_816).toUnixMs() === 946_727_935_816; // J2000.0 (UTC label): exact +fromUnixMs(946_728_000_000).toUnixMs() === 946_728_000_000; // TT noon: exact +fromUnixMs(1_700_000_000_000).toUnixMs() === 1_700_000_000_000; // also exact ``` -For off-day inputs, an error bounded by `~2⁻⁡² Γ— magnitude` applies β€” approximately 0.00012 ms (~120 ns) at current-era timestamps. Systems that can tolerate sub-microsecond error at the Unix-ms boundary should use `BrightDate`. Systems that cannot should use `ExactBrightDate`. +Bit-exactness is not guaranteed for arbitrary Unix-ms inputs. For an input `ms` of typical current-era magnitude (~10ΒΉΒ² ms), the round-trip error is bounded by `~2⁻⁡² Γ— |ms| β‰ˆ 0.00012 ms` (β‰ˆ 120 ns) β€” well under any realistic clock resolution. Systems that can tolerate sub-microsecond error at the Unix-ms boundary should use `BrightDate`. Systems that cannot β€” blockchain consensus, byte-identical archival β€” should use `ExactBrightDate`, which round-trips bit-exactly for *every* integer Unix-ms input by construction. ### 4.2 Algebraic Identity Limits @@ -341,7 +343,7 @@ Two consecutive UTC seconds straddling a positive leap second boundary correspon - **CCSDS Time Code Formats (CCSDS 301.0-B-4):** standardises *encoding* of time tags for spacecraft telemetry, not an underlying unit system or scalar. - **TAI directly:** monotonic and physically correct, but provides no agreed epoch, no decimal-day hierarchy, and no reference implementation oriented toward general engineering use. -BrightDate fills a specific gap: a TAI-substrate scalar anchored at the standard astronomical epoch, with SI decimal sub-units, a paged deep-time extension, honest Float64 precision documentation, and an open-source reference implementation in TypeScript and Rust. +BrightDate fills a specific gap: a TAI-substrate scalar anchored at the standard astronomical epoch, with SI decimal sub-units, a sign-flipping `BD` / `PBD` display convention for pre-epoch instants, honest Float64 precision documentation, and an open-source reference implementation in TypeScript and Rust. --- diff --git a/docs/papers/bsh.md b/docs/papers/bsh.md index 1bb8e912..f471b825 100644 --- a/docs/papers/bsh.md +++ b/docs/papers/bsh.md @@ -19,9 +19,9 @@ The fundamental relevance of BSH lies in its removal of the "translation layer." BSH eliminates this friction by integrating BrightDate directly into the shell’s core: -- **Prompt Escapes:** Native support for `%B` (BrightDate) and other decimal-time indicators in the prompt string allows for real-time situational awareness of the decimal timeline. -- **Built-in Parameters:** Standard variables like `$EPOCHSECONDS` are supplemented with `$BRIGHTDATE` and `$BRIGHTDATE_INT`, providing sub-millisecond precision for scripts without external binary calls. -- **Timestamped History:** Command history in BSH is recorded in BrightDate, ensuring that the forensic timeline of a session is perfectly monotonic and free from leap-second or timezone ambiguities. +- **Prompt Escapes:** A new `%P` prompt escape expands to the current BrightDate (decimal, six-fraction-digit precision) in any `PS1`/`PS2`/`RPROMPT`. It is additive β€” none of zsh's standard prompt escapes (`%B`, `%b`, `%D`, `%T`, etc.) are altered, so existing prompt themes keep working unchanged. +- **Built-in Parameters:** Standard variables like `$EPOCHSECONDS` (from `zsh/datetime`) are supplemented with always-available, read-only `$BRIGHTDATE` (a Float64 scalar in decimal BrightDays since J2000.0) and `$BRIGHTDATE_INT` (the integer floor of `$BRIGHTDATE` β€” the whole-day counter). The `zsh/datetime` module additionally exposes `$BRIGHTEPOCH` for parity with `$EPOCHREALTIME`. +- **Timestamped History:** The `history` builtin renders each entry's execution time as a BrightDate value, ensuring that the forensic timeline of a session is monotonic and free from leap-second or timezone ambiguities. ------ @@ -42,7 +42,7 @@ By making BrightDate the default temporal reference for the shell, BSH changes t Beyond the user experience, BSH provides significant technical advantages for automation: - **Arithmetic Simplicity:** Because BrightDate is a decimal scalar, calculating the duration between two events in BSH is a simple subtraction (`$end - $start`). This removes the need for complex `date` command flags or parsing logic for hours and minutes. -- **High-Performance Built-ins:** Tools like `bdate`, `btime`, and `bcal` are available natively within the Digital Defiance ecosystem, allowing BSH scripts to interact with the Bright Spacetime Standard with zero overhead. +- **High-Performance Built-ins:** Tools like `bdate`, `btime`, `buptime`, `bcal`, and `bwatch` are provided natively (and also shadow the classic `date`, `time`, `uptime`, `cal`, and `watch` names), delegating to the `brightdate-rust` static library through an in-process FFI β€” zero fork/exec overhead. - **Synchronized Infrastructure:** When BSH is used across a network of nodes, the "geographic debt" described in the BrightSpace standard is mitigated by a shared shell environment that understands the exact same temporal substrate, making logs and distributed tasks easier to correlate. ------ @@ -53,4 +53,4 @@ BSH is the essential bridge between the theoretical papers of the Bright Spaceti **"Fixed in Space. Universal in Time. Defiant by Design."** -For more information and to install the shell, visit [bsh.brightdate.org](https://bsh.brightdate.org). \ No newline at end of file +For more information and to install the shell, visit [bsh.brightdate.org](https://bsh.brightdate.org). diff --git a/docs/papers/enclave-bridge-protocol.md b/docs/papers/enclave-bridge-protocol.md new file mode 100644 index 00000000..5ece1ce3 --- /dev/null +++ b/docs/papers/enclave-bridge-protocol.md @@ -0,0 +1,1232 @@ +--- +title: "Enclave Bridge Protocol (EBP/1) β€” Replication-Grade Specification" +parent: "Papers" +nav_order: 18 +--- + +# Enclave Bridge Protocol (EBP/1) β€” A Replication-Grade Specification of the Apple Secure Enclave Bridge, Client, and Keyring + +**Authors:** Digital Defiance +**Status:** Informational specification, replication-grade +**Version:** 1.0 (EBP/1) +**Date:** 2026 +**Companion artifacts:** + +- Server reference implementation: `Enclave Bridge` (SwiftUI macOS status bar app, Apple Silicon) +- Client reference implementation: `@digitaldefiance/enclave-bridge-client` (TypeScript, Node.js β‰₯18) +- Consumer reference: `brightchain-api-lib/src/lib/secureEnclaveKeyring.ts` +- Wire-compatible cipher: `@digitaldefiance/node-ecies-lib` +- Companion paper: [ECIES-Lib](ecies-lib) + +--- + +## Abstract + +This document specifies the Enclave Bridge Protocol (EBP/1), a JSON-over-Unix-domain-socket protocol that exposes a subset of Apple Secure Enclave operations (P-256 hardware-backed signing) and a host-resident secp256k1 key (used for ECIES encryption/decryption compatible with `@digitaldefiance/node-ecies-lib`) to local Node.js clients on macOS Apple Silicon. The specification covers, in normative detail: the transport, the message framing, the complete command set, every request and response field, the cryptographic primitives (curves, key formats, KDF parameters, AAD construction, AES-GCM nonce/tag widths), the binary ECIES wire format expected by `ENCLAVE_DECRYPT`, the on-disk persistence of both the secp256k1 private key and the TOTP configuration, the Secure Enclave key access-control flags, the optional TOTP 2FA layer (RFC 6238 with this server's specific parameters), and the consumer-side double-encryption scheme used by `SecureEnclaveKeyring` in `brightchain-api-lib`. The document is intended to be sufficient for a third party to write an interoperable client or server from scratch. + +**Keywords:** Apple Secure Enclave, ECIES, secp256k1, P-256, AES-256-GCM, HKDF, Unix domain socket, TOTP, scrypt, Node.js, Swift, BrightChain. + +--- + +## 1. Introduction + +Apple Silicon devices ship a Secure Enclave coprocessor capable of generating and using non-extractable P-256 (secp256r1) private keys. Apple's CryptoKit `SecureEnclave.P256.Signing.PrivateKey` exposes those keys for ECDSA signatures, and the underlying SEP also supports key agreement; however, the key material never leaves the enclave and is bound to the device. There is no Apple-provided IPC surface that lets a Node.js process drive the enclave directly. + +The **Enclave Bridge** is a small SwiftUI status-bar application that opens a Unix domain socket on macOS, advertises a JSON command protocol, and proxies a curated subset of cryptographic operations: it can sign with the enclave's P-256 key and decrypt ECIES messages addressed to a host-resident secp256k1 key. The matching **enclave-bridge-client** is a TypeScript library that speaks this protocol from Node.js. On top of those two, **`SecureEnclaveKeyring`** in `brightchain-api-lib` layers password-based AES-256-GCM and ECIES-to-the-bridge encryption to produce a defense-in-depth, hardware-anchored key store usable by the rest of the BrightChain stack. + +This paper documents all three components in enough detail to re-implement the protocol or replace any of the parties. + +### 1.1 Conventions + +- The keywords **MUST**, **MUST NOT**, **SHOULD**, **SHOULD NOT**, and **MAY** are interpreted as in RFC 2119. +- Multi-byte integers on the wire are big-endian (network order) where they appear; this document calls them out explicitly. +- "secp256k1" refers to the Bitcoin curve. "P-256" and "secp256r1" are used interchangeably and refer to the NIST P-256 curve used by the Secure Enclave. +- All hexadecimal byte values are written `0xNN`. +- Byte buffers in transit (over JSON) are encoded as **standard Base64 with padding** (`Data.base64EncodedString()` on the Swift side, `Buffer.toString('base64')` in Node). + +### 1.2 Component overview + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” Unix socket β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Node.js application β”‚ /tmp/enclave-bridge.sock β”‚ Enclave Bridge (SwiftUI) β”‚ +β”‚ β”‚ ───────────────────────► β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ JSON requests β”‚ BridgeProtocolHandler β”‚ +β”‚ β”‚ EnclaveBridge β”‚ β”‚ β”‚ β”œβ”€ ECIESKeyManager β”‚ +β”‚ β”‚ Client β”‚ β”‚ JSON responses β”‚ β”‚ (secp256k1 priv on FS) β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ ◄───────────────────────│ β”œβ”€ SecureEnclaveKeyManager β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ (P-256 in SEP) β”‚ +β”‚ β”‚ node-ecies-lib β”‚ β”‚ β”‚ β”œβ”€ TOTPManager (RFC 6238) β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ └─ AppState / SocketServerβ”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ +β”‚ β”‚SecureEnclave- β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚Keyring β”‚ β”‚ β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +The protocol surface (what a third-party client or server MUST implement) is the JSON command set in Β§4 plus the binary ECIES wire format in Β§5. + +--- + +## 2. Transport + +### 2.1 Socket type + +The server **MUST** create an `AF_UNIX`, `SOCK_STREAM` socket and **MUST** `listen(2)` with a backlog of at least 5. The reference server uses `backlog = 5`. The server accepts connections concurrently and dispatches each accepted file descriptor to its own `DispatchQueue`. + +### 2.2 Socket path discovery + +Clients **MUST** locate the socket by trying these absolute paths in order and using the first one that exists and is accessible (`fs.access(F_OK)`): + +1. `${HOME}/Library/Containers/com.JessicaMulein.EnclaveBridge/Data/.enclave/enclave-bridge.sock` β€” sandboxed (Mac App Store) install. +2. `${HOME}/.enclave/enclave-bridge.sock` β€” direct (non-sandboxed) install. +3. `/tmp/enclave-bridge.sock` β€” legacy/default fallback. This is also the value of `EnclaveBridgeClient.DEFAULT_SOCKET_PATH`. + +If none of those exist, the client **MUST** treat the bridge as unavailable. Implementations MAY accept an explicit override path. + +### 2.3 Concurrency and lifetime + +- The server accepts multiple simultaneous client connections; each connection has its own `BridgeProtocolHandler` instance and therefore its own `peerPublicKey` slot. +- A connection may issue any number of sequential commands; the server returns exactly one JSON response per request. +- Either side MAY close the connection at any time. The reference client treats EOF as a normal close, fires the `disconnect` event, and (if `autoReconnect` is enabled) schedules an exponential-backoff reconnect. + +### 2.4 Read buffer sizing + +The reference server reads in 4096-byte chunks (`bufferSize = 4096`) and accumulates them into a per-connection `dataBuffer`. There is no length-prefix; framing is recovered as described in Β§3. + +--- + +## 3. Message framing + +### 3.1 Wire format + +Messages are **UTF-8-encoded JSON objects**, written back-to-back on the stream with **no delimiter, no newline, and no length prefix**. A request is one JSON object; a response is one JSON object. Both ends MUST parse complete JSON objects out of the byte stream. + +### 3.2 Server framing rule (reference behavior) + +The reference server uses a **byte-equality match on `}` (0x7D)** to terminate a message: each time it reads bytes, it scans the connection buffer for `0x7D`, slices everything up to and including the first `0x7D` as one message, parses it as JSON, and dispatches. This works because no current command has a nested object in its request β€” every request is a flat object whose only `}` is the terminator. + +> **Implementation note.** A new client that needs to send a command containing a nested JSON object would break the reference server's framer. Until the server is updated to count braces, **clients MUST NOT send nested JSON objects in requests**. Strings, numbers, booleans, and Base64-encoded byte strings are sufficient for every command in this specification. + +### 3.3 Client framing rule (reference behavior) + +The reference client implements a brace-counting parser that: + +1. Skips ahead to the next `{`. +2. Walks the buffer character by character, tracking a string-mode flag (toggled by unescaped `"`), an escape flag (set after `\` while in string mode and cleared on the next character), and a brace counter incremented on `{` and decremented on `}` outside strings. +3. Treats `braceCount == 0` as the end of one complete JSON object. +4. Holds incomplete tail bytes in `responseBuffer` until more data arrives. + +This parser correctly handles nested objects, embedded `}` inside strings, and escaped quotes in responses. Reimplementations MAY use a streaming JSON parser instead. + +### 3.4 Request/response correlation + +The reference protocol is strictly **request-response, in-order, per connection**: every request from the client elicits exactly one response, and the server answers in the order it received the requests on a given connection. There is no request ID. The reference client enforces this by tracking a queue of in-flight requests (`requestQueue`) and resolving the oldest "sent" entry when a complete JSON response is parsed. Re-implementations that allow request pipelining MUST preserve FIFO ordering on the wire. + +### 3.5 Encoding of binary fields + +All binary fields carried inside JSON (public keys, signatures, ciphertexts, plaintext output) are **standard Base64 with padding**. Implementations MUST use `Data.base64EncodedString()` (Swift) or `Buffer.from(value, 'base64') / Buffer.toString('base64')` (Node) semantics. Hex is **not** used on the wire. + +--- + +## 4. Command set (EBP/1) + +Every request is a JSON object with at least a `cmd` string field and zero or more typed parameters. Every response is a JSON object that is either a success object (command-specific shape) **or** an error object of the form: + +```json +{ "error": "" } +``` + +If JSON parsing fails or `cmd` is missing/non-string, the server **MUST** return `{"error":"Invalid request format"}`. If the command is unknown, the server **MUST** return `{"error":"Unknown command: "}`. + +The command alphabet defined by EBP/1 is exactly: + +| # | Command | Purpose | +|---|---|---| +| 1 | `HEARTBEAT` | Liveness probe with timestamp | +| 2 | `VERSION` (alias `INFO`) | App version, build, platform, uptime | +| 3 | `STATUS` | Peer-key flag, enclave availability | +| 4 | `METRICS` | Service uptime and (reserved) counters | +| 5 | `GET_PUBLIC_KEY` | Bridge's secp256k1 public key (for ECIES) | +| 6 | `GET_ENCLAVE_PUBLIC_KEY` | Secure Enclave P-256 public key | +| 7 | `SET_PEER_PUBLIC_KEY` | Cache a peer's secp256k1 public key on the connection | +| 8 | `LIST_KEYS` | Enumerate keys with TOTP status | +| 9 | `ENCLAVE_SIGN` | ECDSA-SHA256 over P-256 in SEP | +| 10 | `ENCLAVE_DECRYPT` | ECIES decrypt with the bridge secp256k1 private key | +| 11 | `ENCLAVE_GENERATE_KEY` | (Reserved; returns error in EBP/1) | +| 12 | `ENCLAVE_ROTATE_KEY` | (Reserved; returns error in EBP/1) | +| 13 | `ENABLE_TOTP` | Enable per-key TOTP and emit provisioning URI | +| 14 | `EXPORT_KEY` | Export public key, gated by TOTP if enabled | + +Commands 1–12 form the core protocol. Commands 13–14 form the TOTP 2FA extension, which is part of EBP/1 (the reference server implements it; clients SHOULD implement it but MAY treat it as optional). + +### 4.1 `HEARTBEAT` + +**Request** + +```json +{ "cmd": "HEARTBEAT" } +``` + +**Response** + +```json +{ + "ok": true, + "timestamp": "2026-05-20T17:02:11Z", + "service": "enclave-bridge" +} +``` + +- `timestamp` is an ISO-8601 timestamp produced by `ISO8601DateFormatter` (UTC, second resolution). +- `service` is the literal string `"enclave-bridge"`. +- The server does not error on this command; clients SHOULD treat any successful JSON response with `ok=true` as a positive liveness signal. + +### 4.2 `VERSION` / `INFO` + +`INFO` and `VERSION` are aliases. + +**Request** + +```json +{ "cmd": "VERSION" } +``` + +**Response** + +```json +{ + "appVersion": "1.0", + "build": "12", + "platform": "macOS", + "uptimeSeconds": 4231 +} +``` + +- `appVersion` comes from `Bundle.main.infoDictionary["CFBundleShortVersionString"]`. If unavailable, the server returns the literal `"unknown"`. +- `build` comes from `CFBundleVersion` (same fallback). +- `platform` is always `"macOS"` for EBP/1. +- `uptimeSeconds` is the integer number of seconds since the static `BridgeProtocolHandler.startTime` was initialised at process start. + +### 4.3 `STATUS` + +**Request** + +```json +{ "cmd": "STATUS" } +``` + +**Response** + +```json +{ + "ok": true, + "peerPublicKeySet": false, + "enclaveKeyAvailable": true +} +``` + +- `peerPublicKeySet` is `true` iff `SET_PEER_PUBLIC_KEY` has been called on **this connection**. The peer key is per-connection state; new connections start with `peerPublicKeySet=false`. +- `enclaveKeyAvailable` is `true` iff a successful call to `SecureEnclaveKeyManager.getPublicKeyData()` returned a non-empty `x963Representation`. A `false` value indicates the SEP is unreachable, the device lacks a Secure Enclave, or the key has not been provisioned yet. + +### 4.4 `METRICS` + +**Request** + +```json +{ "cmd": "METRICS" } +``` + +**Response** + +```json +{ + "service": "enclave-bridge", + "uptimeSeconds": 4231, + "requestCounters": {} +} +``` + +`requestCounters` is reserved. The reference server currently returns an empty object; future versions MAY populate it with per-command counters. Clients MUST tolerate any stringβ†’number map and MUST NOT assume any specific keys. + +### 4.5 `GET_PUBLIC_KEY` + +Returns the **bridge's persistent secp256k1 public key** used as the recipient key for ECIES (i.e. the key whose private half decrypts payloads sent to `ENCLAVE_DECRYPT`). + +**Request** + +```json +{ "cmd": "GET_PUBLIC_KEY" } +``` + +**Success response** + +```json +{ "publicKey": "BCsf...==" } +``` + +- `publicKey` is **Base64 of the 65-byte uncompressed SEC1 encoding** of the secp256k1 public key (`0x04 || X(32) || Y(32)`). +- The reference server obtains the bytes via `P256K.KeyAgreement.PrivateKey.publicKey.dataRepresentation`, which always emits the uncompressed form. +- The private half is persisted to disk; see Β§6.1 for the file path and permissions. Subsequent calls MUST return the same key for the lifetime of that file. + +**Error response (example)** + +```json +{ "error": "Failed to get ECIES public key: " } +``` + +### 4.6 `GET_ENCLAVE_PUBLIC_KEY` + +Returns the **Secure Enclave P-256 public key**. + +**Request** + +```json +{ "cmd": "GET_ENCLAVE_PUBLIC_KEY" } +``` + +**Success response** + +```json +{ "publicKey": "BNK1...==" } +``` + +- `publicKey` is **Base64 of the 65-byte uncompressed X9.63 encoding** (`SecureEnclave.P256.Signing.PublicKey.x963Representation`), which is `0x04 || X(32) || Y(32)`. +- The bridge generates the SEP key on first request (see Β§6.2). Once generated, the public key is stable for that device user. +- The corresponding private key is **non-extractable**; this command exposes only the public half. + +### 4.7 `SET_PEER_PUBLIC_KEY` + +Caches a peer's secp256k1 public key in the **per-connection** `peerPublicKey` slot. The reference server retains this for use by future protocol extensions (e.g. server-initiated ECIES outputs) and for `STATUS.peerPublicKeySet`. It is not currently consumed by `ENCLAVE_DECRYPT`, which uses the ephemeral key carried in the ciphertext header. + +**Request** + +```json +{ + "cmd": "SET_PEER_PUBLIC_KEY", + "publicKey": "" +} +``` + +The server **MUST** accept either format on the wire (Base64-decoded byte length 33 compressed or 65 uncompressed). The reference server stores the bytes verbatim without re-validation; clients SHOULD send the **uncompressed** form to match the rest of the protocol. + +**Success** + +```json +{ "ok": true } +``` + +**Errors** + +- `{"error":"Missing or invalid publicKey"}` β€” `publicKey` field is missing or not Base64-decodable. + +### 4.8 `LIST_KEYS` + +Enumerates the keys known to the bridge along with their TOTP state. + +**Request** + +```json +{ "cmd": "LIST_KEYS" } +``` + +**Response** + +```json +{ + "keys": [ + { + "id": "ecies-secp256k1", + "type": "secp256k1", + "publicKeyFingerprint": "AB:CD:EF:01:23:45:67:89", + "isSecureEnclave": false, + "totpEnabled": true, + "totpProvisioningURI": "otpauth://totp/EnclaveBridge:user@example.com?secret=...&issuer=EnclaveBridge&algorithm=SHA1&digits=6&period=30" + }, + { + "id": "secure-enclave-p256", + "type": "Secure Enclave (P-256)", + "publicKeyFingerprint": "12:34:56:78:9A:BC:DE:F0", + "isSecureEnclave": true, + "totpEnabled": false, + "totpProvisioningURI": "" + } + ] +} +``` + +Field semantics: + +- `id` β€” stable identifier. The two reserved IDs are `"ecies-secp256k1"` and `"secure-enclave-p256"`. All TOTP-gated commands take an `id` from this set. +- `type` β€” one of `"secp256k1"` or `"Secure Enclave (P-256)"` (see `KeyInfo.KeyType` in `AppState.swift`). +- `publicKeyFingerprint` β€” uppercase, colon-separated, **first 8 bytes of SHA-256** over the public key bytes (formatted `%02X` per byte, joined with `:`). +- `isSecureEnclave` β€” `true` iff the key is hardware-backed. +- `totpEnabled` β€” `true` iff a TOTP secret is recorded for this `id` in the on-disk TOTP config (see Β§6.3). +- `totpProvisioningURI` β€” present and non-empty iff `totpEnabled` is `true`. Empty string otherwise (the field is always present). + +### 4.9 `ENCLAVE_SIGN` + +Computes an ECDSA signature over the supplied data using the Secure Enclave P-256 private key. Apple's CryptoKit takes care of hashing inside the SEP; the input bytes are passed to `priv.signature(for:)` which performs SHA-256 internally before signing. + +**Request** + +```json +{ + "cmd": "ENCLAVE_SIGN", + "data": "" +} +``` + +- `data` is Base64 of arbitrary bytes. The reference implementation imposes no length cap; in practice, any size that fits in one socket message is acceptable. + +**Success response** + +```json +{ "signature": "MEUCIQ...==" } +``` + +- `signature` is Base64 of the **DER-encoded ECDSA signature** (`P256.Signing.ECDSASignature.derRepresentation`). Verifiers MUST decode the DER `SEQUENCE { INTEGER r, INTEGER s }` to recover `(r, s)`. The reference client's `verifyP256Signature` helper feeds this DER blob into Node's `crypto.createVerify('SHA256')`. + +> **Important:** The signature is computed by passing the raw `data` to `priv.signature(for:)`, which **internally SHA-256-hashes** before applying ECDSA. A verifier that recomputes the digest itself MUST therefore SHA-256 the **same bytes** before calling `verify`. The reference client does this: +> +> ```ts +> const hash = createHash('sha256').update(dataBuffer).digest(); +> const verify = createVerify('SHA256'); +> verify.update(hash); +> verify.verify(pemKey, signature); +> ``` +> +> This double-hash construction (verifier hashes, then `createVerify('SHA256')` hashes again) is consistent with the reference behaviour shipped today; replicators MUST mirror it for compatibility, or use a verifier that calls a low-level ECDSA verify with `(SHA256(data), signature, pubKey)` directly. + +**Errors** + +- `{"error":"Missing or invalid data to sign"}` β€” missing or non-Base64 `data`. +- `{"error":"Signing failed: "}` β€” SEP refused or returned an error. + +### 4.10 `ENCLAVE_DECRYPT` + +Decrypts an ECIES envelope addressed to the bridge's persistent secp256k1 public key (the one returned by `GET_PUBLIC_KEY`). The wire format of the ciphertext is specified in Β§5. + +**Request** + +```json +{ + "cmd": "ENCLAVE_DECRYPT", + "data": "" +} +``` + +**Success** + +```json +{ "plaintext": "" } +``` + +**Errors** + +- `{"error":"Missing or invalid data to decrypt"}` β€” missing/non-Base64 input. +- `{"error":"Encrypted data too short"}` β€” fewer than 64 bytes (header floor). +- `{"error":"Invalid ephemeral public key format"}` β€” header byte after the 3-byte preamble is not `0x02`, `0x03`, or `0x04`, or there are not enough trailing bytes for a 33- or 65-byte key. +- `{"error":"Missing length field"}` / `{"error":"Ciphertext length mismatch"}` β€” only for `WithLength` envelopes (type `0x42`). +- `{"error":"ECDH failed: empty shared secret"}` β€” peer ephemeral key did not yield a shared point. +- `{"error":"Decryption failed"}` β€” AES-GCM tag verification failed. +- `{"error":"ECDH failed: "}` β€” `P256K` raised an exception while loading the ephemeral key or computing the shared secret. + +### 4.11 `ENCLAVE_GENERATE_KEY` (reserved) + +Returns `{"error":"ENCLAVE_GENERATE_KEY not implemented"}` in EBP/1. The bridge auto-creates its keys on first use (see Β§6); this command is reserved for future multi-key support. Clients MUST handle the error gracefully and SHOULD NOT treat it as a fatal condition. + +### 4.12 `ENCLAVE_ROTATE_KEY` (reserved) + +Returns `{"error":"ENCLAVE_ROTATE_KEY not supported on this platform"}` in EBP/1. Apple's APIs do not currently support replacing a Secure Enclave key in place while preserving its access-control metadata. Reserved for future use. + +### 4.13 `ENABLE_TOTP` (TOTP extension) + +Generates a fresh TOTP secret for the named key, writes it to the on-disk TOTP config (Β§6.3), refreshes `AppState.keys`, and returns the provisioning URI suitable for QR-encoding into Google Authenticator, Authy, 1Password, etc. + +**Request** + +```json +{ + "cmd": "ENABLE_TOTP", + "keyId": "ecies-secp256k1", + "account": "alice@example.com", + "issuer": "EnclaveBridge" +} +``` + +- `keyId` MUST be one of the documented IDs (`ecies-secp256k1` or `secure-enclave-p256`). The reference server does not currently validate the ID against the actual key set; future revisions MAY reject unknown IDs. +- `account` and `issuer` are arbitrary strings; they appear in the provisioning URI as `issuer:account`. + +**Success** + +```json +{ + "provisioningURI": "otpauth://totp/EnclaveBridge:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=EnclaveBridge&algorithm=SHA1&digits=6&period=30" +} +``` + +The URI parameters are fixed by the server: `algorithm=SHA1`, `digits=6`, `period=30`. The `secret` is a fresh 20-byte CSPRNG output, RFC 4648 Base32-encoded (uppercase alphabet `A–Z 2–7`). See Β§6.3 for the on-disk format and Β§7 for the TOTP algorithm. + +**Errors** + +- `{"error":"Missing keyId, account, or issuer"}` β€” any of the three fields missing or non-string. +- `{"error":"Failed to enable TOTP for key"}` β€” disk write failed. + +### 4.14 `EXPORT_KEY` (TOTP extension) + +Exports the **public** half of a named key, gated by TOTP if and only if TOTP is enabled for that key. + +**Request** + +```json +{ + "cmd": "EXPORT_KEY", + "keyId": "ecies-secp256k1", + "totpCode": "493017" +} +``` + +- `totpCode` MUST be supplied if TOTP has been enabled for `keyId`; the field MUST be omitted (or any value) otherwise. The reference server's `validateTOTP` implementation returns `true` immediately when no TOTP secret is recorded for the key, regardless of whether `totpCode` is present. + +**Success** (same shape as `GET_PUBLIC_KEY` / `GET_ENCLAVE_PUBLIC_KEY`) + +```json +{ "publicKey": "" } +``` + +The returned public key: + +- For `keyId="ecies-secp256k1"`: 65-byte uncompressed secp256k1, identical to `GET_PUBLIC_KEY`. +- For `keyId="secure-enclave-p256"`: 65-byte uncompressed P-256 X9.63, identical to `GET_ENCLAVE_PUBLIC_KEY`. + +**Errors** + +- `{"error":"Missing keyId"}` β€” `keyId` field absent or non-string. +- `{"error":"TOTP code required or invalid for this key"}` β€” TOTP is enabled and the provided code did not validate within the Β±30 s window. +- `{"error":"Unknown keyId"}` β€” `keyId` not in the reserved set. +- `{"error":"Failed to export ECIES public key: "}` / `{"error":"Failed to export Secure Enclave public key: "}` β€” underlying key access failed. + +### 4.15 Error envelope summary + +All error responses are JSON objects with a single `error` field of type string. Implementations MUST NOT return `error` together with a success field. Implementations MUST close the connection only on hard transport failures; protocol-level errors leave the connection open and ready for the next request. + +--- + +## 5. Cryptography + +### 5.1 Curves and key formats + +| Algorithm | Curve | Public key encoding | Compressed accepted | Notes | +|---|---|---|---|---| +| ECIES (bridge persistent key) | secp256k1 | SEC1 uncompressed (`0x04 \|\| X(32) \|\| Y(32)`, 65 B) | Yes (33 B, `0x02`/`0x03`) inside ciphertext header only | secp256k1.swift / `P256K.KeyAgreement.PrivateKey` | +| ECIES (ephemeral, per message) | secp256k1 | **33 B compressed** in the canonical `@digitaldefiance/ecies-lib` v4.0 wire format. The reference server additionally accepts 65 B uncompressed for legacy interop. | Yes | Format detected from header byte (see Β§5.4) | +| Signing (Secure Enclave) | NIST P-256 | X9.63 uncompressed (65 B), with `0x04` prefix | No | `SecureEnclave.P256.Signing.PrivateKey` | + +The bridge **public-key endpoints** (`GET_PUBLIC_KEY`, `GET_ENCLAVE_PUBLIC_KEY`, `EXPORT_KEY`) ALWAYS emit the **65-byte uncompressed** form. A 33-byte compressed form appears as the **ephemeral** key inside an ECIES envelope. + +> **Canonical encoding for new senders.** The `@digitaldefiance/ecies-lib` v4.0 protocol β€” the binding reference for all wire-level details (constant `ECIES.PUBLIC_KEY_LENGTH = 33`) β€” emits **only 33-byte compressed** ephemeral keys. New senders MUST emit 33-byte compressed; the 65-byte branch in the reference server's `ENCLAVE_DECRYPT` parser is a **legacy-tolerance** path retained only so existing clients that produced uncompressed envelopes continue to work. A future bridge revision MAY reject 65-byte ephemeral keys; replicators of either side SHOULD therefore treat 33 bytes as the wire format and 65 bytes as a deprecated grace window. + +### 5.2 Symmetric cipher + +- Algorithm: **AES-256-GCM**. +- Symmetric key length: **32 bytes** (256 bits). +- IV length: **12 bytes**, randomly chosen per message (`SecRandomCopyBytes` on the server side; `crypto.randomBytes(12)` on the client side). This matches the canonical `@digitaldefiance/ecies-lib` constant `ECIES.IV_SIZE = 12`. + +> **The 16-byte-IV helper is non-conformant; do not use it.** The Swift `eciesEncrypt` helper in `BridgeProtocolHandler` (used only for hypothetical server-originated outputs) generates a **16-byte** IV. This is a residual artefact of an early specification draft and is **not** part of EBP/1 nor part of the canonical `ecies-lib` v4.0 wire format. All inbound ciphertexts that the bridge MUST decrypt use **12-byte** IVs. Replicators MUST always use 12-byte IVs; a future revision of the reference server will remove the 16-byte helper. + +- Tag length: **16 bytes** (full GCM tag), matching `ECIES.AUTH_TAG_SIZE = 16`. +- AAD: see Β§5.5. + +### 5.3 Key derivation (HKDF) + +Both sides derive the AES-256 key from the ECDH shared secret with HKDF-SHA-256 (RFC 5869): + +- `IKM` = the 32-byte x-coordinate of the ECDH point (see Β§5.4 for how it is extracted). +- `salt` = empty (zero-length). +- `info` = the UTF-8 bytes of the literal string `"ecies-v2-key-derivation"` (23 bytes). +- `L` = 32 (output bytes). + +The resulting 32-byte key is the AES-256-GCM key for the message. There is no separate MAC key; integrity is provided by GCM's tag. + +### 5.4 ECDH shared-secret extraction + +The reference server uses `P256K.KeyAgreement.PrivateKey.sharedSecretFromKeyAgreement(with:)`, whose Swift wrapper around libsecp256k1 returns a **33-byte serialized compressed point** (the shared point `S = priv * peerPub`, encoded as `0x02|0x03 || X(32)`). The server then strips the prefix byte and uses the **32-byte x-coordinate** as the HKDF `IKM`. This matches `node-ecies-lib`, which feeds `Buffer` of length 32 into HKDF. + +If the underlying secp256k1 library returns a 32-byte x-coordinate directly, that bytestring is the `IKM` unchanged. Implementations MUST NOT include the curve-point prefix byte, MUST NOT use SHA-256 over the full point, and MUST NOT pad/truncate to a different length. + +### 5.5 AAD construction + +The Additional Authenticated Data fed to AES-256-GCM is the byte concatenation: + +``` +AAD = version(1) || cipherSuite(1) || type(1) || ephemeralPublicKey(33 or 65) +``` + +Where: + +- `version` is the wire-byte `0x01`. +- `cipherSuite` is `0x01`. +- `type` is the encryption-type byte from the header (see Β§5.6). +- `ephemeralPublicKey` is exactly the bytes that appear on the wire β€” either 33 bytes compressed (with `0x02`/`0x03` prefix) or 65 bytes uncompressed (with `0x04` prefix). Implementations MUST NOT canonicalise: the AAD includes the same encoding the sender used. + +If a sender includes an outer `preamble`, it is prepended to AAD as the first segment. EBP/1 uses no preamble; implementations MUST send `preamble == empty`. + +### 5.6 ECIES wire format expected by `ENCLAVE_DECRYPT` + +The bridge accepts `node-ecies-lib`-format envelopes. Concretely: + +``` ++--------+--------+------+----------------------+--------+--------+----------+--------------+ +| ver(1) | cs(1) | t(1) | ephemeralPub (33|65) | iv(12) | tag(16)| len(8)* | ciphertext | ++--------+--------+------+----------------------+--------+--------+----------+--------------+ +* len(8) is present only when type == 0x42 (WithLength) +``` + +Field-by-field: + +| Field | Size (bytes) | Value(s) | Notes | +|---|---|---|---| +| `version` | 1 | `0x01` | Currently the only defined value | +| `cipherSuite` | 1 | `0x01` | secp256k1 + AES-256-GCM + SHA-256 | +| `type` | 1 | `0x21` (33), `0x42` (66), `0x63` (99) | "Basic", "WithLength", "Multiple" β€” see below | +| `ephemeralPublicKey` | 33 or 65 | secp256k1 SEC1 | First byte determines format: `0x02`/`0x03` β‡’ 33 B compressed; `0x04` β‡’ 65 B uncompressed | +| `iv` | 12 | random | AES-GCM nonce | +| `authTag` | 16 | GCM tag | Computed over ciphertext + AAD | +| `length` | 8 | big-endian `uint64` | Present **only** if `type == 0x42`; equals the ciphertext length | +| `ciphertext` | variable | AES-256-GCM output | For `0x42`, exactly `length` bytes; for `0x21`, the rest of the buffer | + +**Decryption type detection:** + +- `0x21` (Basic): the ciphertext consumes everything from the end of the tag to the end of the buffer. +- `0x42` (WithLength): an 8-byte big-endian `uint64` length follows the tag; the ciphertext is exactly that many bytes. +- `0x63` (Multiple): the **client** library defines this as multi-recipient; the **server's** `ENCLAVE_DECRYPT` does **not** implement multi-recipient extraction in EBP/1. Senders MUST therefore use `0x21` or `0x42` when targeting the bridge. Multi-recipient envelopes are produced/consumed only at the `node-ecies-lib` layer and are not part of the bridge's wire surface. + +**Minimum length:** With a compressed ephemeral key, a Basic envelope is at least `1+1+1+33+12+16 = 64` bytes plus ciphertext. The reference server enforces `> 64` bytes. + +**Compressed vs uncompressed key on the wire:** The server's parser chooses based on the byte immediately following the type byte: `0x04` β‡’ read 65 bytes; `0x02`/`0x03` β‡’ read 33 bytes. Anything else is an `Invalid ephemeral public key format` error. + +### 5.7 Decryption procedure (server) + +Given the parsed envelope and the bridge's persistent secp256k1 private key `d`: + +1. Reconstruct AAD = `version || cipherSuite || type || ephemeralPub`. +2. Parse `ephemeralPub` into a `P256K.KeyAgreement.PublicKey`, choosing `.compressed` or `.uncompressed` based on length/prefix. +3. Compute `S = d * ephemeralPub`. Strip the leading prefix byte to obtain the 32-byte x-coordinate. +4. `K = HKDF-SHA256(IKM=x, salt="", info="ecies-v2-key-derivation", L=32)`. +5. `plaintext = AES-256-GCM-Decrypt(K, IV=iv, AAD=AAD, Ciphertext=ciphertext, Tag=authTag)`. +6. Return `{"plaintext": base64(plaintext)}`. + +### 5.8 Encryption procedure (client / sender, for completeness) + +A sender producing ciphertext that the bridge can decrypt MUST: + +1. Acquire the bridge's persistent secp256k1 public key via `GET_PUBLIC_KEY`. +2. Generate an ephemeral secp256k1 keypair `(e, E)`. Encode `E` as 33-byte compressed form (`node-ecies-lib` default) or 65-byte uncompressed. +3. Compute `S = e * P_bridge`, take the 32-byte x-coordinate. +4. `K = HKDF-SHA256(IKM=x, salt="", info="ecies-v2-key-derivation", L=32)`. +5. Choose 12 random bytes as `iv`. +6. Build AAD = `0x01 || 0x01 || type || E_bytes` where `type ∈ {0x21, 0x42}`. +7. `(ciphertext, tag) = AES-256-GCM-Encrypt(K, IV=iv, AAD=AAD, Plaintext=plaintext)`. +8. Concatenate `0x01 || 0x01 || type || E_bytes || iv || tag || [length(8)] || ciphertext` and send Base64-encoded as `data` in `ENCLAVE_DECRYPT`. + +The `@digitaldefiance/node-ecies-lib` library does all of this internally for clients; reimplementations MUST replicate the byte layout exactly to interoperate. + +--- + +## 6. Server-side state and persistence + +This section documents every piece of on-disk state the reference server creates and the access-control flags it sets. + +### 6.1 secp256k1 private key file + +- **Path:** `~/.enclave/ecies-privkey.bin` (computed via `FileManager.default.homeDirectoryForCurrentUser`). +- **Layout:** raw 32 bytes, no header, no encoding. +- **Generation:** on first call to `ECIESKeyManager.getOrCreateSecp256k1PrivateKey()`, the server fills 32 bytes from `SecRandomCopyBytes(kSecRandomDefault, 32, ...)`, writes them atomically (`Data.write(to:options:.atomic)`), and then calls `chmod(path, 0o600)` to restrict the file to the owning user. +- **Lifetime:** persistent across restarts. There is **no rotation** in EBP/1; deleting the file before next startup rotates the bridge's persistent secp256k1 identity (and consequently invalidates any ECIES envelopes targeted at the old public key). +- **Threat model note:** the file is at rest in the user's home directory, protected only by POSIX permissions. Consumers that need defense in depth should layer additional encryption above the bridge; `SecureEnclaveKeyring` (Β§9) does exactly this. + +> The reference `ECIESKeyManager.swift` also defines a constant `keyTag = "com.enclave.ecieskey"`, which is reserved for a future Keychain-backed implementation. EBP/1 does not store the private key in the Keychain. + +### 6.2 Secure Enclave (P-256) signing key + +- **Storage:** in the Secure Enclave coprocessor, indexed in the macOS Keychain by `kSecAttrApplicationTag = "com.enclave.secureenclavekey"`. +- **Class/type:** `kSecClassKey`, `kSecAttrKeyType = kSecAttrKeyTypeECSECPrimeRandom`, `kSecAttrKeyClass = kSecAttrKeyClassPrivate`. +- **Access control flags:** the key is created with `SecAccessControlCreateWithFlags(nil, kSecAttrAccessibleWhenUnlockedThisDeviceOnly, .privateKeyUsage, nil)`. Concretely: + - **Accessibility:** only when the device is unlocked, never synced to iCloud Keychain (`...ThisDeviceOnly`). + - **Usage:** the key is usable as a private key (`.privateKeyUsage`); no biometric/device-passcode prompt is required. +- **Generation:** on first call to `getOrCreatePrivateKey()`, the server constructs `SecureEnclave.P256.Signing.PrivateKey(compactRepresentable: true, accessControl: ...)`. Apple's CryptoKit places the key in the SEP and returns a wrapper that can sign but never expose private bytes. +- **Reload behaviour:** the reference implementation has a known limitation: if a key already exists for the application tag, `getOrCreatePrivateKey()` throws `Unable to load Secure Enclave key with CryptoKit on this platform` because `SecureEnclave.P256.Signing.PrivateKey(secKey:)` is not exposed by the deployed SDK. In practice the server is expected to keep the same key for the application's lifetime; future revisions may use lower-level `SecKey` APIs to support reload across restarts. +- **Public-key bytes:** `getPublicKeyData()` returns `priv.publicKey.x963Representation`, i.e. 65 bytes `0x04 || X || Y`. +- **Signing:** `priv.signature(for:)` produces a `P256.Signing.ECDSASignature`; the server returns `.derRepresentation`. +- **Decrypt:** the SEP P-256 key is **signing-only**; ECIES decryption is performed with the host-resident secp256k1 key (Β§6.1), not the SEP key. + +### 6.3 TOTP configuration file + +- **Path:** `~/.enclave/totp-config.json`. +- **Format:** a JSON object mapping `keyId β†’ {"secret": , "uri": }`. + +Example contents: + +```json +{ + "ecies-secp256k1": { + "secret": "JBSWY3DPEHPK3PXP", + "uri": "otpauth://totp/EnclaveBridge:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=EnclaveBridge&algorithm=SHA1&digits=6&period=30" + } +} +``` + +- **Permissions:** the reference server writes via `Data.write(to:)` without an explicit `chmod`. It inherits the user's umask. Replicators SHOULD apply `0o600` for parity with the secp256k1 file. +- **Read on startup:** `AppState.loadKeys()` reads this file and merges TOTP fields into the in-memory `KeyInfo` records returned by `LIST_KEYS`. +- **Write on `ENABLE_TOTP`:** the server reads the existing file (if any), upserts the `keyId` entry, and writes the JSON back with `JSONSerialization.data(... .prettyPrinted)`. + +### 6.4 Per-process state + +| Field | Type | Scope | Notes | +|---|---|---|---| +| `BridgeProtocolHandler.startTime` | `Date` (static) | per-process | Initialised at first reference; used for `uptimeSeconds`. | +| `peerPublicKey` | `Data?` | per-connection | Set by `SET_PEER_PUBLIC_KEY`; reflected by `STATUS.peerPublicKeySet`. | +| `AppState.connections` | `[ClientConnection]` | per-process | Tracks `id`, fd, connected-at, last-activity, request count. | +| `AppState.keys` | `[KeyInfo]` | per-process | Refreshed from disk on `ENABLE_TOTP`. | +| `AppState.totalRequestsHandled` | `Int` | per-process | Incremented on every `updateConnectionActivity`. Not currently surfaced in `METRICS`. | + +--- + +## 7. TOTP algorithm (RFC 6238 with EBP/1 parameters) + +The bridge implements RFC 6238 / RFC 4226 with the following fixed parameters: + +| Parameter | Value | +|---|---| +| Hash | HMAC-SHA1 | +| Time step | 30 seconds | +| Digits | 6 | +| Counter encoding | 8-byte big-endian Unix-time / 30 | +| Window | Β±1 step (i.e. accept codes for `t-30`, `t`, `t+30`) | +| Secret length | 20 bytes (160 bits) of CSPRNG output | +| Secret encoding (provisioning, on disk) | RFC 4648 Base32, uppercase, alphabet `ABCDEFGHIJKLMNOPQRSTUVWXYZ234567`, no `=` padding | +| Provisioning URI scheme | `otpauth://totp/` | + +### 7.1 Provisioning URI construction + +``` +otpauth://totp/:?secret=&issuer=&algorithm=SHA1&digits=6&period=30 +``` + +The reference implementation does **not** percent-encode `issuer` or `account`. Implementations targeting authenticator apps that require URL-encoded labels SHOULD percent-encode both fields. + +### 7.2 Code computation + +``` +counter = floor(unix_time() / 30) +hmac = HMAC-SHA1(key = base32_decode(secret), msg = uint64_be(counter)) +offset = hmac[19] & 0x0F +truncated = uint32_be(hmac[offset .. offset+4]) & 0x7FFFFFFF +code = truncated % 1_000_000 +``` + +The reference server validates by computing the code for `counter`, `counter-1`, and `counter+1`; if any matches, the supplied 6-digit code is accepted. Codes outside that Β±30 s window are rejected with `TOTP code required or invalid for this key`. + +### 7.3 Validation when TOTP is not enabled + +`AppState.validateTOTP(forKeyId:code:)` returns `true` immediately when no TOTP secret exists for `keyId`, regardless of whether `code` is supplied. This means `EXPORT_KEY` for a key without TOTP behaves exactly like `GET_PUBLIC_KEY` / `GET_ENCLAVE_PUBLIC_KEY`. + +--- + +## 8. Reference client (`@digitaldefiance/enclave-bridge-client`) + +This section documents the TypeScript client's externally observable behaviour. Replicators MAY choose different defaults but SHOULD preserve the wire-level semantics. + +### 8.1 Defaults + +| Constant | Value | +|---|---| +| `DEFAULT_SOCKET_PATH` | `/tmp/enclave-bridge.sock` | +| `DEFAULT_TIMEOUT` | 30 000 ms | +| `DEFAULT_RECONNECT_DELAY` | 1 000 ms | +| `DEFAULT_MAX_RECONNECT_DELAY` | 30 000 ms | +| `DEFAULT_MAX_RECONNECT_ATTEMPTS` | 5 | +| `DEFAULT_HEARTBEAT_INTERVAL` | 30 000 ms | +| `DEFAULT_MAX_CONCURRENT_REQUESTS` | 10 (typings); 1 (steady-state β€” see note below) | + +> **Concurrency note.** The wire protocol supports only in-order, request-response messaging on a single connection (Β§3.4). The client's `maxConcurrentRequests` is enforced in code but, until the server gains correlation IDs, callers SHOULD set it to `1` per connection or use the connection pool (Β§8.6). The reference client's queue still honours arrival order even at higher concurrency, but pipelining beyond 1 reduces robustness against partial parses. + +### 8.2 `EnclaveBridgeClient` class β€” public API + +| Member | Description | +|---|---| +| `new EnclaveBridgeClient(opts?)` | Construct without connecting. | +| `static isSupported(socketPath?)` | Returns `{supported, platform, socketExists, socketPath, reason?}`. Verifies macOS and `fs.access`. | +| `connect()` | Opens the socket; emits `connect`/`stateChange`. Rejects with `TimeoutError`/`ConnectionError`. | +| `disconnect()` | Manual close; suppresses auto-reconnect; clears caches. | +| `isConnected` / `connectionState` | Boolean / `'disconnected'\|'connecting'\|'connected'\|'reconnecting'\|'error'`. | +| `getPublicKey(skipCache?)` | `GET_PUBLIC_KEY`. Returns `PublicKeyInfo` with `.base64`, `.buffer`, `.hex`, `.compressed`. Caches by default. | +| `getEnclavePublicKey(skipCache?)` | `GET_ENCLAVE_PUBLIC_KEY`. Same shape; cached separately. | +| `setPeerPublicKey(key)` | `SET_PEER_PUBLIC_KEY`. Accepts hex, Base64, or Buffer; auto-detects by length. | +| `enclaveSign(data)` | `ENCLAVE_SIGN`. Returns `{base64, buffer, hex, format: 'der'}`. | +| `decrypt(buffer)` / `enclaveDecrypt(buffer)` | `ENCLAVE_DECRYPT`. Returns `{base64, buffer, text}`. | +| `encrypt(data, recipientPubKey?)` | **Local** ECIES via `node-ecies-lib`; does not touch the bridge. | +| `verifySignature(data, sig, pubKey?)` | Local P-256 verify (see Β§4.9 caveat). | +| `enclaveGenerateKey()` | Wraps `ENCLAVE_GENERATE_KEY` (returns server error in EBP/1). | +| `heartbeat()` | `HEARTBEAT` β†’ `HeartbeatResponse`. | +| `getVersion()` / `getStatus()` / `getMetrics()` | `VERSION`, `STATUS`, `METRICS`. | +| `listKeys()` | `LIST_KEYS` β†’ `{keys: KeyInfo[]}`. | +| `rotateKey()` | `ENCLAVE_ROTATE_KEY` (server returns error). | +| `enableTOTP(keyId, account, issuer)` | `ENABLE_TOTP` β†’ provisioning URI string. | +| `exportKey(keyId, totpCode?)` | `EXPORT_KEY` β†’ `PublicKeyInfo`. | +| `ping()` | True iff `getPublicKey()` succeeds. | +| `getHealthStatus()` / `getConnectionInfo()` | Diagnostics. | + +### 8.3 Public-key info object + +Every public-key method returns: + +```ts +interface PublicKeyInfo { + base64: string; // wire form + buffer: Buffer; // raw bytes + hex: string; // hex of buffer + compressed: boolean; // true iff buffer.length === 33 +} +``` + +For EBP/1, **bridge endpoints always return `compressed === false`** (uncompressed 65-byte keys). + +### 8.4 Error class hierarchy + +``` +EnclaveBridgeError (base, has .code, .details) +β”œβ”€β”€ ConnectionError code=CONNECTION_ERROR +β”œβ”€β”€ TimeoutError code=TIMEOUT, has .operation, .timeoutMs +β”œβ”€β”€ DecryptionError code=DECRYPTION_ERROR +β”œβ”€β”€ EncryptionError code=ENCRYPTION_ERROR +β”œβ”€β”€ SignatureError code=SIGNATURE_ERROR +β”œβ”€β”€ InvalidOperationError code=INVALID_OPERATION +β”œβ”€β”€ ProtocolError code=PROTOCOL_ERROR +└── PlatformError code=PLATFORM_ERROR +``` + +`ProtocolError` wraps both invalid JSON and `{"error":...}` server replies. `TimeoutError` carries the original operation name (e.g. `'connect'`, `'GET_PUBLIC_KEY'`). + +### 8.5 Events + +The client extends `EventEmitter` and emits `stateChange`, `connect`, `disconnect`, `reconnecting`, `reconnected`, `reconnectFailed`, `error`, `debug`, `beforeDisconnect`, `requestSent`, `responseReceived`. `reconnecting` carries `(attempt, maxAttempts)`. `reconnectFailed` carries an `Error`. + +### 8.6 Connection pool + +`ConnectionPool` (`pool.ts`) maintains a fixed-size set of independent `EnclaveBridgeClient` instances (default `poolSize = 3`, `acquireTimeout = 5000` ms) and serialises `acquire`/`release` semantics. Use it when an application needs more than one in-flight request at a time. + +### 8.7 Streaming helpers + +`streaming.ts` exposes `encryptStream`, `decryptStream`, `encryptFile`, `decryptToFile`. These are **chunked** wrappers: they split a buffer into `chunkSize`-byte windows (default 65 536) and call the underlying `client.encrypt` / `client.decrypt` for each chunk. Each chunk produces an independent ECIES envelope; there is **no streaming AEAD construction**. Recipients reassemble by concatenating decrypted chunks in order. + +--- + +## 9. The `SecureEnclaveKeyring` consumer + +`brightchain-api-lib/src/lib/secureEnclaveKeyring.ts` is the highest-level consumer of EBP/1 in the BrightChain stack. It exposes the `IKeyring` contract used by `keyringFactory.detectBestKeyring()` (Tier 1 of three: Secure Enclave > OS Keyring > none). + +### 9.1 `IKeyring` interface + +```ts +export interface IKeyring { + storeKey(id: string, data: Uint8Array, password: string): Promise; + retrieveKey(id: string, password: string): Promise; + initialize(): Promise; + rotateKey(id: string, oldPassword: string, newPassword: string): Promise; +} +``` + +`SecureEnclaveKeyring` additionally provides `deleteKey`, `hasKey`, `listKeys`, `signWithEnclave`, and `getEnclavePublicKey`. + +### 9.2 Activation guard + +`SecureEnclaveKeyring.isAvailable(debug=false)` is the gate the factory consults. It does **not** simply check for the socket file; it performs a full live handshake: + +1. Verify `process.platform === 'darwin'` and `process.arch === 'arm64'`. Otherwise return `false` (or throw if `process.env.REQUIRE_SECURE_ENCLAVE === 'true'`). +2. Run Β§2.2 socket discovery. Fail if no path is reachable. +3. Dynamically `import('@digitaldefiance/enclave-bridge-client')` (it is an ESM module; the keyring is CJS-friendly via `await import`). Construct a client with `timeout: 5000`. +4. `client.connect()`. Verify `client.isConnected`. +5. `client.ping()` β€” actually `client.getPublicKey()` under the hood. +6. `client.getPublicKey()`. Verify the returned buffer is non-empty. If `length === 33`, verify the prefix is `0x02` or `0x03` (compressed). The keyring deliberately does **not** restrict to uncompressed even though EBP/1 always returns 65 bytes β€” this leaves room for future bridges. +7. `client.getEnclavePublicKey()`. Verify non-empty. +8. `client.enclaveSign(Buffer.from('enclave-availability-test'))`. Verify the returned signature is non-empty. +9. `client.disconnect()`. + +If any step fails: + +- If `REQUIRE_SECURE_ENCLAVE === 'true'`, the helper **throws** an `Error` describing the failure. +- Otherwise it returns `false`. + +This live-handshake gate exists because a stale socket file on disk does not imply a working bridge. + +### 9.3 Storage layout + +- **Directory:** `${HOME}/.brightchain-enclave-keys/` (Unix) or `${USERPROFILE}/.brightchain-enclave-keys/` (Windows fallback path; not actually reachable on Apple Silicon but kept for code symmetry). Created with `mkdir -p, mode=0o700`. +- **Per-key file:** `.enclave`. The sanitiser replaces every character outside `[A-Za-z0-9_-]` with `_`, preventing path traversal. +- **File mode:** `0o600`. + +### 9.4 Double-encryption envelope + +For each key the keyring stores **a single binary blob** representing two layers of encryption: + +#### 9.4.1 Inner layer β€” password-AES-GCM + +``` +salt(32) || iv(12) || authTag(16) || ciphertext(N) +``` + +- `salt` β€” 32 random bytes from `crypto.randomBytes`. +- `iv` β€” 12 random bytes. +- `authTag` β€” 16-byte AES-GCM tag. +- `ciphertext` β€” output of AES-256-GCM. + +The symmetric key `K_pwd` is derived from the user-supplied password and the salt: + +``` +K_pwd = scrypt(password, salt, length=32, N=2^14, r=8, p=1) +``` + +(`crypto.scrypt` in Node, callback form; the keyring awaits the result via a `Promise`.) + +#### 9.4.2 Outer layer β€” ECIES to the bridge + +The 60-byte-prefix-plus-ciphertext blob from Β§9.4.1 is treated as plaintext and encrypted with ECIES (Basic mode, `0x21`) to the bridge's persistent secp256k1 public key, using `@digitaldefiance/node-ecies-lib`'s `ECIESService.encryptBasic`. The result is the bytes written to disk. + +#### 9.4.3 On-disk file = outer ECIES envelope + +The wire layout from Β§5.6 (Basic mode), but the "ciphertext" carries the inner password-AES-GCM blob. + +### 9.5 Operations + +#### `initialize()` + +- Runs the Β§9.2 availability check **only** when `REQUIRE_SECURE_ENCLAVE === 'true'`. +- Creates the storage directory. +- Connects to the bridge once via the discovered socket and caches `clientPublicKey` (the bridge's secp256k1 public key) in memory. +- Disconnects. + +#### `storeKey(id, data, password)` + +1. Generate `salt`, `iv`. +2. Derive `K_pwd` via scrypt. +3. AES-256-GCM-encrypt `data` with `K_pwd`, `iv` β‡’ `(passwordEncrypted, authTag)`. +4. Build the inner blob `salt || iv || authTag || passwordEncrypted`. +5. ECIES-encrypt the inner blob to `clientPublicKey` β‡’ outer envelope. +6. Write the outer envelope to `/.enclave` with `mode 0o600`. +7. Zero the in-memory copies of `K_pwd`, the inner ciphertext, and the inner blob. + +#### `retrieveKey(id, password)` + +1. Read `/.enclave`. +2. Connect to the bridge; call `client.decrypt(envelope)` (i.e. `ENCLAVE_DECRYPT`) to peel off the outer ECIES layer. Disconnect. +3. Slice the inner blob: `salt = [0..32)`, `iv = [32..44)`, `authTag = [44..60)`, `passwordEncrypted = [60..)`. +4. Derive `K_pwd` via scrypt. +5. AES-256-GCM-decrypt with `K_pwd`, `iv`, `authTag`. Failures bubble up as `Error('Decryption failed: invalid password or corrupted data')`. +6. Zero `K_pwd` and the inner blob; return the recovered `Uint8Array`. + +#### `rotateKey(id, oldPassword, newPassword)` + +`retrieveKey` followed by `storeKey`. Plaintext is held only inside the call. + +#### `deleteKey(id)` + +1. Read the file size. +2. Overwrite the file with random bytes of equal size (best-effort secure erase on filesystems without copy-on-write). +3. `unlink`. +4. Silently ignore `ENOENT`. + +#### `hasKey(id)` / `listKeys()` + +`hasKey` is `fs.access(F_OK)`. `listKeys` is `fs.readdir` filtered to `.enclave` suffix, with the suffix stripped. + +#### `signWithEnclave(data)` and `getEnclavePublicKey()` + +Pass-through to the bridge's `ENCLAVE_SIGN` and `GET_ENCLAVE_PUBLIC_KEY`. Each call opens a fresh connection, performs the operation, and disconnects. Use sparingly; for chatty workloads, prefer holding a single client connection at the application layer. + +### 9.6 Properties + +Given the bridge cannot decrypt without the SEP-resident keypair-wrapper (the bridge's secp256k1 private key file's bytes are accessed only from the bridge process under POSIX `0o600`), and given the password is required to open the inner AES-GCM layer: + +- An attacker with **read access to the keyring directory only** sees outer ECIES envelopes; without the bridge's secp256k1 private key bytes (hostile read of `~/.enclave/ecies-privkey.bin`) they cannot recover even the inner blob. +- An attacker with **read access to both** the keyring directory and `~/.enclave/ecies-privkey.bin` recovers the inner password-AES-GCM blob but still needs the password (gated by scrypt) to recover plaintext. +- The bridge's running state adds a behavioural barrier: the keyring must successfully `connect()` and complete `decrypt`. A user can revoke decrypt access by quitting the menu-bar app; persisted ciphertexts remain readable only after the bridge resumes. +- Forward secrecy is **not** provided: rotating the bridge's secp256k1 key (by deleting `ecies-privkey.bin` before next start) makes all stored envelopes undecryptable, so administrators MUST re-run `rotateKey` for every stored item before rotating the bridge identity. + +--- + +## 10. End-to-end examples + +### 10.1 Hand-crafted JSON over `nc -U` + +```bsh +$ printf '%s' '{"cmd":"HEARTBEAT"}' | nc -U /tmp/enclave-bridge.sock +{"ok":true,"timestamp":"2026-05-20T17:02:11Z","service":"enclave-bridge"} +``` + +```bsh +$ printf '%s' '{"cmd":"GET_PUBLIC_KEY"}' | nc -U /tmp/enclave-bridge.sock +{"publicKey":"BCsf...=="} +``` + +### 10.2 Encrypt in Node, decrypt via the bridge + +```ts +import { EnclaveBridgeClient } from '@digitaldefiance/enclave-bridge-client'; +import { ECIESService } from '@digitaldefiance/node-ecies-lib'; + +const client = new EnclaveBridgeClient(); +await client.connect(); + +const { buffer: bridgePub } = await client.getPublicKey(); // 65-byte uncompressed +const ecies = new ECIESService(); +const envelope = ecies.encryptBasic(bridgePub, Buffer.from('hello, enclave')); + +const { text } = await client.decrypt(envelope); +console.log(text); // "hello, enclave" + +await client.disconnect(); +``` + +### 10.3 Sign and verify + +```ts +const client = new EnclaveBridgeClient(); +await client.connect(); + +const message = Buffer.from('audit-log-entry-#42'); +const { buffer: sigDer } = await client.enclaveSign(message); +const { buffer: enclavePub } = await client.getEnclavePublicKey(); + +// Local verify (uses the Β§4.9 caveat: hash-then-verify(SHA256)) +const valid = await client.verifySignature(message, sigDer, enclavePub); +console.log(valid); // true + +await client.disconnect(); +``` + +### 10.4 Enable TOTP and gated export + +```ts +const uri = await client.enableTOTP('ecies-secp256k1', 'alice@example.com', 'EnclaveBridge'); +console.log(uri); // QR-code this in Authy/Google Authenticator + +// Later, with a fresh authenticator code: +const { buffer } = await client.exportKey('ecies-secp256k1', '493017'); +``` + +### 10.5 Storing a master secret with `SecureEnclaveKeyring` + +```ts +import { SecureEnclaveKeyring } from '@brightchain/api-lib'; + +const kr = SecureEnclaveKeyring.getInstance(); +await kr.initialize(); + +await kr.storeKey('system-user-mnemonic', + Buffer.from('seed words go here'), + 'long-passphrase'); + +const recovered = await kr.retrieveKey('system-user-mnemonic', 'long-passphrase'); +``` + +--- + +## 11. Test vectors + +These vectors are illustrative of the byte layout and should be regenerated by implementations as part of CI. + +### 11.1 ECIES Basic envelope to the bridge + +- Bridge public key (65 B uncompressed): + `04 79 BE 66 7E F9 DC BB AC 55 A0 62 95 CE 87 0B 07 02 9B FC DB 2D CE 28 D9 59 F2 81 5B 16 F8 17 98 48 3A DA 77 26 A3 C4 65 5D A4 FB FC 0E 11 08 A8 FD 17 B4 48 A6 85 54 19 9C 47 D0 8F FB 10 D4 B8` +- Plaintext: `48 65 6C 6C 6F` (`"Hello"`) +- Ephemeral secp256k1 keypair (compressed): random per envelope. +- Suite bytes: `01 01 21` (Basic). +- IV: 12 random bytes. +- AAD = `01 01 21 || ephemeralPubCompressed(33B)`. +- Ciphertext + tag computed by AES-256-GCM with HKDF-SHA256-derived key (info `"ecies-v2-key-derivation"`). +- On-the-wire envelope: `01 01 21 || ephemeralPubCompressed(33B) || iv(12) || tag(16) || ciphertext(5)` β‡’ 67 bytes total. + +A WithLength variant differs only in the type byte (`42` instead of `21`) and the inserted `00 00 00 00 00 00 00 05` length field before the 5-byte ciphertext. + +### 11.2 Public-key fingerprint + +Given a SEC1-uncompressed key beginning `04 79 BE 66 7E F9 DC BB AC 55 ...`, SHA-256 the **full 65 bytes**, take the first 8 bytes of the digest, format as uppercase hex separated by `:`: + +``` +fingerprint = SHA256(pub)[0..8] + = E2 79 B1 47 22 96 87 D6 (example only) +LIST_KEYS publicKeyFingerprint = "E2:79:B1:47:22:96:87:D6" +``` + +### 11.3 TOTP + +- Secret: `JBSWY3DPEHPK3PXP` (Base32 of `Hello!\xDE\xAD\xBE\xEF`, illustrative). +- Time: `1750000000` (Unix seconds). +- Counter: `1750000000 / 30 = 58333333`. +- HMAC-SHA1 over big-endian `00 00 00 00 03 7A 6A 95` β‡’ 20-byte digest; offset `digest[19] & 0x0F`; truncated `uint32_be(digest[offset..offset+4]) & 0x7FFFFFFF`; code = truncated `mod 1_000_000`. + +Implementations MUST generate codes that the reference server accepts within Β±30 s of its own clock. + +--- + +## 12. Security considerations + +### 12.1 Trust boundaries + +- The Unix socket is only accessible to processes running as the same macOS user; macOS enforces this through filesystem permissions on the socket file and the containing directory. +- The reference server sets no per-message authentication. Any local process that can `connect(2)` to the socket can issue any command. Sensitive operations should be guarded with TOTP (Β§7) or by quitting the menu-bar app when not in use. +- The Secure Enclave private key is non-extractable: even root cannot read its bytes. A compromised app running as the user can, however, ask the bridge to **sign or decrypt** arbitrary inputs while the bridge is running. TOTP gating on `EXPORT_KEY` is the only command-level barrier in EBP/1. + +### 12.2 Replay and freshness + +- The protocol has no nonces or sequence numbers at the JSON layer. Replays of `ENCLAVE_DECRYPT` simply re-decrypt the same envelope; AES-GCM ensures integrity but provides no replay defence. +- TOTP codes are single-use within the Β±30 s window in spirit; the reference server does **not** maintain a "burned codes" set. A code remains valid for the full window. Implementations needing strict single-use semantics MUST add a server-side cache of recently-accepted (keyId, code) pairs. + +### 12.3 Side channels and persistence + +- The host secp256k1 private key on disk (Β§6.1) inherits filesystem semantics: copy-on-write snapshots, Time Machine backups, and SSD wear-levelling may all retain stale copies. Treat the bridge identity as device-bound but not durably destroyable without disk-level secure-erase. +- The TOTP config file (Β§6.3) carries the shared secret in cleartext. An attacker who reads it can produce valid codes. Permissions SHOULD be `0o600`; the keyring file in `SecureEnclaveKeyring` is created with `0o700` directory and `0o600` files, which is the recommended baseline. +- Memory zeroisation in the keyring uses `Buffer.fill(0)`. JIT-cached strings, V8 internal buffers, and OS swap may still retain copies; this is best-effort. + +### 12.4 Misuse + +- Sending a 16-byte IV to `ENCLAVE_DECRYPT` will fail integrity verification because AES-GCM expects a 12-byte nonce. The bridge will return `Decryption failed`. +- Sending a `Multiple` (`0x63`) envelope to `ENCLAVE_DECRYPT` is unsupported in EBP/1 and will fall into the `else` branch (treated as Basic), almost certainly failing the GCM check. +- The `SET_PEER_PUBLIC_KEY` cache is per-connection; reusing a single connection for multi-tenant use is explicitly discouraged. + +### 12.5 Threat model summary + +| Adversary capability | Outcome | +|---|---| +| Network-only | Out of scope (no network surface). | +| Unprivileged local process, different user | Cannot connect; macOS filesystem permissions block. | +| Local process, same user, bridge running, no TOTP | Can sign and decrypt arbitrarily via the bridge. | +| Local process, same user, bridge running, TOTP on key | Can call `EXPORT_KEY` only with a valid 6-digit code; `ENCLAVE_SIGN`/`ENCLAVE_DECRYPT` are not gated by TOTP in EBP/1. | +| Local user, bridge stopped | Cannot use the SEP key at all. Stored `SecureEnclaveKeyring` data is at rest behind two encryption layers. | +| Disk-image exfiltration | Reveals secp256k1 private key file, TOTP secrets, keyring envelopes. SEP key remains inaccessible (device-bound). | + +--- + +## 13. Compatibility and versioning + +- The protocol identifier for this document is **EBP/1**. +- All commands MUST be backwards-compatible within EBP/1: future revisions MAY add fields to responses but MUST NOT change existing field semantics. Clients MUST ignore unknown fields. +- New commands SHOULD follow the existing UPPER_SNAKE_CASE naming convention. +- A future EBP/2 SHOULD adopt a length-prefixed framing scheme (Β§3.2's brace-terminator framer is a known limitation) and MAY introduce request IDs for true pipelining. + +--- + +## 14. Implementer's checklist + +A new client implementation MUST: + +1. Discover the socket via the Β§2.2 path order. +2. Send each request as a single UTF-8 JSON object with no newline. +3. Parse responses with a brace-counting parser that respects strings and escapes. +4. Encode/decode all binary fields as standard Base64. +5. Treat any `{"error": "..."}` response as a recoverable protocol error, not a transport failure. +6. For ECIES, use **secp256k1 + AES-256-GCM(IV=12, Tag=16) + HKDF-SHA256(info="ecies-v2-key-derivation")** with the AAD construction in Β§5.5 and the wire layout in Β§5.6. +7. Hash with SHA-256 before P-256 verification, as documented in Β§4.9. + +A new server implementation MUST additionally: + +1. Persist the secp256k1 private key at `~/.enclave/ecies-privkey.bin` with `0o600`, generated from `SecRandomCopyBytes` or a CSPRNG of equivalent strength. +2. Provision the SEP key with the access-control flags in Β§6.2. +3. Track `peerPublicKey` per connection. +4. Implement the TOTP algorithm with the parameters in Β§7 and the on-disk format in Β§6.3. +5. Reject `Multiple`-mode (`0x63`) envelopes for `ENCLAVE_DECRYPT` and return a clean error for `ENCLAVE_GENERATE_KEY` / `ENCLAVE_ROTATE_KEY` until those are specified by a future revision. + +--- + +## 15. References + +1. RFC 5869 β€” *HMAC-based Extract-and-Expand Key Derivation Function (HKDF)*. +2. RFC 6238 β€” *TOTP: Time-Based One-Time Password Algorithm*. +3. RFC 4226 β€” *HOTP: An HMAC-Based One-Time Password Algorithm*. +4. RFC 4648 β€” *The Base16, Base32, and Base64 Data Encodings*. +5. NIST SP 800-38D β€” *Recommendation for Block Cipher Modes of Operation: Galois/Counter Mode*. +6. SEC 1 β€” *Elliptic Curve Cryptography*, Standards for Efficient Cryptography Group. +7. Apple, *CryptoKit* documentation β€” `SecureEnclave.P256.Signing.PrivateKey`, `AES.GCM`, `HKDF`. +8. GigaBitcoin, *secp256k1.swift* β€” Swift wrapper around libsecp256k1, used by the reference server. +9. Companion paper: [ECIES-Lib](ecies-lib) β€” full specification of the v4.0 ECIES wire format used by `node-ecies-lib`. +10. Companion paper: [BrightChain](brightchain) β€” the consuming platform. + +--- + +## Appendix A β€” Reference file map + +| File | Role | +|---|---| +| `enclave/EnclaveBridge/SocketServer.swift` | `AF_UNIX` socket lifecycle, accept loop, framing on `}`. | +| `enclave/EnclaveBridge/BridgeProtocolHandler.swift` | Command dispatch and ECIES decrypt parser. | +| `enclave/EnclaveBridge/ECIES.swift` | secp256k1 ECDH, HKDF, AES-256-GCM. | +| `enclave/EnclaveBridge/ECIESKeyManager.swift` | secp256k1 private key persistence. | +| `enclave/EnclaveBridge/SecureEnclaveKeyManager.swift` | SEP P-256 key generation and signing. | +| `enclave/EnclaveBridge/TOTPManager.swift` | Base32, HOTP/TOTP, provisioning URI. | +| `enclave/EnclaveBridge/AppState.swift` | Connection and key inventory state. | +| `enclave-bridge-client/src/index.ts` | Client class, framing, queue, reconnect. | +| `enclave-bridge-client/src/types.ts` | Request/response types. | +| `enclave-bridge-client/src/ecies.ts` | Wire-format helpers (parse/serialise). | +| `enclave-bridge-client/src/crypto.ts` | Local ECIES encrypt + P-256 verify. | +| `enclave-bridge-client/src/errors.ts` | Custom error classes. | +| `enclave-bridge-client/src/pool.ts` | Connection pool. | +| `enclave-bridge-client/src/streaming.ts` | Chunked encrypt/decrypt helpers. | +| `brightchain-api-lib/src/lib/secureEnclaveKeyring.ts` | `IKeyring` consumer with double encryption. | +| `brightchain-api-lib/src/lib/keyringFactory.ts` | Tiered keyring auto-selection. | +| `brightchain-api-lib/src/lib/keyring.types.ts` | `IKeyring` interface. | + +## Appendix B β€” Worked decryption walkthrough (server pseudocode) + +```text +input: encryptedData (Base64-decoded), bridge secp256k1 private key d +require: encryptedData.length > 64 + +version = encryptedData[0] # 0x01 +cipherSuite = encryptedData[1] # 0x01 +type = encryptedData[2] # 0x21 or 0x42 +prefixByte = encryptedData[3] +if prefixByte == 0x04: ephLen = 65 +elif prefixByte in (0x02,0x03): ephLen = 33 +else: error "Invalid ephemeral public key format" + +ephPub = encryptedData[3 : 3 + ephLen] +o = 3 + ephLen +iv = encryptedData[o : o+12]; o += 12 +tag = encryptedData[o : o+16]; o += 16 + +if type == 0x42: + length = uint64_be(encryptedData[o : o+8]); o += 8 + ciphertext = encryptedData[o : o+length] +else: + ciphertext = encryptedData[o : ] + +aad = bytes([version, cipherSuite, type]) + ephPub +S = ECDH(d, ephPub) # 33-byte compressed point +x = S[1:33] # strip prefix β†’ 32-byte x-coord +K = HKDF_SHA256(IKM=x, salt=b"", info=b"ecies-v2-key-derivation", L=32) +plaintext = AES_256_GCM_decrypt(key=K, nonce=iv, aad=aad, + ciphertext=ciphertext, tag=tag) +return {"plaintext": base64(plaintext)} +``` + +## Appendix C β€” Worked decryption walkthrough (client pseudocode for `SecureEnclaveKeyring.retrieveKey`) + +```text +envelope = read_file("/.enclave") +inner = bridge.ENCLAVE_DECRYPT(envelope) # outer ECIES peeled +salt = inner[0:32] +iv = inner[32:44] +authTag = inner[44:60] +pwdEncrypted = inner[60:] +K_pwd = scrypt(password, salt, 32, N=2^14, r=8, p=1) +plaintext = AES_256_GCM_decrypt(K_pwd, iv, authTag, pwdEncrypted) +zero(K_pwd); zero(inner) +return plaintext +``` + +--- + +*This specification is informational. The reference implementations live in the `enclave/`, `enclave-bridge-client/`, and `brightchain-api-lib/` trees. Discrepancies between this document and the source are bugs in this document; please file an issue.* diff --git a/docs/papers/index.md b/docs/papers/index.md index 1f12d4f9..ba367be2 100644 --- a/docs/papers/index.md +++ b/docs/papers/index.md @@ -15,6 +15,8 @@ The BrightChain project is documented through a series of companion papers, each - [ECDH-to-Paillier Bridge](paillier-bridge) β€” Detailed algorithmic specification of the deterministic key bridge construction, including byte-level DRBG state, reproducible test vectors, and extended security analysis. - [ECIES-Lib](ecies-lib) β€” The Elliptic Curve Integrated Encryption Scheme library that serves as the cryptographic foundation for the entire platform. +- [Enclave Bridge Protocol (EBP/1)](enclave-bridge-protocol) β€” Replication-grade specification of the Apple Secure Enclave bridge protocol, the TypeScript client, and the `SecureEnclaveKeyring` consumer. +- [SDI-EB / OSC 7777 v3](sdi-enclave-bridge) β€” Secure Semantic Data Injection over Enclave Bridge: unified successor to SDI v1/v2, anchoring shell-to-agent terminal traffic on EBP/1 with hardware-signed registration transcripts and the full `geo-context` payload surface. - [BrightChain Crypto Sessions](brightchain-crypto-sessions) β€” Server-side key custody for end-to-end encrypted suites: sliding-TTL `CryptoSessionStore` and the `useSessionEstablish` / `useSessionUnlock` middlewares that let users authenticate once per session instead of once per request. ## Applications diff --git a/docs/papers/sdi-enclave-bridge.md b/docs/papers/sdi-enclave-bridge.md new file mode 100644 index 00000000..fa271758 --- /dev/null +++ b/docs/papers/sdi-enclave-bridge.md @@ -0,0 +1,1710 @@ +--- +title: "SDI-EB: Secure Semantic Data Injection over Enclave Bridge (OSC 7777 v3)" +parent: "Papers" +nav_order: 19 +--- + +# SDI-EB: Secure Semantic Data Injection over Enclave Bridge β€” A Unified Replication-Grade Specification (OSC 7777 v3) + +**Authors:** Jessica Mulein +**Status:** Proposal / Draft Standard, replication-grade +**Version:** 3.0 (SDI-EB / OSC 7777 v3) +**Date:** May 2026 +**Forked from:** `rfc-sdi-osc7777.md` (v1) and `rfc-sdi-osc7777-v2.md` (v2). Built on top of [Enclave Bridge Protocol (EBP/1)](enclave-bridge-protocol). + +> **Relationship to prior versions.** This document is a forked successor to v1 and v2, not a patch. v1 remains valid for any deployment that does not require agent-to-shell traffic. v2 remains valid for deployments that want bidirectional traffic on top of an X25519/HKDF agent. **v3 (this document) makes the SDI agent a thin layer on top of an Enclave Bridge process**, anchoring session establishment to the device's Secure Enclave (Apple Silicon) or to an EBP/1-compatible bridge, and replacing the v1/v2 X25519 handshake with an ECIES-based registration envelope addressed to the bridge's persistent secp256k1 key. All bidirectional-envelope properties of v2 (per-direction counters, AAD-bound `dir_tag`, geographic-context payload type, advisory pre-exec semantics, override helper, BrightDate timestamps) are preserved with byte-for-byte fidelity at the OSC 7777 wire layer; only the registration handshake and the storage of `K_session` change. v1, v2, and v3 sessions are distinguishable on the wire via the registration handshake (Β§4.2). New implementations should target v3 wherever an Enclave Bridge is available and SHOULD fall back to v2 where it is not. + +> **Scope.** SDI-EB covers (a) the SDI transport protocol over OSC 7777, (b) the registration extension to EBP/1 that turns the Enclave Bridge into the SDI agent, (c) every payload type defined by v1 and v2 (`ephemeral-auth`, `db-connection`, `geo-context`), (d) the geographic-context extension with zones, allowlist, audit, presence, advisory pre-exec, and override helper, and (e) integration with the BrightDate / BrightSpace / BrightSpaceTime stack. Earlier drafts split this into two documents; this one merges them so a reader sees one coherent specification with intra-document cross-references. + +--- + +## 1. Abstract + +Modern terminal workflows routinely interact with, generate, and process multi-field, highly structured ephemeral data β€” dynamic test credentials, cloud session authentication elements, complex infrastructure connection contexts, and location fixes that drive zone-aware automation. The protocol layer interfacing terminal emulator tasks with the host operating system has historically been restricted to flat text streaming or unsecured single-value clipboards; v1 and v2 of this RFC introduced an OSC 7777 envelope to fix that, with v2 adding bidirectional traffic and a `geo-context` payload type. + +This document β€” **SDI-EB / OSC 7777 v3** β€” re-grounds that envelope on the [Enclave Bridge Protocol (EBP/1)](enclave-bridge-protocol). Instead of running an X25519 daemon next to bsh, the SDI agent role is delegated to an EBP/1-compatible bridge (the SwiftUI Enclave Bridge app on macOS Apple Silicon is the reference); the bridge already brokers a host-resident secp256k1 ECIES key and hardware-anchored P-256 signing through Apple's Secure Enclave. v3 reuses that machinery for SDI session establishment, gaining defense-in-depth (hardware-anchored signing of the registration transcript, optional TOTP-gated key export, two-tier on-disk key custody via `SecureEnclaveKeyring`) and shedding a daemon (one socket, one running app, one set of menu-bar controls). All v2 OSC 7777 wire-level guarantees survive: AES-256-GCM with per-direction monotonic counters, AAD-bound direction tag, length-prefixed AAD construction, BrightDate-anchored expiry, replay-window validation, and fail-closed injection. All v2 payload types and the entire geographic-context surface (zones, transitions, advisory pre-exec, override, allowlist, audit, presence) are carried forward unchanged at the user-visible layer. + +**Keywords:** OSC 7777, Apple Secure Enclave, ECIES, secp256k1, P-256, AES-256-GCM, HKDF, BrightDate, BrightSpace, terminal protocol, SDI, geo-context, replay protection, advisory enforcement. + +--- + +## 2. The Problem & The Vulnerability Vector + +### 2.1 Limitations of the System Clipboard + +Passing rich structural schemas (such as a username, password, and seed phrase simultaneously) via the native OS clipboard forces an unideal developer compromise: either concatenating strings into fragile formats, manually copying individual fields iteratively, or leaking sensitive credentials to local background clipboard manager histories. + +### 2.2 Terminal Line Hijacking & Rogue Code Execution + +Relying on standard unauthenticated Operating System Commands (OSC) to communicate with native desktop applications poses a severe security hazard. If a terminal environment blindly parses and acts upon plaintext escape sequences embedded within standard output, any untrusted asset β€” such as a malicious repository file evaluated via `cat`, a deceptive git commit log, or an unvetted server Message of the Day (MOTD) β€” can forge sequences to inject structural data or trigger unintended desktop-agent side-effects. + +### 2.3 Daemon Sprawl and Trust Surface + +v2 ran SDI traffic through a dedicated `SDIAgent` daemon next to whatever cryptographic services the host already provided. On macOS that meant two long-running user-space services (the SDI agent and the Enclave Bridge) when the bridge already owned all the cryptographic primitives the SDI agent needed. v3 collapses the two: the bridge becomes the SDI agent. The user runs one menu-bar process and grants it one capability surface. Session establishment piggybacks on the bridge's existing public-key surface, so SDI inherits the bridge's hardware anchoring and access-control flags rather than reinventing them. + +--- + +## 3. Architecture Overview + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Terminal emulator β”‚ PTY (OSC 7777) β”‚ Enclave Bridge / SDI Agent β”‚ +β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ ◄────────────────► β”‚ (SwiftUI menu-bar, Apple β”‚ +β”‚ β”‚ bsh shell + β”‚ β”‚ β”‚ Silicon; or compatible impl) β”‚ +β”‚ β”‚ bsh-inject / β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ bsh-geo / β”‚ β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ +β”‚ β”‚ bsh-geo- β”‚ β”‚ β”‚ β”‚ EBP/1 surface (Β§EBP/1): β”‚ β”‚ +β”‚ β”‚ override β”‚ β”‚ β”‚ β”‚ HEARTBEAT, GET_PUBLIC_KEY β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ ENCLAVE_SIGN/_DECRYPT, β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ ENABLE_TOTP, EXPORT_KEY, β”‚ β”‚ + β–² β–² β”‚ β”‚ LIST_KEYS … β”‚ β”‚ + β”‚ β”‚ Unix socket β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ + β”‚ β”‚ (EBP/1 registration β”‚ β”‚ SDI extensions (Β§4.5–4.7): β”‚ β”‚ + β”‚ β”‚ envelope, geo socket) β”‚ β”‚ SDI_REGISTER, SDI_PUSH, β”‚ β”‚ + β”‚ β–Ό β”‚ β”‚ SDI_GEO_GET / _STATUS / β”‚ β”‚ + β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ _REFRESH / _AUDIT β”‚ β”‚ + └── enclave-bridge-client + β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β”‚ SecureEnclaveKeyring β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ TOTPManager, AppState, β”‚ β”‚ + β”‚ β”‚ SocketServer, ECIES, β”‚ β”‚ + β”‚ β”‚ SecureEnclaveKeyManager β”‚ β”‚ + β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β–Ό + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ Apple Secure Enclave (P-256) β”‚ + β”‚ secp256k1 priv (~/.enclave) β”‚ + β”‚ TOTP config (~/.enclave) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +The bridge keeps its EBP/1 command surface unchanged; SDI-EB adds new commands inside that envelope (`SDI_REGISTER`, `SDI_PUSH`, the geo-socket subset). A v3-aware client library MAY expose them through the same `EnclaveBridgeClient` class that already speaks EBP/1, or through a sibling class. v2-only clients keep working against an EBP/1 bridge that doesn't advertise SDI extensions; the bridge merely returns `Unknown command` for unimplemented v3 commands. + +--- + +## 4. Architecture Specification + +The Secure SDI standard introduces a distinct decoupled separation between the **Transport Vector** (the PTY text pipeline carrying OSC 7777 sequences) and the **Authentication Control Layer** (an out-of-band IPC handshake to the Enclave Bridge). v3 binds the Authentication Control Layer to EBP/1 β€” the registration handshake is itself an EBP/1 command pair, encrypted under the bridge's persistent secp256k1 key and signed with the device's Secure Enclave P-256 key. + +### 4.1 Out-of-Band Cryptographic Registration + +Before any data injection takes place, an interactive shell session registers itself with a localized, user-restricted Enclave Bridge (the v3 SDI agent) running on the host system. + +1. **Local Channel.** The bridge hosts an `AF_UNIX`/`SOCK_STREAM` socket exclusively accessible by the local user (filesystem permissions enforced by macOS). The path is discovered by the EBP/1 socket-path-discovery procedure (`enclave-bridge-protocol.md` Β§2.2): the client tries the sandboxed-app path, the non-sandboxed `~/.enclave/enclave-bridge.sock`, and finally the legacy `/tmp/enclave-bridge.sock` in that order. The bridge's socket is **not** namespaced per shell session; multiple concurrent SDI sessions multiplex on the same socket as separate EBP/1 connections, each holding its own per-connection state (Β§4.3). + +2. **Ephemeral Exchange.** The shell connects to the EBP/1 socket during its initialization phase and performs the `SDI_REGISTER` exchange defined in Β§4.5. The exchange yields a unique 32-byte session key ($K_{session}$) and a 16-byte transient Session-ID, both bound to the Enclave Bridge's P-256 signature over the registration transcript. The handshake never transmits $K_{session}$ in cleartext: client β†’ bridge contributions arrive inside an ECIES envelope addressed to the bridge's persistent secp256k1 public key (`GET_PUBLIC_KEY`), and the bridge's contribution is returned inside an ECIES envelope addressed to the client's ephemeral secp256k1 key from the same handshake. + +3. **Memory Residence.** $K_{session}$ resides strictly within the active memory space of that specific shell process and the bridge. Both ends destroy their copies on session expiry, on agent restart, and on explicit teardown. + +4. **Session Expiry.** Sessions have a maximum lifetime of **8 hours** regardless of activity, identical to v1/v2. The bridge MUST refuse to decrypt OSC 7777 sequences for expired sessions (Β§4.6), MUST refuse to push for expired sessions (Β§4.7), and MUST log the attempt. Shells that outlive their session must re-register. + +5. **Squatting Defense.** The bridge already refuses to start if its primary Unix socket is occupied (EBP/1 Β§2.1 binds after `unlink(2)`-ing any prior file; SDI-EB additionally requires that the bridge log a fatal error and abort if it observes an unexpected non-socket file at the discovered path). New v3 implementations MUST verify that no file exists at the chosen socket path before binding for the first time after install, and MUST abort with a fatal error rather than overwriting an unexpected file. + +6. **No Daemon Plurality.** Unlike v2, no second daemon is run. The Enclave Bridge fulfils the SDI agent role. Implementations targeting platforms without a Secure Enclave SHOULD provide an EBP/1-compatible bridge that uses an OS keyring (e.g. `keytar`, libsecret, DPAPI) for secp256k1 key custody and either a TPM or software signing for the registration transcript; such implementations are still wire-compatible with this RFC at the SDI layer. + +### 4.2 Wire-Level Distinguishability of Versions + +Three SDI session shapes coexist in the wild: + +| Version | Registration transport | HKDF info string | Notes | +| --- | --- | --- | --- | +| v1 | 48-byte raw frame on dedicated socket | `"sdi-session-key"` | Single direction, single counter. | +| v2 | 49-byte raw frame on dedicated socket; first byte `0x01` | `"sdi-session-key-v2"` | Bidirectional, per-direction counters. | +| **v3 (this document)** | EBP/1 `SDI_REGISTER` JSON command (Β§4.5) | `"sdi-session-key-v3"` | Bidirectional, per-direction counters, ECIES-anchored, P-256-signed transcript. | + +v3 sessions are distinguishable on the wire because the registration goes through an EBP/1 command rather than a dedicated raw-binary handshake. A v1 or v2 client MUST NOT speak v3 framing; a v3 client MUST NOT speak v1 or v2 framing. **Mixed-version sessions are not supported.** A v3 bridge that receives a v1 or v2 raw handshake on its EBP/1 socket will fail to parse the bytes as JSON and return `{"error":"Invalid request format"}` (per EBP/1 Β§4 error envelope), at which point the client SHOULD interpret the rejection as "no v3 here" and either downgrade to v2 against a separate SDI agent or fail closed. A v3 client that receives `{"error":"Unknown command: SDI_REGISTER"}` knows it is talking to an EBP/1 bridge that has not implemented the SDI-EB extension; it MUST then either use only EBP/1 commands (no OSC 7777) or fall back to a v2 SDI agent if one exists. + +The HKDF info string `"sdi-session-key-v3"` domain-separates v3 keys from v1 (`"sdi-session-key"`) and v2 (`"sdi-session-key-v2"`) so an accidental version mismatch produces incompatible keys rather than silent cross-version traffic. + +### 4.3 Concurrency, Lifetime, and Per-Session State + +- A single bridge instance serves multiple concurrent client connections; each connection runs an independent EBP/1 `BridgeProtocolHandler` with its own `peerPublicKey` slot (per EBP/1 Β§2.3). +- Each client connection MAY register at most one SDI-EB session via `SDI_REGISTER`. Re-issuing `SDI_REGISTER` on the same connection invalidates any prior session bound to that connection (the bridge wipes the prior `K_session` and `Session-ID` from memory) and returns the new identifiers. +- A client connection MAY send EBP/1 commands (e.g. `HEARTBEAT`, `ENCLAVE_SIGN`, `LIST_KEYS`) interleaved with SDI traffic on the same socket. SDI traffic itself uses two paths: the OSC 7777 wire (Β§4.4) and the bridge-side push command `SDI_PUSH` (Β§4.7), neither of which clobbers the EBP/1 request/response correlation rules. +- Either side MAY close the connection at any time. The reference client treats EOF as a normal close, fires the `disconnect` event, and (if `autoReconnect` is enabled) schedules an exponential-backoff reconnect (per EBP/1 Β§8.5). On disconnect, the bridge MUST destroy any `K_session` bound to that connection. +- Bridge restart destroys all SDI sessions. Clients MUST detect the restart (via `ECONNREFUSED` on a queued request, or via missing `HEARTBEAT` responses) and re-register lazily on the next emit attempt. Until re-registration completes, the client MUST treat the session as having no current fix (for `geo-context`) and as unable to emit OSC 7777 sequences. + +### 4.4 Rate Limiting + +The bridge MUST enforce a rate limit of no more than **10 failed `SDI_REGISTER` attempts per minute per connecting PID**. Exceeding this threshold causes the bridge to close the connection and log a warning. This mitigates local brute-force enumeration of registration parameters. Failed `SDI_PUSH` and OSC 7777 verifications are tracked separately and SHOULD be limited to **30 failures per minute per session**, after which the bridge MUST tear down the session and require re-registration. + +### 4.5 The `SDI_REGISTER` EBP/1 Command + +This section defines the new EBP/1 command that establishes a v3 SDI session. It plugs into EBP/1 Β§4 alongside `HEARTBEAT`, `ENCLAVE_SIGN`, etc. + +**Purpose.** Establish a fresh `(Session-ID, K_session)` pair bound to the bridge's hardware identity and to the client's ephemeral keypair, deliver `K_session` to the client, and provide the client with a verifiable transcript signature so the client knows it is talking to the same Secure Enclave it expects. + +**Request.** + +```json +{ + "cmd": "SDI_REGISTER", + "protocolVersion": 3, + "clientNonce": "", + "envelope": "" +} +``` + +| Field | Type | Description | +| --- | --- | --- | +| `protocolVersion` | integer | MUST be `3`. The bridge MUST reject other values with `{"error":"Unsupported SDI protocol version"}`. | +| `clientNonce` | base64-string | 16 cryptographically random bytes generated by the client. Used as part of the HKDF `salt` (Β§4.5.2). | +| `envelope` | base64-string | An EBP/1-format ECIES envelope (`enclave-bridge-protocol.md` Β§5.6) addressed to the bridge's persistent secp256k1 public key (the one returned by `GET_PUBLIC_KEY`), encrypting a JSON object whose schema is defined in Β§4.5.1. The bridge decrypts this envelope using the same internal pathway as `ENCLAVE_DECRYPT`. | + +The envelope's plaintext is a JSON object so the client and bridge can extend the registration handshake later without re-issuing a new EBP/1 command. + +> **Implementation note β€” two HKDF invocations.** Registration involves **two independent HKDF-SHA256 calls** with **different info strings**, and these are the most common source of porting bugs. Both ends MUST use the exact UTF-8 byte values shown: +> +> 1. **Outer envelope key derivation** (the ECIES key that protects the Β§4.5.1 plaintext on the wire): `info = "ecies-v2-key-derivation"` (23 bytes), `salt = empty`, per EBP/1 Β§5.3 and matching `@digitaldefiance/ecies-lib` / `@digitaldefiance/node-ecies-lib` byte-for-byte. Any divergence here β€” including UTF-8 encoding mistakes, accidental NUL termination, an extra/missing hyphen, or use of `"v1"` instead of `"v2"` β€” causes the bridge to derive a different AES-256-GCM key and `ENCLAVE_DECRYPT` returns `{"error":"Decryption failed"}` with no further diagnostic, even though the rest of the request looks valid. +> 2. **Session-key derivation** (the AES-256-GCM key that protects subsequent OSC 7777 traffic): `info = "sdi-session-key-v3"` (18 bytes), `salt = clientNonce β€– sessionId`, per Β§4.5.2 below. This MUST be `"sdi-session-key-v3"` with no version-byte mistake; v1 and v2 used `"sdi-session-key"` and `"sdi-session-key-v2"` respectively (see Β§4.2 and Appendix B.2), so a copy-paste from a v2 implementation will derive a different key and every OSC 7777 sequence will fail GCM tag verification at the receiver. +> +> A useful self-check: byte-dump the `info` argument as the implementation passes it to `HKDF.deriveKey` and compare to the literal `b"ecies-v2-key-derivation"` (`65 63 69 65 73 2D 76 32 2D 6B 65 79 2D 64 65 72 69 76 61 74 69 6F 6E`) and `b"sdi-session-key-v3"` (`73 64 69 2D 73 65 73 73 69 6F 6E 2D 6B 65 79 2D 76 33`). Cross-implementation interop tests SHOULD include a known-answer vector for each derivation so a divergence is caught at CI rather than at first real registration attempt. + +#### 4.5.0 Pinning to DD-ECIES (Normative) + +The outer envelope used by `SDI_REGISTER` MUST be byte-identical to what the **DD-ECIES** specification (`DD-ECIES-SPEC-v1.0`, the canonical specification of `@digitaldefiance/ecies-lib` and `@digitaldefiance/node-ecies-lib`) defines for **Basic mode** (encryption type `0x21`, version `0x01`, cipher suite `0x01 = Secp256k1_Aes256Gcm_Sha256`). A v3 implementation that builds an envelope from scratch (rather than calling the library) MUST replicate every byte of the format below; a single-byte deviation will fail to decrypt and produce no useful diagnostic on the bridge side. + +The DD-ECIES specification is the single authoritative source for the outer envelope. This section restates the values that affect SDI-EB so a replicator can avoid round-tripping through the larger document, but DD-ECIES wins on every disagreement (see "Conflict resolution" at the end of this subsection). + +**Cryptographic parameters (DD-ECIES Β§5, Β§8, Β§9):** + +| Parameter | Value | DD-ECIES section | +| --- | --- | --- | +| Curve | secp256k1 (SEC 2 Β§2.4.1) | Β§5.1 | +| Symmetric algorithm | AES-256-GCM | Β§9.1 | +| Symmetric key size | 32 bytes (256 bits) | Β§9.1 | +| IV size | **12 bytes** | Β§9.2 | +| Auth tag size | 16 bytes | Β§9.3 | +| HKDF hash | SHA-256 | Β§8.2 | +| HKDF salt | **empty** (`new Uint8Array(0)` / `Buffer.alloc(0)`) | Β§8.2 | +| HKDF info | `b"ecies-v2-key-derivation"` (23 bytes UTF-8) | Β§8.2 | +| HKDF output length | 32 bytes | Β§8.2 | +| ECDH shared-secret representation fed to HKDF | **32-byte x-coordinate** of the shared point (the `0x04` uncompressed prefix and the y-coordinate are stripped before HKDF) | Β§8.1 | + +**Wire-format constants (DD-ECIES Β§5, Β§10, Β§17):** + +| Constant | Value | DD-ECIES section | +| --- | --- | --- | +| Public key length on the wire | **33 bytes (compressed)**, prefix `0x02` or `0x03` | Β§5.2, Β§10.2.3 | +| Version byte | `0x01` (the only registered version) | Β§17.1 | +| Cipher-suite byte | `0x01` (`Secp256k1_Aes256Gcm_Sha256`, the only registered suite) | Β§17.2 | +| Encryption type β€” Basic | `0x21` (decimal 33) | Β§17.3, Β§10.2 | +| Encryption type β€” WithLength | `0x42` (decimal 66) | Β§17.3, Β§10.3 | +| Encryption type β€” Multiple | `0x63` (decimal 99) | Β§17.3, Β§11 | +| Basic-mode fixed overhead | 64 bytes | Β§10.2.4 | +| WithLength-mode fixed overhead | 72 bytes | Β§10.3.5 | + +**Backward-compat acceptance (DD-ECIES Β§5.3) β€” decoders only.** A DD-ECIES-conformant **decoder** MUST accept ephemeral keys in 33-byte compressed (canonical), 65-byte uncompressed, or 64-byte raw form, and MUST normalize them to 33-byte compressed before any cryptographic operation. **Senders, including SDI-EB clients, MUST emit only the 33-byte compressed canonical form.** This means the `SDI_REGISTER` outer envelope as transmitted contains exactly 33 ephemeral-key bytes; the 64- and 65-byte variants exist only as legacy inputs a decoder must tolerate, never as an output a sender may produce. + +**Basic-mode wire format (the form `SDI_REGISTER` uses):** + +``` +Offset Size Field +------ ---- ----------------------------------------------------------- +0 1 version (0x01) +1 1 cipherSuite (0x01) +2 1 encryptionType (0x21 for Basic) +3 33 ephemeralPublicKey (33-byte compressed; first byte 0x02 or 0x03) +36 12 iv (12 bytes, random per envelope, from CSPRNG) +48 16 authTag (AES-256-GCM 16-byte tag) +64 … ciphertext (length = total envelope length βˆ’ 64) +``` + +Total fixed overhead: **64 bytes**. Implementations MUST NOT emit a 65-byte uncompressed ephemeral key in this envelope. + +**AAD construction (DD-ECIES Β§10.2.5; matches `single-recipient.ts:97–116`):** + +``` +AAD = preamble β€– versionByte β€– cipherSuiteByte β€– encryptionTypeByte β€– ephemeralPublicKey +``` + +Where: + +- `preamble` is a zero-length `Uint8Array` for v3 `SDI_REGISTER` envelopes. SDI-EB does not exercise the DD-ECIES preamble facility; implementations MUST set `preamble = empty`. +- The four single-byte fields are appended in the order shown. +- `ephemeralPublicKey` is exactly the 33 bytes that appear on the wire at offset 3. + +The AES-256-GCM AEAD is then invoked with `key = K`, `iv = iv`, `aad = AAD`, where: + +``` +ECDH_x32 = ECDH(d_e, Q_bridge) // 32-byte x-coordinate of shared point +K = HKDF-SHA256(IKM = ECDH_x32, + salt = empty, + info = "ecies-v2-key-derivation", + L = 32) +``` + +Both ends produce the same 32-byte key only if they agree on every parameter. In particular, the IV must be **12 bytes** (Web Crypto's `AES-GCM` default and Node's `crypto.createCipheriv('aes-256-gcm', ...)` 12-byte nonce match); a 16-byte IV will fail GCM tag verification with no useful diagnostic. + +**WithLength and Multiple modes are not used by `SDI_REGISTER`.** They are defined by DD-ECIES Β§10.3 and Β§11 respectively and may be used by other layers (e.g. the `SecureEnclaveKeyring` consumer in EBP/1 Β§9 may store payloads in WithLength mode). `SDI_REGISTER` envelopes MUST be Basic mode (`0x21`); a bridge that receives a WithLength or Multiple envelope inside `SDI_REGISTER` MUST return `{"error":"Decryption failed"}` after AES-GCM tag verification fails, or, if the bridge wants to be more helpful, MAY return `{"error":"Invalid envelope plaintext"}` after detecting the wrong type byte before attempting decrypt. + +**Reproducible interop test vector (from DD-ECIES Β§18.6).** A conforming SDI-EB sender that uses the following deterministic inputs MUST produce a 94-byte Basic-mode envelope that decrypts to the literal plaintext `DD-ECIES test vector plaintext`: + +``` +recipientPubHex = 02dc286c821c7490afbe20a79d13123b9f41f3d7ef21e4a9caacd22f5983b28eca +ephemeralPrivHex = bc4313f0c6e23ae0366e40d80387f49a2e4f64069dcb5a447f22dabefb79dc2f +ephemeralPubHex = 02fbb6f2f3ee200f9cd9f33b86e7de3412eb9aee09f6b10709a595f5ede231494b +ivHex = 31fe1b062e5639622cfc0439 + +Expected wire (94 bytes): +01012102fbb6f2f3ee200f9cd9f33b86e7de3412eb9aee09f6b10709a595f5ede231494b +31fe1b062e5639622cfc0439e6dbf735d3ef9a4235d5513f9e8829cef3c70450f1ac074e +93508eb3caed91a900ebc463d4eaa78c4c56389f36ee +``` + +CI for any from-scratch SDI-EB implementation SHOULD include this test vector as a known-answer test for the outer envelope, alongside the second HKDF derivation's vector (built from `clientShare`, `bridgeShare`, `clientNonce`, and `sessionId` by the implementation under test). + +**Conflict resolution.** + +1. If this paper and DD-ECIES disagree on any wire-level value, **DD-ECIES wins**. File a bug against this document. +2. If DD-ECIES and the live `ecies-lib` source disagree, **the source wins** and DD-ECIES is the bug to fix. +3. The constants binding SDI-EB are: `IV_SIZE = 12`, `AUTH_TAG_SIZE = 16`, `PUBLIC_KEY_LENGTH = 33` on the wire (decoders accept 33/65/64 inputs per DD-ECIES Β§5.3 but normalize to 33 before crypto), `SYMMETRIC.KEY_SIZE = 32`, `BASIC.FIXED_OVERHEAD_SIZE = 64`, `info = "ecies-v2-key-derivation"`, version `0x01`, cipher suite `0x01`, type `0x21`. + +> **Source-comment caveat.** As of the time this RFC was written, `ecies-lib/src/constants.ts` lines 192, 200, and 208 contain inline comments that read `// ... + IV (16) + auth tag (16)`. The IV-size figure in those comments is **wrong** (the actual `expectedBasicOverhead` computation just above uses `ECIES_IV_SIZE = 12`, and the totals add up to 64 = `1+1+1+33+12+16` only when IV = 12). The DD-ECIES specification, the live runtime behaviour, and SDI-EB all agree on **12-byte IV**; the source comments are scheduled to be corrected. Replicators who read the source comments rather than the spec MUST trust the spec. + +#### 4.5.1 Envelope Plaintext Schema + +```json +{ + "v": 3, + "clientPub": "", + "clientShare": "", + "issuedAtBd": 9626.531421, + "ttlSeconds": 28800, + "agent": { + "name": "bsh", + "version": "1.4.2", + "platform":"darwin-arm64" + } +} +``` + +| Field | Notes | +| --- | --- | +| `v` | MUST equal `3`. | +| `clientPub` | The client's **ephemeral** secp256k1 public key (uncompressed, 65 bytes). This is independent of the ephemeral key used inside the ECIES envelope itself. The bridge will use this key as the recipient when ECIES-encrypting its half of the handshake (see Β§4.5.3). | +| `clientShare` | 32 cryptographically random bytes generated by the client. Used as half of the input keying material to HKDF (Β§4.5.2). | +| `issuedAtBd` | BrightDate scalar (days since J2000.0), produced by the client's clock. The bridge MUST reject sessions whose `issuedAtBd` is more than 60 seconds in the future (a defence against rolled-back clocks; the in-the-past direction is bounded only by `ttlSeconds`, since a stale registration is equivalent to a fresh one with a short `ttlSeconds`). | +| `ttlSeconds` | Requested session lifetime. The bridge MUST cap this at 28 800 seconds (8 h) and MAY cap lower. The actual lifetime is communicated back in the bridge's response. | +| `agent.{name,version,platform}` | Free-form strings identifying the client. Recorded in the bridge audit log. Maximum 64 chars each. | + +#### 4.5.2 Session-Key Derivation + +The bridge generates a 32-byte `bridgeShare` from `SecRandomCopyBytes`, a 16-byte `sessionId`, and an 8-byte counter `bridgeIssuedAtUnix` (Unix seconds, big-endian). The session key is then derived bilaterally: + +``` +IKM = clientShare || bridgeShare # 64 bytes +salt = clientNonce || sessionId # 32 bytes +info = b"sdi-session-key-v3" # 18 bytes (UTF-8) +K_session = HKDF-SHA256(IKM = IKM, salt = salt, info = info, L = 32) +``` + +The use of HKDF here is identical in spirit to v1/v2 but the IKM is the concatenation of two random shares (not the X25519 ECDH point) and the HKDF `salt` includes both the client nonce and the bridge-generated `sessionId`. Cross-version reuse of `K_session` between v1/v2 and v3 is prevented by the `info` string difference and by the salt structure. + +The bilateral-share construction means: + +- An attacker who learns only `clientShare` (e.g. by compromising the client process before the envelope is sent) still cannot derive `K_session` without also learning `bridgeShare`. +- An attacker who learns only `bridgeShare` (e.g. by compromising the bridge process after envelope decryption) still cannot derive `K_session` without also learning `clientShare`. +- The bridge's secp256k1 private key (used to decrypt the registration envelope) is in a separate trust domain from the SEP P-256 key (used to sign the transcript). A compromise of one does not enable forging both halves of the handshake. + +#### 4.5.3 Bridge Response + +The bridge returns an EBP/1 success envelope: + +```json +{ + "ok": true, + "sessionId": "", + "bridgeIssuedAtUnix": 1747800000, + "ttlSeconds": 28800, + "responseEnvelope": "", + "transcriptSig": "" +} +``` + +| Field | Notes | +| --- | --- | +| `sessionId` | The 16-byte session identifier the client will encode in the OSC 7777 wire. Generated fresh by the bridge. | +| `bridgeIssuedAtUnix` | Bridge's clock at issue time. Unix seconds. Used by the client to detect clock skew. | +| `ttlSeconds` | The actual session lifetime granted (≀ requested, ≀ 28 800). | +| `responseEnvelope` | ECIES envelope addressed to `clientPub` (from Β§4.5.1). The plaintext is `bridgeShare` (raw 32 bytes). The client decrypts using its ephemeral secp256k1 private key β€” the same key it included as `clientPub`. | +| `transcriptSig` | DER-encoded ECDSA signature, produced by the bridge's Secure Enclave P-256 key (`SecureEnclaveKeyManager.sign`), over the canonical transcript bytes defined below. The client MUST verify this signature against the bridge's SEP public key (`GET_ENCLAVE_PUBLIC_KEY`) before accepting the session. | + +The **canonical transcript** to be hashed-then-signed is the concatenation: + +``` +T = "SDI-EB v3 transcript\0" # 21 bytes (NUL-terminated literal) + || LE32(len(clientNonce)) || clientNonce # 4 + 16 = 20 bytes + || LE32(len(clientPub)) || clientPub # 4 + 65 = 69 bytes + || LE32(len(clientShare)) || clientShare # 4 + 32 = 36 bytes + || LE32(len(sessionId)) || sessionId # 4 + 16 = 20 bytes + || LE32(len(bridgeShare)) || bridgeShare # 4 + 32 = 36 bytes + || LE32(8) || u64_be(issuedAtBd*86400) # 4 + 8 = 12 bytes + # issuedAtBd is rounded to nearest second + || LE32(8) || u64_be(bridgeIssuedAtUnix) # 4 + 8 = 12 bytes + || LE32(4) || u32_be(ttlSeconds) # 4 + 4 = 8 bytes +``` + +Total: 234 bytes. `LE32(n)` is a 4-byte little-endian length prefix (matching the AAD-construction convention of v2 Β§3.4). `u64_be` and `u32_be` are big-endian unsigned integers. + +The bridge passes `T` to `ENCLAVE_SIGN` (which internally SHA-256-hashes and signs with the SEP P-256 key, per EBP/1 Β§4.9). The client verifies the resulting DER signature against the SEP public key it has previously cached (or fetches via `GET_ENCLAVE_PUBLIC_KEY` and trust-on-first-use pins; see Β§4.5.5). On signature failure the client MUST destroy `K_session`, treat the registration as failed, and emit a security event to its local log. + +#### 4.5.4 Client-Side Procedure + +A v3 client implements the following `SDI_REGISTER` flow: + +1. Discover and connect to the bridge socket (EBP/1 Β§2.2). +2. Verify the bridge is responsive: `HEARTBEAT` SHOULD succeed. +3. Fetch the bridge's persistent secp256k1 public key: `GET_PUBLIC_KEY`. Cache. +4. Fetch (or use a previously-pinned) Secure Enclave P-256 public key: `GET_ENCLAVE_PUBLIC_KEY`. +5. Generate `clientNonce` (16 random bytes), `clientShare` (32 random bytes), and an ephemeral secp256k1 keypair `(d_c, Q_c)`. +6. Build the Β§4.5.1 plaintext object. ECIES-encrypt it (Basic mode, `0x21`) to the bridge's persistent secp256k1 public key using `node-ecies-lib` (or any binary-compatible implementation). +7. Send the `SDI_REGISTER` request with `protocolVersion: 3`, `clientNonce`, and `envelope`. +8. On success response: ECIES-decrypt `responseEnvelope` using `d_c` to recover `bridgeShare` (32 bytes). +9. Compute `K_session` via the Β§4.5.2 derivation. +10. Reconstruct the transcript `T` using the values it sent and the values returned. Compute SHA-256 over `T` (the SEP signs the SHA-256 of input, so the verifier hashes too β€” see EBP/1 Β§4.9 caveat). Verify `transcriptSig` against the cached SEP public key. +11. On verification success, store `(sessionId, K_session, expiresAtUnix = bridgeIssuedAtUnix + ttlSeconds)` and zeroize `clientShare`, `bridgeShare`, `d_c`. The client MUST also zeroize the plaintext of the request envelope. +12. Begin emitting OSC 7777 sequences (Β§4.6) and listening for `SDI_PUSH`-relayed pushes (Β§4.7). + +#### 4.5.5 Trust on First Use vs Pinning the SEP Key + +The bridge's SEP P-256 key is stable for the lifetime of the device user. Clients SHOULD pin the public key on first registration and refuse to register if the public key changes thereafter (TOFU + pinning). Implementations MAY surface a UI prompt when the SEP key first appears or when it changes, similar to OpenSSH's host-key prompts. + +If the client cannot pin (e.g. running ephemerally in CI), it MUST verify `transcriptSig` is well-formed but MAY accept any SEP key returned by `GET_ENCLAVE_PUBLIC_KEY`. Clients running in this mode MUST log this fact and SHOULD warn the user, because such a client cannot distinguish a real bridge from an impostor that has compromised the local user account and is running its own bridge against a spoofed socket path. + +#### 4.5.6 Errors + +| Error | Condition | +| --- | --- | +| `{"error":"Unsupported SDI protocol version"}` | `protocolVersion` β‰  3. | +| `{"error":"Missing clientNonce"}` | `clientNonce` missing or not Base64-decodable to 16 bytes. | +| `{"error":"Missing envelope"}` | `envelope` missing or not Base64-decodable. | +| `{"error":"Decryption failed"}` | Envelope ECIES decryption failed (same as `ENCLAVE_DECRYPT` failure, EBP/1 Β§4.10). | +| `{"error":"Invalid envelope plaintext"}` | Plaintext failed JSON parse or schema validation. | +| `{"error":"Stale registration"}` | `issuedAtBd` was more than 60 seconds in the future of the bridge clock. | +| `{"error":"Session limit exceeded"}` | The bridge has hit its concurrent-session ceiling (default: 64). | + +### 4.6 OSC 7777 Wire Format (Identical to v2 Β§3.3) + +When a utility or process inside the shell wants to broadcast structured semantic data, or when the bridge wants to push state to a registered shell, the sender wraps the payload in an encrypted OSC 7777 macro structure. **The wire encoding is byte-for-byte identical to v2** so existing terminal emulators and middleware that already understand v2 OSC 7777 require no changes. + +``` +\e]7777;;;;;;;\a +``` + +| Field | Encoding | Description | +| --- | --- | --- | +| `session-id-hex` | 32-char lowercase hex | Maps the sequence to a registered v3 session key. The 16-byte raw `sessionId` returned by `SDI_REGISTER` is encoded as 32 lowercase hex characters. | +| `base64-counter` | standard Base64 | 8-byte big-endian unsigned monotonic sequence counter (per direction; see Β§4.6.3). | +| `type` | plaintext ASCII | Payload schema identifier (e.g. `ephemeral-auth`, `db-connection`, `geo-context`). | +| `base64-context` | standard Base64 | Routing context (e.g. API URL, zone name); Base64 to avoid semicolon collisions. | +| `base64-nonce` | standard Base64 | 12-byte AES-GCM initialization vector. | +| `base64-ciphertext` | standard Base64 | AES-256-GCM encrypted JSON payload. | +| `base64-auth-tag` | standard Base64 | 16-byte GCM authentication tag. | + +`\a` (BEL, 0x07) is the sequence terminator. Direction is **not encoded on the wire**; it is determined by the receiver (Β§4.6.1). + +#### 4.6.1 Direction Determination + +A sequence read from stdin / PTY input is `Agent β†’ Shell` (`dir_tag = 0x02`). A sequence read from PTY stdout by the bridge (via the terminal emulator's relay) is `Shell β†’ Agent` (`dir_tag = 0x01`). The receiver knows which direction it is reading and uses the corresponding `dir_tag` for AAD reconstruction during decryption. An attacker replaying a captured sequence cannot move it between directions because the receiver always uses its own direction tag, and a tag mismatch causes GCM authentication to fail. + +> **Note on `type` field confidentiality.** The `type` field is transmitted in plaintext and will be visible to any observer of the PTY stream (e.g. terminal recordings, log captures). Implementations that consider the payload schema identifier sensitive SHOULD use a generic `type` value (e.g. `sdi-payload`) and encode the true type inside the encrypted JSON body. + +#### 4.6.2 Additional Authenticated Data (AAD) + +The `dir_tag`, `counter`, `type`, and `context` values are bound into the AES-256-GCM authentication tag as Additional Authenticated Data. The AAD MUST be constructed using length-prefixed encoding to prevent boundary confusion attacks: + +$$\text{AAD} = \text{LE32}(1) \mathbin\| \mathit{dir\_tag} \mathbin\| \text{LE32}(\text{len}(\mathit{counter\_bytes})) \mathbin\| \mathit{counter\_bytes} \mathbin\| \text{LE32}(\text{len}(\mathit{type\_bytes})) \mathbin\| \mathit{type\_bytes} \mathbin\| \text{LE32}(\text{len}(\mathit{context\_bytes})) \mathbin\| \mathit{context\_bytes}$$ + +where: + +- `LE32(n)` is a 4-byte little-endian encoding of `n`. +- `dir_tag` is a single byte: `0x01` for Shell β†’ Agent, `0x02` for Agent β†’ Shell. The leading `LE32(1)` is the length prefix of the `dir_tag` field, kept for symmetry with the rest of the length-prefixed scheme. +- `counter_bytes` is the raw 8-byte big-endian counter from the appropriate direction (Β§4.6.3). +- `type_bytes` is the UTF-8 encoding of the type string. +- `context_bytes` is the raw decoded bytes of the Base64 context field. + +The receiver supplies all four values during decryption. If `type` or `context` is empty, its length prefix is `LE32(0)` and it contributes zero payload bytes (the length prefix itself is still included). `dir_tag` and `counter_bytes` are never absent. + +This construction ensures that a captured ciphertext cannot be replayed under a different direction, type, context, or counter value even if `K_session` were somehow extracted. + +#### 4.6.3 Replay Protection via Per-Direction Monotonic Counters + +Each session maintains **two** independent monotonic counters, one per direction: + +- `c_shell_to_agent` β€” incremented by the shell on every emit; validated by the bridge on receive. +- `c_agent_to_shell` β€” incremented by the bridge on every emit; validated by the shell on receive. + +Both initialize to `0` at registration. The wire encoding (8-byte big-endian unsigned, base64) is unchanged from v2; only the per-side bookkeeping splits. + +Each side MUST track: + +1. Its own outbound counter for the direction it emits in (used to fill `counter_bytes` on emit, incremented by 1 per emit). +2. The highest accepted counter for the direction it receives from (used for replay-window validation). + +On receipt, the receiver: + +- Reconstructs AAD using its receiving direction's `dir_tag` (Β§4.6.2). +- Verifies the GCM authentication tag. +- If verification succeeds, applies the replay window: accepts counters in `(last_accepted + 0, last_accepted + 1000]` (i.e. strictly greater than `last_accepted`, up to a tolerance of 1000 to allow out-of-order delivery in pipelines). Rejects any counter at or below `last_accepted`, or beyond the window. +- Logs all replayed or out-of-window counter values as security events. + +Because direction is bound into AAD, a captured Shellβ†’Agent sequence cannot be replayed as Agentβ†’Shell or vice versa: GCM tag verification fails on the wrong-direction reconstruction. The two counter namespaces are therefore independent and never collide. + +This prevents replay of any captured OSC 7777 sequence in either direction, even within an active session. + +### 4.7 The `SDI_PUSH` EBP/1 Command (Bridge β†’ Shell Channel) + +The bridge cannot write to the PTY directly without help from the terminal emulator: macOS does not give an arbitrary background process the file descriptor of a foreground shell's controlling terminal. v2 sidestepped this by assuming a deeply integrated terminal-emulator API. v3 makes the relay explicit and brings it into the EBP/1 surface as a **client-pulled push channel**. + +The mechanism: the client opens a long-lived "push subscription" by calling `SDI_PUSH` with `op="subscribe"`. The bridge holds the connection open and writes one EBP/1 response per push event. Each event carries a fully-formed OSC 7777 sequence (encrypted under `K_session` with the `Agent β†’ Shell` direction tag) that the client emits to its own PTY (`/dev/tty`). To the terminal emulator, the sequence is indistinguishable from one the shell itself emitted. + +#### 4.7.1 Subscribe Request + +```json +{ + "cmd": "SDI_PUSH", + "op": "subscribe", + "sessionId":"" +} +``` + +The bridge MUST verify that `sessionId` is bound to the connection issuing this command. If not, return `{"error":"Session not registered on this connection"}`. + +#### 4.7.2 Push Event Frame + +For each push event the bridge wishes to deliver, it writes a JSON object: + +```json +{ + "ok": true, + "event": "push", + "sequence": "\u001b]7777;;...\u0007" +} +``` + +`sequence` is the complete OSC 7777 wire string with proper escapes (`\u001b` for `\e`, `\u0007` for `\a`). The client unescapes and writes the resulting bytes to `/dev/tty` (not stdout β€” see EBP/1 Β§4.6 reasoning, mirrored in v2 Β§3.6). The terminal emulator then forwards the sequence in-band to anything reading the user's terminal output. + +The client MUST NOT attempt to interpret `sequence` itself; it is opaque ciphertext destined for the application layer that owns the OSC 7777 dispatch (typically the shell's own input parser). The shell parses the sequence, decrypts it under `K_session`, and routes the plaintext payload to the appropriate handler. + +#### 4.7.3 Unsubscribe / Teardown + +To stop the push subscription, the client closes the connection. The bridge releases the per-session push queue and stops generating events for that session. Re-subscription is a fresh `SDI_PUSH` with `op="subscribe"` on the same or a new connection bound to the same `sessionId`. + +The client MAY also send `{"cmd":"SDI_PUSH","op":"unsubscribe","sessionId":"..."}` to release the subscription without closing the connection (e.g. if the same connection is hosting other long-lived EBP/1 traffic). The bridge MUST respond with `{"ok":true}` and stop emitting push events for that session on that connection. + +#### 4.7.4 Errors + +| Error | Condition | +| --- | --- | +| `{"error":"Session not registered on this connection"}` | `sessionId` not associated with this connection. | +| `{"error":"Session expired"}` | Session has aged past `expiresAtUnix`. | +| `{"error":"Push subscription already active"}` | Connection already has an active push subscription. | +| `{"error":"Unknown op"}` | `op` not `"subscribe"` or `"unsubscribe"`. | + +### 4.8 Injection Interface (Identical to v2 Β§3.6, with Bridge-Specific Wording) + +The reference shell (`bsh`) exposes a builtin `bsh-inject` that implements the full encrypt-and-emit pipeline: + +``` +bsh-inject --type --context +``` + +The JSON payload is read from **stdin**. The builtin performs lazy session initialisation (Β§4.5 if not already registered with the bridge), increments the session counter, encrypts with `K_session`, and writes the OSC 7777 sequence **directly to `/dev/tty`** rather than stdout. This ensures the sequence reaches the terminal emulator regardless of how the caller has redirected stdout, and prevents the ciphertext from being accidentally written to files, pipes, or logs. + +If stdout emission is explicitly required (e.g. for testing or piping to a custom terminal), the flag `--emit-stdout` may be passed to override this behavior. Callers using `--emit-stdout` are responsible for ensuring the sequence reaches a terminal emulator and not a log sink. + +**Bridge failure behavior.** If the Enclave Bridge is unavailable (no socket discovered per EBP/1 Β§2.2), or `SDI_REGISTER` fails for any reason, or the session has expired and re-registration fails, `bsh-inject` MUST fail closed β€” it prints an error to stderr and exits non-zero. It MUST NOT fall back to emitting plaintext or unencrypted OSC sequences. + +**Bridge-restart resilience.** Because the bridge restart destroys all `K_session` instances, `bsh-inject` MUST detect a stale-session error (decryption failure on the first emit attempt after a long gap, or an `ECONNREFUSED` on the persistent push subscription) and re-register transparently before retrying the emit. The retry MUST NOT loop indefinitely; after one re-registration attempt, persistent failure MUST exit non-zero with a clear stderr message. + + +--- + +## 5. Standardized Payload Schemas + +To maintain universal compatibility across browser extensions, form fillers, and desktop application management panels, payloads must adhere to predictable, strongly-typed semantic JSON specifications. v3 carries forward the v1/v2 payload set unchanged at the schema level; the only differences are (a) authoritative timestamps now use BrightDate scalars where they cross the bridge boundary (matching `geo-context`), and (b) replay validation references both the `issued_at` field inside the payload and the `counter` field on the wire. + +Three payload types are defined by this RFC: `ephemeral-auth` (Β§5.1), `db-connection` (Β§5.2), and `geo-context` (Β§5.3). Implementations MAY define additional payload types under their own namespace; consumers MUST ignore unknown types and SHOULD log them at debug level. + +### 5.1 `ephemeral-auth` + +Targeted at seeding short-lived accounts, hotseat multiplayer testing matrices, and web environment login flows. + +```json +{ + "type": "ephemeral-auth", + "context": "http://localhost:3005", + "ttl": 300, + "issued_at": 1748000000, + "data": { + "username": "player1", + "password": "TemporarySecurePassword123!", + "email": "player1@localhost.localdomain", + "additional_fields": { + "mnemonic": "fury appear bargain good coin load tattoo object convince render soft inside..." + } + } +} +``` + +### 5.2 `db-connection` + +Targeted at dynamically focusing or pre-configuring native desktop graphic database viewers straight from an active terminal workspace context. + +```json +{ + "type": "db-connection", + "context": "development-cluster-alpha", + "ttl": 60, + "issued_at": 1748000000, + "data": { + "engine": "postgresql", + "host": "127.0.0.1", + "port": 5432, + "user": "db_admin", + "pass": "ephemeral_token_string" + } +} +``` + +**Schema notes for `ephemeral-auth` and `db-connection`:** + +- The `issued_at` field (Unix timestamp, seconds) is **required** in all payload schemas. Bridges MUST reject payloads whose `issued_at` is more than `ttl` seconds in the past, or more than 60 seconds in the future, as an additional defense-in-depth layer against replayed payloads that bypass counter validation. +- The `ttl` field specifies the maximum lifetime of the decrypted state in the bridge's memory. Bridges MUST purge decrypted state after `ttl` seconds regardless of other conditions. +- v3-specific addition: implementations MAY include a parallel `issued_at_bd` field carrying the same instant as a BrightDate scalar, for consumers that integrate with the BrightDate stack. When both are present and disagree by more than 2 seconds, the bridge MUST log a warning and SHOULD reject the payload. + +### 5.3 `geo-context` + +A bidirectional payload type carrying location fixes from the bridge (push, on zone transitions and `command_jit` triggers β€” see Β§6 and Β§10) and queries / acknowledgements from the shell. Unlike `ephemeral-auth` and `db-connection`, `geo-context` introduces additional machinery β€” zone definitions, an authorization socket for child processes, advisory pre-exec semantics, and audit logging β€” defined in Β§6 through Β§10. + +`geo-context` uses BrightDate scalars for timestamps (not Unix epoch), exposes coordinates in three forms (geodetic / BrightSpace ECEF / BrightSpaceTime) with the geodetic form treated as the canonical input from the host OS, and is the first SDI payload type to require agent-to-shell pushes β€” i.e. the first user of the bidirectional envelope (Β§4.6) and of `SDI_PUSH` (Β§4.7). + +#### 5.3.1 Plaintext Payload Schema (Success) + +The plaintext (post-decryption) payload is JSON. The wire format mirrors the Rust struct names from `brightdate::geodesy` and `brightdate::relativity` so a Rust consumer MAY deserialize the relevant blocks directly. + +```json +{ + "type": "geo-context", + "context": "system-gps", + "issued_at_bd": 9626.531421, + "expires_at_bd": 9626.534893, + "ttl_seconds": 300, + + "zones_entered": ["office"], + "zones_exited": [], + + "geodetic": { "latitude": 47.3073, "longitude": -122.2285, "altitude": 64.2 }, + "ecef": { "x": -2294592.1, "y": -3624318.9, "z": 4665842.4 }, + "spacetime": { "t": 831492283.7, "x": -7.6541e-3, "y": -1.2095e-2, "z": 1.5568e-2 }, + + "altitude_assumed": false, + + "accuracy_metres": 15.0, + "provenance": "hardware", + "user_presence": true +} +``` + +**Field-by-field:** + +| Field | Type | Units | Notes | +| --- | --- | --- | --- | +| `issued_at_bd` | f64 | BrightDate days since J2000.0 | Set by the bridge at emit time using its monotonic clock. Authoritative absolute timestamp for audit/log consumers. | +| `expires_at_bd` | f64 | BrightDate days since J2000.0 | Bridge stops accepting / serving this fix after this instant. | +| `ttl_seconds` | u32 | SI seconds | Convenience; equals `(expires_at_bd βˆ’ issued_at_bd) Γ— SECONDS_PER_DAY`. | +| `zones_entered` / `zones_exited` | array of string | β€” | Names of zones the user transitioned into/out of since the previous fix. Empty array if no transition. Zone names are matched against `~/.config/bsh/geo-zones` (Β§6.1). | +| `geodetic.latitude` / `.longitude` / `.altitude` | f64 | degrees, degrees, metres (WGS84 ellipsoidal) | Mirrors `brightdate::geodesy::GeodeticCoordinate`. `altitude` MAY be `null` when the OS does not report altitude. | +| `ecef.x` / `.y` / `.z` | f64 | metres | Mirrors `brightdate::geodesy::EcefCoordinate`. Computed from `geodetic` via `geodetic_to_ecef`. | +| `spacetime.t` | f64 | **Bright-Seconds since J2000.0** | Equals `issued_at_bd Γ— SECONDS_PER_DAY`. Mirrors the `t` field of `brightdate::relativity::SpacetimeEvent`. | +| `spacetime.x` / `.y` / `.z` | f64 | **BrightMeters** | Computed as `ecef.{x,y,z} / BRIGHT_METER_M`. Mirrors `SpacetimeEvent.{x,y,z}`. Earth-scale ECEF compresses to small fractions of a BrightMeter; this is correct and intentional for c=1 work. | +| `altitude_assumed` | bool | β€” | `true` iff the OS did not report altitude and the bridge substituted `altitude = 0` (WGS84 ellipsoid surface) before computing `ecef` and `spacetime`. Consumers that care about Z accuracy MUST inspect this. | +| `accuracy_metres` | f64 | metres | Horizontal accuracy as reported by the OS. | +| `provenance` | enum | β€” | `"hardware"` (GPS/Cellular) or `"network"` (Wi-Fi/IP heuristics). | +| `user_presence` | bool | β€” | `true` if the bridge verified active user presence (biometric API or strict idle-time check) during this fix; `false` otherwise. See Β§10.3. | + +**Canonical input.** The bridge receives geodetic from the host OS location service and treats it as authoritative. `ecef` and `spacetime` are derived views computed by the bridge using `brightdate::geodesy::geodetic_to_ecef` (Bowring 1985 closed-form). They MUST NOT be considered independent measurements. + +**Single source of truth.** All consumers of a given fix see identical numbers across all three blocks. Bsh and `bsh-geo` are pure transport; the conversion happens once, in the bridge. + +#### 5.3.2 Plaintext Payload Schema (Failure) + +```json +{ + "type": "geo-context", + "context": "system-gps", + "issued_at_bd": 9626.531421, + "expires_at_bd": 9626.531594, + "error": "denied" +} +``` + +Failure payloads carry `expires_at_bd` (matching the success schema) so consumers know when a retry would be reasonable. A failure payload is itself short-lived: bridges SHOULD set `expires_at_bd` to roughly 15 seconds out from `issued_at_bd` so that callers retrying a failed acquisition do not hammer the bridge within that window. + +`error` values: + +| Value | Meaning | +| --- | --- | +| `"denied"` | The OS-level authorization (Location Services / equivalent) denied the request. | +| `"unavailable"` | Hardware/network sensor returned no fix within the configured wait window. | +| `"expired"` | A consumer requested a fix that the bridge had already aged out. (Used in Β§7.3 query replies; not pushed.) | +| `"presence_failed"` | The fix was acquired but `require_presence` was set and presence verification failed. | + +A failure payload received over the push channel MUST cause bsh to assume the `OUTSIDE` state for all currently-defined zones, fail-closed for any in-flight `command_jit` check, and not block waiting for a TTL timeout. + +--- + +## 6. Zone Definitions and Transitions + +### 6.1 Zone File: `~/.config/bsh/geo-zones` + +Zones are user-defined and user-owned. There is no `/etc/` system-wide zone file β€” see Β§11 for the rationale. + +``` +# zone_name latitude,longitude radius_metres [options] +office 47.6062,-122.3321 120 hardware +home 47.7000,-122.2000 150 +datacenter 47.3073,-122.2285 25 hardware,presence +``` + +**Format:** + +- One zone per line. Whitespace-separated. Lines beginning with `#` are comments. +- `zone_name`: ASCII identifier, `[a-z][a-z0-9_-]*`, max 64 chars. +- `latitude,longitude`: WGS84 decimal degrees, ASCII period decimal separator, no thousands separator. Range checked: lat ∈ [βˆ’90, 90], lon ∈ [βˆ’180, 180]. +- `radius_metres`: positive f64, ASCII period decimal separator. Practical range [1, 1e6]. +- `options` (optional, comma-separated): + - `hardware`: zone match requires `provenance == "hardware"`. Network-only fixes are treated as outside. + - `presence`: zone match requires `user_presence == true` at fix time. + +**Permissions.** The bridge MUST refuse to honor `~/.config/bsh/geo-zones` if it is group- or world-writable, or if its owner UID does not match the bridge's effective UID. On refusal the bridge logs a warning and serves no zones. + +**Reload.** The bridge reloads the zone file on `SIGHUP` and on detected file modification. Reload is atomic: a malformed file leaves the previous zone set active and logs a parse error. + +**File-state matrix.** The bridge's response to each zone-file state: + +| File state | Loaded zones | Bridge log level | Effect on shells | +| --- | --- | --- | --- | +| Absent | None | info, once at startup | No zone matches; `command_jit` refusals proceed with `zone='none'`. | +| Present, permissions OK, well-formed, β‰₯1 zone | Parsed set | info | Normal operation. | +| Present, permissions OK, well-formed, empty (or all comments) | None | info | Same as absent. | +| Present, permissions OK, malformed | Previous set retained (or none if first load) | error | `command_jit` continues against last good set; reload retried on next SIGHUP/modification. | +| Present, group/world-writable, or wrong owner | None | warning | Treated as absent; user is notified once via OSC 7777 push (`type: "sdi-config-error"`) so they can fix the perms. | + +**Matching.** The bridge does the matching. Bsh receives only zone *names*. Bsh never sees the geometry of zones the user is currently outside of. (This limits the blast radius of a bsh-side compromise: an attacker learns the names of zones the user has visited in this session, not the full set of definitions.) + +**Example file.** A starter zone file SHOULD ship at `/share/bsh/examples/geo-zones.example` containing two or three commented-out sample zones. Users copy it to `~/.config/bsh/geo-zones`, edit, and `chmod 0600`. + +### 6.2 Transition Events + +The bridge emits a `geo-context` push on the OSC 7777 channel (via `SDI_PUSH`, Β§4.7) whenever: + +- The user enters or exits any defined zone. +- A `command_jit` trigger (Β§10) is fired by bsh. +- Manually requested via the bridge's IPC interface (e.g., `bsh-geo --refresh`, mapped to `SDI_GEO_REFRESH`). + +The push contains the full Β§5.3.1 payload. `zones_entered` and `zones_exited` enumerate the deltas since the previous emitted fix on this session. + +### 6.3 Shell-Side Surface + +Bsh exposes the following when `setopt BSH_GEO` is enabled: + +| Symbol | Type | Notes | +| --- | --- | --- | +| `$BSH_GEO_ZONE` | string | Current matched zone (newest entered). Empty if not in any zone. **Not exported** by default (`typeset -g`, not `typeset -gx`). | +| `$BSH_GEO_ZONES` | array | All zones currently matched. **Not exported** by default. | +| `$BSH_GEO_PROVENANCE` | string | `"hardware"`, `"network"`, or empty. Not exported. | +| `$BSH_GEO_SOCK` | string | Path to the bridge's geo query socket. **Exported** (this is the only geo data deliberately exposed to children, and it is just a socket path, not coordinates). See Β§7. | +| `bsh_geo_enter_` | function | If defined, called when `` is entered. Same dispatch model as `chpwd_functions`. | +| `bsh_geo_exit_` | function | If defined, called when `` is exited. | +| `~/.config/bsh/geo-triggers.d/*` | scripts | If executable, run on every transition. See Β§6.4. | + +When `BSH_GEO` is not set, none of the above are populated; the geo socket is not announced to children; OSC 7777 `geo-context` pushes are silently dropped at the bsh side. + +### 6.4 Trigger Scripts + +Files in `~/.config/bsh/geo-triggers.d/` that are executable and match the pattern `[a-zA-Z0-9._-]+` (no leading dot beyond extensions) are invoked by bsh on every zone transition. + +**Invocation:** + +- Run as the user, never as root. There is no `/etc/` variant. +- A sanitized environment: only `PATH`, `HOME`, `USER`, `LANG`, `LC_*`, and `BSH_GEO_SOCK` are passed. The script can pull coordinates via `BSH_GEO_SOCK` if it is allowlisted (Β§7). +- Transition data is provided on **stdin** as a single JSON line: + ```json + {"event":"enter","zone":"office","provenance":"hardware","issued_at_bd":9626.531421} + ``` + or + ```json + {"event":"exit","zone":"office","provenance":"hardware","issued_at_bd":9626.531421} + ``` +- argv carries no transition data (argv is `ps`-visible). +- Lat/lon are NOT passed to trigger scripts directly. Scripts that need coordinates use the `bsh-geo` helper, which subjects them to the allowlist check. +- Script execution timeout: 5 seconds. Hard kill on timeout. Log on timeout. +- One trigger script's failure does not affect others. + +### 6.5 Advisory Pre-Exec Semantics and Overrides + +This section is normative for Tier 1 implementations. It defines exactly what bsh does when a `command_jit`-fenced command is invoked outside its zone. + +#### 6.5.1 Goals + +The advisory check exists for one purpose: **catch honest mistakes loudly enough to be useful, with an audit trail strong enough to be reviewable, while never claiming to enforce.** A v3 implementation that prints "blocked" without an override path is annoying; one that silently allows is useless; one that pretends to enforce is dishonest. + +#### 6.5.2 Required Behaviour + +When bsh is about to execute a command that matches a `command_jit` rule and the current `geo-context` indicates the command is fenced and the user is outside its zone: + +1. Bsh **MUST NOT** execute the command directly. +2. Bsh **MUST** print an advisory message to stderr in the form specified in Β§6.5.3. +3. Bsh **MUST** emit an audit event to the bridge over the persistent SDI session (encrypted via `K_session`, with `dir_tag = 0x01` shellβ†’agent, and with `type` set to `sdi-audit`). The payload schema is `{"kind":"advisory_refusal","command":...,"argv":[...],"zone_name":...,"distance_metres":...,"accuracy_metres":...,"provenance":...,"issued_at_bd":...}`. See Β§10.3 for what the bridge records. +4. Bsh **MUST** return exit code `124`. (Rationale: avoids the POSIX `EX_NOPERM` 77 collision and the 126/127 shell-builtin collisions; sits in the unused 120–125 range.) +5. Bsh **MUST NOT** propose any flag, alias, or syntax that pretends to override at the level of the fenced command itself. The override is invoked through `bsh-geo-override` (Β§6.5.4). + +#### 6.5.3 Required Advisory Format + +The stderr message MUST contain, in order: + +``` +bsh: advisory: '' is fenced to zone ''. + Current location: m from zone, m accuracy, provenance=. + This is a Tier 1 advisory check, not enforcement; see Appendix C. + To proceed in this shell: + bsh-geo-override --reason "" -- ... + To run without geo-aware checks at all, invoke through any non-bsh shell. +``` + +The "see Appendix C" line is required honesty. The "any non-bsh shell" line is also required honesty: a user who reads the message learns immediately that the friction is at the bsh layer only. Hiding this would be deceptive given the threat model in Β§11.3. + +The exact wording above is a SHOULD; implementations MAY rephrase for localization but MUST preserve all four elements (zone identification, current location summary, advisory disclaimer, override syntax) and MUST mention the non-bsh-shell escape. + +#### 6.5.4 The `bsh-geo-override` Helper + +Shipped alongside `bsh-geo`. Syntax: + +``` +bsh-geo-override [--reason ""] -- ... +``` + +Behaviour: + +1. Allowlist check: `bsh-geo-override` MUST itself appear in `~/.config/bsh/geo-allow`. Without this it cannot read the geo-context for the audit log. +2. Read the current `geo-context` from the bridge (via the geo socket, Β§7). +3. Emit an audit event to the bridge with kind `advisory_override`. The event MUST include: the user-supplied `--reason` text (or empty string if absent), the resolved absolute path of `` (via `realpath`, see Β§7.4), full argv, the active zone(s) at override time, distance from the rule's fenced zone, accuracy, and provenance. +4. `execve` `` with its argv. **No further bsh involvement.** The override helper itself is not a long-running supervisor; once exec'd, the command's exit code becomes the override's exit code. + +`bsh-geo-override` is **not** a privilege boundary. A malicious script that knows the override syntax can use it to bypass the advisory check. That is acceptable: per Β§11.3, this RFC does not defend against malicious code in the user's session. The override exists for the user's own convenience and for audit; it is not gating anything that matters. + +#### 6.5.5 `--reason` Conventions + +The `--reason` flag is technically optional, but `bsh-geo-override` SHOULD prompt interactively if it is missing and the standard input is a TTY: + +``` +$ bsh-geo-override -- kubectl-prod delete deployment/checkout +Override reason (one line): emergency rollback, on-call PagerDuty PD-12345 +``` + +Reasons are free-form strings, capped at 280 characters by the bridge (longer reasons are truncated and a flag is set in the audit entry). They are stored verbatim in the in-memory audit log; they MUST NOT be parsed for structure by the bridge or bsh. + +If neither the flag is supplied nor stdin is a TTY (e.g., the override is invoked from a script), the reason field is recorded as the empty string. The audit entry's `reason_supplied` boolean reflects this distinction. + +#### 6.5.6 What This Buys You + +After a month of use, `bsh-geo --audit overrides` (per Β§10.3) can answer: + +- Which fenced commands are routinely overridden, and for what reasons β†’ those probably shouldn't be fenced; the rule is wrong. +- Which fenced commands are rarely overridden β†’ those rules are doing what they should. +- Which user habits trigger advisory refusals β†’ maybe the user needs a different zone defined. + +The friction itself is not the win. The data the friction generates is the win. + + +--- + +## 7. Coordinate Access for Child Processes + +This section defines how a process other than bsh itself (e.g. an `iputils` helper, a deploy script, a weather utility) reads the current location. + +### 7.1 Design Constraints + +The data has properties that rule out the obvious approaches: + +- **Cannot live in environment variables.** Children inherit indefinitely, the value cannot be revoked from already-running processes, TTL becomes a polite suggestion. Leaks into Docker/CI/sudo. Visible to *every* descendant rather than the specific consumer. +- **Cannot live in a file.** Files are readable by anything sharing the UID, persist past TTL, leave a trail. +- **Cannot live in argv.** History, `ps`, audit-log capture. + +The only shape that gives real TTL, real per-program authorization, and zero ambient broadcast is **agent-as-keyserver**: a Unix-domain socket that authenticated peers query per-invocation. v3 keeps this socket distinct from the EBP/1 socket so that geo queries do not require an SDI session and so that the EBP/1 surface is not contaminated with location-specific access controls. + +### 7.2 The Geo Query Socket + +Distinct from the EBP/1 registration socket of Β§4.1. The bridge uses **two-level path indirection** so that long-running clients survive a bridge restart: + +| Name | Path | Mode | Contents | +| --- | --- | --- | --- | +| **Path file** | `~/.enclave/sdi-agent.geo.path` (or sandbox-equivalent) | `0600`, owner UID | A single line: the absolute path of the live socket. Stable name across restarts. | +| **Socket** | `~/.enclave/sdi-agent-.geo.sock` | `0600`, owner UID | The actual Unix-domain socket. Random component preserves the squat-resistance property of Β§4.1. | + +Bridge startup: + +1. Generate random component, choose socket path under `~/.enclave/`. +2. Verify no file exists at that socket path; fatal-abort if it does (squat defense, Β§4.1). +3. Bind socket. +4. Atomically write the new socket path into `<...>.geo.path` via `write` to a tempfile + `fsync` + `rename`. + +Bridge shutdown: + +5. Unlink socket and path file. (On crash these may persist; clients MUST be tolerant of stale path-file contents.) + +`$BSH_GEO_SOCK` exported by bsh contains the **path-file path**, not the socket path. Clients (`bsh-geo` and any other) follow this protocol per request: + +1. Open and read `$BSH_GEO_SOCK` (the path file). Extract the socket path. +2. Connect to the socket path. +3. On `ECONNREFUSED` / `ENOENT`, re-read the path file (the bridge may have restarted) and retry once. After a second failure, return error code 2 (bridge unreachable). + +This means a long-running process whose `BSH_GEO_SOCK` was set five hours ago still works after a bridge restart, because the indirection layer absorbs the random-path change. + +`$BSH_GEO_SOCK` is the only geo-related variable bsh exports by default. It contains no location data and confers no authority β€” peer authentication (Β§7.4) requires UID match and allowlist membership, neither of which the path discloses. The path is also recoverable by enumerating `~/.enclave/sdi-agent.geo.path`, so withholding it from the environment would not meaningfully improve secrecy. + +A `setopt BSH_GEO_NOEXPORT` option allows users who object to even this discovery hint to keep `BSH_GEO_SOCK` as a shell parameter only; in that mode `bsh-geo` inherits a one-shot-exported copy via the same mechanism Β§7.7 uses, and the user's parent environment carries nothing geo-related at all. See Appendix D for open questions on this option. + +### 7.3 Wire Protocol + +Single request, single response, then close. JSON, line-delimited, UTF-8. **Note:** unlike the EBP/1 socket (which uses brace-terminated JSON per EBP/1 Β§3), the geo socket uses newline-terminated JSON, because requests are short and the exchange is trivially one round-trip. + +**Request:** +```json +{"op":"get","require_altitude":false} +``` + +`op` values: + +| Value | Behaviour | +| --- | --- | +| `"get"` | Return the current fix if available and authorized. | +| `"status"` | Return only metadata (provenance, accuracy, expires_at_bd) β€” does NOT consume the auth window or expose coordinates. | +| `"refresh"` | Ask the bridge to acquire a new fix (subject to Β§9 trigger policy). | +| `"audit"` | Return the audit log (filtered by `since` / `kind` parameters). Requires the calling executable's allowlist entry to carry `audit=true`. | + +`require_altitude` (optional, default `false`): if `true`, the bridge MUST return `error: "altitude_unknown"` rather than serving a fix with `altitude_assumed: true`. + +**Response (success):** The Β§5.3.1 payload, *minus* the `zones_entered`/`zones_exited` arrays (those are only meaningful on the push channel). + +**Response (failure):** The Β§5.3.2 schema, with these additional `error` values: + +| Value | Meaning | +| --- | --- | +| `"not_authorized"` | Caller's executable is not in the allowlist. | +| `"altitude_unknown"` | `require_altitude` was set and altitude is not known. | +| `"rate_limited"` | Caller is currently throttled. | + +### 7.4 Peer Authentication + +When a process connects to the geo socket, the bridge MUST: + +1. **Verify peer UID** matches the bridge's effective UID via `SO_PEERCRED` (Linux), `LOCAL_PEERCRED` / `xucred` (macOS / BSD). Reject with TCP-style close on mismatch; do not respond. +2. **Resolve the peer's executable path** via `proc_pidpath(pid)` (macOS) or readlink of `/proc//exe` (Linux). This is the **kernel's** record of the loaded executable. Argv is not consulted. +3. **Allowlist check:** the resolved path MUST appear in `~/.config/bsh/geo-allow`. If not, respond with `error: "not_authorized"` and close. Log the attempt. + + The bridge canonicalizes both the resolved peer path and each allowlist entry via `realpath(3)` before comparison, so symlinked install paths (e.g. Homebrew's `/opt/homebrew/bin/bsh-geo` β†’ `/opt/homebrew/Cellar/bsh/X.Y.Z/bin/bsh-geo`) match correctly. An allowlist entry whose `realpath` cannot be resolved is treated as not present and logged as a warning at bridge startup and on reload. +4. **Optional integrity check:** if the allowlist entry includes a SHA-256 hash, the bridge MUST verify the hash of the resolved executable matches before serving the fix. Mismatch β†’ `error: "not_authorized"`, log including both hashes. +5. **Per-program approval cache:** if no current approval exists for this `(uid, exe_path, exe_hash)` tuple, the bridge MAY prompt the user (Touch ID / system notification) for approval. Approval is cached in bridge memory only, with a default lifetime of 300 seconds, configurable per allowlist entry. Approval expiry forces re-prompt. v3 implementations that already have a TOTP layer for `EXPORT_KEY` (EBP/1 Β§4.14) MAY surface the same TOTP gate as an alternative to Touch ID for the per-program approval prompt; the user toggles the preferred mechanism in the bridge UI. +6. **Rate limit:** the bridge MUST limit any one peer PID to no more than 60 successful `get` requests per minute. Excess requests return `error: "rate_limited"`. + +Permissions check on `~/.config/bsh/geo-allow` itself: same rules as Β§6.1 (mode no more permissive than `0600`, owner UID match, refuse otherwise). + +### 7.5 Allowlist File Format + +``` +# absolute_exe_path [sha256=] [approve_ttl=] [audit=true] +/usr/local/bin/bsh-geo audit=true +/Users/jess/bin/deploy-aware sha256=ab12... approve_ttl=600 +/opt/iputils/bin/ping-geo +``` + +Lines beginning with `#` are comments. The path MUST be absolute. `audit=true` is required for entries that wish to use `op="audit"` on the geo socket. + +### 7.6 The `bsh-geo` Helper + +A small CLI shipped with bsh. Most callers should use this rather than speaking the raw protocol. + +``` +bsh-geo # default: ECEF, X Y Z metres, space-separated +bsh-geo --geodetic # latitude longitude altitude (degrees, degrees, metres) +bsh-geo --bst # BrightSpaceTime: t x y z (Bright-Seconds, BrightMeters) +bsh-geo --json # full Β§5.3.1 payload (without zones_entered/exited) +bsh-geo --status # provenance, accuracy_metres, seconds-until-expiry only +bsh-geo --refresh # request a new fix, then return it +bsh-geo --refresh-presence # invalidate the presence cache (Β§10.3); does not return coords +bsh-geo --wait # if no current fix, wait up to for one (max 30) +bsh-geo --require-altitude # exit 4 if altitude unknown rather than serving altitude_assumed +bsh-geo --audit [filter] # query the bridge's audit log; see Β§10.3 +bsh-geo --exec -- prog ... # one-shot env injection, see Β§7.7 +``` + +**Output forms:** + +``` +$ bsh-geo +-2294592.1 -3624318.9 4665842.4 + +$ bsh-geo --geodetic +47.3073 -122.2285 64.2 + +$ bsh-geo --bst +831492283.7 -7.6541e-3 -1.2095e-2 1.5568e-2 + +$ bsh-geo --status +provenance=hardware accuracy_metres=15.0 expires_in_seconds=283 +``` + +The default form is BrightSpace ECEF in plain metres. The `--bst` form mirrors `brightdate::relativity::SpacetimeEvent` field order `(t, x, y, z)`: time first, in Bright-Seconds since J2000.0; spatial components in BrightMeters. Because `BRIGHT_METER_M = SPEED_OF_LIGHT_M_PER_S β‰ˆ 2.998 Γ— 10⁸`, Earth-scale spatial coordinates appear as small fractions of a BrightMeter; this is correct. + +**Exit codes:** + +| Code | Meaning | +| --- | --- | +| `0` | Coordinates returned. | +| `1` | Currently no fix available (`error: "expired"`, `"unavailable"`, `"denied"`, `"presence_failed"`). | +| `2` | Bridge unreachable (no `BSH_GEO_SOCK`, socket connect failed, bridge crashed). | +| `3` | Caller not in allowlist (`error: "not_authorized"`). | +| `4` | Altitude unknown and `--require-altitude` was set. | +| `5` | Rate limited. | + +**`bsh-geo` itself MUST be present in the user's allowlist** for any of this to work; it is the canonical proxy through which ad-hoc shell scripts read location. This is the right granularity: trust the helper, not every one-off script. + +#### 7.6.1 The `bsh-geo-override` Helper + +A sibling helper used to invoke a `command_jit`-fenced command outside its zone with audit. See Β§6.5.4 for full semantics. Synopsis: + +``` +bsh-geo-override [--reason ""] -- ... +``` + +Like `bsh-geo`, it MUST appear in `~/.config/bsh/geo-allow`. Unlike `bsh-geo` it does not return coordinates to its caller; it `execve`s the named command after recording an audit entry. + +### 7.7 Single-Shot Environment Injection (Escape Hatch) + +A small fraction of utilities cannot fork a helper or speak a socket protocol. For these, `bsh-geo` provides a one-shot exec wrapper: + +``` +bsh-geo --exec -- prog arg1 arg2 +``` + +This: + +1. Performs the standard authorization check **for `bsh-geo` itself**, not for `prog`. The user is authorizing `bsh-geo` to inject; they remain responsible for what `prog` does with the data. +2. Sets `BSH_GEO_LATITUDE`, `BSH_GEO_LONGITUDE`, `BSH_GEO_ALTITUDE`, `BSH_GEO_ECEF_X`, `BSH_GEO_ECEF_Y`, `BSH_GEO_ECEF_Z`, `BSH_GEO_ALTITUDE_ASSUMED`, `BSH_GEO_PROVENANCE`, `BSH_GEO_ISSUED_AT_BD`, `BSH_GEO_EXPIRES_AT_BD` in **the immediate child's environment only** (via the `execve` envp argument). +3. **Does NOT** modify the parent shell's environment. The variables exist for the lifetime of `prog` and any descendants of `prog`, and not in any other process. + +This is documented as the escape hatch, not the primary path, and the RFC explicitly calls out its limitations: TTL of the variables is bounded by `prog`'s lifetime, not by the bridge-controlled `expires_at_bd`. Implementors and users SHOULD prefer the socket path. + +> **Naming change from v2.** v2 used unqualified `BSH_LATITUDE`/`BSH_LONGITUDE`/etc. v3 namespaces under `BSH_GEO_*` to reduce collision risk and to make the geo-origin obvious in `env` listings. Implementations MAY accept either prefix at the consumer side for one release as a compatibility shim, but `bsh-geo --exec` MUST emit only the `BSH_GEO_*` form. + +### 7.8 What Bsh Itself Does Not See + +Bsh, when `BSH_GEO` is enabled, receives: + +- **Zone names** (`$BSH_GEO_ZONE`, `$BSH_GEO_ZONES`) β€” yes. +- **Provenance** (`$BSH_GEO_PROVENANCE`) β€” yes. +- **Coordinates** (lat/lon/ECEF/BrightSpaceTime) β€” **no, not by default.** Bsh would have to query `BSH_GEO_SOCK` like any other client (and bsh would need to be in the allowlist to do so). + +This means a compromise of bsh's process leaks the names of zones the user has visited this session, plus the provenance flag, plus the socket path (already exported, not sensitive). It does not leak coordinates or the geometry of any zone. + +If a user genuinely wants coordinates in bsh-side parameters (for prompts, etc.), they MUST opt in via `setopt BSH_GEO_COORDS`. With that option set, bsh queries the socket like any other client and populates `$BSH_GEO_LATITUDE`, `$BSH_GEO_LONGITUDE`, `$BSH_GEO_ECEF_X`, etc. β€” which then live in the shell's parameter table but are still not exported. + +### 7.9 Bridge Restart Behaviour + +Β§4.1 defines the EBP/1 socket as accepting fresh connections after restart, with `K_session` resident in bridge memory. A bridge restart therefore destroys all SDI session keys; existing shells with cached `K_session` cannot communicate with the new bridge. + +Bsh and other clients MUST detect bridge loss and re-establish: + +- **Bsh side.** Bsh detects bridge loss either by an explicit OSC 7777 emit failure (the bridge's reply latency exceeds 2 seconds, or the persistent `SDI_PUSH` subscription closes with `ECONNREFUSED`) or by `bsh-geo` returning exit code 2 from any user invocation. On detection, bsh discards its cached `K_session`, marks the session invalid, and re-registers via Β§4.5 lazily on the next emit attempt. Until re-registration completes, bsh treats the session as having no current fix (outside all zones). User-facing behaviour: `bsh: SDI bridge restarted, re-registering...` printed once to stderr; subsequent `command_jit` rules behave as if no fix is available, leading to the Β§6.5 advisory refusal. +- **`bsh-geo` and other clients side.** Per Β§7.2, clients re-read the path file once on `ECONNREFUSED` / `ENOENT` and retry the connection. After a second failure the client returns exit code 2. Clients do NOT participate in SDI session re-registration; they speak only the unauthenticated geo socket protocol (UID + allowlist gated, but not session-keyed). + +The bridge MUST NOT serve `geo-context` data (over either OSC 7777 or the geo socket) until at least one fix has been acquired post-restart. A query against a freshly-started bridge with no fix returns `error: "unavailable"`. + +### 7.10 `bsh-geo --json` Output Stability + +The JSON wire format and `bsh-geo --json` output are append-only across versions: implementations MAY add fields, but MUST NOT remove or rename fields, change field types, or change the meaning of existing values, without bumping the SDI protocol version. Consumers SHOULD ignore unknown fields rather than reject the payload. + +The same stability guarantee applies to `--audit` JSON output (Β§10.3) and to the `BSH_GEO_*` environment variables set by `--exec` (Β§7.7). + + +--- + +## 8. EBP/1 Bridge-Side Geo Commands (Optional) + +The geo socket of Β§7 is the canonical access path for child processes. Clients that already hold an EBP/1 connection (e.g. `bsh` itself, or applications speaking `enclave-bridge-client`) MAY additionally invoke the following EBP/1 commands directly. These are convenience extensions; they do not replace the geo socket and they are subject to identical authorization (UID match + allowlist) plus the existing EBP/1 connection's session binding. + +### 8.1 `SDI_GEO_GET` + +```json +{ "cmd": "SDI_GEO_GET", "sessionId": "", "require_altitude": false } +``` + +Returns the current `geo-context` success or failure payload as a JSON object directly inside the EBP/1 response (not wrapped in an OSC 7777 envelope, since the EBP/1 connection is already encrypted by virtue of being a Unix-domain socket within the user's account; for additional in-flight encryption, the consumer can use the OSC 7777 path instead). + +The bridge MUST verify `sessionId` is bound to the connection issuing this command; otherwise return `{"error":"Session not registered on this connection"}`. + +The bridge MUST also verify the calling client (i.e. the process that opened this EBP/1 connection) appears in `~/.config/bsh/geo-allow`. The peer-credential check uses the same `SO_PEERCRED` / `LOCAL_PEERCRED` mechanism as the geo socket (Β§7.4). On allowlist failure: `{"error":"Caller not in geo allowlist"}`. + +### 8.2 `SDI_GEO_STATUS` + +```json +{ "cmd": "SDI_GEO_STATUS", "sessionId": "" } +``` + +Returns metadata only: `{"provenance":..., "accuracy_metres":..., "expires_at_bd":...}`. Does not consume the auth approval cache and does not expose coordinates. Same `sessionId` and allowlist checks as Β§8.1. + +### 8.3 `SDI_GEO_REFRESH` + +```json +{ "cmd": "SDI_GEO_REFRESH", "sessionId": "", "trigger": "manual" } +``` + +Force-acquires a fresh fix subject to Β§9 trigger policy. `trigger` is a free-form label that lands in the audit log alongside the resulting fix; recommended values are `"manual"`, `"network_change"`, `"command_jit:"`, or `"session_init"`. Same `sessionId` and allowlist checks. + +### 8.4 `SDI_GEO_AUDIT` + +```json +{ + "cmd": "SDI_GEO_AUDIT", + "sessionId":"", + "filter": "all" | "refusals" | "overrides" | "denials", + "since_bd": 9626.5 +} +``` + +Returns the in-memory audit log (per Β§10.3). Requires both `sessionId` binding AND that the calling client's allowlist entry carries `audit=true`. Returns: + +```json +{ + "events": [ + { "kind": "advisory_override", "issued_at_bd": 9626.51, "command": "...", "argv": [...], "zone_name": "...", "reason": "...", "reason_supplied": true, "reason_truncated": false, "...": "..." }, + ... + ] +} +``` + +The output is the same JSON-Lines structure as `bsh-geo --audit`, but wrapped into a single array for transport convenience inside the EBP/1 response envelope. + +### 8.5 `SDI_AUDIT_EMIT` + +```json +{ + "cmd": "SDI_AUDIT_EMIT", + "sessionId":"", + "kind": "advisory_refusal" | "advisory_override" | "", + "payload": { ... } +} +``` + +The shell-to-bridge audit-event emit channel for v3. Replaces v2's "send a `type:sdi-audit` OSC 7777 sequence" pattern with a direct EBP/1 command, because audit events have no need to traverse the PTY (the bridge writes them straight to its in-memory log). The bridge appends to its rolling log subject to the field constraints of Β§10.3 (e.g. truncation of `reason` strings beyond 280 chars, no coordinates). + +Returns `{"ok":true,"recorded_at_bd":}` on success. The bridge MAY drop audit events under memory pressure (logging the drop) and MUST NOT expose audit events of one user to another. + +--- + +## 9. Threat Model and Enforcement Tiers + +### 9.1 Tiers + +The geographic context spec uses a two-tier model. v3 normatively defines **Tier 1 only.** + +| Tier | What it provides | Where enforcement lives | +| --- | --- | --- | +| **Tier 1 β€” Advisory (this RFC)** | Friction against accidental misuse, audit trail, automation hooks, location-aware shell ergonomics. | Bsh + Enclave Bridge, user-space. | +| **Tier 2 β€” Authoritative (Appendix C, informational)** | Actual prevention of `execve`. | Privileged `EndpointSecurity` (macOS) / eBPF LSM (Linux) daemon, sharing this RFC's policy file format. | + +### 9.2 Adversaries In Scope (Tier 1) + +- **Tampered terminal stream.** A captured/forged OSC 7777 sequence in stdout (e.g. from `cat` of a hostile file). Defended by the AES-256-GCM authentication tag (Β§4.6) plus the bidirectional counter and AAD direction tag (Β§4.6.2–4.6.3). +- **Sensor-source spoofing of zone transitions on the wire.** Defended cryptographically by the encrypted SDI envelope. +- **Cross-process leakage of location data.** Defended by the agent-as-keyserver model of Β§7: coordinates never enter `environ`, never enter argv, never enter history. +- **Replay of captured `geo-context` sequences.** Defended by the per-direction monotonic counters (Β§4.6.3) and the BrightDate `issued_at`/`expires_at` window. +- **NTP rollback to resurrect expired payloads.** Defended by monotonic-clock-backed expiry on the bridge side. +- **Impostor bridge running on a spoofed socket path.** Defended by the SEP-signed registration transcript (Β§4.5.3) and TOFU-pinning of the SEP public key (Β§4.5.5). An impostor without access to the device's SEP cannot forge `transcriptSig`. + +### 9.3 Adversaries Out of Scope (Tier 1) + +The following are **not** defended against by this RFC. Implementors and documentation MUST NOT imply otherwise. + +- The user themselves on their own machine. +- Malicious code already executing in the user's session under the user's UID. +- A user running a different shell, copying a binary, executing inside a container/VM, or booting another OS. +- OS-level compromise (kernel, signed-kext, system-level location-service tampering). +- Sensor-level spoofing (GPS spoofing, Wi-Fi BSSID spoofing). The bridge receives whatever the OS reports; this RFC does not introduce a sensor-integrity layer beyond the `provenance` field which records the OS's own classification. + +The intent of Tier 1 is *automation and audit*, not *access control*. Β§11 states this in plain language. + +### 9.4 What Hardware Anchoring Buys (And What It Doesn't) + +Compared to v2's X25519/HKDF agent, v3 adds a Secure Enclave signature over the registration transcript (Β§4.5.3). This anchoring **does** give: + +- A trust-on-first-use pin so a client can detect a **future** impostor bridge that lacks the original SEP key. +- A weak attestation that the bridge process actually has access to the SEP at registration time (a process that did not could not produce `transcriptSig`). +- A clean audit story: the bridge audit log can include the transcript signature alongside each session-init event, so a reviewer can later verify the registration integrity. + +It **does not** give: + +- Protection against a malicious bridge that **does** have access to the SEP. Any process running as the user can call `SecureEnclave.P256.Signing.PrivateKey(...)` for a key with the same access-control flags. Two bridge processes can hold the same logical SEP key handle (different instantiations, same backing key), and either of them can produce valid `transcriptSig` values. +- Protection against keyboard-eavesdropping malware that captures the `K_session` after derivation. Once `K_session` is in process memory it is recoverable by anyone who can read that memory. +- Protection against malware that compromises the bridge process directly. Such an adversary has both the secp256k1 private key and SEP signing access, and is therefore indistinguishable from the legitimate bridge. + +The pin in Β§4.5.5 reduces the risk of a fresh impostor against an established user; it does not provide attestation that a particular running process has not been compromised. + +--- + +## 10. Hardware Polling and Trigger Configuration + +The bridge does not continuously poll hardware. Acquisition is event-driven, governed by `~/.config/bsh/geo-triggers`. + +### 10.1 Trigger File Format + +``` +# event_type target accuracy ttl +session_init * network 1h +network_change * network 30m +command_jit /usr/local/bin/kubectl-prod hardware 120s +command_jit /opt/aws/bin/aws-prod network 60s +``` + +| Field | Values | +| --- | --- | +| `event_type` | `session_init` (one fix per shell start), `network_change` (Wi-Fi/network transition), `command_jit` (just before specified command runs). | +| `target` | Absolute command path for `command_jit`; SSID or `*` for `network_change`; ignored (use `*`) for `session_init`. | +| `accuracy` | `hardware` (force GPS), `network` (Wi-Fi/IP heuristics OK), `any`. | +| `ttl` | Duration the fix is considered current. Suffixes `s`, `m`, `h`. Max 8h (matches Β§4.1 session lifetime). | + +Permissions: same rules as Β§6.1. + +**`command_jit` target distribution.** The trigger file is read by the bridge, but bsh needs to know which commands to intercept *before* `execve`. To avoid duplicate parsing and config drift, the bridge sends bsh the current `command_jit` target list (resolved absolute paths only β€” no zones, no rules) as part of the registration handshake response **post-Β§4.5**, and re-pushes it via OSC 7777 (`type: "sdi-config"`, payload `{"command_jit_targets":[...]}`) on every trigger-file reload. Bsh caches this list in process memory and consults it on every command resolution. Bsh does NOT read `~/.config/bsh/geo-triggers` directly. + +The list is delivered as a regular `SDI_PUSH` event (Β§4.7) the moment the session is established, so the client can begin honouring `command_jit` rules immediately after `SDI_REGISTER` returns. Subsequent reloads (file modification or `SIGHUP` to the bridge) emit a new `sdi-config` push that supersedes the prior list. + +### 10.2 `command_jit` Behaviour + +When bsh is about to execute a command matching a `command_jit` rule: + +1. Bsh sends a `SDI_GEO_REFRESH` request to the bridge (over the established session; alternatively, a `geo-context` query OSC 7777 sequence with `op:"refresh"` inside the payload), naming the trigger. +2. The bridge acquires hardware as required, with a **maximum wait of 5 seconds** for `network` and **15 seconds** for `hardware`. Cold GPS can exceed 15 seconds; `hardware` callers SHOULD use `accuracy: any` for non-critical paths. +3. **During the wait**, bsh prints `bsh: acquiring location for fenced command ''...` to stderr after 1 second of elapsed wait, and updates the line periodically. Stdin is not consumed; SIGINT is honored and aborts the acquisition (treating it as `error: "unavailable"` for Β§6.5 purposes). +4. On success, bridge pushes a `geo-context` payload over OSC 7777 (via `SDI_PUSH`). +5. On timeout or `error: "unavailable"`, bsh treats this as "outside all zones" and proceeds to the Β§6.5 advisory check, which will refuse the command (no current fix β†’ outside fenced zone β†’ advisory refusal). The refusal message specifies `provenance=none, distance_metres=unknown` for clarity. +6. Bsh evaluates the advisory check per Β§6.5. No additional pre-exec hook is defined by this RFC; users wanting custom behaviour write a wrapper script and place it on PATH ahead of the fenced binary. + +### 10.3 Presence Verification + +When a zone has the `presence` option (Β§6.1), the bridge MUST verify presence at fix time via the host's biometric API (Touch ID, Windows Hello) or, where biometrics are unavailable, an idle-time heuristic with a configurable maximum (default 30 seconds). + +Presence verification is **cached for 30 seconds** by default to avoid prompt fatigue on repeated `command_jit` events. The cache lifetime is configurable via `~/.config/bsh/geo-presence-ttl` (single integer, seconds, max 300). + +The cache is cleared immediately on: +- Lid close / display lock +- Explicit logout +- Bridge restart +- `bsh-geo --refresh-presence` + +v3 implementations on devices with Secure Enclave SHOULD use the SEP-backed Touch ID API (`LAContext.evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, ...)`) with the result bound into the same audit log entry as the fix itself, so a reviewer can later confirm not only that presence was verified but that the verification was hardware-attested. The `user_presence` payload field remains a single boolean for backward compatibility; an additional `presence_method: "biometric"` / `"idle-time"` field MAY be appended. + +--- + +## 11. Privacy and Exfil Mitigations + +### 11.1 No Ambient Broadcast + +Coordinates are not in `environ`, argv, `$HISTFILE`, or any file. They live in bridge memory and are served per-request to authenticated, allowlisted peers. + +### 11.2 No Persistence + +The bridge stores fixes in volatile memory only. On bridge shutdown, all fixes are dropped. There is no cache file, no log file, no backup. Audit logs (Β§11.3) record metadata (timestamps, request counts, denial reasons) but never coordinates. + +### 11.3 Audit Logging + +The bridge maintains an in-memory rolling log (default 1000 entries) of: + +- Geo socket connection attempts, with peer PID, peer exe path, allowlist verdict. +- Approval prompts and user responses. +- Rate-limit denials. +- Zone-file and allowlist-file permission rejections. +- **`session_init` events**: SDI registration successes and failures, with the agent identifier strings from Β§4.5.1, the bridge's transcript signature (truncated to first 16 bytes for log compactness), and the resulting session lifetime. +- **`session_teardown` events**: explicit teardown, expiry, or counter-failure-induced teardown. +- **`advisory_refusal` events** (per Β§6.5.2): bsh declined to execute a fenced command. Records `command`, `argv`, fenced `zone_name`, `distance_metres`, `accuracy_metres`, `provenance`, `issued_at_bd`. +- **`advisory_override` events** (per Β§6.5.4): a user invoked `bsh-geo-override` to proceed past an advisory refusal. Records all of the above plus the `--reason` text (verbatim, up to 280 chars), `reason_supplied` boolean, `reason_truncated` boolean. + +The log is queryable via `bsh-geo --audit` (which itself requires allowlist membership β€” by default `bsh-geo` is allowlisted, and `--audit` is gated by an additional `audit=true` flag on the allowlist entry) or via the `SDI_GEO_AUDIT` EBP/1 command (Β§8.4). + +The audit interface supports filtering by event kind: + +``` +bsh-geo --audit # all events, JSON-Lines format (one event per line) +bsh-geo --audit refusals # advisory_refusal only +bsh-geo --audit overrides # advisory_override only +bsh-geo --audit denials # not_authorized + rate_limited +bsh-geo --audit sessions # session_init + session_teardown +bsh-geo --audit --human # human-readable rendering, one line per event with fixed columns +bsh-geo --audit --since # events with issued_at_bd >= +``` + +**Output format.** The default output of `--audit` is JSON-Lines (one JSON object per line, no array wrapper, terminated by newline). This makes the audit log trivially pipeable to `jq`, `grep`, or any line-oriented analysis tool, and it is the format `bsh-geo` itself emits when invoked from automation. The `--human` flag produces a fixed-column human-readable rendering for interactive inspection. The JSON-Lines schema is subject to the Β§7.10 stability guarantee. + +The log MUST NOT contain coordinates, zone geometry, the contents of `geo-context` payload bodies, or `K_session` bytes. It MAY contain zone names, distances, accuracies, provenance flags, transcript-signature prefixes, and SDI version numbers as listed above. The `--reason` text is the only free-form string the bridge stores; it is treated as opaque user-supplied content and is never interpreted, parsed, or matched against patterns. + +Override entries are first-class: a user (or org reviewing the user's logs) can grep for them, count them, and use them as input to refining their `geo-triggers` ruleset. A rule that's overridden 30 times a week is probably the wrong rule. + +### 11.4 No Wall-Clock Dependency + +All timestamps on the wire are BrightDate scalars sourced from the bridge's monotonic clock conversion. An attacker capable of NTP rollback cannot resurrect an expired fix because the bridge's expiry check uses `mach_absolute_time` (or platform equivalent), not `gettimeofday`. + +### 11.5 Provenance and Presence are Plaintext-Adjacent + +`provenance` and `user_presence` ride inside the encrypted payload, so they are integrity-protected. However, they are **assertions by the bridge**, not independent attestations. A user-space compromise of the bridge could lie. The threat model (Β§9.3) excludes this case. + +### 11.6 The `type` Field is Still Visible + +Per Β§4.6, the `type` field on the OSC 7777 wire is plaintext. `geo-context` is therefore visible to any observer of the PTY stream. This RFC does not consider the *fact* that geo data is flowing to be sensitive. If you care about hiding even that, set the wire `type` to `sdi-payload` and inline `"type":"geo-context"` inside the encrypted body. + +### 11.7 Defense Scope (Honesty Section) + +This RFC explicitly does not defend against: + +- The user themselves bypassing geo-aware checks by running `/bin/sh`, `python -c`, or any non-bsh shell. +- Malicious code already executing in the user's session under the user's UID (it can call the geo socket like any allowlisted client; allowlist defends only against unrelated processes, not co-resident malware). +- A user copying the binary they want to "fence" elsewhere or running it inside a container. +- Sensor-level spoofing (GPS spoofing, Wi-Fi BSSID spoofing). The bridge reports what the OS reports. +- OS kernel compromise. + +**For genuine prevention of command execution, see Appendix C.** The shell is the wrong layer for it. + +### 11.8 What the Geographic Context Is Not + +In plain language, restated for emphasis because the v1 draft of the geo extension caused confusion: + +- **Not access control.** Bsh refuses to run a fenced command outside a zone? The user types `/bin/sh` and runs it anyway. We make this clearer; we don't pretend otherwise. +- **Not enforcement.** No "MUST block execution" appears in this RFC. The Β§6.5 advisory refusal is documented as friction, not a security boundary; `bsh-geo-override` makes the bypass explicit and audited rather than hidden. +- **Not anti-coercion.** A user being held at gunpoint to run `kubectl delete` from their office, where the policy permits it, will run it. +- **Not anti-malware.** Malware running as the user can read `BSH_GEO_SOCK` from `environ`, connect, fail the allowlist check, and… still do whatever else it wanted to do anyway. Geo-aware bsh is not an anti-malware product. + +What it **is**: + +- An **automation surface** for legitimate location-aware shell behaviour: kubeconfig switching, prompt indicators, per-zone aliases, on-arrival/on-departure scripts. +- A **privacy-preserving plumbing layer** so scripts can read location without it sprawling through `environ` and `ps`. +- A **friction layer** that catches honest mistakes ("you typed `kubectl delete` and you're in `coffee-shop`, please confirm") with an audit trail. +- A **clean integration with the BrightDate / BrightSpace / BrightSpaceTime stack** so consumer code can deserialize geo replies directly into the canonical Rust types without conversion drift. +- A **cryptographic anchor to device-bound hardware**, so an established user gets a TOFU pin for the bridge they registered against, raising the cost of impostor attacks against future sessions. + + +--- + +## 12. Protocol Security & Threat Mitigation Profiling + +This section covers SDI transport-layer threats. Geographic-context-specific privacy and exfil mitigations live in Β§11. + +- **Rogue Injection Defense.** If an external malicious source outputs an unauthorized OSC 7777 block to stdout, the Enclave Bridge intercepts the sequence (when the terminal emulator forwards it), attempts validation via the mapped session key, and fails immediately at the AES-GCM Authentication Tag verification phase. The packet is dropped instantly with zero user impact. + +- **Replay Defense.** The per-direction monotonic counters (Β§4.6.3), the AAD-bound `dir_tag` (Β§4.6.2), and the `issued_at` timestamp (Β§5) together ensure that a captured OSC 7777 sequence cannot be re-submitted to its receiver, cannot be cross-replayed in the opposite direction, and cannot be resurrected after expiry. Counter replay attempts are logged as security events. + +- **AAD Boundary Confusion Defense.** Length-prefixed AAD encoding (Β§4.6.2) including the leading direction tag prevents any two distinct `(dir, counter, type, context)` tuples from producing identical AAD bytes, closing the boundary confusion attack surface present in naive concatenation. + +- **Socket Squatting Defense.** Randomized geo-socket paths and pre-bind existence checks (Β§4.1, Β§7.2) prevent a malicious process from pre-occupying the socket path before the bridge starts. The EBP/1 socket itself is at a small set of well-known paths but is owned by the bridge process and inherits macOS user-account isolation; an attacker would need to race the bridge at startup and would still be visible to the SEP transcript-signature pin (Β§4.5.5). + +- **Process Ring-Fencing.** Because session encryption keys are isolated per terminal window or tab instance, background commands running concurrently in other workspaces cannot intercept, manipulate, or extract state data crossing adjacent active streams. + +- **Memory-Bound Persistence (Zero Leaks).** Decrypted states are stored exclusively within the volatile memory space of the Enclave Bridge daemon. Payloads automatically drop when their specified Time-To-Live metric expires or when the terminal session registers a clean exit sequence, avoiding long-term storage footprints. In particular, `K_session` is never persisted; bridge restart destroys all sessions. + +- **Fail-Closed Injection.** `bsh-inject` writes directly to `/dev/tty` and refuses to operate if the bridge is unavailable, preventing accidental ciphertext persistence or silent fallback to unprotected channels. + +- **Type Field Visibility.** Implementors are advised that the `type` field is visible in plaintext on the PTY stream. Sensitive schema classification should be encoded within the encrypted payload body (Β§4.6). + +- **Hardware-Anchored Registration.** The Β§4.5.3 transcript signature, produced by the SEP P-256 key, lets clients pin "the bridge that holds this SEP key" and detect future impostor bridges that lack it. See Β§9.4 for the precise scope of what this anchoring buys. + +- **Defense in Depth via `SecureEnclaveKeyring`.** The bridge consumer in `brightchain-api-lib` (`SecureEnclaveKeyring`, EBP/1 Β§9) can additionally store SDI-related secrets β€” including, e.g., the user's pinned SEP public-key hash β€” under a double-encryption envelope (password-AES-GCM inner layer, ECIES-to-the-bridge outer layer). Implementations that need to persist the pin across reinstalls SHOULD use this path. + +- **TOTP-Gated Sensitive Operations.** Where the bridge already implements EBP/1 TOTP (Β§4.13–4.14), implementations MAY require TOTP for `SDI_REGISTER` itself, raising the cost of a malicious local process establishing a session without the user's involvement. The reference implementation does not require this by default to keep `bsh-inject` ergonomic; it is a per-deployment policy choice. + +--- + +## 13. Implementation Ecosystem & Future Directions + +The reference implementation of this protocol is actively deployed and validated inside the BSH shell engine ecosystem (`bsh`), paired with the Swift-based macOS Enclave Bridge (the SwiftUI menu-bar process whose EBP/1 surface is documented in `enclave-bridge-protocol.md`). The bridge runs as a menu-bar background process, hosts the EBP/1 Unix domain socket, hosts the geo socket of Β§7, and routes decrypted payloads to registered application handlers and to PTYs via the Β§4.7 push channel. + +By standardizing structured semantic pipelines, we pave the way for downstream integrations β€” such as secure browser plug-ins that auto-populate active web testing fields based on localized terminal history context, and unified system tray utilities that bridge CLI development speed with the accessibility of modern desktop interface tooling. + +### 13.1 Browser Extension Integration β€” Security Considerations + +Browser extension integrations **must** be treated as a distinct, high-risk trust boundary. Browser extensions operate under a significantly weaker security model than native daemons: content scripts, extension messaging APIs, and browser-controlled sandbox policies introduce attack surfaces not present in the native IPC layer defined in this RFC. Any downstream integration that routes decrypted payloads to a browser extension **must** define its own security protocol, including: + +- A separate authenticated channel between the Enclave Bridge and the extension (e.g. native messaging with explicit host manifest allowlisting). +- Strict scoping of which payload types are permitted to flow to the browser layer. +- An explicit threat model addressing extension compromise, malicious content scripts, and cross-origin message interception. + +Treating browser extension delivery as a transparent extension of the native SDI pipeline is explicitly out of scope for this RFC and **must not** be assumed by implementors. + +### 13.2 Non-macOS Platforms + +The reference bridge is macOS Apple Silicon. A Linux or Windows port MUST: + +- Provide an EBP/1-compatible socket and command surface (`HEARTBEAT`, `GET_PUBLIC_KEY`, `ENCLAVE_DECRYPT`, `ENCLAVE_SIGN`, `LIST_KEYS`, `ENABLE_TOTP`/`EXPORT_KEY`). +- Anchor signing in a hardware key store where available: TPM 2.0 (Linux/Windows), Microsoft DPAPI/CNG (Windows), or fall back to software ECDSA over secp256r1 with the private key wrapped by a platform credential store. The Β§4.5.3 transcript signature is curve-agnostic in spirit β€” any P-256 verifier suffices, since the wire format already carries the DER signature β€” but the public key returned by `GET_ENCLAVE_PUBLIC_KEY` MUST be P-256 to match the EBP/1 expectation. +- Implement the geo socket of Β§7 with the same path-indirection scheme, peer-credential check, and allowlist semantics. + +A v3 client ported to a non-macOS platform MAY refuse to register if the bridge cannot produce a hardware-backed `transcriptSig`, or MAY accept software-backed signatures with a clear visual indicator. The reference `bsh-inject` accepts software-backed bridges and prints a one-time stderr notice during the first registration after install. + +### 13.3 Future Directions + +- **TLS-style cipher-suite negotiation in `SDI_REGISTER`.** Currently the cipher suite is fixed (secp256k1 + AES-256-GCM + SHA-256, matching EBP/1 cipher-suite byte `0x01`). A future v3.x or v4 MAY negotiate. +- **Pluggable HSM bridges.** A YubiKey- or Nitrokey-backed bridge that speaks EBP/1 over a local socket would allow v3 SDI sessions to anchor against an external token instead of the host's SEP. +- **End-to-end push relay.** v3 routes pushes through `SDI_PUSH` and the client writes them to its own PTY. Terminal emulators that wish to bypass the client (e.g. for OOB protocol updates that don't require the shell) MAY implement a direct push relay using the same OSC 7777 envelope; the cryptographic guarantees are unchanged. +- **Multi-bridge sessions.** A future revision MAY allow a single SDI session to span multiple bridges (e.g. one local SEP-backed bridge for signing, one cloud bridge for encrypted backup of audit logs). Out of scope for v3. + +--- + +## 14. Implementer's Checklist + +A new client implementation MUST: + +1. Discover the bridge socket via the EBP/1 Β§2.2 path order. +2. Speak EBP/1 framing (one JSON object per request, brace-terminated; binary fields Base64). +3. Perform `SDI_REGISTER` per Β§4.5, including verifying `transcriptSig` against a pinned (or TOFU-pinned) SEP public key. The outer envelope MUST conform byte-for-byte to the **DD-ECIES** Basic-mode wire format pinned in Β§4.5.0: version `0x01`, cipher suite `0x01`, encryption type `0x21`, **33-byte compressed** ephemeral key on the wire, 12-byte IV, 16-byte tag, AAD = `version β€– cipherSuite β€– type β€– ephemeralPublicKey` (no preamble), HKDF-SHA256 with `info = "ecies-v2-key-derivation"` and empty salt, IKM = 32-byte ECDH x-coordinate. +4. Maintain two per-direction monotonic counters (Β§4.6.3). +5. Construct AAD with the length-prefixed direction tag (Β§4.6.2). Note this is the SDI **OSC 7777** AAD; the **outer ECIES envelope** AAD is different and is defined by DD-ECIES via Β§4.5.0. +6. Use AES-256-GCM with 12-byte IVs and 16-byte tags throughout (both for the outer ECIES envelope and for OSC 7777 traffic). +7. Treat any `{"error": "..."}` response from the bridge as a recoverable protocol error, not a transport failure. +8. Detect bridge restart and re-register transparently (Β§4.8, Β§7.9). +9. Hash with SHA-256 before P-256 verification of `transcriptSig`, matching EBP/1 Β§4.9. +10. Fail closed: if the bridge is unavailable, emit no plaintext OSC 7777 sequences. +11. **Senders MUST emit only 33-byte compressed ephemeral keys** in `SDI_REGISTER` envelopes. Decoders MUST accept the DD-ECIES Β§5.3 backward-compat formats (33-byte compressed, 64-byte raw, 65-byte uncompressed) and normalize to 33-byte compressed before any cryptographic operation, but a sender that emits anything other than 33-byte compressed is non-conformant. +12. SHOULD link against `@digitaldefiance/ecies-lib` or `@digitaldefiance/node-ecies-lib` (the DD-ECIES reference implementations) rather than reimplementing the outer envelope. A from-scratch reimplementation MUST pass the DD-ECIES Β§18 test vectors (notably Β§18.4 ECDH+HKDF, Β§18.5 AES-256-GCM, and Β§18.6 Basic-mode envelope) as known-answer tests in CI before being considered interoperable. + +A new bridge implementation MUST additionally: + +1. Implement the EBP/1 commands required by Β§4.5–4.7 plus the optional Β§8 commands. +2. Bind the geo socket on a randomized path with the path-file indirection of Β§7.2. +3. Enforce peer-credential and allowlist checks on every geo-socket request (Β§7.4). +4. Maintain the audit log of Β§11.3 in volatile memory only. +5. Reload zone, allowlist, and trigger files on `SIGHUP` with permission validation. +6. Honour the rate limits of Β§4.4 and Β§7.4. +7. Sign every `SDI_REGISTER` transcript with the SEP P-256 key per Β§4.5.3. +8. Refuse to parse an SDI v1 or v2 raw handshake on its EBP/1 socket; return only the standard EBP/1 error envelope. + +--- + +## 15. References + +1. RFC 5869 β€” *HMAC-based Extract-and-Expand Key Derivation Function (HKDF)*. +2. RFC 6238 β€” *TOTP: Time-Based One-Time Password Algorithm*. +3. RFC 4226 β€” *HOTP: An HMAC-Based One-Time Password Algorithm*. +4. RFC 4648 β€” *The Base16, Base32, and Base64 Data Encodings*. +5. NIST SP 800-38D β€” *Recommendation for Block Cipher Modes of Operation: Galois/Counter Mode*. +6. SEC 1 β€” *Elliptic Curve Cryptography*, Standards for Efficient Cryptography Group. +7. Apple, *CryptoKit* documentation β€” `SecureEnclave.P256.Signing.PrivateKey`, `AES.GCM`, `HKDF`. +8. Canonical specification: **DD-ECIES Specification** (`DD-ECIES-SPEC-v1.0`) β€” `express-suite/packages/digitaldefiance-ecies-lib/docs/DD-ECIES-SPECIFICATION.md`. Authoritative wire-format and parameter specification for the outer envelope used by `SDI_REGISTER` (Β§4.5.0). When this paper and DD-ECIES disagree, DD-ECIES wins. +9. Companion paper: [Enclave Bridge Protocol (EBP/1)](enclave-bridge-protocol) β€” full specification of the bridge's command surface, ECIES wire format, and SEP key custody. +10. Companion paper: [ECIES-Lib](ecies-lib) β€” narrative description of the cross-platform `ecies-lib`/`node-ecies-lib` system whose wire format is normatively specified by reference 8. +11. Companion paper: [BrightDate Specification](brightdate-specification) β€” the BrightDate temporal foundation referenced by `geo-context` timestamps. +12. Companion paper: [Bright Space Standard](bright-space-standard) β€” the BrightSpace ECEF coordinate frame referenced by `geo-context`. +13. Companion paper: [Bright Spacetime Standard](bright-spacetime-standard) β€” the BrightSpaceTime mathematical framework referenced by the `spacetime` block of `geo-context`. +14. Predecessor: `rfc-sdi-osc7777.md` β€” SDI v1. +15. Predecessor: `rfc-sdi-osc7777-v2.md` β€” SDI v2. + +--- + +## Appendix A β€” Summary of Security Changes from Initial Draft + +| Issue | Resolution | +| --- | --- | +| No replay protection | Monotonic counter added to OSC sequence and AAD (Β§4.6, Β§4.6.3) | +| AAD boundary confusion via naive concatenation | Length-prefixed AAD encoding required (Β§4.6.2) | +| Predictable socket path enables squatting | Randomized socket path + pre-bind existence check required (Β§4.1, Β§7.2) | +| `bsh-inject` writes to stdout (risks log/pipe leakage) | Writes to `/dev/tty` by default; `--emit-stdout` is opt-in (Β§4.8) | +| No session expiry | 8-hour maximum session lifetime defined (Β§4.1) | +| No rate limiting on socket | 10 failed `SDI_REGISTER` attempts/min/PID limit required (Β§4.4); 30 failures/min/session for in-session OSC 7777 verification | +| `type` field confidentiality not addressed | Advisory note added; sensitive type should be inside encrypted body (Β§4.6) | +| Browser extension surface unaddressed | Explicit high-risk boundary callout with required separate protocol (Β§13.1) | +| No agent failure handling | Fail-closed behavior defined; no silent plaintext fallback (Β§4.8) | +| `issued_at` absent from schemas | Required in all payload schemas; bridge should validate against `ttl` (Β§5) | +| Wire protocol says "TCP connection" for a Unix socket | Corrected to "socket connection" throughout | +| No hardware anchoring of registration | SEP-signed transcript added in Β§4.5.3; TOFU pin recommended in Β§4.5.5 | +| Daemon sprawl (separate SDI agent) | Bridge fulfils SDI agent role; one menu-bar process serves both EBP/1 and SDI (Β§3, Β§4) | + +## Appendix B β€” Changes from v1, v2, and Cross-Walk to EBP/1 + +This appendix tracks every cross-version delta so an implementer porting from any prior version can find every change in one place. + +### B.1 v1 β†’ v2 (carried forward unchanged in v3) + +| v1 element | v2 disposition | Rationale | +| --- | --- | --- | +| Single-direction (Shell β†’ Agent) envelope | Bidirectional: Agent β†’ Shell pushes share the same envelope and key (Β§4.6). | Required by `geo-context` (push location updates) and any future agent-pushed payload type. | +| Single per-session monotonic counter | Two independent per-direction counters: `c_shell_to_agent`, `c_agent_to_shell` (Β§4.6.3). | A single counter with two writers cannot soundly distinguish legitimate emits from replays in the opposite direction. | +| AAD covers `(counter, type, context)` | AAD covers `(dir_tag, counter, type, context)` with `dir_tag` length-prefixed (Β§4.6.2). | Cross-direction replay is impossible: GCM tag verification fails when the receiver reconstructs AAD with its direction tag. | +| Handshake on dedicated SDI socket: 48 / 32 bytes | Handshake on dedicated SDI socket: 49 / 33 bytes; first byte is `protocol_version`. | Wire-level distinguishability of v1 and v2 sessions; agents could refuse mismatched versions before key derivation. | +| HKDF info string `"sdi-session-key"` | HKDF info string `"sdi-session-key-v2"`. | Domain-separates v1 and v2 keys so a version-mismatch handshake produces incompatible keys rather than silent cross-version traffic. | + +### B.2 v2 β†’ v3 + +| v2 element | v3 disposition | Rationale | +| --- | --- | --- | +| Dedicated SDIAgent daemon with its own X25519 socket | The Enclave Bridge is the SDI agent. Registration is an EBP/1 command (`SDI_REGISTER`, Β§4.5) on the bridge's existing Unix socket. | One running process instead of two; reuse the bridge's already-audited socket-discovery, sandbox containment, and TOFU surfaces. | +| X25519 ECDH + HKDF over a 48-byte raw frame | secp256k1 ECIES envelope + HKDF over two random 32-byte shares (`clientShare`, `bridgeShare`); Β§4.5.2 | Aligns the cryptographic substrate with EBP/1 (which already speaks secp256k1 via `node-ecies-lib`); avoids running two unrelated curve agreements in the same process. | +| No transcript authentication | SEP P-256 ECDSA over a 234-byte canonical transcript; Β§4.5.3 | Hardware-anchored TOFU pin against future impostor bridges; weak attestation that the bridge had SEP access at registration time. | +| HKDF info string `"sdi-session-key-v2"` | HKDF info string `"sdi-session-key-v3"` | Domain separation, same rationale as v1 β†’ v2. | +| Push channel via direct PTY-emulator integration | Push channel via `SDI_PUSH` (Β§4.7), client-pulled, written to `/dev/tty` by the receiving shell | Removes the requirement that the terminal emulator have an out-of-band agent-to-PTY API; works on any terminal that forwards OSC 7777. | +| `BSH_LATITUDE`, `BSH_LONGITUDE`, etc. environment names from `--exec` | `BSH_GEO_LATITUDE`, `BSH_GEO_LONGITUDE`, etc. (Β§7.7) | Reduces collision risk; clarifies origin. Implementations MAY accept either prefix at consumers for one release as a compatibility shim. | +| Geo socket lives at v2-defined `/run/user//sdi-agent...` | Geo socket lives under `~/.enclave/sdi-agent...` alongside the EBP/1 socket (Β§7.2) | Co-locates all bridge state under one directory; matches sandboxed-app conventions on macOS. | +| Transition events in the audit log | Same, plus `session_init`, `session_teardown`, transcript-signature prefixes (Β§11.3) | Lets reviewers correlate registration anomalies with later behaviour. | +| Trigger-file distribution unspecified | `command_jit` target list pushed via `SDI_PUSH` immediately after registration (Β§10.1) | Removes the file-read race between bridge and bsh; one source of truth. | + +v3 retains all v1/v2 security properties (Appendix A) and adds the above. Implementations that do not need bridge-anchored sessions MAY continue to target v2 indefinitely; v3 is required for any scenario where the SDI agent role is consolidated with an EBP/1 bridge or where hardware anchoring is desired. + +### B.3 Cross-Walk: SDI-EB ↔ EBP/1 + +For implementers fluent in EBP/1 who want to know exactly what SDI-EB adds, removes, or constrains: + +| Aspect | EBP/1 (alone) | SDI-EB (this RFC) | +| --- | --- | --- | +| Socket location | Per EBP/1 Β§2.2 | Identical (uses the same socket) | +| Framing | Brace-terminated JSON | Identical | +| `HEARTBEAT`, `VERSION`, `STATUS`, `METRICS`, `LIST_KEYS` | Defined | Used unchanged | +| `GET_PUBLIC_KEY`, `GET_ENCLAVE_PUBLIC_KEY` | Defined | Used unchanged for bridge-key fetch and SEP-key pin | +| `SET_PEER_PUBLIC_KEY` | Defined; per-connection state | SDI-EB does not require it; left for non-SDI uses | +| `ENCLAVE_SIGN` | Defined | Used internally by the bridge for `transcriptSig` (Β§4.5.3) | +| `ENCLAVE_DECRYPT` | Defined | Used internally by the bridge for `SDI_REGISTER` envelope decryption | +| `ENABLE_TOTP`, `EXPORT_KEY` | Defined (TOTP extension) | Optional gating layer for `SDI_REGISTER` (Β§12) | +| `SDI_REGISTER` | β€” | New (Β§4.5) | +| `SDI_PUSH` | β€” | New (Β§4.7) | +| `SDI_GEO_GET`, `SDI_GEO_STATUS`, `SDI_GEO_REFRESH`, `SDI_GEO_AUDIT`, `SDI_AUDIT_EMIT` | β€” | New (Β§8) | +| Geo socket | β€” | New (Β§7), separate from the EBP/1 socket | +| Rate limit on `SDI_REGISTER` | β€” | New (Β§4.4) | +| Per-session memory (`K_session`, `Session-ID`) | β€” | New (Β§4.3) | +| OSC 7777 wire format | β€” | New, but uses the same ECIES suite values as EBP/1 (Β§4.6) | +| Audit log | β€” | New (Β§11.3) | + +A bridge that implements EBP/1 only is **not** an SDI-EB bridge. A client that wishes to talk to such a bridge MUST handle `{"error":"Unknown command: SDI_REGISTER"}` gracefully and either (a) decline to use SDI features, or (b) fall back to a v2 SDI agent on a separate socket. + +--- + +## Appendix C β€” Tier 2 Enforcement (Informational, Non-Normative) + +This appendix sketches how a future privileged component would deliver actual prevention of command execution, sharing this RFC's policy file format and `geo-context` payload schema. + +### C.1 macOS: EndpointSecurity Client + +A separate daemon, sibling to the Enclave Bridge, registers as an EndpointSecurity client and authorizes `ES_EVENT_TYPE_AUTH_EXEC` events. On each exec: + +1. ES daemon receives the event with full process metadata. +2. Daemon consults a policy file (format compatible with Β§6.1 zone definitions plus an `enforce-on:` clause naming executables to gate). +3. Daemon queries the Enclave Bridge (via a privileged EBP/1 connection, or via a shared-memory cache populated by the bridge) for the current `geo-context`. +4. If the policy denies, daemon returns `ES_AUTH_RESULT_DENY` and the kernel never starts the process. + +This requires the `com.apple.developer.endpoint-security.client` entitlement, root privileges, system-extension installation, and notarization. Latency budget is tight (hundreds of microseconds) and fail-open vs fail-closed semantics need careful design. The Tier 2 daemon MAY use the SEP signature from `SDI_REGISTER` as evidence of a healthy bridge before trusting bridge-supplied geo data; absent a valid signature, the daemon SHOULD fail closed (deny exec) for any policy that depends on geo state. + +### C.2 Linux: eBPF LSM or fanotify + +An eBPF LSM program attached to `bprm_check_security` can deny `execve` based on the same policy. fanotify with `FAN_OPEN_EXEC_PERM` is a more portable alternative. Both require root / `CAP_SYS_ADMIN`. + +### C.3 Windows: Kernel Mode Mini-Filter / Authentication Package + +A Windows port would use a kernel-mode driver (mini-filter or process-creation callback registered via `PsSetCreateProcessNotifyRoutineEx`) to gate `CreateProcess`. The driver consults a user-mode bridge over a local pipe. + +### C.4 What Tier 2 Inherits From This RFC + +- The `~/.config/bsh/geo-zones` format (or a `/etc/` system equivalent for sysadmin-imposed policy). +- The `geo-context` payload schema (Β§5.3.1). +- The bridge's coordinate-conversion authority. +- The provenance and presence semantics. +- The Β§4.5.3 transcript signature, used as a "bridge health" indicator before trusting bridge-supplied state. + +What changes for Tier 2: + +- The policy file lives under `/etc/` and is root-owned, mode `0644`. +- The verdict applies to *every* `execve` system-wide, regardless of which shell or interpreter initiated it. Bsh becomes one consumer of the policy among many. +- `enforce-on:` clauses become real enforcement, not advisory friction. + +Tier 2 is a **separate project**. Its protocol and ABI compatibility with Tier 1 are intentional design goals so that Tier 1's authoring effort is not wasted when Tier 2 ships. + +--- + +## Appendix D β€” Open Questions + +1. **`bsh-geo --exec` `BSH_GEO_*` env names.** v3 settled on `BSH_GEO_*` to reduce collision risk vs. v2's bare `BSH_LATITUDE` etc. Open: should we additionally prefix with `BSH_SDI_` to mark the SDI provenance? Lean: no, `BSH_GEO_*` is enough; the source is documented in this RFC and `bsh-geo --exec` is the only emitter. + +2. **Multi-zone overlap.** What if the user is in `office` and `building-7` simultaneously (overlapping radii)? Β§6.3 says `$BSH_GEO_ZONE` is the *newest entered*; `$BSH_GEO_ZONES` enumerates all matched. Confirmed reasonable, but worth a usability pass once we have a real user. + +3. **Allowlist hash algorithm agility.** Β§7.4 specifies SHA-256. Future-proofing against post-quantum / collision concerns suggests `algo=sha256:` syntax allowing future `sha3-256:`, `blake3:`, etc. Cheap to add now; lean toward `algo=` prefix syntax, keeping `sha256=` as a deprecated shorthand for one release. + +4. **`setopt BSH_GEO_NOEXPORT` semantics.** Β§7.2 sketches an option to keep `BSH_GEO_SOCK` out of the exported environment entirely. Open: under that option, what happens for indirect children (e.g. a script invoked by a script that itself wasn't a direct `bsh-geo --exec` invocation)? The cleanest answer is "they don't have geo access," which is also the most surprising. Alternative: a small forwarding table keyed by session ID, served by the bridge, so any process under the user's bsh process tree can still reach the bridge if it explicitly asks. Punting until we know whether anyone actually wants this option. + +5. **Trigger script PATH.** Β§6.4 sanitized environment passes through `PATH`. Should it instead set a known-good PATH (`/usr/local/bin:/usr/bin:/bin`)? Pro: stops `PATH` injection from a parent compromise. Con: surprises users whose triggers depend on their PATH. Lean: known-good PATH, document the override. + +6. **TOTP gating of `SDI_REGISTER`.** Β§12 mentions TOTP-gating `SDI_REGISTER` itself as a per-deployment policy. Open: should the bridge advertise its policy via `STATUS` or `LIST_KEYS` so a client can know in advance whether to prompt the user? Lean: yes, add a `sdiRequiresTotp` boolean to `STATUS` in a future minor revision. + +7. **Multi-bridge scenarios.** A laptop user on a desk with both an internal SEP bridge and an external YubiKey-backed bridge. Which one should `SDI_REGISTER` go to? Currently undefined; clients pick the first reachable socket per EBP/1 Β§2.2. Future revisions MAY allow the user to express preference via environment variable or config file. + +These are deliberately punted to review rather than guessed at. + +**Resolved during v3 review** (no longer open): + +- ~~Override exit code~~ β€” settled at `124` in Β§6.5.2 (carried from v2). +- ~~`geo-context` over OSC 7777 vs registration socket~~ β€” pushes go over OSC 7777 with v3's bidirectional envelope, transported via `SDI_PUSH`; registration is one-shot per Β§4.5. +- ~~`bsh-geo --json` stability~~ β€” append-only across versions per Β§7.10. +- ~~`--audit` output format~~ β€” JSON-Lines by default, `--human` for interactive inspection (Β§11.3). +- ~~Trigger filename plurality~~ β€” `~/.config/bsh/geo-triggers` (plural) per Β§10.1. +- ~~How to deliver agent-pushed sequences~~ β€” `SDI_PUSH` event frames, client writes to `/dev/tty` (Β§4.7). +- ~~Where to store `K_session`~~ β€” bridge memory only, destroyed on restart or expiry (Β§4.1). + +--- + +## Appendix E β€” Worked Examples + +### E.1 End-to-end registration walkthrough + +Client side, pseudocode: + +```text +client_priv, client_pub = secp256k1.generate() # 32 / 65 bytes +client_share = random(32) +client_nonce = random(16) + +bridge_pub = ebp1.GET_PUBLIC_KEY() # 65 bytes uncompressed +sep_pub = ebp1.GET_ENCLAVE_PUBLIC_KEY() # 65 bytes uncompressed (pinned) + +plaintext = json({ + v: 3, + clientPub: base64(client_pub), + clientShare: base64(client_share), + issuedAtBd: bd_now(), + ttlSeconds: 28800, + agent: { name: "bsh", version: "1.4.2", platform: "darwin-arm64" } +}) + +envelope = ecies_basic_encrypt(bridge_pub, utf8(plaintext)) # node-ecies-lib + +response = ebp1.send({ + cmd: "SDI_REGISTER", + protocolVersion: 3, + clientNonce: base64(client_nonce), + envelope: base64(envelope) +}) +# response = { ok, sessionId, bridgeIssuedAtUnix, ttlSeconds, responseEnvelope, transcriptSig } + +bridge_share = ecies_basic_decrypt(client_priv, base64_decode(response.responseEnvelope)) +session_id = base64_decode(response.sessionId) # 16 bytes + +# Build canonical transcript +T = b"SDI-EB v3 transcript\0" +T += LE32(len(client_nonce)) + client_nonce +T += LE32(len(client_pub)) + client_pub +T += LE32(len(client_share)) + client_share +T += LE32(len(session_id)) + session_id +T += LE32(len(bridge_share)) + bridge_share +T += LE32(8) + u64_be(round(plaintext.issuedAtBd * 86400)) +T += LE32(8) + u64_be(response.bridgeIssuedAtUnix) +T += LE32(4) + u32_be(response.ttlSeconds) + +assert verify_p256(sep_pub, sha256(T), base64_decode(response.transcriptSig)) + +# Derive K_session +K_session = HKDF_SHA256( + IKM = client_share + bridge_share, + salt = client_nonce + session_id, + info = b"sdi-session-key-v3", + L = 32, +) + +zero(client_share); zero(bridge_share); zero(client_priv) +store(session_id, K_session, expires_at = response.bridgeIssuedAtUnix + response.ttlSeconds) +``` + +### E.2 Emit one OSC 7777 sequence (Shell β†’ Agent) + +```text +counter_be = u64_be(c_shell_to_agent); c_shell_to_agent += 1 +iv = random(12) +type = b"ephemeral-auth" +ctx = b"http://localhost:3005" + +aad = LE32(1) + b"\x01" # dir_tag = 0x01 (Shellβ†’Agent) + || LE32(8) + counter_be + || LE32(len(type)) + type + || LE32(len(ctx)) + ctx + +payload = utf8(json({type, context, ttl, issued_at, data})) +ct, tag = aes256_gcm_encrypt(K_session, iv, aad, payload) + +# Emit on /dev/tty (NOT stdout): +write_tty( "\x1b]7777;" + + hex(session_id) + ";" + + base64(counter_be) + ";" + + utf8_str(type) + ";" + + base64(ctx) + ";" + + base64(iv) + ";" + + base64(ct) + ";" + + base64(tag) + + "\x07" ) +``` + +### E.3 Receive a push and write it to `/dev/tty` + +```text +# On the persistent SDI_PUSH subscription: +event = ebp1.recv() +if event.event == "push": + write_tty(unescape_unicode(event.sequence)) # \u001b β†’ ESC, \u0007 β†’ BEL +``` + +The shell's own input parser then sees a normal OSC 7777 sequence and runs the Β§4.6 decrypt path with `dir_tag = 0x02` (Agent β†’ Shell). + +### E.4 Verify a `geo-context` push (`Agent β†’ Shell`) + +```text +parsed = parse_osc_7777(input_bytes) # session_id_hex, counter_b64, type, ctx_b64, iv_b64, ct_b64, tag_b64 + +session_id = hex_decode(parsed.session_id_hex) +counter = base64_decode(parsed.counter_b64) # 8 bytes big-endian +ct = base64_decode(parsed.ct_b64) +tag = base64_decode(parsed.tag_b64) +iv = base64_decode(parsed.iv_b64) +ctx = base64_decode(parsed.ctx_b64) +type = parsed.type.encode() + +aad = LE32(1) + b"\x02" # dir_tag = 0x02 (Agentβ†’Shell) + || LE32(8) + counter + || LE32(len(type)) + type + || LE32(len(ctx)) + ctx + +assert in_replay_window(c_agent_to_shell_last, big_endian_u64(counter)) +plaintext = aes256_gcm_decrypt(K_session, iv, aad, ct, tag) +c_agent_to_shell_last = max(c_agent_to_shell_last, big_endian_u64(counter)) + +geo = json_parse(plaintext) +if geo.error: handle_failure(geo) +else: update_zone_state(geo) +``` + +--- + +## Appendix F β€” Reference File Map + +| File | Role | +|---|---| +| `enclave/EnclaveBridge/SocketServer.swift` | EBP/1 `AF_UNIX` socket lifecycle, accept loop, framing on `}`. Hosts SDI commands. | +| `enclave/EnclaveBridge/BridgeProtocolHandler.swift` | EBP/1 command dispatch, ECIES decrypt parser, `SDI_REGISTER` / `SDI_PUSH` / `SDI_GEO_*` handlers (v3 additions). | +| `enclave/EnclaveBridge/ECIES.swift` | secp256k1 ECDH, HKDF, AES-256-GCM. Used by both EBP/1 ECIES and SDI session derivation. | +| `enclave/EnclaveBridge/SecureEnclaveKeyManager.swift` | SEP P-256 key generation and signing. Used for `transcriptSig` in `SDI_REGISTER`. | +| `enclave/EnclaveBridge/ECIESKeyManager.swift` | secp256k1 private key persistence (`~/.enclave/ecies-privkey.bin`). | +| `enclave/EnclaveBridge/TOTPManager.swift` | RFC 6238 TOTP for optional `EXPORT_KEY` / `SDI_REGISTER` gating. | +| `enclave/EnclaveBridge/AppState.swift` | Connection and key inventory state. v3 adds session inventory: `(sessionId, K_session, expiresAt)` per registered connection. | +| `enclave/EnclaveBridge/GeoSocketServer.swift` | Geo socket of Β§7. Path indirection, peer-credential check, allowlist. | +| `enclave/EnclaveBridge/AuditLog.swift` | In-memory rolling audit log of Β§11.3. | +| `enclave-bridge-client/src/index.ts` | Client class extended with `sdiRegister`, `sdiSubscribePush`, `sdiGeoGet`, etc. | +| `enclave-bridge-client/src/sdi.ts` | OSC 7777 wire helpers: serialize, parse, AAD construction, counter management. | +| `enclave-bridge-client/src/sdi-keys.ts` | `K_session` derivation per Β§4.5.2; transcript construction and verification per Β§4.5.3. | +| `bsh/builtin/inject.bsh` | The `bsh-inject` builtin (Β§4.8). | +| `bsh/builtin/geo.bsh` | The `bsh-geo` and `bsh-geo-override` helpers (Β§7.6, Β§6.5.4). | +| `brightchain-api-lib/src/lib/secureEnclaveKeyring.ts` | Optional persistence layer for SDI-related secrets (e.g. pinned SEP fingerprint). | + +--- + +*This specification is informational. The reference implementations live in the `enclave/`, `enclave-bridge-client/`, and `bsh/` trees. Discrepancies between this document and the source are bugs in this document; please file an issue.* diff --git a/package.json b/package.json index daaaaa88..e4dfd253 100644 --- a/package.json +++ b/package.json @@ -168,7 +168,7 @@ "@brightchain/brightchain-lib": "^0.32.1", "@brightchain/brightchain-react-components": "^0.32.1", "@brightchain/brightchat-lib": "^0.32.1", - "@brightchain/brightdate": "^0.34.0", + "@brightchain/brightdate": "^0.36.0", "@brightchain/brighthub-lib": "^0.32.1", "@brightchain/brighthub-react-components": "^0.32.1", "@brightchain/brightmail-lib": "^0.32.1", @@ -367,7 +367,7 @@ "globals": "^17.0.0", "jest": "30.0.5", "jest-environment-jsdom": "30.0.5", - "jest-environment-node": "30.0.5", + "jest-environment-node": "30.4.1", "jest-util": "30.2.0", "jiti": "2.4.2", "jsdom": "~22.1.0", diff --git a/showcase/package.json b/showcase/package.json index 3f0574d2..89d81132 100644 --- a/showcase/package.json +++ b/showcase/package.json @@ -15,7 +15,7 @@ "dependencies": { "@awesome.me/kit-a20d532681": "^1.0.23", "@brightchain/brightchain-lib": "^0.32.1", - "@brightchain/brightdate": "^0.34.0", + "@brightchain/brightdate": "^0.36.0", "@brightchain/digitalburnbag-react-components": "^0.32.1", "@digitaldefiance/ecies-lib": "5.2.2", "@digitaldefiance/i18n-lib": "4.7.5", diff --git a/yarn.lock b/yarn.lock index 41251cb7..9f0ea4d6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2566,7 +2566,7 @@ __metadata: version: 0.0.0-use.local resolution: "@brightchain/brightchain-lib@workspace:brightchain-lib" dependencies: - "@brightchain/brightdate": "npm:^0.34.0" + "@brightchain/brightdate": "npm:^0.36.0" "@digitaldefiance/branded-interface": "npm:0.0.5" "@digitaldefiance/bzip2-wasm": "npm:^1.1.1" "@digitaldefiance/ecies-lib": "npm:5.2.2" @@ -2597,7 +2597,7 @@ __metadata: email-addresses: "npm:^5.0.0" expect: "npm:^30.3.0" express: "npm:>=5.0.0" - jest-environment-node: "npm:^30.3.0" + jest-environment-node: "npm:^30.4.1" jest-mock: "npm:^30.3.0" nat-pmp: "npm:^1.0.0" nat-upnp: "npm:^1.1.1" @@ -2698,12 +2698,12 @@ __metadata: languageName: unknown linkType: soft -"@brightchain/brightdate@npm:^0.34.0": - version: 0.34.0 - resolution: "@brightchain/brightdate@npm:0.34.0" +"@brightchain/brightdate@npm:^0.36.0": + version: 0.36.0 + resolution: "@brightchain/brightdate@npm:0.36.0" dependencies: tslib: "npm:^2.3.0" - checksum: 10/08bd22344bc895ff4f505ebd08b32f5b82e6916d0fb2c12fcc2fab772135957a8f2462a81ca05967e0fe52a57cdc682f2cd1f216afd71e8461da08367cb893a0 + checksum: 10/ffb8d241eefa386c09aa6a96ddf61592480b1b264c482d67c18a9758d3df9e77c258b8cd491ac75bfa3a7b5178d7fba5770d145fb9fd65d2dc7d69e0ffc3fb54 languageName: node linkType: hard @@ -2850,7 +2850,7 @@ __metadata: react-router-dom: "npm:^7.14.0" peerDependencies: "@brightchain/brightchain-lib": ^0.32.1 - "@brightchain/brightdate": ^0.34.0 + "@brightchain/brightdate": ^0.36.0 "@brightchain/brightmail-lib": ^0.32.1 "@digitaldefiance/express-suite-react-components": 4.30.1 "@mui/icons-material": "*" @@ -5000,6 +5000,18 @@ __metadata: languageName: node linkType: hard +"@jest/environment@npm:30.4.1": + version: 30.4.1 + resolution: "@jest/environment@npm:30.4.1" + dependencies: + "@jest/fake-timers": "npm:30.4.1" + "@jest/types": "npm:30.4.1" + "@types/node": "npm:*" + jest-mock: "npm:30.4.1" + checksum: 10/c25946fee29604f5aa24ea059bc3cc7bc4c8cdaf26db1ed6ffa4f28e37f5193cc4e868650c807d89caff4123e44d07b58200d4cb5960ebdb7d66531509d76359 + languageName: node + linkType: hard + "@jest/expect-utils@npm:30.0.5": version: 30.0.5 resolution: "@jest/expect-utils@npm:30.0.5" @@ -5066,6 +5078,20 @@ __metadata: languageName: node linkType: hard +"@jest/fake-timers@npm:30.4.1": + version: 30.4.1 + resolution: "@jest/fake-timers@npm:30.4.1" + dependencies: + "@jest/types": "npm:30.4.1" + "@sinonjs/fake-timers": "npm:^15.4.0" + "@types/node": "npm:*" + jest-message-util: "npm:30.4.1" + jest-mock: "npm:30.4.1" + jest-util: "npm:30.4.1" + checksum: 10/bc7aff23548395d6e7957bc24f699f921a9616f2357ab49616b0468c7b5e94e6ac4cbdd45d306f1a5d7f72e2a055294f52be3666e4c1da7c137874c5b226e1c6 + languageName: node + linkType: hard + "@jest/get-type@npm:30.0.1": version: 30.0.1 resolution: "@jest/get-type@npm:30.0.1" @@ -5114,6 +5140,16 @@ __metadata: languageName: node linkType: hard +"@jest/pattern@npm:30.4.0": + version: 30.4.0 + resolution: "@jest/pattern@npm:30.4.0" + dependencies: + "@types/node": "npm:*" + jest-regex-util: "npm:30.4.0" + checksum: 10/4fb1db0e586713708d2fcd79059315600978608483ef2d80e04a0a59b20b0d8de0d3f47cad950ff90bfb9ea3cb788709ee3d1eb225734e4dbf1c4b743c93d204 + languageName: node + linkType: hard + "@jest/reporters@npm:30.0.5": version: 30.0.5 resolution: "@jest/reporters@npm:30.0.5" @@ -5195,6 +5231,15 @@ __metadata: languageName: node linkType: hard +"@jest/schemas@npm:30.4.1": + version: 30.4.1 + resolution: "@jest/schemas@npm:30.4.1" + dependencies: + "@sinclair/typebox": "npm:^0.34.0" + checksum: 10/86e62c8fd8fc77535085f1ede3a416430a3740f78b8f88ec7d0ee4516b22daf3326ffc1ade9d5f7839bbde923aaf1b5ac430a42ed4bb1a38edc3de5005a58f51 + languageName: node + linkType: hard + "@jest/schemas@npm:^29.6.3": version: 29.6.3 resolution: "@jest/schemas@npm:29.6.3" @@ -5377,6 +5422,21 @@ __metadata: languageName: node linkType: hard +"@jest/types@npm:30.4.1": + version: 30.4.1 + resolution: "@jest/types@npm:30.4.1" + dependencies: + "@jest/pattern": "npm:30.4.0" + "@jest/schemas": "npm:30.4.1" + "@types/istanbul-lib-coverage": "npm:^2.0.6" + "@types/istanbul-reports": "npm:^3.0.4" + "@types/node": "npm:*" + "@types/yargs": "npm:^17.0.33" + chalk: "npm:^4.1.2" + checksum: 10/cc0999508613487c6d0f55661cd342ebe7cfe579fa9917534b94310204358f03f94524f70f00b4fe3c6dd2ccd0fd44657615a1b9f420ab310d68b43964bff87c + languageName: node + linkType: hard + "@jest/types@npm:^29.6.3": version: 29.6.3 resolution: "@jest/types@npm:29.6.3" @@ -9713,6 +9773,15 @@ __metadata: languageName: node linkType: hard +"@sinonjs/fake-timers@npm:^15.4.0": + version: 15.4.0 + resolution: "@sinonjs/fake-timers@npm:15.4.0" + dependencies: + "@sinonjs/commons": "npm:^3.0.1" + checksum: 10/3960a9fe065f38a4228c66d184eeb101e8a6af9cbfc8454dd5d45ac397201da72134048d4e808a25993494885b172dd6deecdad9949bbf4c1d3a220ef561f6cc + languageName: node + linkType: hard + "@smithy/chunked-blob-reader-native@npm:^4.2.3": version: 4.2.3 resolution: "@smithy/chunked-blob-reader-native@npm:4.2.3" @@ -15121,7 +15190,7 @@ __metadata: dependencies: "@awesome.me/kit-a20d532681": "npm:^1.0.23" "@brightchain/brightchain-lib": "npm:^0.32.1" - "@brightchain/brightdate": "npm:^0.34.0" + "@brightchain/brightdate": "npm:^0.36.0" "@brightchain/digitalburnbag-react-components": "npm:^0.32.1" "@digitaldefiance/ecies-lib": "npm:5.2.2" "@digitaldefiance/i18n-lib": "npm:4.7.5" @@ -15215,7 +15284,7 @@ __metadata: "@brightchain/brightchain-lib": "npm:^0.32.1" "@brightchain/brightchain-react-components": "npm:^0.32.1" "@brightchain/brightchat-lib": "npm:^0.32.1" - "@brightchain/brightdate": "npm:^0.34.0" + "@brightchain/brightdate": "npm:^0.36.0" "@brightchain/brighthub-lib": "npm:^0.32.1" "@brightchain/brighthub-react-components": "npm:^0.32.1" "@brightchain/brightmail-lib": "npm:^0.32.1" @@ -15369,7 +15438,7 @@ __metadata: i18next-http-backend: "npm:^3.0.1" jest: "npm:30.0.5" jest-environment-jsdom: "npm:30.0.5" - jest-environment-node: "npm:30.0.5" + jest-environment-node: "npm:30.4.1" jest-util: "npm:30.2.0" jiti: "npm:2.4.2" js-sha3: "npm:^0.9.3" @@ -22590,7 +22659,7 @@ __metadata: languageName: node linkType: hard -"jest-environment-node@npm:30.3.0, jest-environment-node@npm:^30.3.0": +"jest-environment-node@npm:30.3.0": version: 30.3.0 resolution: "jest-environment-node@npm:30.3.0" dependencies: @@ -22605,6 +22674,21 @@ __metadata: languageName: node linkType: hard +"jest-environment-node@npm:30.4.1, jest-environment-node@npm:^30.4.1": + version: 30.4.1 + resolution: "jest-environment-node@npm:30.4.1" + dependencies: + "@jest/environment": "npm:30.4.1" + "@jest/fake-timers": "npm:30.4.1" + "@jest/types": "npm:30.4.1" + "@types/node": "npm:*" + jest-mock: "npm:30.4.1" + jest-util: "npm:30.4.1" + jest-validate: "npm:30.4.1" + checksum: 10/b93157061c6fa4b62808741bdda5fbc0372a9d2a3db844f0ffb819ed104b3b5c6ff73375d9f57c0d8485a0ad6aed07d4cfbb98aca985c38e1a29f64096b940bc + languageName: node + linkType: hard + "jest-get-type@npm:^29.6.3": version: 29.6.3 resolution: "jest-get-type@npm:29.6.3" @@ -22734,6 +22818,24 @@ __metadata: languageName: node linkType: hard +"jest-message-util@npm:30.4.1": + version: 30.4.1 + resolution: "jest-message-util@npm:30.4.1" + dependencies: + "@babel/code-frame": "npm:^7.27.1" + "@jest/types": "npm:30.4.1" + "@types/stack-utils": "npm:^2.0.3" + chalk: "npm:^4.1.2" + graceful-fs: "npm:^4.2.11" + jest-util: "npm:30.4.1" + picomatch: "npm:^4.0.3" + pretty-format: "npm:30.4.1" + slash: "npm:^3.0.0" + stack-utils: "npm:^2.0.6" + checksum: 10/f83894efa37aa9c61c0a559b1027ecdb0d0cd8afd3e8ea74e797c707d58daea814e72f04b6db0bb6a148c12ae203e9c6e6c5544832ca5fae286c4f80c18ddc3f + languageName: node + linkType: hard + "jest-mock@npm:30.0.5": version: 30.0.5 resolution: "jest-mock@npm:30.0.5" @@ -22756,6 +22858,17 @@ __metadata: languageName: node linkType: hard +"jest-mock@npm:30.4.1": + version: 30.4.1 + resolution: "jest-mock@npm:30.4.1" + dependencies: + "@jest/types": "npm:30.4.1" + "@types/node": "npm:*" + jest-util: "npm:30.4.1" + checksum: 10/8d0c2794130217b9030b888ce380fe57d82388eec19351bd666440ba46f1e24a7e2bdf42cbe9bcfda2b881d4c0ea09db3c80131b9ab788fb5224af2a1339b422 + languageName: node + linkType: hard + "jest-pnp-resolver@npm:^1.2.3": version: 1.2.3 resolution: "jest-pnp-resolver@npm:1.2.3" @@ -22775,6 +22888,13 @@ __metadata: languageName: node linkType: hard +"jest-regex-util@npm:30.4.0": + version: 30.4.0 + resolution: "jest-regex-util@npm:30.4.0" + checksum: 10/8664fcc1d07c8236a3bd012c0f06ae9d14d96e758b32ee340a3a7c4c326d0b5052d8c4ae4f4c4184f08bf78723d905352f22923647df9658ace3604f03bf074f + languageName: node + linkType: hard + "jest-resolve-dependencies@npm:30.0.5": version: 30.0.5 resolution: "jest-resolve-dependencies@npm:30.0.5" @@ -23037,6 +23157,20 @@ __metadata: languageName: node linkType: hard +"jest-util@npm:30.4.1": + version: 30.4.1 + resolution: "jest-util@npm:30.4.1" + dependencies: + "@jest/types": "npm:30.4.1" + "@types/node": "npm:*" + chalk: "npm:^4.1.2" + ci-info: "npm:^4.2.0" + graceful-fs: "npm:^4.2.11" + picomatch: "npm:^4.0.3" + checksum: 10/603093e12076906afcf28be514d5b7ac4e3c0e26997b0047614cf2a308b65d773137304a1fb011d747517e881aeed067f6606b9937f5b838d67f6e5734b49ebe + languageName: node + linkType: hard + "jest-util@npm:^29.7.0": version: 29.7.0 resolution: "jest-util@npm:29.7.0" @@ -23079,6 +23213,20 @@ __metadata: languageName: node linkType: hard +"jest-validate@npm:30.4.1": + version: 30.4.1 + resolution: "jest-validate@npm:30.4.1" + dependencies: + "@jest/get-type": "npm:30.1.0" + "@jest/types": "npm:30.4.1" + camelcase: "npm:^6.3.0" + chalk: "npm:^4.1.2" + leven: "npm:^3.1.0" + pretty-format: "npm:30.4.1" + checksum: 10/527fe8ad02df9a4f7f467ecc4e3ac2a37a27b7b30345e7bc3cb9c2ead33fbc8ed1290c0827baa06471281012c38abb96cb268af274a0a2350548e50db20a434f + languageName: node + linkType: hard + "jest-watcher@npm:30.0.5": version: 30.0.5 resolution: "jest-watcher@npm:30.0.5" @@ -28015,6 +28163,18 @@ __metadata: languageName: node linkType: hard +"pretty-format@npm:30.4.1": + version: 30.4.1 + resolution: "pretty-format@npm:30.4.1" + dependencies: + "@jest/schemas": "npm:30.4.1" + ansi-styles: "npm:^5.2.0" + react-is-18: "npm:react-is@^18.3.1" + react-is-19: "npm:react-is@^19.2.5" + checksum: 10/60311ef47a646eeaec0432efe66290cb6f0d2eccb123a28ad4ab6d7e53087bc62db91cfd54c3cc00c89d6875aefb2bf6264381b6c9411ce6bff3d6aa8280abad + languageName: node + linkType: hard + "pretty-format@npm:^27.0.2": version: 27.5.1 resolution: "pretty-format@npm:27.5.1" @@ -28565,6 +28725,20 @@ __metadata: languageName: node linkType: hard +"react-is-18@npm:react-is@^18.3.1, react-is@npm:^18.0.0, react-is@npm:^18.3.1": + version: 18.3.1 + resolution: "react-is@npm:18.3.1" + checksum: 10/d5f60c87d285af24b1e1e7eaeb123ec256c3c8bdea7061ab3932e3e14685708221bf234ec50b21e10dd07f008f1b966a2730a0ce4ff67905b3872ff2042aec22 + languageName: node + linkType: hard + +"react-is-19@npm:react-is@^19.2.5, react-is@npm:^19.2.3, react-is@npm:^19.2.5": + version: 19.2.5 + resolution: "react-is@npm:19.2.5" + checksum: 10/b2e18d4efd39496474956684a3757c43b9102af56add174abf2a46a6c1441dbdfe5fa7d9e7d7ebb42f543ffc9c7941fc74eb1e2bfdbf08979ea9e2ccc36da600 + languageName: node + linkType: hard + "react-is@npm:^16.13.1, react-is@npm:^16.7.0": version: 16.13.1 resolution: "react-is@npm:16.13.1" @@ -28579,20 +28753,6 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^18.0.0, react-is@npm:^18.3.1": - version: 18.3.1 - resolution: "react-is@npm:18.3.1" - checksum: 10/d5f60c87d285af24b1e1e7eaeb123ec256c3c8bdea7061ab3932e3e14685708221bf234ec50b21e10dd07f008f1b966a2730a0ce4ff67905b3872ff2042aec22 - languageName: node - linkType: hard - -"react-is@npm:^19.2.3, react-is@npm:^19.2.5": - version: 19.2.5 - resolution: "react-is@npm:19.2.5" - checksum: 10/b2e18d4efd39496474956684a3757c43b9102af56add174abf2a46a6c1441dbdfe5fa7d9e7d7ebb42f543ffc9c7941fc74eb1e2bfdbf08979ea9e2ccc36da600 - languageName: node - linkType: hard - "react-markdown@npm:^10.1.0, react-markdown@npm:~10.1.0": version: 10.1.0 resolution: "react-markdown@npm:10.1.0"