@frockbot/connection-core 0.0.0 → 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/package.json +18 -6
- package/src/authorization-state.test.ts +101 -0
- package/src/authorization-state.ts +153 -0
- package/src/credentials.test.ts +115 -0
- package/src/credentials.ts +303 -0
- package/src/index.test.ts +174 -0
- package/src/index.ts +345 -0
- package/src/models.ts +497 -0
- package/tsconfig.json +13 -0
- package/README.md +0 -3
package/package.json
CHANGED
|
@@ -1,14 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/connection-core",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./package.json": "./package.json"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "bun test src",
|
|
12
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
13
|
+
},
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@types/bun": "1.3.6",
|
|
16
|
+
"typescript": "^7.0.2"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
6
21
|
"repository": {
|
|
7
22
|
"type": "git",
|
|
8
23
|
"url": "git+https://github.com/timoconnellaus/frockbot.git",
|
|
9
24
|
"directory": "packages/connection-core"
|
|
10
|
-
},
|
|
11
|
-
"publishConfig": {
|
|
12
|
-
"access": "public"
|
|
13
25
|
}
|
|
14
26
|
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
decodeAuthorizationState,
|
|
4
|
+
encodeAuthorizationState,
|
|
5
|
+
isStrongAuthorizationStateSecretV1,
|
|
6
|
+
type AuthorizationState,
|
|
7
|
+
} from "./authorization-state.js";
|
|
8
|
+
|
|
9
|
+
const SECRET = "an-independent-random-secret-0123456789";
|
|
10
|
+
|
|
11
|
+
function state(
|
|
12
|
+
overrides: Partial<AuthorizationState> = {},
|
|
13
|
+
): AuthorizationState {
|
|
14
|
+
return {
|
|
15
|
+
schemaVersion: 1,
|
|
16
|
+
authorizationStateId: "state-1",
|
|
17
|
+
userId: "user-1",
|
|
18
|
+
connectionId: "mcp-1",
|
|
19
|
+
returnTarget: "browser",
|
|
20
|
+
expiresAt: Date.now() + 600_000,
|
|
21
|
+
...overrides,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe("the signed callback state", () => {
|
|
26
|
+
test("round-trips, and the payload is the identity a callback acts as", async () => {
|
|
27
|
+
const encoded = await encodeAuthorizationState(state(), SECRET);
|
|
28
|
+
expect(await decodeAuthorizationState(encoded, SECRET)).toMatchObject({
|
|
29
|
+
userId: "user-1",
|
|
30
|
+
connectionId: "mcp-1",
|
|
31
|
+
authorizationStateId: "state-1",
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("keeps the wire format it had inside plugin-composio", async () => {
|
|
36
|
+
// Two base64url segments joined by a dot: a state minted before the move
|
|
37
|
+
// to `connection-core` must still verify after it.
|
|
38
|
+
const encoded = await encodeAuthorizationState(state(), SECRET);
|
|
39
|
+
const [payload, signature, extra] = encoded.split(".");
|
|
40
|
+
expect(extra).toBeUndefined();
|
|
41
|
+
expect(payload).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
42
|
+
expect(signature).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
43
|
+
expect(
|
|
44
|
+
JSON.parse(atob(payload!.replaceAll("-", "+").replaceAll("_", "/"))),
|
|
45
|
+
).toMatchObject({ schemaVersion: 1, userId: "user-1" });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("refuses a tampered payload", async () => {
|
|
49
|
+
const encoded = await encodeAuthorizationState(state(), SECRET);
|
|
50
|
+
const [payload, signature] = encoded.split(".");
|
|
51
|
+
const forged = JSON.stringify({ ...state(), userId: "user-2" });
|
|
52
|
+
const swapped = btoa(forged)
|
|
53
|
+
.replaceAll("+", "-")
|
|
54
|
+
.replaceAll("/", "_")
|
|
55
|
+
.replace(/=+$/, "");
|
|
56
|
+
expect(swapped).not.toBe(payload);
|
|
57
|
+
await expect(
|
|
58
|
+
decodeAuthorizationState(`${swapped}.${signature}`, SECRET),
|
|
59
|
+
).rejects.toThrow(/invalid/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("refuses a state signed with another secret", async () => {
|
|
63
|
+
const encoded = await encodeAuthorizationState(state(), SECRET);
|
|
64
|
+
await expect(
|
|
65
|
+
decodeAuthorizationState(encoded, `${SECRET}-other`),
|
|
66
|
+
).rejects.toThrow(/invalid/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("refuses an expired state", async () => {
|
|
70
|
+
const encoded = await encodeAuthorizationState(
|
|
71
|
+
state({ expiresAt: Date.now() - 1 }),
|
|
72
|
+
SECRET,
|
|
73
|
+
);
|
|
74
|
+
await expect(decodeAuthorizationState(encoded, SECRET)).rejects.toThrow(
|
|
75
|
+
/expired/,
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("refuses a malformed token rather than reading half of one", async () => {
|
|
80
|
+
for (const value of ["", "a", "a.b.c", "..", "not-base64url!.x"]) {
|
|
81
|
+
await expect(decodeAuthorizationState(value, SECRET)).rejects.toThrow();
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("the secret strength check", () => {
|
|
87
|
+
test("refuses short, repeated, and placeholder secrets", () => {
|
|
88
|
+
expect(isStrongAuthorizationStateSecretV1("short")).toBe(false);
|
|
89
|
+
expect(isStrongAuthorizationStateSecretV1("ab".repeat(32))).toBe(false);
|
|
90
|
+
expect(isStrongAuthorizationStateSecretV1("a".repeat(64))).toBe(false);
|
|
91
|
+
expect(
|
|
92
|
+
isStrongAuthorizationStateSecretV1(
|
|
93
|
+
"replace-with-an-independent-random-secret",
|
|
94
|
+
),
|
|
95
|
+
).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("accepts an independent random secret", () => {
|
|
99
|
+
expect(isStrongAuthorizationStateSecretV1(SECRET)).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The signed `state` a redirect-based Connection carries through an external
|
|
3
|
+
* authorization server and back.
|
|
4
|
+
*
|
|
5
|
+
* This is the *only* identity a callback has. A `publicRoute` runs before the
|
|
6
|
+
* gateway has authenticated anyone — an authorization server redirects the
|
|
7
|
+
* User's browser to it with whatever query it likes — so the User the callback
|
|
8
|
+
* acts as is read from this token's verified payload and from nowhere else.
|
|
9
|
+
* Query parameters are data; the HMAC is the authority.
|
|
10
|
+
*
|
|
11
|
+
* It began inside `plugin-composio`. Two Packages now mint one, so it lives in
|
|
12
|
+
* `connection-core` where both can reach it. The wire format is byte-for-byte
|
|
13
|
+
* what Composio emitted, so a state minted before this move still verifies
|
|
14
|
+
* after it: `base64url(JSON payload).base64url(HMAC-SHA-256)`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** A public identifier, as `@frockbot/configuration-core` defines one. */
|
|
18
|
+
const PUBLIC_IDENTIFIER_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
|
|
19
|
+
|
|
20
|
+
function isIdentifier(value: unknown): value is string {
|
|
21
|
+
return typeof value === "string" && PUBLIC_IDENTIFIER_PATTERN.test(value);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AuthorizationState {
|
|
25
|
+
schemaVersion: 1;
|
|
26
|
+
authorizationStateId: string;
|
|
27
|
+
userId: string;
|
|
28
|
+
connectionId: string;
|
|
29
|
+
returnTarget: "browser" | "desktop";
|
|
30
|
+
expiresAt: number;
|
|
31
|
+
nativeReturnNonce?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const MINIMUM_AUTHORIZATION_STATE_SECRET_LENGTH = 32;
|
|
35
|
+
const MINIMUM_AUTHORIZATION_STATE_SECRET_UNIQUE_CHARACTERS = 8;
|
|
36
|
+
const FORBIDDEN_AUTHORIZATION_STATE_SECRETS = new Set([
|
|
37
|
+
"replace-with-an-independent-random-secret",
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
function isRepeatedAuthorizationStateSecret(secret: string): boolean {
|
|
41
|
+
for (
|
|
42
|
+
let patternLength = 1;
|
|
43
|
+
patternLength <= Math.min(16, Math.floor(secret.length / 2));
|
|
44
|
+
patternLength += 1
|
|
45
|
+
) {
|
|
46
|
+
if (
|
|
47
|
+
secret.length % patternLength === 0 &&
|
|
48
|
+
secret ===
|
|
49
|
+
secret.slice(0, patternLength).repeat(secret.length / patternLength)
|
|
50
|
+
) {
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Whether a configured secret is strong enough to sign a callback identity
|
|
59
|
+
* with. A short, repeated, or placeholder secret is refused at configuration
|
|
60
|
+
* time rather than trusted at callback time.
|
|
61
|
+
*/
|
|
62
|
+
export function isStrongAuthorizationStateSecretV1(secret: string): boolean {
|
|
63
|
+
return (
|
|
64
|
+
secret.length >= MINIMUM_AUTHORIZATION_STATE_SECRET_LENGTH &&
|
|
65
|
+
new Set(secret).size >=
|
|
66
|
+
MINIMUM_AUTHORIZATION_STATE_SECRET_UNIQUE_CHARACTERS &&
|
|
67
|
+
!FORBIDDEN_AUTHORIZATION_STATE_SECRETS.has(secret) &&
|
|
68
|
+
!isRepeatedAuthorizationStateSecret(secret)
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function base64UrlEncodeV1(bytes: Uint8Array): string {
|
|
73
|
+
let binary = "";
|
|
74
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
75
|
+
return btoa(binary)
|
|
76
|
+
.replaceAll("+", "-")
|
|
77
|
+
.replaceAll("/", "_")
|
|
78
|
+
.replace(/=+$/, "");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function base64UrlDecodeV1(value: string): Uint8Array {
|
|
82
|
+
const padded = value.replaceAll("-", "+").replaceAll("_", "/");
|
|
83
|
+
const binary = atob(padded.padEnd(Math.ceil(padded.length / 4) * 4, "="));
|
|
84
|
+
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function stateKey(secret: string): Promise<CryptoKey> {
|
|
88
|
+
return crypto.subtle.importKey(
|
|
89
|
+
"raw",
|
|
90
|
+
new TextEncoder().encode(secret),
|
|
91
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
92
|
+
false,
|
|
93
|
+
["sign", "verify"],
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function encodeAuthorizationState(
|
|
98
|
+
state: AuthorizationState,
|
|
99
|
+
secret: string,
|
|
100
|
+
): Promise<string> {
|
|
101
|
+
const payload = new TextEncoder().encode(JSON.stringify(state));
|
|
102
|
+
const signature = new Uint8Array(
|
|
103
|
+
await crypto.subtle.sign("HMAC", await stateKey(secret), payload),
|
|
104
|
+
);
|
|
105
|
+
return `${base64UrlEncodeV1(payload)}.${base64UrlEncodeV1(signature)}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function decodeAuthorizationState(
|
|
109
|
+
value: string,
|
|
110
|
+
secret: string,
|
|
111
|
+
now: number = Date.now(),
|
|
112
|
+
): Promise<AuthorizationState> {
|
|
113
|
+
const [payloadPart, signaturePart, extra] = value.split(".");
|
|
114
|
+
if (!payloadPart || !signaturePart || extra !== undefined) {
|
|
115
|
+
throw new Error("Connection authorization state is invalid");
|
|
116
|
+
}
|
|
117
|
+
const payload = base64UrlDecodeV1(payloadPart);
|
|
118
|
+
const signature = base64UrlDecodeV1(signaturePart);
|
|
119
|
+
if (
|
|
120
|
+
!(await crypto.subtle.verify(
|
|
121
|
+
"HMAC",
|
|
122
|
+
await stateKey(secret),
|
|
123
|
+
new Uint8Array(signature).buffer,
|
|
124
|
+
new Uint8Array(payload).buffer,
|
|
125
|
+
))
|
|
126
|
+
) {
|
|
127
|
+
throw new Error("Connection authorization state is invalid");
|
|
128
|
+
}
|
|
129
|
+
let decoded: unknown;
|
|
130
|
+
try {
|
|
131
|
+
decoded = JSON.parse(new TextDecoder().decode(payload));
|
|
132
|
+
} catch {
|
|
133
|
+
throw new Error("Connection authorization state is invalid");
|
|
134
|
+
}
|
|
135
|
+
if (!decoded || typeof decoded !== "object") {
|
|
136
|
+
throw new Error("Connection authorization state is invalid");
|
|
137
|
+
}
|
|
138
|
+
const state = decoded as Partial<AuthorizationState>;
|
|
139
|
+
if (
|
|
140
|
+
state.schemaVersion !== 1 ||
|
|
141
|
+
!isIdentifier(state.authorizationStateId) ||
|
|
142
|
+
!isIdentifier(state.userId) ||
|
|
143
|
+
!isIdentifier(state.connectionId) ||
|
|
144
|
+
(state.returnTarget !== "browser" && state.returnTarget !== "desktop") ||
|
|
145
|
+
!Number.isSafeInteger(state.expiresAt) ||
|
|
146
|
+
(state.expiresAt as number) <= now ||
|
|
147
|
+
(state.nativeReturnNonce !== undefined &&
|
|
148
|
+
!isIdentifier(state.nativeReturnNonce))
|
|
149
|
+
) {
|
|
150
|
+
throw new Error("Connection authorization state is invalid or expired");
|
|
151
|
+
}
|
|
152
|
+
return state as AuthorizationState;
|
|
153
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
decodeCredentialLeaseV1,
|
|
4
|
+
openCredentialV1,
|
|
5
|
+
parseCredentialKeyringV1,
|
|
6
|
+
sealCredentialV1,
|
|
7
|
+
} from "./credentials.js";
|
|
8
|
+
|
|
9
|
+
const key = Uint8Array.from({ length: 32 }, (_, index) => index + 1);
|
|
10
|
+
let binary = "";
|
|
11
|
+
for (const byte of key) binary += String.fromCharCode(byte);
|
|
12
|
+
const encoded = btoa(binary)
|
|
13
|
+
.replaceAll("+", "-")
|
|
14
|
+
.replaceAll("/", "_")
|
|
15
|
+
.replace(/=+$/, "");
|
|
16
|
+
|
|
17
|
+
const keyring = parseCredentialKeyringV1(
|
|
18
|
+
JSON.stringify({
|
|
19
|
+
schemaVersion: 1,
|
|
20
|
+
currentKeyId: "2026-08",
|
|
21
|
+
keys: { "2026-08": encoded },
|
|
22
|
+
}),
|
|
23
|
+
);
|
|
24
|
+
const context = {
|
|
25
|
+
accountId: "account-1",
|
|
26
|
+
connectionId: "connection-1",
|
|
27
|
+
packageId: "provider-ollama-cloud",
|
|
28
|
+
credentialGeneration: "generation-1",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
describe("Connection credential envelopes", () => {
|
|
32
|
+
test("requires the current key to be an own keyring entry", () => {
|
|
33
|
+
expect(() =>
|
|
34
|
+
parseCredentialKeyringV1(
|
|
35
|
+
JSON.stringify({
|
|
36
|
+
schemaVersion: 1,
|
|
37
|
+
currentKeyId: "toString",
|
|
38
|
+
keys: { primary: encoded },
|
|
39
|
+
}),
|
|
40
|
+
),
|
|
41
|
+
).toThrow("credential keyring current key is unavailable");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("round-trips a secret without placing plaintext in the envelope", async () => {
|
|
45
|
+
const envelope = await sealCredentialV1({
|
|
46
|
+
keyring,
|
|
47
|
+
context,
|
|
48
|
+
plaintext: "ollama-secret",
|
|
49
|
+
createdAt: "2026-08-30T00:00:00.000Z",
|
|
50
|
+
randomBytes: () => new Uint8Array(12).fill(7),
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(JSON.stringify(envelope)).not.toContain("ollama-secret");
|
|
54
|
+
expect(await openCredentialV1({ keyring, context, envelope })).toBe(
|
|
55
|
+
"ollama-secret",
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("authenticates Connection ownership context", async () => {
|
|
60
|
+
const envelope = await sealCredentialV1({
|
|
61
|
+
keyring,
|
|
62
|
+
context,
|
|
63
|
+
plaintext: "ollama-secret",
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
await expect(
|
|
67
|
+
openCredentialV1({
|
|
68
|
+
keyring,
|
|
69
|
+
envelope,
|
|
70
|
+
context: { ...context, accountId: "another-account" },
|
|
71
|
+
}),
|
|
72
|
+
).rejects.toThrow("credential envelope authentication failed");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("decodes exact versioned credential leases at RPC seams", async () => {
|
|
76
|
+
const envelope = await sealCredentialV1({
|
|
77
|
+
keyring,
|
|
78
|
+
context,
|
|
79
|
+
plaintext: "ollama-secret",
|
|
80
|
+
createdAt: "2026-08-30T00:00:00.000Z",
|
|
81
|
+
});
|
|
82
|
+
const lease = {
|
|
83
|
+
schemaVersion: 1,
|
|
84
|
+
leaseId: "lease-1",
|
|
85
|
+
effectId: "effect-1",
|
|
86
|
+
connectionId: context.connectionId,
|
|
87
|
+
credentialGeneration: context.credentialGeneration,
|
|
88
|
+
expiresAt: "2026-08-30T01:00:00.000Z",
|
|
89
|
+
envelope,
|
|
90
|
+
} as const;
|
|
91
|
+
|
|
92
|
+
expect(decodeCredentialLeaseV1(lease)).toEqual(lease);
|
|
93
|
+
expect(() =>
|
|
94
|
+
decodeCredentialLeaseV1({ ...lease, unexpected: true }),
|
|
95
|
+
).toThrow("Credential lease is invalid");
|
|
96
|
+
expect(() =>
|
|
97
|
+
decodeCredentialLeaseV1({
|
|
98
|
+
...lease,
|
|
99
|
+
credentialGeneration: "generation-2",
|
|
100
|
+
}),
|
|
101
|
+
).toThrow("Credential lease is invalid");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("requires a versioned 32-byte keyring", () => {
|
|
105
|
+
expect(() =>
|
|
106
|
+
parseCredentialKeyringV1(
|
|
107
|
+
JSON.stringify({
|
|
108
|
+
schemaVersion: 1,
|
|
109
|
+
currentKeyId: "weak",
|
|
110
|
+
keys: { weak: "c2hvcnQ" },
|
|
111
|
+
}),
|
|
112
|
+
),
|
|
113
|
+
).toThrow('credential key "weak" must contain 32 bytes');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
export type ConnectionAuthorizationKind =
|
|
2
|
+
"none" | "api-key" | "ambient-native" | "grant";
|
|
3
|
+
|
|
4
|
+
export interface CredentialDescriptorV1 {
|
|
5
|
+
schemaVersion: 1;
|
|
6
|
+
configured: boolean;
|
|
7
|
+
source: ConnectionAuthorizationKind;
|
|
8
|
+
writable: boolean;
|
|
9
|
+
generation?: string;
|
|
10
|
+
updatedAt?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface CredentialEnvelopeV1 {
|
|
14
|
+
schemaVersion: 1;
|
|
15
|
+
algorithm: "AES-GCM";
|
|
16
|
+
keyId: string;
|
|
17
|
+
credentialGeneration: string;
|
|
18
|
+
nonce: string;
|
|
19
|
+
ciphertext: string;
|
|
20
|
+
createdAt: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CredentialLeaseV1 {
|
|
24
|
+
schemaVersion: 1;
|
|
25
|
+
leaseId: string;
|
|
26
|
+
effectId: string;
|
|
27
|
+
connectionId: string;
|
|
28
|
+
credentialGeneration: string;
|
|
29
|
+
expiresAt: string;
|
|
30
|
+
envelope: CredentialEnvelopeV1;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CredentialKeyringV1 {
|
|
34
|
+
schemaVersion: 1;
|
|
35
|
+
currentKeyId: string;
|
|
36
|
+
keys: Record<string, string>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface CredentialContextV1 {
|
|
40
|
+
accountId: string;
|
|
41
|
+
connectionId: string;
|
|
42
|
+
packageId: string;
|
|
43
|
+
credentialGeneration: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function credentialRecord(input: unknown): Record<string, unknown> | undefined {
|
|
47
|
+
return input && typeof input === "object" && !Array.isArray(input)
|
|
48
|
+
? (input as Record<string, unknown>)
|
|
49
|
+
: undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function hasCredentialKeys(
|
|
53
|
+
value: Record<string, unknown>,
|
|
54
|
+
keys: readonly string[],
|
|
55
|
+
): boolean {
|
|
56
|
+
return (
|
|
57
|
+
Object.keys(value).length === keys.length &&
|
|
58
|
+
keys.every((key) => Object.hasOwn(value, key))
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function credentialString(value: unknown): value is string {
|
|
63
|
+
return typeof value === "string" && value.length > 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function decodeCredentialEnvelopeV1(
|
|
67
|
+
input: unknown,
|
|
68
|
+
): CredentialEnvelopeV1 {
|
|
69
|
+
const value = credentialRecord(input);
|
|
70
|
+
if (
|
|
71
|
+
!value ||
|
|
72
|
+
!hasCredentialKeys(value, [
|
|
73
|
+
"schemaVersion",
|
|
74
|
+
"algorithm",
|
|
75
|
+
"keyId",
|
|
76
|
+
"credentialGeneration",
|
|
77
|
+
"nonce",
|
|
78
|
+
"ciphertext",
|
|
79
|
+
"createdAt",
|
|
80
|
+
]) ||
|
|
81
|
+
value.schemaVersion !== 1 ||
|
|
82
|
+
value.algorithm !== "AES-GCM" ||
|
|
83
|
+
!credentialString(value.keyId) ||
|
|
84
|
+
!credentialString(value.credentialGeneration) ||
|
|
85
|
+
!credentialString(value.nonce) ||
|
|
86
|
+
!credentialString(value.ciphertext) ||
|
|
87
|
+
!credentialString(value.createdAt) ||
|
|
88
|
+
!Number.isFinite(Date.parse(value.createdAt))
|
|
89
|
+
) {
|
|
90
|
+
throw new Error("Credential envelope is invalid");
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
schemaVersion: 1,
|
|
94
|
+
algorithm: "AES-GCM",
|
|
95
|
+
keyId: value.keyId,
|
|
96
|
+
credentialGeneration: value.credentialGeneration,
|
|
97
|
+
nonce: value.nonce,
|
|
98
|
+
ciphertext: value.ciphertext,
|
|
99
|
+
createdAt: value.createdAt,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function decodeCredentialLeaseV1(input: unknown): CredentialLeaseV1 {
|
|
104
|
+
const value = credentialRecord(input);
|
|
105
|
+
if (
|
|
106
|
+
!value ||
|
|
107
|
+
!hasCredentialKeys(value, [
|
|
108
|
+
"schemaVersion",
|
|
109
|
+
"leaseId",
|
|
110
|
+
"effectId",
|
|
111
|
+
"connectionId",
|
|
112
|
+
"credentialGeneration",
|
|
113
|
+
"expiresAt",
|
|
114
|
+
"envelope",
|
|
115
|
+
]) ||
|
|
116
|
+
value.schemaVersion !== 1 ||
|
|
117
|
+
!credentialString(value.leaseId) ||
|
|
118
|
+
!credentialString(value.effectId) ||
|
|
119
|
+
!credentialString(value.connectionId) ||
|
|
120
|
+
!credentialString(value.credentialGeneration) ||
|
|
121
|
+
!credentialString(value.expiresAt) ||
|
|
122
|
+
!Number.isFinite(Date.parse(value.expiresAt))
|
|
123
|
+
) {
|
|
124
|
+
throw new Error("Credential lease is invalid");
|
|
125
|
+
}
|
|
126
|
+
const envelope = decodeCredentialEnvelopeV1(value.envelope);
|
|
127
|
+
if (envelope.credentialGeneration !== value.credentialGeneration) {
|
|
128
|
+
throw new Error("Credential lease is invalid");
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
schemaVersion: 1,
|
|
132
|
+
leaseId: value.leaseId,
|
|
133
|
+
effectId: value.effectId,
|
|
134
|
+
connectionId: value.connectionId,
|
|
135
|
+
credentialGeneration: value.credentialGeneration,
|
|
136
|
+
expiresAt: value.expiresAt,
|
|
137
|
+
envelope,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const encoder = new TextEncoder();
|
|
142
|
+
const decoder = new TextDecoder();
|
|
143
|
+
|
|
144
|
+
function base64Url(bytes: Uint8Array): string {
|
|
145
|
+
let binary = "";
|
|
146
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
147
|
+
return btoa(binary)
|
|
148
|
+
.replaceAll("+", "-")
|
|
149
|
+
.replaceAll("/", "_")
|
|
150
|
+
.replace(/=+$/, "");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function fromBase64Url(value: string): Uint8Array {
|
|
154
|
+
if (!/^[A-Za-z0-9_-]+$/.test(value)) {
|
|
155
|
+
throw new Error("credential key material is not base64url");
|
|
156
|
+
}
|
|
157
|
+
const padded = value.replaceAll("-", "+").replaceAll("_", "/");
|
|
158
|
+
const binary = atob(padded.padEnd(Math.ceil(padded.length / 4) * 4, "="));
|
|
159
|
+
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function contextBytes(context: CredentialContextV1): Uint8Array {
|
|
163
|
+
return encoder.encode(
|
|
164
|
+
JSON.stringify([
|
|
165
|
+
context.accountId,
|
|
166
|
+
context.connectionId,
|
|
167
|
+
context.packageId,
|
|
168
|
+
context.credentialGeneration,
|
|
169
|
+
]),
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function decodeKeyring(input: string): CredentialKeyringV1 {
|
|
174
|
+
let parsed: unknown;
|
|
175
|
+
try {
|
|
176
|
+
parsed = JSON.parse(input);
|
|
177
|
+
} catch {
|
|
178
|
+
throw new Error("credential keyring is not valid JSON");
|
|
179
|
+
}
|
|
180
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
181
|
+
throw new Error("credential keyring is invalid");
|
|
182
|
+
}
|
|
183
|
+
const value = parsed as Record<string, unknown>;
|
|
184
|
+
if (
|
|
185
|
+
value.schemaVersion !== 1 ||
|
|
186
|
+
typeof value.currentKeyId !== "string" ||
|
|
187
|
+
!value.currentKeyId ||
|
|
188
|
+
!value.keys ||
|
|
189
|
+
typeof value.keys !== "object" ||
|
|
190
|
+
Array.isArray(value.keys)
|
|
191
|
+
) {
|
|
192
|
+
throw new Error("credential keyring is invalid");
|
|
193
|
+
}
|
|
194
|
+
const entries = Object.entries(value.keys as Record<string, unknown>);
|
|
195
|
+
if (entries.length === 0) throw new Error("credential keyring is empty");
|
|
196
|
+
const keys: Record<string, string> = Object.create(null) as Record<
|
|
197
|
+
string,
|
|
198
|
+
string
|
|
199
|
+
>;
|
|
200
|
+
for (const [keyId, encoded] of entries) {
|
|
201
|
+
if (!keyId || typeof encoded !== "string") {
|
|
202
|
+
throw new Error("credential keyring is invalid");
|
|
203
|
+
}
|
|
204
|
+
const bytes = fromBase64Url(encoded);
|
|
205
|
+
if (bytes.byteLength !== 32) {
|
|
206
|
+
throw new Error(`credential key "${keyId}" must contain 32 bytes`);
|
|
207
|
+
}
|
|
208
|
+
keys[keyId] = encoded;
|
|
209
|
+
}
|
|
210
|
+
if (!Object.hasOwn(keys, value.currentKeyId)) {
|
|
211
|
+
throw new Error("credential keyring current key is unavailable");
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
schemaVersion: 1,
|
|
215
|
+
currentKeyId: value.currentKeyId,
|
|
216
|
+
keys,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function encryptionKey(encoded: string): Promise<CryptoKey> {
|
|
221
|
+
const bytes = fromBase64Url(encoded);
|
|
222
|
+
return crypto.subtle.importKey(
|
|
223
|
+
"raw",
|
|
224
|
+
bytes as Uint8Array<ArrayBuffer>,
|
|
225
|
+
{ name: "AES-GCM" },
|
|
226
|
+
false,
|
|
227
|
+
["encrypt", "decrypt"],
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export function parseCredentialKeyringV1(input: string): CredentialKeyringV1 {
|
|
232
|
+
return decodeKeyring(input);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export async function sealCredentialV1(input: {
|
|
236
|
+
keyring: CredentialKeyringV1;
|
|
237
|
+
context: CredentialContextV1;
|
|
238
|
+
plaintext: string;
|
|
239
|
+
createdAt?: string;
|
|
240
|
+
randomBytes?: (length: number) => Uint8Array;
|
|
241
|
+
}): Promise<CredentialEnvelopeV1> {
|
|
242
|
+
if (!input.plaintext) throw new Error("credential must not be empty");
|
|
243
|
+
const encodedKey = input.keyring.keys[input.keyring.currentKeyId];
|
|
244
|
+
if (!encodedKey) throw new Error("credential encryption key is unavailable");
|
|
245
|
+
const nonce = input.randomBytes
|
|
246
|
+
? input.randomBytes(12)
|
|
247
|
+
: crypto.getRandomValues(new Uint8Array(12));
|
|
248
|
+
if (nonce.byteLength !== 12) {
|
|
249
|
+
throw new Error("credential nonce must contain 12 bytes");
|
|
250
|
+
}
|
|
251
|
+
const ciphertext = await crypto.subtle.encrypt(
|
|
252
|
+
{
|
|
253
|
+
name: "AES-GCM",
|
|
254
|
+
iv: nonce as Uint8Array<ArrayBuffer>,
|
|
255
|
+
additionalData: contextBytes(input.context) as Uint8Array<ArrayBuffer>,
|
|
256
|
+
},
|
|
257
|
+
await encryptionKey(encodedKey),
|
|
258
|
+
encoder.encode(input.plaintext),
|
|
259
|
+
);
|
|
260
|
+
return {
|
|
261
|
+
schemaVersion: 1,
|
|
262
|
+
algorithm: "AES-GCM",
|
|
263
|
+
keyId: input.keyring.currentKeyId,
|
|
264
|
+
credentialGeneration: input.context.credentialGeneration,
|
|
265
|
+
nonce: base64Url(nonce),
|
|
266
|
+
ciphertext: base64Url(new Uint8Array(ciphertext)),
|
|
267
|
+
createdAt: input.createdAt ?? new Date().toISOString(),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export async function openCredentialV1(input: {
|
|
272
|
+
keyring: CredentialKeyringV1;
|
|
273
|
+
context: CredentialContextV1;
|
|
274
|
+
envelope: CredentialEnvelopeV1;
|
|
275
|
+
}): Promise<string> {
|
|
276
|
+
if (
|
|
277
|
+
input.envelope.schemaVersion !== 1 ||
|
|
278
|
+
input.envelope.algorithm !== "AES-GCM" ||
|
|
279
|
+
input.envelope.credentialGeneration !== input.context.credentialGeneration
|
|
280
|
+
) {
|
|
281
|
+
throw new Error("credential envelope is invalid");
|
|
282
|
+
}
|
|
283
|
+
const encodedKey = input.keyring.keys[input.envelope.keyId];
|
|
284
|
+
if (!encodedKey) {
|
|
285
|
+
throw new Error(
|
|
286
|
+
`credential encryption key "${input.envelope.keyId}" is unavailable`,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
try {
|
|
290
|
+
const plaintext = await crypto.subtle.decrypt(
|
|
291
|
+
{
|
|
292
|
+
name: "AES-GCM",
|
|
293
|
+
iv: fromBase64Url(input.envelope.nonce) as Uint8Array<ArrayBuffer>,
|
|
294
|
+
additionalData: contextBytes(input.context) as Uint8Array<ArrayBuffer>,
|
|
295
|
+
},
|
|
296
|
+
await encryptionKey(encodedKey),
|
|
297
|
+
fromBase64Url(input.envelope.ciphertext) as Uint8Array<ArrayBuffer>,
|
|
298
|
+
);
|
|
299
|
+
return decoder.decode(plaintext);
|
|
300
|
+
} catch {
|
|
301
|
+
throw new Error("credential envelope authentication failed");
|
|
302
|
+
}
|
|
303
|
+
}
|