papercliped 2.6.1 → 2.7.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
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## v2.7.0 — Ed25519-signed Connect account claims
|
|
6
|
+
|
|
7
|
+
- **Connect claims can be signed with an Ed25519 key.** Set `CONNECT_CLAIM_ED25519_PRIVATE_KEY` (PKCS#8 PEM; `node scripts/gen-claim-key.mjs <file>` makes a pair) and optionally `CONNECT_CLAIM_KEY_ID`; Connect then verifies with the public key only and can no longer forge claims. The shared `CONNECT_CLAIM_SIGNING_KEY` still works for one more release. Nothing to do unless you run Connect claims; they stay off by default.
|
|
8
|
+
|
|
5
9
|
## v2.6.1 — docs: mention the upcoming managed address
|
|
6
10
|
|
|
7
11
|
- **Docs:** the "At home, with a tunnel" guide now mentions that a managed address from Papercliped Connect (for ePaperclip on Proxmox) is being built and is not available yet. Nothing to do.
|
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
import { createHash, createHmac, randomBytes } from "node:crypto";
|
|
1
|
+
import { createHash, createHmac, createPrivateKey, randomBytes, sign as edSign } from "node:crypto";
|
|
2
2
|
/**
|
|
3
3
|
* This service-only assertion proves ownership of one EXISTING Papercliped
|
|
4
4
|
* account for a particular (already verified on Connect) OIDC identity.
|
|
5
5
|
* Disabled unless CONNECT_CLAIM_SIGNING_KEY is configured explicitly.
|
|
6
6
|
* It is NOT a login session, MCP grant, or Proxmox permission.
|
|
7
|
+
*
|
|
8
|
+
* Two signature formats (Connect accepts both during the rollout release):
|
|
9
|
+
* - EdDSA (Ed25519), header { alg: "EdDSA", v: 2, kid }: signed with a private key held only here; Connect verifies with a
|
|
10
|
+
* public key, so Connect can no longer forge claims. Preferred; used whenever an Ed25519 key is configured.
|
|
11
|
+
* - HS256, header { alg: "HS256", v: 1 }: the legacy shared secret.
|
|
7
12
|
*/
|
|
8
13
|
export const CLAIM_TTL_SECONDS = 60;
|
|
9
14
|
const ASSERTION_ISSUER = "https://papercliped.co";
|
|
@@ -23,21 +28,35 @@ export function claimIdentityBinding(issuer, subject) {
|
|
|
23
28
|
throw new Error("Invalid verified identity");
|
|
24
29
|
return createHash("sha256").update(JSON.stringify([issuer, subject]), "utf8").digest("base64url");
|
|
25
30
|
}
|
|
26
|
-
|
|
31
|
+
/** Parses CONNECT_CLAIM_ED25519_PRIVATE_KEY (PKCS#8 PEM; literal "\\n" sequences are accepted for single-line env vars). Throws unless it is Ed25519. */
|
|
32
|
+
export function parseClaimEdKey(pem, kid) {
|
|
33
|
+
if (!/^[A-Za-z0-9._-]{1,64}$/.test(kid))
|
|
34
|
+
throw new Error("Invalid claim key id");
|
|
35
|
+
const key = createPrivateKey(pem.replace(/\\n/g, "\n"));
|
|
36
|
+
if (key.asymmetricKeyType !== "ed25519")
|
|
37
|
+
throw new Error("Claim signing key must be Ed25519");
|
|
38
|
+
return { key, kid };
|
|
39
|
+
}
|
|
40
|
+
/** The configured Ed25519 key, or null when unset. A set-but-invalid key throws at startup instead of silently falling back to HS256. */
|
|
41
|
+
export function claimEdKeyFromEnv(env = process.env) {
|
|
42
|
+
const pem = env.CONNECT_CLAIM_ED25519_PRIVATE_KEY?.trim();
|
|
43
|
+
return pem ? parseClaimEdKey(pem, env.CONNECT_CLAIM_KEY_ID?.trim() || "pcd-claim-1") : null;
|
|
44
|
+
}
|
|
45
|
+
export function signConnectClaim({ accountId, issuer, subject, signingKey, edKey, nowMs = Date.now(), }) {
|
|
27
46
|
if (!ACCOUNT_ID.test(accountId) || !validClaimIdentity(issuer, subject))
|
|
28
47
|
throw new Error("Invalid claim subject");
|
|
29
|
-
if (typeof signingKey !== "string" || Buffer.byteLength(signingKey, "utf8") < 32)
|
|
48
|
+
if (!edKey && (typeof signingKey !== "string" || Buffer.byteLength(signingKey, "utf8") < 32))
|
|
30
49
|
throw new Error("Signing key not configured");
|
|
31
50
|
if (!Number.isSafeInteger(nowMs) || nowMs < 0)
|
|
32
51
|
throw new Error("Invalid time");
|
|
33
52
|
const iat = Math.floor(nowMs / 1000);
|
|
34
|
-
const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "PCD-ACCOUNT-ASSERTION", v: 1 })).toString("base64url");
|
|
53
|
+
const header = Buffer.from(JSON.stringify(edKey ? { alg: "EdDSA", typ: "PCD-ACCOUNT-ASSERTION", v: 2, kid: edKey.kid } : { alg: "HS256", typ: "PCD-ACCOUNT-ASSERTION", v: 1 })).toString("base64url");
|
|
35
54
|
const payload = Buffer.from(JSON.stringify({
|
|
36
55
|
iss: ASSERTION_ISSUER, aud: CLAIM_AUDIENCE, sub: accountId,
|
|
37
56
|
jti: randomBytes(24).toString("base64url"), iat, exp: iat + CLAIM_TTL_SECONDS,
|
|
38
57
|
binding: claimIdentityBinding(issuer, subject),
|
|
39
58
|
})).toString("base64url");
|
|
40
59
|
const signed = header + "." + payload;
|
|
41
|
-
const
|
|
42
|
-
return { assertion: signed + "." +
|
|
60
|
+
const sig = edKey ? edSign(null, Buffer.from(signed), edKey.key).toString("base64url") : createHmac("sha256", signingKey).update(signed).digest("base64url");
|
|
61
|
+
return { assertion: signed + "." + sig, expiresIn: CLAIM_TTL_SECONDS };
|
|
43
62
|
}
|
package/dist/http.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { supabaseIdentityVerifier } from "./accounts/supabase-session.js";
|
|
3
|
+
import { claimEdKeyFromEnv } from "./accounts/connect-claim.js";
|
|
3
4
|
import { readConfig, readHttpConfig } from "./config.js";
|
|
4
5
|
import { migrate } from "./migrate.js";
|
|
5
6
|
import { OAuthProvider } from "./oauth/provider.js";
|
|
@@ -76,7 +77,7 @@ async function main() {
|
|
|
76
77
|
const web = webDir ? new WebAppRoutes({ dir: webDir, hosts, forumUrl: process.env.FORUM_URL?.trim() || null }) : undefined;
|
|
77
78
|
if (web)
|
|
78
79
|
console.error(`website: ${webDir}${process.env.PUBLIC_HOSTS ? ` (hosts: ${process.env.PUBLIC_HOSTS})` : ""}`);
|
|
79
|
-
const manage = provider && http.oauth?.mode === "multi" ? new ManageRoutes(provider, { connectClaimSigningKey: process.env.CONNECT_CLAIM_SIGNING_KEY?.trim() || null, verifyIdentity: process.env.CONNECT_SUPABASE_PUBLISHABLE_KEY?.trim() ? supabaseIdentityVerifier(process.env.CONNECT_SUPABASE_PUBLISHABLE_KEY.trim()) : null }) : undefined;
|
|
80
|
+
const manage = provider && http.oauth?.mode === "multi" ? new ManageRoutes(provider, { connectClaimSigningKey: process.env.CONNECT_CLAIM_SIGNING_KEY?.trim() || null, connectClaimEdKey: claimEdKeyFromEnv(), verifyIdentity: process.env.CONNECT_SUPABASE_PUBLISHABLE_KEY?.trim() ? supabaseIdentityVerifier(process.env.CONNECT_SUPABASE_PUBLISHABLE_KEY.trim()) : null }) : undefined;
|
|
80
81
|
const server = createHttpServer(config, http, { oauth: provider, recorder, site, manage, publicApi, web });
|
|
81
82
|
server.listen(http.port, http.host, () => {
|
|
82
83
|
const o = http.oauth;
|
package/dist/manage/routes.js
CHANGED
|
@@ -74,9 +74,10 @@ export class ManageRoutes {
|
|
|
74
74
|
// The returned 60-second token is bound to the browser identity separately
|
|
75
75
|
// verified by Connect, and can be consumed only once there.
|
|
76
76
|
if (route === "/connect-claim") {
|
|
77
|
-
const
|
|
77
|
+
const hmacKey = this.options.connectClaimSigningKey;
|
|
78
|
+
const edKey = this.options.connectClaimEdKey ?? null;
|
|
78
79
|
const verify = this.options.verifyIdentity;
|
|
79
|
-
if (!
|
|
80
|
+
if ((!edKey && (!hmacKey || hmacKey.length < 32)) || !verify)
|
|
80
81
|
return this.json(res, 404, { error: "Not found" });
|
|
81
82
|
if (m !== "POST")
|
|
82
83
|
return this.json(res, 405, { error: "Method not allowed" }, { Allow: "POST" });
|
|
@@ -102,7 +103,7 @@ export class ManageRoutes {
|
|
|
102
103
|
return this.json(res, 409, { error: "This account or sign-in is already linked to another" });
|
|
103
104
|
const claim = signConnectClaim({
|
|
104
105
|
accountId: login.account.id, issuer: who.issuer, subject: who.subject,
|
|
105
|
-
signingKey:
|
|
106
|
+
signingKey: hmacKey, edKey, nowMs: now,
|
|
106
107
|
});
|
|
107
108
|
return this.json(res, 200, claim);
|
|
108
109
|
}
|
|
@@ -6,6 +6,8 @@ The new `POST /api/manage/connect-claim` route is **disabled unless** the server
|
|
|
6
6
|
|
|
7
7
|
The route also needs `CONNECT_SUPABASE_PUBLISHABLE_KEY` (the Paperclipped project's public key; without it the route stays 404). The caller sends `{username, secret, accessToken}`: the bridge asks Supabase Auth who the `accessToken` belongs to (confirmed email, not anonymous, not banned), so issuer and subject are never taken from the request. It then verifies the owner's existing username + secret key via the throttled `manageLogin` function, binds the identity to the account in `bridge.account_identities` (409 if the account or the identity is already bound to another), and returns a 60-second HMAC-SHA256 assertion with `iss=https://papercliped.co`, `aud=papercliped-connect`, canonical account `sub`, unique `jti`, and an SHA256 binding to the claimed OIDC issuer + subject. Connect must independently verify the authenticated OIDC principal, check exact binding and atomically consume the `jti` in `connect.account_assertion_replays`.
|
|
8
8
|
|
|
9
|
+
**Asymmetric signing (preferred).** With `CONNECT_CLAIM_ED25519_PRIVATE_KEY` set (Ed25519, PKCS#8 PEM, `\n` escapes allowed) the assertion is signed `EdDSA` with header `{alg:"EdDSA", typ:"PCD-ACCOUNT-ASSERTION", v:2, kid}` (`CONNECT_CLAIM_KEY_ID`, default `pcd-claim-1`); Connect verifies with the matching public JWK (`CLAIM_PUBLIC_JWKS`) and rotates keys by kid. If both keys are set the Ed25519 key is used. An invalid Ed25519 key stops startup. `CONNECT_CLAIM_SIGNING_KEY` (HS256, `v:1`) remains for one release and is then removed.
|
|
10
|
+
|
|
9
11
|
**Security boundaries:** This is **not** an OIDC provider, browser session, Connect entitlement, MCP grant, or Proxmox access. The issuer does not itself verify third-party OIDC identities. The bridge now verifies the Supabase session itself; Connect must still verify binding and consume the `jti`. Do not enable this route until a proper account-claiming UX, CSRF defenses, secret rotation and privacy handling are reviewed. No code is deployed from this branch.
|
|
10
12
|
|
|
11
13
|
This route does not modify stored accounts or grants. It never stores or returns the user's secret key. Existing plugin sign-in and MCP OAuth behavior are unchanged.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "papercliped",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.0",
|
|
4
4
|
"description": "Papercliped — connect Claude, ChatGPT and any MCP app to your Paperclip: control agents, sync, report. MCP server + hosted multi-tenant OAuth bridge + public stats API.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|