@novasamatech/handoff-service 0.6.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,156 @@
1
+ # @novasamatech/handoff-service
2
+
3
+ HOP (Handoff Pool) file transfer service for peer-to-peer chat. Uploads files to a Bulletin chain HOP pool as AES-256-GCM encrypted chunks and returns a compact identifier + claim ticket that the recipient uses to download and decrypt the file.
4
+
5
+ Non-custodial, ephemeral, end-to-end encrypted. The pool node only ever sees encrypted bytes. Wire format matches the iOS `HandoffService` and Android `HopFileUploader` implementations.
6
+
7
+ ## Installation
8
+
9
+ ```shell
10
+ npm install @novasamatech/handoff-service --save -E
11
+ ```
12
+
13
+ ## Overview
14
+
15
+ A transfer happens in two roles:
16
+
17
+ - **Sender** generates a random 32-byte `ticket`. From the ticket the service derives an sr25519 keypair (signer) and an AES-256-GCM key (encryption) via keyed blake2b (`khash(ticket, "signer" | "encryption")`). The file is split into 2 MB chunks, each chunk is AES-GCM encrypted and submitted to the pool addressed to the ticket's public key. A SCALE-encoded metadata blob listing all chunk hashes is then encrypted and submitted the same way — its hash is the file `identifier`. The sender ships `{ identifier, claimTicket }` through a side channel (the chat message).
18
+ - **Recipient** re-derives the encryption key and signing keypair from `claimTicket`, signs the identifier to prove ownership, and calls `hop_claim` to fetch the encrypted metadata. The metadata's chunk hashes are claimed and decrypted one by one, then concatenated to reconstruct the original bytes.
19
+
20
+ ```
21
+ ┌────────┐ hop_submit(enc(chunk_i), [pubkey]) ┌─────────┐
22
+ │ Sender │ ──────────────────────────────────────► │ HOP │
23
+ │ │ hop_submit(enc(metadata), [pubkey]) │ Pool │
24
+ └────────┘ ──────────────────────────────────────► └─────────┘
25
+ ticket ▲
26
+ │ │ hop_claim(hash, sig)
27
+ │ identifier + claimTicket (via chat) │
28
+ ▼ ┌──────────┐
29
+ │ Recipient│
30
+ └──────────┘
31
+ ```
32
+
33
+ ## Usage
34
+
35
+ ### Upload a file
36
+
37
+ ```typescript
38
+ import { createHopClient, uploadFile } from '@novasamatech/handoff-service';
39
+
40
+ // Your JSON-RPC transport — any function that calls methods on a HOP node.
41
+ const requestFn = <T>(method: string, params: unknown[]): Promise<T> =>
42
+ wsClient.request(method, params);
43
+
44
+ const hopClient = createHopClient(requestFn);
45
+
46
+ const result = await uploadFile({
47
+ data: fileBytes, // Uint8Array
48
+ hopClient,
49
+ onProgress: (sent, total) => console.info(`${sent}/${total} chunks`),
50
+ });
51
+
52
+ if (result.isErr()) {
53
+ console.error('Upload failed:', result.error);
54
+ } else {
55
+ const { identifier, claimTicket } = result.value;
56
+ // Send identifier + claimTicket to the recipient (e.g. inside an encrypted chat message).
57
+ }
58
+ ```
59
+
60
+ ### Download a file
61
+
62
+ ```typescript
63
+ import { createHopClient, downloadFile } from '@novasamatech/handoff-service';
64
+
65
+ const hopClient = createHopClient(requestFn);
66
+
67
+ const result = await downloadFile({
68
+ identifier, // Uint8Array from the sender
69
+ claimTicket, // Uint8Array from the sender
70
+ hopClient,
71
+ onProgress: (received, total) => console.info(`${received}/${total} chunks`),
72
+ });
73
+
74
+ if (result.isErr()) {
75
+ console.error('Download failed:', result.error);
76
+ } else {
77
+ const fileBytes = result.value; // Uint8Array — original file contents
78
+ }
79
+ ```
80
+
81
+ ### Inspect pool capacity
82
+
83
+ ```typescript
84
+ const status = await hopClient.poolStatus();
85
+ // { entryCount, totalBytes, maxBytes }
86
+ ```
87
+
88
+ ## API
89
+
90
+ ### `createHopClient(requestFn): HopClient`
91
+
92
+ Wraps a JSON-RPC request function into a typed HOP client. `requestFn` is called with the raw method names `hop_submit`, `hop_claim`, and `hop_poolStatus` — bring your own WebSocket / HTTP transport.
93
+
94
+ ```typescript
95
+ type HopClient = {
96
+ submit(data: Uint8Array, recipients: Uint8Array[]): ResultAsync<PoolStatus, Error>;
97
+ claim(hash: Uint8Array, signature: Uint8Array): ResultAsync<Uint8Array, Error>;
98
+ poolStatus(): ResultAsync<PoolStatus, Error>;
99
+ };
100
+ ```
101
+
102
+ ### `uploadFile(params): ResultAsync<UploadResult, Error>`
103
+
104
+ ```typescript
105
+ type UploadParams = {
106
+ data: Uint8Array;
107
+ hopClient: HopClient;
108
+ chunkSize?: number; // default 2_000_000
109
+ onProgress?: (sent: number, total: number) => void;
110
+ };
111
+
112
+ type UploadResult = {
113
+ identifier: Uint8Array; // blake2b-256 hash of the encrypted metadata
114
+ claimTicket: Uint8Array; // 32-byte secret — share with recipient
115
+ };
116
+ ```
117
+
118
+ ### `downloadFile(params): ResultAsync<Uint8Array, Error>`
119
+
120
+ ```typescript
121
+ type DownloadParams = {
122
+ identifier: Uint8Array;
123
+ claimTicket: Uint8Array;
124
+ hopClient: HopClient;
125
+ onProgress?: (received: number, total: number) => void;
126
+ };
127
+ ```
128
+
129
+ Reassembled bytes are validated against the `totalSize` encoded in the metadata; a size mismatch produces an `Err`.
130
+
131
+ ### Crypto primitives
132
+
133
+ Exposed for advanced use cases (e.g. signing custom pool entries):
134
+
135
+ ```typescript
136
+ import {
137
+ generateTicket,
138
+ derivePublicKey,
139
+ deriveEncryptionKey,
140
+ deriveSigningKeypair,
141
+ signWithTicket,
142
+ createFileEncryption,
143
+ } from '@novasamatech/handoff-service';
144
+
145
+ const ticket = generateTicket(); // 32 random bytes
146
+ const pubkey = derivePublicKey(ticket); // sr25519 public key
147
+ const signature = signWithTicket(ticket, messageBytes);
148
+
149
+ const enc = createFileEncryption(deriveEncryptionKey(ticket));
150
+ const ciphertext = enc.encrypt(plainBytes); // nonce(12) || ciphertext || tag(16)
151
+ const plain = enc.decrypt(ciphertext);
152
+ ```
153
+
154
+ ## Error handling
155
+
156
+ All async operations return `neverthrow` `ResultAsync`, so errors are values rather than thrown exceptions. Chain with `.andThen` / `.map` or unwrap via `.isErr()` / `.value` / `.error`.
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Internal metadata stored in the HOP pool that references all chunks of an uploaded file.
3
+ * This is not part of the chat message — it's only used by the file loader.
4
+ */
5
+ export declare const UploadedFile: import("scale-ts").Codec<{
6
+ totalSize: bigint;
7
+ chunks: Uint8Array<ArrayBufferLike>[];
8
+ }>;
package/dist/codec.js ADDED
@@ -0,0 +1,9 @@
1
+ import { Bytes, Struct, Vector, u64 } from 'scale-ts';
2
+ /**
3
+ * Internal metadata stored in the HOP pool that references all chunks of an uploaded file.
4
+ * This is not part of the chat message — it's only used by the file loader.
5
+ */
6
+ export const UploadedFile = Struct({
7
+ totalSize: u64,
8
+ chunks: Vector(Bytes()),
9
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { UploadedFile } from './codec.js';
3
+ describe('UploadedFile codec', () => {
4
+ it('encodes and decodes round-trip', () => {
5
+ const hash1 = new Uint8Array(32).fill(0xaa);
6
+ const hash2 = new Uint8Array(32).fill(0xbb);
7
+ const original = {
8
+ totalSize: 4000000n,
9
+ chunks: [hash1, hash2],
10
+ };
11
+ const encoded = UploadedFile.enc(original);
12
+ const decoded = UploadedFile.dec(encoded);
13
+ expect(decoded.totalSize).toBe(4000000n);
14
+ expect(decoded.chunks).toHaveLength(2);
15
+ expect(decoded.chunks[0]).toEqual(hash1);
16
+ expect(decoded.chunks[1]).toEqual(hash2);
17
+ });
18
+ it('handles single chunk', () => {
19
+ const hash = new Uint8Array(32).fill(0xcc);
20
+ const original = { totalSize: 100n, chunks: [hash] };
21
+ const encoded = UploadedFile.enc(original);
22
+ const decoded = UploadedFile.dec(encoded);
23
+ expect(decoded.totalSize).toBe(100n);
24
+ expect(decoded.chunks).toHaveLength(1);
25
+ });
26
+ it('handles many chunks', () => {
27
+ const chunks = Array.from({ length: 50 }, (_, i) => new Uint8Array(32).fill(i));
28
+ const original = { totalSize: 100000000n, chunks };
29
+ const encoded = UploadedFile.enc(original);
30
+ const decoded = UploadedFile.dec(encoded);
31
+ expect(decoded.totalSize).toBe(100000000n);
32
+ expect(decoded.chunks).toHaveLength(50);
33
+ });
34
+ });
@@ -0,0 +1,9 @@
1
+ export type FileEncryption = {
2
+ encrypt(data: Uint8Array): Uint8Array;
3
+ decrypt(data: Uint8Array): Uint8Array;
4
+ };
5
+ /**
6
+ * AES-256-GCM encryption for file chunks and metadata.
7
+ * Format: nonce (12 bytes) || ciphertext || tag (16 bytes)
8
+ */
9
+ export declare function createFileEncryption(key: Uint8Array): FileEncryption;
@@ -0,0 +1,22 @@
1
+ import { gcm } from '@noble/ciphers/aes.js';
2
+ import { randomBytes } from '@noble/hashes/utils.js';
3
+ import { mergeUint8 } from '@polkadot-api/utils';
4
+ /**
5
+ * AES-256-GCM encryption for file chunks and metadata.
6
+ * Format: nonce (12 bytes) || ciphertext || tag (16 bytes)
7
+ */
8
+ export function createFileEncryption(key) {
9
+ return {
10
+ encrypt(data) {
11
+ const nonce = randomBytes(12);
12
+ const aes = gcm(key, nonce);
13
+ return mergeUint8([nonce, aes.encrypt(data)]);
14
+ },
15
+ decrypt(encryptedData) {
16
+ const nonce = encryptedData.slice(0, 12);
17
+ const ciphertext = encryptedData.slice(12);
18
+ const aes = gcm(key, nonce);
19
+ return aes.decrypt(ciphertext);
20
+ },
21
+ };
22
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,54 @@
1
+ import { randomBytes } from '@noble/hashes/utils.js';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { createFileEncryption } from './encryption.js';
4
+ describe('file encryption', () => {
5
+ it('encrypts and decrypts data', () => {
6
+ const key = randomBytes(32);
7
+ const encryption = createFileEncryption(key);
8
+ const plaintext = new TextEncoder().encode('hello world');
9
+ const encrypted = encryption.encrypt(plaintext);
10
+ const decrypted = encryption.decrypt(encrypted);
11
+ expect(decrypted).toEqual(plaintext);
12
+ });
13
+ it('encrypted data is longer than plaintext (nonce + tag)', () => {
14
+ const key = randomBytes(32);
15
+ const encryption = createFileEncryption(key);
16
+ const plaintext = new Uint8Array(100);
17
+ const encrypted = encryption.encrypt(plaintext);
18
+ // 12 bytes nonce + 100 bytes ciphertext + 16 bytes tag = 128
19
+ expect(encrypted.length).toBe(100 + 12 + 16);
20
+ });
21
+ it('produces different ciphertext for same plaintext (random nonce)', () => {
22
+ const key = randomBytes(32);
23
+ const encryption = createFileEncryption(key);
24
+ const plaintext = new Uint8Array([1, 2, 3]);
25
+ const a = encryption.encrypt(plaintext);
26
+ const b = encryption.encrypt(plaintext);
27
+ expect(a).not.toEqual(b);
28
+ });
29
+ it('fails to decrypt with wrong key', () => {
30
+ const key1 = randomBytes(32);
31
+ const key2 = randomBytes(32);
32
+ const enc1 = createFileEncryption(key1);
33
+ const enc2 = createFileEncryption(key2);
34
+ const plaintext = new TextEncoder().encode('secret');
35
+ const encrypted = enc1.encrypt(plaintext);
36
+ expect(() => enc2.decrypt(encrypted)).toThrow();
37
+ });
38
+ it('handles empty data', () => {
39
+ const key = randomBytes(32);
40
+ const encryption = createFileEncryption(key);
41
+ const plaintext = new Uint8Array(0);
42
+ const encrypted = encryption.encrypt(plaintext);
43
+ const decrypted = encryption.decrypt(encrypted);
44
+ expect(decrypted).toEqual(plaintext);
45
+ });
46
+ it('handles large data', () => {
47
+ const key = randomBytes(32);
48
+ const encryption = createFileEncryption(key);
49
+ const plaintext = new Uint8Array(50_000).fill(0x42);
50
+ const encrypted = encryption.encrypt(plaintext);
51
+ const decrypted = encryption.decrypt(encrypted);
52
+ expect(decrypted).toEqual(plaintext);
53
+ });
54
+ });
@@ -0,0 +1,4 @@
1
+ export type { FileEncryption } from './encryption.js';
2
+ export { createFileEncryption } from './encryption.js';
3
+ export type { FileTicket } from './ticket.js';
4
+ export { deriveEncryptionKey, derivePublicKey, deriveSigningKeypair, deriveSigningSeed, generateTicket, signWithTicket, } from './ticket.js';
@@ -0,0 +1,2 @@
1
+ export { createFileEncryption } from './encryption.js';
2
+ export { deriveEncryptionKey, derivePublicKey, deriveSigningKeypair, deriveSigningSeed, generateTicket, signWithTicket, } from './ticket.js';
@@ -0,0 +1,7 @@
1
+ export type FileTicket = Uint8Array;
2
+ export declare function generateTicket(): FileTicket;
3
+ export declare function deriveSigningSeed(ticket: FileTicket): Uint8Array;
4
+ export declare function deriveSigningKeypair(ticket: FileTicket): Uint8Array;
5
+ export declare function derivePublicKey(ticket: FileTicket): Uint8Array;
6
+ export declare function signWithTicket(ticket: FileTicket, message: Uint8Array): Uint8Array;
7
+ export declare function deriveEncryptionKey(ticket: FileTicket): Uint8Array;
@@ -0,0 +1,28 @@
1
+ import { blake2b } from '@noble/hashes/blake2.js';
2
+ import { randomBytes } from '@noble/hashes/utils.js';
3
+ import { getPublicKey as sr25519GetPublicKey, secretFromSeed as sr25519SecretFromSeed, sign as sr25519Sign, } from '@scure/sr25519';
4
+ const textEncoder = new TextEncoder();
5
+ function khash(secret, message) {
6
+ return blake2b(message, { dkLen: 32, key: secret });
7
+ }
8
+ export function generateTicket() {
9
+ return randomBytes(32);
10
+ }
11
+ export function deriveSigningSeed(ticket) {
12
+ return khash(ticket, textEncoder.encode('signer'));
13
+ }
14
+ export function deriveSigningKeypair(ticket) {
15
+ const seed = deriveSigningSeed(ticket);
16
+ return sr25519SecretFromSeed(seed);
17
+ }
18
+ export function derivePublicKey(ticket) {
19
+ const keypair = deriveSigningKeypair(ticket);
20
+ return sr25519GetPublicKey(keypair);
21
+ }
22
+ export function signWithTicket(ticket, message) {
23
+ const keypair = deriveSigningKeypair(ticket);
24
+ return sr25519Sign(keypair, message);
25
+ }
26
+ export function deriveEncryptionKey(ticket) {
27
+ return khash(ticket, textEncoder.encode('encryption'));
28
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,55 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { deriveEncryptionKey, derivePublicKey, deriveSigningKeypair, deriveSigningSeed, generateTicket, signWithTicket, } from './ticket.js';
3
+ describe('ticket key derivation', () => {
4
+ it('generates a 32-byte ticket', () => {
5
+ const ticket = generateTicket();
6
+ expect(ticket).toBeInstanceOf(Uint8Array);
7
+ expect(ticket.length).toBe(32);
8
+ });
9
+ it('generates unique tickets', () => {
10
+ const a = generateTicket();
11
+ const b = generateTicket();
12
+ expect(a).not.toEqual(b);
13
+ });
14
+ it('derives deterministic signing seed from ticket', () => {
15
+ const ticket = generateTicket();
16
+ const seed1 = deriveSigningSeed(ticket);
17
+ const seed2 = deriveSigningSeed(ticket);
18
+ expect(seed1).toEqual(seed2);
19
+ expect(seed1.length).toBe(32);
20
+ });
21
+ it('derives deterministic signing keypair from ticket', () => {
22
+ const ticket = generateTicket();
23
+ const kp1 = deriveSigningKeypair(ticket);
24
+ const kp2 = deriveSigningKeypair(ticket);
25
+ expect(kp1).toEqual(kp2);
26
+ expect(kp1.length).toBe(64); // sr25519 secret is 64 bytes
27
+ });
28
+ it('derives deterministic public key from ticket', () => {
29
+ const ticket = generateTicket();
30
+ const pk1 = derivePublicKey(ticket);
31
+ const pk2 = derivePublicKey(ticket);
32
+ expect(pk1).toEqual(pk2);
33
+ expect(pk1.length).toBe(32);
34
+ });
35
+ it('derives deterministic encryption key from ticket', () => {
36
+ const ticket = generateTicket();
37
+ const key1 = deriveEncryptionKey(ticket);
38
+ const key2 = deriveEncryptionKey(ticket);
39
+ expect(key1).toEqual(key2);
40
+ expect(key1.length).toBe(32);
41
+ });
42
+ it('signing key and encryption key are different', () => {
43
+ const ticket = generateTicket();
44
+ const sigSeed = deriveSigningSeed(ticket);
45
+ const encKey = deriveEncryptionKey(ticket);
46
+ expect(sigSeed).not.toEqual(encKey);
47
+ });
48
+ it('produces valid sr25519 signature', () => {
49
+ const ticket = generateTicket();
50
+ const message = new Uint8Array([1, 2, 3, 4]);
51
+ const signature = signWithTicket(ticket, message);
52
+ expect(signature).toBeInstanceOf(Uint8Array);
53
+ expect(signature.length).toBe(64);
54
+ });
55
+ });
@@ -0,0 +1,20 @@
1
+ import type { ResultAsync } from 'neverthrow';
2
+ import type { HopClient } from '../rpc/index.js';
3
+ export type UploadParams = {
4
+ data: Uint8Array;
5
+ hopClient: HopClient;
6
+ chunkSize?: number;
7
+ onProgress?: (sent: number, total: number) => void;
8
+ };
9
+ export type UploadResult = {
10
+ identifier: Uint8Array;
11
+ claimTicket: Uint8Array;
12
+ };
13
+ export declare function uploadFile(params: UploadParams): ResultAsync<UploadResult, Error>;
14
+ export type DownloadParams = {
15
+ identifier: Uint8Array;
16
+ claimTicket: Uint8Array;
17
+ hopClient: HopClient;
18
+ onProgress?: (received: number, total: number) => void;
19
+ };
20
+ export declare function downloadFile(params: DownloadParams): ResultAsync<Uint8Array, Error>;
@@ -0,0 +1,96 @@
1
+ import { blake2b } from '@noble/hashes/blake2.js';
2
+ import { mergeUint8 } from '@polkadot-api/utils';
3
+ import { errAsync, okAsync } from 'neverthrow';
4
+ import { UploadedFile } from '../codec.js';
5
+ import { createFileEncryption, deriveEncryptionKey, derivePublicKey, generateTicket, signWithTicket, } from '../crypto/index.js';
6
+ const DEFAULT_CHUNK_SIZE = 2_000_000;
7
+ function hash256(data) {
8
+ return blake2b(data, { dkLen: 32 });
9
+ }
10
+ function splitIntoChunks(data, chunkSize) {
11
+ const chunks = [];
12
+ for (let offset = 0; offset < data.length; offset += chunkSize) {
13
+ chunks.push(data.subarray(offset, offset + chunkSize));
14
+ }
15
+ return chunks;
16
+ }
17
+ export function uploadFile(params) {
18
+ const { data, hopClient, chunkSize = DEFAULT_CHUNK_SIZE, onProgress } = params;
19
+ const ticket = generateTicket();
20
+ const recipientPublicKey = derivePublicKey(ticket);
21
+ const encryptionKey = deriveEncryptionKey(ticket);
22
+ const encryption = createFileEncryption(encryptionKey);
23
+ const recipients = [recipientPublicKey];
24
+ const chunks = splitIntoChunks(data, chunkSize);
25
+ const totalChunks = chunks.length;
26
+ let result = okAsync([]);
27
+ for (let i = 0; i < totalChunks; i++) {
28
+ const chunk = chunks[i];
29
+ result = result.andThen(hashes => {
30
+ const encrypted = encryption.encrypt(chunk);
31
+ return hopClient.submit(encrypted, recipients).map(_poolStatus => {
32
+ const chunkHash = hash256(encrypted);
33
+ onProgress?.(i + 1, totalChunks + 1); // +1 for metadata
34
+ return [...hashes, chunkHash];
35
+ });
36
+ });
37
+ }
38
+ return result.andThen(chunkHashes => {
39
+ const metadata = UploadedFile.enc({
40
+ totalSize: BigInt(data.length),
41
+ chunks: chunkHashes,
42
+ });
43
+ const encryptedMetadata = encryption.encrypt(metadata);
44
+ return hopClient.submit(encryptedMetadata, recipients).map(_poolStatus => {
45
+ const metadataHash = hash256(encryptedMetadata);
46
+ onProgress?.(totalChunks + 1, totalChunks + 1);
47
+ return { identifier: metadataHash, claimTicket: ticket };
48
+ });
49
+ });
50
+ }
51
+ export function downloadFile(params) {
52
+ const { identifier, claimTicket, hopClient, onProgress } = params;
53
+ const encryptionKey = deriveEncryptionKey(claimTicket);
54
+ const encryption = createFileEncryption(encryptionKey);
55
+ const metadataSignature = signWithTicket(claimTicket, identifier);
56
+ return hopClient
57
+ .claim(identifier, metadataSignature)
58
+ .andThen(encryptedMetadata => {
59
+ try {
60
+ const metadataBytes = encryption.decrypt(encryptedMetadata);
61
+ const uploadedFile = UploadedFile.dec(metadataBytes);
62
+ return okAsync(uploadedFile);
63
+ }
64
+ catch (e) {
65
+ return errAsync(new Error(`Failed to decrypt/decode metadata: ${e}`));
66
+ }
67
+ })
68
+ .andThen(uploadedFile => {
69
+ const { totalSize, chunks: chunkHashes } = uploadedFile;
70
+ const totalChunks = chunkHashes.length;
71
+ let result = okAsync([]);
72
+ for (let i = 0; i < totalChunks; i++) {
73
+ const chunkHash = chunkHashes[i];
74
+ result = result.andThen(decryptedChunks => {
75
+ const chunkSignature = signWithTicket(claimTicket, chunkHash);
76
+ return hopClient.claim(chunkHash, chunkSignature).andThen(encryptedChunk => {
77
+ try {
78
+ const chunk = encryption.decrypt(encryptedChunk);
79
+ onProgress?.(i + 1, totalChunks);
80
+ return okAsync([...decryptedChunks, chunk]);
81
+ }
82
+ catch (e) {
83
+ return errAsync(new Error(`Failed to decrypt chunk ${i}: ${e}`));
84
+ }
85
+ });
86
+ });
87
+ }
88
+ return result.andThen(decryptedChunks => {
89
+ const reassembled = mergeUint8(decryptedChunks);
90
+ if (BigInt(reassembled.length) !== totalSize) {
91
+ return errAsync(new Error(`File size mismatch: expected ${totalSize}, got ${reassembled.length}`));
92
+ }
93
+ return okAsync(reassembled);
94
+ });
95
+ });
96
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,127 @@
1
+ import { blake2b } from '@noble/hashes/blake2.js';
2
+ import { randomBytes } from '@noble/hashes/utils.js';
3
+ import { okAsync } from 'neverthrow';
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import { downloadFile, uploadFile } from './fileLoader.js';
6
+ function hash256(data) {
7
+ return blake2b(data, { dkLen: 32 });
8
+ }
9
+ function createMockHopClient() {
10
+ const submittedEntries = new Map();
11
+ function hashKey(hash) {
12
+ return Array.from(hash)
13
+ .map(b => b.toString(16).padStart(2, '0'))
14
+ .join('');
15
+ }
16
+ const submitMock = vi.fn();
17
+ const claimMock = vi.fn();
18
+ submitMock.mockImplementation((data, _recipients) => {
19
+ const h = hash256(data);
20
+ submittedEntries.set(hashKey(h), new Uint8Array(data));
21
+ return okAsync({ entryCount: submittedEntries.size, totalBytes: 0, maxBytes: 10_000_000 });
22
+ });
23
+ claimMock.mockImplementation((hash, _signature) => {
24
+ const key = hashKey(hash);
25
+ const entry = submittedEntries.get(key);
26
+ if (!entry) {
27
+ throw new Error(`No entry for hash ${key}`);
28
+ }
29
+ submittedEntries.delete(key);
30
+ return okAsync(entry);
31
+ });
32
+ const client = {
33
+ submit: submitMock,
34
+ claim: claimMock,
35
+ poolStatus: vi.fn(() => okAsync({ entryCount: 0, totalBytes: 0, maxBytes: 10_000_000 })),
36
+ };
37
+ return { client, submitMock, claimMock, submittedEntries };
38
+ }
39
+ describe('file loader', () => {
40
+ it('uploads and downloads a small file (single chunk)', async () => {
41
+ const { client, submitMock } = createMockHopClient();
42
+ const data = new TextEncoder().encode('hello world');
43
+ const uploadResult = await uploadFile({ data, hopClient: client });
44
+ expect(uploadResult.isOk()).toBe(true);
45
+ const { identifier, claimTicket } = uploadResult._unsafeUnwrap();
46
+ expect(identifier.length).toBe(32);
47
+ expect(claimTicket.length).toBe(32);
48
+ // 1 chunk + 1 metadata = 2 submissions
49
+ expect(submitMock).toHaveBeenCalledTimes(2);
50
+ const downloadResult = await downloadFile({
51
+ identifier,
52
+ claimTicket,
53
+ hopClient: client,
54
+ });
55
+ expect(downloadResult.isOk()).toBe(true);
56
+ expect(downloadResult._unsafeUnwrap()).toEqual(data);
57
+ });
58
+ it('uploads and downloads a multi-chunk file', async () => {
59
+ const { client, submitMock } = createMockHopClient();
60
+ const data = randomBytes(5_000); // 5KB with 2KB chunk size
61
+ const uploadResult = await uploadFile({
62
+ data,
63
+ hopClient: client,
64
+ chunkSize: 2_000,
65
+ });
66
+ expect(uploadResult.isOk()).toBe(true);
67
+ const { identifier, claimTicket } = uploadResult._unsafeUnwrap();
68
+ // 3 chunks (2000 + 2000 + 1000) + 1 metadata = 4 submissions
69
+ expect(submitMock).toHaveBeenCalledTimes(4);
70
+ const downloadResult = await downloadFile({
71
+ identifier,
72
+ claimTicket,
73
+ hopClient: client,
74
+ });
75
+ expect(downloadResult.isOk()).toBe(true);
76
+ expect(downloadResult._unsafeUnwrap()).toEqual(data);
77
+ });
78
+ it('reports upload progress', async () => {
79
+ const { client } = createMockHopClient();
80
+ const data = randomBytes(5_000);
81
+ const progress = [];
82
+ await uploadFile({
83
+ data,
84
+ hopClient: client,
85
+ chunkSize: 2_000,
86
+ onProgress: (sent, total) => progress.push([sent, total]),
87
+ });
88
+ // 3 chunks + 1 metadata = total 4 steps
89
+ expect(progress).toEqual([
90
+ [1, 4],
91
+ [2, 4],
92
+ [3, 4],
93
+ [4, 4],
94
+ ]);
95
+ });
96
+ it('reports download progress', async () => {
97
+ const { client } = createMockHopClient();
98
+ const data = randomBytes(5_000);
99
+ const { identifier, claimTicket } = (await uploadFile({ data, hopClient: client, chunkSize: 2_000 }))._unsafeUnwrap();
100
+ const progress = [];
101
+ await downloadFile({
102
+ identifier,
103
+ claimTicket,
104
+ hopClient: client,
105
+ onProgress: (received, total) => progress.push([received, total]),
106
+ });
107
+ expect(progress).toEqual([
108
+ [1, 3],
109
+ [2, 3],
110
+ [3, 3],
111
+ ]);
112
+ });
113
+ it('handles empty file', async () => {
114
+ const { client } = createMockHopClient();
115
+ const data = new Uint8Array(0);
116
+ const uploadResult = await uploadFile({ data, hopClient: client });
117
+ expect(uploadResult.isOk()).toBe(true);
118
+ const { identifier, claimTicket } = uploadResult._unsafeUnwrap();
119
+ const downloadResult = await downloadFile({
120
+ identifier,
121
+ claimTicket,
122
+ hopClient: client,
123
+ });
124
+ expect(downloadResult.isOk()).toBe(true);
125
+ expect(downloadResult._unsafeUnwrap()).toEqual(data);
126
+ });
127
+ });
@@ -0,0 +1,2 @@
1
+ export type { DownloadParams, UploadParams, UploadResult } from './fileLoader.js';
2
+ export { downloadFile, uploadFile } from './fileLoader.js';
@@ -0,0 +1 @@
1
+ export { downloadFile, uploadFile } from './fileLoader.js';
@@ -0,0 +1,8 @@
1
+ export type { HopClient } from './rpc/index.js';
2
+ export { createHopClient } from './rpc/index.js';
3
+ export type { HexString, PoolStatus, RequestFn } from './rpc/index.js';
4
+ export type { FileEncryption, FileTicket } from './crypto/index.js';
5
+ export { createFileEncryption, deriveEncryptionKey, derivePublicKey, deriveSigningKeypair, deriveSigningSeed, generateTicket, signWithTicket, } from './crypto/index.js';
6
+ export type { DownloadParams, UploadParams, UploadResult } from './fileLoader/index.js';
7
+ export { downloadFile, uploadFile } from './fileLoader/index.js';
8
+ export { UploadedFile } from './codec.js';
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { createHopClient } from './rpc/index.js';
2
+ export { createFileEncryption, deriveEncryptionKey, derivePublicKey, deriveSigningKeypair, deriveSigningSeed, generateTicket, signWithTicket, } from './crypto/index.js';
3
+ export { downloadFile, uploadFile } from './fileLoader/index.js';
4
+ // Codec (internal pool metadata)
5
+ export { UploadedFile } from './codec.js';
@@ -0,0 +1,8 @@
1
+ import type { ResultAsync } from 'neverthrow';
2
+ import type { PoolStatus, RequestFn } from './types.js';
3
+ export type HopClient = {
4
+ submit(data: Uint8Array, recipients: Uint8Array[]): ResultAsync<PoolStatus, Error>;
5
+ claim(hash: Uint8Array, signature: Uint8Array): ResultAsync<Uint8Array, Error>;
6
+ poolStatus(): ResultAsync<PoolStatus, Error>;
7
+ };
8
+ export declare function createHopClient(requestFn: RequestFn): HopClient;
@@ -0,0 +1,29 @@
1
+ import { fromHex, toHex } from '@polkadot-api/utils';
2
+ import { fromPromise } from 'neverthrow';
3
+ import { MultiSignature, MultiSigner } from './scale.js';
4
+ function toHexString(bytes) {
5
+ return toHex(bytes);
6
+ }
7
+ function encodeSr25519Signer(publicKey) {
8
+ return toHexString(MultiSigner.enc({ tag: 'sr25519', value: publicKey }));
9
+ }
10
+ function encodeSr25519Signature(signature) {
11
+ return toHexString(MultiSignature.enc({ tag: 'sr25519', value: signature }));
12
+ }
13
+ function toError(e) {
14
+ return e instanceof Error ? e : new Error(String(e));
15
+ }
16
+ export function createHopClient(requestFn) {
17
+ return {
18
+ submit(data, recipients) {
19
+ const encodedRecipients = recipients.map(r => encodeSr25519Signer(r));
20
+ return fromPromise(requestFn('hop_submit', [toHexString(data), encodedRecipients, '0x']), toError);
21
+ },
22
+ claim(hash, signature) {
23
+ return fromPromise(requestFn('hop_claim', [toHexString(hash), encodeSr25519Signature(signature)]).then(hex => fromHex(hex)), toError);
24
+ },
25
+ poolStatus() {
26
+ return fromPromise(requestFn('hop_poolStatus', []), toError);
27
+ },
28
+ };
29
+ }
@@ -0,0 +1,3 @@
1
+ export type { HopClient } from './client.js';
2
+ export { createHopClient } from './client.js';
3
+ export type { HexString, PoolStatus, RequestFn } from './types.js';
@@ -0,0 +1 @@
1
+ export { createHopClient } from './client.js';
@@ -0,0 +1,32 @@
1
+ /**
2
+ * SCALE-encoded MultiSigner:
3
+ * 0 = Ed25519 (32 bytes)
4
+ * 1 = SR25519 (32 bytes)
5
+ * 2 = ECDSA (33 bytes)
6
+ */
7
+ export declare const MultiSigner: import("scale-ts").Codec<{
8
+ tag: "ed25519";
9
+ value: Uint8Array<ArrayBufferLike>;
10
+ } | {
11
+ tag: "sr25519";
12
+ value: Uint8Array<ArrayBufferLike>;
13
+ } | {
14
+ tag: "ecdsa";
15
+ value: Uint8Array<ArrayBufferLike>;
16
+ }>;
17
+ /**
18
+ * SCALE-encoded MultiSignature:
19
+ * 0 = Ed25519 (64 bytes)
20
+ * 1 = SR25519 (64 bytes)
21
+ * 2 = ECDSA (65 bytes)
22
+ */
23
+ export declare const MultiSignature: import("scale-ts").Codec<{
24
+ tag: "ed25519";
25
+ value: Uint8Array<ArrayBufferLike>;
26
+ } | {
27
+ tag: "sr25519";
28
+ value: Uint8Array<ArrayBufferLike>;
29
+ } | {
30
+ tag: "ecdsa";
31
+ value: Uint8Array<ArrayBufferLike>;
32
+ }>;
@@ -0,0 +1,23 @@
1
+ import { Bytes, Enum } from 'scale-ts';
2
+ /**
3
+ * SCALE-encoded MultiSigner:
4
+ * 0 = Ed25519 (32 bytes)
5
+ * 1 = SR25519 (32 bytes)
6
+ * 2 = ECDSA (33 bytes)
7
+ */
8
+ export const MultiSigner = Enum({
9
+ ed25519: Bytes(32),
10
+ sr25519: Bytes(32),
11
+ ecdsa: Bytes(33),
12
+ });
13
+ /**
14
+ * SCALE-encoded MultiSignature:
15
+ * 0 = Ed25519 (64 bytes)
16
+ * 1 = SR25519 (64 bytes)
17
+ * 2 = ECDSA (65 bytes)
18
+ */
19
+ export const MultiSignature = Enum({
20
+ ed25519: Bytes(64),
21
+ sr25519: Bytes(64),
22
+ ecdsa: Bytes(65),
23
+ });
@@ -0,0 +1,16 @@
1
+ export type HexString = `0x${string}`;
2
+ export type PoolStatus = {
3
+ entryCount: number;
4
+ totalBytes: number;
5
+ maxBytes: number;
6
+ };
7
+ export type SubmitParams = {
8
+ data: HexString;
9
+ recipients: HexString[];
10
+ proof: HexString;
11
+ };
12
+ export type ClaimParams = {
13
+ hash: HexString;
14
+ signature: HexString;
15
+ };
16
+ export type RequestFn = <Reply>(method: string, params: unknown[]) => Promise<Reply>;
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@novasamatech/handoff-service",
3
+ "type": "module",
4
+ "version": "0.6.18",
5
+ "description": "HOP (Handoff Pool) file transfer service for P2P chat",
6
+ "license": "Apache-2.0",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/paritytech/triangle-js-sdks.git"
10
+ },
11
+ "keywords": [
12
+ "polkadot"
13
+ ],
14
+ "main": "dist/index.js",
15
+ "exports": {
16
+ "./package.json": "./package.json",
17
+ ".": {
18
+ "#/source": "./src/index.ts",
19
+ "types": "./dist/index.d.ts",
20
+ "default": "./dist/index.js"
21
+ }
22
+ },
23
+ "files": [
24
+ "dist",
25
+ "README.md"
26
+ ],
27
+ "dependencies": {
28
+ "@novasamatech/scale": "0.6.18",
29
+ "@noble/ciphers": "2.2.0",
30
+ "@noble/hashes": "2.2.0",
31
+ "@polkadot-api/utils": "0.2.0",
32
+ "@scure/sr25519": "1.0.0",
33
+ "neverthrow": "^8.2.0",
34
+ "scale-ts": "1.6.1"
35
+ },
36
+ "publishConfig": {
37
+ "access": "public"
38
+ }
39
+ }