Use 11-character signatures and detect headers across WBR

This commit is contained in:
2026-09-17 22:10:12 -07:00
parent a312b1f58a
commit f8935c656c
24 changed files with 363 additions and 101 deletions

View File

@@ -1,18 +0,0 @@
# Building inØsight
## Prerequisites
- Node.js (v20+ or v22+)
- npm (v10+)
## Step-by-Step Build Instructions
1. Install dependencies:
```bash
npm ci
```
2. Build the Firefox extension:
```bash
npm run build:firefox
```
3. The generated add-on files will be in `dist/firefox/`.

View File

@@ -199,41 +199,44 @@ function onAction(entry, parsed) {
} }
} }
function getPayloadRange(node, base, start) { function acrossWbr(node, side) {
let endNode = node; let sibling = node[side], hasWbr = false;
let end = getPayloadEnd(node.nodeValue, base, start); while (sibling?.nodeType === Node.ELEMENT_NODE && sibling.tagName === 'WBR') {
hasWbr = true;
// Gmail inserts <wbr> elements into long zero-width runs, splitting one payload across text nodes. sibling = sibling[side];
while (end === endNode.nodeValue.length) {
let next = endNode.nextSibling;
let hasWbr = false;
while (next?.nodeType === Node.ELEMENT_NODE && next.tagName === 'WBR') {
hasWbr = true;
next = next.nextSibling;
}
if (!hasWbr || next?.nodeType !== Node.TEXT_NODE) break;
const nextEnd = getPayloadEnd(next.nodeValue, base, 0);
if (!nextEnd) break;
endNode = next;
end = nextEnd;
} }
return { endNode, end }; return hasWbr && sibling?.nodeType === Node.TEXT_NODE ? sibling : null;
} }
function scanNode(node) { function scanNode(node) {
if (!node || node.nodeType !== Node.TEXT_NODE) return; if (!node || node.nodeType !== Node.TEXT_NODE) return;
const val = node.nodeValue; for (let prev; (prev = acrossWbr(node, 'previousSibling'));) node = prev;
const nodes = [node];
let val = node.nodeValue;
for (let next; (next = acrossWbr(nodes.at(-1), 'nextSibling'));) {
nodes.push(next);
val += next.nodeValue;
}
if (!val || !val.includes(SIG_PREFIX)) return; if (!val || !val.includes(SIG_PREFIX)) return;
const point = (offset, atEnd) => {
for (const current of nodes) {
if (offset < current.nodeValue.length || (atEnd && offset === current.nodeValue.length))
return { node: current, offset };
offset -= current.nodeValue.length;
}
return { node: nodes.at(-1), offset: nodes.at(-1).nodeValue.length };
};
let idx = 0; let idx = 0;
while (idx < val.length) { while (idx < val.length) {
const sub = val.slice(idx); const p = parseSig(val.slice(idx));
const p = parseSig(sub);
if (!p) break; if (!p) break;
const start = idx + p.sigIdx; const start = idx + p.sigIdx;
const { endNode, end } = getPayloadRange(node, p.base, start + p.sigLen); const end = getPayloadEnd(val, p.base, start + p.sigLen);
createOverlay(node, p, start, endNode, end); const from = point(start, false), to = point(end, true);
idx = endNode === node ? end + 1 : val.length; createOverlay(from.node, p, from.offset, to.node, to.offset);
idx = end + 1;
} }
} }
@@ -244,7 +247,7 @@ function scanTree(root) {
acceptNode: n => (ign[n.parentElement?.tagName] ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT) acceptNode: n => (ign[n.parentElement?.tagName] ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT)
}); });
let n; let n;
while ((n = walker.nextNode())) scanNode(n); while ((n = walker.nextNode())) if (!acrossWbr(n, 'previousSibling')) scanNode(n);
} }
scanTree(document.body); scanTree(document.body);
@@ -260,7 +263,10 @@ function scheduleUpdate() {
const obs = new MutationObserver(muts => { const obs = new MutationObserver(muts => {
for (const e of [...active]) { for (const e of [...active]) {
if (!e.node.isConnected || !e.node.nodeValue?.startsWith(SIG_PREFIX, e.start)) { let prefix = e.node.nodeValue?.slice(e.start) || '';
for (let next = e.node; prefix.length < SIG_PREFIX.length &&
(next = acrossWbr(next, 'nextSibling'));) prefix += next.nodeValue;
if (!e.node.isConnected || !prefix.startsWith(SIG_PREFIX)) {
e.wrap.remove(); e.wrap.remove();
active.delete(e); active.delete(e);
} }
@@ -269,7 +275,10 @@ const obs = new MutationObserver(muts => {
if (m.type === 'characterData') scanNode(m.target); if (m.type === 'characterData') scanNode(m.target);
else for (const an of m.addedNodes) { else for (const an of m.addedNodes) {
if (an.nodeType === Node.TEXT_NODE) scanNode(an); if (an.nodeType === Node.TEXT_NODE) scanNode(an);
else if (an.nodeType === Node.ELEMENT_NODE && an.id !== 'in0-host') scanTree(an); else if (an.nodeType === Node.ELEMENT_NODE && an.tagName === 'WBR') {
scanNode(an.previousSibling);
scanNode(an.nextSibling);
} else if (an.nodeType === Node.ELEMENT_NODE && an.id !== 'in0-host') scanTree(an);
} }
} }
scheduleUpdate(); scheduleUpdate();

89
content.test.js Normal file
View File

@@ -0,0 +1,89 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import zwus from 'zwus';
import { makeSig } from './sig.js';
test('page overlay detects headers split by WBR and decodes across the split', async () => {
const buttons = [], inserted = [], fixtures = [], texts = [];
const rect = { top: 10, bottom: 20, left: 10, right: 20, width: 10, height: 10 };
const body = { tagName: 'BODY', appendChild() {} };
const parent = { tagName: 'DIV', parentElement: body, getBoundingClientRect: () => rect };
let observer;
const element = tag => ({
tagName: tag.toUpperCase(), nodeType: 1, style: {}, offsetWidth: 0, offsetHeight: 0,
appendChild(child) { child.parentElement = this; },
attachShadow() { return element('shadow'); },
getBoundingClientRect: () => rect,
remove() { this.removed = true; }
});
const addRun = (encoded, cut, cipher) => {
const parts = [encoded.slice(0, cut), encoded.slice(cut, 13), encoded.slice(13)];
const nodes = parts.flatMap((part, i) => i ? [element('wbr'), {
nodeType: 3, nodeValue: part, parentElement: parent, isConnected: true
}] : [{ nodeType: 3, nodeValue: part, parentElement: parent, isConnected: true }]);
nodes.forEach((node, i) => {
node.previousSibling = nodes[i - 1] || null;
node.nextSibling = nodes[i + 1] || null;
if (node.nodeType === 3) texts.push(node);
});
fixtures.push({ nodes, cipher });
};
for (const base of [3, 6, 7]) for (const cipher of
['PLAIN', 'SPECK48_96CTR', 'SPECK32_64ECB (insecure)']) {
const payload = cipher === 'PLAIN' ? zwus.encodeString('hello', base) :
zwus.encodeNumberArray([1, 2, 3], base);
for (const cut of [1, 2, 3, 5, 6, 10]) addRun(makeSig(base, cipher) + payload, cut, cipher);
}
globalThis.Node = { ELEMENT_NODE: 1, TEXT_NODE: 3 };
globalThis.NodeFilter = { SHOW_TEXT: 4, FILTER_ACCEPT: 1, FILTER_REJECT: 2 };
globalThis.document = {
body, documentElement: body,
createElement(tag) { const node = element(tag); if (tag === 'button') buttons.push(node); return node; },
createTreeWalker() {
let i = 0;
return { nextNode: () => texts[i++] || null };
},
createRange() {
return {
setStart(node, offset) { this.startNode = node; this.start = offset; },
setEnd(node, offset) { this.endNode = node; this.end = offset; },
getBoundingClientRect: () => rect,
toString() {
let value = '', node = this.startNode;
while (node) {
if (node.nodeType === 3) value += node.nodeValue.slice(
node === this.startNode ? this.start : 0,
node === this.endNode ? this.end : undefined
);
if (node === this.endNode) break;
node = node.nextSibling;
}
return value;
},
deleteContents() {},
insertNode(node) { inserted.push(node); }
};
}
};
globalThis.window = {
innerHeight: 100, innerWidth: 100, addEventListener() {},
getComputedStyle: () => ({ overflow: 'visible', overflowX: 'visible', overflowY: 'visible' })
};
globalThis.MutationObserver = class {
constructor(callback) { this.callback = callback; observer = this; }
observe() {}
};
globalThis.requestAnimationFrame = callback => { callback(); return 1; };
await import('./content.js');
assert.equal(buttons.length, fixtures.length);
fixtures.forEach(({ cipher }, i) =>
assert.equal(buttons[i].textContent, cipher === 'PLAIN' ? 'Decode' : 'Decrypt'));
observer.callback([{ type: 'childList', addedNodes: [] }]);
assert.equal(buttons.some(button => button.parentElement.removed), false);
buttons[0].onclick();
assert.equal(inserted[0].textContent, ' hello ');
});

File diff suppressed because one or more lines are too long

View File

@@ -7,7 +7,7 @@
<script type="module" crossorigin src="/index.js"></script> <script type="module" crossorigin src="/index.js"></script>
<link rel="stylesheet" crossorigin href="/index.css"> <link rel="stylesheet" crossorigin href="/index.css">
</head> </head>
<body> <body>
<div id="overbar"><p>inØsight 3.3.0<span id="sigDetect"></span><a id="homepage" href="https://github.com/inzerosight/inzerosight" target="_blank" rel="noopener">source</a></p></div> <div id="overbar"><p>inØsight 3.3.0<span id="sigDetect"></span><a id="homepage" href="https://github.com/inzerosight/inzerosight" target="_blank" rel="noopener">source</a></p></div>
<textarea id="textarea" placeholder="input text here..."></textarea> <textarea id="textarea" placeholder="input text here..."></textarea>
<input id="encodeButton" type="button" name="button" value="encode to clipboard"/> <input id="encodeButton" type="button" name="button" value="encode to clipboard"/>

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"name":"inØsight","version":"3.2.0","author":"planetrenox@pm.me","homepage_url":"https://github.com/inzerosight/inzerosight","description":"Communicate undetected in plain sight.","icons":{"48":"icon_500.png"},"permissions":["clipboardWrite"],"manifest_version":3,"action":{"default_icon":{"48":"icon_500.png"},"default_title":"inØsight","default_popup":"index.html"},"content_scripts":[{"matches":["<all_urls>"],"js":["content.js"],"run_at":"document_idle"}]} {"name":"inØsight","version":"3.3.0","author":"planetrenox@pm.me","homepage_url":"https://github.com/inzerosight/inzerosight","description":"Communicate undetected in plain sight.","icons":{"48":"icon_500.png"},"permissions":["clipboardWrite"],"manifest_version":3,"action":{"default_icon":{"48":"icon_500.png"},"default_title":"inØsight","default_popup":"index.html"},"content_scripts":[{"matches":["<all_urls>"],"js":["content.js"],"run_at":"document_idle"}]}

File diff suppressed because one or more lines are too long

View File

@@ -7,7 +7,7 @@
<script type="module" crossorigin src="/index.js"></script> <script type="module" crossorigin src="/index.js"></script>
<link rel="stylesheet" crossorigin href="/index.css"> <link rel="stylesheet" crossorigin href="/index.css">
</head> </head>
<body> <body>
<div id="overbar"><p>inØsight 3.3.0<span id="sigDetect"></span><a id="homepage" href="https://github.com/inzerosight/inzerosight" target="_blank" rel="noopener">source</a></p></div> <div id="overbar"><p>inØsight 3.3.0<span id="sigDetect"></span><a id="homepage" href="https://github.com/inzerosight/inzerosight" target="_blank" rel="noopener">source</a></p></div>
<textarea id="textarea" placeholder="input text here..."></textarea> <textarea id="textarea" placeholder="input text here..."></textarea>
<input id="encodeButton" type="button" name="button" value="encode to clipboard"/> <input id="encodeButton" type="button" name="button" value="encode to clipboard"/>

File diff suppressed because one or more lines are too long

View File

@@ -1 +1 @@
{"name":"inØsight","version":"3.2.0","author":"planetrenox@pm.me","homepage_url":"https://github.com/inzerosight/inzerosight","description":"Communicate undetected in plain sight.","icons":{"48":"icon_500.png"},"permissions":["clipboardWrite"],"manifest_version":2,"browser_action":{"browser_style":false,"default_icon":"icon_500.png","default_title":"inØsight","default_popup":"index.html"},"content_security_policy":"script-src 'self'; style-src 'self';","browser_specific_settings":{"gecko":{"id":"{0a73f41c-c59c-404b-9e07-f7392fa830d4}"}},"content_scripts":[{"matches":["<all_urls>"],"js":["content.js"],"run_at":"document_idle"}]} {"name":"inØsight","version":"3.3.0","author":"planetrenox@pm.me","homepage_url":"https://github.com/inzerosight/inzerosight","description":"Communicate undetected in plain sight.","icons":{"48":"icon_500.png"},"permissions":["clipboardWrite"],"manifest_version":2,"browser_action":{"browser_style":false,"default_icon":"icon_500.png","default_title":"inØsight","default_popup":"index.html"},"content_security_policy":"script-src 'self'; style-src 'self';","browser_specific_settings":{"gecko":{"id":"{0a73f41c-c59c-404b-9e07-f7392fa830d4}"}},"content_scripts":[{"matches":["<all_urls>"],"js":["content.js"],"run_at":"document_idle"}]}

Binary file not shown.

Binary file not shown.

Binary file not shown.

4
dist/web/assets/index-DlLeEfTp.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/web/index.html vendored
View File

@@ -3,11 +3,11 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<script type="module" crossorigin src="/assets/index-G7fqkm88.js"></script> <script type="module" crossorigin src="/assets/index-DlLeEfTp.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DbbumV8Y.css"> <link rel="stylesheet" crossorigin href="/assets/index-DbbumV8Y.css">
</head> </head>
<body> <body>
<div id="overbar"><p>inØsight 3.2.0<span id="sigDetect"></span><a id="homepage" href="https://github.com/inzerosight/inzerosight" target="_blank" rel="noopener">source</a></p></div> <div id="overbar"><p>inØsight 3.3.0<span id="sigDetect"></span><a id="homepage" href="https://github.com/inzerosight/inzerosight" target="_blank" rel="noopener">source</a></p></div>
<textarea id="textarea" placeholder="input text here..."></textarea> <textarea id="textarea" placeholder="input text here..."></textarea>
<input id="encodeButton" type="button" name="button" value="encode to clipboard"/> <input id="encodeButton" type="button" name="button" value="encode to clipboard"/>
<input id="decodeButton" type="button" name="button" value="decode from text"/> <input id="decodeButton" type="button" name="button" value="decode from text"/>

View File

@@ -15,6 +15,30 @@ Choosing the right ZWUS base depends on platform compatibility.
If you enable **Sign** when encoding, an invisible signature is attached to the secret message. When decoding—either in the extension popup or via the on-screen overlay—inØsight automatically identifies the base and cipher. If you enable **Sign** when encoding, an invisible signature is attached to the secret message. When decoding—either in the extension popup or via the on-screen overlay—inØsight automatically identifies the base and cipher.
### Signature format
New signatures contain exactly **11 zero-width characters**:
`standard header (5) + unifier (1) + zero digit (1) + mode ID (4)`
The five-character standard headers are unchanged. The unifier and digits use the selected standard's alphabet. Mode IDs are encoded in that standard's base and padded to exactly four digits:
| Mode ID | Mode |
| :--- | :--- |
| `0000` | PLAIN |
| `0001` | SPECK48_96CTR |
| `0002` | SPECK32_64ECB (insecure) |
Detection requires an exact match to one of the nine registered signatures (three standards × three modes). Unknown IDs and partial headers are not modern signatures. Characters after a matched signature belong to the payload; they never extend the signature. PLAIN uses the same header format as the ciphers but does not encrypt the payload.
### Legacy signatures
For compatibility, inØsight still reads the old five-character PLAIN headers and ten-character encrypted headers for ZWUS-3, ZWUS-6, and ZWUS-7. New encoding always writes the 11-character format. Complete modern signatures take priority within an uninterrupted zero-width run. Legacy messages separated from modern messages by visible text remain independently detectable.
Legacy recognition is isolated in `LEGACY_SIGNATURES` and `parseLegacySig` in `sig.js`, with explicitly labeled legacy tests. To retire it, remove that registry, reader, legacy tests, and the `legacyPlain` condition in `findSig`, then make `parseSig` use only `parseModernSig`. Future standards must not be added to the legacy registry.
Incomplete or unknown extended headers do not fall back to legacy PLAIN. Exact legacy encrypted headers remain recognized, with everything after their tenth character treated as payload.
--- ---
## Unicode Alphabet by Standard ## Unicode Alphabet by Standard

View File

@@ -6,7 +6,7 @@
<link rel="stylesheet" href="style.css"> <link rel="stylesheet" href="style.css">
</head> </head>
<body> <body>
<div id="overbar"><p>inØsight 3.2.0<span id="sigDetect"></span><a id="homepage" href="https://github.com/inzerosight/inzerosight" target="_blank" rel="noopener">source</a></p></div> <div id="overbar"><p>inØsight 3.3.0<span id="sigDetect"></span><a id="homepage" href="https://github.com/inzerosight/inzerosight" target="_blank" rel="noopener">source</a></p></div>
<textarea id="textarea" placeholder="input text here..."></textarea> <textarea id="textarea" placeholder="input text here..."></textarea>
<input id="encodeButton" type="button" name="button" value="encode to clipboard"/> <input id="encodeButton" type="button" name="button" value="encode to clipboard"/>
<input id="decodeButton" type="button" name="button" value="decode from text"/> <input id="decodeButton" type="button" name="button" value="decode from text"/>

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "inzerosight", "name": "inzerosight",
"version": "3.2.0", "version": "3.3.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "inzerosight", "name": "inzerosight",
"version": "3.2.0", "version": "3.3.0",
"dependencies": { "dependencies": {
"blakejs": "1.2.1", "blakejs": "1.2.1",
"generic-speck": "1.1.1", "generic-speck": "1.1.1",

View File

@@ -1,9 +1,10 @@
{ {
"name": "inzerosight", "name": "inzerosight",
"version": "3.2.0", "version": "3.3.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"test": "bun test sig.test.js content.test.js",
"dev": "vite --mode chrome", "dev": "vite --mode chrome",
"build:web": "vite build --mode web", "build:web": "vite build --mode web",
"build:firefox": "vite build --mode firefox", "build:firefox": "vite build --mode firefox",

74
sig.js
View File

@@ -9,6 +9,7 @@ export const SIG = {
}; };
export const CIPHERS = { export const CIPHERS = {
0: 'PLAIN',
1: 'SPECK48_96CTR', 1: 'SPECK48_96CTR',
2: 'SPECK32_64ECB (insecure)' 2: 'SPECK32_64ECB (insecure)'
}; };
@@ -17,36 +18,59 @@ export const CIPHER_TO_ID = Object.fromEntries(
Object.entries(CIPHERS).map(([k, v]) => [v, +k]) Object.entries(CIPHERS).map(([k, v]) => [v, +k])
); );
const SIGNATURES = Object.entries(SIG).flatMap(([base, prefix]) =>
Object.entries(CIPHERS).map(([id, cipher]) => {
const digits = (+id).toString(base).padStart(4, '0');
if (digits.length !== 4) throw new RangeError(`Cipher ID ${id} exceeds ZWUS-${base}'s signature capacity`);
const sig = prefix + zwus[base].unifier + zwus[base][0] +
Array.from(digits, d => zwus[base][d]).join('');
if (sig.length !== 11) throw new RangeError('Signatures must contain exactly 11 characters');
return { base, cipher, sig, legacy: false };
})
);
export function makeSig(base, cipher) { export function makeSig(base, cipher) {
let s = SIG[base]; const entry = SIGNATURES.find(s => s.base === String(base) && s.cipher === cipher);
const id = CIPHER_TO_ID[cipher]; if (!entry) throw new RangeError(`Unsupported signature: ZWUS-${base}, ${cipher}`);
if (id) { return entry.sig;
const zwDigits = Array.from(id.toString(base).padStart(3, '0'), d => zwus[base][d]).join('');
s += zwus[base].unifier + zwus[base][0] + zwDigits;
}
return s;
} }
export function parseSig(text) { function findSig(text, signatures) {
let sigIdx = text.indexOf(SIG_PREFIX), base; let sigIdx = text.indexOf(SIG_PREFIX);
while (sigIdx !== -1) { while (sigIdx !== -1) {
base = Object.keys(SIG).find(b => text.startsWith(SIG[b], sigIdx)); const entry = signatures.find(s => text.startsWith(s.sig, sigIdx) &&
if (base) break; (!s.legacyPlain || text[sigIdx + s.sig.length] !== zwus[s.base].unifier));
sigIdx = text.indexOf(SIG_PREFIX, sigIdx + SIG_PREFIX.length); if (entry) {
const { base, cipher, sig, legacy } = entry;
return { base, cipher, payload: text.slice(sigIdx + sig.length), sigIdx, sigLen: sig.length, legacy };
}
sigIdx = text.indexOf(SIG_PREFIX, sigIdx + 1);
} }
if (!base) return null; return null;
const after = text.slice(sigIdx + SIG[base].length); }
const barrier = zwus[base].unifier + zwus[base][0];
if (after.startsWith(barrier)) { // Current format: match only complete, registered 11-character signatures.
const zwDigits = Array.from(after.slice(barrier.length, barrier.length + 3)); export const parseModernSig = text => findSig(text, SIGNATURES);
const digits = zwDigits.map(z => Object.keys(zwus[base]).find(k => zwus[base][k] === z)).join('');
const cipher = CIPHERS[parseInt(digits, base)]; // Legacy compatibility: remove this registry, reader, and parseSig fallback together.
if (!cipher) return { base, cipher: 'PLAIN', payload: after, sigIdx, sigLen: SIG[base].length }; // Keep the supported bases and cipher IDs frozen; future standards use only the current format.
const payload = after.slice(barrier.length + 3); const LEGACY_SIGNATURES = [3, 6, 7].flatMap(base => [
const sigLen = SIG[base].length + barrier.length + 3; ...[1, 2].map(id => ({
return { base, cipher, payload, sigIdx, sigLen }; base: String(base), cipher: CIPHERS[id], legacy: true,
} sig: SIG[base] + zwus[base].unifier + zwus[base][0].repeat(3) + zwus[base][id]
return { base, cipher: 'PLAIN', payload: after, sigIdx, sigLen: SIG[base].length }; })),
{ base: String(base), cipher: 'PLAIN', sig: SIG[base], legacy: true, legacyPlain: true }
]);
// Legacy plaintext never starts with a unifier; do not downgrade incomplete/unknown extensions.
export const parseLegacySig = text => findSig(text, LEGACY_SIGNATURES);
export function parseSig(text) {
const modern = parseModernSig(text), legacy = parseLegacySig(text);
if (!modern) return legacy;
// Legacy messages separated by visible text still need their own overlay.
if (legacy && legacy.sigIdx < modern.sigIdx &&
getPayloadEnd(text, legacy.base, legacy.sigIdx + legacy.sigLen) < modern.sigIdx) return legacy;
return modern;
} }
export function getPayloadEnd(text, base, startOffset) { export function getPayloadEnd(text, base, startOffset) {

133
sig.test.js Normal file
View File

@@ -0,0 +1,133 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import zwus from 'zwus';
import * as chunked from './chunked.js';
import * as ctr from './speck48_96ctr.js';
import * as ecb from './speck32_64ecb.js';
import { makeSig, parseSig, parseModernSig, parseLegacySig, getPayloadEnd } from './sig.js';
// Frozen wire-format fixtures, independent of the signature registry.
const HEADERS = {
3: '\u200D\u200B\u00AD\u180E\u200D',
6: '\u200D\u200B\u00AD\u200C\u200D',
7: '\u200D\u200B\u00AD\u200C\u200C'
};
const MODES = ['PLAIN', 'SPECK48_96CTR', 'SPECK32_64ECB (insecure)'];
const MESSAGE = 'tt\0 Hello, 世界 🌍\n';
const key = 'signature regression';
for (const base of [3, 6, 7]) {
const z = zwus[base], header = HEADERS[base];
const extended = digits => header + z.unifier + z[0] +
Array.from(digits, d => z[d]).join('');
test(`ZWUS-${base}: exact 11-character headers and round trips for every mode`, () => {
for (const [id, cipher] of MODES.entries()) {
const expected = extended('000' + id);
assert.equal(makeSig(base, cipher), expected);
assert.equal(makeSig(String(base), cipher).length, 11);
const engine = [null, ctr, ecb][id];
const payload = engine ? chunked.encodeNumberArray(engine.encrypt(MESSAGE, engine.getKey(key)), base) :
chunked.encodeString(MESSAGE, base);
const parsed = parseSig('visible ' + expected + payload + ' suffix');
assert.deepEqual(parsed, {
base: String(base), cipher, payload: payload + ' suffix', sigIdx: 8, sigLen: 11, legacy: false
});
const start = parsed.sigIdx + parsed.sigLen;
const source = 'visible ' + expected + payload + ' suffix';
const end = getPayloadEnd(source, base, start);
assert.equal(source.slice(start, end), payload);
const decoded = engine ? engine.decrypt(chunked.decodeToNumberArray(parsed.payload, base), engine.getKey(key)) :
chunked.decodeToString(parsed.payload, base);
assert.equal(decoded, MESSAGE);
}
});
test(`ZWUS-${base}: modern reader rejects every unregistered four-digit ID`, () => {
for (let id = 3; id < base ** 4; id++)
assert.equal(parseModernSig(extended(id.toString(base).padStart(4, '0'))), null, `ID ${id}`);
});
test(`ZWUS-${base}: modern reader requires every character to match`, () => {
const sig = extended('0000');
for (let end = 0; end < sig.length; end++)
assert.equal(parseModernSig(sig.slice(0, end)), null);
for (let i = 0; i < sig.length; i++)
assert.equal(parseModernSig(sig.slice(0, i) + 'x' + sig.slice(i + 1)), null);
assert.equal(parseModernSig(extended('10000')), null);
assert.equal(parseSig(extended('0000') + z[2]).sigLen, 11);
assert.equal(parseSig(extended('0000') + z[2]).payload, z[2]);
});
test(`ZWUS-${base}: malformed extensions do not fall back to legacy PLAIN`, () => {
for (const tail of [z.unifier, z.unifier + z[0], z.unifier + z[0].repeat(4),
z.unifier + z[0] + 'xxx', z.unifier + z[0] + z[2].repeat(4)])
assert.equal(parseSig(header + tail), null);
});
test(`ZWUS-${base}: legacy five-character PLAIN stays readable`, () => {
const payload = zwus.encodeString(MESSAGE, base);
const parsed = parseSig(header + payload);
assert.deepEqual(parsed, {
base: String(base), cipher: 'PLAIN', payload, sigIdx: 0, sigLen: 5, legacy: true
});
assert.equal(zwus.decodeToString(parsed.payload, base), MESSAGE);
assert.equal(parseModernSig(header + payload), null);
});
test(`ZWUS-${base}: legacy ten-character encrypted signatures stay readable`, () => {
for (const [id, engine] of [[1, ctr], [2, ecb]]) {
const sig = extended('00' + id);
const payload = zwus.encodeNumberArray(engine.encrypt(MESSAGE, engine.getKey(key)), base);
const parsed = parseSig(sig + payload);
assert.equal(parsed.cipher, MODES[id]);
assert.equal(parsed.sigLen, 10);
assert.equal(parsed.legacy, true);
assert.equal(parsed.payload, payload);
assert.equal(engine.decrypt(zwus.decodeToNumberArray(parsed.payload, base), engine.getKey(key)), MESSAGE);
// A legacy header's next digit is payload, even if all 11 characters resemble a future ID.
assert.equal(parseLegacySig(sig + z[0]).payload, z[0]);
}
});
test(`ZWUS-${base}: complete modern signatures take priority and preserve offsets`, () => {
const sig = extended('0000'), payload = zwus.encodeString('hello', base);
const prefix = header + z.unifier + 'broken ';
const parsed = parseSig(prefix + sig + payload);
assert.equal(parsed.sigIdx, prefix.length);
assert.equal(parsed.legacy, false);
assert.equal(parsed.payload, payload);
});
test(`ZWUS-${base}: legacy and modern messages separated by visible text remain discoverable`, () => {
const payload = zwus.encodeString('hello', base);
const legacy = header + payload, modern = extended('0000') + payload;
const text = legacy + ' and ' + modern;
const first = parseSig(text);
assert.equal(first.legacy, true);
assert.equal(first.sigIdx, 0);
const end = getPayloadEnd(text, base, first.sigLen);
assert.equal(end, legacy.length);
assert.equal(parseSig(text.slice(end)).legacy, false);
});
test(`ZWUS-${base}: empty and chunked payloads preserve their contents`, () => {
const sig = makeSig(base, 'PLAIN');
assert.equal(parseSig(sig).payload, '');
const text = 't'.repeat(65535) + '🌍\0';
assert.equal(chunked.decodeToString(parseSig(sig + chunked.encodeString(text, base)).payload, base), text);
});
}
test('unknown standards and modes cannot generate signatures', () => {
assert.throws(() => makeSig(8, 'PLAIN'), RangeError);
assert.throws(() => makeSig(7, 'unknown'), RangeError);
assert.throws(() => makeSig(7), RangeError);
});
test('legacy cross-standard collision does not match a modern signature', () => {
const text = zwus.encodeString('ŵt', 7);
assert.equal(parseModernSig(text), null);
assert.equal(parseLegacySig(text).base, '6');
assert.equal(parseSig(text + makeSig(7, 'PLAIN')).legacy, false);
});

View File

@@ -12,7 +12,7 @@ export default defineConfig(async ({ mode }) => {
manifest: () => { manifest: () => {
const base = { const base = {
name: "in\u00D8sight", name: "in\u00D8sight",
version: "3.2.0", version: "3.3.0",
author: "planetrenox@pm.me", author: "planetrenox@pm.me",
homepage_url: "https://github.com/inzerosight/inzerosight", homepage_url: "https://github.com/inzerosight/inzerosight",
description: "Communicate undetected in plain sight.", description: "Communicate undetected in plain sight.",