@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.
- package/package.json +2 -1
- 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.
|
|
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
|
+
}
|