@tegis/player 0.1.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 ADDED
@@ -0,0 +1,17 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@tegis/player` are documented here. This project follows [semver](https://semver.org).
4
+
5
+ ## [0.1.0] — unreleased
6
+
7
+ Initial extraction from the Tegis reference SDK (formerly the private `@aegis/sdk`).
8
+
9
+ - `TegisPlayer` — the browser hot path (attest → handshake → mint → renew) with WebCrypto AES-CTR
10
+ segment decryption over MSE.
11
+ - `loadWasmHandshake` / `loadWhitenedHandshake` — WASM-backed handshake (obfuscation-grade), byte-identical
12
+ to the WebCrypto path so the Go mint accepts it unchanged. Ships the compiled `wasm/hmac-sha256.wasm`.
13
+ - Self-contained: no repo-relative imports.
14
+
15
+ > Note: demo-only `x-aegis-*` request headers are retained as the current frozen wire contract; a real
16
+ > deployment routes by Host and never sets them. Renaming the wire headers is a coordinated server+client
17
+ > change, tracked separately from this packaging work.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Burak Saraloglu (okbrk)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # @tegis/player
2
+
3
+ Browser player SDK for **Tegis** — the content-protection gateway for video. It runs the protected hot
4
+ path in the browser: **attest → handshake → mint → renew**, then decrypts and plays segments with
5
+ WebCrypto (AES-CTR) over Media Source Extensions. The player **never holds a tenant key** — only a
6
+ short-lived attestation and playback grant.
7
+
8
+ The crypto/fetch/decrypt core runs identically in the browser and in Bun (for headless e2e); the MSE
9
+ glue is browser-only and guarded.
10
+
11
+ ## Install
12
+
13
+ ```sh
14
+ bun add @tegis/player
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ ```ts
20
+ import { TegisPlayer, loadWasmHandshake } from "@tegis/player";
21
+
22
+ // 1. (Recommended) load the WASM handshake so the per-session signature is computed inside an opaque
23
+ // module rather than a one-line JS HMAC. `handshakeSecret` is delivered by Tegis (WASM-whitened in prod).
24
+ const wasm = await fetch(new URL("@tegis/player/wasm/hmac-sha256.wasm", import.meta.url)).then(r => r.arrayBuffer());
25
+ const handshakeFn = await loadWasmHandshake(handshakeSecret, wasm);
26
+
27
+ const player = new TegisPlayer({
28
+ mint: "https://your-tenant.tegis.io", // your tenant CNAME (mint endpoint)
29
+ edge: "https://your-tenant.tegis.io", // edge / CDN base
30
+ tid: "t_yourtenant",
31
+ handshakeSecret, // Uint8Array, delivered by Tegis
32
+ handshakeFn, // optional WASM override (falls back to WebCrypto)
33
+ });
34
+
35
+ // `entitlement` comes from your backend via @tegis/server.
36
+ const video = document.querySelector("video")!;
37
+ await player.play(video, { assetId, entitlement });
38
+ ```
39
+
40
+ For best join-time, call `player.prewarm()` at page load (solves the bot-wall off the click→play path).
41
+
42
+ ## API
43
+
44
+ - `new TegisPlayer(config: BrowserPlayerConfig)`
45
+ - `.prewarm(opts?)` — pre-solve attestation and hold it
46
+ - `.play(video, { assetId, entitlement, ... })` — full hot path + MSE playback
47
+ - `.mint(...)`, `.renew(...)`, `.decryptedSegment(...)` — lower-level steps
48
+ - `loadWasmHandshake(secret, wasmBytes)` / `loadWhitenedHandshake(wasmBytes)` — build a `HandshakeFn`
49
+
50
+ A `@tegis/player/bundle` entry exposes `TegisPlayer` on `globalThis` for `<script>`-tag use.
51
+
52
+ ## Browser support
53
+
54
+ Playback requires **real Chrome** (H.264 + MSE) — see the Tegis docs for the supported-browser matrix.
55
+ Surface a clear unsupported-browser message to viewers on other engines.
56
+
57
+ MIT © okbrk
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,188 @@
1
+ // src/crypto.ts
2
+ var te = new TextEncoder;
3
+ function b64u(buf) {
4
+ const b = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
5
+ let s = "";
6
+ for (const x of b)
7
+ s += String.fromCharCode(x);
8
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
9
+ }
10
+ function unb64u(s) {
11
+ s = s.replace(/-/g, "+").replace(/_/g, "/");
12
+ while (s.length % 4)
13
+ s += "=";
14
+ const bin = atob(s);
15
+ const out = new Uint8Array(bin.length);
16
+ for (let i = 0;i < bin.length; i++)
17
+ out[i] = bin.charCodeAt(i);
18
+ return out;
19
+ }
20
+ async function hmacSha256(secret, msg) {
21
+ const key = await crypto.subtle.importKey("raw", secret, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
22
+ return new Uint8Array(await crypto.subtle.sign("HMAC", key, te.encode(msg)));
23
+ }
24
+ async function sha256b64u(s) {
25
+ return b64u(await crypto.subtle.digest("SHA-256", te.encode(s)));
26
+ }
27
+ async function handshake(secret, att, ent, nonce, t) {
28
+ const d = await sha256b64u(ent);
29
+ return b64u(await hmacSha256(secret, `${att}.${d}.${nonce}.${t}`));
30
+ }
31
+ async function hbSign(hbKeyB64u, hbJSON) {
32
+ return b64u(await hmacSha256(unb64u(hbKeyB64u), hbJSON));
33
+ }
34
+ async function decryptSegment(keyRaw, blob) {
35
+ const iv = blob.slice(0, 16);
36
+ const ct = blob.slice(16);
37
+ const key = await crypto.subtle.importKey("raw", keyRaw, { name: "AES-CTR" }, false, ["decrypt"]);
38
+ const pt = await crypto.subtle.decrypt({ name: "AES-CTR", counter: iv, length: 128 }, key, ct);
39
+ return new Uint8Array(pt);
40
+ }
41
+
42
+ // src/player.ts
43
+ function randHex(n) {
44
+ const b = new Uint8Array(n);
45
+ crypto.getRandomValues(b);
46
+ return [...b].map((x) => x.toString(16).padStart(2, "0")).join("");
47
+ }
48
+
49
+ class TegisPlayer {
50
+ cfg;
51
+ att;
52
+ attSes;
53
+ constructor(cfg) {
54
+ this.cfg = cfg;
55
+ }
56
+ get f() {
57
+ return this.cfg.fetchImpl ?? globalThis.fetch.bind(globalThis);
58
+ }
59
+ hdr(extra = {}) {
60
+ const h = { "content-type": "application/json", ...extra };
61
+ if (this.cfg.demoHeaders) {
62
+ h["x-aegis-tenant"] = this.cfg.tid;
63
+ if (this.cfg.clientIp)
64
+ h["x-aegis-client-ip"] = this.cfg.clientIp;
65
+ }
66
+ return h;
67
+ }
68
+ async post(path, body) {
69
+ const r = await this.f(this.cfg.mint + path, { method: "POST", headers: this.hdr(), body: JSON.stringify(body) });
70
+ return { status: r.status, json: await r.json().catch(() => ({})) };
71
+ }
72
+ handshake(att, ent, nonce, t) {
73
+ return (this.cfg.handshakeFn ?? ((a, e, n, tt) => handshake(this.cfg.handshakeSecret, a, e, n, tt)))(att, ent, nonce, t);
74
+ }
75
+ async prewarm(opts = {}) {
76
+ const ses = opts.ses ?? "ses_" + randHex(4);
77
+ const body = { ses, fph: opts.fph ?? "fp_" + ses };
78
+ if (opts.solution) {
79
+ body.nonce = opts.nonce;
80
+ body.solution = opts.solution;
81
+ }
82
+ if (opts.token)
83
+ body.token = opts.token;
84
+ const r = await this.post("/attest/v1/verify", body);
85
+ if (!r.json.att)
86
+ throw new Error("attestation failed: " + JSON.stringify(r.json));
87
+ this.att = r.json.att;
88
+ this.attSes = ses;
89
+ return this.att;
90
+ }
91
+ async mint(opts) {
92
+ const ses = this.attSes ?? opts.ses ?? "ses_" + randHex(4);
93
+ if (!this.att)
94
+ await this.prewarm({ ses, fph: opts.fph, token: opts.token });
95
+ const nonce = (await this.post("/mint/v1/nonce", { ses })).json.nonce;
96
+ const t = Math.floor(Date.now() / 1000);
97
+ const hs = await this.handshake(this.att, opts.entitlement, nonce, t);
98
+ const r = await this.post("/mint/v1", { assetId: opts.assetId, att: this.att, entitlement: opts.entitlement, nonce, handshake: hs, t });
99
+ if (r.status !== 200)
100
+ throw new Error("mint failed: " + r.status + " " + JSON.stringify(r.json));
101
+ return r.json;
102
+ }
103
+ async contentKey(assetId) {
104
+ const r = await this.f(`${this.cfg.mint}/key/v1/${assetId}?att=${this.att}`, { headers: this.hdr() });
105
+ if (r.status !== 200)
106
+ throw new Error("key fetch failed: " + r.status);
107
+ return unb64u((await r.json()).key);
108
+ }
109
+ async fetchBytes(url) {
110
+ const r = await this.f(url.startsWith("http") ? url : this.cfg.edge + url, { headers: this.hdr() });
111
+ if (r.status !== 200)
112
+ throw new Error("fetch failed " + r.status + ": " + url);
113
+ return new Uint8Array(await r.arrayBuffer());
114
+ }
115
+ async decryptedSegment(assetId, url, key) {
116
+ const k = key ?? await this.contentKey(assetId);
117
+ return decryptSegment(k, await this.fetchBytes(url));
118
+ }
119
+ async renew(playbackId, hbKeyB64u, progress) {
120
+ const hb = { pbk: playbackId, pos: progress.pos, seq: progress.seq, state: "playing", iat: Math.floor(Date.now() / 1000) };
121
+ const sig = await hbSign(hbKeyB64u, JSON.stringify(hb));
122
+ const r = await this.post("/mint/v1/renew", { playbackId, heartbeat: hb, sig });
123
+ if (r.status !== 200)
124
+ throw new Error("renew failed: " + r.status);
125
+ return r.json;
126
+ }
127
+ async play(video, opts) {
128
+ if (typeof MediaSource === "undefined")
129
+ throw new Error("MSE unavailable in this environment");
130
+ const g = await this.mint(opts);
131
+ const key = await this.contentKey(opts.assetId);
132
+ const ms = new MediaSource;
133
+ video.src = URL.createObjectURL(ms);
134
+ await new Promise((res) => ms.addEventListener("sourceopen", () => res(), { once: true }));
135
+ const mime = opts.mime ?? 'video/mp4; codecs="avc1.4d401e, mp4a.40.2"';
136
+ const sb = ms.addSourceBuffer(mime);
137
+ const append = (buf) => new Promise((res, rej) => {
138
+ sb.addEventListener("updateend", () => res(), { once: true });
139
+ sb.addEventListener("error", (e) => rej(e), { once: true });
140
+ sb.appendBuffer(buf);
141
+ });
142
+ await append(await this.fetchBytes(g.init));
143
+ for (const url of g.manifest) {
144
+ let seg;
145
+ try {
146
+ seg = await this.decryptedSegment(opts.assetId, url, key);
147
+ } catch {
148
+ break;
149
+ }
150
+ await append(seg);
151
+ }
152
+ ms.endOfStream();
153
+ await video.play().catch(() => {});
154
+ return g;
155
+ }
156
+ }
157
+
158
+ // src/handshake-wasm.ts
159
+ async function loadWhitenedHandshake(wasmBytes) {
160
+ const { instance } = await WebAssembly.instantiate(wasmBytes, {
161
+ env: { abort: () => {
162
+ throw new Error("wasm abort");
163
+ } }
164
+ });
165
+ const ex = instance.exports;
166
+ const te2 = new TextEncoder;
167
+ const mem = () => new Uint8Array(ex.memory.buffer);
168
+ function write(data) {
169
+ const p = ex.alloc(data.length);
170
+ mem().set(data, p);
171
+ return p;
172
+ }
173
+ function call(fn, data) {
174
+ const p = write(data);
175
+ const out = ex.alloc(32);
176
+ fn(p, data.length, out);
177
+ return mem().slice(out, out + 32);
178
+ }
179
+ return async (att, ent, nonce, t) => {
180
+ const entDigest = b64u(call(ex.sha256, te2.encode(ent)));
181
+ const msg = te2.encode(`${att}.${entDigest}.${nonce}.${t}`);
182
+ return b64u(call(ex.signKeyed, msg));
183
+ };
184
+ }
185
+
186
+ // src/bundle-entry.ts
187
+ globalThis.TegisPlayer = TegisPlayer;
188
+ globalThis.TegisLoadWhitenedHandshake = loadWhitenedHandshake;
@@ -0,0 +1,17 @@
1
+ export declare function b64u(buf: ArrayBuffer | Uint8Array): string;
2
+ export declare function unb64u(s: string): Uint8Array;
3
+ export declare function sha256b64u(s: string): Promise<string>;
4
+ /**
5
+ * The per-session handshake — HMAC(secret, `att.sha256(ent)b64u.nonce.t`) — identical to the Go mint's
6
+ * verifyHandshake. In production this runs inside the WASM module (F1 §8, obfuscation-grade); this
7
+ * WebCrypto path is the reference/fallback and proves byte-parity with the mint.
8
+ */
9
+ export declare function handshake(secret: Uint8Array, att: string, ent: string, nonce: string, t: number): Promise<string>;
10
+ /** Heartbeat signature for the renewal loop — HMAC(hbKey, canonical-heartbeat-json). */
11
+ export declare function hbSign(hbKeyB64u: string, hbJSON: string): Promise<string>;
12
+ /**
13
+ * Decrypt a reference segment: blob = IV(16) ‖ AES-128-CTR(key, plaintext). Returns the plaintext fMP4.
14
+ * (F7 will add an EME clear-key path for ISO-CENC; this manual path is the launch decrypt for the
15
+ * whole-segment-CTR packaging the reference produces today.)
16
+ */
17
+ export declare function decryptSegment(keyRaw: Uint8Array, blob: Uint8Array): Promise<Uint8Array>;
@@ -0,0 +1,13 @@
1
+ export type HandshakeFn = (att: string, ent: string, nonce: string, t: number) => Promise<string>;
2
+ /**
3
+ * Build a WASM-backed handshake function bound to the tenant's handshake secret. `wasmBytes` is the
4
+ * compiled hmac-sha256.wasm (embedded in the bundle, or fetched).
5
+ */
6
+ export declare function loadWasmHandshake(secret: Uint8Array, wasmBytes: BufferSource): Promise<HandshakeFn>;
7
+ /**
8
+ * F9: load a per-tenant WHITENED module (src/whiten.ts output). Unlike loadWasmHandshake, it takes NO
9
+ * secret — the tenant's HMAC key lives inside the module as split ipad/opad midstates and never crosses
10
+ * the JS↔WASM boundary. The module exports `sha256` (pure, for the entitlement digest) and `signKeyed`
11
+ * (HMAC with the baked key). Output is byte-identical to HMAC-SHA256(hs_tenant, msg), so the mint accepts it.
12
+ */
13
+ export declare function loadWhitenedHandshake(wasmBytes: BufferSource): Promise<HandshakeFn>;
@@ -0,0 +1,4 @@
1
+ export { TegisPlayer } from "./player.js";
2
+ export type { BrowserPlayerConfig, Grant } from "./player.js";
3
+ export { loadWasmHandshake, loadWhitenedHandshake } from "./handshake-wasm.js";
4
+ export type { HandshakeFn } from "./handshake-wasm.js";
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export {
2
+ loadWhitenedHandshake,
3
+ loadWasmHandshake,
4
+ TegisPlayer
5
+ };
@@ -0,0 +1,76 @@
1
+ export interface BrowserPlayerConfig {
2
+ mint: string;
3
+ edge: string;
4
+ tid: string;
5
+ handshakeSecret: Uint8Array;
6
+ handshakeFn?: (att: string, ent: string, nonce: string, t: number) => Promise<string>;
7
+ demoHeaders?: boolean;
8
+ clientIp?: string;
9
+ fetchImpl?: typeof fetch;
10
+ }
11
+ export interface Grant {
12
+ grant: string;
13
+ playbackId: string;
14
+ hbKeyB64u: string;
15
+ init: string;
16
+ manifest: string[];
17
+ window: {
18
+ from: number;
19
+ to: number;
20
+ };
21
+ res: string;
22
+ }
23
+ export declare class TegisPlayer {
24
+ private cfg;
25
+ private att?;
26
+ private attSes?;
27
+ constructor(cfg: BrowserPlayerConfig);
28
+ private get f();
29
+ private hdr;
30
+ private post;
31
+ private handshake;
32
+ /** Pre-warm attestation OFF the click→play path (F1 §3): solve the bot-wall at page load, hold the att. */
33
+ prewarm(opts?: {
34
+ ses?: string;
35
+ fph?: string;
36
+ nonce?: string;
37
+ solution?: string;
38
+ token?: string;
39
+ }): Promise<string>;
40
+ /** Mint a playback grant for an asset (pre-warms inline if not already warm). */
41
+ mint(opts: {
42
+ assetId: string;
43
+ entitlement: string;
44
+ ses?: string;
45
+ fph?: string;
46
+ token?: string;
47
+ }): Promise<Grant>;
48
+ /** Fetch the att-gated content key (AES-128, 16 bytes). */
49
+ contentKey(assetId: string): Promise<Uint8Array>;
50
+ fetchBytes(url: string): Promise<Uint8Array>;
51
+ /** The headless-verifiable core: fetch a media segment from the edge/CDN + decrypt it with WebCrypto. */
52
+ decryptedSegment(assetId: string, url: string, key?: Uint8Array): Promise<Uint8Array>;
53
+ /** Steady-state renewal: report realtime progress to receive the next signed window. */
54
+ renew(playbackId: string, hbKeyB64u: string, progress: {
55
+ pos: number;
56
+ seq: number;
57
+ }): Promise<{
58
+ manifest: string[];
59
+ window: {
60
+ from: number;
61
+ to: number;
62
+ };
63
+ }>;
64
+ /**
65
+ * Full browser playback via MSE (browser-only): mint → append the init segment → fetch+decrypt+append
66
+ * each media segment → play. The att-gated key is fetched once. Returns the grant.
67
+ */
68
+ play(video: HTMLVideoElement, opts: {
69
+ assetId: string;
70
+ entitlement: string;
71
+ ses?: string;
72
+ fph?: string;
73
+ mime?: string;
74
+ token?: string;
75
+ }): Promise<Grant>;
76
+ }
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@tegis/player",
3
+ "version": "0.1.0",
4
+ "description": "Tegis browser player SDK — attest → WASM handshake → mint → renew, with WebCrypto AES-CTR segment playback. Never holds a tenant key.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "okbrk <burak@okbrk.com>",
8
+ "homepage": "https://tegis.io",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/okbrk/aegis.git",
12
+ "directory": "reference/sdk-ts/packages/player"
13
+ },
14
+ "keywords": [
15
+ "tegis",
16
+ "content-protection",
17
+ "anti-piracy",
18
+ "player",
19
+ "video",
20
+ "drm",
21
+ "mse",
22
+ "webcrypto",
23
+ "wasm",
24
+ "hls"
25
+ ],
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "browser": "./dist/index.js",
30
+ "import": "./dist/index.js",
31
+ "default": "./dist/index.js"
32
+ },
33
+ "./bundle": {
34
+ "types": "./dist/bundle-entry.d.ts",
35
+ "default": "./dist/bundle-entry.js"
36
+ },
37
+ "./wasm/hmac-sha256.wasm": "./wasm/hmac-sha256.wasm",
38
+ "./package.json": "./package.json"
39
+ },
40
+ "main": "./dist/index.js",
41
+ "module": "./dist/index.js",
42
+ "browser": "./dist/index.js",
43
+ "types": "./dist/index.d.ts",
44
+ "files": [
45
+ "dist",
46
+ "wasm/hmac-sha256.wasm",
47
+ "README.md",
48
+ "CHANGELOG.md",
49
+ "LICENSE"
50
+ ],
51
+ "sideEffects": [
52
+ "./src/bundle-entry.ts"
53
+ ],
54
+ "publishConfig": {
55
+ "access": "public"
56
+ },
57
+ "scripts": {
58
+ "typecheck": "tsc --noEmit",
59
+ "clean": "rm -rf dist",
60
+ "build": "bun run clean && bun build ./src/index.ts ./src/bundle-entry.ts --target=browser --format=esm --outdir dist && tsc -p tsconfig.build.json && bun ../../scripts/fix-dts.ts dist",
61
+ "prepublishOnly": "bun run build"
62
+ }
63
+ }
Binary file