Skip to content

Commit 863714d

Browse files
guguclaude
andcommitted
add createEncryptedLink for e2e encrypted short links
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0a5f08e commit 863714d

3 files changed

Lines changed: 190 additions & 0 deletions

File tree

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Official Node.js SDK for the [Short.io](https://short.io) URL shortening and lin
1717
- [Folders](#folders)
1818
- [OpenGraph](#opengraph)
1919
- [Permissions](#permissions)
20+
- [Encrypted Links](#encrypted-links)
2021
- [API Reference](#api-reference)
2122
- [Advanced Configuration](#advanced-configuration)
2223
- [TypeScript Support](#typescript-support)
@@ -39,6 +40,7 @@ Official Node.js SDK for the [Short.io](https://short.io) URL shortening and lin
3940
- **Permissions Management** - Control user access to links
4041
- **Full TypeScript Support** - Comprehensive type definitions included
4142
- **Modern ESM** - Built with ES modules
43+
- **Encrypted Links** - End-to-end encrypted links with client-side AES-GCM encryption
4244
- **Automatic Rate Limit Handling** - Built-in retry logic for 429 responses with exponential backoff
4345

4446
## Requirements
@@ -527,6 +529,33 @@ await deleteLinkPermission({
527529
});
528530
```
529531

532+
### Encrypted Links
533+
534+
Create end-to-end encrypted links where the destination URL is encrypted client-side before being sent to the API. The decryption key is embedded in the URL hash fragment (`#key`), which browsers never send to servers, ensuring true e2e encryption.
535+
536+
```javascript
537+
import { setApiKey, createEncryptedLink } from "@short.io/client-node";
538+
539+
setApiKey("YOUR_API_KEY");
540+
541+
const result = await createEncryptedLink({
542+
body: {
543+
originalURL: "https://secret-destination.com/private-page",
544+
domain: "your-domain.com",
545+
path: "secure-link", // Optional
546+
title: "Secret Link" // Optional
547+
}
548+
});
549+
550+
console.log("Encrypted short URL:", result.data.shortURL);
551+
// => https://your-domain.com/secure-link#<base64-encryption-key>
552+
553+
console.log("Encryption key:", result.data.encryptionKey);
554+
// => base64-encoded AES-GCM key (included in the URL hash fragment)
555+
```
556+
557+
The original URL is encrypted with AES-128-GCM before being sent to the Short.io API. The API only ever sees the encrypted payload (`shortsecure://...`), never the actual destination URL. The decryption key is appended as a hash fragment to the short URL, so it is only available to the end user's browser.
558+
530559
## API Reference
531560

532561
### Link Operations
@@ -547,6 +576,7 @@ await deleteLinkPermission({
547576
| `createLinkPublic` | Create link using public API key | 50/s |
548577
| `createLinkSimple` | Create link (GET method) | 50/s |
549578
| `createExampleLinks` | Generate example links | 5/10s |
579+
| `createEncryptedLink` | Create an e2e encrypted link | 50/s |
550580

551581
### Bulk Operations
552582

src/index.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,3 +189,58 @@ export const setApiKey = (apiKey: string) => {
189189
}
190190
})
191191
}
192+
193+
// ─── Encrypted Links ─────────────────────────────────────────────────────────
194+
195+
import type { Options } from "./generated/sdk.gen"
196+
import type { CreateLinkData } from "./generated/types.gen"
197+
import { createLink } from "./generated/sdk.gen"
198+
import { webcrypto } from "node:crypto"
199+
200+
function arrayBufferToBase64(buffer: ArrayBuffer): string {
201+
return Buffer.from(buffer).toString('base64');
202+
}
203+
204+
async function encryptURL(originalURL: string): Promise<{ encryptedURL: string; key: string }> {
205+
const subtle = webcrypto.subtle;
206+
const cryptoKey = await subtle.generateKey({ name: "AES-GCM", length: 128 }, true, [
207+
"encrypt",
208+
"decrypt",
209+
]);
210+
const iv = webcrypto.getRandomValues(new Uint8Array(12));
211+
const urlData = new TextEncoder().encode(originalURL);
212+
const encryptedUrl = await subtle.encrypt({ name: "AES-GCM", iv }, cryptoKey, urlData);
213+
const encryptedUrlBase64 = arrayBufferToBase64(encryptedUrl);
214+
const encryptedIvBase64 = arrayBufferToBase64(iv.buffer);
215+
const encryptedURL = `shortsecure://${encryptedUrlBase64}?${encryptedIvBase64}`;
216+
const exportedKey = await subtle.exportKey("raw", cryptoKey);
217+
const key = arrayBufferToBase64(exportedKey);
218+
return { encryptedURL, key };
219+
}
220+
221+
export async function createEncryptedLink(
222+
options: Options<CreateLinkData, false> & { body: { originalURL: string } }
223+
) {
224+
const { encryptedURL, key } = await encryptURL(options.body.originalURL);
225+
226+
const result = await createLink({
227+
...options,
228+
body: {
229+
...options.body,
230+
originalURL: encryptedURL,
231+
},
232+
});
233+
234+
if (result.data) {
235+
const data = result.data as Record<string, unknown>;
236+
if (typeof data.shortURL === 'string') {
237+
data.shortURL = `${data.shortURL}#${key}`;
238+
}
239+
if (typeof data.secureShortURL === 'string') {
240+
data.secureShortURL = `${data.secureShortURL}#${key}`;
241+
}
242+
return { ...result, data: { ...data, encryptionKey: key } };
243+
}
244+
245+
return result;
246+
}

tests/sdk.test.ts

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
enableRateLimiting,
1616
disableRateLimiting,
1717
getRateLimitConfig,
18+
createEncryptedLink,
1819
type RateLimitInfo,
1920
type SleepFunction,
2021
} from '../src/index';
@@ -620,3 +621,107 @@ describe('Rate limiting', () => {
620621
expect(mockSleep).toHaveBeenCalledWith(5000); // Capped at maxDelayMs
621622
});
622623
});
624+
625+
// ── 5. Encrypted link creation ──────────────────────────────────────────
626+
627+
describe('Encrypted link creation', () => {
628+
it('encrypts URL and creates link with key in shortURL', async () => {
629+
let capturedBody: Record<string, unknown> | null = null;
630+
631+
server.use(
632+
http.post(`${BASE_URL}/links`, async ({ request }) => {
633+
capturedBody = await request.json() as Record<string, unknown>;
634+
return HttpResponse.json({
635+
originalURL: capturedBody.originalURL,
636+
path: 'enc123',
637+
shortURL: 'https://short.io/enc123',
638+
secureShortURL: 'https://short.io/enc123',
639+
idString: 'lnk_enc_123',
640+
});
641+
}),
642+
);
643+
644+
const result = await createEncryptedLink({
645+
client: makeClient(),
646+
body: {
647+
originalURL: 'https://example.com/secret',
648+
domain: 'short.io',
649+
},
650+
});
651+
652+
// API should receive shortsecure:// URL, not the original
653+
expect(capturedBody).not.toBeNull();
654+
expect((capturedBody as Record<string, unknown>).originalURL).toMatch(/^shortsecure:\/\//);
655+
656+
// Response should have key appended as hash fragment
657+
expect(result.data).toBeDefined();
658+
const data = result.data as Record<string, unknown>;
659+
expect(data.shortURL).toMatch(/#.+$/);
660+
expect(data.secureShortURL).toMatch(/#.+$/);
661+
expect(data.encryptionKey).toBeDefined();
662+
expect(typeof data.encryptionKey).toBe('string');
663+
});
664+
665+
it('preserves other body options (title, path)', async () => {
666+
let capturedBody: Record<string, unknown> | null = null;
667+
668+
server.use(
669+
http.post(`${BASE_URL}/links`, async ({ request }) => {
670+
capturedBody = await request.json() as Record<string, unknown>;
671+
return HttpResponse.json({
672+
originalURL: capturedBody.originalURL,
673+
path: 'custom-path',
674+
title: 'My Title',
675+
shortURL: 'https://short.io/custom-path',
676+
secureShortURL: 'https://short.io/custom-path',
677+
idString: 'lnk_custom_123',
678+
});
679+
}),
680+
);
681+
682+
await createEncryptedLink({
683+
client: makeClient(),
684+
body: {
685+
originalURL: 'https://example.com/secret',
686+
domain: 'short.io',
687+
path: 'custom-path',
688+
title: 'My Title',
689+
},
690+
});
691+
692+
expect(capturedBody).not.toBeNull();
693+
expect((capturedBody as Record<string, unknown>).path).toBe('custom-path');
694+
expect((capturedBody as Record<string, unknown>).title).toBe('My Title');
695+
expect((capturedBody as Record<string, unknown>).domain).toBe('short.io');
696+
});
697+
698+
it('returned encryptionKey is a valid base64 string', async () => {
699+
server.use(
700+
http.post(`${BASE_URL}/links`, () => {
701+
return HttpResponse.json({
702+
originalURL: 'shortsecure://test',
703+
path: 'b64test',
704+
shortURL: 'https://short.io/b64test',
705+
secureShortURL: 'https://short.io/b64test',
706+
idString: 'lnk_b64_123',
707+
});
708+
}),
709+
);
710+
711+
const result = await createEncryptedLink({
712+
client: makeClient(),
713+
body: {
714+
originalURL: 'https://example.com',
715+
domain: 'short.io',
716+
},
717+
});
718+
719+
const data = result.data as Record<string, unknown>;
720+
const key = data.encryptionKey as string;
721+
722+
// Valid base64: decode and re-encode should round-trip
723+
const decoded = Buffer.from(key, 'base64');
724+
expect(decoded.length).toBe(16); // 128-bit AES key = 16 bytes
725+
expect(decoded.toString('base64')).toBe(key);
726+
});
727+
});

0 commit comments

Comments
 (0)