...
 
Commits (3)
......@@ -11,9 +11,11 @@ import WriteFileWebpackPlugin from 'write-file-webpack-plugin'
require('dotenv').config();
const {
URN_PREFIX,
HASHING_ALGORITHM,
APPLICATION_LABEL,
MAX_REQUEST_HISTORY,
BASE32_URN_LENGTH,
} = process.env;
const paths = {};
......@@ -83,6 +85,8 @@ const configure = (NODE_ENV: ?string) => {
_HASHING_ALGORITHM: JSON.stringify(HASHING_ALGORITHM || 'SHA-256'),
_APPLICATION_LABEL: JSON.stringify(APPLICATION_LABEL || '_haschk'),
_MAX_REQUEST_HISTORY: JSON.stringify(MAX_REQUEST_HISTORY || 1000),
_BASE32_URN_LENGTH: JSON.stringify(BASE32_URN_LENGTH || 112),
_URN_PREFIX: JSON.stringify(URN_PREFIX || '::::'),
}),
new CopyWebpackPlugin([{
......
......@@ -13,3 +13,9 @@ APPLICATION_LABEL=_haschk
# The maximum number of requests we'll keep around to associate with downloads
MAX_REQUEST_HISTORY=1000
# The correct length of a base32-encoded URN (crockford, uppercase, w/ padding)
BASE32_URN_LENGTH=112
# The prefix used to construct URNs
URN_PREFIX=urn:hash:x:sha256:
This diff is collapsed.
......@@ -5,7 +5,7 @@
"type": "git",
"url": "https://git.xunn.io/closed-source/research/psd-mirrored/haschk"
},
"version": "0.10.0",
"version": "0.11.0",
"scripts": {
"dev": "npx gulp wpdevserv",
"build": "npx gulp build",
......@@ -43,9 +43,11 @@
],
"globals": {
"_NODE_ENV": "test",
"_HASHING_ALGORITHM": "SHA-256",
"_APPLICATION_LABEL": "_haschk",
"_MAX_REQUEST_HISTORY": "1000"
"_HASHING_ALGORITHM": "",
"_APPLICATION_LABEL": "",
"_MAX_REQUEST_HISTORY": "",
"_BASE32_URN_LENGTH": "",
"URN_PREFIX": ""
}
},
"dependencies": {
......@@ -58,10 +60,12 @@
"@babel/plugin-proposal-throw-expressions": "^7.8.3",
"@babel/preset-env": "^7.8.4",
"@babel/preset-flow": "^7.8.3",
"base32-encode": "^1.1.1",
"array-buffer-to-hex": "^1.0.0",
"axios": "^0.19.2",
"base32-encode": "^1.1.1",
"dotenv": "^8.2.0",
"eventemitter3": "^4.0.0",
"hex-to-array-buffer": "^1.1.0",
"install": "^0.13.0",
"url-parse": "^1.4.7"
},
......@@ -73,7 +77,6 @@
"babel-jest": "^25.1.0",
"babel-loader": "^8.0.6",
"babel-plugin-source-map-support": "^2.1.1",
"base32-encode": "^1.1.1",
"bluebird": "^3.7.2",
"clean-webpack-plugin": "^3.0.0",
"copy-webpack-plugin": "^5.1.1",
......
......@@ -188,7 +188,7 @@ export default (oracle: EventFrameEmitter, chrome: Chrome, context: Object) => {
const response = await http.get(GOOGLE_DNS_HTTPS_BACKEND_EXISTS(candidate));
const data = extractAnswerDataFromResponse(response);
if(data == '"OK"') {
if(data === 'ok') {
downloadItem.backendDomain = candidate;
downloadItem.judgement = JUDGEMENT_UNDECIDED;
break loop;
......
......@@ -8,12 +8,14 @@ import { EventFrameEmitter } from 'universe/events'
import {
Debug,
URN_PREFIX,
HASHING_ALGORITHM,
GOOGLE_DNS_HTTPS_BACKEND_QUERY,
extractAnswerDataFromResponse,
JUDGEMENT_UNKNOWN,
JUDGEMENT_UNSAFE,
JUDGEMENT_SAFE,
BASE32_URN_LENGTH,
} from 'universe'
import {
......@@ -30,16 +32,6 @@ import type { EventFrame } from 'universe/events'
declare var crypto;
export default (oracle: EventFrameEmitter, chrome: Chrome, context: Object) => {
// ? These events update the context.downloadItems cache
const updateDownloadItemInContext = downloadItem => context.downloadItems[downloadItem.id] = downloadItem;
oracle.addListener('download.incoming', (e, downloadItem) => updateDownloadItemInContext(downloadItem));
oracle.addListener('download.paused', (e, downloadItem) => updateDownloadItemInContext(downloadItem));
oracle.addListener('download.resumed', (e, downloadItem) => updateDownloadItemInContext(downloadItem));
oracle.addListener('download.interrupted', (e, downloadItem) => updateDownloadItemInContext(downloadItem));
oracle.addListener('download.completed', (e, downloadItem) => updateDownloadItemInContext(downloadItem));
// ? This event fires whenever haschk decides a download is NOT safe
oracle.addListener(`judgement.${JUDGEMENT_UNSAFE}`, (e: EventFrame, downloadItem) => {
chrome.downloads.removeFile(downloadItem.id, () => {
......@@ -76,21 +68,39 @@ export default (oracle: EventFrameEmitter, chrome: Chrome, context: Object) => {
return;
}
// ? Since it's finished downloading, grab the file's data
const file = await http.get(`file://${downloadItem.filename}`, { responseType: 'arraybuffer' });
const hasFilesystemAccess = await new Promise(res => chrome.extension.isAllowedFileSchemeAccess(t => res(t)));
if(!hasFilesystemAccess)
throw new Error(`HASCHK needs file scheme access to operate. Please allow access to file URLs in settings`);
// ? Since it's finished downloading, grab the file's data, but we need
// ? to use custom XHR because fetch (and Axios) can't handle status
// ? code 0 (pathetic)
const fetchLocal = async url => {
return new Promise(function(resolve, reject) {
const xhr = new XMLHttpRequest();
xhr.onload = () => resolve(xhr.response);
xhr.onerror = () => reject(new Error('file://XMLHttpRequest request failed'));
xhr.responseType = 'arraybuffer';
xhr.open('GET', url);
xhr.send(null);
});
};
const fileData = await fetchLocal(`file://${downloadItem.filename}`);
// ? Hash file data with proper algorithm
const base32FileHash = base32Encode(await crypto.subtle.digest(HASHING_ALGORITHM, file.data), 'Crockford', {
const base32FileHash = base32Encode(await crypto.subtle.digest(HASHING_ALGORITHM, fileData), 'Crockford', {
padding: false
});
// ? Construct BASE32 encoded URN and slice it up to yield C1 and C2
const base32Urn = base32Encode((new TextEncoder()).encode(`urn:hash::sha256:${base32FileHash}`), 'Crockford', {
const base32Urn = base32Encode((new TextEncoder()).encode(`${URN_PREFIX}${base32FileHash}`), 'Crockford', {
padding: true
});
Debug.if(() =>
base32Urn.length % 2 !== 0 && console.warn(`URN length is not an even number (${base32Urn.length})!`));
if(base32Urn.length !== BASE32_URN_LENGTH)
throw new Error(`URN length is not ${BASE32_URN_LENGTH}, got ${base32Urn.length} instead`);
const [ C1, C2 ] = [
base32Urn.slice(0, base32Urn.length / 2),
......@@ -104,9 +114,9 @@ export default (oracle: EventFrameEmitter, chrome: Chrome, context: Object) => {
Debug.log(chrome, `C1: ${C1}`);
Debug.log(chrome, `C2: ${C2}`);
Debug.log(chrome, `backend domain: ${downloadItem.backendDomain}`);
Debug.log(chrome, `query response data: ${data}`);
Debug.log(chrome, `query response data: ${data || 'null'}`);
// ? Compare DNS result with expected
oracle.emit(`judgement.${data === '"OK"' ? JUDGEMENT_SAFE : JUDGEMENT_UNSAFE}`, downloadItem);
oracle.emit(`judgement.${data === 'ok' ? JUDGEMENT_SAFE : JUDGEMENT_UNSAFE}`, downloadItem);
});
};
// @flow
const Koa = require('koa');
const Router = require('koa-router');
const base32Encode = require('base32-encode');
const hexToArrayBuffer = require('hex-to-array-buffer');
const DigitalOcean = require('do-wrapper').default;
require('dotenv').config();
const api = new DigitalOcean('e6c3d3bce6571c02293d6d1b863491ce9b77f13f663b77a90d5e1c861f52b81f', 1);
const app = new Koa();
const router = new Router();
const outputBuilder = (out, prefix = '') => console[prefix == 'ERR:' ? 'error' : 'log'](`DNSCHK-hotcrp:${prefix}`, out);
const outputErrorBuilder = out => outputBuilder(out, 'ERR:');
const {
DEBUG,
NODE_ENV,
URN_PREFIX,
BASE32_URN_LENGTH,
APPLICATION_LABEL,
HOTCRP_BACKEND_HOST,
HOTCRP_BACKEND_PORT,
} = process.env;
const DEBUG_MODE = DEBUG || ['development', 'debug', 'test'].includes(NODE_ENV);
if(!URN_PREFIX) throw new Error('Bad environment: missing URN_PREFIX');
if(!BASE32_URN_LENGTH) throw new Error('Bad environment: missing BASE32_URN_LENGTH');
if(!APPLICATION_LABEL) throw new Error('Bad environment: missing APPLICATION_LABEL');
if(!HOTCRP_BACKEND_HOST) throw new Error('Bad environment: missing HOTCRP_BACKEND_HOST');
if(!HOTCRP_BACKEND_PORT) throw new Error('Bad environment: missing HOTCRP_BACKEND_PORT');
router.get('primary', '/:contentHash', async ctx => {
ctx.body = 'not ok';
ctx.status = 403;
const contentHash = (ctx.params.contentHash || '').toLowerCase()
if(!contentHash) {
outputBuilder(`(aborted request with empty or missing contentHash parameter)`);
return;
}
if(contentHash.length != 64) {
outputBuilder(`(aborted request with invalid contentHash "${contentHash}" of length ${contentHash.length} !== 64)`);
return;
}
// ? Hash file data with proper algorithm
const base32FileHash = base32Encode(hexToArrayBuffer(contentHash), 'Crockford', {
padding: false
});
DEBUG_MODE && outputBuilder(`base32FileHash: ${base32FileHash}`);
DEBUG_MODE && outputBuilder(`base32FileHash.length: ${base32FileHash.length}`);
// ? Construct BASE32 encoded URN and slice it up to yield C1 and C2
const base32Urn = base32Encode((new TextEncoder()).encode(`${URN_PREFIX}${base32FileHash}`), 'Crockford', {
padding: false
});
DEBUG_MODE && outputBuilder(`base32Urn: ${base32Urn}`);
DEBUG_MODE && outputBuilder(`base32Urn.length: ${base32Urn.length}`);
if(base32Urn.length != BASE32_URN_LENGTH) {
outputErrorBuilder(`(encountered strange base32Urn derived from "${contentHash}" of length ${base32Urn.length} != ${BASE32_URN_LENGTH})`);
return;
}
ctx.body = 'ok';
ctx.status = 200;
const [ C1, C2 ] = [
base32Urn.slice(0, base32Urn.length / 2),
base32Urn.slice(base32Urn.length / 2, base32Urn.length),
];
const recordName = `${C1}.${C2}.${APPLICATION_LABEL}`.toLowerCase();
let res = '<no response object>';
outputBuilder(`adding DNS TXT record to ${HOTCRP_BACKEND_HOST}:\ncontent hash ${ctx.params.contentHash} ==> TXT record ${recordName}`);
try {
await api.domains.getAllRecords(HOTCRP_BACKEND_HOST).then(async data => {
const filtered = JSON.parse(data).domain_records.filter(item => item.type == 'TXT' && item.name == recordName);
DEBUG_MODE && outputBuilder(`Filtered result: [ ${JSON.stringify(filtered)} ]`);
if(filtered.length)
outputBuilder('(skipping adding DNS TXT record as it already exists)');
else {
res = await api.domains.createRecord(HOTCRP_BACKEND_HOST, {
type: 'TXT',
name: recordName,
data: 'OK'
});
}
});
}
catch(e) { outputErrorBuilder(e); }
outputBuilder(DEBUG_MODE ? res : '<succeeded>');
});
app.use(router.routes());
const server = app.listen(HOTCRP_BACKEND_PORT).on('error', err => outputErrorBuilder(err));
outputBuilder(`hotcrp DNS adapter running in ${DEBUG_MODE ? 'debug' : 'production'} mode`);
if(DEBUG_MODE) {
outputBuilder(`URN_PREFIX: ${URN_PREFIX}`);
outputBuilder(`BASE32_URN_LENGTH: ${BASE32_URN_LENGTH}`);
outputBuilder(`APPLICATION_LABEL: ${APPLICATION_LABEL}`);
outputBuilder(`HOTCRP_BACKEND_HOST: ${HOTCRP_BACKEND_HOST}`);
outputBuilder(`HOTCRP_BACKEND_PORT: ${HOTCRP_BACKEND_PORT}\n`);
}
module.exports = server;
......@@ -16,7 +16,6 @@
"options_page": "options.html",
"permissions": [
"<all_urls>",
"file://*/",
"downloads",
"webRequest"
],
......
......@@ -14,11 +14,15 @@ declare var _NODE_ENV: string;
declare var _HASHING_ALGORITHM: string;
declare var _APPLICATION_LABEL: string;
declare var _MAX_REQUEST_HISTORY: string;
declare var _BASE32_URN_LENGTH: string;
declare var _URN_PREFIX: string;
export const NODE_ENV = _NODE_ENV;
export const URN_PREFIX = _URN_PREFIX;
export const HASHING_ALGORITHM = _HASHING_ALGORITHM;
export const APPLICATION_LABEL = _APPLICATION_LABEL;
export const MAX_REQUEST_HISTORY = parseInt(_MAX_REQUEST_HISTORY);
export const BASE32_URN_LENGTH = parseInt(_BASE32_URN_LENGTH);
// ? Returns a string HTTPS endpoint URI used to query the backend for URNs. See
// ? the paper for details on what C1, C2, and BD are.
......@@ -49,10 +53,12 @@ export const extractBDCandidatesFromURI = (uri: string) => {
/**
* Extracts the actual DNS response from the Google DoH API response object.
*
* @param {*} response The DoH response JSON object
* @param {*} response The DoH answer string or NULL if there was no answer
*/
export const extractAnswerDataFromResponse = (response: any) => {
return !response.data.Answer ? '<no answer>' : response.data.Answer.slice(-1)[0].data;
return !response.data.Answer
? null
: response.data.Answer.slice(-1)[0].data.replace(/[^0-9a-z]/gi, '').toLowerCase();
};
/**
......@@ -78,31 +84,3 @@ export const Debug = {
NODE_ENV === 'development' && fn();
}
};
/**
* Accepts an ArrayBuffer instance from the `SubtleCrypto` interface and returns
* a hex string.
*
* @param {ArrayBuffer} buffer
*/
export const bufferToHex = (buffer: ArrayBuffer) => {
let hexCodes = [];
let view = new DataView(buffer);
if(buffer.byteLength % 4 !== 0)
throw new Error('Buffer byte length must be a multiple of 4');
for(let i = 0; i < view.byteLength; i += 4) {
// ? Using getUint32 reduces the number of iterations needed (we process 4 bytes each time)
let value = view.getUint32(i)
// ? toString(16) will give the hex representation of the number without padding
let stringValue = value.toString(16)
// ? We use concatenation and slice for padding
let padding = '00000000'
let paddedValue = (padding + stringValue).slice(-padding.length)
hexCodes.push(paddedValue);
}
// ? Join all the hex strings into one
return hexCodes.join('');
}
/* @flow */
import {
bufferToHex,
extractBDCandidatesFromURI,
} from 'universe'
......@@ -21,24 +20,3 @@ test('extractBDCandidatesFromURI returns 3LD and 2LD URI with a deep path', () =
expect(extractBDCandidatesFromURI('https://return.base.domain.xunn.io/paper/4/paper/4.faker?something=5&other&nother=fake#hash-mash.cash'))
.toEqual(['domain.xunn.io', 'xunn.io']);
});
test('bufferToHex translate buffer to hex as expected', () => {
const data = new ArrayBuffer(4);
const dataview = new DataView(data);
dataview.setUint8(0, 15);
dataview.setUint8(1, 34);
dataview.setUint8(2, 56);
dataview.setUint8(3, 79);
expect(bufferToHex(data)).toBe('0f22384f');
});
test('bufferToHex translate buffer to hex if all zeros', () => {
const data = new ArrayBuffer(8);
expect(bufferToHex(data)).toBe('0000000000000000');
});
test('bufferToHex throws if ArrayBuffer is of non-conformant byte length', () => {
const data = new ArrayBuffer(7);
expect(() => bufferToHex(data)).toThrow();
});
This diff is collapsed.