@appstrate/afps-shared 0.3.0 → 0.3.1

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.
Files changed (2) hide show
  1. package/package.json +2 -1
  2. package/src/signed-token.ts +106 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appstrate/afps-shared",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Zero-dependency AFPS helpers shared by @appstrate/core and @appstrate/afps-runtime (companion-file checks, semver resolution, SRI integrity, credential templates, delivery.http projection)",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -47,6 +47,7 @@
47
47
  "./token-usage": "./src/token-usage.ts",
48
48
  "./ssrf-dns": "./src/ssrf-dns.ts",
49
49
  "./guarded-fetch": "./src/guarded-fetch.ts",
50
+ "./signed-token": "./src/signed-token.ts",
50
51
  "./unzip-bounded": "./src/unzip-bounded.ts",
51
52
  "./backoff": "./src/backoff.ts"
52
53
  },
@@ -0,0 +1,106 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+
3
+ /**
4
+ * Keyring-HMAC capability tokens — the ONE codec behind every short-lived,
5
+ * URL-carried capability the platform mints (filesystem/proxy upload URLs,
6
+ * document previews, hosted connect sessions).
7
+ *
8
+ * Wire format: `base64url(JSON payload).base64url(HMAC-SHA256)`.
9
+ *
10
+ * Two properties are load-bearing and were previously re-implemented (and
11
+ * drifted) per token type:
12
+ *
13
+ * - **Keyring rotation.** A secret is a comma-separated list (or an array):
14
+ * the FIRST key signs new tokens, ALL keys verify, so a rotation never
15
+ * invalidates tokens already in flight. Individual keys must therefore not
16
+ * contain commas.
17
+ * - **Domain separation.** {@link signKeyringToken} takes the domain as its
18
+ * FIRST, REQUIRED argument and mixes it into the signed content, so a token
19
+ * minted for one purpose can never be verified as another — including when
20
+ * two token types share a signing secret (upload URLs and document previews
21
+ * both key off `UPLOAD_SIGNING_SECRET`). Making the parameter mandatory is
22
+ * the point: an optional domain is a domain someone forgets, and the
23
+ * resulting protection is one-directional — exactly the asymmetry this
24
+ * module replaces.
25
+ *
26
+ * Deliberately NOT part of the codec: expiry and claim validation. Every token
27
+ * type names its expiry field differently and enforces its own required
28
+ * claims, so {@link verifyKeyringToken} returns the decoded payload after the
29
+ * signature check and leaves semantics to the caller.
30
+ *
31
+ * Zero-dependency leaf so `@appstrate/core` (storage), the platform API
32
+ * (document previews) and `@appstrate/connect` (hosted connect sessions) can
33
+ * all sit above it without a cycle.
34
+ */
35
+
36
+ import { createHmac, timingSafeEqual } from "node:crypto";
37
+
38
+ /**
39
+ * Normalize a signing secret into a keyring. A plain string is split on commas
40
+ * (rotation: prepend the new key); empty segments are dropped.
41
+ */
42
+ export function toKeyring(secret: string | readonly string[]): string[] {
43
+ const keys = typeof secret === "string" ? secret.split(",") : [...secret];
44
+ return keys.filter((k) => k.length > 0);
45
+ }
46
+
47
+ /**
48
+ * Encode + HMAC-sign a payload with the FIRST key of the keyring, binding the
49
+ * signature to `domain`. Throws when the keyring holds no usable key.
50
+ *
51
+ * `domain` is a short, stable, versioned literal (`"doc-preview.v1."`) — change
52
+ * it and every token already in flight stops verifying.
53
+ */
54
+ export function signKeyringToken(
55
+ domain: string,
56
+ payload: unknown,
57
+ secret: string | readonly string[],
58
+ ): string {
59
+ const [activeKey] = toKeyring(secret);
60
+ if (!activeKey) throw new Error("signKeyringToken requires at least one signing key");
61
+ const body = Buffer.from(JSON.stringify(payload), "utf-8").toString("base64url");
62
+ const sig = createHmac("sha256", activeKey)
63
+ .update(domain + body)
64
+ .digest("base64url");
65
+ return `${body}.${sig}`;
66
+ }
67
+
68
+ /**
69
+ * Verify a token against `domain` and decode its payload. Returns null on any
70
+ * failure (malformed shape, wrong/absent signature, non-JSON body) — never
71
+ * throws. Verifies against EVERY key of the keyring (constant-time comparison
72
+ * per key) so tokens signed before a rotation stay valid.
73
+ *
74
+ * The returned value is the raw decoded JSON cast to `T`: the signature proves
75
+ * WE minted it, not that its fields are the ones the caller expects. Callers
76
+ * validate expiry + required claims themselves.
77
+ */
78
+ export function verifyKeyringToken<T>(
79
+ domain: string,
80
+ token: string,
81
+ secret: string | readonly string[],
82
+ ): T | null {
83
+ const dot = token.indexOf(".");
84
+ if (dot <= 0) return null;
85
+ const body = token.slice(0, dot);
86
+ const sig = token.slice(dot + 1);
87
+ const a = Buffer.from(sig);
88
+ let valid = false;
89
+ for (const key of toKeyring(secret)) {
90
+ const b = Buffer.from(
91
+ createHmac("sha256", key)
92
+ .update(domain + body)
93
+ .digest("base64url"),
94
+ );
95
+ if (a.length === b.length && timingSafeEqual(a, b)) {
96
+ valid = true;
97
+ break;
98
+ }
99
+ }
100
+ if (!valid) return null;
101
+ try {
102
+ return JSON.parse(Buffer.from(body, "base64url").toString("utf-8")) as T;
103
+ } catch {
104
+ return null;
105
+ }
106
+ }