[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
130
infrastructure/services/syncSignature.js
Normal file
130
infrastructure/services/syncSignature.js
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* syncSignature - Provider-agnostic remote snapshot fingerprint.
|
||||
*
|
||||
* Stable, order-independent signature of a SyncedFile used by
|
||||
* CloudSyncManager to decide whether a remote has changed since we last
|
||||
* observed it. Must produce the same value for semantically-identical
|
||||
* remotes and a different value for any ciphertext/metadata change.
|
||||
*
|
||||
* Kept as a plain ESM .js file (JSDoc-typed) so it works seamlessly with
|
||||
* both Vite's bundler in the renderer AND Node's `node --test` harness
|
||||
* without needing a TypeScript test runner. CloudSyncManager.ts imports
|
||||
* it via a normal ESM import.
|
||||
*
|
||||
* The previous implementation in CloudSyncManager only hashed
|
||||
* `[version, updatedAt, deviceId, iv, salt]`. That meant:
|
||||
* - a misbehaving adapter could replay those five fields while
|
||||
* mutating algorithm/kdf/appVersion and the anchor would treat the
|
||||
* remote as unchanged;
|
||||
* - deviceId (a field the remote controls) was weighted as strongly
|
||||
* as iv/salt;
|
||||
* - ciphertext changes with metadata held constant could slip past.
|
||||
*
|
||||
* v3 hashes the full meta object (sorted for stability) plus the
|
||||
* SHA-256 of the full payload ciphertext so any of those mutations flip
|
||||
* the anchor. v2 used only a 64-char prefix of the ciphertext, which is
|
||||
* easily defeated by an adversary that controls the remote and can
|
||||
* tail-mutate while preserving the prefix. v3 is resistant to any
|
||||
* ciphertext mutation.
|
||||
*
|
||||
* Version prefixes are part of the signature string itself (`v3:`) so
|
||||
* an older anchor persisted from a previous build will simply never
|
||||
* compare equal to a fresh signature from this build, forcing a
|
||||
* single-cycle safe re-detection (treated as "remote changed" which
|
||||
* triggers three-way merge) rather than a silent mismatch.
|
||||
*
|
||||
* INVARIANT: `meta` values must be primitives (strings, numbers,
|
||||
* booleans, null/undefined). Nested objects or arrays in meta would
|
||||
* serialize via JSON.stringify, which does NOT sort keys — breaking
|
||||
* signature stability. All current SyncedFile meta fields satisfy this.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Sentinel error for a missing WebCrypto subtle digest — see
|
||||
* `sha256Hex` and `createSyncedFileSignature` for the fail-closed
|
||||
* handling.
|
||||
*/
|
||||
class SyncSignatureUnavailableError extends Error {
|
||||
constructor() {
|
||||
super('WebCrypto subtle.digest is unavailable; signature cannot be computed safely.');
|
||||
this.name = 'SyncSignatureUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute SHA-256 of a UTF-8 string, returning lowercase hex.
|
||||
*
|
||||
* Uses `globalThis.crypto.subtle` (Web Crypto API) which is available in
|
||||
* both the Electron renderer and Node.js ≥ 19 (the repo's runtime targets
|
||||
* both, and CI/tests run under Node). Keeping to the Web Crypto API also
|
||||
* avoids pulling `node:crypto` into the renderer bundle.
|
||||
*
|
||||
* Throws `SyncSignatureUnavailableError` when subtle.digest is missing.
|
||||
* Earlier revisions returned a length-only fallback string (`nosha-N`),
|
||||
* which would produce a short, truncation-trivial pseudo-signature that
|
||||
* an attacker controlling the remote could alias against a legitimate
|
||||
* v3 signature of the same length. Failing loudly here lets the caller
|
||||
* in `createSyncedFileSignature` return `null`, which routes through
|
||||
* the "unreadable remote → treat as changed → three-way merge or
|
||||
* surface decrypt error" path — strictly safer than a weak signature.
|
||||
*
|
||||
* @param {string} input
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function sha256Hex(input) {
|
||||
const subtle = globalThis.crypto?.subtle;
|
||||
if (!subtle?.digest) {
|
||||
throw new SyncSignatureUnavailableError();
|
||||
}
|
||||
const bytes = new globalThis.TextEncoder().encode(input);
|
||||
const buf = await subtle.digest('SHA-256', bytes);
|
||||
const arr = new Uint8Array(buf);
|
||||
let hex = '';
|
||||
for (let i = 0; i < arr.length; i += 1) {
|
||||
hex += arr[i].toString(16).padStart(2, '0');
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('../../domain/sync').SyncedFile | null} syncedFile
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
export async function createSyncedFileSignature(syncedFile) {
|
||||
if (!syncedFile) return null;
|
||||
const { meta, payload } = syncedFile;
|
||||
if (!meta || typeof meta !== 'object') return null;
|
||||
|
||||
// Serialize meta as a canonical JSON object with keys sorted. Earlier
|
||||
// versions joined `${key}=${JSON.stringify(...)}` with `|`, which left
|
||||
// the `=` separator unescaped: a future meta key containing `=` in its
|
||||
// name (or a string value that mimics the separator syntax) could
|
||||
// alias with a different key/value pair. JSON.stringify of a sorted
|
||||
// plain object is injection-proof because string values are quoted
|
||||
// and escaped by the serializer.
|
||||
const metaKeys = Object.keys(meta).sort();
|
||||
const canonicalMeta = {};
|
||||
for (const key of metaKeys) {
|
||||
canonicalMeta[key] = meta[key] ?? null;
|
||||
}
|
||||
const metaSerialized = JSON.stringify(canonicalMeta);
|
||||
|
||||
const payloadStr = typeof payload === 'string' ? payload : '';
|
||||
const payloadLen = payloadStr.length;
|
||||
let payloadHash;
|
||||
try {
|
||||
payloadHash = payloadStr ? await sha256Hex(payloadStr) : 'empty';
|
||||
} catch (error) {
|
||||
if (error instanceof SyncSignatureUnavailableError) {
|
||||
// Fail closed: no signature → decideRemoteChanged's
|
||||
// `currentSignature === null` branch treats the remote as
|
||||
// "unreadable" and routes through three-way merge. That is the
|
||||
// safe behavior vs. a weak pseudo-signature that could silently
|
||||
// alias against another payload of the same length.
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return `v3:${metaSerialized}|len=${payloadLen}|sha256=${payloadHash}`;
|
||||
}
|
||||
Reference in New Issue
Block a user