@novasamatech/handoff-service 0.8.4 → 0.8.5

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.
@@ -2,3 +2,4 @@ export type { FileEncryption } from './encryption.js';
2
2
  export { createFileEncryption } from './encryption.js';
3
3
  export type { FileTicket } from './ticket.js';
4
4
  export { deriveEncryptionKey, derivePublicKey, deriveSigningKeypair, deriveSigningSeed, generateTicket, signWithTicket, } from './ticket.js';
5
+ export { HopSigningPayloads } from './signingPayloads.js';
@@ -1,2 +1,3 @@
1
1
  export { createFileEncryption } from './encryption.js';
2
2
  export { deriveEncryptionKey, derivePublicKey, deriveSigningKeypair, deriveSigningSeed, generateTicket, signWithTicket, } from './ticket.js';
3
+ export { HopSigningPayloads } from './signingPayloads.js';
@@ -0,0 +1,5 @@
1
+ export declare const HopSigningPayloads: {
2
+ submit(data: Uint8Array, submitTimestampMs: bigint): Uint8Array;
3
+ claim(hash: Uint8Array): Uint8Array;
4
+ ack(hash: Uint8Array): Uint8Array;
5
+ };
@@ -0,0 +1,49 @@
1
+ import { blake2b } from '@noble/hashes/blake2.js';
2
+ /**
3
+ * Domain-separated 32-byte payloads that the HOP node verifies for
4
+ * submit/claim/ack. Byte layouts must remain identical to
5
+ * `substrate/client/hop/src/types.rs` — `signing_payload` and
6
+ * `submit_signing_payload` — and match the Android client
7
+ * (`HopSigningPayloads` in `feature_chats_impl.data.hop`).
8
+ *
9
+ * Without this domain separation, the HOP server rejects the signature and
10
+ * surfaces the failure as "Data not found" (the server doesn't reveal that
11
+ * the data is present but the signature didn't verify).
12
+ */
13
+ const textEncoder = new TextEncoder();
14
+ const SUBMIT_CONTEXT = textEncoder.encode('hop-submit-v1:');
15
+ const CLAIM_CONTEXT = textEncoder.encode('hop-claim-v1:');
16
+ const ACK_CONTEXT = textEncoder.encode('hop-ack-v1:');
17
+ function concat(...parts) {
18
+ const total = parts.reduce((n, p) => n + p.length, 0);
19
+ const out = new Uint8Array(total);
20
+ let offset = 0;
21
+ for (const p of parts) {
22
+ out.set(p, offset);
23
+ offset += p.length;
24
+ }
25
+ return out;
26
+ }
27
+ function blake2b256(data) {
28
+ return blake2b(data, { dkLen: 32 });
29
+ }
30
+ function u64LeBytes(value) {
31
+ const out = new Uint8Array(8);
32
+ let v = value;
33
+ for (let i = 0; i < 8; i++) {
34
+ out[i] = Number(v & 0xffn);
35
+ v >>= 8n;
36
+ }
37
+ return out;
38
+ }
39
+ export const HopSigningPayloads = {
40
+ submit(data, submitTimestampMs) {
41
+ return blake2b256(concat(SUBMIT_CONTEXT, blake2b256(data), u64LeBytes(submitTimestampMs)));
42
+ },
43
+ claim(hash) {
44
+ return blake2b256(concat(CLAIM_CONTEXT, hash));
45
+ },
46
+ ack(hash) {
47
+ return blake2b256(concat(ACK_CONTEXT, hash));
48
+ },
49
+ };
@@ -2,7 +2,7 @@ import { blake2b } from '@noble/hashes/blake2.js';
2
2
  import { mergeUint8 } from '@polkadot-api/utils';
3
3
  import { errAsync, okAsync } from 'neverthrow';
4
4
  import { UploadedFile } from '../codec.js';
5
- import { createFileEncryption, deriveEncryptionKey, derivePublicKey, generateTicket, signWithTicket, } from '../crypto/index.js';
5
+ import { HopSigningPayloads, createFileEncryption, deriveEncryptionKey, derivePublicKey, generateTicket, signWithTicket, } from '../crypto/index.js';
6
6
  const DEFAULT_CHUNK_SIZE = 2_000_000;
7
7
  function hash256(data) {
8
8
  return blake2b(data, { dkLen: 32 });
@@ -48,13 +48,28 @@ export function uploadFile(params) {
48
48
  });
49
49
  });
50
50
  }
51
+ // Sign the canonical claim payload (`blake2b256("hop-claim-v1:" || hash)`)
52
+ // instead of the raw hash — the HOP server validates the signature against
53
+ // this exact payload, identical to Android's `HopSigningPayloads.claim`.
54
+ // Signing the raw hash was the previous behaviour; the server then rejected
55
+ // the signature and returned the error as "Data not found", indistinguishable
56
+ // from an actually-missing entry.
57
+ function signClaimPayload(claimTicket, hash) {
58
+ return signWithTicket(claimTicket, HopSigningPayloads.claim(hash));
59
+ }
51
60
  export function downloadFile(params) {
52
61
  const { identifier, claimTicket, hopClient, onProgress } = params;
53
62
  const encryptionKey = deriveEncryptionKey(claimTicket);
54
63
  const encryption = createFileEncryption(encryptionKey);
55
- const metadataSignature = signWithTicket(claimTicket, identifier);
56
- return hopClient
57
- .claim(identifier, metadataSignature)
64
+ const claim = (hash) => {
65
+ // Skip ack — claim already evicts the entry server-side, and firing a
66
+ // fire-and-forget ack on the same WSS during chunk loops can stall the
67
+ // next claim's response (observed in practice). Android calls ack
68
+ // explicitly per claim, but it's not required for the receive to
69
+ // complete; the server keeps cleanup paths idempotent.
70
+ return hopClient.claim(hash, signClaimPayload(claimTicket, hash));
71
+ };
72
+ return claim(identifier)
58
73
  .andThen(encryptedMetadata => {
59
74
  try {
60
75
  const metadataBytes = encryption.decrypt(encryptedMetadata);
@@ -72,8 +87,7 @@ export function downloadFile(params) {
72
87
  for (let i = 0; i < totalChunks; i++) {
73
88
  const chunkHash = chunkHashes[i];
74
89
  result = result.andThen(decryptedChunks => {
75
- const chunkSignature = signWithTicket(claimTicket, chunkHash);
76
- return hopClient.claim(chunkHash, chunkSignature).andThen(encryptedChunk => {
90
+ return claim(chunkHash).andThen(encryptedChunk => {
77
91
  try {
78
92
  const chunk = encryption.decrypt(encryptedChunk);
79
93
  onProgress?.(i + 1, totalChunks);
@@ -32,6 +32,7 @@ function createMockHopClient() {
32
32
  const client = {
33
33
  submit: submitMock,
34
34
  claim: claimMock,
35
+ ack: vi.fn(() => okAsync(null)),
35
36
  poolStatus: vi.fn(() => okAsync({ entryCount: 0, totalBytes: 0, maxBytes: 10_000_000 })),
36
37
  };
37
38
  return { client, submitMock, claimMock, submittedEntries };
@@ -3,6 +3,7 @@ import type { PoolStatus, RequestFn } from './types.js';
3
3
  export type HopClient = {
4
4
  submit(data: Uint8Array, recipients: Uint8Array[]): ResultAsync<PoolStatus, Error>;
5
5
  claim(hash: Uint8Array, signature: Uint8Array): ResultAsync<Uint8Array, Error>;
6
+ ack(hash: Uint8Array, signature: Uint8Array): ResultAsync<null, Error>;
6
7
  poolStatus(): ResultAsync<PoolStatus, Error>;
7
8
  };
8
9
  export declare function createHopClient(requestFn: RequestFn): HopClient;
@@ -22,6 +22,12 @@ export function createHopClient(requestFn) {
22
22
  claim(hash, signature) {
23
23
  return fromPromise(requestFn('hop_claim', [toHexString(hash), encodeSr25519Signature(signature)]).then(hex => fromHex(hex)), toError);
24
24
  },
25
+ ack(hash, signature) {
26
+ // hop_ack acknowledges a successful claim so the server can evict the
27
+ // entry. Android calls this after every successful claim; failure is
28
+ // non-fatal for the receiver (best-effort cleanup).
29
+ return fromPromise(requestFn('hop_ack', [toHexString(hash), encodeSr25519Signature(signature)]).then(() => null), toError);
30
+ },
25
31
  poolStatus() {
26
32
  return fromPromise(requestFn('hop_poolStatus', []), toError);
27
33
  },
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@novasamatech/handoff-service",
3
3
  "type": "module",
4
- "version": "0.8.4",
4
+ "version": "0.8.5",
5
5
  "description": "HOP (Handoff Pool) file transfer service for P2P chat",
6
6
  "license": "Apache-2.0",
7
7
  "repository": {