diff --git a/app/src/App.css b/app/src/App.css
index a6a8855f..58bd3514 100644
--- a/app/src/App.css
+++ b/app/src/App.css
@@ -247,8 +247,10 @@ textarea.otp {
color: #DDDDDD !important;
}
-.ui.inverted.accordion .title,
-.ui.inverted.accordion .content {
+/* Direct children only, so nested components that use the `content`/`title` class names (e.g. a Message's
+ Message.Content) are not given the accordion's dark background. */
+.ui.inverted.accordion > .title,
+.ui.inverted.accordion > .content {
background-color: #171616 !important;
}
diff --git a/app/src/api.js b/app/src/api.js
index 261e7d59..01a3445e 100644
--- a/app/src/api.js
+++ b/app/src/api.js
@@ -7,7 +7,6 @@ import {
COLLECTIONS_API,
DEFAULT_LIMIT,
Downloaders,
- OTP_API,
VIDEOS_API,
ZIM_API
} from "./components/Vars";
@@ -584,39 +583,6 @@ export async function refreshChannel(channelId) {
}
return response;
}
-
-export async function encryptOTP(otp, plaintext) {
- let body = {otp, plaintext};
- const response = await apiPost(`${OTP_API}/encrypt_otp`, body);
- if (response.ok) {
- return await response.json();
- } else {
- const message = await getErrorMessage(response, 'Failed to encrypt OTP. See server logs.');
- toast({
- type: 'error',
- title: 'Failed to encrypt OTP',
- description: message,
- time: 5000,
- })
- }
-}
-
-export async function decryptOTP(otp, ciphertext) {
- let body = {otp, ciphertext};
- const response = await apiPost(`${OTP_API}/decrypt_otp`, body);
- if (response.ok) {
- return await response.json();
- } else {
- const message = await getErrorMessage(response, 'Failed to decrypt OTP. See server logs.');
- toast({
- type: 'error',
- title: 'Failed to decrypt OTP',
- description: message,
- time: 5000,
- })
- }
-}
-
export async function getCategories() {
const response = await apiGet(`${API_URI}/inventory/categories`);
if (response.ok) {
diff --git a/app/src/components/Apps.js b/app/src/components/Apps.js
index 55458509..877cc183 100644
--- a/app/src/components/Apps.js
+++ b/app/src/components/Apps.js
@@ -1,8 +1,11 @@
-import React, {useContext} from 'react';
+import React, {useContext, useState} from 'react';
import {
+ Checkbox,
Divider,
+ Icon,
Input,
Label,
+ Radio,
SegmentGroup,
StatisticLabel,
StatisticValue,
@@ -10,136 +13,157 @@ import {
TableRow
} from "semantic-ui-react";
import {Link, Route, Routes} from "react-router";
-import {decryptOTP, encryptOTP} from "../api";
import {
- APIButton,
+ appendChecksum,
+ decryptOTP,
+ encryptOTP,
+ formatMessage,
+ generateHtml,
+ OTP_CHARS,
+ OTP_CHARS_ALPHA,
+ stripChecksum,
+ validateCharset
+} from "./otp";
+import {
ErrorMessage,
humanFileSize,
humanNumber,
mimetypeColor,
PageContainer,
+ SimpleAccordion,
toLocaleString,
useTitle
} from "./Common";
import {ThemeContext} from "../contexts/contexts";
-import {Button, Header, Loader, Segment, Statistic, Table, TextArea} from "./Theme";
+import {Button, Form, Header, Loader, Segment, Statistic, Table, TextArea} from "./Theme";
import {useStatistics, useVINDecoder} from "../hooks/customHooks";
import {CalculatorsPage} from "./Calculators";
-class Encrypt extends React.Component {
- constructor(props) {
- super(props);
- this.state = {
- otp: '', plaintext: '', ciphertext: '',
+function Encrypt({chars, checksum}) {
+ // Encrypt happens live as the user types or pastes. Nothing leaves the browser.
+ const [otp, setOtp] = useState('');
+ const [plaintext, setPlaintext] = useState('');
+
+ const handleInputChange = (event, {name, value}) => {
+ if (name === 'otp') {
+ setOtp(value);
+ } else {
+ setPlaintext(value);
+ }
+ };
+
+ let ciphertext = '';
+ let error = '';
+ if (otp && plaintext && chars) {
+ try {
+ const raw = encryptOTP(otp, plaintext, chars).ciphertext;
+ // When enabled, append a checksum character then re-group the whole message+checksum.
+ ciphertext = checksum ? formatMessage(appendChecksum(raw, chars)) : raw;
+ } catch (e) {
+ error = e.message;
}
}
- handleSubmit = async () => {
- let {otp, plaintext, ciphertext} = await encryptOTP(this.state.otp, this.state.plaintext);
- this.setState({otp, plaintext, ciphertext});
- }
-
- handleInputChange = async (event, {name, value}) => {
- this.setState({[name]: value});
- }
-
- render() {
- const disabled = !(this.state.otp && this.state.plaintext);
-
- return <>
-
+ Key
-
- Plaintext
-
- Ciphertext
- {this.state.ciphertext || 'Enter your message above'}
-
- Key
+
+ Plaintext
+
+ Ciphertext
+ {error
+ ? {ciphertext || (chars ? 'Enter your message above' : 'Set valid characters in Advanced options')}}
+ Key
-
- Ciphertext
-
- Plaintext
- {this.state.plaintext || 'Enter the encrypted message above'}
-
-
- Key
+
+ Ciphertext
+
+ Plaintext
+ {checksumValid !== null && {plaintext || (chars ? 'Enter the encrypted message above' : 'Set valid characters in Advanced options')}}
+
These messages are never stored and cannot be retrieved.
- + -Characters: {chars}
+
+{messages}
+
+
+Print this page and distribute the copies (along with the One Time Pad Cheat Sheet
+https://lrnsr.co/aY6m) to all members of your group that you trust to receive your encrypted messages. Every
+person must have their OWN copy of this "One Time Pad" to encrypt and decrypt messages.
+MESSAGE ${i}${generateMessage(chars)}`);
+ }
+ // Use function replacers so a '$' in a custom character set is not treated as a replacement pattern.
+ return PAGE_HTML.replace('{messages}', () => messages.join('\n\n')).replace('{chars}', () => chars);
+}
diff --git a/app/src/components/otp.test.js b/app/src/components/otp.test.js
new file mode 100644
index 00000000..36ac3489
--- /dev/null
+++ b/app/src/components/otp.test.js
@@ -0,0 +1,301 @@
+import {webcrypto} from 'crypto';
+import {
+ appendChecksum,
+ calculateChecksum,
+ decryptOTP,
+ encryptOTP,
+ formatMessage,
+ generateHtml,
+ generateMessage,
+ InvalidCiphertext,
+ InvalidOTP,
+ InvalidPlaintext,
+ OTP_CHARS,
+ OTP_CHARS_ALPHA,
+ stripChecksum,
+ validateCharset,
+ verifyChecksum
+} from './otp';
+
+// jsdom does not always provide window.crypto; real browsers always do. Polyfill it for the generation tests.
+if (!window.crypto || !window.crypto.getRandomValues) {
+ window.crypto = webcrypto;
+}
+
+// These cases are migrated from the original backend tests (modules/otp/test/test_otp.py and test_api.py); the
+// expected data is identical so the browser implementation matches the old server behavior exactly.
+
+describe('encryptOTP', () => {
+ test.each([
+ ['', '', ''],
+ ['A', 'A', 'A'],
+ ['B', 'B', 'C'],
+ ['9', '9', '8'],
+ ['W', 'H', '3'],
+ ['W8JD7', 'HELLO', '3CUOL'],
+ ['XMCKL', 'HELLO', '4QNVZ'],
+ [
+ // This message says: TEST THE ENCRYPTION OF A LONG MESSAGE THE SPACES SHOULDNT MATTER
+ // The message is shorter than the OTP, this is fine. The ciphertext should be the same
+ // length as the message.
+ 'V4YPT RCJLZ QZVSD 0U2E6 OOKNU\nZDH8F 4XQ76 OR6EK J0TRG 0G0HU\nS4BUJ',
+ 'TESTT HEENC RYPTI ONOFA LONGM\nESSAG ETHES PACES SHOUL DNTMA\nTTER',
+ 'E8G8C YGNY1 7NABL E7GJ6 Z2XT6\n3VZ8L 8GXBO 3R8I2 177BR 3TJTU\nBNFB',
+ ],
+ // From the original API test (test_api.py).
+ ['asdf', 'asdf', 'AAGK'],
+ ])('encrypts otp=%j plaintext=%j', (otp, plaintext, ciphertext) => {
+ const result = encryptOTP(otp, plaintext);
+ expect(result).toEqual({
+ otp: formatMessage(otp.toUpperCase().replace(/\s/g, '')),
+ plaintext: formatMessage(plaintext.toUpperCase().replace(/\s/g, '')),
+ ciphertext,
+ });
+
+ // Whitespace is ignored.
+ const paddedPlaintext = `\t ${plaintext}`;
+ const paddedOtp = `\n ${otp} `;
+ const paddedResult = encryptOTP(paddedOtp, paddedPlaintext);
+ expect(paddedResult).toEqual({
+ otp: formatMessage(otp.toUpperCase().replace(/\s/g, '')),
+ plaintext: formatMessage(plaintext.toUpperCase().replace(/\s/g, '')),
+ ciphertext,
+ });
+ });
+});
+
+describe('decryptOTP', () => {
+ test.each([
+ ['', '', ''],
+ ['A', 'A', 'A'],
+ ['B', 'C', 'B'],
+ ['W', '3', 'H'],
+ ['9', '8', '9'],
+ ['W8JD7', '3CUOL', 'HELLO'],
+ ['XMCKL', '4QNVZ', 'HELLO'],
+ [
+ 'V4YPT RCJLZ QZVSD 0U2E6 OOKNU\nZDH8F 4XQ76 OR6EK J0TRG 0G0HU\nS4BUJ',
+ 'E8G8C YGNY1 7NABL E7GJ6 Z2XT6\n3VZ8L 8GXBO 3R8I2 177BR 3TJTU\nBNFB',
+ 'TESTT HEENC RYPTI ONOFA LONGM\nESSAG ETHES PACES SHOUL DNTMA\nTTER',
+ ],
+ // From the original API test (test_api.py).
+ ['asdf', 'aagk', 'ASDF'],
+ ])('decrypts otp=%j ciphertext=%j', (otp, ciphertext, plaintext) => {
+ const result = decryptOTP(otp, ciphertext);
+ expect(result).toEqual({
+ otp: formatMessage(otp.toUpperCase().replace(/\s/g, '')),
+ ciphertext: formatMessage(ciphertext.toUpperCase().replace(/\s/g, '')),
+ plaintext,
+ });
+ });
+});
+
+describe('formatMessage', () => {
+ test.each([
+ ['ABC', 'ABC'],
+ ['ABCDEFG', 'ABCDE FG'],
+ ['SOMEMESSAGE', 'SOMEM ESSAG E'],
+ [
+ 'ABCDEFGHIJKLMNOPQRSTUVWXYZ01234567890',
+ 'ABCDE FGHIJ KLMNO PQRST UVWXY\nZ0123 45678 90',
+ ],
+ [
+ 'A'.repeat(94),
+ 'AAAAA AAAAA AAAAA AAAAA AAAAA\n' +
+ 'AAAAA AAAAA AAAAA AAAAA AAAAA\n' +
+ 'AAAAA AAAAA AAAAA AAAAA AAAAA\n' +
+ 'AAAAA AAAAA AAAAA AAAA',
+ ],
+ ])('formats %j', (message, expected) => {
+ expect(formatMessage(message)).toEqual(expected);
+ });
+});
+
+describe('custom character sets', () => {
+ test('A–Z only encrypts with the classic Vigenère vector', () => {
+ // With 0–9 absent (mod 26), HELLO + XMCKL = EQNVZ — different from the 36-char result.
+ const result = encryptOTP('XMCKL', 'HELLO', OTP_CHARS_ALPHA);
+ expect(result.ciphertext).toEqual('EQNVZ');
+ expect(decryptOTP('XMCKL', 'EQNVZ', OTP_CHARS_ALPHA).plaintext).toEqual('HELLO');
+ });
+
+ test('a digit is rejected when the set is A–Z only', () => {
+ expect(() => encryptOTP('ABCDE', 'HELL0', OTP_CHARS_ALPHA)).toThrow('Plaintext has invalid characters');
+ });
+
+ test('round-trips through a custom numeric set', () => {
+ const chars = '0123456789';
+ const {ciphertext} = encryptOTP('48271', '13579', chars);
+ expect(decryptOTP('48271', ciphertext, chars).plaintext).toEqual('13579');
+ });
+
+ test('an out-of-set character throws', () => {
+ expect(() => encryptOTP('01234', 'ABCDE', '0123456789')).toThrow('Plaintext has invalid characters');
+ });
+
+ test('an all-uppercase custom set auto-uppercases input', () => {
+ // The set is all uppercase, so lowercase input is folded to uppercase (like the built-in sets).
+ const chars = 'ABCDEF';
+ expect(encryptOTP('aabbcc', 'abcdef', chars)).toEqual(encryptOTP('AABBCC', 'ABCDEF', chars));
+ });
+
+ test('a custom set with lowercase is case-sensitive', () => {
+ // The set contains lowercase, so input is used verbatim; uppercase input is not in the set.
+ expect(encryptOTP('abcde', 'abcde', 'abcde').ciphertext).toBeTruthy();
+ expect(() => encryptOTP('abcde', 'ABCDE', 'abcde')).toThrow('Plaintext has invalid characters');
+ });
+});
+
+describe('validateCharset', () => {
+ test.each([
+ ['ABCDEFGHIJKLMNOPQRSTUVWXYZ', null],
+ ['0123456789', null],
+ ['', 'Enter at least 2 characters'],
+ ['A', 'Enter at least 2 characters'],
+ ['AABC', 'Characters must be unique'],
+ ['AB CD', 'Characters cannot contain spaces'],
+ ['A'.repeat(257), 'Too many characters (max 256)'],
+ ])('validates %j', (chars, expected) => {
+ expect(validateCharset(chars)).toEqual(expected);
+ });
+});
+
+describe('checksum', () => {
+ // Vectors adapted from the original backend plan (otp-checksum.md), default 36-char set.
+ test.each([
+ ['', 'A'],
+ ['A', 'A'],
+ ['B', 'B'],
+ ['HELLO', 'S'], // 7+8+33+44+70 = 162 mod 36 = 18 = 'S'
+ ['HLELO', 'L'], // transposition produces a different checksum (162→155, 155 mod 36 = 11 = 'L')
+ ])('calculateChecksum(%j) = %j', (message, expected) => {
+ expect(calculateChecksum(message)).toEqual(expected);
+ });
+
+ test.each([
+ ['HELLOS', true],
+ ['HELLOX', false], // wrong checksum
+ ['HLELOS', false], // transposed message, original checksum
+ ])('verifyChecksum(%j) = %j', (messageWithChecksum, expected) => {
+ expect(verifyChecksum(messageWithChecksum)).toEqual(expected);
+ });
+
+ test('appendChecksum adds the checksum character', () => {
+ expect(appendChecksum('HELLO')).toEqual('HELLOS');
+ });
+
+ test('checksum follows the character set', () => {
+ // With A–Z (mod 26): 162 mod 26 = 6 = 'G', not 'S'.
+ expect(calculateChecksum('HELLO', OTP_CHARS_ALPHA)).toEqual('G');
+ });
+
+ test('checksum protects an encrypted message end-to-end', () => {
+ const {ciphertext} = encryptOTP('W8JD7', 'HELLO'); // '3CUOL'
+ expect(appendChecksum(ciphertext)).toEqual('3CUOLY');
+ // A transposition in the ciphertext yields a different checksum.
+ expect(calculateChecksum('3UCOL')).not.toEqual(calculateChecksum('3CUOL'));
+ });
+});
+
+describe('validation errors', () => {
+ test('an invalid OTP character throws InvalidOTP', () => {
+ expect(() => encryptOTP('A!', 'AB')).toThrow(InvalidOTP);
+ expect(() => decryptOTP('A!', 'AB')).toThrow(InvalidOTP);
+ });
+
+ test('an invalid plaintext character throws InvalidPlaintext', () => {
+ expect(() => encryptOTP('ABC', 'A!')).toThrow(InvalidPlaintext);
+ });
+
+ test('an invalid ciphertext character throws InvalidCiphertext', () => {
+ expect(() => decryptOTP('ABC', 'A!')).toThrow(InvalidCiphertext);
+ });
+
+ test('plaintext longer than the OTP throws', () => {
+ expect(() => encryptOTP('AB', 'ABC')).toThrow('Plaintext is longer than OTP');
+ });
+
+ test('ciphertext longer than the OTP throws', () => {
+ expect(() => decryptOTP('AB', 'ABC')).toThrow('Ciphertext is longer than OTP');
+ });
+});
+
+describe('encrypt/decrypt roundtrip', () => {
+ // A small deterministic PRNG keeps the fuzz reproducible (no reliance on Math.random/crypto).
+ let seed = 123456789;
+ const rand = () => {
+ seed = (seed * 1103515245 + 12345) & 0x7fffffff;
+ return seed / 0x7fffffff;
+ };
+ const pick = (chars, n) => {
+ let out = '';
+ for (let i = 0; i < n; i++) out += chars[Math.floor(rand() * chars.length)];
+ return out;
+ };
+
+ test.each([
+ OTP_CHARS_ALPHA,
+ OTP_CHARS,
+ '0123456789',
+ 'abcdef0123', // mixed-case-safe custom set (lowercase => case-sensitive, no uppercasing)
+ ])('recovers the plaintext over %j', (chars) => {
+ for (let n = 0; n < 50; n++) {
+ const len = 1 + Math.floor(rand() * 20);
+ const plaintext = pick(chars, len);
+ const otp = pick(chars, len + Math.floor(rand() * 10)); // OTP at least as long as the message
+ const {ciphertext} = encryptOTP(otp, plaintext, chars);
+ expect(decryptOTP(otp, ciphertext, chars).plaintext).toEqual(formatMessage(plaintext));
+ }
+ });
+});
+
+describe('stripChecksum', () => {
+ test('removes a valid trailing checksum', () => {
+ // calculateChecksum('HELLO') === 'S'
+ expect(stripChecksum('HELLOS')).toEqual({body: 'HELLO', valid: true});
+ });
+
+ test('does NOT truncate a message that has no valid checksum', () => {
+ // Regression: with the checksum option on, the UI used to blindly drop the last character. 'HELLO' has no
+ // checksum (its checksum would be 'S'), so it must be returned intact rather than decoded as 'HELL'.
+ expect(stripChecksum('HELLO')).toEqual({body: 'HELLO', valid: false});
+ });
+
+ test('leaves a corrupted checksummed message intact and flags it invalid', () => {
+ expect(stripChecksum('HELLOX')).toEqual({body: 'HELLOX', valid: false});
+ });
+
+ test('mirrors the decrypt UI flow end-to-end', () => {
+ const otp = 'W8JD7';
+ const raw = encryptOTP(otp, 'HELLO').ciphertext; // '3CUOL'
+ const transmitted = formatMessage(appendChecksum(raw)); // '3CUOL Y'
+ const {body, valid} = stripChecksum(transmitted);
+ expect(valid).toBe(true);
+ expect(decryptOTP(otp, body).plaintext).toEqual('HELLO');
+ });
+});
+
+describe('generateMessage', () => {
+ test('produces 320 characters from the default set', () => {
+ const message = generateMessage().replace(/\s/g, '');
+ expect(message).toHaveLength(320);
+ expect([...message].every(c => OTP_CHARS.includes(c))).toBe(true);
+ });
+
+ test('only uses the given character set', () => {
+ const chars = 'AB';
+ const message = generateMessage(chars).replace(/\s/g, '');
+ expect(message).toHaveLength(320);
+ expect([...message].every(c => chars.includes(c))).toBe(true);
+ });
+});
+
+describe('generateHtml', () => {
+ test('generates a OTP HTML page', () => {
+ expect(generateHtml()).toBeTruthy();
+ });
+
+ test('labels the page with the character set it used', () => {
+ expect(generateHtml(OTP_CHARS_ALPHA)).toContain('Characters: ABCDEFGHIJKLMNOPQRSTUVWXYZ');
+ });
+});
diff --git a/modules/__init__.py b/modules/__init__.py
index 916bb81c..3e496d2f 100644
--- a/modules/__init__.py
+++ b/modules/__init__.py
@@ -2,6 +2,5 @@ from .archive import api
from .docs import api
from .inventory import api
from .map import api
-from .otp import api
from .videos import api
from .zim import api, test
diff --git a/modules/otp/__init__.py b/modules/otp/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/modules/otp/api.py b/modules/otp/api.py
deleted file mode 100644
index febfb6ac..00000000
--- a/modules/otp/api.py
+++ /dev/null
@@ -1,36 +0,0 @@
-from sanic import response, Blueprint
-from sanic.request import Request
-from sanic_ext import validate
-from sanic_ext.extensions.openapi import openapi
-
-from . import lib, schema
-
-otp_bp = Blueprint('OTP', '/api/otp')
-
-
-@otp_bp.post('/encrypt_otp')
-@openapi.definition(
- summary='Encrypt a message with OTP.',
- body=schema.EncryptOTPRequest,
-)
-@validate(schema.EncryptOTPRequest)
-async def post_encrypt_otp(_: Request, body: schema.EncryptOTPRequest):
- data = lib.encrypt_otp(body.otp, body.plaintext)
- return response.json(data)
-
-
-@otp_bp.post('/decrypt_otp')
-@openapi.definition(
- summary='Decrypt a message with OTP.',
- body=schema.DecryptOTPRequest,
-)
-@validate(schema.DecryptOTPRequest)
-async def post_decrypt_otp(_: Request, body: schema.DecryptOTPRequest):
- data = lib.decrypt_otp(body.otp, body.ciphertext)
- return response.json(data)
-
-
-@otp_bp.get('/html')
-async def get_new_otp_html(_: Request):
- body = lib.generate_html()
- return response.html(body)
diff --git a/modules/otp/errors.py b/modules/otp/errors.py
deleted file mode 100644
index 865a24df..00000000
--- a/modules/otp/errors.py
+++ /dev/null
@@ -1,21 +0,0 @@
-from http import HTTPStatus
-
-from wrolpi.errors import APIError
-
-
-class InvalidOTP(APIError):
- code = 'INVALID_OTP'
- summary = 'OTP has invalid characters'
- status_code = HTTPStatus.BAD_REQUEST
-
-
-class InvalidPlaintext(APIError):
- code = 'INVALID_PLAINTEXT'
- summary = 'Plaintext has invalid characters'
- status_code = HTTPStatus.BAD_REQUEST
-
-
-class InvalidCiphertext(APIError):
- code = 'INVALID_CIPHERTEXT'
- summary = 'Ciphertext has invalid characters'
- status_code = HTTPStatus.BAD_REQUEST
diff --git a/modules/otp/lib.py b/modules/otp/lib.py
deleted file mode 100644
index db1f149e..00000000
--- a/modules/otp/lib.py
+++ /dev/null
@@ -1,135 +0,0 @@
-import secrets
-from functools import partial
-from typing import Dict
-
-from modules.otp.errors import InvalidOTP, InvalidPlaintext, InvalidCiphertext
-from wrolpi.common import chunks, remove_whitespace, logger
-
-# These are the only characters we support.
-
-logger = logger.getChild(__name__)
-
-OTP_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
-# The number of characters in each group, separated by spaces.
-GROUP_SIZE = 5
-# The groups in each row of a message, separated by newlines. This is small to allow for mobile phones.
-GROUP_COUNT = 5
-
-
-def format_message(message: str, group_count: int = GROUP_COUNT) -> str:
- """
- Formats messages in the style of a One-Time-Pad. Usually 5 characters per group.
-
- >>> format_message('SOMEMESSAGE')
- 'SOMEM ESSAG E'
- """
- groups = [''.join(i) for i in chunks(message, GROUP_SIZE)]
- rows = chunks(groups, group_count)
- message = '\n'.join(' '.join(group for group in groups) for groups in rows)
- return message
-
-
-generate_char = partial(secrets.choice, OTP_CHARS)
-
-
-def generate_message():
- """
- Create a OTP message. Format it for ease of use.
- """
- # Use 20 messages per line because this page will be printed.
- return format_message(''.join(generate_char() for _ in range(320)), 16)
-
-
-PAGE_HTML = '''
-
-MESSAGE {i}{j}' for i, j in enumerate(messages, 1))
- return PAGE_HTML.format(messages=messages)
-
-
-def validate_message(otp, error_class):
- otp = otp.upper()
- otp = remove_whitespace(otp)
-
- if not all(i in OTP_CHARS for i in otp):
- raise error_class()
-
- return otp
-
-
-CHARS_LEN = len(OTP_CHARS)
-
-
-def encrypt_char(plaintext: str, otp: str) -> str:
- calculated = (OTP_CHARS.index(plaintext) + OTP_CHARS.index(otp)) % CHARS_LEN
- return OTP_CHARS[calculated]
-
-
-def decrypt_char(ciphertext: str, otp: str) -> str:
- calculated = (OTP_CHARS.index(ciphertext) - OTP_CHARS.index(otp)) % CHARS_LEN
- return OTP_CHARS[calculated]
-
-
-def encrypt_otp(otp: str, plaintext: str) -> Dict[str, str]:
- """
- Encrypt a plaintext message using an OTP.
- """
- otp = validate_message(otp, InvalidOTP)
- plaintext = validate_message(plaintext, InvalidPlaintext)
-
- if len(plaintext) > len(otp):
- raise InvalidPlaintext('Plaintext is longer than OTP')
-
- ciphertext = ''.join(encrypt_char(p, o) for p, o in zip(plaintext, otp))
-
- data = dict(
- ciphertext=format_message(ciphertext),
- otp=format_message(otp),
- plaintext=format_message(plaintext),
- )
-
- return data
-
-
-def decrypt_otp(otp: str, ciphertext: str) -> Dict[str, str]:
- """
- Decrypt an encrypted message that was encrypted with an OTP.
- """
- otp = validate_message(otp, InvalidOTP)
- ciphertext = validate_message(ciphertext, InvalidCiphertext)
-
- if len(ciphertext) > len(otp):
- raise InvalidCiphertext('Ciphertext is longer than OTP')
-
- plaintext = ''.join(decrypt_char(c, o) for c, o in zip(ciphertext, otp))
- data = dict(
- plaintext=format_message(plaintext),
- ciphertext=format_message(ciphertext),
- otp=format_message(otp),
- )
- return data
diff --git a/modules/otp/schema.py b/modules/otp/schema.py
deleted file mode 100644
index 7f5b6191..00000000
--- a/modules/otp/schema.py
+++ /dev/null
@@ -1,13 +0,0 @@
-from dataclasses import dataclass
-
-
-@dataclass
-class EncryptOTPRequest:
- otp: str
- plaintext: str
-
-
-@dataclass
-class DecryptOTPRequest:
- otp: str
- ciphertext: str
diff --git a/modules/otp/test/__init__.py b/modules/otp/test/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/modules/otp/test/test_api.py b/modules/otp/test/test_api.py
deleted file mode 100644
index 0c205345..00000000
--- a/modules/otp/test/test_api.py
+++ /dev/null
@@ -1,34 +0,0 @@
-import json
-from http import HTTPStatus
-
-import pytest
-
-
-@pytest.mark.asyncio
-async def test_encrypt(async_client):
- body = dict(otp='asdf', plaintext='asdf')
- request, response = await async_client.post('/api/otp/encrypt_otp', content=json.dumps(body))
- assert response.status_code == HTTPStatus.OK
- assert response.json == dict(
- ciphertext='AAGK',
- otp='ASDF',
- plaintext='ASDF',
- )
-
-
-@pytest.mark.asyncio
-async def test_decrypt(async_client):
- body = dict(otp='asdf', ciphertext='aagk')
- request, response = await async_client.post('/api/otp/decrypt_otp', content=json.dumps(body))
- assert response.status_code == HTTPStatus.OK
- assert response.json == dict(
- ciphertext='AAGK',
- otp='ASDF',
- plaintext='ASDF',
- )
-
-
-@pytest.mark.asyncio
-async def test_get_html(async_client):
- request, response = await async_client.get('/api/otp/html')
- assert response.status_code == HTTPStatus.OK
diff --git a/modules/otp/test/test_otp.py b/modules/otp/test/test_otp.py
deleted file mode 100644
index 15c0b879..00000000
--- a/modules/otp/test/test_otp.py
+++ /dev/null
@@ -1,101 +0,0 @@
-import pytest
-
-from modules.otp import lib
-
-
-@pytest.mark.parametrize(
- 'otp,plaintext,ciphertext',
- [
- ('', '', ''),
- ('A', 'A', 'A'),
- ('B', 'B', 'C'),
- ('9', '9', '8'),
- ('W', 'H', '3'),
- ('W', 'H', '3'),
- ('W8JD7', 'HELLO', '3CUOL'),
- ('XMCKL', 'HELLO', '4QNVZ'),
- (
- # This message says: TEST THE ENCRYPTION OF A LONG MESSAGE THE SPACES SHOULDNT MATTER
- # The message is shorter than the OTP, this is fine. The ciphertext should be the same
- # length as the message.
- 'V4YPT RCJLZ QZVSD 0U2E6 OOKNU\nZDH8F 4XQ76 OR6EK J0TRG 0G0HU\nS4BUJ',
- 'TESTT HEENC RYPTI ONOFA LONGM\nESSAG ETHES PACES SHOUL DNTMA\nTTER',
- 'E8G8C YGNY1 7NABL E7GJ6 Z2XT6\n3VZ8L 8GXBO 3R8I2 177BR 3TJTU\nBNFB'
- ),
- ],
-)
-def test_encrypt_otp(otp, plaintext, ciphertext):
- result = lib.encrypt_otp(otp, plaintext)
- expected = dict(
- otp=otp,
- plaintext=plaintext,
- ciphertext=ciphertext,
- )
- assert result == expected
-
- # Whitespace is ignored
- plaintext = f'\t {plaintext}'
- otp = f'\n {otp} '
- result = lib.encrypt_otp(otp, plaintext)
- expected = dict(
- otp=otp.strip(),
- plaintext=plaintext.strip(),
- ciphertext=ciphertext,
- )
- assert result == expected
-
-
-@pytest.mark.parametrize(
- 'otp,ciphertext,plaintext',
- [
- ('', '', ''),
- ('A', 'A', 'A'),
- ('B', 'C', 'B'),
- ('W', '3', 'H'),
- ('9', '8', '9'),
- ('W8JD7', '3CUOL', 'HELLO'),
- ('XMCKL', '4QNVZ', 'HELLO'),
- (
- 'V4YPT RCJLZ QZVSD 0U2E6 OOKNU\nZDH8F 4XQ76 OR6EK J0TRG 0G0HU\nS4BUJ',
- 'E8G8C YGNY1 7NABL E7GJ6 Z2XT6\n3VZ8L 8GXBO 3R8I2 177BR 3TJTU\nBNFB',
- 'TESTT HEENC RYPTI ONOFA LONGM\nESSAG ETHES PACES SHOUL DNTMA\nTTER',
- ),
- ],
-)
-def test_decrypt_otp(otp, ciphertext, plaintext):
- result = lib.decrypt_otp(otp, ciphertext)
- expected = dict(
- otp=otp,
- plaintext=plaintext,
- ciphertext=ciphertext,
- )
- assert result == expected
-
-
-@pytest.mark.parametrize(
- 'message,expected',
- [
- ('ABC', 'ABC'),
- ('ABCDEFG', 'ABCDE FG'),
- ('SOMEMESSAGE', 'SOMEM ESSAG E'),
- (
- 'ABCDEFGHIJKLMNOPQRSTUVWXYZ01234567890',
- 'ABCDE FGHIJ KLMNO PQRST UVWXY\nZ0123 45678 90',
- ),
- (
- ''.join('A' for _ in range(94)),
- 'AAAAA AAAAA AAAAA AAAAA AAAAA\n'
- 'AAAAA AAAAA AAAAA AAAAA AAAAA\n'
- 'AAAAA AAAAA AAAAA AAAAA AAAAA\n'
- 'AAAAA AAAAA AAAAA AAAA'
- ),
- ]
-)
-def test_format_message(message, expected):
- result = lib.format_message(message)
- assert result == expected
-
-
-def test_get_new_otp_html():
- """Generate a OTP HTML page."""
- assert lib.generate_html()
diff --git a/wrolpi/root_api.py b/wrolpi/root_api.py
index 160bd698..81c0d1c8 100644
--- a/wrolpi/root_api.py
+++ b/wrolpi/root_api.py
@@ -17,7 +17,6 @@ from modules.archive.api import archive_bp
from modules.docs.api import docs_bp
from modules.inventory import inventory_bp
from modules.map.api import map_bp
-from modules.otp.api import otp_bp
from modules.videos.api import videos_bp
from modules.zim.api import zim_bp
from wrolpi import flags, schema, dates
@@ -55,7 +54,6 @@ api_app.blueprint(docs_bp)
api_app.blueprint(files_bp)
api_app.blueprint(inventory_bp)
api_app.blueprint(map_bp)
-api_app.blueprint(otp_bp)
api_app.blueprint(videos_bp)
api_app.blueprint(zim_bp)