This commit is contained in:
Sergey Starostin 2023-10-07 22:29:20 +00:00 committed by GitHub
commit 3668fbd932
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 828 additions and 0 deletions

View File

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>KeeWeb Plugin: Keeweb S3 Storage</title>
<link rel="shortcut icon" href="/favicon.png" />
<style>
body {
font-family: -apple-system, "BlinkMacSystemFont", "Helvetica Neue", "Helvetica", "Roboto", "Arial", sans-serif;;
font-size: 14px;
}
</style>
</head>
<body>
<h1>KeeWeb Plugin: Keeweb S3 Storage</h1>
<a href="https://plugins.keeweb.info/plugins/keeweb-s3-storage">https://plugins.keeweb.info/plugins/keeweb-s3-storage</a>
<p>This plugin provides S3 storage for KeeWeb</p>
</body>
</html>

View File

@ -0,0 +1,17 @@
{
"version": "0.0.1",
"manifestVersion": "0.1.0",
"name": "keeweb-s3-storage",
"description": "S3 storage for KeeWeb",
"author": {
"name": "Sergey Starostin",
"email": "starostin89@gmail.com",
"url": "https://github.com/s-starostin/"
},
"resources": {
"js": "DTz5bI/vY7pL17Z7R2BRTj2uPMmNhuBHbZbq8IBMiavhU40j+iG59K7xYhy0ELfMkej9E89T6G3kbF3MllTXsuwRwlwCSgE3fN39C5AIwv0TOaSKgge59tG/r6Y5aITrN5Ri/J9aybhatqKlgbXtucgiHvSWbuPv4wCV/Kb9zteutuFX/ut/AW4V4iV0cd3vCs+x3IuFA982fOUWoCei2kfhltueWznEuF67qeRpaTcwab3bdtHqopjRCTFacJ0h2Hq+uJZVl4vhQpS2zIJnxybOXmtToyR5wGFUAgLmUEG/+PZJNyOczgFIDUtwqV0OF+SVuKSPlxs57ipMCavhPQ=="
},
"license": "MIT",
"url": "https://plugins.keeweb.info/plugins/keeweb-s3-storage",
"publicKey": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxelzC5fZIN7LBOGvUYhmK81O+JuCHCWZpwPtMHW710X/QLw7+pWLuGcjdt+tdy61gsyn6n64pOMZtW1bQWJMdSdLbtZSL0PRgprbPXkfXuc0KA0ag4/kN30y870fU4HvcUZx0LVNVIWnYZMlnIbijX5in99tqyC4em2jUfqiRZ4fbXvwPlelEV7jORtXyIWif1A16MfMGx+y2uj8SBe/rkvUQvZ1IA4PDayPqxa7FW0nAOAWT6HktcxlRmgEBTY+aXgzNuj3HxHoYfG5VqWZZM7boLCksXxSxqdoXS10Ryy+93ftpPznKAz8qgeRkvi9Ai5idaAlWujLgM8qv+7F2QIDAQAB"
}

View File

@ -0,0 +1,792 @@
/**
* KeeWeb plugin: keeweb-s3-storage
* @author Sergey Starostin
* @license MIT
*/
const Storage = require('storage/index').Storage;
const BaseLocale = require('locales/base');
const StorageBase = require('storage/storage-base').StorageBase;
const Logger = require('util/logger').Logger;
const signV4Algorithm = "AWS4-HMAC-SHA256";
const HMAC = {
m: new Uint32Array(64),
littleEndian: !!new Uint8Array(new Uint32Array([1]).buffer)[0],
encoder: new TextEncoder("utf-8"),
_getFractionalBits: function(n) {
return ((n - (n | 0)) * Math.pow(2, 32)) | 0;
},
get origin() {
delete this.origin;
let defaultState = new Uint32Array(8);
let roundConstants = [];
let n = 2,
nPrime = 0;
while (nPrime < 64) {
let isPrime = true;
for (var factor = 2; factor <= n / 2; factor++) {
if (n % factor === 0) {
isPrime = false;
}
}
if (isPrime) {
if (nPrime < 8) {
defaultState[nPrime] = this._getFractionalBits(Math.pow(n, 1 / 2));
}
roundConstants[nPrime] = this._getFractionalBits(Math.pow(n, 1 / 3));
nPrime++;
}
n++;
}
return this.origin = {
defaultState,
roundConstants
};
},
_convertEndian: function(word) {
if (this.littleEndian) {
return (
(word >>> 24) |
(((word >>> 16) & 0xff) << 8) |
((word & 0xff00) << 8) |
(word << 24)
);
} else {
return word;
}
},
_rightRotate: function(word, bits) {
return (word >>> bits) | (word << (32 - bits));
},
_sha256: function(data) {
let state = this.origin.defaultState.slice();
let length = data.length;
let bitLength = length * 8;
let newBitLength = (512 - ((bitLength + 64) % 512) - 1) + bitLength + 65;
let bytes = new Uint8Array(newBitLength / 8);
let words = new Uint32Array(bytes.buffer);
bytes.set(data, 0);
bytes[length] = 0b10000000;
words[words.length - 1] = this._convertEndian(bitLength);
let round;
for (let block = 0; block < newBitLength / 32; block += 16) {
let workingState = state.slice();
for (round = 0; round < 64; round++) {
let MRound;
if (round < 16) {
MRound = this._convertEndian(words[block + round]);
} else {
let gamma0x = this.m[round - 15];
let gamma1x = this.m[round - 2];
MRound =
this.m[round - 7] + this.m[round - 16] + (
this._rightRotate(gamma0x, 7) ^
this._rightRotate(gamma0x, 18) ^
(gamma0x >>> 3)
) + (
this._rightRotate(gamma1x, 17) ^
this._rightRotate(gamma1x, 19) ^
(gamma1x >>> 10)
);
}
this.m[round] = MRound |= 0;
let t1 =
(
this._rightRotate(workingState[4], 6) ^
this._rightRotate(workingState[4], 11) ^
this._rightRotate(workingState[4], 25)
) +
(
(workingState[4] & workingState[5]) ^
(~workingState[4] & workingState[6])
) + workingState[7] + MRound + this.origin.roundConstants[round];
let t2 =
(
this._rightRotate(workingState[0], 2) ^
this._rightRotate(workingState[0], 13) ^
this._rightRotate(workingState[0], 22)
) +
(
(workingState[0] & workingState[1]) ^
(workingState[2] & (workingState[0] ^
workingState[1]))
);
for (let i = 7; i > 0; i--) {
workingState[i] = workingState[i - 1];
}
workingState[0] = (t1 + t2) | 0;
workingState[4] = (workingState[4] + t1) | 0;
}
for (round = 0; round < 8; round++) {
state[round] = (state[round] + workingState[round]) | 0;
}
}
let self = this;
return new Uint8Array(new Uint32Array(
state.map(function(val) {
return self._convertEndian(val);
})
).buffer);
},
_hmac: function(key, data) {
if (key.length > 64)
key = this._sha256(key);
if (key.length < 64) {
const tmp = new Uint8Array(64);
tmp.set(key, 0);
key = tmp;
}
let innerKey = new Uint8Array(64);
let outerKey = new Uint8Array(64);
for (var i = 0; i < 64; i++) {
innerKey[i] = 0x36 ^ key[i];
outerKey[i] = 0x5c ^ key[i];
}
let msg = new Uint8Array(data.length + 64);
msg.set(innerKey, 0);
msg.set(data, 64);
let result = new Uint8Array(64 + 32);
result.set(outerKey, 0);
result.set(this._sha256(msg), 64);
return this._sha256(result);
},
sign: function(inputKey, inputData) {
const key = typeof inputKey === "string" ? this.encoder.encode(inputKey) : inputKey;
const data = typeof inputData === "string" ? this.encoder.encode(inputData) : inputData;
return this._hmac(key, data);
},
hash: function(str) {
return this.hex(this._sha256(this.encoder.encode(str)));
},
hex: function(bin) {
return bin.reduce((acc, val) =>
acc + ("00" + val.toString(16)).substr(-2), "");
},
hashSign: function(inputKey, inputData) {
return this.sign(inputKey, inputData);
},
hashHexSign: function(inputKey, inputData) {
return this.hex(this.sign(inputKey, inputData));
}
};
class S3Storage extends StorageBase {
constructor(props) {
super(props);
this.name = "s3Storage";
this.icon = "database";
this.enabled = true;
this.uipos = 100;
this.logger = new Logger("storage-s3");
}
needShowOpenConfig() {
return true;
}
getOpenConfig() {
return {
fields: [{
id: "key",
title: "s3AccessKeyTitle",
desc: "s3AccessKeyDesc",
placeholder: "s3AccessKeyPlaceholder",
type: "text",
required: true,
},
{
id: "secret",
title: "s3SecretTitle",
desc: "s3SecretDesc",
placeholder: "s3SecretPlaceholder",
type: "password",
required: true
},
{
id: "region",
title: "s3RegionTitle",
desc: "s3RegionDesc",
placeholder: "s3RegionPlaceholder",
type: "text"
},
{
id: "origin",
title: "s3OriginTitle",
desc: "s3OriginDesc",
placeholder: "s3OriginPlaceholder",
type: "text",
required: true
},
{
id: "path",
title: "s3PathTitle",
desc: "s3PathDesc",
placeholder: "s3PathPlaceholder",
type: "text",
required: true
}
]
};
}
getSettingsConfig() {
return {
fields: []
};
}
fileOptsToStoreOpts(opts, file) {
const result = {
key: opts.key,
secret: opts.secret,
region: opts.region,
origin: opts.origin
};
return result;
}
storeOptsToFileOpts(opts, file) {
const result = {
key: opts.key,
secret: opts.secret,
region: opts.region,
origin: opts.origin
};
return result;
}
applySetting(key, value) {
this.appSettings[key] = value;
}
getPathForName(fileName) {
return fileName;
}
load(path, opts, callback) {
this._request({
op: "Load",
method: "GET",
path,
key: opts ? opts.key : null,
secret: opts ? opts.secret : null,
region: opts ? opts.region : null,
origin: opts ? opts.origin : null
},
callback ?
(err, xhr) => {
this.logger.debug(xhr, err, callback);
callback(err, xhr.response, this._calcStatByContent(xhr));
} :
null
);
}
stat(path, opts, callback) {
this._statRequest(
path,
opts,
"Stat",
callback ? (err, xhr, stat) => callback(err, stat) : null
);
}
_isNumber(v) {
return typeof v === "number" && !isNaN(v);
}
_isString(v) {
return (typeof v === "string" || v instanceof String);
}
_isObject(v) {
return (typeof v === "object" || v !== null);
}
_isArray(v) {
return (v.constructor === Array);
}
_statRequest(path, opts, op, callback) {
this._request({
op,
method: "GET",
path,
key: opts ? opts.key : null,
secret: opts ? opts.secret : null,
region: opts ? opts.region : null,
origin: opts ? opts.origin : null
},
callback ?
(err, xhr) => {
callback(err, xhr, this._calcStatByContent(xhr));
} :
null
);
}
save(path, opts, data, callback, rev) {
const cb = function(err, xhr, stat) {
if (callback) {
callback(err, stat);
callback = null;
}
};
const saveOpts = {
path,
key: opts ? opts.key : null,
secret: opts ? opts.secret : null,
region: opts ? opts.region : null,
origin: opts ? opts.origin : null
};
this._statRequest(path, opts, "Save:stat", (err, xhr, stat) => {
if (err) {
if (!err.notFound) {
return cb(err);
} else {
this.logger.debug("Save: not found, creating");
}
} else if (stat.rev !== rev) {
this.logger.debug("Save error", path, "rev conflict", stat.rev, rev);
return cb({
revConflict: true
}, xhr, stat);
}
this._request({
...saveOpts,
op: "Save:put",
method: "PUT",
data
},
(err) => {
if (err) {
return cb(err);
}
this._statRequest(path, opts, "Save:stat", (err, xhr, stat) => {
cb(err, xhr, stat);
});
}
);
});
}
list(dir, callback) {
callback("fail");
}
remove(path, callback) {
callback("fail");
}
setEnabled(enabled) {
StorageBase.prototype.setEnabled.call(this, enabled);
}
_getCanonicalRequest(method, path, headers, signedHeaders) {
if (!this._isString(method)) {
//method should be of type "string"
}
if (!this._isString(path)) {
//path should be of type "string"
}
if (!this._isObject(headers)) {
//headers should be of type "object"
}
if (!this._isArray(signedHeaders)) {
//signedHeaders should be of type "array"
}
const headersArray = signedHeaders.reduce((acc, i) => {
// Trim spaces from the value (required by V4 spec)
const val = `${headers[i]}`.replace(/ +/g, " ");
acc.push(`${i.toLowerCase()}:${val}`);
return acc;
}, []);
const canonical = [];
canonical.push(method.toUpperCase());
canonical.push(path);
canonical.push("");
canonical.push(headersArray.join("\n") + "\n");
canonical.push(signedHeaders.join(";").toLowerCase());
canonical.push("UNSIGNED-PAYLOAD");
return canonical.join("\n");
}
_makeDateShort(date) {
date = date || new Date();
// Gives format like: "2017-08-07T16:28:59.889Z"
date = date.toISOString();
return date.substr(0, 4) +
date.substr(5, 2) +
date.substr(8, 2);
}
_makeDateLong(date) {
date = date || new Date();
// Gives format like: "2017-08-07T16:28:59.889Z"
date = date.toISOString();
return date.substr(0, 4) +
date.substr(5, 2) +
date.substr(8, 5) +
date.substr(14, 2) +
date.substr(17, 2) + "Z";
}
_getScope(region, date, serviceName = "s3") {
return `${this._makeDateShort(date)}/${region}/${serviceName}/aws4_request`;
}
_uriEscape(string) {
return string.split("").reduce((acc, elem) => {
let bytes = [];
let code = elem.charCodeAt(0);
bytes.push(code & 0xff);
let e = code / 256 >>> 0;
if (e > 0) {
bytes.push(e);
}
if (bytes.length === 1) {
// length 1 indicates that elem is not a unicode character.
// Check if it is an unreserved characer.
if ("A" <= elem && elem <= "Z" ||
"a" <= elem && elem <= "z" ||
"0" <= elem && elem <= "9" ||
elem === "_" ||
elem === "." ||
elem === "~" ||
elem === "-") {
// Unreserved characer should not be encoded.
acc = acc + elem;
return acc;
}
}
// elem needs encoding - i.e elem should be encoded if it's not unreserved
// character or if it's a unicode character.
for (var i = 0; i < bytes.length; i++) {
acc = acc + "%" + bytes[i].toString(16).toUpperCase();
}
return acc;
}, "");
}
_uriResourceEscape(string) {
return this._uriEscape(string).replace(/%2F/g, '/');
}
_getCredential(accessKey, region, requestDate, serviceName = "s3") {
if (!this._isString(accessKey)) {
//accessKey should be of type "string"
}
if (!this._isString(region)) {
//region should be of type "string"
}
if (!this._isObject(requestDate)) {
//requestDate should be of type "object"
}
return `${accessKey}/${this._getScope(region, requestDate, serviceName)}`;
}
_getSignedHeaders(headers) {
if (!this._isObject(headers)) {
//request should be of type "object"
}
const passedHeaders = ["host", "x-amz-", "content-type"];
let _ = Object.entries(headers);
return _.map(([header, value]) => header)
.filter((header) => {
return passedHeaders.some(h => header.toLowerCase().includes(h));
});
}
// returns the key used for calculating signature
_getSigningKey(date, region, secretKey, serviceName = "s3") {
if (!this._isObject(date)) {
//date should be of type "object"
}
if (!this._isString(region)) {
//region should be of type "string"
}
if (!this._isString(secretKey)) {
//secretKey should be of type "string"
}
const dateLine = this._makeDateShort(date);
let hmac1 = HMAC.hashSign("AWS4" + secretKey, dateLine),
hmac2 = HMAC.hashSign(hmac1, region),
hmac3 = HMAC.hashSign(hmac2, serviceName);
return HMAC.hashSign(hmac3, "aws4_request");
}
// returns the string that needs to be signed
_getStringToSign(canonicalRequest, requestDate, region, serviceName = "s3") {
if (!this._isString(canonicalRequest)) {
//canonicalRequest should be of type "string"
}
if (!this._isObject(requestDate)) {
//requestDate should be of type "object"
}
if (!this._isString(region)) {
//region should be of type "string"
}
const hash = HMAC.hash(canonicalRequest);
const scope = this._getScope(region, requestDate, serviceName);
const stringToSign = [];
stringToSign.push(signV4Algorithm);
stringToSign.push(this._makeDateLong(requestDate));
stringToSign.push(scope);
stringToSign.push(hash);
const signString = stringToSign.join("\n");
return signString;
}
// calculate the signature of the POST policy
_postPresignSignatureV4(region, date, secretKey, policyBase64) {
if (!this._isString(region)) {
//region should be of type "string"
}
if (!this._isObject(date)) {
//date should be of type "object"
}
if (!this._isString(secretKey)) {
//secretKey should be of type "string"
}
if (!this._isString(policyBase64)) {
//policyBase64 should be of type "string"
}
const signingKey = this._getSigningKey(date, region, secretKey);
return HMAC.hashHexSign(signingKey, policyBase64).toLowerCase();
}
// Returns the authorization header
_signV4(request, accessKey, secretKey, region, requestDate, serviceName = "s3") {
if (!this._isObject(request)) {
//request should be of type "object"
}
if (!this._isString(accessKey)) {
//accessKey should be of type "string"
}
if (!this._isString(secretKey)) {
//secretKey should be of type "string"
}
if (!this._isString(region)) {
//region should be of type "string"
}
if (!accessKey) {
//accessKey is required for signing
}
if (!secretKey) {
//secretKey is required for signing
}
let sortedHeaders = [];
for (var header in request.headers) {
sortedHeaders.push([header, request.headers[header]]);
}
sortedHeaders.sort((a, b) => a[0].localeCompare(b[0], undefined, {
sensitivity: "base"
}));
request.headers = {};
sortedHeaders.forEach(function(item) {
request.headers[item[0]] = item[1];
});
const signedHeaders = this._getSignedHeaders(request.headers);
const canonicalRequest = this._getCanonicalRequest(request.method, request.path, request.headers,
signedHeaders);
const serviceIdentifier = serviceName || "s3";
requestDate = requestDate || new Date();
const stringToSign = this._getStringToSign(canonicalRequest, requestDate, region, serviceIdentifier);
const signingKey = this._getSigningKey(requestDate, region, secretKey, serviceIdentifier);
const credential = this._getCredential(accessKey, region, requestDate, serviceIdentifier);
const signature = HMAC.hashHexSign(signingKey, stringToSign).toLowerCase();
return `${signV4Algorithm} Credential=${credential},SignedHeaders=${signedHeaders.join(";").toLowerCase()},Signature=${signature}`;
}
_signV4ByServiceName(request, accessKey, secretKey, region, requestDate, serviceName = "s3") {
return this._signV4(request, accessKey, secretKey, region, requestDate, serviceName);
}
_calcStatByContent(xhr) {
if (
xhr.status !== 200 ||
xhr.responseType !== "arraybuffer" ||
!xhr.response ||
!xhr.response.byteLength
) {
this.logger.debug("Cannot calculate rev by content");
return null;
}
const rev = HMAC.hash(
String.fromCharCode.apply(null, new Uint16Array(xhr.response))
).substr(0, 10);
this.logger.debug("Calculated rev by content", `${xhr.response.byteLength} bytes`, rev);
return { rev };
}
_request(config, callback) {
if (config.rev) {
this.logger.debug(config.op, config.path, config.rev);
} else {
this.logger.debug(config.op, config.path);
}
if (!config.headers) {
config.headers = {};
}
const ts = this.logger.ts();
const xhr = new XMLHttpRequest();
xhr.responseType = "arraybuffer";
xhr.addEventListener("load", () => {
if ([200, 201, 204].indexOf(xhr.status) < 0) {
this.logger.debug(
config.op + " error",
config.path,
xhr.status,
this.logger.ts(ts)
);
let err;
switch (xhr.status) {
case 404:
err = {
notFound: true
};
break;
case 412:
err = {
revConflict: true
};
break;
default:
err = "HTTP status " + xhr.status;
break;
}
if (callback) {
callback(err, xhr);
callback = null;
}
return;
}
const rev = xhr.getResponseHeader("Last-Modified");
const completedOpName =
config.op + (config.op.charAt(config.op.length - 1) === "e" ? "d" : "ed");
this.logger.debug(completedOpName, config.path, rev, this.logger.ts(ts));
if (callback) {
callback(null, xhr, rev ? {
rev
} : null);
callback = null;
}
});
xhr.addEventListener("error", () => {
this.logger.debug(config.op + " error", config.path, this.logger.ts(ts));
if (callback) {
callback("network error", xhr);
callback = null;
}
});
xhr.addEventListener("abort", () => {
this.logger.debug(config.op + " error", config.path, "aborted", this.logger.ts(ts));
if (callback) {
callback("aborted", xhr);
callback = null;
}
});
config.headers["host"] = (new URL(config.origin)).host;
config.headers["x-amz-date"] = this._makeDateLong(new Date());
config.headers["x-amz-content-sha256"] = "UNSIGNED-PAYLOAD";
config.headers["content-type"] = "application/octet-stream";
xhr.open(config.method, config.origin + config.path);
if (["GET", "HEAD"].indexOf(config.method) >= 0) {
xhr.setRequestHeader("cache-control", "no-cache");
}
if (config.key) {
xhr.setRequestHeader("authorization", this._signV4ByServiceName(config, config.key, config.secret, config.region));
}
if (config.headers) {
for (const [header, value] of Object.entries(config.headers)) {
if (header != "host") {
xhr.setRequestHeader(header, value);
}
}
}
if (config.data) {
const blob = new Blob([config.data], {
type: "application/octet-stream"
});
xhr.send(blob);
} else {
xhr.send();
}
}
}
Object.assign(BaseLocale, {
s3Storage: "S3 Storage",
s3AccessKeyTitle: "Access Key",
s3AccessKeyDesc: "An access key grants programmatic access to your resources.",
s3AccessKeyPlaceholder: "AKIAJSIE27KKMHXI3BJQ",
s3SecretTitle: "Secret Key",
s3SecretDesc: "Secret access keys are secrets, like your password.",
s3SecretPlaceholder: "5bEYu26084qjSFyclM/f2pz4gviSfoOg+mFwBH39",
s3RegionTitle: "Region",
s3RegionDesc: "Amazon S3 creates bucket in a region you specify.",
s3RegionPlaceholder: "us-east-1",
s3OriginTitle: "Origin",
s3OriginDesc: "An S3 bucket can be accessed through its URL.",
s3OriginPlaceholder: "http://docexamplebucket1.s3.amazonaws.com",
s3PathTitle: "Path to .kdbx",
s3PathDesc: "Path to KeePass data file in the bucket.",
s3PathPlaceholder: "/bucket/file.kdbx",
s3SaveMove: "Upload a temporary file and move",
s3SavePut: "Overwrite the kdbx file with PUT"
});
Storage.s3Storage = new S3Storage();
module.exports.uninstall = function() {
delete BaseLocale.s3Storage;
delete Storage.s3Storage;
};