feat: Initial clean release of CHM
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
一些检查失败了
automerge / automerge (push) Has been cancelled
react-native-android-build-apk / AssembleRelease (push) Has been cancelled
Build macOS M1 / Main (push) Has been cancelled
Joplin Continuous Integration / Main (macos-15-intel) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / Main (ubuntu-22.04-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-11-vs2026-arm) (push) Has been cancelled
Joplin Continuous Integration / Main (windows-2025) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04) (push) Has been cancelled
Joplin Continuous Integration / ServerDockerImage (ubuntu-22.04-arm) (push) Has been cancelled
Joplin UI tests / Main (ubuntu-22.04) (push) Has been cancelled
Joplin UI tests / Main (windows-2025) (push) Has been cancelled
Joplin UI tests / Mobile (push) Has been cancelled
Close stale issues / ProcessStaleIssues (push) Has been cancelled
这个提交包含在:
@@ -0,0 +1,73 @@
|
||||
import { CiphertextBuffer, PublicKeyCrypto } from '../../types';
|
||||
|
||||
// This implementation is intended to roughly match what's used by NodeRSA
|
||||
// for long input data (https://github.com/rzcoder/node-rsa/blob/e7e7f7d2942a3bac1d2e132a881e5a3aceda10a1/src/libs/rsa.js#L252).
|
||||
// However, this approach ("Electronic Code Block mode") is known to be insecure
|
||||
// in certain situations (e.g. if an attacker controls a prefix of the plaintext
|
||||
// and can inspect the ciphertext or for certain plaintexts). The two previously-listed
|
||||
// cases *shouldn't* affect Joplin master key encryption (pseudorandom data with
|
||||
// a length slightly bigger than the block size). However, it would be best to
|
||||
// avoid using "LongDataWrapper" if an alternative is available.
|
||||
export default class LongDataWrapper<KeyPair> implements PublicKeyCrypto<KeyPair, Buffer<ArrayBuffer>> {
|
||||
public constructor(
|
||||
private publicKeyCrypto_: PublicKeyCrypto<KeyPair, Buffer<ArrayBuffer>>,
|
||||
// For RSA, this is usually the key size
|
||||
private ciphertextBlockSizeBytes_: number,
|
||||
) {}
|
||||
|
||||
public async generateKeyPair() {
|
||||
return this.publicKeyCrypto_.generateKeyPair();
|
||||
}
|
||||
|
||||
public async loadKeys(publicKeySource: string, privateKeySource: string, keySizeBits: number) {
|
||||
return this.publicKeyCrypto_.loadKeys(publicKeySource, privateKeySource, keySizeBits);
|
||||
}
|
||||
|
||||
public get maximumPlaintextLengthBytes() {
|
||||
// For security, input data should not be much longer than the wrapped
|
||||
// implementation's maximum length.
|
||||
return this.publicKeyCrypto_.maximumPlaintextLengthBytes * 2;
|
||||
}
|
||||
|
||||
public async encrypt(plaintext: Buffer<ArrayBuffer>, rsaKeyPair: KeyPair) {
|
||||
if (plaintext.length > this.maximumPlaintextLengthBytes) {
|
||||
throw new Error(`Input data too long (maximum length: ${this.maximumPlaintextLengthBytes})`);
|
||||
}
|
||||
|
||||
const chunks = [];
|
||||
|
||||
const internalMaximumLength = this.publicKeyCrypto_.maximumPlaintextLengthBytes;
|
||||
for (let offset = 0; offset < plaintext.length; offset += internalMaximumLength) {
|
||||
const subarray = plaintext.subarray(offset, offset + internalMaximumLength);
|
||||
const chunk = await this.publicKeyCrypto_.encrypt(subarray, rsaKeyPair);
|
||||
|
||||
if (chunk.byteLength !== this.ciphertextBlockSizeBytes_) {
|
||||
throw new Error(`Assertion failed: ciphertextBlockSize_ (${this.ciphertextBlockSizeBytes_}) does not match the actual block size.`);
|
||||
}
|
||||
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
public async decrypt(ciphertext: CiphertextBuffer, rsaKeyPair: KeyPair) {
|
||||
if (ciphertext.byteLength % this.ciphertextBlockSizeBytes_ !== 0) {
|
||||
throw new Error(`The ciphertext length (${ciphertext.byteLength}) must be a multiple of the block size (${this.ciphertextBlockSizeBytes_})`);
|
||||
}
|
||||
|
||||
const chunks = [];
|
||||
for (let offset = 0; offset < ciphertext.length; offset += this.ciphertextBlockSizeBytes_) {
|
||||
const subarray = ciphertext.subarray(offset, offset + this.ciphertextBlockSizeBytes_);
|
||||
chunks.push(await this.publicKeyCrypto_.decrypt(subarray, rsaKeyPair));
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
public async publicKey(rsaKeyPair: KeyPair) {
|
||||
return this.publicKeyCrypto_.publicKey(rsaKeyPair);
|
||||
}
|
||||
|
||||
public async privateKey(rsaKeyPair: KeyPair) {
|
||||
return this.publicKeyCrypto_.privateKey(rsaKeyPair);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { PublicKeyCrypto } from '../../types';
|
||||
|
||||
|
||||
const isLowercaseHexadecimalString = (text: string) => {
|
||||
return text.match(/^[a-f0-9]+$/) && text.length % 2 === 0;
|
||||
};
|
||||
|
||||
export default class StringToBufferDecorator<KeyPair> implements PublicKeyCrypto<KeyPair> {
|
||||
public constructor(private publicKeyCrypto_: PublicKeyCrypto<KeyPair, Buffer<ArrayBuffer>>) {}
|
||||
|
||||
public async generateKeyPair() {
|
||||
return this.publicKeyCrypto_.generateKeyPair();
|
||||
}
|
||||
|
||||
public async loadKeys(publicKeySource: string, privateKeySource: string, keySizeBits: number) {
|
||||
return this.publicKeyCrypto_.loadKeys(publicKeySource, privateKeySource, keySizeBits);
|
||||
}
|
||||
|
||||
private textToBuffer_(textUtf8: string) {
|
||||
// To avoid data loss when restoring (whether everything is capital or lowercase), work only
|
||||
// with lowercase hexadecimal.
|
||||
const isHex = isLowercaseHexadecimalString(textUtf8);
|
||||
|
||||
// RSA can only encrypt a limited amount of data. If given hexadecimal data (e.g. when given
|
||||
// a Joplin master key), try to encrypt in that format.
|
||||
let data = isHex ? Buffer.from(textUtf8, 'hex') : Buffer.from(textUtf8, 'utf8');
|
||||
|
||||
// Store the original data format with a single-byte suffix.
|
||||
if (isHex) {
|
||||
data = Buffer.concat([data, new Uint8Array([1])]);
|
||||
} else {
|
||||
data = Buffer.concat([data, new Uint8Array([0])]);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
private bufferToText_(buffer: Buffer) {
|
||||
let encoding: BufferEncoding;
|
||||
const isHex = buffer.byteLength > 0 && buffer.readUInt8(buffer.byteLength - 1) === 1;
|
||||
if (isHex) {
|
||||
encoding = 'hex';
|
||||
} else {
|
||||
encoding = 'utf8';
|
||||
}
|
||||
|
||||
// Use .slice for mobile compatibility:
|
||||
buffer = buffer.slice(0, buffer.byteLength - 1);
|
||||
return Buffer.from(buffer).toString(encoding);
|
||||
}
|
||||
|
||||
public get maximumPlaintextLengthBytes() {
|
||||
const baseMaximumLength = this.publicKeyCrypto_.maximumPlaintextLengthBytes;
|
||||
if (!baseMaximumLength) return baseMaximumLength;
|
||||
|
||||
// Subtract an additional -1 to account for metadata:
|
||||
return baseMaximumLength - 1;
|
||||
}
|
||||
|
||||
public async encrypt(plaintextUtf8: string, rsaKeyPair: KeyPair) {
|
||||
const plaintextBuffer = this.textToBuffer_(plaintextUtf8);
|
||||
const internalMaximumLength = this.publicKeyCrypto_.maximumPlaintextLengthBytes;
|
||||
if (plaintextBuffer.byteLength > internalMaximumLength) {
|
||||
throw new Error(`Data too long (longer than ${internalMaximumLength})`);
|
||||
}
|
||||
|
||||
const ciphertext = await this.publicKeyCrypto_.encrypt(plaintextBuffer, rsaKeyPair);
|
||||
return Buffer.from(ciphertext);
|
||||
}
|
||||
|
||||
public async decrypt(ciphertext: Buffer<ArrayBuffer>, rsaKeyPair: KeyPair) {
|
||||
const plaintextBuffer = await this.publicKeyCrypto_.decrypt(ciphertext, rsaKeyPair);
|
||||
return this.bufferToText_(plaintextBuffer);
|
||||
}
|
||||
|
||||
public async publicKey(rsaKeyPair: KeyPair) {
|
||||
return this.publicKeyCrypto_.publicKey(rsaKeyPair);
|
||||
}
|
||||
|
||||
public async privateKey(rsaKeyPair: KeyPair) {
|
||||
return this.publicKeyCrypto_.privateKey(rsaKeyPair);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { CiphertextBuffer, PublicKeyCrypto } from '../../types';
|
||||
|
||||
// The web crypto API is available in both NodeJS and browsers
|
||||
export type WebCryptoSlice = {
|
||||
subtle: Pick<
|
||||
SubtleCrypto,
|
||||
// Restrict to methods that are known to be supported on mobile:
|
||||
'generateKey'|'importKey'|'encrypt'|'decrypt'|'exportKey'
|
||||
>;
|
||||
};
|
||||
|
||||
export interface KeyPair {
|
||||
publicKey: CryptoKey;
|
||||
privateKey: CryptoKey|null;
|
||||
}
|
||||
|
||||
interface RsaCryptoConfig {
|
||||
modulusLengthBits: number;
|
||||
}
|
||||
|
||||
export default class WebCryptoRsa implements PublicKeyCrypto<KeyPair, Buffer<ArrayBuffer>> {
|
||||
public constructor(private webCrypto_: WebCryptoSlice, private config_: RsaCryptoConfig) {}
|
||||
|
||||
public async generateKeyPair() {
|
||||
// See the RSA dom example:
|
||||
// https://github.com/mdn/dom-examples/blob/8c3d2a9781204e60b52371e300aebf067cd0b876/web-crypto/encrypt-decrypt/rsa-oaep.js#L71
|
||||
// and the relevant MDN documentation:
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/RsaHashedKeyGenParams
|
||||
const keyPair = await this.webCrypto_.subtle.generateKey({
|
||||
name: 'RSA-OAEP',
|
||||
modulusLength: this.config_.modulusLengthBits,
|
||||
// From https://developer.mozilla.org/en-US/docs/Web/API/RsaHashedKeyGenParams#publicexponent
|
||||
// "Unless you have a good reason to use something else, specify 65537 here ([0x01, 0x00, 0x01])."
|
||||
publicExponent: new Uint8Array([1, 0, 1]),
|
||||
hash: 'SHA-256',
|
||||
}, true, ['encrypt', 'decrypt']);
|
||||
|
||||
return {
|
||||
keyPair,
|
||||
keySize: this.config_.modulusLengthBits,
|
||||
};
|
||||
}
|
||||
|
||||
public async loadKeys(publicKeySource: string, privateKeySource: string): Promise<KeyPair> {
|
||||
const importKey = (keySource: string, usages: KeyUsage[]) => {
|
||||
return this.webCrypto_.subtle.importKey(
|
||||
'jwk', JSON.parse(keySource), { name: 'RSA-OAEP', hash: 'SHA-256' }, true, usages,
|
||||
);
|
||||
};
|
||||
const publicKey = await importKey(publicKeySource, ['encrypt']);
|
||||
const privateKey = privateKeySource ? await importKey(privateKeySource, ['decrypt']) : null;
|
||||
return {
|
||||
publicKey,
|
||||
privateKey,
|
||||
};
|
||||
}
|
||||
|
||||
public get maximumPlaintextLengthBytes() {
|
||||
// See https://www.rfc-editor.org/rfc/rfc8017#section-7.1.1,
|
||||
// and https://crypto.stackexchange.com/a/42100
|
||||
const modulusLengthBytes = Math.ceil(this.config_.modulusLengthBits / 8);
|
||||
const hashLengthBytes = 256 / 8;
|
||||
const maximumMessageSize = modulusLengthBytes - 2 * hashLengthBytes - 2;
|
||||
return maximumMessageSize;
|
||||
}
|
||||
|
||||
public async encrypt(plaintextBuffer: Buffer<ArrayBuffer>, rsaKeyPair: KeyPair) {
|
||||
if (!rsaKeyPair.publicKey) {
|
||||
throw new Error('Missing public key');
|
||||
}
|
||||
|
||||
if (plaintextBuffer.byteLength > this.maximumPlaintextLengthBytes) {
|
||||
// Fail early -- the error provided by webCrypto when the data is too long is often
|
||||
// difficult to understand.
|
||||
throw new Error('Data too long');
|
||||
}
|
||||
|
||||
const ciphertext = await this.webCrypto_.subtle.encrypt(
|
||||
{ name: 'RSA-OAEP' }, rsaKeyPair.publicKey, plaintextBuffer,
|
||||
);
|
||||
return Buffer.from(ciphertext);
|
||||
}
|
||||
|
||||
public async decrypt(ciphertext: CiphertextBuffer, rsaKeyPair: KeyPair) {
|
||||
if (!rsaKeyPair.privateKey) {
|
||||
throw new Error('Missing private key');
|
||||
}
|
||||
|
||||
const plaintextBuffer = Buffer.from(await this.webCrypto_.subtle.decrypt(
|
||||
{ name: 'RSA-OAEP' }, rsaKeyPair.privateKey, ciphertext,
|
||||
));
|
||||
return plaintextBuffer;
|
||||
}
|
||||
|
||||
private async exportKey_(key: CryptoKey) {
|
||||
const exported = { ...await this.webCrypto_.subtle.exportKey('jwk', key) };
|
||||
|
||||
// Remove padding -- When running in React Native, JWK base64URL fields are padded with "."s.
|
||||
// Chromium fails to import such keys, with a comment that the "JSON web signature spec says that padding is omitted."
|
||||
// See https://github.com/chromium/chromium/blob/dd96966cf845460bad4bc352625b9188e98ae501/components/webcrypto/jwk.cc#L309
|
||||
const base64Members = ['n', 'e', 'qi', 'dp', 'dq', 'q', 'p', 'd'];
|
||||
const result = Object.create(null);
|
||||
for (const [key, value] of Object.entries(exported)) {
|
||||
if (base64Members.includes(key) && typeof value === 'string') {
|
||||
// react-native-quick-crypto uses "."s for padding:
|
||||
result[key] = value.replace(/\.+$/, '');
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify(result);
|
||||
}
|
||||
|
||||
public async publicKey(rsaKeyPair: KeyPair) {
|
||||
return this.exportKey_(rsaKeyPair.publicKey);
|
||||
}
|
||||
|
||||
public async privateKey(rsaKeyPair: KeyPair) {
|
||||
if (!rsaKeyPair.privateKey) {
|
||||
throw new Error('Missing private key');
|
||||
}
|
||||
|
||||
return this.exportKey_(rsaKeyPair.privateKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { webcrypto } from 'node:crypto';
|
||||
import buildRsaCryptoProvider from './buildRsaCryptoProvider';
|
||||
import { PublicKeyAlgorithm } from '../../types';
|
||||
|
||||
|
||||
describe.each([
|
||||
{ algorithm: PublicKeyAlgorithm.RsaV2 },
|
||||
{ algorithm: PublicKeyAlgorithm.RsaV3 },
|
||||
])('buildRsaCryptoProvider (%j)', ({ algorithm }) => {
|
||||
const rsaProvider = buildRsaCryptoProvider(algorithm, webcrypto);
|
||||
|
||||
// The default error message when a message is larger than the maximum length
|
||||
// can be confusing. Verifies that a better message is provided:
|
||||
test('should encrypt data up to the maximum length, then throw', async () => {
|
||||
const { keyPair, keySize: keySizeBits } = await rsaProvider.generateKeyPair();
|
||||
|
||||
// Should handle the case where given empty input
|
||||
const keyLengthBytes = keySizeBits / 8;
|
||||
expect(await rsaProvider.encrypt('', keyPair)).toHaveLength(keyLengthBytes);
|
||||
|
||||
const maximumLength = rsaProvider.maximumPlaintextLengthBytes;
|
||||
expect(maximumLength).toBeGreaterThanOrEqual(190);
|
||||
for (let length = 1; length <= maximumLength; length++) {
|
||||
const hexData = Buffer.alloc(length).toString('hex');
|
||||
// Verify that it encrypts without throwing
|
||||
const encrypted = await rsaProvider.encrypt(hexData, keyPair);
|
||||
expect(encrypted.length).toBeGreaterThanOrEqual(keyLengthBytes);
|
||||
}
|
||||
|
||||
for (let length = maximumLength + 1; length <= maximumLength * 2; length++) {
|
||||
const hexData = Buffer.alloc(length).toString('hex');
|
||||
await expect(() => rsaProvider.encrypt(hexData, keyPair)).rejects.toThrow(/Data too long/);
|
||||
}
|
||||
});
|
||||
|
||||
test('output should not contain the input (medium-length input)', async () => {
|
||||
const { keyPair } = await rsaProvider.generateKeyPair();
|
||||
|
||||
for (const hexInput of ['123456789aa', '111aaaabbbbccccddddaa', '567890aaa']) {
|
||||
expect(await rsaProvider.encrypt(hexInput, keyPair)).not.toContain(hexInput);
|
||||
expect(await rsaProvider.encrypt(hexInput, keyPair)).not.toContain(
|
||||
Buffer.from(hexInput, 'hex').toString('base64'),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { webcrypto } from 'node:crypto';
|
||||
import { PublicKeyAlgorithm } from '../../types';
|
||||
import StringToBufferWrapper from './StringToBufferWrapper';
|
||||
import WebCryptoRsa, { WebCryptoSlice } from './WebCryptoRsa';
|
||||
import LongDataWrapper from './LongDataWrapper';
|
||||
|
||||
const buildRsaCryptoProvider = (rsaMode: PublicKeyAlgorithm, crypto: WebCryptoSlice|typeof webcrypto) => {
|
||||
// Cast: Old versions of @types/node don't include crypto.subtle:
|
||||
const cryptoSlice = crypto as unknown as WebCryptoSlice;
|
||||
if (rsaMode === PublicKeyAlgorithm.RsaV2) {
|
||||
const keySizeBits = 2048;
|
||||
return new StringToBufferWrapper(
|
||||
new LongDataWrapper(
|
||||
new WebCryptoRsa(
|
||||
cryptoSlice,
|
||||
{ modulusLengthBits: keySizeBits },
|
||||
),
|
||||
keySizeBits / 8,
|
||||
),
|
||||
);
|
||||
} else if (rsaMode === PublicKeyAlgorithm.RsaV3) {
|
||||
return new StringToBufferWrapper(
|
||||
new WebCryptoRsa(
|
||||
cryptoSlice,
|
||||
{ modulusLengthBits: 4096 },
|
||||
),
|
||||
);
|
||||
} else {
|
||||
throw new Error(`Unsupported mode for webCrypto: ${rsaMode}`);
|
||||
}
|
||||
};
|
||||
|
||||
export default buildRsaCryptoProvider;
|
||||
在新工单中引用
屏蔽一个用户