Replace FNV32a pad's id generator with salted SHA1

When managing Etherpad's pads, Meteor makes API calls to initiate the closed captions
and shared notes modules. The pad id was being mapped to a shorter id than the meeting
id because of a Etherpad lenght limitation.

Changed to something less guessable.
This commit is contained in:
Pedro Beschorner Marin 2021-02-09 12:59:59 -03:00
parent 705ea9915a
commit c0a7f9cd92
21 changed files with 60 additions and 79 deletions

View File

@ -1,22 +1,20 @@
import { Meteor } from 'meteor/meteor'; import { Meteor } from 'meteor/meteor';
import { hashFNV32a } from '/imports/api/common/server/helpers'; import { hashSHA1 } from '/imports/api/common/server/helpers';
import { check } from 'meteor/check'; import { check } from 'meteor/check';
const ETHERPAD = Meteor.settings.private.etherpad;
const CAPTIONS_CONFIG = Meteor.settings.public.captions; const CAPTIONS_CONFIG = Meteor.settings.public.captions;
const BASENAME = Meteor.settings.public.app.basename; const BASENAME = Meteor.settings.public.app.basename;
const APP = Meteor.settings.private.app; const APP = Meteor.settings.private.app;
const LOCALES_URL = `http://${APP.host}:${APP.port}${BASENAME}${APP.localesUrl}`; const LOCALES_URL = `http://${APP.host}:${APP.port}${BASENAME}${APP.localesUrl}`;
const CAPTIONS = '_captions_'; const CAPTIONS_TOKEN = '_cc_';
const TOKEN = '$'; const TOKEN = '$';
// Captions padId should look like: {padId}_captions_{locale} // Captions padId should look like: {prefix}_cc_{locale}
const generatePadId = (meetingId, locale) => { const generatePadId = (meetingId, locale) => `${hashSHA1(meetingId+locale+ETHERPAD.apikey)}${CAPTIONS_TOKEN}${locale}`;
const padId = `${hashFNV32a(meetingId, true)}${CAPTIONS}${locale}`;
return padId;
};
const isCaptionsPad = (padId) => { const isCaptionsPad = (padId) => {
const splitPadId = padId.split(CAPTIONS); const splitPadId = padId.split(CAPTIONS_TOKEN);
return splitPadId.length === 2; return splitPadId.length === 2;
}; };
@ -45,6 +43,7 @@ const processForCaptionsPadOnly = fn => (message, ...args) => {
}; };
export { export {
CAPTIONS_TOKEN,
generatePadId, generatePadId,
processForCaptionsPadOnly, processForCaptionsPadOnly,
isEnabled, isEnabled,

View File

@ -23,8 +23,9 @@ export default function appendText(text, locale) {
responseType: 'json', responseType: 'json',
}).then((response) => { }).then((response) => {
const { status } = response; const { status } = response;
if (status === 200) { if (status !== 200) {
Logger.verbose('Captions: appended text', { padId }); Logger.error(`Could not append captions for padId=${padId}`);
return;
} }
}).catch(error => Logger.error(`Could not append captions for padId=${padId}: ${error}`)); }).catch(error => Logger.error(`Could not append captions for padId=${padId}: ${error}`));
} }

View File

@ -25,6 +25,7 @@ export default function createCaptions(meetingId) {
const { status } = response; const { status } = response;
if (status !== 200) { if (status !== 200) {
Logger.error(`Could not get locales info for ${meetingId} ${status}`); Logger.error(`Could not get locales info for ${meetingId} ${status}`);
return;
} }
const locales = response.data; const locales = response.data;
locales.forEach((locale) => { locales.forEach((locale) => {

View File

@ -21,7 +21,6 @@ export default function editCaptions(padId, data) {
return; return;
} }
const { const {
meetingId, meetingId,
ownerId, ownerId,

View File

@ -11,11 +11,17 @@ export default function fetchReadOnlyPadId(padId) {
check(padId, String); check(padId, String);
const readOnlyURL = getReadOnlyIdURL(padId); const readOnlyURL = getReadOnlyIdURL(padId);
axios({ axios({
method: 'get', method: 'get',
url: readOnlyURL, url: readOnlyURL,
responseType: 'json', responseType: 'json',
}).then((response) => { }).then((response) => {
const { status } = response;
if (status !== 200) {
Logger.error(`Could not get closed captions readOnlyID for ${padId} ${status}`);
return;
}
const readOnlyPadId = getDataFromResponse(response.data, 'readOnlyID'); const readOnlyPadId = getDataFromResponse(response.data, 'readOnlyID');
if (readOnlyPadId) { if (readOnlyPadId) {
updateReadOnlyPadId(padId, readOnlyPadId); updateReadOnlyPadId(padId, readOnlyPadId);

View File

@ -2,13 +2,14 @@ import { check } from 'meteor/check';
import Captions from '/imports/api/captions'; import Captions from '/imports/api/captions';
import updateOwnerId from '/imports/api/captions/server/modifiers/updateOwnerId'; import updateOwnerId from '/imports/api/captions/server/modifiers/updateOwnerId';
import { extractCredentials } from '/imports/api/common/server/helpers'; import { extractCredentials } from '/imports/api/common/server/helpers';
import { CAPTIONS_TOKEN } from '/imports/api/captions/server/helpers';
export default function takeOwnership(locale) { export default function takeOwnership(locale) {
const { meetingId, requesterUserId } = extractCredentials(this.userId); const { meetingId, requesterUserId } = extractCredentials(this.userId);
check(locale, String); check(locale, String);
const pad = Captions.findOne({ meetingId, padId: { $regex: `_captions_${locale}$` } }); const pad = Captions.findOne({ meetingId, padId: { $regex: `${CAPTIONS_TOKEN}${locale}$` } });
if (pad) { if (pad) {
updateOwnerId(meetingId, requesterUserId, pad.padId); updateOwnerId(meetingId, requesterUserId, pad.padId);

View File

@ -1,3 +1,4 @@
import sha1 from 'crypto-js/sha1';
import Users from '/imports/api/users'; import Users from '/imports/api/users';
const MSG_DIRECT_TYPE = 'DIRECT'; const MSG_DIRECT_TYPE = 'DIRECT';
@ -38,31 +39,7 @@ export const processForHTML5ServerOnly = fn => (message, ...args) => {
return fn(message, ...args); return fn(message, ...args);
}; };
/** export const hashSHA1 = (str) => sha1(str).toString();
* Calculate a 32 bit FNV-1a hash
* Found here: https://gist.github.com/vaiorabbit/5657561
* Ref.: http://isthe.com/chongo/tech/comp/fnv/
*
* @param {string} str the input value
* @param {boolean} [asString=false] set to true to return the hash value as
* 8-digit hex string instead of an integer
* @param {integer} [seed] optionally pass the hash of the previous chunk
* @returns {integer | string}
*/
/* eslint-disable */
export const hashFNV32a = (str, asString, seed) => {
let hval = (seed === undefined) ? 0x811c9dc5 : seed;
for (let i = 0, l = str.length; i < l; i++) {
hval ^= str.charCodeAt(i);
hval += (hval << 1) + (hval << 4) + (hval << 7) + (hval << 8) + (hval << 24);
}
if (asString) {
return (`0000000${(hval >>> 0).toString(16)}`).substr(-8);
}
return hval >>> 0;
};
/* eslint-enable */
export const extractCredentials = (credentials) => { export const extractCredentials = (credentials) => {
if (!credentials) return {}; if (!credentials) return {};

View File

@ -1,5 +1,5 @@
import { Meteor } from 'meteor/meteor'; import { Meteor } from 'meteor/meteor';
import { hashFNV32a } from '/imports/api/common/server/helpers'; import { hashSHA1 } from '/imports/api/common/server/helpers';
const ETHERPAD = Meteor.settings.private.etherpad; const ETHERPAD = Meteor.settings.private.etherpad;
const NOTE_CONFIG = Meteor.settings.public.note; const NOTE_CONFIG = Meteor.settings.public.note;
@ -12,10 +12,7 @@ const getReadOnlyIdURL = padId => `${BASE_URL}/getReadOnlyID?apikey=${ETHERPAD.a
const appendTextURL = (padId, text) => `${BASE_URL}/appendText?apikey=${ETHERPAD.apikey}&padID=${padId}&text=${encodeURIComponent(text)}`; const appendTextURL = (padId, text) => `${BASE_URL}/appendText?apikey=${ETHERPAD.apikey}&padID=${padId}&text=${encodeURIComponent(text)}`;
const generateNoteId = (meetingId) => { const generateNoteId = (meetingId) => hashSHA1(meetingId+ETHERPAD.apikey);
const noteId = hashFNV32a(meetingId, true);
return noteId;
};
const isEnabled = () => NOTE_CONFIG.enabled; const isEnabled = () => NOTE_CONFIG.enabled;

View File

@ -22,6 +22,7 @@ export default function createNote(meetingId) {
const noteId = generateNoteId(meetingId); const noteId = generateNoteId(meetingId);
const createURL = createPadURL(noteId); const createURL = createPadURL(noteId);
axios({ axios({
method: 'get', method: 'get',
url: createURL, url: createURL,
@ -30,6 +31,7 @@ export default function createNote(meetingId) {
const { status } = responseOuter; const { status } = responseOuter;
if (status !== 200) { if (status !== 200) {
Logger.error(`Could not get note info for ${meetingId} ${status}`); Logger.error(`Could not get note info for ${meetingId} ${status}`);
return;
} }
const readOnlyURL = getReadOnlyIdURL(noteId); const readOnlyURL = getReadOnlyIdURL(noteId);
axios({ axios({

View File

@ -10,11 +10,9 @@ const getLang = () => {
}; };
const getPadParams = () => { const getPadParams = () => {
const { config } = NOTE_CONFIG; let config = {};
const User = Users.findOne({ userId: Auth.userID }, { fields: { name: 1, color: 1 } });
config.userName = User.name;
config.userColor = User.color;
config.lang = getLang(); config.lang = getLang();
config.rtl = document.documentElement.getAttribute('dir') === 'rtl';
const params = []; const params = [];
Object.keys(config).forEach((k) => { Object.keys(config).forEach((k) => {
@ -26,12 +24,12 @@ const getPadParams = () => {
const getPadURL = (padId, readOnlyPadId, ownerId) => { const getPadURL = (padId, readOnlyPadId, ownerId) => {
const userId = Auth.userID; const userId = Auth.userID;
const params = getPadParams();
let url; let url;
if (!ownerId || (ownerId && userId === ownerId)) { if (!ownerId || (ownerId && userId === ownerId)) {
const params = getPadParams();
url = Auth.authenticateURL(`${NOTE_CONFIG.url}/p/${padId}?${params}`); url = Auth.authenticateURL(`${NOTE_CONFIG.url}/p/${padId}?${params}`);
} else { } else {
url = Auth.authenticateURL(`${NOTE_CONFIG.url}/p/${readOnlyPadId}`); url = Auth.authenticateURL(`${NOTE_CONFIG.url}/p/${readOnlyPadId}?${params}`);
} }
return url; return url;
}; };

View File

@ -7,7 +7,7 @@ import { Meteor } from 'meteor/meteor';
import { Session } from 'meteor/session'; import { Session } from 'meteor/session';
const CAPTIONS_CONFIG = Meteor.settings.public.captions; const CAPTIONS_CONFIG = Meteor.settings.public.captions;
const CAPTIONS = '_captions_'; const CAPTIONS_TOKEN = '_cc_';
const LINE_BREAK = '\n'; const LINE_BREAK = '\n';
const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator; const ROLE_MODERATOR = Meteor.settings.public.user.role_moderator;
@ -19,7 +19,7 @@ const getActiveCaptions = () => {
const getCaptions = locale => Captions.findOne({ const getCaptions = locale => Captions.findOne({
meetingId: Auth.meetingID, meetingId: Auth.meetingID,
padId: { $regex: `${CAPTIONS}${locale}$` }, padId: { $regex: `${CAPTIONS_TOKEN}${locale}$` },
}); });
const getCaptionsData = () => { const getCaptionsData = () => {
@ -170,6 +170,7 @@ const initSpeechRecognition = (locale) => {
}; };
export default { export default {
CAPTIONS_TOKEN,
getCaptionsData, getCaptionsData,
getAvailableLocales, getAvailableLocales,
getOwnedLocales, getOwnedLocales,

View File

@ -24,18 +24,15 @@ const getLang = () => {
}; };
const getNoteParams = () => { const getNoteParams = () => {
const { config } = NOTE_CONFIG; let config = {};
const User = Users.findOne({ userId: Auth.userID }, { fields: { name: 1, color: 1 } });
config.userName = User.name;
config.userColor = User.color;
config.lang = getLang(); config.lang = getLang();
config.rtl = document.documentElement.getAttribute('dir') === 'rtl';
const params = []; const params = [];
for (const key in config) { Object.keys(config).forEach((k) => {
if (config.hasOwnProperty(key)) { params.push(`${k}=${encodeURIComponent(config[k])}`);
params.push(`${key}=${encodeURIComponent(config[key])}`); });
}
}
return params.join('&'); return params.join('&');
}; };
@ -51,7 +48,8 @@ const isLocked = () => {
const getReadOnlyURL = () => { const getReadOnlyURL = () => {
const readOnlyNoteId = getReadOnlyNoteId(); const readOnlyNoteId = getReadOnlyNoteId();
const url = Auth.authenticateURL(`${NOTE_CONFIG.url}/p/${readOnlyNoteId}`); const params = getNoteParams();
const url = Auth.authenticateURL(`${NOTE_CONFIG.url}/p/${readOnlyNoteId}?${params}`);
return url; return url;
}; };

View File

@ -1127,6 +1127,11 @@
"integrity": "sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs=", "integrity": "sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs=",
"dev": true "dev": true
}, },
"crypto-js": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.0.0.tgz",
"integrity": "sha512-bzHZN8Pn+gS7DQA6n+iUmBfl0hO5DJq++QP3U6uTucDtk/0iGpXd/Gg7CGR0p8tJhofJyaKoWBuJI4eAO00BBg=="
},
"css-selector-tokenizer": { "css-selector-tokenizer": {
"version": "0.7.0", "version": "0.7.0",
"resolved": "https://registry.npmjs.org/css-selector-tokenizer/-/css-selector-tokenizer-0.7.0.tgz", "resolved": "https://registry.npmjs.org/css-selector-tokenizer/-/css-selector-tokenizer-0.7.0.tgz",

View File

@ -41,6 +41,7 @@
"browser-detect": "^0.2.28", "browser-detect": "^0.2.28",
"classnames": "^2.2.6", "classnames": "^2.2.6",
"clipboard": "^2.0.4", "clipboard": "^2.0.4",
"crypto-js": "^4.0.0",
"eventemitter2": "~5.0.1", "eventemitter2": "~5.0.1",
"fastdom": "^1.0.9", "fastdom": "^1.0.9",
"fibers": "^3.1.1", "fibers": "^3.1.1",

View File

@ -288,12 +288,6 @@ public:
note: note:
enabled: false enabled: false
url: ETHERPAD_HOST url: ETHERPAD_HOST
config:
showLineNumbers: false
showChat: false
noColors: true
showControls: true
rtl: false
layout: layout:
autoSwapLayout: false autoSwapLayout: false
hidePresentation: false hidePresentation: false

View File

@ -22,7 +22,6 @@ source "https://rubygems.org"
gem "absolute_time" gem "absolute_time"
gem "builder" gem "builder"
gem "fastimage" gem "fastimage"
gem "fnv"
gem "java_properties" gem "java_properties"
gem "journald-logger" gem "journald-logger"
gem "jwt" gem "jwt"

View File

@ -7,7 +7,6 @@ GEM
crass (1.0.5) crass (1.0.5)
fastimage (2.1.5) fastimage (2.1.5)
ffi (1.11.1) ffi (1.11.1)
fnv (0.2.0)
jaro_winkler (1.5.2) jaro_winkler (1.5.2)
java_properties (0.0.4) java_properties (0.0.4)
journald-logger (2.0.4) journald-logger (2.0.4)
@ -48,7 +47,6 @@ DEPENDENCIES
absolute_time absolute_time
builder builder
fastimage fastimage
fnv
java_properties java_properties
journald-logger journald-logger
jwt jwt

View File

@ -36,7 +36,7 @@ require 'logger'
require 'find' require 'find'
require 'rubygems' require 'rubygems'
require 'net/http' require 'net/http'
require 'fnv' require 'digest'
require 'shellwords' require 'shellwords'
require 'English' require 'English'
@ -226,9 +226,10 @@ module BigBlueButton
r.split("-")[1].to_i / 1000 r.split("-")[1].to_i / 1000
end end
# Notes id will be an 8-sized hash string based on the meeting id # Notes id will be a SHA1 hash string based on the meeting id and etherpad's apikey
def self.get_notes_id(meeting_id) def self.get_notes_id(meeting_id, notes_apikey)
FNV.new.fnv1a_32(meeting_id).to_s(16).rjust(8, '0') value = meeting_id + notes_apikey
Digest::SHA1.hexdigest value
end end
def self.done_to_timestamp(r) def self.done_to_timestamp(r)

View File

@ -45,11 +45,11 @@ def archive_events(meeting_id, redis_host, redis_port, redis_password, raw_archi
end end
end end
def archive_notes(meeting_id, notes_endpoint, notes_formats, raw_archive_dir) def archive_notes(meeting_id, notes_endpoint, notes_formats, notes_apikey, raw_archive_dir)
BigBlueButton.logger.info("Archiving notes for #{meeting_id}") BigBlueButton.logger.info("Archiving notes for #{meeting_id}")
notes_dir = "#{raw_archive_dir}/#{meeting_id}/notes" notes_dir = "#{raw_archive_dir}/#{meeting_id}/notes"
FileUtils.mkdir_p(notes_dir) FileUtils.mkdir_p(notes_dir)
notes_id = BigBlueButton.get_notes_id(meeting_id) notes_id = BigBlueButton.get_notes_id(meeting_id, notes_apikey)
tmp_note = "#{notes_dir}/tmp_note.txt" tmp_note = "#{notes_dir}/tmp_note.txt"
BigBlueButton.try_download("#{notes_endpoint}/#{notes_id}/export/txt", tmp_note) BigBlueButton.try_download("#{notes_endpoint}/#{notes_id}/export/txt", tmp_note)
@ -180,6 +180,7 @@ kurento_screenshare_dir = props['kurento_screenshare_src']
log_dir = props['log_dir'] log_dir = props['log_dir']
notes_endpoint = props['notes_endpoint'] notes_endpoint = props['notes_endpoint']
notes_formats = props['notes_formats'] notes_formats = props['notes_formats']
notes_apikey = props['notes_apikey']
# Determine the filenames for the done and fail files # Determine the filenames for the done and fail files
if !break_timestamp.nil? if !break_timestamp.nil?
@ -198,7 +199,7 @@ archive_events(meeting_id, redis_host, redis_port, redis_password, raw_archive_d
# FreeSWITCH Audio files # FreeSWITCH Audio files
archive_audio(meeting_id, audio_dir, raw_archive_dir) archive_audio(meeting_id, audio_dir, raw_archive_dir)
# Etherpad notes # Etherpad notes
archive_notes(meeting_id, notes_endpoint, notes_formats, raw_archive_dir) archive_notes(meeting_id, notes_endpoint, notes_formats, notes_apikey, raw_archive_dir)
# Presentation files # Presentation files
archive_directory("#{presentation_dir}/#{meeting_id}/#{meeting_id}", "#{target_dir}/presentation") archive_directory("#{presentation_dir}/#{meeting_id}/#{meeting_id}", "#{target_dir}/presentation")
# Red5 media # Red5 media

View File

@ -8,6 +8,7 @@ raw_webrtc_deskshare_src: /usr/share/red5/webapps/video-broadcast/streams
raw_deskshare_src: /var/bigbluebutton/deskshare raw_deskshare_src: /var/bigbluebutton/deskshare
raw_presentation_src: /var/bigbluebutton raw_presentation_src: /var/bigbluebutton
notes_endpoint: http://localhost:9001/p notes_endpoint: http://localhost:9001/p
notes_apikey: ETHERPAD_APIKEY
# Specify the notes formats we archive # Specify the notes formats we archive
# txt, doc and odt are also supported # txt, doc and odt are also supported
notes_formats: notes_formats:

View File

@ -24,9 +24,9 @@ require 'logger'
require 'trollop' require 'trollop'
require 'yaml' require 'yaml'
def keep_etherpad_events(meeting_id, events_etherpad, notes_endpoint) def keep_etherpad_events(meeting_id, events_etherpad, notes_endpoint, notes_apikey)
BigBlueButton.logger.info("Keeping etherpad events for #{meeting_id}") BigBlueButton.logger.info("Keeping etherpad events for #{meeting_id}")
notes_id = BigBlueButton.get_notes_id(meeting_id) notes_id = BigBlueButton.get_notes_id(meeting_id, notes_apikey)
# Always fetch for the audit format # Always fetch for the audit format
BigBlueButton.try_download("#{notes_endpoint}/#{notes_id}/export/etherpad", events_etherpad) BigBlueButton.try_download("#{notes_endpoint}/#{notes_id}/export/etherpad", events_etherpad)
@ -61,6 +61,7 @@ redis_port = props['redis_port']
redis_password = props['redis_password'] redis_password = props['redis_password']
log_dir = props['log_dir'] log_dir = props['log_dir']
notes_endpoint = props['notes_endpoint'] notes_endpoint = props['notes_endpoint']
notes_apikey = props['notes_apikey']
raw_events_xml = "#{raw_archive_dir}/#{meeting_id}/events.xml" raw_events_xml = "#{raw_archive_dir}/#{meeting_id}/events.xml"
ended_done_file = "#{recording_dir}/status/ended/#{meeting_id}.done" ended_done_file = "#{recording_dir}/status/ended/#{meeting_id}.done"
@ -83,7 +84,7 @@ if not FileTest.directory?(target_dir)
FileUtils.mkdir_p target_dir FileUtils.mkdir_p target_dir
events_etherpad = "#{target_dir}/events.etherpad" events_etherpad = "#{target_dir}/events.etherpad"
keep_etherpad_events(meeting_id, events_etherpad, notes_endpoint) keep_etherpad_events(meeting_id, events_etherpad, notes_endpoint, notes_apikey)
events_xml = "#{target_dir}/events.xml" events_xml = "#{target_dir}/events.xml"
if File.exist? raw_events_xml if File.exist? raw_events_xml