@droponair/sdk-js 0.3.1 → 0.5.0
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/CHANGELOG.md +42 -0
- package/README.md +72 -0
- package/dist/attachment/attachment-client.d.ts +74 -0
- package/dist/attachment/attachment-client.js +323 -0
- package/dist/attachment/attachment-types.d.ts +77 -0
- package/dist/attachment/attachment-types.js +11 -0
- package/dist/core/messaging-client.d.ts +25 -2
- package/dist/core/messaging-client.js +314 -3
- package/dist/core/types.d.ts +77 -1
- package/dist/index.d.ts +2 -1
- package/dist/transport/protobuf-codec.d.ts +61 -0
- package/dist/transport/protobuf-codec.js +139 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +2 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,48 @@ This project follows [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
+
## [0.5.0], 2026-05-18
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- **Attachment pointers with customer-managed storage.** DropOnAir never holds file bytes; the SDK uploads/downloads directly against the customer's bucket via short-lived presigned URLs.
|
|
13
|
+
- **New high-level API:** `client.prepareAttachmentAndUpload(bytes, options)` encrypts (for E2EE), uploads to the customer's bucket, finalizes, and returns an `AttachmentRef` ready to drop into `sendMessage(toUserId, text, { attachments: [...] })`.
|
|
14
|
+
- **New low-level API:** `client.createUploadSession(options)`, `client.finalizeAttachment(id, sha256)`, `client.downloadAttachment(ref)`.
|
|
15
|
+
- **E2EE attachments:** per-attachment AES-256-GCM file key wrapped per recipient device using X25519 + HKDF, mirroring the per-device key model used for message payloads. Server never sees the unwrapped file key.
|
|
16
|
+
- **`DecryptedMessage.attachments`:** incoming messages now carry the optional `attachments: AttachmentRef[]` field.
|
|
17
|
+
- **PROTOCOL_VERSION = 4.** `features` now advertises `attachments` from `GET /api/info`.
|
|
18
|
+
- New public types: `AttachmentRef`, `DeviceWrappedKey`, `AttachmentEncryptionType`, `AttachmentConversationType`, `CreateUploadSessionOptions`, `UploadSession`, `PrepareAttachmentOptions`, `DownloadedAttachment`.
|
|
19
|
+
|
|
20
|
+
### Notes
|
|
21
|
+
- Wire-level additive: legacy 0.4.x clients ignore the new `Envelope.attachments` field (proto3 forwards-compat). Existing apps continue to work unchanged.
|
|
22
|
+
- v1 storage adapters supported by the server: S3-compatible (S3, R2, MinIO, B2, Wasabi), Google Cloud Storage, Azure Blob.
|
|
23
|
+
- Plan limits: `maxAttachmentSizeMb` per file + `maxAttachmentsPerMonth`. FREE = attachments disabled. PRO = 25 MB / 1000 per month. GROWTH = 100 MB / 10000. PAYG = 250 MB / metered. ENTERPRISE = custom.
|
|
24
|
+
- Requires sdk-be `0.5.0` server.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## [0.4.0], 2026-04-21
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
- **Message edit (E2EE + cleartext):** `editMessage(originalMessageId, toUserId, newText)` and `editCleartextMessage(originalMessageId, toUserId, newText)`. Edits are re-encrypted per-device with the same shared key as the original message; the server stores them as immutable separate records and forwards opaquely.
|
|
32
|
+
- **Message delete:** `deleteMessage(originalMessageId, toUserId, scope)` with `scope` of `"FOR_EVERYONE"` (recipient receives tombstone, cleartext plaintext is also wiped server-side) or `"FOR_ME"` (only sender's other devices receive the tombstone).
|
|
33
|
+
- **New event types:** `MessageEditEvent` and `MessageDeleteEvent` with `onMessageEdit()` / `onMessageDelete()` listeners.
|
|
34
|
+
- **Offline catch-up:** Pending edits and tombstones are now replayed alongside offline messages when the client reconnects (`pendingEdits` and `pendingTombstones` returned by `/api/messages/offline`).
|
|
35
|
+
- **PROTOCOL_VERSION = 3** and `features` now advertises `message_edit` + `message_delete` from `GET /api/info`.
|
|
36
|
+
|
|
37
|
+
### Notes
|
|
38
|
+
- Each edit and each `FOR_EVERYONE` delete counts as **one** MESSAGE usage record (subject to plan quotas + rate limits).
|
|
39
|
+
- Wire-level additive: legacy 0.3.x clients ignore unknown frame types (proto3 forwards-compat), so existing TwinFlame and CrowdSyncer apps continue to work unchanged.
|
|
40
|
+
- Requires sdk-be `0.4.0` server.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## [0.3.1], 2026-04-19
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
- Patch version bump for npm publish alignment
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
9
51
|
## [0.3.0], 2026-04-04
|
|
10
52
|
|
|
11
53
|
### Added
|
package/README.md
CHANGED
|
@@ -45,6 +45,7 @@ await client.sendMessage('recipient-user-id', 'Hello!');
|
|
|
45
45
|
## Features
|
|
46
46
|
|
|
47
47
|
- **E2EE Messaging** - X25519 key agreement, AES-256-GCM encryption, multi-device support
|
|
48
|
+
- **Message Edit & Delete** - Edit sent messages or tombstone them for everyone or only your own devices
|
|
48
49
|
- **Cleartext Messaging** - Lightweight messages without E2EE overhead
|
|
49
50
|
- **Broadcast Channels** - Pub/sub for announcements and notifications
|
|
50
51
|
- **Group Messaging** - Server-managed groups with member roles
|
|
@@ -86,11 +87,82 @@ const client = await initialize(options);
|
|
|
86
87
|
| Method | Returns | Description |
|
|
87
88
|
|--------|---------|-------------|
|
|
88
89
|
| `sendMessage(toUserId, plaintext)` | `Promise<{ messageId }>` | Send an encrypted message |
|
|
90
|
+
| `editMessage(originalMessageId, toUserId, newText)` | `Promise<{ editId }>` | Edit a previously sent encrypted message |
|
|
91
|
+
| `deleteMessage(originalMessageId, toUserId, scope)` | `Promise<{ deleteId }>` | Delete a previously sent 1:1 message. `scope` is `FOR_EVERYONE` or `FOR_ME` |
|
|
89
92
|
| `sendCleartextMessage(toUserId, plaintext)` | `Promise<{ messageId }>` | Send a cleartext message (no E2EE) |
|
|
93
|
+
| `editCleartextMessage(originalMessageId, toUserId, newText)` | `Promise<{ editId }>` | Edit a previously sent cleartext message |
|
|
90
94
|
| `onMessage(callback)` | `() => void` | Listen for incoming messages. Returns unsubscribe function |
|
|
95
|
+
| `onMessageEdit(callback)` | `() => void` | Listen for inbound edits to 1:1 messages |
|
|
96
|
+
| `onMessageDelete(callback)` | `() => void` | Listen for inbound delete tombstones for 1:1 messages |
|
|
91
97
|
| `onEvent(callback)` | `() => void` | Listen for system events (CONNECTED, DELIVERED, ERROR, etc.) |
|
|
92
98
|
| `ack(messageId)` | `Promise<void>` | Manually acknowledge a message |
|
|
93
99
|
|
|
100
|
+
### Message Edit & Delete
|
|
101
|
+
|
|
102
|
+
Available since SDK `0.4.0` and requires sdk-be `0.4.0`.
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
// Edit an encrypted message
|
|
106
|
+
await client.editMessage(originalMessageId, 'recipient-user-id', 'Updated text');
|
|
107
|
+
|
|
108
|
+
// Edit a cleartext message
|
|
109
|
+
await client.editCleartextMessage(originalMessageId, 'recipient-user-id', 'Updated text');
|
|
110
|
+
|
|
111
|
+
// Delete for everyone
|
|
112
|
+
await client.deleteMessage(originalMessageId, 'recipient-user-id', 'FOR_EVERYONE');
|
|
113
|
+
|
|
114
|
+
// Delete only on the sender's own devices
|
|
115
|
+
await client.deleteMessage(originalMessageId, 'recipient-user-id', 'FOR_ME');
|
|
116
|
+
|
|
117
|
+
client.onMessageEdit((edit) => {
|
|
118
|
+
console.log('edited', edit.originalMessageId, edit.text);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
client.onMessageDelete((del) => {
|
|
122
|
+
console.log('deleted', del.originalMessageId, del.scope);
|
|
123
|
+
});
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
- E2EE edits are re-encrypted per recipient device; the relay never sees the new plaintext.
|
|
127
|
+
- `FOR_EVERYONE` notifies the recipient; `FOR_ME` only syncs the tombstone to the sender's other devices.
|
|
128
|
+
- Each edit and each `FOR_EVERYONE` delete counts as one `MESSAGE` usage record.
|
|
129
|
+
|
|
130
|
+
### Attachments (Customer-Managed Storage)
|
|
131
|
+
|
|
132
|
+
Available since SDK `0.5.0` and requires sdk-be `0.5.0`. Configure your bucket once in the DropOnAir panel (S3-compatible, GCS, or Azure Blob). DropOnAir never holds file bytes - the SDK uploads/downloads directly against your bucket via short-lived presigned URLs.
|
|
133
|
+
|
|
134
|
+
```typescript
|
|
135
|
+
// Convenience: encrypt + upload + finalize in one call, then send with the message.
|
|
136
|
+
const ref = await client.prepareAttachmentAndUpload(fileBytes, {
|
|
137
|
+
toUserId: 'recipient-user-id',
|
|
138
|
+
mimeType: 'image/jpeg',
|
|
139
|
+
encryptionType: 'E2EE', // or 'CLEARTEXT' for public files
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await client.sendMessage('recipient-user-id', 'Here is the file', { attachments: [ref] });
|
|
143
|
+
|
|
144
|
+
// Recipient side: incoming DecryptedMessage carries optional attachments[]
|
|
145
|
+
client.onMessage(async (msg) => {
|
|
146
|
+
console.log(msg.plaintext);
|
|
147
|
+
for (const att of msg.attachments ?? []) {
|
|
148
|
+
const dl = await client.downloadAttachment(att);
|
|
149
|
+
// dl.bytes is Uint8Array, dl.mimeType / dl.sizeBytes / dl.sha256 are populated
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
| Method | Returns | Description |
|
|
155
|
+
|--------|---------|-------------|
|
|
156
|
+
| `prepareAttachmentAndUpload(bytes, options)` | `Promise<AttachmentRef>` | Encrypt (E2EE), upload, finalize, return ref |
|
|
157
|
+
| `createUploadSession(options)` | `Promise<UploadSession>` | Low-level: presigned PUT URL only |
|
|
158
|
+
| `finalizeAttachment(attachmentId, sha256)` | `Promise<void>` | Low-level: commit integrity hash |
|
|
159
|
+
| `downloadAttachment(ref)` | `Promise<DownloadedAttachment>` | Get presigned URL + download + decrypt |
|
|
160
|
+
| `sendMessage(toUserId, text, { attachments })` | `Promise<{ messageId }>` | Send with attachments |
|
|
161
|
+
|
|
162
|
+
- E2EE: a random AES-256-GCM file key encrypts the bytes; the file key is wrapped per recipient device using X25519 + HKDF (same model as message payloads). Server never sees the unwrapped file key.
|
|
163
|
+
- Plan limits: `maxAttachmentSizeMb` per file + `maxAttachmentsPerMonth`. Enforced server-side before the presigned URL is issued.
|
|
164
|
+
- Upload URL TTL = 15 min. Download URL TTL = 5 min. Download authorization checks that the requester is the original sender or in the captured recipient list.
|
|
165
|
+
|
|
94
166
|
### Broadcast Channels
|
|
95
167
|
|
|
96
168
|
| Method | Returns | Description |
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { CryptoService } from '../crypto/crypto-service';
|
|
2
|
+
import { WireAttachmentRef } from '../transport/protobuf-codec';
|
|
3
|
+
interface DeviceKeyInfo {
|
|
4
|
+
deviceId: string;
|
|
5
|
+
publicKey: string;
|
|
6
|
+
}
|
|
7
|
+
import { AttachmentRef, CreateUploadSessionOptions, DownloadedAttachment, PrepareAttachmentOptions, UploadSession } from './attachment-types';
|
|
8
|
+
interface AttachmentClientDeps {
|
|
9
|
+
httpUrl: string;
|
|
10
|
+
fetchFn: typeof fetch;
|
|
11
|
+
getValidDropOnAirJwt: () => Promise<string>;
|
|
12
|
+
fetchDeviceKeys: (userId: string) => Promise<DeviceKeyInfo[]>;
|
|
13
|
+
fetchMyOtherDeviceKeys: (myDeviceId: string) => Promise<DeviceKeyInfo[]>;
|
|
14
|
+
getCurrentUserId: () => string;
|
|
15
|
+
getCurrentDeviceId: () => Promise<string>;
|
|
16
|
+
getKeyStorage: () => {
|
|
17
|
+
get(k: string): Promise<string | null>;
|
|
18
|
+
};
|
|
19
|
+
cryptoService: CryptoService;
|
|
20
|
+
log: (stage: string, data?: Record<string, unknown>) => void;
|
|
21
|
+
logError: (stage: string, data?: Record<string, unknown>) => void;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* SDK-side coordinator for phase1/attachments. Handles:
|
|
25
|
+
* - createUploadSession: reserves an attachmentId + presigned PUT URL via sdk-be.
|
|
26
|
+
* - uploadBytes: PUTs bytes directly to the customer's bucket.
|
|
27
|
+
* - finalize: confirms upload + commits sha256.
|
|
28
|
+
* - prepareAttachmentAndUpload: convenience wrapper that does upload session,
|
|
29
|
+
* optional E2EE encryption + per-device key wrap, PUT, finalize, and returns
|
|
30
|
+
* a fully-populated AttachmentRef ready to drop into sendMessage.
|
|
31
|
+
* - downloadAttachment: fetches presigned GET, downloads, decrypts if E2EE.
|
|
32
|
+
*
|
|
33
|
+
* DropOnAir never holds file bytes - the SDK transfers bytes directly between
|
|
34
|
+
* the client and the customer's storage bucket.
|
|
35
|
+
*/
|
|
36
|
+
export declare class AttachmentClient {
|
|
37
|
+
private readonly deps;
|
|
38
|
+
constructor(deps: AttachmentClientDeps);
|
|
39
|
+
createUploadSession(opts: CreateUploadSessionOptions): Promise<UploadSession>;
|
|
40
|
+
finalize(attachmentId: string, sha256: string): Promise<void>;
|
|
41
|
+
/**
|
|
42
|
+
* Convenience: encrypts bytes if E2EE, uploads to the customer's bucket,
|
|
43
|
+
* finalizes, and returns a fully-formed AttachmentRef for sendMessage.
|
|
44
|
+
*/
|
|
45
|
+
prepareAttachmentAndUpload(input: Uint8Array, opts: PrepareAttachmentOptions): Promise<AttachmentRef>;
|
|
46
|
+
getDownloadUrl(attachmentId: string): Promise<{
|
|
47
|
+
url: string;
|
|
48
|
+
method: string;
|
|
49
|
+
headers: Record<string, string>;
|
|
50
|
+
mimeType: string;
|
|
51
|
+
sizeBytes: number;
|
|
52
|
+
sha256: string;
|
|
53
|
+
}>;
|
|
54
|
+
/**
|
|
55
|
+
* Downloads + decrypts an attachment. For E2EE, finds the wrappedKey for the
|
|
56
|
+
* current device, unwraps it, then decrypts the stored bytes.
|
|
57
|
+
*/
|
|
58
|
+
downloadAttachment(ref: AttachmentRef): Promise<DownloadedAttachment>;
|
|
59
|
+
/** Convert public AttachmentRef into the wire-format type for proto encoding. */
|
|
60
|
+
toWire(ref: AttachmentRef): WireAttachmentRef;
|
|
61
|
+
/** Convert a wire AttachmentRef (from a received message) into the public type. */
|
|
62
|
+
static fromWire(wire: WireAttachmentRef): AttachmentRef;
|
|
63
|
+
private uploadBytes;
|
|
64
|
+
private wrapKeyForRecipients;
|
|
65
|
+
private unwrapKey;
|
|
66
|
+
/**
|
|
67
|
+
* HKDF for the attachment file-key wrap. Sender and recipient both compute
|
|
68
|
+
* the same X25519 shared secret (by symmetry of curve25519) and derive the
|
|
69
|
+
* same AES-GCM key via this HKDF. Distinct info/salt from the message-payload
|
|
70
|
+
* HKDF in CryptoService to avoid key reuse across protocols.
|
|
71
|
+
*/
|
|
72
|
+
private deriveAttachmentWrapKey;
|
|
73
|
+
}
|
|
74
|
+
export {};
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.AttachmentClient = void 0;
|
|
7
|
+
const tweetnacl_1 = __importDefault(require("tweetnacl"));
|
|
8
|
+
const bytes_1 = require("../core/bytes");
|
|
9
|
+
const STORAGE_PRIVATE = 'droponair.identity.privateKey.v1';
|
|
10
|
+
function asBufferSource(data) {
|
|
11
|
+
return new Uint8Array(data);
|
|
12
|
+
}
|
|
13
|
+
async function sha256Hex(bytes) {
|
|
14
|
+
const digest = await crypto.subtle.digest('SHA-256', asBufferSource(bytes));
|
|
15
|
+
const hex = [];
|
|
16
|
+
const view = new Uint8Array(digest);
|
|
17
|
+
for (let i = 0; i < view.length; i += 1) {
|
|
18
|
+
hex.push(view[i].toString(16).padStart(2, '0'));
|
|
19
|
+
}
|
|
20
|
+
return hex.join('');
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* SDK-side coordinator for phase1/attachments. Handles:
|
|
24
|
+
* - createUploadSession: reserves an attachmentId + presigned PUT URL via sdk-be.
|
|
25
|
+
* - uploadBytes: PUTs bytes directly to the customer's bucket.
|
|
26
|
+
* - finalize: confirms upload + commits sha256.
|
|
27
|
+
* - prepareAttachmentAndUpload: convenience wrapper that does upload session,
|
|
28
|
+
* optional E2EE encryption + per-device key wrap, PUT, finalize, and returns
|
|
29
|
+
* a fully-populated AttachmentRef ready to drop into sendMessage.
|
|
30
|
+
* - downloadAttachment: fetches presigned GET, downloads, decrypts if E2EE.
|
|
31
|
+
*
|
|
32
|
+
* DropOnAir never holds file bytes - the SDK transfers bytes directly between
|
|
33
|
+
* the client and the customer's storage bucket.
|
|
34
|
+
*/
|
|
35
|
+
class AttachmentClient {
|
|
36
|
+
constructor(deps) {
|
|
37
|
+
this.deps = deps;
|
|
38
|
+
}
|
|
39
|
+
async createUploadSession(opts) {
|
|
40
|
+
const jwt = await this.deps.getValidDropOnAirJwt();
|
|
41
|
+
const body = {
|
|
42
|
+
mimeType: opts.mimeType ?? '',
|
|
43
|
+
sizeBytes: opts.sizeBytes,
|
|
44
|
+
encryptionType: opts.encryptionType ?? 'E2EE',
|
|
45
|
+
conversationType: opts.conversationType ?? 'ONE_TO_ONE',
|
|
46
|
+
conversationId: opts.conversationId ?? '',
|
|
47
|
+
recipientUserIds: opts.recipientUserIds ?? [],
|
|
48
|
+
};
|
|
49
|
+
const resp = await this.deps.fetchFn(`${this.deps.httpUrl}/v1/attachments/upload-session`, {
|
|
50
|
+
method: 'POST',
|
|
51
|
+
headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
|
|
52
|
+
body: JSON.stringify(body),
|
|
53
|
+
});
|
|
54
|
+
if (!resp.ok) {
|
|
55
|
+
const text = await resp.text().catch(() => '');
|
|
56
|
+
this.deps.logError('attachment_upload_session_failed', { status: resp.status, body: text.slice(0, 400) });
|
|
57
|
+
throw new Error(`Failed to create upload session (HTTP ${resp.status}): ${text}`);
|
|
58
|
+
}
|
|
59
|
+
return (await resp.json());
|
|
60
|
+
}
|
|
61
|
+
async finalize(attachmentId, sha256) {
|
|
62
|
+
const jwt = await this.deps.getValidDropOnAirJwt();
|
|
63
|
+
const resp = await this.deps.fetchFn(`${this.deps.httpUrl}/v1/attachments/${encodeURIComponent(attachmentId)}/finalize`, {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: { Authorization: `Bearer ${jwt}`, 'Content-Type': 'application/json' },
|
|
66
|
+
body: JSON.stringify({ sha256 }),
|
|
67
|
+
});
|
|
68
|
+
if (!resp.ok) {
|
|
69
|
+
const text = await resp.text().catch(() => '');
|
|
70
|
+
throw new Error(`Failed to finalize attachment (HTTP ${resp.status}): ${text}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Convenience: encrypts bytes if E2EE, uploads to the customer's bucket,
|
|
75
|
+
* finalizes, and returns a fully-formed AttachmentRef for sendMessage.
|
|
76
|
+
*/
|
|
77
|
+
async prepareAttachmentAndUpload(input, opts) {
|
|
78
|
+
if (!input || input.length === 0) {
|
|
79
|
+
throw new Error('attachment bytes are empty');
|
|
80
|
+
}
|
|
81
|
+
const encryptionType = opts.encryptionType ?? 'E2EE';
|
|
82
|
+
const conversationType = opts.groupId ? 'GROUP' : 'ONE_TO_ONE';
|
|
83
|
+
if (conversationType === 'ONE_TO_ONE' && !opts.toUserId) {
|
|
84
|
+
throw new Error('toUserId is required for one-to-one attachments');
|
|
85
|
+
}
|
|
86
|
+
if (conversationType === 'GROUP' && (!opts.recipientUserIds || opts.recipientUserIds.length === 0)) {
|
|
87
|
+
throw new Error('recipientUserIds is required for group attachments');
|
|
88
|
+
}
|
|
89
|
+
let storedBytes;
|
|
90
|
+
let fileKey = null;
|
|
91
|
+
let fileNonce = null;
|
|
92
|
+
if (encryptionType === 'E2EE') {
|
|
93
|
+
fileKey = (0, bytes_1.randomBytes)(32);
|
|
94
|
+
fileNonce = (0, bytes_1.randomBytes)(12);
|
|
95
|
+
const aesKey = await crypto.subtle.importKey('raw', asBufferSource(fileKey), { name: 'AES-GCM' }, false, ['encrypt']);
|
|
96
|
+
const cipher = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: asBufferSource(fileNonce), tagLength: 128 }, aesKey, asBufferSource(input));
|
|
97
|
+
// Pack nonce || ciphertext so download side can split without separate transport.
|
|
98
|
+
const ct = new Uint8Array(cipher);
|
|
99
|
+
storedBytes = new Uint8Array(fileNonce.length + ct.length);
|
|
100
|
+
storedBytes.set(fileNonce, 0);
|
|
101
|
+
storedBytes.set(ct, fileNonce.length);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
storedBytes = input;
|
|
105
|
+
}
|
|
106
|
+
const sha256 = await sha256Hex(storedBytes);
|
|
107
|
+
// Reserve upload session up-front so the server can validate plan limits
|
|
108
|
+
// before we burn bandwidth on a bucket PUT we know would fail later.
|
|
109
|
+
const recipientUserIds = opts.recipientUserIds ?? (opts.toUserId ? [opts.toUserId] : []);
|
|
110
|
+
const session = await this.createUploadSession({
|
|
111
|
+
mimeType: opts.mimeType ?? '',
|
|
112
|
+
sizeBytes: storedBytes.length,
|
|
113
|
+
encryptionType,
|
|
114
|
+
conversationType,
|
|
115
|
+
conversationId: opts.groupId,
|
|
116
|
+
recipientUserIds,
|
|
117
|
+
});
|
|
118
|
+
await this.uploadBytes(session, storedBytes, opts.onUploadProgress);
|
|
119
|
+
await this.finalize(session.attachmentId, sha256);
|
|
120
|
+
let wrappedKeys = [];
|
|
121
|
+
if (encryptionType === 'E2EE' && fileKey) {
|
|
122
|
+
wrappedKeys = await this.wrapKeyForRecipients(fileKey, opts);
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
attachmentId: session.attachmentId,
|
|
126
|
+
storageHint: session.storageHint,
|
|
127
|
+
mimeType: opts.mimeType ?? '',
|
|
128
|
+
sizeBytes: storedBytes.length,
|
|
129
|
+
sha256,
|
|
130
|
+
encryptionType,
|
|
131
|
+
wrappedKeys,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
async getDownloadUrl(attachmentId) {
|
|
135
|
+
const jwt = await this.deps.getValidDropOnAirJwt();
|
|
136
|
+
const resp = await this.deps.fetchFn(`${this.deps.httpUrl}/v1/attachments/${encodeURIComponent(attachmentId)}/download-url`, {
|
|
137
|
+
method: 'GET',
|
|
138
|
+
headers: { Authorization: `Bearer ${jwt}` },
|
|
139
|
+
});
|
|
140
|
+
if (!resp.ok) {
|
|
141
|
+
const text = await resp.text().catch(() => '');
|
|
142
|
+
throw new Error(`Failed to get download URL (HTTP ${resp.status}): ${text}`);
|
|
143
|
+
}
|
|
144
|
+
const body = await resp.json();
|
|
145
|
+
return {
|
|
146
|
+
url: body.downloadUrl,
|
|
147
|
+
method: body.downloadMethod,
|
|
148
|
+
headers: body.downloadHeaders ?? {},
|
|
149
|
+
mimeType: body.mimeType,
|
|
150
|
+
sizeBytes: body.sizeBytes,
|
|
151
|
+
sha256: body.sha256,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Downloads + decrypts an attachment. For E2EE, finds the wrappedKey for the
|
|
156
|
+
* current device, unwraps it, then decrypts the stored bytes.
|
|
157
|
+
*/
|
|
158
|
+
async downloadAttachment(ref) {
|
|
159
|
+
const info = await this.getDownloadUrl(ref.attachmentId);
|
|
160
|
+
const resp = await this.deps.fetchFn(info.url, { method: info.method, headers: info.headers });
|
|
161
|
+
if (!resp.ok) {
|
|
162
|
+
const text = await resp.text().catch(() => '');
|
|
163
|
+
throw new Error(`Failed to download attachment bytes (HTTP ${resp.status}): ${text}`);
|
|
164
|
+
}
|
|
165
|
+
const arrayBuf = await resp.arrayBuffer();
|
|
166
|
+
let stored = new Uint8Array(arrayBuf);
|
|
167
|
+
if (ref.encryptionType === 'CLEARTEXT') {
|
|
168
|
+
return { attachmentId: ref.attachmentId, mimeType: info.mimeType, sizeBytes: info.sizeBytes, sha256: info.sha256, bytes: stored };
|
|
169
|
+
}
|
|
170
|
+
const myDeviceId = await this.deps.getCurrentDeviceId();
|
|
171
|
+
const myWrapped = ref.wrappedKeys.find(wk => wk.deviceId === myDeviceId);
|
|
172
|
+
if (!myWrapped) {
|
|
173
|
+
throw new Error('No wrapped key for the current device on this attachment');
|
|
174
|
+
}
|
|
175
|
+
const fileKey = await this.unwrapKey(myWrapped);
|
|
176
|
+
// stored = fileNonce(12) || ciphertext
|
|
177
|
+
if (stored.length < 13) {
|
|
178
|
+
throw new Error('Encrypted attachment payload too short');
|
|
179
|
+
}
|
|
180
|
+
const fileNonce = stored.slice(0, 12);
|
|
181
|
+
const ciphertext = stored.slice(12);
|
|
182
|
+
const aesKey = await crypto.subtle.importKey('raw', asBufferSource(fileKey), { name: 'AES-GCM' }, false, ['decrypt']);
|
|
183
|
+
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: asBufferSource(fileNonce), tagLength: 128 }, aesKey, asBufferSource(ciphertext));
|
|
184
|
+
return {
|
|
185
|
+
attachmentId: ref.attachmentId,
|
|
186
|
+
mimeType: info.mimeType,
|
|
187
|
+
sizeBytes: info.sizeBytes,
|
|
188
|
+
sha256: info.sha256,
|
|
189
|
+
bytes: new Uint8Array(plain),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
/** Convert public AttachmentRef into the wire-format type for proto encoding. */
|
|
193
|
+
toWire(ref) {
|
|
194
|
+
return {
|
|
195
|
+
attachmentId: ref.attachmentId,
|
|
196
|
+
storageHint: ref.storageHint,
|
|
197
|
+
mimeType: ref.mimeType,
|
|
198
|
+
sizeBytes: ref.sizeBytes,
|
|
199
|
+
sha256: ref.sha256,
|
|
200
|
+
encryptionType: ref.encryptionType === 'CLEARTEXT' ? 1 : 0,
|
|
201
|
+
wrappedKeys: ref.wrappedKeys.map(wk => ({
|
|
202
|
+
deviceId: wk.deviceId,
|
|
203
|
+
wrappedKey: wk.wrappedKey,
|
|
204
|
+
senderPublicKey: wk.senderPublicKey,
|
|
205
|
+
nonce: wk.nonce,
|
|
206
|
+
})),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
/** Convert a wire AttachmentRef (from a received message) into the public type. */
|
|
210
|
+
static fromWire(wire) {
|
|
211
|
+
return {
|
|
212
|
+
attachmentId: wire.attachmentId,
|
|
213
|
+
storageHint: wire.storageHint,
|
|
214
|
+
mimeType: wire.mimeType,
|
|
215
|
+
sizeBytes: Number(wire.sizeBytes),
|
|
216
|
+
sha256: wire.sha256,
|
|
217
|
+
encryptionType: wire.encryptionType === 1 ? 'CLEARTEXT' : 'E2EE',
|
|
218
|
+
wrappedKeys: (wire.wrappedKeys ?? []).map((wk) => ({
|
|
219
|
+
deviceId: wk.deviceId,
|
|
220
|
+
wrappedKey: new Uint8Array(wk.wrappedKey),
|
|
221
|
+
senderPublicKey: new Uint8Array(wk.senderPublicKey),
|
|
222
|
+
nonce: new Uint8Array(wk.nonce),
|
|
223
|
+
})),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
// ─── internals ───
|
|
227
|
+
async uploadBytes(session, bytes, onProgress) {
|
|
228
|
+
const resp = await this.deps.fetchFn(session.uploadUrl, {
|
|
229
|
+
method: session.uploadMethod || 'PUT',
|
|
230
|
+
headers: session.uploadHeaders,
|
|
231
|
+
body: asBufferSource(bytes),
|
|
232
|
+
});
|
|
233
|
+
if (!resp.ok) {
|
|
234
|
+
const text = await resp.text().catch(() => '');
|
|
235
|
+
throw new Error(`Storage upload failed (HTTP ${resp.status}): ${text.slice(0, 400)}`);
|
|
236
|
+
}
|
|
237
|
+
// fetch does not surface upload-progress; report the final byte count.
|
|
238
|
+
if (onProgress) {
|
|
239
|
+
onProgress(bytes.length, bytes.length);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
async wrapKeyForRecipients(fileKey, opts) {
|
|
243
|
+
const currentUserId = this.deps.getCurrentUserId();
|
|
244
|
+
const myDeviceId = await this.deps.getCurrentDeviceId();
|
|
245
|
+
const myIdentity = await this.deps.cryptoService.getOrCreateIdentity();
|
|
246
|
+
const myPublicKeyBytes = (0, bytes_1.fromBase64)(myIdentity.publicKey);
|
|
247
|
+
const myPrivBase64 = await this.deps.getKeyStorage().get(STORAGE_PRIVATE);
|
|
248
|
+
if (!myPrivBase64) {
|
|
249
|
+
throw new Error('Local identity keypair is missing; cannot wrap attachment key');
|
|
250
|
+
}
|
|
251
|
+
const myPriv = (0, bytes_1.fromBase64)(myPrivBase64);
|
|
252
|
+
// Resolve target devices: recipients' devices + sender's other devices.
|
|
253
|
+
const recipientUserIds = opts.recipientUserIds ?? (opts.toUserId ? [opts.toUserId] : []);
|
|
254
|
+
const seenDeviceIds = new Set();
|
|
255
|
+
const targets = [];
|
|
256
|
+
for (const uid of recipientUserIds) {
|
|
257
|
+
const keys = await this.deps.fetchDeviceKeys(uid);
|
|
258
|
+
for (const k of keys) {
|
|
259
|
+
if (seenDeviceIds.has(k.deviceId))
|
|
260
|
+
continue;
|
|
261
|
+
seenDeviceIds.add(k.deviceId);
|
|
262
|
+
targets.push({ deviceId: k.deviceId, publicKey: k.publicKey });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const myOther = await this.deps.fetchMyOtherDeviceKeys(myDeviceId);
|
|
266
|
+
for (const k of myOther) {
|
|
267
|
+
if (seenDeviceIds.has(k.deviceId))
|
|
268
|
+
continue;
|
|
269
|
+
seenDeviceIds.add(k.deviceId);
|
|
270
|
+
targets.push({ deviceId: k.deviceId, publicKey: k.publicKey });
|
|
271
|
+
}
|
|
272
|
+
const wrapped = [];
|
|
273
|
+
for (const target of targets) {
|
|
274
|
+
const peerPub = (0, bytes_1.fromBase64)(target.publicKey);
|
|
275
|
+
if (peerPub.length !== 32) {
|
|
276
|
+
this.deps.logError('attachment_invalid_peer_pubkey', { deviceId: target.deviceId, len: peerPub.length });
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const shared = tweetnacl_1.default.scalarMult(myPriv, peerPub);
|
|
280
|
+
const aesKey = await this.deriveAttachmentWrapKey(shared);
|
|
281
|
+
const nonce = (0, bytes_1.randomBytes)(12);
|
|
282
|
+
const cipherBuf = await crypto.subtle.encrypt({ name: 'AES-GCM', iv: asBufferSource(nonce), tagLength: 128 }, aesKey, asBufferSource(fileKey));
|
|
283
|
+
wrapped.push({
|
|
284
|
+
deviceId: target.deviceId,
|
|
285
|
+
wrappedKey: new Uint8Array(cipherBuf),
|
|
286
|
+
senderPublicKey: myPublicKeyBytes,
|
|
287
|
+
nonce,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
return wrapped;
|
|
291
|
+
}
|
|
292
|
+
async unwrapKey(wrapped) {
|
|
293
|
+
const privBase64 = await this.deps.getKeyStorage().get(STORAGE_PRIVATE);
|
|
294
|
+
if (!privBase64) {
|
|
295
|
+
throw new Error('Local identity keypair is missing; cannot unwrap attachment key');
|
|
296
|
+
}
|
|
297
|
+
const myPriv = (0, bytes_1.fromBase64)(privBase64);
|
|
298
|
+
if (myPriv.length !== 32 || wrapped.senderPublicKey.length !== 32) {
|
|
299
|
+
throw new Error('Invalid X25519 key length on wrapped attachment key');
|
|
300
|
+
}
|
|
301
|
+
const shared = tweetnacl_1.default.scalarMult(myPriv, wrapped.senderPublicKey);
|
|
302
|
+
const aesKey = await this.deriveAttachmentWrapKey(shared);
|
|
303
|
+
const plainBuf = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: asBufferSource(wrapped.nonce), tagLength: 128 }, aesKey, asBufferSource(wrapped.wrappedKey));
|
|
304
|
+
const plain = new Uint8Array(plainBuf);
|
|
305
|
+
if (plain.length !== 32) {
|
|
306
|
+
throw new Error(`unwrapped attachment key has unexpected length ${plain.length}`);
|
|
307
|
+
}
|
|
308
|
+
return plain;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* HKDF for the attachment file-key wrap. Sender and recipient both compute
|
|
312
|
+
* the same X25519 shared secret (by symmetry of curve25519) and derive the
|
|
313
|
+
* same AES-GCM key via this HKDF. Distinct info/salt from the message-payload
|
|
314
|
+
* HKDF in CryptoService to avoid key reuse across protocols.
|
|
315
|
+
*/
|
|
316
|
+
async deriveAttachmentWrapKey(sharedSecret) {
|
|
317
|
+
const info = (0, bytes_1.utf8Encode)('droponair-attachment-key-wrap-v1');
|
|
318
|
+
const salt = (0, bytes_1.utf8Encode)('droponair-attachment-hkdf-salt-v1');
|
|
319
|
+
const ikm = await crypto.subtle.importKey('raw', asBufferSource(sharedSecret), 'HKDF', false, ['deriveKey']);
|
|
320
|
+
return crypto.subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt: asBufferSource(salt), info: asBufferSource(info) }, ikm, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
exports.AttachmentClient = AttachmentClient;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/** Encryption mode for an attachment. */
|
|
2
|
+
export type AttachmentEncryptionType = 'E2EE' | 'CLEARTEXT';
|
|
3
|
+
/** Conversation scope used for download authorization on the server side. */
|
|
4
|
+
export type AttachmentConversationType = 'ONE_TO_ONE' | 'GROUP';
|
|
5
|
+
/**
|
|
6
|
+
* Per-device wrapped AES-256-GCM file key for an E2EE attachment.
|
|
7
|
+
* The wrapped bytes decrypt to a 32-byte raw AES key that decrypts the file.
|
|
8
|
+
*/
|
|
9
|
+
export interface DeviceWrappedKey {
|
|
10
|
+
deviceId: string;
|
|
11
|
+
wrappedKey: Uint8Array;
|
|
12
|
+
senderPublicKey: Uint8Array;
|
|
13
|
+
nonce: Uint8Array;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Reference to an attachment hosted in the customer's storage bucket,
|
|
17
|
+
* carried inside Envelope.attachments. After the SDK uploads the bytes
|
|
18
|
+
* and finalizes, this is the structure the sender embeds in the message.
|
|
19
|
+
*/
|
|
20
|
+
export interface AttachmentRef {
|
|
21
|
+
attachmentId: string;
|
|
22
|
+
storageHint: string;
|
|
23
|
+
mimeType: string;
|
|
24
|
+
sizeBytes: number;
|
|
25
|
+
/** Hex-encoded SHA-256 of stored bytes (ciphertext for E2EE, plaintext for CLEARTEXT). */
|
|
26
|
+
sha256: string;
|
|
27
|
+
encryptionType: AttachmentEncryptionType;
|
|
28
|
+
/** Empty for CLEARTEXT. One entry per recipient device for E2EE. */
|
|
29
|
+
wrappedKeys: DeviceWrappedKey[];
|
|
30
|
+
}
|
|
31
|
+
/** Optional metadata for {@link DropOnAirClient.createUploadSession}. */
|
|
32
|
+
export interface CreateUploadSessionOptions {
|
|
33
|
+
mimeType?: string;
|
|
34
|
+
sizeBytes: number;
|
|
35
|
+
encryptionType?: AttachmentEncryptionType;
|
|
36
|
+
conversationType?: AttachmentConversationType;
|
|
37
|
+
/** For GROUP conversations: the groupId. For ONE_TO_ONE: omit. */
|
|
38
|
+
conversationId?: string;
|
|
39
|
+
/** Recipient user IDs (used by the server for download authorization). */
|
|
40
|
+
recipientUserIds?: string[];
|
|
41
|
+
}
|
|
42
|
+
/** Result of reserving an upload session. */
|
|
43
|
+
export interface UploadSession {
|
|
44
|
+
attachmentId: string;
|
|
45
|
+
storageHint: string;
|
|
46
|
+
uploadUrl: string;
|
|
47
|
+
uploadMethod: string;
|
|
48
|
+
uploadHeaders: Record<string, string>;
|
|
49
|
+
expiresAt: string;
|
|
50
|
+
}
|
|
51
|
+
/** Options for the convenience method {@link DropOnAirClient.prepareAttachmentAndUpload}. */
|
|
52
|
+
export interface PrepareAttachmentOptions {
|
|
53
|
+
/** Recipient user ID for ONE_TO_ONE conversations. */
|
|
54
|
+
toUserId?: string;
|
|
55
|
+
/** Group ID for GROUP conversations. Mutually exclusive with toUserId. */
|
|
56
|
+
groupId?: string;
|
|
57
|
+
/**
|
|
58
|
+
* Roster of recipient user IDs the server should authorize for download.
|
|
59
|
+
* For ONE_TO_ONE: defaults to {@code [toUserId]} if omitted.
|
|
60
|
+
* For GROUP: required - the SDK does not infer group membership.
|
|
61
|
+
*/
|
|
62
|
+
recipientUserIds?: string[];
|
|
63
|
+
/** Defaults to {@code 'E2EE'}. Use {@code 'CLEARTEXT'} for public/unencrypted content. */
|
|
64
|
+
encryptionType?: AttachmentEncryptionType;
|
|
65
|
+
/** Override the mime type detected from the File object. */
|
|
66
|
+
mimeType?: string;
|
|
67
|
+
/** Progress callback called with bytes uploaded so far. */
|
|
68
|
+
onUploadProgress?: (bytesUploaded: number, totalBytes: number) => void;
|
|
69
|
+
}
|
|
70
|
+
/** Bytes payload returned by downloadAttachment. */
|
|
71
|
+
export interface DownloadedAttachment {
|
|
72
|
+
attachmentId: string;
|
|
73
|
+
mimeType: string;
|
|
74
|
+
sizeBytes: number;
|
|
75
|
+
sha256: string;
|
|
76
|
+
bytes: Uint8Array;
|
|
77
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Attachment types (PROTOCOL_VERSION 4+)
|
|
4
|
+
//
|
|
5
|
+
// DropOnAir never holds file bytes. The SDK uploads/downloads directly
|
|
6
|
+
// against the customer's storage bucket via short-lived presigned URLs
|
|
7
|
+
// minted by sdk-be. For E2EE messages a per-attachment AES-256-GCM file
|
|
8
|
+
// key is wrapped per recipient device using the same X25519 + HKDF path
|
|
9
|
+
// used for message payloads.
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|