feat: add files package
This commit is contained in:
51
packages/files/src/Domain/UseCase/FileDecryptor.spec.ts
Normal file
51
packages/files/src/Domain/UseCase/FileDecryptor.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { FileDecryptor } from './FileDecryptor'
|
||||
import { PureCryptoInterface, StreamEncryptor } from '@standardnotes/sncrypto-common'
|
||||
import { FileContent } from '@standardnotes/models'
|
||||
import { assert } from '@standardnotes/utils'
|
||||
|
||||
describe('file decryptor', () => {
|
||||
let decryptor: FileDecryptor
|
||||
let file: {
|
||||
encryptionHeader: FileContent['encryptionHeader']
|
||||
remoteIdentifier: FileContent['remoteIdentifier']
|
||||
key: FileContent['key']
|
||||
}
|
||||
let crypto: PureCryptoInterface
|
||||
|
||||
beforeEach(() => {
|
||||
crypto = {} as jest.Mocked<PureCryptoInterface>
|
||||
|
||||
crypto.xchacha20StreamInitDecryptor = jest.fn().mockReturnValue({
|
||||
state: {},
|
||||
} as StreamEncryptor)
|
||||
|
||||
crypto.xchacha20StreamDecryptorPush = jest.fn().mockReturnValue({ message: new Uint8Array([0xaa]), tag: 0 })
|
||||
|
||||
file = {
|
||||
remoteIdentifier: '123',
|
||||
encryptionHeader: 'some-header',
|
||||
key: 'secret',
|
||||
}
|
||||
|
||||
decryptor = new FileDecryptor(file, crypto)
|
||||
})
|
||||
|
||||
it('initialize', () => {
|
||||
expect(crypto.xchacha20StreamInitDecryptor).toHaveBeenCalledWith(file.encryptionHeader, file.key)
|
||||
})
|
||||
|
||||
it('decryptBytes should return decrypted bytes', () => {
|
||||
const encryptedBytes = new Uint8Array([0xaa])
|
||||
const result = decryptor.decryptBytes(encryptedBytes)
|
||||
|
||||
assert(result)
|
||||
|
||||
expect(crypto.xchacha20StreamDecryptorPush).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
encryptedBytes,
|
||||
file.remoteIdentifier,
|
||||
)
|
||||
|
||||
expect(result.decryptedBytes.length).toEqual(1)
|
||||
})
|
||||
})
|
||||
29
packages/files/src/Domain/UseCase/FileDecryptor.ts
Normal file
29
packages/files/src/Domain/UseCase/FileDecryptor.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { PureCryptoInterface, StreamDecryptor, SodiumConstant } from '@standardnotes/sncrypto-common'
|
||||
import { FileContent } from '@standardnotes/models'
|
||||
|
||||
export class FileDecryptor {
|
||||
private decryptor: StreamDecryptor
|
||||
|
||||
constructor(
|
||||
private file: {
|
||||
encryptionHeader: FileContent['encryptionHeader']
|
||||
remoteIdentifier: FileContent['remoteIdentifier']
|
||||
key: FileContent['key']
|
||||
},
|
||||
private crypto: PureCryptoInterface,
|
||||
) {
|
||||
this.decryptor = this.crypto.xchacha20StreamInitDecryptor(this.file.encryptionHeader, this.file.key)
|
||||
}
|
||||
|
||||
public decryptBytes(encryptedBytes: Uint8Array): { decryptedBytes: Uint8Array; isFinalChunk: boolean } | undefined {
|
||||
const result = this.crypto.xchacha20StreamDecryptorPush(this.decryptor, encryptedBytes, this.file.remoteIdentifier)
|
||||
|
||||
if (result === false) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const isFinal = result.tag === SodiumConstant.CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_FINAL
|
||||
|
||||
return { decryptedBytes: result.message, isFinalChunk: isFinal }
|
||||
}
|
||||
}
|
||||
58
packages/files/src/Domain/UseCase/FileDownloader.spec.ts
Normal file
58
packages/files/src/Domain/UseCase/FileDownloader.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { FileContent } from '@standardnotes/models'
|
||||
import { FilesApiInterface } from '@standardnotes/services'
|
||||
import { FileDownloader } from './FileDownloader'
|
||||
|
||||
describe('file downloader', () => {
|
||||
let apiService: FilesApiInterface
|
||||
let downloader: FileDownloader
|
||||
let file: {
|
||||
encryptedChunkSizes: FileContent['encryptedChunkSizes']
|
||||
remoteIdentifier: FileContent['remoteIdentifier']
|
||||
}
|
||||
|
||||
const numChunks = 5
|
||||
|
||||
beforeEach(() => {
|
||||
apiService = {} as jest.Mocked<FilesApiInterface>
|
||||
apiService.createFileValetToken = jest.fn()
|
||||
apiService.downloadFile = jest
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
(
|
||||
_file: string,
|
||||
_chunkIndex: number,
|
||||
_apiToken: string,
|
||||
_rangeStart: number,
|
||||
onBytesReceived: (bytes: Uint8Array) => void,
|
||||
) => {
|
||||
return new Promise<void>((resolve) => {
|
||||
for (let i = 0; i < numChunks; i++) {
|
||||
onBytesReceived(Uint8Array.from([0xaa]))
|
||||
}
|
||||
|
||||
resolve()
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
file = {
|
||||
encryptedChunkSizes: [100_000],
|
||||
remoteIdentifier: '123',
|
||||
}
|
||||
})
|
||||
|
||||
it('should pass back bytes as they are received', async () => {
|
||||
let receivedBytes = new Uint8Array()
|
||||
|
||||
downloader = new FileDownloader(file, apiService)
|
||||
|
||||
expect(receivedBytes.length).toBe(0)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
await downloader.run(async (encryptedBytes) => {
|
||||
receivedBytes = new Uint8Array([...receivedBytes, ...encryptedBytes])
|
||||
})
|
||||
|
||||
expect(receivedBytes.length).toEqual(numChunks)
|
||||
})
|
||||
})
|
||||
83
packages/files/src/Domain/UseCase/FileDownloader.ts
Normal file
83
packages/files/src/Domain/UseCase/FileDownloader.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { ClientDisplayableError } from '@standardnotes/responses'
|
||||
import { FileDownloadProgress } from '../Types/FileDownloadProgress'
|
||||
import { FilesApiInterface } from '@standardnotes/services'
|
||||
import { Deferred } from '@standardnotes/utils'
|
||||
import { FileContent } from '@standardnotes/models'
|
||||
|
||||
export type AbortSignal = 'aborted'
|
||||
export type AbortFunction = () => void
|
||||
type OnEncryptedBytes = (
|
||||
encryptedBytes: Uint8Array,
|
||||
progress: FileDownloadProgress,
|
||||
abort: AbortFunction,
|
||||
) => Promise<void>
|
||||
|
||||
export type FileDownloaderResult = ClientDisplayableError | AbortSignal | undefined
|
||||
|
||||
export class FileDownloader {
|
||||
private aborted = false
|
||||
private abortDeferred = Deferred<AbortSignal>()
|
||||
private totalBytesDownloaded = 0
|
||||
|
||||
constructor(
|
||||
private file: {
|
||||
encryptedChunkSizes: FileContent['encryptedChunkSizes']
|
||||
remoteIdentifier: FileContent['remoteIdentifier']
|
||||
},
|
||||
private readonly api: FilesApiInterface,
|
||||
) {}
|
||||
|
||||
private getProgress(): FileDownloadProgress {
|
||||
const encryptedSize = this.file.encryptedChunkSizes.reduce((total, chunk) => total + chunk, 0)
|
||||
|
||||
return {
|
||||
encryptedFileSize: encryptedSize,
|
||||
encryptedBytesDownloaded: this.totalBytesDownloaded,
|
||||
encryptedBytesRemaining: encryptedSize - this.totalBytesDownloaded,
|
||||
percentComplete: (this.totalBytesDownloaded / encryptedSize) * 100.0,
|
||||
}
|
||||
}
|
||||
|
||||
public async run(onEncryptedBytes: OnEncryptedBytes): Promise<FileDownloaderResult> {
|
||||
const tokenResult = await this.getValetToken()
|
||||
|
||||
if (tokenResult instanceof ClientDisplayableError) {
|
||||
return tokenResult
|
||||
}
|
||||
|
||||
return this.performDownload(tokenResult, onEncryptedBytes)
|
||||
}
|
||||
|
||||
private async getValetToken(): Promise<string | ClientDisplayableError> {
|
||||
const tokenResult = await this.api.createFileValetToken(this.file.remoteIdentifier, 'read')
|
||||
|
||||
return tokenResult
|
||||
}
|
||||
|
||||
private async performDownload(valetToken: string, onEncryptedBytes: OnEncryptedBytes): Promise<FileDownloaderResult> {
|
||||
const chunkIndex = 0
|
||||
const startRange = 0
|
||||
|
||||
const onRemoteBytesReceived = async (bytes: Uint8Array) => {
|
||||
if (this.aborted) {
|
||||
return
|
||||
}
|
||||
|
||||
this.totalBytesDownloaded += bytes.byteLength
|
||||
|
||||
await onEncryptedBytes(bytes, this.getProgress(), this.abort)
|
||||
}
|
||||
|
||||
const downloadPromise = this.api.downloadFile(this.file, chunkIndex, valetToken, startRange, onRemoteBytesReceived)
|
||||
|
||||
const result = await Promise.race([this.abortDeferred.promise, downloadPromise])
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
public abort(): void {
|
||||
this.aborted = true
|
||||
|
||||
this.abortDeferred.resolve('aborted')
|
||||
}
|
||||
}
|
||||
65
packages/files/src/Domain/UseCase/FileEncryptor.spec.ts
Normal file
65
packages/files/src/Domain/UseCase/FileEncryptor.spec.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { FileContent } from '@standardnotes/models'
|
||||
import { PureCryptoInterface, StreamEncryptor, SodiumConstant } from '@standardnotes/sncrypto-common'
|
||||
import { FileEncryptor } from './FileEncryptor'
|
||||
|
||||
describe('file encryptor', () => {
|
||||
let encryptor: FileEncryptor
|
||||
let file: { key: FileContent['key']; remoteIdentifier: FileContent['remoteIdentifier'] }
|
||||
let crypto: PureCryptoInterface
|
||||
|
||||
beforeEach(() => {
|
||||
crypto = {} as jest.Mocked<PureCryptoInterface>
|
||||
crypto.xchacha20StreamInitEncryptor = jest.fn().mockReturnValue({
|
||||
header: 'some-header',
|
||||
state: {},
|
||||
} as StreamEncryptor)
|
||||
|
||||
crypto.xchacha20StreamEncryptorPush = jest.fn().mockReturnValue(new Uint8Array())
|
||||
|
||||
file = {
|
||||
remoteIdentifier: '123',
|
||||
key: 'secret',
|
||||
}
|
||||
|
||||
encryptor = new FileEncryptor(file, crypto)
|
||||
})
|
||||
|
||||
it('should initialize header', () => {
|
||||
const header = encryptor.initializeHeader()
|
||||
|
||||
expect(header.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('pushBytes should return encrypted bytes', () => {
|
||||
encryptor.initializeHeader()
|
||||
const encryptedBytes = encryptor.pushBytes(new Uint8Array(), false)
|
||||
|
||||
expect(encryptedBytes).toBeInstanceOf(Uint8Array)
|
||||
})
|
||||
|
||||
it('pushBytes with last chunk should pass final tag', () => {
|
||||
encryptor.initializeHeader()
|
||||
const decryptedBytes = new Uint8Array()
|
||||
encryptor.pushBytes(decryptedBytes, true)
|
||||
|
||||
expect(crypto.xchacha20StreamEncryptorPush).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
decryptedBytes,
|
||||
file.remoteIdentifier,
|
||||
SodiumConstant.CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_FINAL,
|
||||
)
|
||||
})
|
||||
|
||||
it('pushBytes with not last chunk should not pass final tag', () => {
|
||||
encryptor.initializeHeader()
|
||||
const decryptedBytes = new Uint8Array()
|
||||
encryptor.pushBytes(decryptedBytes, false)
|
||||
|
||||
expect(crypto.xchacha20StreamEncryptorPush).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
decryptedBytes,
|
||||
file.remoteIdentifier,
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
})
|
||||
34
packages/files/src/Domain/UseCase/FileEncryptor.ts
Normal file
34
packages/files/src/Domain/UseCase/FileEncryptor.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { FileContent } from '@standardnotes/models'
|
||||
import { PureCryptoInterface, StreamEncryptor, SodiumConstant } from '@standardnotes/sncrypto-common'
|
||||
|
||||
export class FileEncryptor {
|
||||
private stream!: StreamEncryptor
|
||||
|
||||
constructor(
|
||||
private readonly file: { key: FileContent['key']; remoteIdentifier: FileContent['remoteIdentifier'] },
|
||||
private crypto: PureCryptoInterface,
|
||||
) {}
|
||||
|
||||
public initializeHeader(): string {
|
||||
this.stream = this.crypto.xchacha20StreamInitEncryptor(this.file.key)
|
||||
|
||||
return this.stream.header
|
||||
}
|
||||
|
||||
public pushBytes(decryptedBytes: Uint8Array, isFinalChunk: boolean): Uint8Array {
|
||||
if (!this.stream) {
|
||||
throw new Error('FileEncryptor must call initializeHeader first')
|
||||
}
|
||||
|
||||
const tag = isFinalChunk ? SodiumConstant.CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_FINAL : undefined
|
||||
|
||||
const encryptedBytes = this.crypto.xchacha20StreamEncryptorPush(
|
||||
this.stream,
|
||||
decryptedBytes,
|
||||
this.file.remoteIdentifier,
|
||||
tag,
|
||||
)
|
||||
|
||||
return encryptedBytes
|
||||
}
|
||||
}
|
||||
21
packages/files/src/Domain/UseCase/FileUploader.spec.ts
Normal file
21
packages/files/src/Domain/UseCase/FileUploader.spec.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { FilesApiInterface } from '@standardnotes/services'
|
||||
import { FileUploader } from './FileUploader'
|
||||
|
||||
describe('file uploader', () => {
|
||||
let apiService
|
||||
let uploader: FileUploader
|
||||
|
||||
beforeEach(() => {
|
||||
apiService = {} as jest.Mocked<FilesApiInterface>
|
||||
apiService.uploadFileBytes = jest.fn().mockReturnValue(true)
|
||||
|
||||
uploader = new FileUploader(apiService)
|
||||
})
|
||||
|
||||
it('should return true when a chunk is uploaded', async () => {
|
||||
const bytes = new Uint8Array()
|
||||
const success = await uploader.uploadBytes(bytes, 2, 'api-token')
|
||||
|
||||
expect(success).toEqual(true)
|
||||
})
|
||||
})
|
||||
11
packages/files/src/Domain/UseCase/FileUploader.ts
Normal file
11
packages/files/src/Domain/UseCase/FileUploader.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { FilesApiInterface } from '@standardnotes/services'
|
||||
|
||||
export class FileUploader {
|
||||
constructor(private apiService: FilesApiInterface) {}
|
||||
|
||||
public async uploadBytes(encryptedBytes: Uint8Array, chunkId: number, apiToken: string): Promise<boolean> {
|
||||
const result = await this.apiService.uploadFileBytes(apiToken, chunkId, encryptedBytes)
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user