Merge branch '5.4-rc'

This commit is contained in:
David Benque 2023-08-10 12:11:39 +01:00
commit a99a704398
329 changed files with 39738 additions and 1565 deletions

View File

@ -1,3 +0,0 @@
{
"directory" : "www/bower_components"
}

9
.dockerignore Normal file
View File

@ -0,0 +1,9 @@
.dockerignore
.git
.gitignore
.gitmodules
.github
docker-compose.yml
traefik2.yml
Dockerfile*
*.png

12
.editorconfig Normal file
View File

@ -0,0 +1,12 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
# Unix-style newlines with a newline ending every file
[*]
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 4
charset = utf-8

View File

@ -1,5 +1,5 @@
[ignore]
.*/bower_components/.*
.*/components/.*
.*/node_modules/lesshint/*
[include]

View File

@ -85,7 +85,7 @@ body:
label: Version
description: What version of CryptPad are you running?
options:
- 5.4-rc
- 5.4.0
- 5.3.0
- 5.2.1
- 5.2.0

2
.gitignore vendored
View File

@ -1,5 +1,6 @@
datastore
tasks
www/components/*
www/bower_components/*
www/accounts
node_modules
@ -22,4 +23,3 @@ block/
logs/
privileged.conf
config/config.js
*.sh

View File

@ -1,4 +1,5 @@
node_modules/
www/components/
www/bower_components/
www/common/onlyoffice/sdkjs
www/common/onlyoffice/web-apps

View File

@ -1,3 +1,75 @@
# 5.4.0
## Goals
This release introduces two major new features:
- New Diagram application
- 2 factor authentication using time-based one-time passwords (TOTP)
Also included are some improvements, dependency updates, and bug fixes
## Features
- Diagram application: integration of [Draw.io](https://www.drawio.com/) with CryptPad's encrypted real time collaboration [[#1070](https://github.com/cryptpad/cryptpad/pull/1070)]
- Introduce a new app color for Diagram and adjust Whiteboard color [[#1059](https://github.com/cryptpad/cryptpad/issues/1059)]
- New 2 Factor Authentication with TOTP [[#1071](https://github.com/cryptpad/cryptpad/pull/1071)]. To enable for a user account:
1. Settings > Security & Privacy
2. Enter your password
3. Save the recovery code
4. Snap the QR code with a 2FA app of your choice
5. ✅ 2FA is enabled
- Docker deployment is now officially supported [[#1064](https://github.com/cryptpad/cryptpad/pull/1064)]
## Improvements
- New setting to destroy all documents of which you are the sole owner
- Settings re-organization
- Add favicons in ICO format [[#1068](https://github.com/cryptpad/cryptpad/pull/1068) thanks @lemondevxyz]
## Bugs / issues
- Form
- Make Form question text selectable in participant view [[#1046](https://github.com/cryptpad/cryptpad/issues/1046)]
- Add form title to archived notifications [[#1065](https://github.com/cryptpad/cryptpad/pull/1065) thanks to @lemondevxyz]
- Add "make a copy" to office editors [[#1067](https://github.com/cryptpad/cryptpad/pull/1067) thanks to @lemondevxyz]
- Disable the "protect tab" feature in Sheets as it cannot be integrated in CryptPad [[#1053](https://github.com/cryptpad/cryptpad/issues/1053)]
## Dependencies
- Remove Bower to manage client side dependencies [[#989](https://github.com/cryptpad/cryptpad/pull/989) [#1072](https://github.com/cryptpad/cryptpad/pull/1072) thanks to @Pamplemousse] ⚠️ Please read upgrade notes carefully if you administer an instance
- Upgrade Mermaid diagrams to 10.2.4 [[#1118](https://github.com/cryptpad/cryptpad/issues/1118)]
- Upgrade CKeditor to 4.22.1 [[#1119](https://github.com/cryptpad/cryptpad/issues/1119)]
## Upgrade notes
⚠️ Please read upgrade notes carefully as this version introduces breaking changes
If you are upgrading from a version older than `5.3.0` please read the upgrade notes of all versions between yours and `5.4.0` to avoid configuration issues.
To upgrade:
1. Stop your server
2. Get the latest code with git
```bash
git fetch origin --tags
git checkout 5.4.0
```
3. Major changes to the Nginx config
- Access-Control-Allow-Credentials header
- proxy_pass request for /blob/ and /block/ to the node process
- new port for the websocket
- set CSP headers for draw.io, used by the new diagram app
- see the [full diff](https://github.com/cryptpad/cryptpad/compare/5.4-rc#diff-a97d166145edec9545df5228d500c144bd5ec20db759cf5cc6f90309e963b1ca)
4. Bower removed
- To download all dependencies, use `npm install`
- Then, to copy client-side dependencies, use `npm run install:components`
- `www/bower_components` can be removed
5. If you have previously used the `build` command to enable opengraph preview images
- Please run `npm run build` again after upgrading
6. Restart your server
7. Review your instance's checkup page to ensure that you are passing all tests
# 5.3.0
## Goals

51
Dockerfile Normal file
View File

@ -0,0 +1,51 @@
# Multistage build to reduce image size and increase security
FROM node:lts-slim AS build
# Create folder for CryptPad
RUN mkdir /cryptpad
WORKDIR /cryptpad
# Copy CryptPad source code to the container
COPY . /cryptpad
RUN sed -i "s@//httpAddress: '::'@httpAddress: '0.0.0.0'@" /cryptpad/config/config.example.js
RUN sed -i "s@installMethod: 'unspecified'@installMethod: 'docker'@" /cryptpad/config/config.example.js
# Install dependencies
RUN npm install --production \
&& npm run install:components
# Create actual CryptPad image
FROM node:lts-slim
# Create user and group for CryptPad so it does not run as root
RUN groupadd cryptpad -g 4001
RUN useradd cryptpad -u 4001 -g 4001 -d /cryptpad
# Copy cryptpad with installed modules
COPY --from=build --chown=cryptpad /cryptpad /cryptpad
USER cryptpad
# Copy docker-entrypoint.sh script
COPY --chown=cryptpad docker-entrypoint.sh /cryptpad/docker-entrypoint.sh
# Set workdir to cryptpad
WORKDIR /cryptpad
# Create directories
RUN mkdir blob block customize data datastore
# Volumes for data persistence
VOLUME /cryptpad/blob
VOLUME /cryptpad/block
VOLUME /cryptpad/customize
VOLUME /cryptpad/data
VOLUME /cryptpad/datastore
ENTRYPOINT ["/bin/bash", "/cryptpad/docker-entrypoint.sh"]
# Ports
EXPOSE 3000 3001
# Run cryptpad on startup
CMD ["npm", "start"]

View File

@ -1,57 +0,0 @@
{
"name": "cryptpad",
"version": "0.1.0",
"authors": [
"Caleb James DeLisle <cjd@cjdns.fr>"
],
"description": "realtime collaborative visual editor with zero knowlege server",
"main": "www/index.html",
"moduleType": [
"node"
],
"license": "AGPLv3",
"ignore": [
"**/.*",
"node_modules",
"bower_components",
"test",
"tests"
],
"dependencies": {
"jquery": "3.6.0",
"tweetnacl": "0.12.2",
"components-font-awesome": "^4.6.3",
"ckeditor": "4.14.0",
"codemirror": "^5.19.0",
"requirejs": "2.3.5",
"marked": "1.1.0",
"rangy": "rangy-release#~1.3.0",
"json.sortify": "~2.1.0",
"hyperjson": "~1.4.0",
"chainpad-crypto": "^0.2.0",
"chainpad-listmap": "^1.0.0",
"chainpad": "^5.2.0",
"file-saver": "1.3.1",
"alertifyjs": "1.0.11",
"scrypt-async": "1.2.0",
"require-css": "0.1.10",
"bootstrap": "^v4.0.0",
"diff-dom": "2.1.1",
"nthen": "0.1.7",
"open-sans-fontface": "^1.4.2",
"bootstrap-tokenfield": "0.12.1",
"localforage": "^1.5.2",
"html2canvas": "^0.4.1",
"croppie": "^2.5.0",
"sortablejs": "^1.6.0",
"saferphore": "^0.0.1",
"jszip": "3.7.1",
"requirejs-plugins": "^1.0.3",
"dragula.js": "3.7.2",
"MathJax": "3.0.5"
},
"resolutions": {
"bootstrap": "^v4.0.0",
"jquery": "3.6.0"
}
}

View File

@ -92,6 +92,19 @@ module.exports = {
*/
//httpSafePort: 3001,
/* Websockets need to be exposed on a separate port from the rest of
* the platform's HTTP traffic. Port 3003 is used by default.
* You can change this to a different port if it is in use by a
* different service, but under most circumstances you can leave this
* commented and it will work.
*
* In production environments, your reverse proxy (usually NGINX)
* will need to forward websocket traffic (/cryptpad_websocket)
* to this port.
*
*/
// websocketPort: 3003,
/* CryptPad will launch a child process for every core available
* in order to perform CPU-intensive tasks in parallel.
* Some host environments may have a very large number of cores available

View File

@ -6,7 +6,7 @@
<meta content="text/html; charset=utf-8" http-equiv="content-type"/>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<link rel="icon" type="image/png" href="/customize/favicon/main-favicon.png" id="favicon"/>
<script async data-bootload="/customize/four-oh-four.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script>
<script async data-bootload="/customize/four-oh-four.js" data-main="/common/boot.js?ver=1.0" src="/components/requirejs/require.js?ver=2.3.5"></script>
</head>
<body class="html">
<noscript>

View File

@ -6,7 +6,7 @@
<meta content="text/html; charset=utf-8" http-equiv="content-type"/>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<link rel="icon" type="image/png" href="/customize/favicon/main-favicon.png" id="favicon"/>
<script async data-bootload="/customize/four-oh-four.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script>
<script async data-bootload="/customize/four-oh-four.js" data-main="/common/boot.js?ver=1.0" src="/components/requirejs/require.js?ver=2.3.5"></script>
</head>
<body class="html">
<noscript>

View File

@ -62,7 +62,7 @@ CKEDITOR.editorConfig = function( config ) {
// every part of ckeditor will get in the browser cache.
var fix = function (x) {
if (x.map) { return x.map(fix); }
return (/\/bower_components\/.*\.css$/.test(x)) ? (x + '?ver=' + CKEDITOR.timestamp) : x;
return (/\/components\/.*\.css$/.test(x)) ? (x + '?ver=' + CKEDITOR.timestamp) : x;
};
CKEDITOR.tools._buildStyleHtml = CKEDITOR.tools.buildStyleHtml;
CKEDITOR.document._appendStyleSheet = CKEDITOR.document.appendStyleSheet;

View File

@ -8,7 +8,7 @@
<link rel="icon" type="image/png" href="/customize/favicon/main-favicon.png" id="favicon"/>
<script src="/customize/pre-loading.js?ver=1.1"></script>
<link href="/customize/src/pre-loading.css?ver=1.0" rel="stylesheet" type="text/css">
<script async data-bootload="/customize/template.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script>
<script async data-bootload="/customize/template.js" data-main="/common/boot.js?ver=1.0" src="/components/requirejs/require.js?ver=2.3.5"></script>
</head>
<body class="html">
<noscript></noscript>

View File

@ -1,5 +1,5 @@
define([
'/bower_components/chainpad/chainpad.dist.js',
'/components/chainpad/chainpad.dist.js',
], function (ChainPad) {
var Diff = ChainPad.Diff;

BIN
customize.dist/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

View File

@ -6,8 +6,8 @@
viewBox="0 0 135.46606 135.46728"
id="svg942"
sodipodi:docname="favicon_source.svg"
inkscape:version="1.1.1 (1:1.1+202109281949+c3084ef5ed)"
inkscape:export-filename="/home/david/cryptpad/customize.dist/favicon/alt-favicon-document.png"
inkscape:version="1.2.2 (1:1.2.2+202305151914+b0a8486541)"
inkscape:export-filename="alt-favicon-diagram.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
@ -32,6 +32,14 @@
</metadata>
<defs
id="defs946">
<linearGradient
id="linearGradient1098"
inkscape:swatch="solid">
<stop
style="stop-color:#ce3ad3;stop-opacity:1;"
offset="0"
id="stop1096" />
</linearGradient>
<linearGradient
id="linearGradient943"
inkscape:swatch="solid">
@ -81,6 +89,15 @@
x2="458.45312"
y2="256.63477"
gradientUnits="userSpaceOnUse" />
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient1098"
id="linearGradient1100"
x1="53.544922"
y1="256.63476"
x2="458.45312"
y2="256.63476"
gradientUnits="userSpaceOnUse" />
</defs>
<sodipodi:namedview
pagecolor="#ffffff"
@ -92,18 +109,20 @@
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1832"
inkscape:window-height="1133"
inkscape:window-height="1128"
id="namedview944"
showgrid="false"
inkscape:zoom="0.819555"
inkscape:cx="60.398631"
inkscape:cy="380.6944"
inkscape:window-x="26"
inkscape:window-y="23"
inkscape:cx="12.811831"
inkscape:cy="384.96501"
inkscape:window-x="1280"
inkscape:window-y="745"
inkscape:window-maximized="0"
inkscape:current-layer="g952"
inkscape:current-layer="g1160"
inkscape:document-rotation="0"
inkscape:pagecheckerboard="0" />
inkscape:pagecheckerboard="0"
inkscape:showpageshadow="2"
inkscape:deskcolor="#d1d1d1" />
<g
inkscape:groupmode="layer"
id="layer3"
@ -270,7 +289,7 @@
inkscape:groupmode="layer"
id="g373"
inkscape:label="[export] shield [OO_doc]"
style="display:inline">
style="display:none">
<g
id="g371"
transform="matrix(1.4853714,0,0,1.4853714,12.798765,-0.61151946)">
@ -593,24 +612,62 @@
style="fill:#2c9e98;fill-opacity:1">
<path
d="m 128.98,30.355 0.55499,39.644 h 33.141 l 0.004,-39.644 z"
style="fill:#a72ba7;fill-opacity:0.4"
style="fill:#8f40f5;fill-opacity:0.4"
id="path1009" />
<path
d="m 162.69,70 0.003,43.946 c 12.825,-5.8796 32.762,-17.077 33.127,-43.157 l 0.0108,-0.78911 z"
style="fill:#a72ba7;fill-opacity:0.4"
style="fill:#8f40f5;fill-opacity:0.4"
id="path1011" />
<path
id="path1013"
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-variant-east-asian:normal;font-feature-settings:normal;font-variation-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;shape-margin:0;inline-size:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:#a72ba7;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:29.7103;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate;stop-color:#000000;stop-opacity:1"
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-variant-east-asian:normal;font-feature-settings:normal;font-variation-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;shape-margin:0;inline-size:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:url(#linearGradient1100);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:29.7103;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate;stop-color:#000000;stop-opacity:1"
d="m 53.544922,4.7617188 0.185547,15.0449222 3.179687,256.695309 c 0.953422,76.9836 32.1134,127.84094 71.929684,161.46875 39.81629,33.62784 87.45761,51.28822 122.63086,67.4668 l 6.66602,3.07031 6.48047,-3.43359 c 31.31498,-16.53917 79.28982,-34.11259 120.05273,-67.35547 40.76297,-33.24293 73.7832,-83.90225 73.7832,-161.40234 V 130.05664 L 323.60742,4.7617188 Z M 83.621094,34.476562 H 291.42578 V 160.75781 H 428.75 v 115.5586 c 0,69.39522 -27.09036,109.2031 -62.86133,138.375 -33.66112,27.45122 -74.94064,43.84038 -108.41015,60.82226 C 221.97696,459.48582 180.81444,442.98268 148.00586,415.27344 113.30228,385.96366 87.475071,345.55977 86.615234,276.13086 Z m 237.519526,8.556641 94.7461,88.021487 h -94.7461 z"
transform="matrix(0.17812685,0,0,0.17812685,116.76305,26.860695)" />
<g
transform="matrix(1.1107,0,0,1.1107,18.926,21.932)"
style="fill:#a72ba7;fill-opacity:1"
style="fill:#8f40f5;fill-opacity:1"
id="g1017">
<path
id="path1015"
style="fill:#a72ba7;fill-opacity:1;stroke-width:6.23544"
style="fill:#8f40f5;fill-opacity:1;stroke-width:6.23544"
d="m 255.8125,188.08008 a 50.622452,50.622452 0 0 0 -50.62305,50.62304 50.622452,50.622452 0 0 0 28.73243,45.64258 L 216.25,377.66602 h 79.14648 l -17.67382,-93.33008 a 50.622452,50.622452 0 0 0 28.71289,-45.63282 50.622452,50.622452 0 0 0 -50.62305,-50.62304 z"
transform="matrix(0.1603735,0,0,0.1603735,88.085934,4.437467)" />
</g>
</g>
</g>
</g>
<g
inkscape:groupmode="layer"
id="g1160"
inkscape:label="[export] shield [diagram]"
style="display:inline">
<g
id="g1158"
transform="matrix(1.4853714,0,0,1.4853714,12.798765,-0.61151946)">
<g
transform="translate(-125.38,-26.449)"
id="g1156"
style="fill:#2c9e98;fill-opacity:1">
<path
d="m 128.98,30.355 0.55499,39.644 h 33.141 l 0.004,-39.644 z"
style="fill:#ce3ad3;fill-opacity:0.40000001"
id="path1146" />
<path
d="m 162.69,70 0.003,43.946 c 12.825,-5.8796 32.762,-17.077 33.127,-43.157 l 0.0108,-0.78911 z"
style="fill:#ce3ad3;fill-opacity:0.40000001"
id="path1148" />
<path
id="path1150"
style="color:#000000;font-style:normal;font-variant:normal;font-weight:normal;font-stretch:normal;font-size:medium;line-height:normal;font-family:sans-serif;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-variant-east-asian:normal;font-feature-settings:normal;font-variation-settings:normal;text-indent:0;text-align:start;text-decoration:none;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000000;letter-spacing:normal;word-spacing:normal;text-transform:none;writing-mode:lr-tb;direction:ltr;text-orientation:mixed;dominant-baseline:auto;baseline-shift:baseline;text-anchor:start;white-space:normal;shape-padding:0;shape-margin:0;inline-size:0;clip-rule:nonzero;display:inline;overflow:visible;visibility:visible;opacity:1;isolation:auto;mix-blend-mode:normal;color-interpolation:sRGB;color-interpolation-filters:linearRGB;solid-color:#000000;solid-opacity:1;vector-effect:none;fill:url(#linearGradient1100);fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:29.7103;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;color-rendering:auto;image-rendering:auto;shape-rendering:auto;text-rendering:auto;enable-background:accumulate;stop-color:#000000;stop-opacity:1"
d="m 53.544922,4.7617188 0.185547,15.0449222 3.179687,256.695309 c 0.953422,76.9836 32.1134,127.84094 71.929684,161.46875 39.81629,33.62784 87.45761,51.28822 122.63086,67.4668 l 6.66602,3.07031 6.48047,-3.43359 c 31.31498,-16.53917 79.28982,-34.11259 120.05273,-67.35547 40.76297,-33.24293 73.7832,-83.90225 73.7832,-161.40234 V 130.05664 L 323.60742,4.7617188 Z M 83.621094,34.476562 H 291.42578 V 160.75781 H 428.75 v 115.5586 c 0,69.39522 -27.09036,109.2031 -62.86133,138.375 -33.66112,27.45122 -74.94064,43.84038 -108.41015,60.82226 C 221.97696,459.48582 180.81444,442.98268 148.00586,415.27344 113.30228,385.96366 87.475071,345.55977 86.615234,276.13086 Z m 237.519526,8.556641 94.7461,88.021487 h -94.7461 z"
transform="matrix(0.17812685,0,0,0.17812685,116.76305,26.860695)" />
<g
transform="matrix(1.1107,0,0,1.1107,18.926,21.932)"
style="fill:#8f40f5;fill-opacity:1"
id="g1154">
<path
id="path1152"
style="fill:#ce3ad3;fill-opacity:1;stroke-width:6.23544"
d="m 255.8125,188.08008 a 50.622452,50.622452 0 0 0 -50.62305,50.62304 50.622452,50.622452 0 0 0 28.73243,45.64258 L 216.25,377.66602 h 79.14648 l -17.67382,-93.33008 a 50.622452,50.622452 0 0 0 28.71289,-45.63282 50.622452,50.622452 0 0 0 -50.62305,-50.62304 z"
transform="matrix(0.1603735,0,0,0.1603735,88.085934,4.437467)" />
</g>

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

View File

@ -8,7 +8,7 @@
<link rel="icon" type="image/png" href="/customize/favicon/main-favicon.png" id="favicon"/>
<script src="/customize/pre-loading.js?ver=1.1"></script>
<link href="/customize/src/pre-loading.css?ver=1.0" rel="stylesheet" type="text/css">
<script async data-bootload="/customize/template.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script>
<script async data-bootload="/customize/template.js" data-main="/common/boot.js?ver=1.0" src="/components/requirejs/require.js?ver=2.3.5"></script>
</head>
<body class="html">
<noscript></noscript>

View File

@ -40,4 +40,5 @@
<glyph unicode="&#xe91e;" glyph-name="form-poll-maybe" horiz-adv-x="1094" d="M856.492 569.333c0 9.987-3.996 19.973-11.187 27.163l-54.325 54.327c-7.191 7.191-17.175 11.185-27.163 11.185s-19.972-3.995-27.163-11.185l-262.048-262.447-117.442 117.841c-7.191 7.19-17.175 11.185-27.163 11.185s-19.972-3.995-27.163-11.185l-54.327-54.327c-7.191-7.191-11.187-17.177-11.187-27.163s3.995-19.973 11.187-27.163l198.932-198.932c7.191-7.191 17.175-11.185 27.163-11.185s19.972 3.995 27.163 11.185l343.538 343.537c7.192 7.191 11.187 17.177 11.187 27.163zM65.903 444.364q0 120.454 35 225.454 35.454 105 101.818 184.090h73.636q-65.454-87.727-98.636-192.727-32.727-105-32.727-215.908 0-109.091 33.636-213.181t96.818-189.999h-72.727q-66.818 77.273-101.818 180.454t-35 221.818zM1027.916 444.364q0-119.545-35.454-222.727-35-103.182-101.363-179.545h-72.727q63.182 85.454 96.818 189.545 33.636 104.545 33.636 213.636 0 110.909-33.182 215.908-32.727 105-98.182 192.727h73.636q66.818-79.545 101.818-184.999 35-105 35-224.545z" />
<glyph unicode="&#xe91f;" glyph-name="destroy" horiz-adv-x="1094" d="M191.671 946.511c-28.024 0-50.742-22.731-50.742-50.756v-399.384h-74.822c-29.561-0.003-53.524-23.966-53.527-53.527 0.025-29.545 23.982-53.484 53.527-53.487h174.773l-88.853-95.929 95.929-104.66-95.835-104.524 121.3-132.182 43.855 55.423-70.369 76.813 95.781 104.471-95.687 104.364 89.122 96.225h190.445l-88.853-95.929 95.929-104.66-95.781-104.524 121.246-132.182 43.855 55.423-70.369 76.813 95.781 104.471-95.687 104.364 89.122 96.225h190.445l-88.853-95.929 95.983-104.66-95.835-104.524 121.286-132.182 43.815 55.423-70.383 76.759 95.835 104.524-95.727 104.417 89.122 96.171h117.318c29.55-0.004 53.515 23.936 53.54 53.487-0.003 29.566-23.974 53.531-53.54 53.527h-71.728v162.501c0 28.024-16.394 67.15-35.958 86.714l-164.966 164.966c-19.564 19.564-58.69 35.958-86.714 35.958zM208.607 878.832h406.073v-219.96c0-28.024 22.745-50.769 50.769-50.769h219.96v-111.732h-676.802zM682.359 874.608c8.989-3.172 17.984-7.936 21.685-11.636l165.505-165.505c3.702-3.7 8.463-12.696 11.636-21.685h-198.826z" />
<glyph unicode="&#xe920;" glyph-name="drive" horiz-adv-x="878" d="M586.884 124.372c0 23.946-19.592 43.537-43.537 43.537s-43.537-19.592-43.537-43.537c0-23.946 19.592-43.537 43.537-43.537s43.537 19.592 43.537 43.537zM726.203 124.372c0 23.946-19.592 43.537-43.537 43.537s-43.537-19.592-43.537-43.537c0-23.946 19.592-43.537 43.537-43.537s43.537 19.592 43.537 43.537zM787.155 37.298c0-9.252-8.163-17.415-17.415-17.415h-661.767c-9.252 0-17.415 8.163-17.415 17.415v174.149c0 9.252 8.163 17.415 17.415 17.415h661.767c9.252 0 17.415-8.163 17.415-17.415zM117.77 298.521l85.442 262.312c2.721 9.252 13.061 16.326 22.857 16.326h425.577c9.796 0 20.136-7.075 22.857-16.326l85.442-262.312zM856.815 211.447c0 14.694-4.354 27.211-8.707 40.816l-107.211 329.795c-12.517 38.095-48.979 64.762-89.251 64.762h-425.577c-40.272 0-76.734-26.667-89.251-64.762l-107.211-329.795c-4.354-13.605-8.707-26.122-8.707-40.816v-174.149c0-47.891 39.184-87.075 87.075-87.075h661.767c47.891 0 87.075 39.184 87.075 87.075z" />
<glyph unicode="&#xe921;" glyph-name="diagram" horiz-adv-x="878" d="M827.746 734.667l-173.333 173.333c-20.556 20.556-61.667 37.778-91.111 37.778h-497.778c-29.444 0-53.333-23.889-53.333-53.333v-888.889c0-29.444 23.889-53.333 53.333-53.333h746.667c29.444 0 53.333 23.889 53.333 53.333v640c0 29.444-17.222 70.556-37.778 91.111zM581.079 870.222c9.444-3.333 18.889-8.333 22.778-12.222l173.889-173.889c3.889-3.889 8.889-13.333 12.222-22.778h-208.889zM794.413 21.333h-711.111v853.333h426.667v-231.111c0-29.444 23.889-53.333 53.333-53.333h231.111zM142.19 699.381v-233.667h242.476l103.19-129.667-66.571 7.571c-0.464 0.060-1 0.095-1.544 0.095-6.469 0-11.802-4.862-12.545-11.13l-0.006-0.060c-0.043-0.393-0.068-0.848-0.068-1.309 0-6.459 4.847-11.786 11.103-12.542l0.060-0.006 119.095-13.714 13.667 119.095c0.059 0.459 0.093 0.99 0.093 1.529 0 6.519-4.937 11.884-11.276 12.562l-0.055 0.005c-0.401 0.045-0.865 0.071-1.336 0.071-6.528 0-11.899-4.95-12.564-11.302l-0.005-0.055-7.571-66.571-101.952 128.143v210.952h-274.19zM190.952 650.619h176.667v-136.143h-176.667v136.143zM628.143 307.952c-64.030 0-117.525-47.559-126.857-109.095l-73.095 0.762 47.857 46.81c2.398 2.303 3.888 5.536 3.888 9.117 0 3.487-1.413 6.645-3.698 8.931v0c-2.288 2.295-5.452 3.714-8.947 3.714-0.018 0-0.037 0-0.055 0h0.003c-0.058 0.001-0.127 0.002-0.196 0.002-3.425 0-6.532-1.363-8.807-3.576l0.003 0.003-85.524-83.81 83.809-85.714c2.293-2.327 5.478-3.768 9.001-3.768 3.427 0 6.536 1.365 8.812 3.58l-0.003-0.003c2.327 2.293 3.768 5.478 3.768 9.001 0 3.427-1.365 6.536-3.58 8.812l0.003-0.003-46.952 47.857 73.81-0.714c9.614-61.186 62.962-108.286 126.762-108.286 70.545 0 128.095 57.551 128.095 128.095s-57.551 128.286-128.095 128.286zM626.095 259.19c0.686 0.017 1.357 0 2.048 0 44.192 0 79.333-35.332 79.333-79.524s-35.141-79.524-79.333-79.524c-44.192 0-79.524 35.332-79.524 79.524 0 43.501 34.254 78.443 77.476 79.524zM244.048 438.143l-85.381-84.048c-2.384-2.301-3.864-5.525-3.864-9.094 0-3.475 1.403-6.623 3.674-8.907l-0.001 0.001c2.295-2.341 5.49-3.792 9.024-3.792s6.729 1.451 9.022 3.79l0.002 0.002 47.571 46.762-0.952-117.667h-80.952v-191.286h202.381v191.286h-82.381l0.905 117.571 47.143-47.81c2.29-2.312 5.466-3.744 8.977-3.744 3.439 0 6.557 1.374 8.835 3.603l-0.002-0.002c2.384 2.301 3.864 5.525 3.864 9.094 0 3.475-1.403 6.623-3.674 8.907l0.001-0.001-84.19 85.333zM190.952 216.429h104.857v-93.762h-104.857v93.762z" />
</font></defs></svg>

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 44 KiB

View File

@ -1,10 +1,10 @@
@font-face {
font-family: 'cptools';
src: url('fonts/cptools.eot?pmxg4b');
src: url('fonts/cptools.eot?pmxg4b#iefix') format('embedded-opentype'),
url('fonts/cptools.ttf?pmxg4b') format('truetype'),
url('fonts/cptools.woff?pmxg4b') format('woff'),
url('fonts/cptools.svg?pmxg4b#cptools') format('svg');
src: url('fonts/cptools.eot?nobkj');
src: url('fonts/cptools.eot?nobkj#iefix') format('embedded-opentype'),
url('fonts/cptools.ttf?nobkj') format('truetype'),
url('fonts/cptools.woff?nobkj') format('woff'),
url('fonts/cptools.svg?nobkj#cptools') format('svg');
font-weight: normal;
font-style: normal;
font-display: block;
@ -26,6 +26,9 @@
-moz-osx-font-smoothing: grayscale;
}
.cptools-diagram:before {
content: "\e921";
}
.cptools-drive:before {
content: "\e920";
}

View File

@ -1,6 +1,6 @@
/* Open Sans @font-face kit */
@OpenSansPath: "/bower_components/open-sans-fontface/fonts";
@OpenSansPath: "/components/open-sans-fontface/fonts";
/* BEGIN Light */
@font-face {

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -8,7 +8,7 @@
<link rel="icon" type="image/png" href="/customize/favicon/main-favicon.png" id="favicon"/>
<script src="/customize/pre-loading.js?ver=1.1"></script>
<link href="/customize/src/pre-loading.css?ver=1.0" rel="stylesheet" type="text/css">
<script async data-bootload="/customize/template.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script>
<script async data-bootload="/customize/template.js" data-main="/common/boot.js?ver=1.0" src="/components/requirejs/require.js?ver=2.3.5"></script>
</head>
<body class="html">
<noscript></noscript>

View File

@ -1,25 +1,26 @@
define([
'jquery',
'chainpad-listmap',
'/bower_components/chainpad-crypto/crypto.js',
'/components/chainpad-crypto/crypto.js',
'/common/common-util.js',
'/common/outer/network-config.js',
'/common/common-credential.js',
'/bower_components/chainpad/chainpad.dist.js',
'/components/chainpad/chainpad.dist.js',
'/common/common-realtime.js',
'/common/common-constants.js',
'/common/common-interface.js',
'/common/common-feedback.js',
'/common/outer/local-store.js',
'/customize/messages.js',
'/bower_components/nthen/index.js',
'/components/nthen/index.js',
'/common/outer/login-block.js',
'/common/common-hash.js',
'/common/outer/http-command.js',
'/bower_components/tweetnacl/nacl-fast.min.js',
'/bower_components/scrypt-async/scrypt-async.min.js', // better load speed
'/components/tweetnacl/nacl-fast.min.js',
'/components/scrypt-async/scrypt-async.min.js', // better load speed
], function ($, Listmap, Crypto, Util, NetConfig, Cred, ChainPad, Realtime, Constants, UI,
Feedback, LocalStore, Messages, nThen, Block, Hash) {
Feedback, LocalStore, Messages, nThen, Block, Hash, ServerCommand) {
var Exports = {
Cred: Cred,
Block: Block,
@ -99,7 +100,6 @@ define([
opt.channelHex = parsed.channel;
opt.keys = parsed.keys;
opt.edPublic = blockInfo.edPublic;
opt.User_name = blockInfo.User_name;
return opt;
};
@ -135,7 +135,7 @@ define([
Exports.mergeAnonDrive = 1;
};
Exports.loginOrRegister = function (uname, passwd, isRegister, shouldImport, cb) {
Exports.loginOrRegister = function (uname, passwd, isRegister, shouldImport, onOTP, cb) {
if (typeof(cb) !== 'function') { return; }
// Usernames are all lowercase. No going back on this one
@ -173,26 +173,113 @@ define([
// determine where a block for your set of keys would be stored
blockUrl = Block.getBlockUrl(res.opt.blockKeys);
// Check whether there is a block at that location
Util.fetch(blockUrl, waitFor(function (err, block) {
// if users try to log in or register, we must check
// whether there is a block.
var TOTP_prompt = function (err, cb) {
onOTP(function (code) {
ServerCommand(res.opt.blockKeys.sign, {
command: 'TOTP_VALIDATE',
code: code,
// TODO optionally allow the user to specify a lifetime for this session?
// this will require a little bit of server work
// and more UI/UX:
// ie. just a simple "remember me" checkbox?
// allow them to specify a lifetime for the session?
// "log me out after one day"?
}, cb);
}, false, err);
};
// the block is only useful if it can be decrypted, though
if (err) {
console.log("no block found");
return;
}
var done = waitFor();
var responseToDecryptedBlock = function (response, cb) {
response.arrayBuffer().then(arraybuffer => {
arraybuffer = new Uint8Array(arraybuffer);
var decryptedBlock = Block.decrypt(arraybuffer, blockKeys);
if (!decryptedBlock) {
console.error("BLOCK DECRYPTION ERROR");
return void cb("BLOCK_DECRYPTION_ERROR");
}
cb(void 0, decryptedBlock);
});
};
var decryptedBlock = Block.decrypt(block, blockKeys);
if (!decryptedBlock) {
console.error("Found a login block but failed to decrypt");
return;
}
var TOTP_response;
nThen(function (w) {
Util.getBlock(blockUrl, {
// request the block without credentials
}, w(function (err, response) {
if (err === 401) {
return void console.log("Block requires 2FA");
}
//console.error(decryptedBlock);
res.blockInfo = decryptedBlock;
}));
// Some other error?
if (err) {
console.error(err);
w.abort();
return void done();
}
// If the block was returned without requiring authentication
// then we can abort the subsequent steps of this nested nThen
w.abort();
// decrypt the response and continue the normal procedure with its payload
responseToDecryptedBlock(response, function (err, decryptedBlock) {
if (err) {
// if a block was present but you were not able to decrypt it...
console.error(err);
waitFor.abort();
return void cb(err);
}
res.blockInfo = decryptedBlock;
done();
});
}));
}).nThen(function (w) {
// if you're here then you need to request a JWT
var done = w();
var tries = 3;
var ask = function () {
if (!tries) {
w.abort();
waitFor.abort();
return void cb('TOTP_ATTEMPTS_EXHAUSTED');
}
tries--;
TOTP_prompt(tries !== 2, function (err, response) {
// ask again until your number of tries are exhausted
if (err) {
console.error(err);
console.log("Normal failure. Asking again...");
return void ask();
}
if (!response || !response.bearer) {
console.log(response);
console.log("Unexpected failure. No bearer token. Asking again");
return void ask();
}
console.log("Successfully retrieved a bearer token");
res.TOTP_token = TOTP_response = response;
done();
});
};
ask();
}).nThen(function (w) {
Util.getBlock(blockUrl, TOTP_response, function (err, response) {
if (err) {
w.abort();
console.error(err);
return void cb('BLOCK_ERROR_3');
}
responseToDecryptedBlock(response, function (err, decryptedBlock) {
if (err) {
waitFor.abort();
return void cb(err);
}
res.blockInfo = decryptedBlock;
done();
});
});
});
}).nThen(function (waitFor) {
// we assume that if there is a block, it was created in a valid manner
// so, just proceed to the next block which handles that stuff
@ -275,7 +362,7 @@ define([
Realtime.whenRealtimeSyncs(rt.realtime, function () {
// the following stages are there to initialize a new drive
// if you are registering
LocalStore.login(res.userHash, res.userName, function () {
LocalStore.login(res.userHash, undefined, res.userName, function () {
setTimeout(function () { cb(void 0, res); });
});
});
@ -348,7 +435,6 @@ define([
}
if (!isRegister && !isProxyEmpty(rt.proxy)) {
LocalStore.setBlockHash(blockHash);
waitFor.abort();
if (shouldImport) {
setMergeAnonDrive();
@ -358,7 +444,11 @@ define([
if (l) {
localStorage.setItem(LS_LANG, l);
}
return void LocalStore.login(userHash, uname, function () {
if (res.TOTP_token && res.TOTP_token.bearer) {
LocalStore.setSessionToken(res.TOTP_token.bearer);
}
return void LocalStore.login(undefined, blockHash, uname, function () {
cb(void 0, res);
});
}
@ -409,12 +499,13 @@ define([
// Finally, create the login block for the object you just created.
var toPublish = {};
toPublish[Constants.userNameKey] = uname;
toPublish[Constants.userHashKey] = userHash;
toPublish.edPublic = RT.proxy.edPublic;
var blockRequest = Block.serialize(JSON.stringify(toPublish), res.opt.blockKeys);
rpc.writeLoginBlock(blockRequest, waitFor(function (e) {
Block.writeLoginBlock({
blockKeys: blockKeys,
content: toPublish
}, waitFor(function (e) {
if (e) {
console.error(e);
waitFor.abort();
@ -431,8 +522,7 @@ define([
}
console.log("blockInfo available at:", blockHash);
LocalStore.setBlockHash(blockHash);
LocalStore.login(userHash, uname, function () {
LocalStore.login(undefined, blockHash, uname, function () {
cb(void 0, res);
});
}));
@ -458,7 +548,7 @@ define([
};
var hashing;
Exports.loginOrRegisterUI = function (uname, passwd, isRegister, shouldImport, testing, test) {
Exports.loginOrRegisterUI = function (uname, passwd, isRegister, shouldImport, onOTP, testing, test) {
if (hashing) { return void console.log("hashing is already in progress"); }
hashing = true;
@ -483,7 +573,7 @@ define([
// We need a setTimeout(cb, 0) otherwise the loading screen is only displayed
// after hashing the password
window.setTimeout(function () {
Exports.loginOrRegister(uname, passwd, isRegister, shouldImport, function (err, result) {
Exports.loginOrRegister(uname, passwd, isRegister, shouldImport, onOTP, function (err, result) {
var proxy;
if (result) { proxy = result.proxy; }
@ -537,11 +627,9 @@ define([
proxy[Constants.displayNameKey] = uname;
}
if (result.blockHash) {
LocalStore.setBlockHash(result.blockHash);
}
LocalStore.login(result.userHash, result.userName, function () {
var block = result.blockHash;
var user = block ? undefined : result.userHash;
LocalStore.login(user, block, result.userName, function () {
setTimeout(function () { proceed(result); });
});
});

View File

@ -95,7 +95,7 @@ define([
return h('a', attrs, [icon, text]);
};
Pages.versionString = "5.3.0";
Pages.versionString = "5.4.0";
var customURLs = Pages.customURLs = {};
(function () {

View File

@ -35,6 +35,7 @@ define([
};
return function () {
document.title = Msg.homePage;
var icons = [
[ 'sheet', Msg.type.sheet],
[ 'doc', Msg.type.doc],
@ -43,7 +44,7 @@ define([
[ 'kanban', Msg.type.kanban],
[ 'code', Msg.type.code],
[ 'form', Msg.type.form],
[ 'whiteboard', Msg.type.whiteboard],
[ 'diagram', Msg.type.diagram],
[ 'slide', Msg.type.slide]
].filter(function (x) {
return isAvailableType(x[0]);

View File

@ -0,0 +1,85 @@
define([
'/api/config',
'jquery',
'/common/hyperscript.js',
'/common/common-interface.js',
'/customize/messages.js',
'/customize/pages.js'
], function (Config, $, h, UI, Msg, Pages) {
return function () {
document.title = Msg.recovery_header;
var frame = function (content) {
return [
h('div#cp-main', [
Pages.infopageTopbar(),
h('div.container.cp-container', [
h('div.row.cp-page-title', h('h1', Msg.recovery_header)),
].concat(content)),
Pages.infopageFooter(),
]),
];
};
return frame([
h('div.row.cp-recovery-det', [
h('div.hidden.col-md-3'),
h('div#userForm.form-group.hidden.col-md-6', [
h('div.cp-recovery-step.step1', [
h('p', Msg.recovery_mfa_description),
h('div.alert.alert-danger.wrong-cred.cp-hidden', Msg.login_noSuchUser),
h('input.form-control#username', {
type: 'text',
autocomplete: 'off',
autocorrect: 'off',
autocapitalize: 'off',
spellcheck: false,
placeholder: Msg.login_username,
autofocus: true,
}),
h('input.form-control#password', {
type: 'password',
placeholder: Msg.login_password,
}),
h('div.cp-recover-button',
h('button.btn.btn-primary#cp-recover-login', Msg.continue)
)
]),
h('div.cp-recovery-step.step2', { style: 'display: none;' }, [
h('label', Msg.recovery_mfa_secret),
h('input.form-control#mfarecovery', {
type: 'text',
autocomplete: 'off',
autocorrect: 'off',
autocapitalize: 'off',
spellcheck: false,
placeholder: Msg.recovery_mfa_secret_ph,
autofocus: true,
}),
h('div.cp-recovery-forgot', [
h('i.fa.fa-caret-right'),
h('span', Msg.recovery_forgot)
]),
h('div.cp-recovery-alt', { style: 'display: none;' }, [
UI.setHTML(h('div'),
Msg._getKey('recovery_forgot_text', [Config.adminEmail || ''])),
h('textarea.cp-recover-email', {readonly: 'readonly'}),
h('button.btn.btn-secondary#mfacopyproof', Msg.copyToClipboard),
]),
h('div.cp-recover-button',
h('button.btn.btn-primary#cp-recover', Msg.mfa_disable)
)
]),
h('div.cp-recovery-step.step-info', { style: 'display: none;' }, [
h('div.alert.alert-info.cp-hidden.disabled', Msg.recovery_mfa_disabled),
h('div.alert.alert-danger.cp-hidden.unknown-error', Msg.recovery_mfa_error),
]),
]),
h('div.hidden.col-md-3'),
])
]);
};
});

View File

@ -33,6 +33,7 @@
font-size: large;
box-shadow: @alertify_box-shadow;
border-radius: @variables_radius;
&, &.default {
// FIXME
background: @cp_alertify-log-bg;
@ -487,5 +488,32 @@
overflow-x: auto;
}
}
// XXX this might not be the best place for this.
// I just put it next to other "share" styles
// --Aaron
#cp-qr-container {
position: relative;
background-color: white;
display: inline-flex;
padding: @alertify_padding-base;
border-radius: @variables_radius_L;
#cp-qr-blocker {
position: absolute;
height: 100%;
width: 100%;
border-radius: @variables_radius_L;
margin: -@alertify_padding-base;
padding-top: @alertify_padding-base * 2;
text-align: center;
background: @cryptpad_color_brand;
color: @cryptpad_text_col;
font-weight: bold;
&.hidden {
opacity: 0;
}
}
}
}

View File

@ -11,7 +11,8 @@
slide: #e57614;
poll: #2c9e98;
form: #2c9e98;
whiteboard: #a72ba7;
whiteboard: #8f40f5;
diagram: #ce3ad3;
kanban: #8C4;
sheet: #40865c;
doc: #5170B5;
@ -59,6 +60,7 @@
@cryptpad_color_red_fader: fade(@cryptpad_color_red, 15%);
@cryptpad_color_warn_red: @cryptpad_color_red_fade;
@cryptpad_color_dark_red: #9e0000;
@cryptpad_color_mid_red: #FF8880;
@cryptpad_color_light_red: #FFD4D4;
@cryptpad_color_light_red_fade: fade(@cryptpad_color_light_red, 20%);
@cryptpad_color_orange: #f49842;
@ -180,6 +182,10 @@
@cp_sidebar-left-active-fg: @cryptpad_color_grey_900;
@cp_sidebar-hint: fade(@cryptpad_text_col, 80%);
// Settings
@cp_settings_enabled: @cryptpad_color_light_green;
@cp_settings_disabled: @cryptpad_color_mid_red;
// Drive
@cp_drive-bg: @cp_sidebar-right-bg;
@cp_drive-fg: @cp_sidebar-right-fg;

View File

@ -11,7 +11,8 @@
slide: #e57614;
poll: #2c9e98;
form: #2c9e98;
whiteboard: #a72ba7;
whiteboard: #8f40f5;
diagram: #ce3ad3;
kanban: #8C4;
sheet: #40865c;
doc: #5170B5;
@ -59,6 +60,7 @@
@cryptpad_color_red_fader: fade(@cryptpad_color_red, 15%);
@cryptpad_color_warn_red: @cryptpad_color_red_fade;
@cryptpad_color_dark_red: #9e0000;
@cryptpad_color_mid_red: #FF8880;
@cryptpad_color_light_red: #FFD4D4;
@cryptpad_color_light_red_fade: fade(@cryptpad_color_light_red, 75%);
@cryptpad_color_orange: #f49842;
@ -179,6 +181,10 @@
@cp_sidebar-left-active-fg: @cryptpad_color_grey_200;
@cp_sidebar-hint: @cryptpad_color_grey_600;
// Settings
@cp_settings_enabled: extract(@cp_palette-dark, 4);
@cp_settings_disabled: @cryptpad_color_dark_red;
// Drive
@cp_drive-bg: @cp_sidebar-right-bg;
@cp_drive-fg: @cp_sidebar-right-fg;

View File

@ -291,7 +291,14 @@
margin-bottom: 3px;
margin-left: -2px;
}
.droppable-tree-element () {
.cp-app-drive-element-droppable {
background-color: @cp_drive-droppable-bg !important;
color: @cp_drive-droppable-fg !important;
}
}
.cp-app-drive-tree-docs {
.droppable-tree-element();
margin-top: 15px;
//padding: 0 0 0 20px;
padding: 0;
@ -346,6 +353,7 @@
border-radius: @variables_radius;
box-shadow: @cryptpad_ui_shadow;
.cp-app-drive-tree-root {
.droppable-tree-element();
.fa-trash-o {
padding-left: 2px;
}

View File

@ -24,6 +24,7 @@
font-size: @colortheme_app-font-size;
}
&[readonly] {
//margin-top:1rem;
background-color: @cp_forms-readonly;
border-color: @cp_forms-readonly-border;
color: @cp_forms-fg;
@ -53,6 +54,16 @@
}
}
}
&.mfa-enabled {
span, i {
color: @cp_settings_enabled;
}
}
&.mfa-disabled {
span, i {
color: @cp_settings_disabled;
}
}
textarea, div.cp-textarea {
padding: 8px;
@ -101,12 +112,8 @@
}
}
}
button.cp-button-confirm-placeholder:not(.new) {
margin-bottom: 3px !important;
}
button.btn {
background-color: @cp_buttons-cancel;
box-sizing: border-box;
outline: 0;
@ -167,6 +174,7 @@
}
}
&.danger-alt, &.btn-danger-alt, &.btn-danger-outline {
border-color: @cp_buttons-red;
color: @cp_buttons-red-text;
@ -177,11 +185,10 @@
}
}
&.primary, &.btn-primary, &.btn-success {
&.primary, &.btn-primary, &.btn-success, &.disable-button {
background-color: @cp_buttons-primary;
color: @cp_buttons-primary-text;
border-color: @cp_buttons-primary-border;
font-weight: bold;
&:hover, &:not(:disabled):active, &:focus {
color: @cp_buttons-primary-text;
border-color: @cp_buttons-primary-border;

View File

@ -20,6 +20,11 @@
.infopages_main () {
--LessLoader_require: LessLoader_currentFile();
}
.cp-loading-noscroll {
overflow: hidden;
}
body.html {
.font_main();
@infopages_infobar-height: 64px;
@ -105,7 +110,7 @@ body.html {
filter: @cp_static-img-invert-filter;
}
button {
button:not(.btn) {
outline: none;
background-color: @cp_buttons-primary;
color: @cp_buttons-primary-text;

View File

@ -6,11 +6,11 @@
#cp-loading {
@font-face {
font-family: 'Open Sans';
src: url('/bower_components/open-sans-fontface/fonts/Regular/OpenSans-Regular.eot');
src: url('/bower_components/open-sans-fontface/fonts/Regular/OpenSans-Regular.eot?#iefix') format('embedded-opentype'),
url('/bower_components/open-sans-fontface/fonts/Regular/OpenSans-Regular.woff') format('woff'),
url('/bower_components/open-sans-fontface/fonts/Regular/OpenSans-Regular.ttf') format('truetype'),
url('/bower_components/open-sans-fontface/fonts/Regular/OpenSans-Regular.svg#OpenSansRegular') format('svg');
src: url('/components/open-sans-fontface/fonts/Regular/OpenSans-Regular.eot');
src: url('/components/open-sans-fontface/fonts/Regular/OpenSans-Regular.eot?#iefix') format('embedded-opentype'),
url('/components/open-sans-fontface/fonts/Regular/OpenSans-Regular.woff') format('woff'),
url('/components/open-sans-fontface/fonts/Regular/OpenSans-Regular.ttf') format('truetype'),
url('/components/open-sans-fontface/fonts/Regular/OpenSans-Regular.svg#OpenSansRegular') format('svg');
font-weight: normal;
font-style: normal;
}

View File

@ -18,6 +18,11 @@
display: flex;
flex: 1;
min-height: 0;
@media(min-width:800px) {
#cp-sidebarlayout-leftside {
overflow-y: scroll;
}
}
#cp-sidebarlayout-leftside {
color: @cp_sidebar-left-fg;
width: 250px;
@ -98,6 +103,9 @@
}
margin-bottom: 20px;
}
.secret-code {
margin-top: 1rem;
}
[type="text"], [type="password"], button {
vertical-align: middle;
min-width: 40px;
@ -127,11 +135,11 @@
&>div {
margin: 10px 0;
}
button.btn {
margin: 0 5px 0 0;
}
//button.btn {
// margin: 0 5px 0 0;
//}
span.cp-password-container {
margin-bottom: 1px;
margin-bottom: 1rem;
}
}
@media screen and (max-width: @browser_media-medium-screen) {

View File

@ -53,6 +53,13 @@
}
}
}
.cp-password-form {
flex-flow: row !important;
input:not(:last-child) {
margin-right: 10px;
}
}
.cp-container {
padding-top: 3em;
min-height: 66vh;

View File

@ -0,0 +1,115 @@
@import (reference) "../include/infopages.less";
@import (reference) "../include/colortheme-all.less";
@import (reference) "../include/alertify.less";
@import (reference) "../include/checkmark.less";
@import (reference) "../include/forms.less";
&.cp-page-recovery {
.infopages_main();
.forms_main();
.alertify_main();
.checkmark_main(20px);
.cp-container {
.alert {
font-size: @colortheme_app-font-size;
}
.form-group {
.cp-recovery-desc {
margin-bottom: 10px;
}
.cp-recovery-desc, .cp-recovery-step {
width: 100%;
}
#register {
&.btn {
padding: .5rem .5rem;
}
margin-top: 16px;
font-size: 1.25em;
min-width: 30%;
}
}
padding-bottom: 3em;
min-height: 5vh;
.cp-hidden {
display: none;
}
}
.alertify {
// workaround for alertify making empty p
p:empty {
display: none;
}
nav {
display: flex;
align-items: center;
justify-content: flex-end;
}
@media screen and (max-width: 600px) {
nav .btn-danger {
line-height: inherit;
}
}
}
.cp-recovery-det {
.cp-recover-button {
text-align: right;
}
.cp-recovery-forgot {
cursor: pointer;
i {
margin-right: 5px;
width: 10px;
}
}
.cp-recovery-method {
padding: 5px;
border: 1px solid white;
border-radius: 5px;
&:not(:last-child) {
margin-bottom: 10px;
}
h3 {
margin-top: 0;
}
}
.cp-recover-email {
height: 164px;
overflow: scroll;
}
//for chrome,safari
.cp-recover-email::-webkit-scrollbar {
width: 0;
}
.btn-secondary {
margin-top: 1rem;
}
#userForm {
padding: 15px;
background-color: @cp_static-card-bg;
position: relative;
z-index: 2;
margin-bottom: 100px;
border-radius: @infopages-radius-L;
.cp-shadow();
.form-control {
border-radius: @infopages-radius;
color: @cryptpad_text_col;
background-color: @cp_forms-bg;
margin-bottom: 10px;
&:focus {
border-color: @cryptpad_color_brand;
}
.tools_placeholder-color();
}
}
}
}

View File

@ -18,3 +18,4 @@ iframe-placeholder, #sbox-iframe, #sbox-secure-iframe {
padding:0;
overflow:hidden;
}

View File

@ -8,7 +8,7 @@
<link rel="icon" type="image/png" href="/customize/favicon/main-favicon.png" id="favicon"/>
<script src="/customize/pre-loading.js?ver=1.1"></script>
<link href="/customize/src/pre-loading.css?ver=1.0" rel="stylesheet" type="text/css">
<script async data-bootload="/customize/template.js" data-main="/common/boot.js?ver=1.0" src="/bower_components/requirejs/require.js?ver=2.3.5"></script>
<script async data-bootload="/customize/template.js" data-main="/common/boot.js?ver=1.0" src="/components/requirejs/require.js?ver=2.3.5"></script>
</head>
<body class="html">
<noscript></noscript>

View File

@ -2,9 +2,9 @@ define([
'jquery',
'/common/hyperscript.js',
'/customize/pages.js',
'/bower_components/nthen/index.js',
'/components/nthen/index.js',
'css!/bower_components/components-font-awesome/css/font-awesome.min.css',
'css!/components/components-font-awesome/css/font-awesome.min.css',
], function ($, h, Pages, nThen) {
// we consider that there is no valid reason to load any of the info pages
// in an iframe. abort everything if you detect that you are embedded.
@ -41,11 +41,19 @@ $(function () {
});
}).nThen(function () {
require([
'/api/config',
'/common/common-util.js',
'optional!/api/instance',
'less!/customize/src/less2/pages/page-' + pageName + '.less',
'css!/bower_components/bootstrap/dist/css/bootstrap.min.css',
'css!/components/bootstrap/dist/css/bootstrap.min.css',
'css!/customize/fonts/cptools/style.css'
], function () {
], function (ApiConfig, Util, Instance) {
var $main = $(infoPage());
var titleSuffix = (Util.find(Instance, ['name','default']) || '').trim();
if (!titleSuffix || titleSuffix === ApiConfig.httpUnsafeOrigin) {
titleSuffix = window.location.hostname;
}
document.title = document.title + ' - ' + titleSuffix;
$('#placeholder').remove();
@ -55,6 +63,8 @@ $(function () {
require([ '/register/main.js' ], function () {});
} else if (/^\/install\//.test(pathname)) {
require([ '/install/main.js' ], function () {});
} else if (/^\/recovery\//.test(pathname)) {
require([ '/recovery/main.js' ], function () {});
} else if (/^\/login\//.test(pathname)) {
require([ '/login/main.js' ], function () {});
} else if (/^\/($|^\/index\.html$)/.test(pathname)) {

28
docker-compose.yml Normal file
View File

@ -0,0 +1,28 @@
---
version: '3.8'
services:
cryptpad:
image: "cryptpad/cryptpad:version-5.4.0"
hostname: cryptpad
environment:
- CPAD_MAIN_DOMAIN=https://your-main-domain.com
- CPAD_SANDBOX_DOMAIN=https://your-sandbox-domain.com
- CPAD_CONF=/cryptpad/config/config.js
volumes:
- ./data/blob:/cryptpad/blob
- ./data/block:/cryptpad/block
- ./customize:/cryptpad/customize
- ./data/data:/cryptpad/data
- ./data/files:/cryptpad/datastore
ports:
- "3000:3000"
- "3001:3001"
ulimits:
nofile:
soft: 1000000
hard: 1000000

31
docker-entrypoint.sh Executable file
View File

@ -0,0 +1,31 @@
#/bin/bash
## Required vars
# CPAD_MAIN_DOMAIN
# CPAD_SANDBOX_DOMAIN
# CPAD_CONF
set -e
CPAD_HOME="/cryptpad"
if [ ! -f "$CPAD_CONF" ]; then
echo -e "\n\
#################################################################### \n\
Warning: No config file provided for cryptpad \n\
We will create a basic one for now but you should rerun this service \n\
by providing a file with your settings \n\
eg: docker run -v /path/to/config.js:/cryptpad/config/config.js \n\
#################################################################### \n"
cp "$CPAD_HOME"/config/config.example.js "$CPAD_CONF"
sed -i -e "s@\(httpUnsafeOrigin:\).*[^,]@\1 '$CPAD_MAIN_DOMAIN'@" \
-e "s@\(^ *\).*\(httpSafeOrigin:\).*[^,]@\1\2 '$CPAD_SANDBOX_DOMAIN'@" $CPAD_CONF
fi
cd $CPAD_HOME
npm run build
exec "$@"

View File

@ -79,6 +79,7 @@ server {
add_header X-XSS-Protection "1; mode=block";
add_header X-Content-Type-Options nosniff;
add_header Access-Control-Allow-Origin "${allowed_origins}";
add_header Access-Control-Allow-Credentials true;
# add_header X-Frame-Options "SAMEORIGIN";
# Opt out of Google's FLoC Network
@ -161,6 +162,11 @@ server {
# We've applied other sandboxing techniques to mitigate the risk of running WebAssembly in this privileged scope
if ($uri ~ ^\/unsafeiframe\/inner\.html.*$) { set $unsafe 1; }
# draw.io uses inline script tags in it's index.html. The hashes are added here.
if ($uri ~ ^\/components\/drawio\/src\/main\/webapp\/index.html.*$) {
set $scriptSrc "'self' 'sha256-6zAB96lsBZREqf0sT44BhH1T69sm7HrN34rpMOcWbNo=' 'sha256-6g514VrT/cZFZltSaKxIVNFF46+MFaTSDTPB8WfYK+c=' resource: https://${main_domain}";
}
# privileged contexts allow a few more rights than unprivileged contexts, though limits are still applied
if ($unsafe) {
set $scriptSrc "'self' 'unsafe-eval' 'unsafe-inline' resource: https://${main_domain}";
@ -173,7 +179,12 @@ server {
# We prefer to serve static content from nginx directly and to leave the API server to handle
# the dynamic content that only it can manage. This is primarily an optimization
location ^~ /cryptpad_websocket {
proxy_pass http://localhost:3000;
# XXX
# static assets like blobs and blocks are served by clustered workers in the API server
# Websocket traffic still needs to be handled by the main process, which means it needs
# to be hosted on a different port. By default 3003 will be used, though this is configurable
# via config.websocketPort
proxy_pass http://localhost:3003;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@ -212,10 +223,15 @@ server {
add_header Cross-Origin-Embedder-Policy require-corp;
}
# encrypted blobs are immutable and are thus cached for a year
location ^~ /blob/ {
# Requests for blobs and blocks are now proxied to the API server
# This simplifies NGINX path configuration in the event they are being hosted in a non-standard location
# or with odd unexpected permissions. Serving blobs in this manner also means that it will be possible to
# enforce access control for them, though this is not yet implemented.
# Access control (via TOTP 2FA) has been added to blocks, so they can be handled with the same directives.
location ~ ^/(blob|block)/.*$ {
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' "${allowed_origins}";
add_header 'Access-Control-Allow-Credentials' true;
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Content-Range,Range';
add_header 'Access-Control-Max-Age' 1728000;
@ -223,36 +239,19 @@ server {
add_header 'Content-Length' 0;
return 204;
}
add_header X-Content-Type-Options nosniff;
add_header Cache-Control max-age=31536000;
add_header 'Access-Control-Allow-Origin' "${allowed_origins}";
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Content-Range,Range,Content-Length';
add_header 'Access-Control-Expose-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Content-Range,Range,Content-Length';
try_files $uri =404;
# Since we are proxying to the API server these headers can get duplicated
# so we hide them
proxy_hide_header 'X-Content-Type-Options';
proxy_hide_header 'Access-Control-Allow-Origin';
proxy_hide_header 'Permissions-Policy';
proxy_hide_header 'X-XSS-Protection';^
proxy_pass http://localhost:3000;
}
# the "block-store" serves encrypted payloads containing users' drive keys
# these payloads are unlocked via login credentials. They are mutable
# and are thus never cached. They're small enough that it doesn't matter, in any case.
location ^~ /block/ {
add_header X-Content-Type-Options nosniff;
add_header Cache-Control max-age=0;
try_files $uri =404;
}
# This block provides an alternative means of loading content
# otherwise only served via websocket. This is solely for debugging purposes,
# and is thus not allowed by default.
#location ^~ /datastore/ {
#add_header Cache-Control max-age=0;
#try_files $uri =404;
#}
# The nodejs server has some built-in forwarding rules to prevent
# URLs like /pad from resulting in a 404. This simply adds a trailing slash
# to a variety of applications.
location ~ ^/(register|login|settings|user|pad|drive|poll|slide|code|whiteboard|file|media|profile|contacts|todo|filepicker|debug|kanban|sheet|support|admin|notifications|teams|calendar|presentation|doc|form|report|convert|checkup)$ {
location ~ ^/(register|login|recovery|settings|user|pad|drive|poll|slide|code|whiteboard|file|media|profile|contacts|todo|filepicker|debug|kanban|sheet|support|admin|notifications|teams|calendar|presentation|doc|form|report|convert|checkup|diagram)$ {
rewrite ^(.*)$ $1/ redirect;
}

View File

@ -6,6 +6,7 @@ const Decrees = require("./decrees");
const nThen = require("nthen");
const Fs = require("fs");
const Path = require("path");
const Nacl = require("tweetnacl/nacl-fast");
module.exports.create = function (Env) {
var log = Env.Log;
@ -21,6 +22,21 @@ nThen(function (w) {
console.error(err);
}
}));
}).nThen(function (w) {
// we assume the server has generated a secret used to validate JWT tokens
if (typeof(Env.bearerSecret) === 'string') { return; }
// if one does not exist, then create one and remember it
// 256 bits
var bearerSecret = Nacl.util.encodeBase64(Nacl.randomBytes(32));
Env.Log.info("GENERATING_BEARER_SECRET", {});
Decrees.write(Env, [
'SET_BEARER_SECRET',
[bearerSecret],
'INTERNAL',
+new Date()
], w(function (err) {
if (err) { throw err; }
}));
}).nThen(function (w) {
var fullPath = Path.join(Env.paths.block, 'placeholder.txt');
Fs.writeFile(fullPath, 'PLACEHOLDER\n', w());

View File

@ -0,0 +1,76 @@
const Block = require("../commands/block");
const MFA = require("../storage/mfa");
const Util = require("../common-util");
const Commands = module.exports;
var isValidBlockId = Block.isValidBlockId;
// Read the MFA settings for the given public key
const checkMFA = (Env, publicKey, cb) => {
// Success if we can't get the MFA settings
MFA.read(Env, publicKey, function (err, content) {
if (err) {
if (err.code !== "ENOENT") {
Env.Log.error('TOTP_VALIDATE_MFA_READ', {
error: err,
publicKey: publicKey,
});
}
return void cb();
}
var parsed = Util.tryParse(content);
if (!parsed) { return void cb(); }
cb("NOT_ALLOWED");
});
};
// Make sure the block is not protected by MFA but don't do anything else
const check = Commands.MFA_CHECK = function (Env, body, cb) {
var { publicKey } = body;
if (!isValidBlockId(publicKey)) { return void cb("INVALID_KEY"); }
checkMFA(Env, publicKey, cb);
};
check.complete = function (Env, body, cb) { cb(); };
// Write a login block IFF
// 1. You can sign for the block's public key
// 2. the block is not protected by MFA
// Note: the internal WRITE_LOGIN_BLOCK will check is you're allowed to create this block
const writeBlock = Commands.WRITE_BLOCK = function (Env, body, cb) {
const { publicKey, content } = body;
// they must provide a valid block public key
if (!isValidBlockId(publicKey)) { return void cb("INVALID_KEY"); }
if (publicKey !== content.publicKey) { return void cb("INVALID_KEY"); }
// check MFA
checkMFA(Env, publicKey, cb);
};
writeBlock.complete = function (Env, body, cb) {
const { content } = body;
Block.writeLoginBlock(Env, content, cb);
};
// Remove a login block IFF
// 1. You can sign for the block's public key
// 2. the block is not protected by MFA
const removeBlock = Commands.REMOVE_BLOCK = function (Env, body, cb) {
const { publicKey } = body;
// they must provide a valid block public key
if (!isValidBlockId(publicKey)) { return void cb("INVALID_KEY"); }
// check MFA
checkMFA(Env, publicKey, cb);
};
removeBlock.complete = function (Env, body, cb) {
const { publicKey } = body;
Block.removeLoginBlock(Env, publicKey, cb);
};

View File

@ -0,0 +1,500 @@
/* globals Buffer */
const B32 = require("thirty-two");
const OTP = require("notp");
const nThen = require("nthen");
const Util = require("../common-util");
const MFA = require("../storage/mfa");
const Sessions = require("../storage/sessions");
const BlockStore = require("../storage/block");
const Block = require("../commands/block");
const Commands = module.exports;
var isString = s => typeof(s) === 'string';
// basic definition of what we'll accept as an OTP code
// exactly six numerical digits
var isValidOTP = otp => {
return isString(otp) &&
// in the future this could be updated to support 8 digits
otp.length === 6 &&
// \D is non-digit characters, so this tests that it is exclusively numeric
!/\D/.test(otp);
};
// basic definition of what we'll accept as a recovery key
// 24 bytes encoded as b64 ==> 32 characters
var isValidRecoveryKey = otp => {
return isString(otp) &&
// in the future this could be updated to support 8 digits
otp.length === 32 &&
// \D is non-digit characters, so this tests that it is exclusively numeric
/[A-Za-z0-9+\/]{32}/.test(otp);
};
// we'll only allow users to set up multi-factor auth
// for keypairs they control which already have blocks
// this check doesn't confirm that their id is valid base64
// any attempt relying on this should fail when we can't decode
// the id they provided.
var isValidBlockId = Block.isValidBlockId;
// the base32 library can throw when decoding under various conditions.
// we have some basic requirements for the length of base32 as well,
// so we just do all the validation here. It either returns a buffer
// of length 20 or undefined, so the caller can just check whether it's
// falsey and otherwise assume it was well-formed
// Length === 20 comes from the recommendation of 160 bits of entropy
// in RFC4226 (https://www.rfc-editor.org/rfc/rfc4226#section-4)
var decode32 = S => {
let decoded;
try {
decoded = B32.decode(S);
} catch (err) { return; }
if (!(decoded instanceof Buffer) || decoded.length !== 20) { return; }
return decoded;
};
// XXX Decide expire time
// Allow user settings?
var EXPIRATION = 7 * 24 * 3600 * 1000; // Sessions are valid 7 days
// Create a session with a token for the given public key
const makeSession = (Env, publicKey, cb) => {
const sessionId = Sessions.randomId();
nThen(function (w) {
// store the token
Sessions.write(Env, publicKey, sessionId, JSON.stringify({
mfa: {
type: 'otp',
exp: (+new Date()) + EXPIRATION
}
}), w(function (err) {
if (err) {
Env.Log.error("TOTP_VALIDATE_SESSION_WRITE", {
error: Util.serializeError(err),
publicKey: publicKey,
sessionId: sessionId,
});
w.abort();
return void cb("SESSION_WRITE_ERROR");
}
// else continue
}));
}).nThen(function () {
cb(void 0, {
bearer: sessionId,
});
});
};
// Read the MFA settings for the given public key
const readMFA = (Env, publicKey, cb) => {
// check that there is an MFA configuration for the given account
MFA.read(Env, publicKey, function (err, content) {
if (err) {
Env.Log.error('TOTP_VALIDATE_MFA_READ', {
error: err,
publicKey: publicKey,
});
return void cb('NO_MFA_CONFIGURED');
}
var parsed = Util.tryParse(content);
if (!parsed) { return void cb("INVALID_CONFIGURATION"); }
cb(undefined, parsed);
});
};
// Check if an OTP code is valid against the provided secret
const checkCode = (Env, secret, code, publicKey, _cb) => {
const cb = Util.mkAsync(_cb);
let decoded = decode32(secret);
if (!decoded) {
Env.Log.error("TOTP_VALIDATE_INVALID_SECRET", {
publicKey, // log the public key so the admin can investigate further
// don't log the problematic secret directly as
// logs are likely to be pasted in random places
});
return void cb("E_INVALID_SECRET");
}
// validate the code
var validated = OTP.totp.verify(code, decoded, {
window: 1,
});
if (!validated) {
// I won't worry about logging these OTPs as they shouldn't leak any useful information
Env.Log.error("TOTP_VALIDATE_BAD_OTP", {
code,
});
return void cb("INVALID_OTP");
}
// call back to indicate that their request was well-formed and valid
cb();
};
// This command allows clients to configure TOTP as a second factor protecting
// their login block IFF they:
// 1. provide a sufficiently strong TOTP secret
// 2. are able to produce a valid OTP code for that secret (indicating that their clock is sufficiently close to ours)
// 3. such a login block actually exists
// 4. are able to sign an arbitrary message for the login block's public key
// 5. have not already configured TOTP protection for this account
// (changing to a new secret can be done by disabling and re-enabling TOTP 2FA)
const TOTP_SETUP = Commands.TOTP_SETUP = function (Env, body, cb) {
const { publicKey, secret, code, contact } = body;
// the client MUST provide an OTP code of the expected format
// this doesn't check if it matches the secret and time, just that it's well-formed
if (!isValidOTP(code)) { return void cb("E_INVALID"); }
// if they provide an (optional) point of contact as a recovery mechanism then it should be a string.
// the intent is to allow to specify some side channel for those who inevitably lock themselves out
// we should be able to use that to validate their identity.
// I don't want to assume email, but limiting its length to 254 (the maximum email length) seems fair.
if (contact && (!isString(contact) || contact.length > 254)) { return void cb("INVALID_CONTACT"); }
// Check that the provided public key is the expected format for a block
if (!isValidBlockId(publicKey)) {
return void cb("INVALID_KEY");
}
// decode32 checks whether the secret decodes to a sufficiently long buffer
var decoded = decode32(secret);
if (!decoded) { return void cb('INVALID_SECRET'); }
// Reject attempts to setup TOTP if a record of their preferences already exists
MFA.read(Env, publicKey, function (err) {
// There **should be** an error here, because anything else
// means that a record already exists
// This may need to be adjusted as other methods of MFA are added
if (!err) { return void cb("EEXISTS"); }
// if no MFA settings exist then we expect ENOENT
// anything else indicates a problem and should result in rejection
if (err.code !== 'ENOENT') { return void cb(err); }
try {
// allow for 30s of clock drift in either direction
// returns an object ({ delta: 0 }) indicating the amount of clock drift
// if successful, otherwise `null`
var validated = OTP.totp.verify(code, decoded, {
window: 1,
});
if (!validated) { return void cb("INVALID_OTP"); }
cb();
} catch (err2) {
Env.Log.error('TOTP_SETUP_VERIFICATION_ERROR', {
error: err2,
});
return void cb("INTERNAL_ERROR");
}
});
};
// The 'complete' step for TOTP_SETUP will only be called if the client
// passed earlier validation and successfully signed the server's challenge.
// There's still a little bit more to do and it could still fail.
TOTP_SETUP.complete = function (Env, body, cb) {
// the OTP code should have already been validated
var { publicKey, secret, contact } = body;
// the device from which they configure MFA settings
// is assumed to be safe, so we'll respond with a JWT token
// the remainder of the setup is successfully completed.
// Otherwise they would have to reauthenticate.
// The session id is used as a reference to this particular session.
nThen(function (w) {
// confirm that the block exists
BlockStore.check(Env, publicKey, w(function (err) {
if (err) {
Env.Log.error("TOTP_SETUP_NO_BLOCK", {
publicKey,
});
w.abort();
return void cb("NO_BLOCK");
}
// otherwise the block exists, continue
}));
}).nThen(function (w) {
// store the data you'll need in the future
var data = {
method: 'TOTP', // specify this so it's easier to add other methods later?
secret: secret, // the 160 bit, base32-encoded secret that is used for OTP validation
creation: new Date(), // the moment at which the MFA was configured
};
if (isString(contact)) {
// 'contact' is an arbitary (and optional) string for manual recovery from 2FA auth fails
// it should already be validated
data.contact = contact;
}
// We attempt to store a record of the above preferences
// if it fails then we abort and inform the client of an error.
MFA.write(Env, publicKey, JSON.stringify(data), w(function (err) {
if (err) {
w.abort();
Env.Log.error("TOTP_SETUP_STORAGE_FAILURE", {
publicKey: publicKey,
error: err,
});
return void cb('STORAGE_FAILURE');
}
// otherwise continue
}));
}).nThen(function () {
// we have already stored the MFA data, which will cause access to the resource to be restricted to the provided TOTP secret.
// we attempt to create a session as a matter of convenience - so if it fails
// that just means they'll be forced to authenticate
makeSession(Env, publicKey, cb);
});
};
// This command is somewhat simpler than TOTP_SETUP
// Issue a client a JWT which will allow them to access a login block IFF:
// 1. That login block exists
// 2. That login block is protected by TOTP 2FA
// 3. They can produce a valid OTP for that block's TOTP secret
// 4. They can sign for the block's public key
const validate = Commands.TOTP_VALIDATE = function (Env, body, cb) {
var { publicKey, code } = body;
// they must provide a valid OTP code
if (!isValidOTP(code)) { return void cb('E_INVALID'); }
// they must provide a valid block public key
if (!isValidBlockId(publicKey)) { return void cb("INVALID_KEY"); }
var secret;
nThen(function (w) {
// check that there is an MFA configuration for the given account
readMFA(Env, publicKey, w(function (err, content) {
if (err) {
w.abort();
return void cb(err);
}
secret = content.secret;
}));
}).nThen(function () {
checkCode(Env, secret, code, publicKey, cb);
});
};
validate.complete = function (Env, body, cb) {
/*
if they are here then they:
1. have a valid block configured with TOTP-based 2FA
2. were able to provide a valid TOTP for that block's secret
3. were able to sign their messages for the block's public key
So, we should:
1. instanciate a session for them by generating and storing a token for their public key
2. send them the token
*/
var { publicKey } = body;
makeSession(Env, publicKey, cb);
};
// Same as TOTP_VALIDATE but without making a session at the end
const check = Commands.TOTP_CHECK = function (Env, body, cb) {
var { publicKey, auth } = body;
const code = auth;
if (!isValidOTP(code)) { return void cb('E_INVALID'); }
if (!isValidBlockId(publicKey)) { return void cb("INVALID_KEY"); }
var secret;
nThen(function (w) {
readMFA(Env, publicKey, w(function (err, content) {
if (err) {
w.abort();
return void cb(err);
}
secret = content.secret;
}));
}).nThen(function () {
checkCode(Env, secret, code, publicKey, cb);
});
};
check.complete = function (Env, body, cb) { cb(); };
// Revoke a client TOTP secret which will allow them to disable TOTP for a login block IFF:
// 1. That login block exists
// 2. That login block is protected by TOTP 2FA
// 3. They can produce a valid OTP for that block's TOTP secret
// 4. They can sign for the block's public key
const revoke = Commands.TOTP_REVOKE = function (Env, body, cb) {
var { publicKey, code, recoveryKey } = body;
// they must provide a valid OTP code
if (!isValidOTP(code) && !isValidRecoveryKey(recoveryKey)) { return void cb('E_INVALID'); }
// they must provide a valid block public key
if (!isValidBlockId(publicKey)) { return void cb("INVALID_KEY"); }
var secret, recoveryStored;
nThen(function (w) {
// check that there is an MFA configuration for the given account
readMFA(Env, publicKey, w(function (err, content) {
if (err) {
w.abort();
return void cb(err);
}
secret = content.secret;
recoveryStored = content.contact;
}));
}).nThen(function (w) {
if (!recoveryKey) { return; }
w.abort();
if (!/^secret:/.test(recoveryStored)) {
return void cb("E_NO_RECOVERY_KEY");
}
recoveryStored = recoveryStored.slice(7);
if (recoveryKey !== recoveryStored) {
return void cb("E_WRONG_RECOVERY_KEY");
}
cb();
}).nThen(function () {
checkCode(Env, secret, code, publicKey, cb);
});
};
revoke.complete = function (Env, body, cb) {
/*
if they are here then they:
1. have a valid block configured with TOTP-based 2FA
2. were able to provide a valid TOTP for that block's secret
3. were able to sign their messages for the block's public key
So, we should:
1. Revoke the TOTP authentication for their block
2. Remove all existing sessions
*/
var { publicKey } = body;
MFA.revoke(Env, publicKey, cb);
};
// Write a login block using an existing OTP block IFF
// 1. You can sign for the block's public key
// 2. You have a proof for the old block
// 3. The old block is OTP protected
// 4. The OTP code is valid
// Note: this is used when users change their password
const writeBlock = Commands.TOTP_WRITE_BLOCK = function (Env, body, cb) {
const { publicKey, content } = body;
const code = content.auth;
const registrationProof = content.registrationProof;
// they must provide a valid block public key
if (!isValidBlockId(publicKey)) { return void cb("INVALID_KEY"); }
if (publicKey !== content.publicKey) { return void cb("INVALID_KEY"); }
if (!isValidOTP(code)) { return void cb('E_INVALID'); }
if (!registrationProof) { return void cb('MISSING_ANCESTOR'); }
let secret;
let oldKey;
nThen(function (w) {
Block.validateAncestorProof(Env, registrationProof, w((err, provenKey) => {
if (err || !provenKey) {
w.abort();
return void cb('INVALID_ANCESTOR');
}
oldKey = provenKey;
}));
}).nThen(function (w) {
// check that there is an MFA configuration for the ancestor account
readMFA(Env, oldKey, w(function (err, content) {
if (err) {
w.abort();
return void cb(err);
}
secret = content.secret;
}));
}).nThen(function () {
// check that the OTP code is valid
checkCode(Env, secret, code, oldKey, cb);
});
};
writeBlock.complete = function (Env, body, cb) {
const { publicKey, content } = body;
nThen(function (w) {
// Write new block
Block.writeLoginBlock(Env, content, w((err) => {
if (err) {
w.abort();
return void cb("BLOCK_WRITE_ERROR");
}
}));
}).nThen(function (w) {
// Copy MFA settings
const proof = Util.tryParse(content.registrationProof);
const oldKey = proof && proof[0];
if (!oldKey) {
w.abort();
return void cb('INVALID_ANCESTOR');
}
MFA.copy(Env, oldKey, publicKey, w());
}).nThen(function () {
// Create a session for the current user
makeSession(Env, publicKey, cb);
});
};
// Remove a login block IFF
// 1. You can sign for the block's public key
const removeBlock = Commands.TOTP_REMOVE_BLOCK = function (Env, body, cb) {
const { publicKey, auth } = body;
const code = auth;
// they must provide a valid block public key
if (!isValidBlockId(publicKey)) { return void cb("INVALID_KEY"); }
if (!isValidOTP(code)) { return void cb('E_INVALID'); }
let secret;
nThen(function (w) {
// check that there is an MFA configuration for this block
readMFA(Env, publicKey, w(function (err, content) {
if (err) {
w.abort();
return void cb(err);
}
secret = content.secret;
}));
}).nThen(function () {
// check that the OTP code is valid
checkCode(Env, secret, code, publicKey, cb);
});
};
removeBlock.complete = function (Env, body, cb) {
const { publicKey } = body;
nThen(function (w) {
// Remove the block
Block.removeLoginBlock(Env, publicKey, w((err) => {
if (err) {
w.abort();
return void cb(err);
}
}));
}).nThen(() => {
// Delete the MFA settings and sessions
MFA.revoke(Env, publicKey, cb);
});
};

View File

@ -9,6 +9,7 @@ const Pinning = require("./pin-rpc");
const Core = require("./core");
const Channel = require("./channel");
const BlockStore = require("../storage/block");
const MFA = require("../storage/mfa");
var Fs = require("fs");
@ -498,6 +499,19 @@ var getDocumentStatus = function (Env, Server, cb, data) {
}
response.archived = result;
}));
MFA.read(Env, id, w(function (err, v) {
if (err === 'ENOENT') {
response.totp = 'DISABLED';
} else if (v) {
var parsed = Util.tryParse(v);
response.totp = {
enabled: true,
recovery: parsed.contact && parsed.contact.split(':')[0]
};
} else {
response.totp = err;
}
}));
}).nThen(function () {
cb(void 0, response);
});
@ -539,6 +553,12 @@ var getDocumentStatus = function (Env, Server, cb, data) {
});
};
var disableMFA = function (Env, Server, cb, data) {
var id = Array.isArray(data) && data[1];
if (typeof(id) !== 'string' || id.length !== 44) { return void cb("EINVAL"); }
MFA.revoke(Env, id, cb);
};
var getPinList = function (Env, Server, cb, data) {
var key = Array.isArray(data) && data[1];
if (!isValidKey(key)) { return void cb("EINVAL"); }
@ -746,6 +766,8 @@ var commands = {
GET_LAST_CHANNEL_TIME: getLastChannelTime,
GET_DOCUMENT_STATUS: getDocumentStatus,
DISABLE_MFA: disableMFA,
GET_PIN_LIST: getPinList,
GET_PIN_HISTORY: getPinHistory,
ARCHIVE_PIN_LOG: archivePinLog,

View File

@ -6,6 +6,11 @@ const nThen = require("nthen");
const Util = require("../common-util");
const BlockStore = require("../storage/block");
var isString = s => typeof(s) === 'string';
Block.isValidBlockId = id => {
return id && isString(id) && id.length === 44;
};
/*
We assume that the server is secured against MitM attacks
via HTTPS, and that malicious actors do not have code execution
@ -98,33 +103,24 @@ Block.validateAncestorProof = function (Env, proof, _cb) {
}
};
Block.writeLoginBlock = function (Env, safeKey, msg, _cb) {
Block.writeLoginBlock = function (Env, msg, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
var publicKey = msg[0];
var signature = msg[1];
var block = msg[2];
var registrationProof = msg[3];
var previousKey;
const { publicKey, signature, ciphertext, registrationProof } = msg;
var previousKey;
var validatedBlock, path;
nThen(function (w) {
if (Util.escapeKeyCharacters(publicKey) !== safeKey) {
w.abort();
return void cb("INCORRECT_KEY");
}
}).nThen(function (w) {
if (!Env.restrictRegistration) { return; }
if (!registrationProof) {
// we allow users with existing blocks to create new ones
// call back with error if registration is restricted and no proof of an existing block was provided
w.abort();
Env.Log.info("BLOCK_REJECTED_REGISTRATION", {
safeKey: safeKey,
publicKey: publicKey,
});
return cb("E_RESTRICTED");
}
Env.validateAncestorProof(registrationProof, w(function (err, provenKey) {
Block.validateAncestorProof(Env, registrationProof, w(function (err, provenKey) {
if (err || !provenKey) { // double check that a key was validated
w.abort();
Env.Log.warn('BLOCK_REJECTED_INVALID_ANCESTOR', {
@ -135,7 +131,7 @@ Block.writeLoginBlock = function (Env, safeKey, msg, _cb) {
previousKey = provenKey;
}));
}).nThen(function (w) {
Env.validateLoginBlock(publicKey, signature, block, w(function (e, _validatedBlock) {
Block.validateLoginBlock(Env, publicKey, signature, ciphertext, w(function (e, _validatedBlock) {
if (e) {
w.abort();
return void cb(e);
@ -156,7 +152,6 @@ Block.writeLoginBlock = function (Env, safeKey, msg, _cb) {
}
BlockStore.write(Env, publicKey, buffer, function (err) {
Env.Log.info('BLOCK_WRITE_BY_OWNER', {
safeKey: safeKey,
blockId: publicKey,
isChange: Boolean(registrationProof),
previousKey: previousKey,
@ -167,8 +162,6 @@ Block.writeLoginBlock = function (Env, safeKey, msg, _cb) {
});
};
const DELETE_BLOCK = Nacl.util.encodeBase64(Nacl.util.decodeUTF8('DELETE_BLOCK'));
/*
When users write a block, they upload the block, and provide
a signature proving that they deserve to be able to write to
@ -179,28 +172,15 @@ const DELETE_BLOCK = Nacl.util.encodeBase64(Nacl.util.decodeUTF8('DELETE_BLOCK')
information, we can just sign some constant and use that as proof.
*/
Block.removeLoginBlock = function (Env, safeKey, msg, _cb) {
Block.removeLoginBlock = function (Env, publicKey, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
var publicKey = msg[0];
var signature = msg[1];
nThen(function (w) {
if (Util.escapeKeyCharacters(publicKey) !== safeKey) {
w.abort();
return void cb("INCORRECT_KEY");
}
}).nThen(function () {
Env.validateLoginBlock(publicKey, signature, DELETE_BLOCK, function (e) {
if (e) { return void cb(e); }
BlockStore.archive(Env, publicKey, function (err) {
Env.Log.info('ARCHIVAL_BLOCK_BY_OWNER_RPC', {
publicKey: publicKey,
status: err? String(err): 'SUCCESS',
});
cb(err);
});
BlockStore.archive(Env, publicKey, function (err) {
Env.Log.info('ARCHIVAL_BLOCK_BY_OWNER_RPC', {
publicKey: publicKey,
status: err? String(err): 'SUCCESS',
});
cb(err);
});
};

View File

@ -49,6 +49,9 @@ SET_INSTANCE_DESCRIPTION
SET_INSTANCE_NAME
SET_INSTANCE_NOTICE
// bearer secret
SET_BEARER_SECRET
NOT IMPLEMENTED:
// RESTRICTED REGISTRATION
@ -348,6 +351,17 @@ commands.ADD_ADMIN_KEY = function (Env, args) {
return true;
};
commands.SET_BEARER_SECRET = function (Env, args) {
if (!args_isString(args) || args.length !== 1 || !args[0]) {
throw new Error("INVALID_ARGS");
}
var secret = args[0];
if (secret === Env.bearerSecret) { return false; }
Env.bearerSecret = secret;
return true;
};
// [<command>, <args>, <author>, <time>]
var handleCommand = Decrees.handleCommand = function (Env, line) {
var command = line[0];
@ -357,7 +371,12 @@ var handleCommand = Decrees.handleCommand = function (Env, line) {
throw new Error("DECREE_UNSUPPORTED_COMMAND");
}
return commands[command](Env, args);
var outcome = commands[command](Env, args);
if (outcome) {
// trigger Env change event...
Env.envUpdated.fire();
}
return outcome;
};
Decrees.createLineHandler = function (Env) {

View File

@ -26,7 +26,7 @@ Default.commonCSP = function (Env) {
if you are deploying to production, you'll probably want to remove
the ws://* directive
*/
"connect-src 'self' blob: " + (/^https:/.test(domain)? 'wss:': domain.replace('http://', 'ws://')) + ' ' + domain + sandbox + accounts_api,
"connect-src 'self' localhost blob: " + (/^https:/.test(domain)? 'wss:': domain.replace('http://', 'ws://')) + ' ' + domain + sandbox + accounts_api,
// data: is used by codemirror
"img-src 'self' data: blob:" + domain,
@ -47,6 +47,10 @@ Default.padContentSecurity = function (Env) {
return (Default.commonCSP(Env).join('; ') + "script-src 'self' 'unsafe-eval' 'unsafe-inline' resource: " + Env.httpUnsafeOrigin).replace(/\s+/g, ' ');
};
Default.diagramContentSecurity = function (Env) {
return (Default.commonCSP(Env).join('; ') + "script-src 'self' 'sha256-6zAB96lsBZREqf0sT44BhH1T69sm7HrN34rpMOcWbNo=' 'sha256-6g514VrT/cZFZltSaKxIVNFF46+MFaTSDTPB8WfYK+c=' resource: " + Env.httpUnsafeOrigin).replace(/\s+/g, ' ');
};
Default.httpHeaders = function (Env) {
return {
"X-XSS-Protection": "1; mode=block",
@ -65,6 +69,12 @@ Default.mainPages = function () {
];
};
/* The recommmended minimum Node.js version
* ideally managed using NVM and not your system's
* package manager, which usually provides a very outdated version
*/
Default.recommendedVersion = [16,14,2];
/* By default the CryptPad server will run scheduled tasks every five minutes
* If you want to run scheduled tasks in a separate process (like a crontab)
* you can disable this behaviour by setting the following value to true

View File

@ -11,6 +11,7 @@ const Core = require("./commands/core");
const Quota = require("./commands/quota");
const Util = require("./common-util");
const Package = require("../package.json");
const Default = require("./defaults");
const Path = require("path");
const Nacl = require("tweetnacl/nacl-fast");
@ -31,6 +32,7 @@ var deriveSandboxOrigin = function (unsafe, port) {
};
var isRecentVersion = function () {
var R = Default.recommendedVersion;
var V = process.version;
if (typeof(V) !== 'string') { return false; }
var parts = V.replace(/^v/, '').split('.').map(Number);
@ -38,13 +40,13 @@ var isRecentVersion = function () {
if (!parts.every(n => typeof(n) === 'number' && !isNaN(n))) {
return false;
}
if (parts[0] < 16) { return false; }
if (parts[0] > 16) { return true; }
if (parts[0] < R[0]) { return false; }
if (parts[0] > R[0]) { return true; }
// v16
if (parts[1] < 14) { return false; }
if (parts[1] > 14) { return true; }
if (parts[2] >= 2) { return true; }
if (parts[1] < R[1]) { return false; }
if (parts[1] > R[1]) { return true; }
if (parts[2] >= R[2]) { return true; }
return false;
};
@ -65,6 +67,10 @@ module.exports.create = function (config) {
httpSafeOrigin = canonicalizeOrigin(config.httpSafeOrigin);
}
if (typeof(config.websocketPort) !== 'number') {
config.websocketPort = 3003;
}
var permittedEmbedders = config.permittedEmbedders;
if (typeof(permittedEmbedders) === 'string') {
permittedEmbedders = permittedEmbedders.trim();
@ -73,11 +79,16 @@ module.exports.create = function (config) {
const curve = Nacl.box.keyPair();
const Env = {
logFeedback: Boolean(config.logFeedback),
mainPages: config.mainPages || Default.mainPages(),
protocol: new URL(httpUnsafeOrigin).protocol,
fileHost: config.fileHost || undefined,
NO_SANDBOX: NO_SANDBOX,
httpSafePort: httpSafePort,
websocketPort: config.websocketPort,
accounts_api: config.accounts_api || undefined, // this simplifies integration with an accounts page
shouldUpdateNode: !isRecentVersion(),
@ -106,15 +117,9 @@ module.exports.create = function (config) {
apiHeadersCache: undefined,
flushCache: function () {
Env.configCache = {};
Env.broadcastCache = {};
Env.officeHeadersCache = undefined;
Env.standardHeadersCache = undefined;
Env.apiHeadersCache = undefined;
Env.FRESH_KEY = +new Date();
if (!(Env.DEV_MODE || Env.FRESH_MODE)) { Env.FRESH_MODE = true; }
Env.cacheFlushed.fire();
if (!Env.Log) { return; }
Env.Log.info("UPDATING_FRESH_KEY", Env.FRESH_KEY);
},
@ -211,17 +216,29 @@ module.exports.create = function (config) {
// but it is referenced in Quota
domain: config.domain,
maxWorkers: config.maxWorkers,
maxWorkers: undefined,
disableIntegratedTasks: config.disableIntegratedTasks || false,
disableIntegratedEviction: typeof(config.disableIntegratedEviction) === 'undefined'? true: config.disableIntegratedEviction,
lastEviction: +new Date(),
evictionReport: {},
commandTimers: {},
// initialized as undefined
bearerSecret: void 0,
curvePrivate: curve.secretKey,
curvePublic: Nacl.util.encodeBase64(curve.publicKey),
selfDestructTo: {},
};
(function () {
var max = config.maxWorkers;
// if the supplied value is not a positive number, leave maxWorkers undefined
// one worker will be created for each CPU core
if (typeof(max) !== 'number' || isNaN(max) || max < 1) { return; }
Env.maxWorkers = max;
}());
(function () {
// mode can be FRESH (default), DEV, or PACKAGE
if (process.env.PACKAGE) {
@ -323,6 +340,7 @@ module.exports.create = function (config) {
paths.staging = keyOrDefaultString('blobStagingPath', './blobstage');
paths.blob = keyOrDefaultString('blobPath', './blob');
paths.decree = keyOrDefaultString('decreePath', './data/');
paths.base = keyOrDefaultString('base', './data');
paths.archive = keyOrDefaultString('archivePath', './data/archive');
paths.task = keyOrDefaultString('taskPath', './tasks');
@ -342,5 +360,45 @@ module.exports.create = function (config) {
console.error("Can't parse admin keys. Please update or fix your config.js file!");
}
Env.envUpdated = Util.mkEvent();
Env.cacheFlushed = Util.mkEvent();
return Env;
};
// don't serialize these things
const BAD = [
'Log',
'envUpdated',
'cacheFlushed',
'evictionReports',
'commandTimers',
'metadata_cache',
'channel_cache',
'cache_checks',
'intervals',
'Sessions',
'netfluxUsers',
'limits',
'customLimits',
'scheduleDecree',
'httpServer',
'pinStore',
'msgStore',
'store',
'blobStore',
];
module.exports.serialize = function (Env) {
return JSON.stringify(Env, function (key, value) {
if (value === Env) { return value; }
if (BAD.includes(key)) { return; }
if (typeof(value) === 'function') { return; }
//console.log('serializing', { key, value, });
if (Util.isCircular(value)) { return; }
return value;
});
};

View File

@ -63,6 +63,16 @@ module.exports.create = function (Env, cb) {
error: err,
});
}
if (metadata && metadata.selfdestruct && metadata.selfdestruct !== Env.id) {
HK.expireChannel(Env, channelName);
return void cb('ESELFDESTRUCT');
}
if (Env.selfDestructTo && Env.selfDestructTo[channelName]) {
clearTimeout(Env.selfDestructTo[channelName]);
}
if (!metadata || (metadata && !metadata.restricted)) {
// the channel doesn't have metadata, or it does and it's not restricted
// either way, let them join.

View File

@ -40,6 +40,9 @@ const ADMIN_CHANNEL_LENGTH = HK.ADMIN_CHANNEL_LENGTH = 33;
// with a 34 character id
const EPHEMERAL_CHANNEL_LENGTH = HK.EPHEMERAL_CHANNEL_LENGTH = 34;
// Temporary channels are archived X ms after everyone has left them
const TEMPORARY_CHANNEL_LIFETIME = 30 * 1000;
const tryParse = HK.tryParse = function (Env, str) {
try {
return JSON.parse(str);
@ -120,7 +123,7 @@ var CHECKPOINT_PATTERN = /^cp\|(([A-Za-z0-9+\/=]+)\|)?/;
/* expireChannel is here to clean up channels that should have been removed
but for some reason are still present
*/
const expireChannel = function (Env, channel) {
const expireChannel = HK.expireChannel = function (Env, channel) {
return void Env.store.archiveChannel(channel, function (err) {
Env.Log.info("ARCHIVAL_CHANNEL_BY_HISTORY_KEEPER_EXPIRATION", {
channelId: channel,
@ -133,8 +136,14 @@ const expireChannel = function (Env, channel) {
* cleans up memory structures which are managed entirely by the historyKeeper
*/
const dropChannel = HK.dropChannel = function (Env, chanName) {
let meta = Env.metadata_cache[chanName];
delete Env.metadata_cache[chanName];
delete Env.channel_cache[chanName];
if (meta && meta.selfdestruct && Env.selfDestructTo) {
Env.selfDestructTo[chanName] = setTimeout(function () {
expireChannel(Env, chanName);
}, TEMPORARY_CHANNEL_LIFETIME);
}
};
/* checkExpired
@ -478,8 +487,8 @@ const getHistoryOffset = (Env, channelName, lastKnownHash, _cb) => {
cb(null, lkh);
}));
}).nThen((w) => {
// XXX entire block and getHashOffset to remove?
// If we're here it means we asked for a lastKnownHash but it is old (not in the index)
// and this is not a "chainpad" channel so we can't recover from a checkpoint.
// skip past this block if the offset is anything other than -1
// this basically makes these first two nThen blocks behave like if-else
@ -565,6 +574,10 @@ const handleRPC = function (Env, Server, seq, userId, parsed) {
if the provided metadata has an expire time then we also create a task to expire it.
*/
const handleFirstMessage = function (Env, channelName, metadata) {
if (metadata.selfdestruct) {
// Set the selfdestruct flag to history keeper ID to handle server crash.
metadata.selfdestruct = Env.id;
}
Env.store.writeMetadata(channelName, JSON.stringify(metadata), function (err) {
if (err) {
// FIXME tell the user that there was a channel error?

323
lib/http-commands.js Normal file
View File

@ -0,0 +1,323 @@
var Nacl = require("tweetnacl/nacl-fast");
var Util = require('./common-util.js');
var Challenge = require("./storage/challenge.js");
// C.read(Env, id, cb)
// C.write(Env,id, data, cb)
// C.delete(Env, id, cb)
/*
The API for command definition consists of two stages:
Clients first send a command and its associated parameters.
The server validates that the command is supported, and that
the provided parameters are valid. If it fails validation for any reason,
the server responds with an error and the protocol is aborted.
COMMANDS[COMMAND_NAME] = function (Env, body, cb) {
// inspect parameters in the request body
if (!body.essential_parameter) {
return void cb('NO');
}
cb();
};
Commands whose parameters are successfully validated
have those parameters stored on the disk (or a relational DB in the future).
The server then requests that the client sign their well-formulated
command along with a server-generated transaction id ('txid': randomized to prevent replays)
and a date (so that it can ensure that the client responds within a reasonable window.
Clients then respond with a txid and a cryptographic signature
which matches the parameters of the command. The server loads the command
with the corresponding txid, checks that it was signed within a reasonable time window,
validates the signature, and attempts to complete the command's execution:
COMMAND[COMMAND_NAME].complete = function (Env, body, cb) {
doAThing(function (err, values) {
if (err) {
// Log the error and respond that the command was not successful
return void cb("SORRY_BUT_IM_NOT_OK");
}
cb(void 0, {
arbitrary: values,
});
});
};
In this second stage the protocol can be aborted if the client has done something wrong:
(ie. if it did not produce a valid signature for the command)
or it can can fail because the server was not able to complete the requested task
(ie. because of an I/O error or because an error was thrown and caught).
It is intended that the server will respond with an appropriate error if
the request cannot be completed, and it will respond OK if everything completed successfully.
*/
var COMMANDS = {};
// Methods allowing clients to configure Time-based One-Time Passwords for their login-block,
// and to authenticate new sessions once a TOTP secret has been associated with their account,
const NOAUTH = require("./challenge-commands/base.js");
COMMANDS.MFA_CHECK = NOAUTH.MFA_CHECK;
COMMANDS.WRITE_BLOCK = NOAUTH.WRITE_BLOCK;
COMMANDS.REMOVE_BLOCK = NOAUTH.REMOVE_BLOCK;
const TOTP = require("./challenge-commands/totp.js");
COMMANDS.TOTP_SETUP = TOTP.TOTP_SETUP;
COMMANDS.TOTP_VALIDATE = TOTP.TOTP_VALIDATE;
COMMANDS.TOTP_CHECK = TOTP.TOTP_CHECK;
COMMANDS.TOTP_REVOKE = TOTP.TOTP_REVOKE;
COMMANDS.TOTP_WRITE_BLOCK = TOTP.TOTP_WRITE_BLOCK;
COMMANDS.TOTP_REMOVE_BLOCK = TOTP.TOTP_REMOVE_BLOCK;
var randomToken = () => Nacl.util.encodeBase64(Nacl.randomBytes(24)).replace(/\//g, '-');
// this function handles the first stage of the protocol
// (the server's validation of the client's request and the generation of its challenge)
var handleCommand = function (Env, req, res) {
var body = req.body;
var command = body.command;
// reject if the command does not have a corresponding function
if (typeof(COMMANDS[command]) !== 'function') {
Env.Log.error('CHALLENGE_UNSUPPORTED_COMMAND', command);
return void res.status(500).json({
error: 'invalid command',
});
}
var publicKey = body.publicKey;
// reject if they did not provide a valid public key
if (!publicKey || typeof(publicKey) !== 'string' || publicKey.length !== 44) {
Env.Log.error('CHALLENGE_INVALID_KEY', publicKey);
return void res.status(500).json({
error: 'Invalid key',
});
}
try {
COMMANDS[command](Env, body, function (err) {
if (err) {
Env.Log.error('CHALLENGE_COMMAND_EXECUTION_ERROR', {
body: body,
error: Util.serializeError(err),
});
// errors returned from commands are passed back to the client
// as a weak precaution, we try to only send an error's message
// if one exists. This makes it less likely that we'll respond with any
// sensitive information in a stack trace. Ideally functions should
// only return error messages or codes in the form of a string or number,
// but mistakes happen.
return void res.status(500).json({
error: (err && err.message) || err,
});
}
var txid = randomToken();
var date = new Date().toISOString();
var copy = Util.clone(body);
copy.txid = txid;
copy.date = date;
// Write the command and challenge to disk, because the challenge protocol
// is interactive and the subsequent response might be handled by a different http worker
// this makes it so we can avoid holding state in memory
Challenge.write(Env, txid, JSON.stringify(copy), function (err) {
if (err) {
Env.Log.error('CHALLENGE_WRITE_ERROR', Util.serializeError(err));
return void res.status(500).json({
// arbitrary error message, only intended for debugging
error: 'Internal server error 6250',
});
}
// respond with challenge parameters
return void res.status(200).json({
txid: txid,
date: date,
});
});
});
} catch (err) {
Env.Log.error("CHALLENGE_COMMAND_THROWN_ERROR", {
error: Util.serializeError(err),
});
return void res.status(500).json({
// arbitrary error message, only intended for debugging
error: 'Internal server error 7692',
});
}
};
// this function handles the second stage of the protocol
// (the client's response to the server's challenge)
var handleResponse = function (Env, req, res) {
var body = req.body;
if (Object.keys(body).some(k => !/(sig|txid)/.test(k))) {
Env.Log.error("CHALLENGE_RESPONSE_DEBUGGING", body);
// we expect the response to only have two keys
// if any more are present then the response is malformed
return void res.status(500).json({
error: 'extraneous parameters',
});
}
// transaction ids are issued to the client by the server
// they allow it to recall the full details of the challenge
// to which the client is responding
var txid = body.txid;
// if no txid is present, then the server can't look up the corresponding challenge
// the response is definitely malformed, so reject it.
// Additionally, we expect txids to be 32 characters long (24 Uint8s as base64)
// reject txids of any other length
if (!txid || typeof(txid) !== 'string' || txid.length !== 32) {
Env.Log.error('CHALLENGE_RESPONSE_BAD_TXID', body);
return void res.status(500).json({
error: "Invalid txid",
});
}
var sig = body.sig;
if (!sig || typeof(sig) !== 'string' || sig.length !== 88) {
Env.Log.error("CHALLENGE_RESPONSE_BAD_SIG", body);
return void res.status(500).json({
error: "Missing signature",
});
}
Challenge.read(Env, txid, function (err, text) {
if (err) {
Env.Log.error("CHALLENGE_READ_ERROR", {
txid: txid,
error: Util.serializeError(err),
});
return void res.status(500).json({
error: "Unexpected response",
});
}
// garbage collection can clean this up later
Challenge.delete(Env, txid, function (err) {
if (err) {
Env.Log.error("CHALLENGE_DELETION_ERROR", {
txid: txid,
error: Util.serializeError(err),
});
}
});
var json = Util.tryParse(text);
if (!json) {
Env.Log.error("CHALLENGE_PARSE_ERROR", {
txid: txid,
});
return void res.status(500).json({
error: "Internal server error 129",
});
}
var publicKey = json.publicKey;
if (!publicKey || typeof(publicKey) !== 'string') {
// This shouldn't happen, as we expect that the server
// will have validated the key to an extent before storing the challenge
Env.Log.error('CHALLENGE_INVALID_PUBLICKEY', {
publicKey: publicKey,
});
return res.status(500).json({
error: "Invalid public key",
});
}
var action;
try {
action = COMMANDS[json.command].complete;
} catch (err2) {}
if (typeof(action) !== 'function') {
Env.Log.error("CHALLENGE_RESPONSE_ACTION_NOT_IMPLEMENTED", json.command);
return res.status(501).json({
error: 'Not implemented',
});
}
var u8_toVerify,
u8_sig,
u8_publicKey;
try {
u8_toVerify = Nacl.util.decodeUTF8(text);
u8_sig = Nacl.util.decodeBase64(sig);
u8_publicKey = Nacl.util.decodeBase64(publicKey);
} catch (err3) {
Env.Log.error('CHALLENGE_RESPONSE_DECODING_ERROR', {
text: text,
sig: sig,
publicKey: publicKey,
error: Util.serializeError(err3),
});
return res.status(500).json({
error: "decoding error"
});
}
// validate the response
var success = Nacl.sign.detached.verify(u8_toVerify, u8_sig, u8_publicKey);
if (success !== true) {
Env.Log.error("CHALLENGE_RESPONSE_SIGNATURE_FAILURE", {
publicKey,
});
return void res.status(500).json({
error: 'Failed signature validation',
});
}
// execute the command
action(Env, json, function (err, content) {
if (err) {
Env.Log.error("CHALLENGE_RESPONSE_ACTION_ERROR", {
error: Util.serializeError(err),
});
return res.status(500).json({
error: 'Execution error',
});
}
res.status(200).json(content);
});
});
};
module.exports.handle = function (Env, req, res /*, next */) {
var body = req.body;
// we expect that the client has posted some JSON data
if (!body) {
return void res.status(500).json({
error: 'invalid request',
});
}
// we only expect responses to challenges to have a 'txid' attribute
// further validation is performed in handleResponse
if (body.txid) {
return void handleResponse(Env, req, res);
}
// we only expect initial requests to have a 'command' attribute
// further validation is performed in handleCommand
if (body.command) {
return void handleCommand(Env, req, res);
}
// if a request is neither a command nor a response, then reject it with an error
res.status(500).json({
error: 'invalid request',
});
};

622
lib/http-worker.js Normal file
View File

@ -0,0 +1,622 @@
const process = require("node:process");
const Http = require("node:http");
const Default = require("./defaults");
const Path = require("node:path");
const Fs = require("node:fs");
const nThen = require("nthen");
const Util = require("./common-util");
const Logger = require("./log");
const AuthCommands = require("./http-commands");
const MFA = require("./storage/mfa");
const Sessions = require("./storage/sessions");
const DEFAULT_QUERY_TIMEOUT = 5000;
const PID = process.pid;
var Env = JSON.parse(process.env.Env);
const response = Util.response(function (errLabel, info) {
if (!Env.Log) { return; }
Env.Log.error(errLabel, info);
});
const guid = () => {
return Util.guid(response._pending);
};
const sendMessage = (msg, cb, opt) => {
var txid = guid();
var timeout = (opt && opt.timeout) || DEFAULT_QUERY_TIMEOUT;
var obj = {
pid: PID,
txid: txid,
content: msg,
};
response.expect(txid, cb, timeout);
process.send(obj);
};
const Log = {};
Logger.levels.forEach(level => {
Log[level] = function (tag, info) {
sendMessage({
command: 'LOG',
level: level,
tag: tag,
info: info,
}, (err) => {
if (err) {
return void console.error(new Error(err));
}
});
};
});
Env.Log = Log;
Env.incrementBytesWritten = function () {};
const EVENTS = {};
EVENTS.ENV_UPDATE = function (data /*, cb */) {
try {
Env = JSON.parse(data);
Env.Log = Log;
Env.incrementBytesWritten = function () {};
} catch (err) {
Log.error('HTTP_WORKER_ENV_UPDATE', Util.serializeError(err));
}
};
EVENTS.FLUSH_CACHE = function (data) {
if (typeof(data) !== 'number') {
return Log.error('INVALID_FRESH_KEY', data);
}
Env.FRESH_KEY = data;
[ 'configCache', 'broadcastCache', ].forEach(key => {
Env[key] = {};
});
[ 'officeHeadersCache', 'standardHeadersCache', 'apiHeadersCache', ].forEach(key => {
Env[key] = undefined;
});
};
process.on('message', msg => {
if (!(msg && msg.txid)) { return; }
if (msg.type === 'REPLY') {
var txid = msg.txid;
return void response.handle(txid, [msg.error, msg.value]);
} else if (msg.type === 'EVENT') {
// response to event...
// ie. Update Env, flush cache, etc.
var ev = EVENTS[msg.command];
if (typeof(ev) === 'function') {
return void ev(msg.data, () => {});
}
}
//console.error("UNHANDLED_MESSAGE", msg);
});
var applyHeaderMap = function (res, map) {
for (let header in map) {
if (typeof(map[header]) === 'string') { res.setHeader(header, map[header]); }
}
};
var EXEMPT = [
/^\/common\/onlyoffice\/.*\.html.*/,
/^\/(sheet|presentation|doc)\/inner\.html.*/,
/^\/unsafeiframe\/inner\.html.*$/,
];
var cacheHeaders = function (Env, key, headers) {
if (Env.DEV_MODE) { return; }
Env[key] = headers;
};
var getHeaders = function (Env, type) {
var key = type + 'HeadersCache';
if (Env[key]) { return Env[key]; }
var headers = Default.httpHeaders(Env);
var csp;
if (type === 'office') {
csp = Default.padContentSecurity(Env);
} else if (type === 'diagram') {
csp = Default.diagramContentSecurity(Env);
} else {
csp = Default.contentSecurity(Env);
}
headers['Content-Security-Policy'] = csp;
if (Env.NO_SANDBOX) { // handles correct configuration for local development
// https://stackoverflow.com/questions/11531121/add-duplicate-http-response-headers-in-nodejs
headers["Cross-Origin-Resource-Policy"] = 'cross-origin';
headers["Cross-Origin-Embedder-Policy"] = 'require-corp';
}
// Don't set CSP headers on /api/ endpoints
// because they aren't necessary and they cause problems
// when duplicated by NGINX in production environments
if (type === 'api') {
cacheHeaders(Env, key, headers);
return headers;
}
headers["Cross-Origin-Resource-Policy"] = 'cross-origin';
cacheHeaders(Env, key, headers);
return headers;
};
var setHeaders = function (req, res) {
var type;
if (EXEMPT.some(regex => regex.test(req.url))) {
type = 'office';
} else if (/^\/api\/(broadcast|config)/.test(req.url)) {
type = 'api';
} else if (/^\/components\/drawio\/src\/main\/webapp\/index.html.*$/.test(req.url)) {
type = 'diagram';
} else {
type = 'standard';
}
var h = getHeaders(Env, type);
applyHeaderMap(res, h);
};
const Express = require("express");
var app = Express();
(function () {
if (!Env.logFeedback) { return; }
const logFeedback = function (url) {
url.replace(/\?(.*?)=/, function (all, fb) {
Log.feedback(fb, '');
});
};
app.head(/^\/common\/feedback\.html/, function (req, res, next) {
logFeedback(req.url);
next();
});
}());
const { createProxyMiddleware } = require("http-proxy-middleware");
var proxyTarget = new URL('', 'ws:localhost');
proxyTarget.port = Env.websocketPort;
const wsProxy = createProxyMiddleware({
target: proxyTarget.href,
ws: true,
logLevel: 'error',
logProvider: (p) => {
p.error = (data) => {
if (/ECONNRESET/.test(data)) { return; }
Env.Log.error('HTTP_PROXY_MIDDLEWARE', data);
};
return p;
}
});
app.use('/cryptpad_websocket', wsProxy);
app.use('/blob', function (req, res, next) {
/* Head requests are used to check the size of a blob.
Clients can configure a maximum size to download automatically,
and can manually click to download blobs which exceed that limit. */
if (req.method === 'HEAD') {
Express.static(Path.resolve(Env.paths.blob), {
setHeaders: function (res /*, path, stat */) {
res.set('Access-Control-Allow-Origin', Env.enableEmbedding? '*': Env.permittedEmbedders);
res.set('Access-Control-Allow-Headers', 'Content-Length');
res.set('Access-Control-Expose-Headers', 'Content-Length');
}
})(req, res, next);
return;
}
/* Some GET requests concern the whole file,
others only target ranges, either:
1. a two octet prefix which encodes the length of the metadata in octets
2. the metadata itself, excluding the two preceding octets
*/
/*
// Example code to demonstrate the types of requests which are handled
if (req.method === 'GET') {
if (!req.headers.range) {
// metadata
} else {
// full request
}
}
*/
next();
});
app.use(function (req, res, next) {
/* These are pre-flight requests, through which the client
confirms with the server that it is permitted to make the
actual requests which will follow */
if (req.method === 'OPTIONS' && /\/blob\//.test(req.url)) {
res.setHeader('Access-Control-Allow-Origin', Env.enableEmbedding? '*': Env.permittedEmbedders);
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Content-Range,Range,Access-Control-Allow-Origin');
res.setHeader('Access-Control-Max-Age', 1728000);
res.setHeader('Content-Type', 'application/octet-stream; charset=utf-8');
res.setHeader('Content-Length', 0);
res.statusCode = 204;
return void res.end();
}
setHeaders(req, res);
if (/[\?\&]ver=[^\/]+$/.test(req.url)) { res.setHeader("Cache-Control", "max-age=31536000"); }
else { res.setHeader("Cache-Control", "no-cache"); }
next();
});
// serve custom app content from the customize directory
// useful for testing pages customized with opengraph data
app.use(Express.static(Path.resolve('./customize/www')));
app.use(Express.static(Path.resolve('./www')));
var mainPages = Env.mainPages || Default.mainPages();
var mainPagePattern = new RegExp('^\/(' + mainPages.join('|') + ').html$');
app.get(mainPagePattern, Express.static('./customize'));
app.get(mainPagePattern, Express.static('./customize.dist'));
app.use("/blob", Express.static(Path.resolve(Env.paths.blob), {
maxAge: Env.DEV_MODE? "0d": "365d"
}));
app.head("/datastore", Express.static(Env.paths.data, {
maxAge: "0d"
}));
app.use('/block/', function (req, res, next) {
var parsed = Path.parse(req.url);
var name = parsed.name;
// block access control only applies to files
// identified by base64-encoded public keys
// skip everything else, ie. /block/placeholder.txt
if (typeof(name) !== 'string' || name.length !== 44) {
return void next();
}
var authorization = req.headers.authorization;
var mfa_params;
nThen(function (w) {
// First, check whether the block id in question has any MFA settings stored
MFA.read(Env, name, w(function (err, content) {
// ENOENT means there are no settings configured
// it could be a 404 or an existing block without MFA protection
// in either case you can abort and fall through
// allowing the static webserver to handle either case
if (err && err.code === 'ENOENT') {
w.abort();
return void next();
}
// we're not expecting other errors. the sensible thing is to fail
// closed - meaning assume some protection is in place but that
// the settings couldn't be loaded for some reason. block access
// to the resource, logging for the admin and responding to the client
// with a vague error code
if (err) {
Log.error('GET_BLOCK_METADATA', err);
return void res.status(500).json({
code: 500,
error: "UNEXPECTED_ERROR",
});
}
// Otherwise, some settings were loaded correctly.
// We're expecting stringified JSON, so try to parse it.
// Log and respond with an error again if this fails.
// If it parses successfully then fall through to the next block.
try {
mfa_params = JSON.parse(content);
} catch (err2) {
w.abort();
Log.error("INVALID_BLOCK_METADATA", err2);
return res.status(500).json({
code: 500,
error: "UNEXPECTED_ERROR",
});
}
}));
}).nThen(function (w) {
// We should only be able to reach this logic
// if we successfully loaded and parsed some JSON
// representing the user's MFA settings.
// Failures at this point relate to insufficient or incorrect authorization.
// This function standardizes how we reject such requests.
// So far the only additional factor which is supported is TOTP.
// We specify what the method is to allow for future alternatives
// and inform the client so they can determine how to respond
// "401" means "Unauthorized"
var no = function () {
w.abort();
res.status(401).json({
method: mfa_params.method,
code: 401
});
};
// if you are here it is because this block is protected by MFA.
// they will need to provide a JSON Web Token, so we can reject them outright
// if one is not present in their authorization header
if (!authorization) { return void no(); }
// The authorization header should be of the form
// "Authorization: Bearer <JWT>"
// We can reject the request if it is malformed.
let token = authorization.replace(/^Bearer\s+/, '').trim();
if (!token) { return void no(); }
Sessions.read(Env, name, token, function (err, contentStr) {
if (err) {
Log.error('SESSION_READ_ERROR', err);
return res.status(401).json({
method: mfa_params.method,
code: 401,
});
}
let content = Util.tryParse(contentStr);
if (content.mfa && content.mfa.exp && ((+new Date()) > content.mfa.exp)) {
Log.error("OTP_SESSION_EXPIRED", content.mfa);
Sessions.delete(Env, name, token, function (err) {
if (err) {
Log.error('SESSION_DELETE_EXPIRED_ERROR', err);
return;
}
Log.info('SESSION_DELETE_EXPIRED', err);
});
return void no();
}
// we could also check whether the content of the file matches the token,
// but clients don't have any influence over the reference and can only
// request to create tokens that are scoped to a public key they control.
// I don' think there's any practical benefit to such a check.
// So, interpret the existence of a file in that location as the continued
// validity of the session. Fall through and let the built-in webserver
// handle the 404 or serving the file.
next();
});
});
});
// TODO this would be a good place to update a block's atime
// in a manner independent of the filesystem. ie. for detecting and archiving
// inactive accounts in a way that will not be invalidated by other forms of access
// like filesystem backups.
app.use("/block", Express.static(Path.resolve(Env.paths.block), {
maxAge: "0d",
}));
app.use("/customize", Express.static('customize'));
app.use("/customize", Express.static('customize.dist'));
app.use("/customize.dist", Express.static('customize.dist'));
app.use(/^\/[^\/]*$/, Express.static('customize'));
app.use(/^\/[^\/]*$/, Express.static('customize.dist'));
// if dev mode: never cache
var cacheString = function () {
return (Env.FRESH_KEY? '-' + Env.FRESH_KEY: '') + (Env.DEV_MODE? '-' + (+new Date()): '');
};
var makeRouteCache = function (template, cacheName) {
var cleanUp = {};
var cache = Env[cacheName] = Env[cacheName] || {};
return function (req, res) {
var host = req.headers.host.replace(/\:[0-9]+/, '');
res.setHeader('Content-Type', 'text/javascript');
// don't cache anything if you're in dev mode
if (Env.DEV_MODE) {
return void res.send(template(host));
}
// generate a lookup key for the cache
var cacheKey = host + ':' + cacheString();
// FIXME mutable
// we must be able to clear the cache when updating any mutable key
// if there's nothing cached for that key...
if (!cache[cacheKey]) {
// generate the response and cache it in memory
cache[cacheKey] = template(host);
// and create a function to conditionally evict cache entries
// which have not been accessed in the last 20 seconds
cleanUp[cacheKey] = Util.throttle(function () {
delete cleanUp[cacheKey];
delete cache[cacheKey];
}, 20000);
}
// successive calls to this function
cleanUp[cacheKey]();
return void res.send(cache[cacheKey]);
};
};
var serveConfig = makeRouteCache(function () {
return [
'define(function(){',
'return ' + JSON.stringify({
requireConf: {
waitSeconds: 600,
urlArgs: 'ver=' + Env.version + cacheString(),
},
removeDonateButton: (Env.removeDonateButton === true),
allowSubscriptions: (Env.allowSubscriptions === true),
websocketPath: Env.websocketPath,
httpUnsafeOrigin: Env.httpUnsafeOrigin,
adminEmail: Env.adminEmail,
adminKeys: Env.admins,
inactiveTime: Env.inactiveTime,
supportMailbox: Env.supportMailbox,
defaultStorageLimit: Env.defaultStorageLimit,
maxUploadSize: Env.maxUploadSize,
premiumUploadSize: Env.premiumUploadSize,
restrictRegistration: Env.restrictRegistration,
httpSafeOrigin: Env.httpSafeOrigin,
enableEmbedding: Env.enableEmbedding,
fileHost: Env.fileHost,
shouldUpdateNode: Env.shouldUpdateNode || undefined,
listMyInstance: Env.listMyInstance,
accounts_api: Env.accounts_api,
}, null, '\t'),
'});'
].join(';\n');
}, 'configCache');
var serveBroadcast = makeRouteCache(function () {
var maintenance = Env.maintenance;
if (maintenance && maintenance.end && maintenance.end < (+new Date())) {
maintenance = undefined;
}
return [
'define(function(){',
'return ' + JSON.stringify({
lastBroadcastHash: Env.lastBroadcastHash,
surveyURL: Env.surveyURL,
maintenance: maintenance
}, null, '\t'),
'});'
].join(';\n');
}, 'broadcastCache');
app.get('/api/config', serveConfig);
app.get('/api/broadcast', serveBroadcast);
var Define = function (obj) {
return `define(function (){
return ${JSON.stringify(obj, null, '\t')};
});`;
};
app.get('/api/instance', function (req, res) {
res.setHeader('Content-Type', 'text/javascript');
res.send(Define({
name: Env.instanceName,
description: Env.instanceDescription,
location: Env.instanceJurisdiction,
notice: Env.instanceNotice,
}));
});
var four04_path = Path.resolve('./customize.dist/404.html');
var fivehundred_path = Path.resolve('./customize.dist/500.html');
var custom_four04_path = Path.resolve('./customize/404.html');
var custom_fivehundred_path = Path.resolve('./customize/500.html');
var send404 = function (res, path) {
if (!path && path !== four04_path) { path = four04_path; }
Fs.exists(path, function (exists) {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
if (exists) { return Fs.createReadStream(path).pipe(res); }
send404(res);
});
};
var send500 = function (res, path) {
if (!path && path !== fivehundred_path) { path = fivehundred_path; }
Fs.exists(path, function (exists) {
res.setHeader('Content-Type', 'text/html; charset=utf-8');
if (exists) { return Fs.createReadStream(path).pipe(res); }
send500(res);
});
};
app.get('/api/updatequota', function (req, res) {
if (!Env.accounts_api) {
res.status(404);
return void send404(res);
}
sendMessage({
command: 'UPDATE_QUOTA',
}, (err) => {
if (err) {
res.status(500);
return void send500(res);
}
res.send();
});
});
app.get('/api/profiling', function (req, res) {
if (!Env.enableProfiling) { return void send404(res); }
sendMessage({
command: 'GET_PROFILING_DATA',
}, (err, value) => {
if (err) {
res.status(500);
return void send500(res);
}
res.setHeader('Content-Type', 'text/javascript');
res.send(JSON.stringify({
bytesWritten: value,
}));
});
});
// This endpoint handles authenticated RPCs over HTTP
// via an interactive challenge-response protocol
app.use(Express.json());
app.post('/api/auth', function (req, res, next) {
AuthCommands.handle(Env, req, res, next);
});
app.use(function (req, res /*, next */) {
if (/^(\/favicon\.ico\/|.*\.js\.map)$/.test(req.url)) {
// ignore common 404s
} else {
Log.info('HTTP_404', req.url);
}
res.status(404);
send404(res, custom_four04_path);
});
// default message for thrown errors in ExpressJS routes
app.use(function (err, req, res /*, next*/) {
Log.error('EXPRESSJS_ROUTING', {
error: err.stack || err,
});
res.status(500);
send500(res, custom_fivehundred_path);
});
var server = Http.createServer(app);
nThen(function (w) {
server.listen(Env.httpPort, w());
if (Env.httpSafePort) {
server.listen(Env.httpSafePort, w());
}
server.on('upgrade', function (req, socket, head) {
// TODO warn admins that websockets should only be proxied in this way in a dev environment
// in production it's more efficient to have your reverse proxy (NGINX) directly forward
// websocket traffic to the correct port (Env.websocketPort)
wsProxy.upgrade(req, socket, head);
});
}).nThen(function () {
// TODO inform the parent process that this worker is ready
});
process.on('uncaughtException', function (err) {
console.error('[%s] UNCAUGHT EXCEPTION IN HTTP WORKER', new Date());
console.error(err);
console.error("TERMINATING");
process.exit(1);
});

View File

@ -5,7 +5,6 @@ const Core = require("./commands/core");
const Admin = require("./commands/admin-rpc");
const Pinning = require("./commands/pin-rpc");
const Quota = require("./commands/quota");
const Block = require("./commands/block");
const Metadata = require("./commands/metadata");
const Channel = require("./commands/channel");
const Upload = require("./commands/upload");
@ -54,8 +53,6 @@ const AUTHENTICATED_USER_TARGETED = {
UPLOAD_COMPLETE: Upload.complete,
UPLOAD_CANCEL: Upload.cancel,
OWNED_UPLOAD_COMPLETE: Upload.complete_owned,
WRITE_LOGIN_BLOCK: Block.writeLoginBlock,
REMOVE_LOGIN_BLOCK: Block.removeLoginBlock,
ADMIN: Admin.command,
SET_METADATA: Metadata.setMetadata,
};

69
lib/storage/basic.js Normal file
View File

@ -0,0 +1,69 @@
/* Mulfi-factor auth requires some rudimentary storage methods
for a number of data types:
* "challenges" (described in challenge.js)
* account settings for MFA (described in mfa.js)
* session tokens (described in sessions.js)
Each data type requires the same three simple methods:
* read
* write
* delete
These could be implemented as tables in a relational database, but committing to a relational DB
is a big decision, so these methods are instead implemented using the filesystem, with each
file's path and naming convention implemented outside of this module.
Feel free to migrate all of these to a relational DB at some point in the future if you like.
*/
const Basic = module.exports;
const Fs = require("node:fs");
const Path = require("node:path");
var pathError = (cb) => {
setTimeout(function () {
cb(new Error("INVALID_PATH"));
});
};
Basic.read = function (Env, path, cb) {
if (!path) { return void pathError(cb); }
Fs.readFile(path, 'utf8', (err, content) => {
if (err) { return void cb(err); }
cb(void 0, content);
});
};
Basic.readDir = function (Env, path, cb) {
if (!path) { return void pathError(cb); }
Fs.readdir(path, cb);
};
Basic.write = function (Env, path, data, cb) {
if (!path) { return void pathError(cb); }
var dirpath = Path.dirname(path);
Fs.mkdir(dirpath, { recursive: true }, function (err) {
if (err) { return void cb(err); }
// the 'wx' flag causes writes to fail with EEXIST if a file is already present at the given path
// this could be overridden with options in the future if necessary, but it seems like a sensible default
Fs.writeFile(path, data, { flag: 'wx', }, cb);
});
};
// TODO I didn't bother implementing the usual "archive/restore/delete-from-archives" methods
// because they didn't seem particularly important for the data implemented with this module.
// They're still worth considering, though, so don't let my ommission stop you.
// Login blocks could probably be implemented with this module if these methods were supported.
// --Aaron
Basic.delete = function (Env, path, cb) {
if (!path) { return void pathError(cb); }
Fs.rm(path, cb);
};
Basic.deleteDir = function (Env, path, cb) {
if (!path) { return void pathError(cb); }
Fs.rm(path, { recursive: true, force: true }, cb);
};

View File

@ -116,6 +116,8 @@ Block.check = function (Env, publicKey, _cb) { // 'check' because 'exists' impli
Fs.access(path, Fs.constants.F_OK, cb);
};
Block.MAX_SIZE = 256;
Block.write = function (Env, publicKey, buffer, _cb) {
var cb = Util.once(Util.mkAsync(_cb));
var path = Block.mkPath(Env, publicKey);

39
lib/storage/challenge.js Normal file
View File

@ -0,0 +1,39 @@
const Basic = require("./basic.js");
const Path = require("node:path");
const Challenge = module.exports;
/* This module manages storage used to implement a public-key authenticated
challenge-response protocol.
Each 'challenge' is only intended to be valid for a short period of time.
1. A client makes a request of the server
2. The server stores their request with a nonce and challenges them to sign for this request
3. If the client successfully signs for the request within a short window then the request is executed
4. Whether the signature is valid or not, the challenge is removed
Thus, we only expect challenges to remain in storage if the request was aborted or interrupted
for some unexpected reason.
Some form of garbage collection should be implemented in the future.
*/
const pathFromId = function (Env, id) {
if (!id || typeof(id) !== 'string') { return void console.error('CHALLENGE_BAD_ID', id); }
return Path.join(Env.paths.base, "challenges", id.slice(0, 2), id);
};
Challenge.read = function (Env, id, cb) {
var path = pathFromId(Env, id);
Basic.read(Env, path, cb);
};
Challenge.write = function (Env, id, data, cb) {
var path = pathFromId(Env, id);
Basic.write(Env, path, data, cb);
};
Challenge.delete = function (Env, id, cb) {
var path = pathFromId(Env, id);
Basic.delete(Env, path, cb);
};

View File

@ -623,7 +623,7 @@ var listChannels = function (root, handler, cb, fast) {
var metadataName;
// if the current file is not the channel data, then it must be metadata
if (!/^[0-9a-fA-F]{32, 33}\.ndjson$/.test(item)) {
if (!/^[0-9a-fA-F]{32,33}\.ndjson$/.test(item)) {
metadataName = item;
channelName = item.replace(/\.metadata/, '');

88
lib/storage/mfa.js Normal file
View File

@ -0,0 +1,88 @@
const Basic = require("./basic");
const Path = require("node:path");
const Util = require("../common-util");
const Sessions = require("./sessions");
const nThen = require("nthen");
const MFA = module.exports;
/*
This module manages storage related to accounts' multi-factor authentication settings.
These settings are checked every time a block is accessed, so we do as little as possible
so that it can be accessed quickly.
*/
/* The path for a given account's settings is based on the public signing key
which identifies its "login block". We expect that any action to create or access
a block will be authenticated with a challenge-response protocol, so we
don't bother checking the validity of an identifier in here aside from
ensuring that it won't throw when using string methods.
*/
var pathFromId = function (Env, id) {
if (!id || typeof(id) !== 'string') { return; }
id = Util.escapeKeyCharacters(id);
return Path.join(Env.paths.base, "mfa", id.slice(0, 2), `${id}.json`);
};
MFA.read = function (Env, id, cb) {
var path = pathFromId(Env, id);
Basic.read(Env, path, cb);
};
// data should be a string
MFA.write = function (Env, id, data, cb) {
var path = pathFromId(Env, id);
Basic.write(Env, path, data, cb);
};
MFA.delete = function (Env, id, cb) {
var path = pathFromId(Env, id);
Basic.delete(Env, path, cb);
};
MFA.revoke = function (Env, publicKey, cb) {
nThen(function (w) {
MFA.delete(Env, publicKey, w(function (err) {
if (!err) { return; }
w.abort();
Env.Log.error('TOTP_REVOKE_MFA_DELETE', {
error: err,
publicKey: publicKey,
});
cb('MFA_ERROR');
}));
}).nThen(function () {
Sessions.deleteUser(Env, publicKey, function (err) {
if (!err) { return; }
// If we can't delete the sessions, don't send an error, just log to the server.
// The MFA will still be correctly disabled as long as the first step is done.
Env.Log.error('TOTP_REVOKE_SESSIONS__DELETE', {
error: err,
publicKey: publicKey,
});
});
}).nThen(function () {
cb(void 0, {
success: true
});
});
};
MFA.copy = function (Env, oldKey, newKey, cb) {
let content;
nThen(function (w) {
MFA.read(Env, oldKey, w(function (err, c) {
if (err) {
// No MFA configured, nothing to copy
w.abort();
return void cb();
}
content = c;
}));
}).nThen(function () {
MFA.write(Env, newKey, content, cb);
});
};

58
lib/storage/sessions.js Normal file
View File

@ -0,0 +1,58 @@
const Basic = require("./basic");
const Path = require("node:path");
const Nacl = require("tweetnacl/nacl-fast");
const Util = require("../common-util");
const Sessions = module.exports;
/* This module manages storage for per-acccount session tokens - currently assumed to be
JSON Web Tokens (JWTs).
Decisions about what goes into each of those JWTs happens upstream, so the storage
itself is relatively unopinionated.
The key things to understand are:
* valid sessions allow the holder of a given JWT to access a given "login block"
* JWTs are signed with a key held in the server's memory. If that key leaks then it should be rotated (with the SET_BEARER_SECRET decree) to invalidate all existing JWTs. Under these conditions then all tokens signed with the old key can be removed. Garbage collection of these older tokens is not implemented.
* it is expected that any given login-block can have multiple active sessions (for different devices, or if their browser clears its cache automatically). All sessions for a given block are stored in a per-user directory which is intended to make listing or iterating over them simple.
* It could be desirable to expose the list of sessions to the relevant user and allow them to revoke sessions individually or en-masse, though this is not currently implemented.
*/
var pathFromId = function (Env, id, ref) {
if (!id || typeof(id) !== 'string') { return; }
id = Util.escapeKeyCharacters(id);
return Path.join(Env.paths.base, "sessions", id.slice(0, 2), id, ref);
};
Sessions.randomId = () => Nacl.util.encodeBase64(Nacl.randomBytes(24)).replace(/\//g, '-');
Sessions.read = function (Env, id, ref, cb) {
var path = pathFromId(Env, id, ref);
Basic.read(Env, path, cb);
};
Sessions.write = function (Env, id, ref, data, cb) {
var path = pathFromId(Env, id, ref);
Basic.write(Env, path, data, cb);
};
Sessions.delete = function (Env, id, ref, cb) {
var path = pathFromId(Env, id, ref);
Basic.delete(Env, path, cb);
};
Sessions.deleteUser = function (Env, id, cb) {
if (!id || typeof(id) !== 'string') { return; }
id = Util.escapeKeyCharacters(id);
var dirPath = Path.join(Env.paths.base, "sessions", id.slice(0, 2), id);
Basic.readDir(Env, dirPath, (err, files) => {
var checkContent = !files || (Array.isArray(files) && files.every((file) => {
return file && file.length === 32;
}));
if (!checkContent) { return void cb('INVALID_SESSIONS_DIR'); }
Basic.deleteDir(Env, dirPath, cb);
});
};

View File

@ -6,6 +6,7 @@ const OS = require("os");
const { fork } = require('child_process');
const Workers = module.exports;
const PID = process.pid;
const Block = require("../storage/block");
const DB_PATH = 'lib/workers/db-worker';
const MAX_JOBS = 16;
@ -260,22 +261,7 @@ Workers.initialize = function (Env, config, _cb) {
};
nThen(function (w) {
const max = config.maxWorkers;
var limit;
if (typeof(max) !== 'undefined') {
// the admin provided a limit on the number of workers
if (typeof(max) === 'number' && !isNaN(max)) {
if (max < 1) {
Log.info("INSUFFICIENT_MAX_WORKERS", max);
limit = 1;
}
limit = max;
} else {
Log.error("INVALID_MAX_WORKERS", '[' + max + ']');
}
}
var limit = Env.maxWorkers;
var logged;
OS.cpus().forEach(function (cpu, index) {
@ -472,6 +458,16 @@ Workers.initialize = function (Env, config, _cb) {
};
Env.validateLoginBlock = function (publicKey, signature, block, cb) {
if (!block || !block.length || block.length > Block.MAX_SIZE) {
return void setTimeout(function () {
Env.Log.error('E_INVALID_BLOCK_SIZE', {
size: block.length,
});
cb('E_INVALID_BLOCK_SIZE');
});
}
sendCommand({
command: 'VALIDATE_LOGIN_BLOCK',
publicKey: publicKey,

1647
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
{
"name": "cryptpad",
"description": "realtime collaborative visual editor with zero knowlege server",
"version": "5.3.0",
"version": "5.4.0",
"license": "AGPL-3.0+",
"repository": {
"type": "git",
@ -18,16 +18,50 @@
"express": "~4.18.2",
"fs-extra": "^7.0.0",
"get-folder-size": "^2.0.1",
"netflux-websocket": "^0.1.20",
"netflux-websocket": "^1.0.0",
"http-proxy-middleware": "^2.0.6",
"jsonwebtoken": "^9.0.0",
"notp": "^2.0.3",
"nthen": "0.1.8",
"prompt-confirm": "^2.0.4",
"pull-stream": "^3.6.1",
"saferphore": "0.0.1",
"sortify": "^1.0.4",
"stream-to-pull-stream": "^1.7.2",
"thirty-two": "^1.0.2",
"tweetnacl": "~0.12.2",
"ulimit": "0.0.2",
"ws": "^3.3.1"
"ws": "^3.3.1",
"alertify.js": "1.0.11",
"bootstrap": "^4.0.0",
"bootstrap-tokenfield": "^0.12.0",
"chainpad": "^5.2.6",
"chainpad-listmap": "^1.0.0",
"chainpad-netflux": "^1.0.0",
"ckeditor": "npm:ckeditor4@^4.22.1",
"codemirror": "^5.19.0",
"components-font-awesome": "^4.6.3",
"croppie": "^2.5.0",
"file-saver": "1.3.1",
"hyper-json": "~1.4.0",
"jquery": "3.6.0",
"json.sortify": "~2.1.0",
"jszip": "3.10.1",
"dragula": "3.7.2",
"html2canvas": "^1.4.0",
"localforage": "^1.5.2",
"marked": "^4.3.0",
"mathjax": "3.0.5",
"open-sans-fontface": "^1.4.0",
"require-css": "0.1.10",
"requirejs": "2.3.5",
"requirejs-plugins": "^1.0.2",
"scrypt-async": "1.2.0",
"sortablejs": "^1.6.0",
"drawio": "cryptpad/drawio-npm#npm",
"pako": "^2.1.0",
"x2js": "^3.4.4"
},
"devDependencies": {
"jshint": "^2.13.4",
@ -35,9 +69,13 @@
},
"overrides": {
"glob-parent": "5.1.2",
"set-value": "4.0.1"
"set-value": "4.0.1",
"minimist": "~1.2.3",
"minimatch": "~3.1.2",
"jquery": "3.6.0"
},
"scripts": {
"install:components": "node scripts/copy-components.js",
"start": "node server.js",
"dev": "DEV=1 node server.js",
"fresh": "FRESH=1 node server.js",

View File

@ -22,7 +22,9 @@ The most recent version and all past release notes can be found [here](https://g
## Setup using Docker
See [CryptPad-Docker](https://github.com/cryptpad/cryptpad-docker) repository for details on how to get up-and-running with CryptPad in Docker. This repository is maintained by the community and not officially supported.
You can find `Dockerfile`, `docker-compose.yml` and `docker-entrypoint.sh` files at the root of this repository. We also publish every release on [Docker Hub](https://hub.docker.com/r/cryptpad/cryptpad) as AMD64 & ARM64 official images.
Previously, Docker images were community maintained, had their own repository and weren't official supported. We changed that with v5.4.0 during July 2023. Thanks to @promasu for all the work on the community images.
# Security

Some files were not shown because too many files have changed in this diff Show More