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

View File

@ -23,8 +23,9 @@ export default function appendText(text, locale) {
responseType: 'json',
}).then((response) => {
const { status } = response;
if (status === 200) {
Logger.verbose('Captions: appended text', { padId });
if (status !== 200) {
Logger.error(`Could not append captions for padId=${padId}`);
return;
}
}).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;
if (status !== 200) {
Logger.error(`Could not get locales info for ${meetingId} ${status}`);
return;
}
const locales = response.data;
locales.forEach((locale) => {

View File

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

View File

@ -11,11 +11,17 @@ export default function fetchReadOnlyPadId(padId) {
check(padId, String);
const readOnlyURL = getReadOnlyIdURL(padId);
axios({
method: 'get',
url: readOnlyURL,
responseType: 'json',
}).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');
if (readOnlyPadId) {
updateReadOnlyPadId(padId, readOnlyPadId);

View File

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

View File

@ -1,3 +1,4 @@
import sha1 from 'crypto-js/sha1';
import Users from '/imports/api/users';
const MSG_DIRECT_TYPE = 'DIRECT';
@ -38,31 +39,7 @@ export const processForHTML5ServerOnly = fn => (message, ...args) => {
return fn(message, ...args);
};
/**
* 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 hashSHA1 = (str) => sha1(str).toString();
export const extractCredentials = (credentials) => {
if (!credentials) return {};

View File

@ -1,5 +1,5 @@
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 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 generateNoteId = (meetingId) => {
const noteId = hashFNV32a(meetingId, true);
return noteId;
};
const generateNoteId = (meetingId) => hashSHA1(meetingId+ETHERPAD.apikey);
const isEnabled = () => NOTE_CONFIG.enabled;

View File

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

View File

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

View File

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

View File

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

View File

@ -1127,6 +1127,11 @@
"integrity": "sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs=",
"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": {
"version": "0.7.0",
"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",
"classnames": "^2.2.6",
"clipboard": "^2.0.4",
"crypto-js": "^4.0.0",
"eventemitter2": "~5.0.1",
"fastdom": "^1.0.9",
"fibers": "^3.1.1",

View File

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

View File

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

View File

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

View File

@ -36,7 +36,7 @@ require 'logger'
require 'find'
require 'rubygems'
require 'net/http'
require 'fnv'
require 'digest'
require 'shellwords'
require 'English'
@ -226,9 +226,10 @@ module BigBlueButton
r.split("-")[1].to_i / 1000
end
# Notes id will be an 8-sized hash string based on the meeting id
def self.get_notes_id(meeting_id)
FNV.new.fnv1a_32(meeting_id).to_s(16).rjust(8, '0')
# Notes id will be a SHA1 hash string based on the meeting id and etherpad's apikey
def self.get_notes_id(meeting_id, notes_apikey)
value = meeting_id + notes_apikey
Digest::SHA1.hexdigest value
end
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
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}")
notes_dir = "#{raw_archive_dir}/#{meeting_id}/notes"
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"
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']
notes_endpoint = props['notes_endpoint']
notes_formats = props['notes_formats']
notes_apikey = props['notes_apikey']
# Determine the filenames for the done and fail files
if !break_timestamp.nil?
@ -198,7 +199,7 @@ archive_events(meeting_id, redis_host, redis_port, redis_password, raw_archive_d
# FreeSWITCH Audio files
archive_audio(meeting_id, audio_dir, raw_archive_dir)
# 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
archive_directory("#{presentation_dir}/#{meeting_id}/#{meeting_id}", "#{target_dir}/presentation")
# 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_presentation_src: /var/bigbluebutton
notes_endpoint: http://localhost:9001/p
notes_apikey: ETHERPAD_APIKEY
# Specify the notes formats we archive
# txt, doc and odt are also supported
notes_formats:

View File

@ -24,9 +24,9 @@ require 'logger'
require 'trollop'
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}")
notes_id = BigBlueButton.get_notes_id(meeting_id)
notes_id = BigBlueButton.get_notes_id(meeting_id, notes_apikey)
# Always fetch for the audit format
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']
log_dir = props['log_dir']
notes_endpoint = props['notes_endpoint']
notes_apikey = props['notes_apikey']
raw_events_xml = "#{raw_archive_dir}/#{meeting_id}/events.xml"
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
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"
if File.exist? raw_events_xml