Skip to content

Repository files navigation

@incy/link-encoder

Encode VPN subscription URLs into incy://crypt1/<payload> deep links that the INCY iOS, Android, and Desktop clients decode automatically.

https://sub.your-provider.example/abc123token
                ⬇
incy://crypt1/AAECAwQFBgcICQoLNyIQL3rDwRZqnyoD8pGK…

Open the resulting link on a device with INCY installed → the subscription imports without the user copy-pasting anything.

Install

npm install @incy/link-encoder

Usage

import { encryptLink, decryptLink } from '@incy/link-encoder';

const link = encryptLink('https://sub.your-provider.example/abc123token', {
  name: 'My Provider VPN',
});

console.log(link);
// → incy://crypt1/AAECAwQFBgcICQoLNyIQL3rDwRZqnyoD8pGK…

// Decryption mainly for testing — the INCY apps do this end-side.
const decoded = decryptLink(link);
console.log(decoded.url, decoded.name);

encryptLink(url, opts?) accepts:

Field Type Notes
url string The http(s) subscription URL. Required.
opts.name string? Display name shown in the receiver's import sheet.

Browser / edge usage (Web Crypto)

The main entry is synchronous and uses node:crypto. For frontends — subscription pages, user dashboards, anything bundled for the browser — import the /web entry instead. Same wire format, same function names, built on globalThis.crypto.subtle, so it runs in browsers, web workers, Cloudflare Workers / edge runtimes, Deno, and modern Node. Web Crypto is Promise-based, so every function returns a Promise — that is the only interface difference:

import { encryptLink, decryptLink } from '@incy/link-encoder/web';

const link = await encryptLink('https://sub.your-provider.example/abc123token', {
  name: 'My Provider VPN',
});
const decoded = await decryptLink(link);

Notes:

  • Output is byte-for-byte identical to the Node entry (the test suite cross-checks both against the same pinned vector).
  • crypto.subtle only exists in secure contexts — serve the page over HTTPS or localhost.
  • Using this in a frontend reveals no secrets that aren't already public: the key ships in this package and in every INCY client (see "What this is NOT" below).

Synchronous entry (no await)

The Web entry is Promise-based because crypto.subtle is async. Some hosts need to build a link inside a synchronous codepath where an await is impossible — most notably template-variable substitution (e.g. Remnawave's INCY_CRYPT1_LINK), and any case where the very next step is navigating to the incy:// scheme. On iOS/Safari that navigation must happen inside the user-activation tick of the tap; awaiting first loses the "fresh tap" and the link silently won't open.

The /sync entry solves this — same wire format, same key, same function names, but AES-256-GCM and SHA-256 run on @noble/ciphers + @noble/hashes (audited, MIT), so everything is synchronous and runs in any runtime including the browser:

import { encryptLink } from '@incy/link-encoder/sync';

const link = encryptLink('https://sub.your-provider.example/abc123token', {
  name: 'My Provider VPN',
}); // ← no await; safe to use right before location.href = link

Output is byte-for-byte identical to the Node and Web entries (the test suite cross-checks all three against the same pinned vector). This entry pulls in the two @noble/* packages; prefer /web when you're already in an async context and want zero dependencies.

Other languages

Wire-compatible ports live in this repo, all pinned against the same cross-platform test vector:

Language Package Directory
Python incy-link-encoder (PyPI) python/
PHP incy/link-encoder (Composer) php/
Go github.com/INCY-DEV/incy-link-encoder/go go/

The embedded key material for every port is generated from assets/*.bin by npm run gen-keymat; CI fails if any port's keymat drifts from the canonical bytes.

CLI

The package ships a small CLI — no install needed with npx:

# Encrypt
npx @incy/link-encoder --url https://sub.example.org/token --name "My VPN"
# → incy://crypt1/AAECAwQFBgcICQoLNyIQL3rDwRZqnyoD8pGK…

# Decrypt (auto-detected from the incy:// prefix)
npx @incy/link-encoder --decode incy://crypt1/AAEC…

# Pipe a URL in, get a link out
echo "https://sub.example.org/token" | npx @incy/link-encoder

# JSON output for scripting
npx @incy/link-encoder --json --url https://sub.example.org/token

Run npx @incy/link-encoder --help for all flags.

Framework examples

React (browser — the /web entry):

import { useState } from 'react';
import { encryptLink } from '@incy/link-encoder/web';

function EncodeButton({ url, name }) {
  const [link, setLink] = useState('');
  return (
    <button onClick={async () => setLink(await encryptLink(url, { name }))}>
      {link || 'Encode subscription link'}
    </button>
  );
}

Express / NestJS (server — the Node entry, synchronous):

import { encryptLink } from '@incy/link-encoder';

// Express route
app.post('/encode', (req, res) => {
  res.json({ link: encryptLink(req.body.url, { name: req.body.name }) });
});

// NestJS service
@Injectable()
export class SubscriptionService {
  toDeepLink(url: string, name?: string): string {
    return encryptLink(url, name ? { name } : {});
  }
}

What this is

A small, dependency-free encoder for embedding subscription URLs in chat messages and websites without exposing the raw URL to scanners, moderation bots, or screenshots.

What this is NOT

This is not encryption-for-secrecy. The AES-256-GCM key is derived from constants and binary assets shipped inside this package — anyone reading the source can reconstruct it.

The exact same key already lives inside every INCY client (iOS, Android, Desktop). Anyone with a copy of those apps could already extract it using standard mobile reverse-engineering tools. Publishing this package reveals nothing new — it just makes the limitation explicit.

Threat model

Defended
Telegram chat moderation bots
Russian regulator (RKN) automated scanners
Casual screenshots and clipboard mishaps
grep over chat dumps
Determined reverse engineer with Frida

If the key is ever published publicly (e.g. extracted and shared on Twitter), a future INCY release will introduce crypt2/ with a fresh key. Existing crypt1/ links in chat histories will keep working forever — the clients never remove old schemes.

API

// '@incy/link-encoder' — Node, synchronous
encryptLink(url: string, opts?: { name?: string }): string
decryptLink(link: string): { url: string; name?: string }

// '@incy/link-encoder/web' — browsers/workers/edge, Promise-based
encryptLink(url: string, opts?: { name?: string }): Promise<string>
decryptLink(link: string): Promise<{ url: string; name?: string }>

// '@incy/link-encoder/sync' — synchronous, pure-JS (@noble/ciphers)
encryptLink(url: string, opts?: { name?: string }): string
decryptLink(link: string): { url: string; name?: string }

// For deterministic tests only — never reuse an IV with different
// plaintexts in production code.
encryptLinkDeterministic(url: string, opts: { iv: Uint8Array; name?: string }): string

// Runtime info
VERSION: string         // package version
SCHEME_VERSION: string  // current deep-link scheme, e.g. "crypt1"
KEY_FINGERPRINT: string // SHA-256 of K1 — for sanity checks

// Registry of every scheme this build understands (today: crypt1).
// A future key rotation adds crypt2 here without breaking callers.
SCHEMES: Record<string, { host: string; prefix: string; keyFingerprint: string }>

Cross-platform compatibility

A link generated by this package decodes bit-for-bit identically on iOS (CryptoKit), Android (javax.crypto), and Desktop (Compose Multiplatform JVM, also javax.crypto). A test vector pinned in the test suite guards against drift between updates.

License

MIT

About

Encode VPN subscription URLs into incy://crypt1/<payload> deep links — npm package + standalone library for INCY providers.

Topics

Resources

Stars

6 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages