mirror of
https://github.com/planetrenox/inzerosight.git
synced 2026-09-18 10:05:44 +00:00
Use 11-character signatures and detect headers across WBR
This commit is contained in:
18
BUILD.md
18
BUILD.md
@@ -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/`.
|
|
||||||
63
content.js
63
content.js
@@ -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
89
content.test.js
Normal 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 ');
|
||||||
|
});
|
||||||
8
dist/chrome/content.js
vendored
8
dist/chrome/content.js
vendored
File diff suppressed because one or more lines are too long
2
dist/chrome/index.html
vendored
2
dist/chrome/index.html
vendored
@@ -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"/>
|
||||||
|
|||||||
8
dist/chrome/index.js
vendored
8
dist/chrome/index.js
vendored
File diff suppressed because one or more lines are too long
2
dist/chrome/manifest.json
vendored
2
dist/chrome/manifest.json
vendored
@@ -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"}]}
|
||||||
8
dist/firefox/content.js
vendored
8
dist/firefox/content.js
vendored
File diff suppressed because one or more lines are too long
2
dist/firefox/index.html
vendored
2
dist/firefox/index.html
vendored
@@ -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"/>
|
||||||
|
|||||||
8
dist/firefox/index.js
vendored
8
dist/firefox/index.js
vendored
File diff suppressed because one or more lines are too long
2
dist/firefox/manifest.json
vendored
2
dist/firefox/manifest.json
vendored
@@ -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"}]}
|
||||||
BIN
dist/inzerosight-chrome.zip
vendored
BIN
dist/inzerosight-chrome.zip
vendored
Binary file not shown.
BIN
dist/inzerosight-firefox-source.zip
vendored
BIN
dist/inzerosight-firefox-source.zip
vendored
Binary file not shown.
BIN
dist/inzerosight-firefox.zip
vendored
BIN
dist/inzerosight-firefox.zip
vendored
Binary file not shown.
4
dist/web/assets/index-DlLeEfTp.js
vendored
Normal file
4
dist/web/assets/index-DlLeEfTp.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
dist/web/assets/index-G7fqkm88.js
vendored
4
dist/web/assets/index-G7fqkm88.js
vendored
File diff suppressed because one or more lines are too long
4
dist/web/index.html
vendored
4
dist/web/index.html
vendored
@@ -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"/>
|
||||||
|
|||||||
24
docs/zwus.md
24
docs/zwus.md
@@ -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
|
||||||
|
|||||||
@@ -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
4
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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
74
sig.js
@@ -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
133
sig.test.js
Normal 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);
|
||||||
|
});
|
||||||
@@ -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.",
|
||||||
|
|||||||
Reference in New Issue
Block a user